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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user