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:
@@ -1,5 +1,6 @@
|
||||
build/
|
||||
build-release/
|
||||
build-sim/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
+15
-4
@@ -39,8 +39,10 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
(script ~/.hermes/scripts/mirror_push.py, log
|
||||
~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start
|
||||
com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200.
|
||||
REMAINING: app-side read (provider chain GitHub → relay → bundled dump) + the
|
||||
telemetry beacon.*
|
||||
REMAINING: live provider chain (GitHub → relay → bundled dump for CURRENT
|
||||
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
|
||||
|
||||
@@ -56,7 +58,7 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
|
||||
- [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
|
||||
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
|
||||
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
|
||||
@@ -64,7 +66,16 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ struct FavouritesView: View {
|
||||
/// favourites (e.g. the last one is un-starred while viewing it).
|
||||
@State private var fuel: FuelType = .e10
|
||||
|
||||
/// Trends sheet (price history chart) presentation state.
|
||||
@State private var showTrends = false
|
||||
|
||||
private var activeFuel: FuelType {
|
||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||
}
|
||||
@@ -140,9 +143,24 @@ struct FavouritesView: View {
|
||||
.navigationTitle("Favourites")
|
||||
.toolbar {
|
||||
if !favourites.isEmpty {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -134,3 +134,17 @@
|
||||
|
||||
/* Station row */
|
||||
"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
|
||||
/// 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
|
||||
@@ -143,20 +165,9 @@ private struct RelayResponse: Codable {
|
||||
/// regression (e.g. the band being removed server-side) can't re-poison
|
||||
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
|
||||
var allPrices: [FuelType: Double] {
|
||||
var result: [FuelType: Double] = [:]
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = FuelPriceProvider.mapGrades(prices ?? [:])
|
||||
// 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
|
||||
}
|
||||
return result
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
|
||||
BIN
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastAccessedDate</key>
|
||||
<date>2026-08-14T22:47:15Z</date>
|
||||
<date>2026-08-15T09:07:46Z</date>
|
||||
<key>WorkspacePath</key>
|
||||
<string>/Users/apt/workspace/fuelboard/FuelBoard.xcodeproj</string>
|
||||
</dict>
|
||||
|
||||
Reference in New Issue
Block a user