diff --git a/BACKLOG.md b/BACKLOG.md
index 03d0a13..370bb2f 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -113,12 +113,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
action in the Alerts tab that forces a fetch and immediately re-evaluates the
cheapest-favourite alert. Copy should set expectation honestly: standard
mode checks when FuelBoard refreshes prices (up to twice daily).
-- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
- relaxes the current "twice daily max" data policy, expose this as a clearly
- separate mode rather than a freeform frequency slider. Example framing:
- `Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
- with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
- promise exact timing under iOS background scheduling.
+- [ ] **Data checking mode: Standard vs Smart** — keep the current cache-first
+ / 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
+ `Smart` should try lightweight background checks of the mirror pointer
+ (`latest.json` / `data_updated`) more often, and only download the full price
+ dataset when a newer snapshot is available. Design goal: better freshness
+ without pretending to do real-time refreshes or re-downloading the full dump
+ unnecessarily. User-facing copy must be explicit and honest:
+ - `Standard` — Saves battery. Refreshes price data up to twice daily.
+ - `Smart` — Checks for newer data more often in the background and refreshes
+ full prices only when an update is available.
+ Add a footnote/subcopy that background checks are best-effort and happen only
+ when iOS allows, so timing is not exact. Prefer this 2-mode setting over a
+ freeform frequency slider.
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list).
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
diff --git a/Config/App-Info.plist b/Config/App-Info.plist
index 155c3e2..4ced56f 100644
--- a/Config/App-Info.plist
+++ b/Config/App-Info.plist
@@ -41,8 +41,13 @@
FuelBoard uses your location to find the cheapest nearby petrol stations.
NSSupportsLiveActivities
+ BGTaskSchedulerPermittedIdentifiers
+
+ com.apt.fuelboard.smart-refresh
+
UIBackgroundModes
+ fetch
location
UILaunchScreen
diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift
index 07f98ae..d33653b 100644
--- a/FuelBoard/ContentView.swift
+++ b/FuelBoard/ContentView.swift
@@ -16,6 +16,7 @@ struct ContentView: View {
@State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
+ @State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@@ -365,6 +366,7 @@ struct ContentView: View {
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
installLocationUpdateHook()
+ SmartDataRefreshScheduler.scheduleNextIfNeeded()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -380,6 +382,7 @@ struct ContentView: View {
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
}
+ SmartDataRefreshScheduler.scheduleNextIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
@@ -787,6 +790,7 @@ struct ContentView: View {
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
+ dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult,
@@ -849,6 +853,7 @@ struct ContentView: View {
stations = fetched
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
+ FuelStore.saveLastSmartProbe()
// Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings → About section — the
// live chain records whichever leg served the fetch.
diff --git a/FuelBoard/FuelBoardApp.swift b/FuelBoard/FuelBoardApp.swift
index bd508f7..2845171 100644
--- a/FuelBoard/FuelBoardApp.swift
+++ b/FuelBoard/FuelBoardApp.swift
@@ -1,4 +1,5 @@
import ActivityKit
+import BackgroundTasks
import SwiftUI
@main
@@ -22,6 +23,9 @@ struct FuelBoardApp: App {
ContentView()
.onOpenURL(perform: handleOpenURL)
}
+ .backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
+ await SmartDataRefreshCoordinator.runBackgroundProbe()
+ }
}
#if DEBUG
diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift
index 3f0744f..1443ec9 100644
--- a/FuelBoard/SettingsView.swift
+++ b/FuelBoard/SettingsView.swift
@@ -18,6 +18,7 @@ struct SettingsView: View {
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
+ @Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10
@@ -120,6 +121,29 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
+ Section {
+ Picker("Data checking", selection: $dataRefreshMode) {
+ ForEach(DataRefreshMode.allCases) { mode in
+ Text(mode.displayName).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+ .onChange(of: dataRefreshMode) { _, newValue in
+ FuelStore.saveDataRefreshMode(newValue)
+ SmartDataRefreshScheduler.scheduleNextIfNeeded()
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ Text(dataRefreshMode.summary)
+ Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
+ .foregroundStyle(.secondary)
+ }
+ .font(.footnote)
+ } header: {
+ Text("Data checking")
+ } footer: {
+ Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
+ }
+
Section {
Button {
onShowOnboarding()
diff --git a/FuelBoard/SmartDataRefresh.swift b/FuelBoard/SmartDataRefresh.swift
new file mode 100644
index 0000000..6700d72
--- /dev/null
+++ b/FuelBoard/SmartDataRefresh.swift
@@ -0,0 +1,88 @@
+import BackgroundTasks
+import Foundation
+import WidgetKit
+
+enum SmartDataRefreshScheduler {
+ static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
+
+ static func register() {
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
+ guard let task = task as? BGAppRefreshTask else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+ handle(task)
+ }
+ }
+
+ static func scheduleNextIfNeeded() {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
+ guard FuelStore.loadDataRefreshMode() == .smart else { return }
+ let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
+ request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ #if DEBUG
+ print("SMART-REFRESH schedule failed: \(error)")
+ #endif
+ }
+ }
+
+ private static func handle(_ task: BGAppRefreshTask) {
+ scheduleNextIfNeeded()
+ let refreshTask = Task {
+ let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
+ task.setTaskCompleted(success: success)
+ }
+ task.expirationHandler = {
+ refreshTask.cancel()
+ }
+ }
+}
+
+enum SmartDataRefreshCoordinator {
+ @discardableResult
+ static func runBackgroundProbe(now: Date = Date()) async -> Bool {
+ guard FuelStore.loadDataRefreshMode() == .smart else { return true }
+ guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
+ return true
+ }
+ FuelStore.saveLastSmartProbe(now)
+ guard !Task.isCancelled else { return false }
+
+ guard let latest = await FuelHistoryStore.fetchLatest() else {
+ return false
+ }
+
+ let latestDay = latest.availableTo ?? latest.date
+ let latestUpdated = latest.dataUpdated
+ let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
+ let savedUpdated = FuelStore.loadDataUpdated()
+ let shouldFetchFullDump = FuelStore.loadStations().isEmpty
+ || !FuelStore.isCacheFresh(now: now)
+ || (latestDay != nil && latestDay != cachedDay)
+ || (latestUpdated != nil && latestUpdated != savedUpdated)
+
+ guard shouldFetchFullDump, !Task.isCancelled else { return true }
+
+ do {
+ let fetched = try await FuelPriceProvider.active.fetchStations(
+ near: nil,
+ lng: nil,
+ fuel: FuelStore.loadSelectedFuel(),
+ radiusKM: nil
+ )
+ guard !Task.isCancelled else { return false }
+ FuelStore.saveStations(fetched)
+ FuelStore.saveLastRefresh(now)
+ if let meta = LiveChainProvider.latestMeta {
+ FuelStore.saveRelayMeta(meta)
+ }
+ WidgetCenter.shared.reloadAllTimelines()
+ return true
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift b/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift
new file mode 100644
index 0000000..6179099
--- /dev/null
+++ b/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum DataRefreshMode: String, CaseIterable, Identifiable {
+ case standard
+ case smart
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .standard: return "Standard"
+ case .smart: return "Smart"
+ }
+ }
+
+ var summary: String {
+ switch self {
+ case .standard:
+ return "Saves battery. Refreshes price data up to twice daily."
+ case .smart:
+ return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
+ }
+ }
+}
diff --git a/Shared/DataRefreshMode.swift b/Shared/DataRefreshMode.swift
new file mode 100644
index 0000000..6179099
--- /dev/null
+++ b/Shared/DataRefreshMode.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum DataRefreshMode: String, CaseIterable, Identifiable {
+ case standard
+ case smart
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .standard: return "Standard"
+ case .smart: return "Smart"
+ }
+ }
+
+ var summary: String {
+ switch self {
+ case .standard:
+ return "Saves battery. Refreshes price data up to twice daily."
+ case .smart:
+ return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
+ }
+ }
+}
diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift
index 2ae5e16..7615eba 100644
--- a/Shared/FuelStore.swift
+++ b/Shared/FuelStore.swift
@@ -370,6 +370,8 @@ struct FuelStore {
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
+ static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
+ static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
@@ -861,10 +863,24 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
}
- // MARK: Refresh policy — data is cached; the app only auto-refreshes
- // twice a day (pull-to-refresh is the manual override).
+ // MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
+ // the same full-dump freshness cap but may probe the tiny mirror pointer
+ // more often in the background and only refresh the full dump if it changed.
static let refreshInterval: TimeInterval = 12 * 60 * 60
+ static let smartProbeInterval: TimeInterval = 4 * 60 * 60
+
+ static func loadDataRefreshMode() -> DataRefreshMode {
+ if let raw = loadString(service: dataRefreshModeKey),
+ let mode = DataRefreshMode(rawValue: raw) {
+ return mode
+ }
+ return .standard
+ }
+
+ static func saveDataRefreshMode(_ mode: DataRefreshMode) {
+ saveString(mode.rawValue, service: dataRefreshModeKey)
+ }
static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
@@ -877,6 +893,17 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
}
+ static func loadLastSmartProbe() -> Date? {
+ if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
+ return Date(timeIntervalSince1970: ts)
+ }
+ return nil
+ }
+
+ static func saveLastSmartProbe(_ date: Date = Date()) {
+ saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
+ }
+
// MARK: Relay metadata — shown in Settings → About. Written after each
// successful full fetch so the About section reflects the live source.
@@ -929,8 +956,17 @@ struct FuelStore {
/// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool {
+ isCacheFresh(now: Date())
+ }
+
+ static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false }
- return Date().timeIntervalSince(last) < refreshInterval
+ return now.timeIntervalSince(last) < refreshInterval
+ }
+
+ static func isSmartProbeDue(now: Date = Date()) -> Bool {
+ guard let last = loadLastSmartProbe() else { return true }
+ return now.timeIntervalSince(last) >= smartProbeInterval
}
// MARK: Onboarding — the app shows the intro screen on first launch only