// 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) } } /// Average pence over a series' points; nil when there are no points /// (a station with no data shows no bracket figure in the chart key). static func averagePence(_ points: [PricePoint]) -> Double? { guard !points.isEmpty else { return nil } return points.reduce(0) { $0 + $1.pence } / Double(points.count) } /// 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) } }