Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54aca14fd4 | ||
|
|
d1fbac3f05 | ||
|
|
6cbf1dfc8f | ||
|
|
494412ba99 | ||
|
|
d0d25811c7 | ||
|
|
aab2d02ef5 |
@@ -1,5 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
build-release/
|
build-release/
|
||||||
|
build-sim/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -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") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
EditButton()
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showTrends = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chart.xyaxis.line")
|
||||||
|
.accessibilityLabel("Trends")
|
||||||
|
}
|
||||||
|
EditButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showTrends) {
|
||||||
|
TrendsView(
|
||||||
|
favourites: favourites,
|
||||||
|
selectedFuel: activeFuel,
|
||||||
|
priceDisplayStyle: priceDisplayStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||