- 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.
269 lines
11 KiB
Swift
269 lines
11 KiB
Swift
// 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)
|
|
}
|
|
}
|