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
+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)
}
}