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.
This commit is contained in:
FuelBoard Contributor
2026-08-15 10:12:23 +01:00
parent 5b83d9f510
commit aab2d02ef5
16 changed files with 777 additions and 20 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
+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
)
}
}
} }
} }
+251
View File
@@ -0,0 +1,251 @@
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 {
if isLoading {
Spacer()
ProgressView("Fetching price history…")
Spacer()
} else if loadFailed {
Spacer()
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)
}
Spacer()
} else if !hasAnyData {
emptyState
} else if !hasEnoughData {
emptyState // single point nothing to draw yet
} else {
VStack(spacing: 12) {
chart
legend
}
}
}
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
@@ -1 +1 @@
1786747638.063251: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-sim/ModuleCache.noindex") 1786784868.9666538: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-sim/ModuleCache.noindex")
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LastAccessedDate</key> <key>LastAccessedDate</key>
<date>2026-08-14T22:47:15Z</date> <date>2026-08-15T09:07:46Z</date>
<key>WorkspacePath</key> <key>WorkspacePath</key>
<string>/Users/apt/workspace/fuelboard/FuelBoard.xcodeproj</string> <string>/Users/apt/workspace/fuelboard/FuelBoard.xcodeproj</string>
</dict> </dict>