diff --git a/.gitignore b/.gitignore index 7d62490..f78762c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ build-release/ +build-sim/ .DS_Store *.xcuserstate xcuserdata/ diff --git a/BACKLOG.md b/BACKLOG.md index 1c73f12..e1d1334 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index 401e242..eef6576 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -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 { - 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 + ) + } } } } diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift new file mode 100644 index 0000000..474d223 --- /dev/null +++ b/FuelBoard/TrendsView.swift @@ -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) + } +} diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index e6cf9cd..2a2d62f 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -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."; diff --git a/FuelBoardTests/Sources/FuelBoardShared/FuelHistory.swift b/FuelBoardTests/Sources/FuelBoardShared/FuelHistory.swift new file mode 120000 index 0000000..955df22 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/FuelHistory.swift @@ -0,0 +1 @@ +../../../Shared/FuelHistory.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelHistoryTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelHistoryTests.swift new file mode 100644 index 0000000..3780d29 --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelHistoryTests.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" + ) + } +} diff --git a/Shared/FuelHistory.swift b/Shared/FuelHistory.swift new file mode 100644 index 0000000..d8cd5b8 --- /dev/null +++ b/Shared/FuelHistory.swift @@ -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..) 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) + } +} diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index a9fafaf..ae60775 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -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 = 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 diff --git a/build-sim/Index.noindex/DataStore/v5/units/ContentView.o-2HA55JTYXCDBI b/build-sim/Index.noindex/DataStore/v5/units/ContentView.o-2HA55JTYXCDBI index 190c9ca..efb6249 100644 Binary files a/build-sim/Index.noindex/DataStore/v5/units/ContentView.o-2HA55JTYXCDBI and b/build-sim/Index.noindex/DataStore/v5/units/ContentView.o-2HA55JTYXCDBI differ diff --git a/build-sim/Index.noindex/DataStore/v5/units/FavouritesView.o-294OJU9LUPJH7 b/build-sim/Index.noindex/DataStore/v5/units/FavouritesView.o-294OJU9LUPJH7 index ea4a875..435f679 100644 Binary files a/build-sim/Index.noindex/DataStore/v5/units/FavouritesView.o-294OJU9LUPJH7 and b/build-sim/Index.noindex/DataStore/v5/units/FavouritesView.o-294OJU9LUPJH7 differ diff --git a/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-1G8ESDNGSHICY b/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-1G8ESDNGSHICY index b087727..97801f5 100644 Binary files a/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-1G8ESDNGSHICY and b/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-1G8ESDNGSHICY differ diff --git a/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-2634DJBJD0HCN b/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-2634DJBJD0HCN index 1e2b07c..3024e27 100644 Binary files a/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-2634DJBJD0HCN and b/build-sim/Index.noindex/DataStore/v5/units/FuelPriceProvider.o-2634DJBJD0HCN differ diff --git a/build-sim/ModuleCache.noindex/Session.modulevalidation b/build-sim/ModuleCache.noindex/Session.modulevalidation index a131ed1..c3ae4be 100644 --- a/build-sim/ModuleCache.noindex/Session.modulevalidation +++ b/build-sim/ModuleCache.noindex/Session.modulevalidation @@ -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") diff --git a/build-sim/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache b/build-sim/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache index b798611..6b62937 100644 Binary files a/build-sim/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache and b/build-sim/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache differ diff --git a/build-sim/info.plist b/build-sim/info.plist index ce3dcaa..d183652 100644 --- a/build-sim/info.plist +++ b/build-sim/info.plist @@ -3,7 +3,7 @@ LastAccessedDate - 2026-08-14T22:47:15Z + 2026-08-15T09:07:46Z WorkspacePath /Users/apt/workspace/fuelboard/FuelBoard.xcodeproj