6 Commits
Author SHA1 Message Date
FuelBoard Contributor 54aca14fd4 Trends: anchor empty/loading/error states to the top of the chart slot
The no-data states previously floated or centred vertically (Spacer
sandwich), so the sheet rendered them halfway down the page while the
chart-with-data anchors to the top. All chartArea states now share one
top-aligned container that fills the space below the pickers — the
empty state sits exactly where the chart will render, and the layout no
longer jumps between states. 83 tests, Release build green.
2026-08-15 12:30:25 +01:00
FuelBoard Contributor d1fbac3f05 New app icon (drip: £ fuel drop, green border, gradient bg) + Alerts radius guidance labels
- AppIcon / AppIcon-dark / AppIcon-tinted swapped for the drip design
  (1024px, dark + tinted variants), onboarding artwork matched
- Alerts tab: live explainer under the Radius picker — situational
  guidance per option (Follow search = set-and-forget mirror of the
  Stations distance; 1 = town driving; 2 = city/suburb commute;
  3 = mixed; 5 = A-roads/motorway exits; 8 = long-distance) — plus the
  trade-off line in the section footer
- 83 tests, Release build green
2026-08-15 12:18:56 +01:00
FuelBoard Contributor 6cbf1dfc8f Debug: 100 m fence delivery test + test-button trace logging
Settings → Debug → 'Debug fence (delivery test)': registers a 100 m
CLCircularRegion at the current fix that exists solely to prove iOS
delivers region events on this install. Entering it ticks 'Region events
this session', logs 'debug fence entered' in the Alert trace, and fires a
plain notification — zero alert gates. Cleared via button; survives the
station re-registration loop (update() now skips debug-fence regions);
cleared when alerts are disabled.

The Test alert / Plain test buttons now also write to the Alert trace
('test alert scheduled — …'), so the trace reflects every probe, not just
live geofence entries. 83 tests, Release build green.
2026-08-15 11:46:00 +01:00
FuelBoard Contributor 494412ba99 Debug: live alert trace — surface every gate decision from the alert chain
ProximityMonitor now records each stage the live chain reaches (Settings →
Debug → Alert trace, in-memory, newest-first, capped at 8):
- didEnterRegion arrival (regionEventCount + entry line) — distinguishes
  'geofence never fires' from 'gate blocked the alert'
- gate misses: station not in loaded list, hourly dedup (with minutes ago),
  no fuel sellers within radius after fresh fetch, entered-not-cheapest
  (with the cheaper station + price)
- the previously-silent fresh-fetch catch now logs the error
- 'alert scheduled' once UNUserNotificationCenter.add is called — if that
  line appears but no banner shows, the failure is delivery/permission,
  not alert logic

SettingsView shows the trace (symbol per kind) + region-event count; the
existing Test-alert button already probes notification permission and
reports denial inline. 83 tests, Release build green.
2026-08-15 11:10:10 +01:00
FuelBoard Contributor d0d25811c7 chore: untrack build-sim artifacts (ignored now; were committed before the rule) 2026-08-15 10:12:46 +01:00
FuelBoard Contributor aab2d02ef5 P1: Favourites Trends graph — FuelHistoryStore + TrendsView (Swift Charts)
- Shared/FuelHistory.swift: GitHub mirror read path (latest.json pointer +
  history/YYYY-MM-DD.json day files); day math (UTC noon keys), slim day decode
  with the shared price band, series + deltaSeries (vs-cheapest rebase),
  favourites-only app-group cache (90-day prune), parallel per-day fetches,
  404/network = graph gap never error.
- FuelPriceProvider: shared priceBand + mapGrades (single source for live +
  history decoding).
- FuelBoard/TrendsView.swift: line chart in the Favourites tab via toolbar
  button + sheet; fuel capsule (fuels with favourites only), 7/30/90 range,
  Price/vs-cheapest toggle, per-station legend, price-display style on y-axis,
  empty/loading/retry states with honest copy; no widget in v1.
- Localizable.strings: Trends keys.
- 83 tests (13 history + URL regression): day math, band guard, series gaps,
  delta rebasing, prune, and the appendingPathComponent regression (relative
  URL resolution dropped /main — all fetches 404'd).
- Sim-verified: favourites rows + TOP/deltas; sheet controls + building-up
  state with live first-snapshot date (archive has 1 day; lines render once
  >=2 snapshots).
- Backlog: P1 Trends DONE (unmerged); P0 REMAINING = live provider chain +
  telemetry beacon.
2026-08-15 10:12:23 +01:00
2814 changed files with 1019 additions and 78 deletions
+1
View File
@@ -1,5 +1,6 @@
build/ build/
build-release/ build-release/
build-sim/
.DS_Store .DS_Store
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
+15 -4
View File
@@ -39,8 +39,10 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
(script ~/.hermes/scripts/mirror_push.py, log (script ~/.hermes/scripts/mirror_push.py, log
~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start ~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start
com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200. com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200.
REMAINING: app-side read (provider chain GitHub → relay → bundled dump) + the REMAINING: live provider chain (GitHub → relay → bundled dump for CURRENT
telemetry beacon.* prices) + the telemetry beacon — NOTE: the HISTORY read path shipped with P1
(FuelHistoryStore: latest.json + day files for starred stations, favourites-only
app-group cache, 404 = gap never error, 90-day prune).*
## P1 — Soon ## P1 — Soon
@@ -56,7 +58,7 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.* Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
- [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by - [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
distance instead of price. Free second phrase in the same `AppShortcutsProvider`. distance instead of price. Free second phrase in the same `AppShortcutsProvider`.
- [ ] **Favourites Trends graph** — price history chart for starred stations. - [x] **Favourites Trends graph** — price history chart for starred stations.
Swift Charts (iOS 26 target) line chart in the Favourites tab; one series per Swift Charts (iOS 26 target) line chart in the Favourites tab; one series per
favourite, fuel-scoped (station, fuel) pairs map 1:1 to the archive. Default = favourite, fuel-scoped (station, fuel) pairs map 1:1 to the archive. Default =
absolute price lines; toggle "vs cheapest favourite" re-baselines to 0 as signed absolute price lines; toggle "vs cheapest favourite" re-baselines to 0 as signed
@@ -64,7 +66,16 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
capsule fuel picker; range control 7/30/90 days; respects the price-display capsule fuel picker; range control 7/30/90 days; respects the price-display
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
each refresh — check back in a few days"). No widget in v1 (one-kind rule). each refresh — check back in a few days"). No widget in v1 (one-kind rule).
Depends on the P0 archive. Depends on the P0 archive. *DONE 2026-08-15 on feature/trends-history (unmerged):
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
band, series + deltaSeries, app-group favourites-only cache, parallel per-day
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
cheapest, per-station legend, empty/loading/retry states) + toolbar entry in
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
snapshot date. Pending: merge to main after device sideload test; lines render
once ≥2 snapshots (archive warming daily).*
## P2 — Later ## P2 — Later
+32 -1
View File
@@ -46,6 +46,34 @@ struct AlertsView: View {
return "\(shown) \(distanceUnit.label(for: Double(shown)))" return "\(shown) \(distanceUnit.label(for: Double(shown)))"
} }
/// Situational guidance for the currently-selected alert radius, shown
/// under the picker so users know what each choice is best for. Labels
/// mirror the picker row text (unit-aware) and explain the trade-off:
/// smaller radii ping more often but later; larger radii ping rarely in
/// town but earlier from afar.
private var alertRadiusGuidance: String {
switch alertChoice.wrappedValue {
case .followSearch:
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
case .fixed(let value):
let optionLabel = "\(value) \(distanceUnit.label(for: Double(value)))"
let body: String
switch value {
case 1:
body = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
case 2:
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
case 3:
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
case 5:
body = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle."
default:
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
}
return "\(optionLabel)\(body)"
}
}
private enum AlertRadiusChoice: Hashable { private enum AlertRadiusChoice: Hashable {
case followSearch case followSearch
case fixed(Int) case fixed(Int)
@@ -115,6 +143,9 @@ struct AlertsView: View {
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value)) Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
} }
} }
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if enabled && monitoredCount == 0 { if enabled && monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.") Text("No stations monitored yet — open the Stations tab to load prices first.")
.font(.caption) .font(.caption)
@@ -123,7 +154,7 @@ struct AlertsView: View {
} header: { } header: {
Text("Cheapest-station alerts") Text("Cheapest-station alerts")
} footer: { } footer: {
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing.") Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
} }
if enabled, let lastAlert { if enabled, let lastAlert {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+6 -1
View File
@@ -403,7 +403,12 @@ struct ContentView: View {
debugStatus: monitor.debugStatus, debugStatus: monitor.debugStatus,
appCheapest: appCheapestStatus, appCheapest: appCheapestStatus,
regionError: monitor.lastRegionError, regionError: monitor.lastRegionError,
monitoredCount: monitor.monitoredStationIDs.count monitoredCount: monitor.monitoredStationIDs.count,
alertLog: monitor.alertLog,
regionEventCount: monitor.regionEventCount,
debugFenceIdentifier: monitor.debugFenceIdentifier,
onDebugFence: { monitor.registerDebugFenceAroundMe() },
onClearDebugFence: { monitor.clearDebugFence() }
) )
.tabItem { Label("Settings", systemImage: "gearshape.fill") } .tabItem { Label("Settings", systemImage: "gearshape.fill") }
} }
+18
View File
@@ -30,6 +30,9 @@ struct FavouritesView: View {
/// favourites (e.g. the last one is un-starred while viewing it). /// favourites (e.g. the last one is un-starred while viewing it).
@State private var fuel: FuelType = .e10 @State private var fuel: FuelType = .e10
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
private var activeFuel: FuelType { private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10) availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
} }
@@ -140,9 +143,24 @@ struct FavouritesView: View {
.navigationTitle("Favourites") .navigationTitle("Favourites")
.toolbar { .toolbar {
if !favourites.isEmpty { if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
showTrends = true
} label: {
Image(systemName: "chart.xyaxis.line")
.accessibilityLabel("Trends")
}
EditButton() EditButton()
} }
} }
} }
.sheet(isPresented: $showTrends) {
TrendsView(
favourites: favourites,
selectedFuel: activeFuel,
priceDisplayStyle: priceDisplayStyle
)
}
}
} }
} }
+120 -7
View File
@@ -18,6 +18,27 @@ struct DebugLocationStatus: Equatable {
var cheapestDistanceKM: Double? var cheapestDistanceKM: Double?
} }
/// One recorded event in the live alert trace (Settings Debug). Lets the
/// tester see exactly which stage of the alert chain ran whether a region
/// event arrived at all, which gate (if any) suppressed the alert, and when
/// an alert was scheduled. In-memory only: resets on relaunch.
struct AlertLogEntry: Identifiable, Equatable {
enum Kind: String, Equatable {
/// A geofence entry event reached the monitor (didEnterRegion).
case entry
/// A gate blocked the alert (dedup, not-cheapest, station missing).
case gate
/// A non-recoverable failure (fresh-price fetch threw).
case error
/// An alert was scheduled with UNUserNotificationCenter.
case fired
}
let id = UUID()
let date: Date
let kind: Kind
let text: String
}
/// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations /// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations
/// list computes (selected fuel, stationLimit radius, current location), so /// list computes (selected fuel, stationLimit radius, current location), so
/// the debug "Cheapest in range" row matches the TOP badge in the app /// the debug "Cheapest in range" row matches the TOP badge in the app
@@ -69,6 +90,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Debug so a silently-failed `startMonitoring` (region budget exceeded, /// Debug so a silently-failed `startMonitoring` (region budget exceeded,
/// auth not granted, radius too large) is visible instead of invisible. /// auth not granted, radius too large) is visible instead of invisible.
@Published private(set) var lastRegionError: String? @Published private(set) var lastRegionError: String?
/// Live alert trace (Settings Debug): every stage the chain reached,
/// newest first, capped at 8. Empty = no region event has ever arrived.
@Published private(set) var alertLog: [AlertLogEntry] = []
/// How many `didEnterRegion` callbacks this session proof the geofences
/// actually fire (vs. a gate silently blocking every alert).
@Published private(set) var regionEventCount = 0
/// Identifier of the armed debug fence (Settings Debug "Register
/// 100 m fence"), nil when not armed. The fence is a 100 m circle at the
/// current location that exists only to prove iOS delivers region events
/// on this install; entering it fires a plain notification, no gates.
@Published private(set) var debugFenceIdentifier: String?
private let manager = CLLocationManager() private let manager = CLLocationManager()
private var stations: [FuelStation] = [] private var stations: [FuelStation] = []
@@ -119,7 +151,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
refreshDebugStatus() refreshDebugStatus()
for region in manager.monitoredRegions { // Drop every region EXCEPT the debug fence (if armed) the fence is
// a fixed 100 m circle at a fixed point that must survive re-registration
// churn; stations re-register from scratch every pass.
for region in manager.monitoredRegions where !region.identifier.hasPrefix("debug-fence") {
manager.stopMonitoring(for: region) manager.stopMonitoring(for: region)
} }
monitoredStationIDs = [] monitoredStationIDs = []
@@ -173,6 +208,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
manager.stopMonitoring(for: region) manager.stopMonitoring(for: region)
} }
monitoredStationIDs = [] monitoredStationIDs = []
debugFenceIdentifier = nil
} else { } else {
requestPermissions() requestPermissions()
// Re-register geofences from restored cache immediately. // Re-register geofences from restored cache immediately.
@@ -182,6 +218,48 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled } var isEnabled: Bool { enabled }
/// Records a stage in the live alert trace, newest first, capped at 8.
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
if alertLog.count > 8 { alertLog.removeLast() }
}
// MARK: - Debug fence
/// Debug-only (Settings Debug "Register 100 m fence"): arms a 100 m
/// circle around the last known fix purely to prove iOS delivers region
/// events on this install. Stepping 100 m away and back should tick
/// "Region events this session" and fire a plain notification no alert
/// gates apply. If that produces nothing, region delivery is broken at the
/// system level and no amount of alert-logic fixing will help.
func registerDebugFenceAroundMe() {
guard let fix = FuelStore.loadLocationWithDate() else {
logAlert(.error, "debug fence — no location fix yet (wait for one, then retry)")
return
}
clearDebugFence()
let id = "debug-fence-\(UUID().uuidString.prefix(8))"
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: fix.coordinate.lat, longitude: fix.coordinate.lng),
radius: 100,
identifier: id
)
region.notifyOnEntry = true
region.notifyOnExit = false
manager.startMonitoring(for: region)
debugFenceIdentifier = id
logAlert(.entry, "debug fence armed — 100 m circle at current location (step away and back in)")
}
func clearDebugFence() {
guard let id = debugFenceIdentifier else { return }
for region in manager.monitoredRegions where region.identifier == id {
manager.stopMonitoring(for: region)
}
debugFenceIdentifier = nil
logAlert(.gate, "debug fence cleared")
}
/// Recomputes the Settings Debug location snapshot from current state. /// Recomputes the Settings Debug location snapshot from current state.
/// "In range" uses the SAME criteria as live alerts: stations selling the /// "In range" uses the SAME criteria as live alerts: stations selling the
/// monitored fuel within the alert radius of the last known location. /// monitored fuel within the alert radius of the last known location.
@@ -250,6 +328,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
Task { @MainActor in Task { @MainActor in
// Trace: prove the geofence event itself arrived. This is the
// single point that distinguishes "no region delivery" from
// "delivery fine, a gate blocked the alert".
self.regionEventCount += 1
self.logAlert(.entry, "didEnterRegion — \(region.identifier)")
// Debug fence (Settings Debug): exists only to prove iOS
// delivers region events on this install. Entering it fires a
// plain notification with no alert gates if the counter ticks
// and a banner shows after stepping out and back, delivery works.
if region.identifier.hasPrefix("debug-fence") {
self.logAlert(.fired, "debug fence entered — iOS delivered the region event (100 m)")
self.sendPlainTestNotification()
return
}
self.handleEntry(region) self.handleEntry(region)
} }
} }
@@ -277,10 +369,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// Fall back to cached data on background wake (stations may not be // Fall back to cached data on background wake (stations may not be
// fetched yet when the app is relaunched by a region event). // fetched yet when the app is relaunched by a region event).
if stations.isEmpty { stations = FuelStore.loadStations() } if stations.isEmpty { stations = FuelStore.loadStations() }
guard let station = stations.first(where: { $0.id == stationID }) else { return } guard let station = stations.first(where: { $0.id == stationID }) else {
logAlert(.gate, "region \(stationID) — station not in loaded list (Stations tab not fetched yet?)")
return
}
// Dedup: one alert per station per hour. // Dedup: one alert per station per hour.
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return } if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 {
logAlert(.gate, "\(station.name) — dedup: already alerted \(Int(Date().timeIntervalSince(last) / 60)) min ago")
return
}
Task { Task {
do { do {
@@ -293,9 +391,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
$0.prices[fuel] != nil && $0.prices[fuel] != nil &&
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM $0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
} }
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }), guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
cheapest.id == station.id, logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
let price = cheapest.prices[fuel] else { return } return
}
guard cheapest.id == station.id else {
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
return
}
// withinRadius guarantees the fuel exists on every candidate.
guard let price = cheapest.prices[fuel] else { return }
// Ties: all stations in radius at the same cheapest price. // Ties: all stations in radius at the same cheapest price.
let tied = FuelStore.tiedStations( let tied = FuelStore.tiedStations(
@@ -306,7 +411,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
fireAlert(for: cheapest, price: price, ties: tied) fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[stationID] = Date() lastNotified[stationID] = Date()
} catch { } catch {
// Silent geofence state stays valid for next entry. // Trace the silent catch a relay failure drops the alert
// with no banner, which previously looked like "no alert".
logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)")
} }
} }
} }
@@ -341,6 +448,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
addAlertRequest(content: content, station: station) addAlertRequest(content: content, station: station)
} }
logAlert(.fired, "alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
} }
@@ -531,6 +639,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions." content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions."
let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))" let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(choices.count) tied at \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate, ties: choices) { status in addAlertRequest(content: content, station: candidate, ties: choices) { status in
self.lastTestResult = "\(baseResult) · \(status)" self.lastTestResult = "\(baseResult) · \(status)"
} }
@@ -539,6 +648,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))" let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
lastTestResult = baseResult lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate) { status in addAlertRequest(content: content, station: candidate) { status in
self.lastTestResult = "\(baseResult) · \(status)" self.lastTestResult = "\(baseResult) · \(status)"
} }
@@ -555,6 +665,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
logAlert(.fired, "test alert scheduled — no \(fuel.displayName.lowercased()) candidate, fallback copy sent")
lastTestResult = sellers.isEmpty lastTestResult = sellers.isEmpty
? "No stations loaded — open the Stations tab first" ? "No stations loaded — open the Stations tab first"
: "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))" : "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))"
@@ -588,6 +699,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.title = "\(brand) — plain test notification" content.title = "\(brand) — plain test notification"
content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions." content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
content.sound = .default content.sound = .default
logAlert(.fired, "plain test notification scheduled — \(brand)")
addAlertRequest(content: content, station: nearest) addAlertRequest(content: content, station: nearest)
} else { } else {
content.title = "FuelBoard test notification" content.title = "FuelBoard test notification"
@@ -595,6 +707,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
logAlert(.fired, "plain test notification scheduled — no station data")
} }
} }
+80
View File
@@ -41,6 +41,17 @@ struct SettingsView: View {
/// Live geofence count (monitor.monitoredStationIDs.count) Debug-only /// Live geofence count (monitor.monitoredStationIDs.count) Debug-only
/// status; the user-facing Alerts tab no longer exposes fence plumbing. /// status; the user-facing Alerts tab no longer exposes fence plumbing.
var monitoredCount: Int = 0 var monitoredCount: Int = 0
/// Live alert trace (monitor.alertLog) every stage the alert chain
/// reached this session (region event / gate / scheduled). Debug-only.
var alertLog: [AlertLogEntry] = []
/// Region events received this session (monitor.regionEventCount).
var regionEventCount: Int = 0
/// Debug fence state (monitor.debugFenceIdentifier) armed vs cleared.
var debugFenceIdentifier: String?
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
var onDebugFence: () -> Void = {}
/// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore() @StateObject private var tipStore = TipStore()
@State private var showTipAlert = false @State private var showTipAlert = false
@@ -228,6 +239,55 @@ struct SettingsView: View {
} header: { } header: {
Text("Geofence monitoring") Text("Geofence monitoring")
} }
Section {
if debugFenceIdentifier != nil {
LabeledContent("Debug fence", value: "armed (100 m)")
Text("Step 100 m away from your current position, then walk back in. 'Region events this session' should tick and a plain notification fires — proof iOS delivers region events on this install, with no alert gates involved.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Clear debug fence", role: .destructive) {
onClearDebugFence()
}
} else {
Button("Register 100 m fence at current location") {
onDebugFence()
}
Text("A debug-only circle around your current position that proves geofence delivery end-to-end: step out 100 m and back in, and watch 'Region events this session' tick.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Debug fence (delivery test)")
}
Section {
LabeledContent("Region events this session", value: "\(regionEventCount)")
if alertLog.isEmpty {
Text("No alert activity yet — drive across a geofence boundary, or tap the test-alert button above to exercise the chain without a geofence.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(alertLog) { entry in
HStack(alignment: .firstTextBaseline) {
Image(systemName: alertSymbol(for: entry.kind))
.foregroundStyle(alertColor(for: entry.kind))
VStack(alignment: .leading, spacing: 1) {
Text(entry.text)
.font(.caption2)
.textSelection(.enabled)
Text(entry.date, format: .dateTime.hour().minute().second())
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
} header: {
Text("Alert trace")
} footer: {
Text("One line per stage the live chain reached: a geofence entry, each gate that passed or blocked the alert (dedup, cheapest-within-radius, fresh fetch), then the scheduled alert. If the trace shows 'alert scheduled' but no banner appears, the failure is in notification delivery itself (permission or background delivery), not the alert logic.")
}
} }
Section { Section {
@@ -442,6 +502,26 @@ struct SettingsView: View {
} }
} }
// MARK: - Alert trace rendering
private func alertSymbol(for kind: AlertLogEntry.Kind) -> String {
switch kind {
case .entry: return "location.circle.fill"
case .gate: return "arrow.right.circle"
case .error: return "xmark.octagon.fill"
case .fired: return "bell.badge.fill"
}
}
private func alertColor(for kind: AlertLogEntry.Kind) -> Color {
switch kind {
case .entry: return .green
case .gate: return .orange
case .error: return .red
case .fired: return .blue
}
}
private func sendTestAlert() { private func sendTestAlert() {
UNUserNotificationCenter.current().getNotificationSettings { settings in UNUserNotificationCenter.current().getNotificationSettings { settings in
Task { @MainActor in Task { @MainActor in
+257
View File
@@ -0,0 +1,257 @@
import SwiftUI
import Charts
/// Trends the Favourites price-history chart.
///
/// Plots one line per favourited station for the active fuel across the
/// selected range (7/30/90 days), fed by the GitHub price mirror
/// (`FuelHistoryStore`). Default shows absolute prices; "vs cheapest" rebases
/// each day to the cheapest favourite (0 baseline, signed pence above it)
/// the same delta pattern the list rows already use. Missing days are gaps,
/// never fabricated. No widget in v1 (one-kind rule).
struct TrendsView: View {
/// All favourites (fuel-scoped entries) the sheet derives the active
/// fuel's stations and which fuels have favourites.
let favourites: [FavouriteEntry]
let selectedFuel: FuelType
let priceDisplayStyle: PriceDisplayStyle
@Environment(\.dismiss) private var dismiss
@State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30
@State private var mode: TrendsMode = .price
@State private var series: [StationHistory] = []
@State private var isLoading = false
@State private var loadFailed = false
@State private var firstSnapshot: String?
enum TrendsMode: String, CaseIterable, Identifiable {
case price
case vsCheapest
var id: String { rawValue }
}
/// Fuels that currently have at least one favourite only these tabs show.
private var availableFuels: [FuelType] {
FuelType.allCases.filter { fuel in
favourites.contains { $0.fuel == fuel }
}
}
/// The active fuel, with the same fallback as the Favourites tab.
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
/// Stations favourited for the active fuel, in the user's stored order
/// the chart keeps this order so line colours are stable.
private var orderedStations: [(id: String, name: String)] {
favourites.filter { $0.fuel == activeFuel }.map { ($0.station.id, $0.station.name) }
}
private var displaySeries: [StationHistory] {
mode == .price ? series : FuelHistoryStore.deltaSeries(series)
}
/// A line needs at least two points to draw; anything less is the
/// "building up" state, not a broken chart.
private var hasEnoughData: Bool {
series.contains { $0.points.count >= 2 }
}
private var hasAnyData: Bool {
series.contains { !$0.points.isEmpty }
}
private func seriesColor(_ index: Int) -> Color {
let palette: [Color] = [.blue, .orange, .purple, .pink, .teal, .indigo, .brown, .green]
return palette[index % palette.count]
}
/// X-axis tick density a tick per day for short ranges, monthly for the
/// 90-day view so labels never collide.
private var xStride: Int {
switch rangeDays {
case ...14: return 1
case 15...60: return 7
default: return 30
}
}
private func load() async {
isLoading = true
loadFailed = false
defer { isLoading = false }
// The pointer is a non-fatal hint for the empty state; history fetch
// failures surface as the retry state.
firstSnapshot = await FuelHistoryStore.fetchLatest()?.availableFrom
let fetched = await FuelHistoryStore.fetchHistory(
stations: orderedStations,
fuel: activeFuel,
days: rangeDays
)
if fetched.allSatisfy({ $0.points.isEmpty }), !orderedStations.isEmpty {
// All days missing either the mirror is unreachable (retry) or
// genuinely empty (the building-up state). Distinguish by a quick
// pointer probe already done above.
loadFailed = firstSnapshot == nil
}
series = fetched
}
private func yLabel(_ pence: Double) -> String {
switch mode {
case .price:
return FuelStore.priceText(pence, style: priceDisplayStyle)
case .vsCheapest:
return String(format: "%.1fp", pence)
}
}
var body: some View {
NavigationStack {
VStack(spacing: 14) {
FuelTypeSegmentedPicker(selection: $fuel, fuels: availableFuels)
HStack(spacing: 10) {
Picker("Range", selection: $rangeDays) {
ForEach(FuelHistoryStore.rangeOptions, id: \.self) { days in
Text("\(days) days").tag(days)
}
}
.pickerStyle(.segmented)
Picker("Mode", selection: $mode) {
Text("Price").tag(TrendsMode.price)
Text("vs cheapest").tag(TrendsMode.vsCheapest)
}
.pickerStyle(.segmented)
}
chartArea
}
.padding()
.navigationTitle("Trends")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.task(id: "\(activeFuel.rawValue)-\(rangeDays)") {
await load()
}
}
}
@ViewBuilder
private var chartArea: some View {
// Every state anchors to the TOP of the chart slot the same spot the
// chart occupies when data exists. The empty/loading/error states must
// not float or centre in the sheet, or the layout jumps between states.
Group {
if isLoading {
VStack(spacing: 12) {
ProgressView("Fetching price history…")
}
.padding(.top, 24)
} else if loadFailed {
VStack(spacing: 10) {
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("Couldn't load price history")
.font(.headline)
Button("Retry") { Task { await load() } }
.buttonStyle(.bordered)
}
.padding(.top, 24)
} else if !hasAnyData {
emptyState
} else if !hasEnoughData {
emptyState // single point nothing to draw yet
} else {
VStack(spacing: 12) {
chart
legend
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("No price history yet")
.font(.headline)
if let firstSnapshot {
Text("First snapshot \(firstSnapshot) — a few days are needed to draw a trend.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Text("Prices are recorded each day FuelBoard's relay runs — check back in a few days.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
private var chart: some View {
Chart(displaySeries) { history in
ForEach(history.points) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Price", point.pence)
)
.foregroundStyle(seriesColor(index(of: history.stationID)))
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let pence = value.as(Double.self) {
Text(yLabel(pence))
}
}
}
}
.frame(height: 260)
}
private func index(of stationID: String) -> Int {
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
}
private var legend: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in
HStack(spacing: 8) {
Circle()
.fill(seriesColor(index))
.frame(width: 8, height: 8)
Text(history.name)
.font(.caption)
.lineLimit(1)
Spacer()
}
}
}
.padding(.horizontal, 4)
}
}
+14
View File
@@ -134,3 +134,17 @@
/* Station row */ /* Station row */
"best" = "best"; "best" = "best";
/* Trends — price history chart */
"Trends" = "Trends";
"Range" = "Range";
"Mode" = "Mode";
"Price" = "Price";
"vs cheapest" = "vs cheapest";
"%lld days" = "%lld days";
"Fetching price history…" = "Fetching price history…";
"Couldn't load price history" = "Couldn't load price history";
"Retry" = "Retry";
"No price history yet" = "No price history yet";
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
@@ -0,0 +1 @@
../../../Shared/FuelHistory.swift
@@ -0,0 +1,182 @@
import XCTest
@testable import FuelBoardShared
// FuelHistory price-history store for the Favourites Trends graph.
// Covers day math, slim day parsing, series building, delta rebasing and
// cache pruning. The network layer is exercised separately (pure helpers are
// what the chart logic depends on).
final class FuelHistoryTests: XCTestCase {
// MARK: Day math
func testDayStringFormat() {
XCTAssertEqual(FuelHistoryStore.dayString(), FuelHistoryStore.dayString())
let d = FuelHistoryStore.date(fromDay: "2026-08-15")
XCTAssertNotNil(d)
XCTAssertEqual(FuelHistoryStore.dayString(d!), "2026-08-15")
}
func testNeededDaysCountAndOrder() {
let days = FuelHistoryStore.neededDays(back: 7, from: FuelHistoryStore.date(fromDay: "2026-08-15")!)
XCTAssertEqual(days.count, 7)
XCTAssertEqual(days.first, "2026-08-09")
XCTAssertEqual(days.last, "2026-08-15")
}
func testNeededDaysZero() {
XCTAssertTrue(FuelHistoryStore.neededDays(back: 0).isEmpty)
}
// MARK: Day parsing (slim decode + shared band guard)
private func dayJSON(stations: [[String: Any]]) -> Data {
let dict: [String: Any] = ["stations": stations]
return try! JSONSerialization.data(withJSONObject: dict)
}
func testParseDayStationsExtractsRequestedFuels() throws {
let data = dayJSON(stations: [
["id": "s1", "prices": ["E10": 129.9, "E5": 139.9, "B7": 134.9]],
["id": "s2", "prices": ["E10": 131.9]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1", "s2"])
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
XCTAssertEqual(parsed["s1"]?[.e5], 139.9)
XCTAssertEqual(parsed["s1"]?[.diesel], 134.9) // B7 diesel
XCTAssertEqual(parsed["s2"]?[.e10], 131.9)
XCTAssertNil(parsed["s2"]?[.e5])
}
func testParseDayStationsIgnoresUnrequestedStations() throws {
let data = dayJSON(stations: [
["id": "wanted", "prices": ["E10": 129.9]],
["id": "other", "prices": ["E10": 99.9]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["wanted"])
XCTAssertNotNil(parsed["wanted"])
XCTAssertNil(parsed["other"])
}
func testParseDayStationsBandGuardDropsGarbage() throws {
let data = dayJSON(stations: [
["id": "s1", "prices": ["E10": 129.9, "E5": 1.3, "B7": 1589.0]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
XCTAssertNil(parsed["s1"]?[.e5])
XCTAssertNil(parsed["s1"]?[.diesel])
}
func testParseDayStationsMissingPricesIsEmpty() throws {
let data = dayJSON(stations: [["id": "s1"]])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
XCTAssertTrue(parsed.isEmpty)
}
// MARK: Series building
func testSeriesFromCacheBuildsOrderedPoints() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["s1": ["e10": 130.0]],
"2026-08-14": ["s1": ["e10": 129.5]],
"2026-08-15": ["s1": ["e10": 128.9]],
]
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let series = FuelHistoryStore.series(
fromCache: cache,
stations: [("s1", "Shell Test")],
fuel: .e10,
days: days
)
XCTAssertEqual(series.count, 1)
XCTAssertEqual(series[0].name, "Shell Test")
XCTAssertEqual(series[0].points.count, 3)
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 129.5, 128.9])
XCTAssertEqual(series[0].points.map(\.date), days.compactMap { FuelHistoryStore.date(fromDay: $0) })
}
func testSeriesSkipsMissingDays() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["s1": ["e10": 130.0]],
"2026-08-15": ["s1": ["e10": 128.9]], // gap on the 14th
]
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let series = FuelHistoryStore.series(
fromCache: cache,
stations: [("s1", "Shell Test")],
fuel: .e10,
days: days
)
XCTAssertEqual(series[0].points.count, 2) // gap skipped, never fabricated
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 128.9])
}
func testSeriesFiltersFuel() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-15": ["s1": ["e10": 128.9, "diesel": 134.9]],
]
let days = ["2026-08-15"]
let diesel = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .diesel, days: days)
XCTAssertEqual(diesel[0].points.map(\.pence), [134.9])
let e10 = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .e10, days: days)
XCTAssertEqual(e10[0].points.map(\.pence), [128.9])
}
// MARK: Delta rebasing
func testDeltaSeriesRebasesToCheapestPerDay() {
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["a": ["e10": 130.0], "b": ["e10": 132.0]],
"2026-08-14": ["a": ["e10": 131.0], "b": ["e10": 131.0]],
"2026-08-15": ["a": ["e10": 129.0], "b": ["e10": 130.5]],
]
let stations = [("a", "Asda A"), ("b", "Bp B")]
let raw = FuelHistoryStore.series(fromCache: cache, stations: stations, fuel: .e10, days: days)
let delta = FuelHistoryStore.deltaSeries(raw)
// Day 1: a=0, b=+2. Day 2: both 0. Day 3: a=0, b=+1.5
XCTAssertEqual(delta[0].points.map(\.pence), [0, 0, 0])
XCTAssertEqual(delta[1].points.map(\.pence), [2.0, 0, 1.5])
}
func testDeltaSeriesGapDayKeepsOnlyPresentStations() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-15": ["a": ["e10": 129.0]], // b missing this day
]
let raw = FuelHistoryStore.series(fromCache: cache, stations: [("a", "A"), ("b", "B")], fuel: .e10, days: ["2026-08-15"])
let delta = FuelHistoryStore.deltaSeries(raw)
XCTAssertEqual(delta[0].points.map(\.pence), [0])
XCTAssertTrue(delta[1].points.isEmpty) // gap stays a gap
}
// MARK: Cache pruning
func testPruneKeepsRecentDaysOnly() {
let days = FuelHistoryStore.neededDays(back: FuelHistoryStore.maxCachedDays)
var cache: [String: [String: [String: Double]]] = [:]
for (i, d) in days.enumerated() {
cache[d] = ["s1": ["e10": Double(100 + i)]]
}
cache["2020-01-01"] = ["s1": ["e10": 1.0]] // stale must go
let pruned = FuelHistoryStore.prunedCache(cache)
XCTAssertNil(pruned["2020-01-01"])
XCTAssertEqual(pruned.count, days.count)
}
// MARK: Mirror URLs
func testHistoryFileURLKeepsBaseLastSegment() {
// Regression: URL(string:relativeTo:) drops the base's last segment
// ("main") without a trailing slash every fetch 404'd (2026-08-15).
let base = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
XCTAssertEqual(
FuelHistoryStore.historyFileURL(day: "2026-08-15", base: base).absoluteString,
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/history/2026-08-15.json"
)
XCTAssertEqual(
FuelHistoryStore.latestFileURL(base: base).absoluteString,
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json"
)
}
}
+268
View File
@@ -0,0 +1,268 @@
// FuelHistory.swift price-history store for the Favourites Trends graph.
//
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
// The store keeps a favourites-only day cache in the app group so the chart
// works offline once a day has been seen, and only fetches the days it is
// missing. Foundation-only so the unit-test target can compile it on macOS.
//
// Payload shape (verified 2026-08-15): each day file is the raw relay
// /api/v1/stations response { fuel, count, stations_count, source,
// data_updated, stations: [{ id, name, brand, address, postcode, lat, lng,
// price, prices: {E5/E10/DIESEL: pence}, price_updated }] }.
import Foundation
// MARK: - History model
/// One price observation for a station on a calendar day (pence/litre).
struct PricePoint: Identifiable, Equatable, Codable {
let date: Date
let pence: Double
var id: String { "\(date.timeIntervalSince1970)" }
}
/// A favourite station's price series for one fuel, oldest-first.
struct StationHistory: Equatable, Identifiable {
let stationID: String
let name: String
let fuel: FuelType
let points: [PricePoint]
/// A series is unique per (station, fuel) the two keys the archive and
/// favourites are scoped by.
var id: String { "\(stationID)-\(fuel.rawValue)" }
}
/// The mirror pointer file (latest.json).
struct MirrorLatest: Codable, Equatable {
let date: String?
let stationCount: Int?
let dataUpdated: String?
let availableFrom: String?
let availableTo: String?
enum CodingKeys: String, CodingKey {
case date
case stationCount = "station_count"
case dataUpdated = "data_updated"
case availableFrom = "available_from"
case availableTo = "available_to"
}
}
enum FuelHistoryError: LocalizedError {
case unavailable
case invalidData
var errorDescription: String? {
switch self {
case .unavailable: return "Price history mirror unreachable."
case .invalidData: return "Price history data was invalid."
}
}
}
// MARK: - Store
enum FuelHistoryStore {
/// GitHub raw mirror base the app reads history directly from here;
/// the LAN relay remains the live-data fallback.
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
static let historyCacheKey = "fuelboard.historyCache"
static let maxCachedDays = 90
static let rangeOptions = [7, 30, 90]
// MARK: Pure helpers (unit-tested)
/// Calendar day string (yyyy-MM-dd) for a date, anchored at UTC noon so
/// timezone shifts never move a snapshot to the wrong day.
static func dayString(_ date: Date = Date()) -> String {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: date) ?? date
let fmt = DateFormatter()
fmt.calendar = cal
fmt.timeZone = cal.timeZone
fmt.dateFormat = "yyyy-MM-dd"
return fmt.string(from: noon)
}
/// Parse a day-string back into a Date (UTC noon), for chart x-values.
static func date(fromDay day: String) -> Date? {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let fmt = DateFormatter()
fmt.calendar = cal
fmt.timeZone = cal.timeZone
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: day)
}
/// The last `days` calendar-day strings, oldest-first, today included.
static func neededDays(back days: Int, from today: Date = Date()) -> [String] {
guard days > 0 else { return [] }
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
return (0..<days).reversed().compactMap { offset in
guard let d = cal.date(byAdding: .day, value: -offset, to: today) else { return nil }
return dayString(d)
}
}
/// Slim-decode one day file into [stationID: [FuelType: pence]] for the
/// requested station IDs only (a full decode of 8k stations per day would
/// be wasteful). Applies the same price band as the live decode.
static func parseDayStations(_ data: Data, stationIDs: Set<String>) throws -> [String: [FuelType: Double]] {
struct DayPayload: Codable {
struct S: Codable {
let id: String?
let prices: [String: Double]?
}
let stations: [S]
}
let payload = try JSONDecoder().decode(DayPayload.self, from: data)
var out: [String: [FuelType: Double]] = [:]
for s in payload.stations {
guard let id = s.id, stationIDs.contains(id), let prices = s.prices else { continue }
let mapped = FuelPriceProvider.mapGrades(prices)
if !mapped.isEmpty { out[id] = mapped }
}
return out
}
/// Builds per-station series from the day cache. Days without an entry for
/// a station are gaps (skipped) never fabricated.
static func series(fromCache cache: [String: [String: [String: Double]]],
stations: [(id: String, name: String)],
fuel: FuelType,
days: [String]) -> [StationHistory] {
stations.map { station in
let points: [PricePoint] = days.compactMap { day in
guard let pence = cache[day]?[station.id]?[fuel.rawValue] else { return nil }
guard let date = date(fromDay: day) else { return nil }
return PricePoint(date: date, pence: pence)
}
return StationHistory(stationID: station.id, name: station.name,
fuel: fuel, points: points)
}
}
/// Rebase every station's series so each day's CHEAPEST favourite sits at
/// 0 and the others show signed pence above it (mirrors the list's
/// baseline delta pattern). Days where a station has no point are gaps.
static func deltaSeries(_ series: [StationHistory]) -> [StationHistory] {
var dayMin: [Date: Double] = [:]
for s in series {
for p in s.points {
dayMin[p.date] = min(dayMin[p.date] ?? .infinity, p.pence)
}
}
return series.map { s in
StationHistory(stationID: s.stationID, name: s.name, fuel: s.fuel,
points: s.points.compactMap { p in
guard let min = dayMin[p.date] else { return nil }
return PricePoint(date: p.date, pence: p.pence - min)
})
}
}
/// Keep only the most recent `maxCachedDays` days (bounded growth).
static func prunedCache(_ cache: [String: [String: [String: Double]]],
from today: Date = Date()) -> [String: [String: [String: Double]]] {
let keep = Set(neededDays(back: maxCachedDays, from: today))
return cache.filter { keep.contains($0.key) }
}
// MARK: Cache (app-group, favourites-only)
/// Cache shape: [day: [stationID: [fuelRawValue: pence]]].
static func loadCache() -> [String: [String: [String: Double]]] {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let data = defaults.data(forKey: historyCacheKey),
let cache = try? JSONDecoder().decode([String: [String: [String: Double]]].self, from: data) else {
return [:]
}
return cache
}
static func saveCache(_ cache: [String: [String: [String: Double]]]) {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let data = try? JSONEncoder().encode(prunedCache(cache)) else { return }
defaults.set(data, forKey: historyCacheKey)
}
// MARK: URLs
/// The mirror pointer URL. Built with appendingPathComponent NOT
/// URL(string:relativeTo:), which silently drops the base's last segment
/// ("main") when the base lacks a trailing slash (found 2026-08-15: every
/// history fetch 404'd because the path resolved without /main).
static func latestFileURL(base: URL = mirrorBase) -> URL {
base.appendingPathComponent("latest.json")
}
static func historyFileURL(day: String, base: URL = mirrorBase) -> URL {
base.appendingPathComponent("history/\(day).json")
}
// MARK: Network
/// The mirror pointer used for the empty-state hint ("first snapshot
/// landed "). Non-fatal: nil just means no hint.
static func fetchLatest(base: URL = mirrorBase,
session: URLSession = .shared) async -> MirrorLatest? {
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)),
(response as? HTTPURLResponse)?.statusCode == 200,
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
return nil
}
return latest
}
/// Fetches price history for the given favourites (station id + name) and
/// fuel over the last `days` calendar days. Missing days (404, network
/// failure, station not present that day) become gaps, never errors. Uses
/// the local cache first and only fetches days it doesn't have complete.
static func fetchHistory(stations: [(id: String, name: String)],
fuel: FuelType,
days: Int,
base: URL = mirrorBase,
session: URLSession = .shared) async -> [StationHistory] {
guard !stations.isEmpty, days > 0 else { return [] }
let ids = Set(stations.map(\.id))
let dayList = neededDays(back: days)
var cache = loadCache()
// Fetch missing/incomplete days in parallel; per-day failures are
// silent gaps so one bad day never kills the whole chart.
var fetched: [(day: String, value: [String: [String: Double]])] = []
await withTaskGroup(of: (String, [String: [String: Double]]?).self) { group in
for day in dayList {
group.addTask {
if let cached = cache[day], ids.allSatisfy({ cached[$0] != nil }) {
return (day, nil) // already complete skip network
}
do {
let (data, response) = try await session.data(from: historyFileURL(day: day, base: base))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return (day, nil) }
let parsed = try parseDayStations(data, stationIDs: ids)
let encoded: [String: [String: Double]] = parsed.mapValues { prices in
Dictionary(uniqueKeysWithValues: prices.map { ($0.key.rawValue, $0.value) })
}
return (day, encoded.isEmpty ? nil : encoded)
} catch {
return (day, nil)
}
}
}
for await result in group {
if let value = result.1 { fetched.append((result.0, value)) }
}
}
for (day, value) in fetched { cache[day] = value }
saveCache(cache)
return series(fromCache: cache, stations: stations, fuel: fuel, days: dayList)
}
}
+24 -13
View File
@@ -42,6 +42,28 @@ enum FuelPriceProvider {
} }
} }
/// Price sanity band (pence/litre) anything outside is relay regression
/// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500
/// Maps relay grade keys (E5/E10/DIESEL) to FuelType with the defensive
/// band guard. Shared by the relay decode and the history mirror parser so
/// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:]
for (grade, value) in prices {
guard priceBand.contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
return result
}
/// Decodes the relay envelope metadata (source, dataset update time) the /// Decodes the relay envelope metadata (source, dataset update time) the
/// About section shows these so the user can see which data source is /// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive /// live and how fresh the GOV.UK data itself is. The fields are additive
@@ -143,20 +165,9 @@ private struct RelayResponse: Codable {
/// regression (e.g. the band being removed server-side) can't re-poison /// regression (e.g. the band being removed server-side) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage. /// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] { var allPrices: [FuelType: Double] {
var result: [FuelType: Double] = [:] var result = FuelPriceProvider.mapGrades(prices ?? [:])
if let prices {
for (grade, value) in prices {
guard (50...500).contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
}
// Backwards-compat: relay versions without `prices` still send `price`. // Backwards-compat: relay versions without `prices` still send `price`.
if result.isEmpty, let price, (50...500).contains(price) { if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price result[.e10] = price
} }
return result return result

Some files were not shown because too many files have changed in this diff Show More