diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 859bd11..a732b56 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -293,6 +293,11 @@ struct ContentView: View { stations = fetched FuelStore.saveStations(fetched) FuelStore.saveLastRefresh() + // Persist relay envelope metadata (source, station count, GOV.UK + // dataset update time) for the Settings → About section. + if let meta = RelayFuelProvider.latestMeta { + FuelStore.saveRelayMeta(meta) + } // Keep the keychain favourites fresh with the new prices — the // widget's Favourites mode reads them from keychain (the only // channel shared on SideStore free), so stale star-time snapshots diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index a60f541..a4e1499 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -121,6 +121,33 @@ struct SettingsView: View { Text("A small tip helps keep the data relay and app development going. Thank you!") } + Section { + HStack { + Text("Connection") + Spacer() + Text(connectionText) + .foregroundStyle(.secondary) + } + HStack { + Text("Stations") + Spacer() + Text(stationCountText) + .foregroundStyle(.secondary) + .monospacedDigit() + } + HStack { + Text("Data updated") + Spacer() + Text(dataUpdatedText) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } header: { + Text("Data source") + } footer: { + Text("Connection shows whether prices come from the official Fuel Finder API or the CSV mirror. Data updated is the latest price change reported by the GOV.UK server itself.") + } + Section { HStack { Text("Version") @@ -252,6 +279,40 @@ struct SettingsView: View { return "\(version) (\(build))" } + /// "API" when the relay is serving the official Fuel Finder API, "CSV" + /// when it fell back to the public mirror, "—" when unknown/never fetched. + private var connectionText: String { + switch FuelStore.loadRelaySource()?.lowercased() { + case "api": return "API" + case "csv": return "CSV" + default: return "—" + } + } + + private var stationCountText: String { + guard let count = FuelStore.loadStationCount() else { return "—" } + return count.formatted() + } + + /// GOV.UK server's own dataset update timestamp (ISO 8601 from the API, + /// e.g. 2026-08-12T10:23:00.000Z). Shown as a local date/time — it is the + /// data's own freshness, not the relay's sync time. + private var dataUpdatedText: String { + guard let raw = FuelStore.loadDataUpdated(), !raw.isEmpty else { return "—" } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { + return date.formatted(date: .abbreviated, time: .shortened) + } + // Some sources send "YYYY-MM-DDTHH:MM:SS" without fractional seconds + // or timezone — try the plain form before showing the raw string. + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: raw) { + return date.formatted(date: .abbreviated, time: .shortened) + } + return raw + } + /// Hidden debug-mode toggle: five taps on the Version row flips the flag. /// Nothing in the UI advertises this — the Debug section simply appears or /// disappears. Deliberately NOT a visible switch so end users never see it. diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 1e81170..38e9a25 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -62,6 +62,27 @@ final class PriceGuardTests: XCTestCase { XCTAssertEqual(stations[0].prices[.diesel], 142.9) } + func testRelayMetaDecoded() throws { + let json = """ + {"source":"api","stations_count":8010,"data_updated":"2026-08-12T10:23:00.000Z","stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]} + """ + let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8)) + XCTAssertEqual(meta?.source, "api") + XCTAssertEqual(meta?.stationCount, 8010) + XCTAssertEqual(meta?.dataUpdated, "2026-08-12T10:23:00.000Z") + } + + func testRelayMetaAbsentIsNil() throws { + // Older relay without envelope metadata must not fail decode — nil meta. + let json = """ + {"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]} + """ + let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8)) + XCTAssertNil(meta?.source) + XCTAssertNil(meta?.stationCount) + XCTAssertNil(meta?.dataUpdated) + } + func testOutOfBandPricesDropped() throws { let json = """ {"stations":[{"id":"s1","name":"GARBAGE","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":1.3,"E5":1589.0,"DIESEL":137.9}}]} diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index d96efd2..f769ec6 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -41,6 +41,21 @@ enum FuelPriceProvider { ) } } + + /// 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 + /// on the relay, so older relays simply return nil values. + static func decodeRelayMeta(from data: Data) -> RelayMeta? { + guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else { + return nil + } + return RelayMeta( + source: payload.source, + stationCount: payload.stationsCount, + dataUpdated: payload.dataUpdated + ) + } } // MARK: - Relay provider (default) @@ -51,6 +66,11 @@ enum FuelPriceProvider { struct RelayFuelProvider: FuelPriceProviding { var baseURL = URL(string: "http://192.168.1.131:8789")! + /// Envelope metadata from the most recent successful relay fetch — + /// consumed by Settings → About (source api/csv, station count, GOV.UK + /// dataset update time). Written by fetchStations, read by the view. + static var latestMeta: RelayMeta? + func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)! var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)] @@ -73,11 +93,25 @@ struct RelayFuelProvider: FuelPriceProviding { guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw FuelProviderError.relayUnavailable } - return try FuelPriceProvider.decodeStations(from: data) + let stations = try FuelPriceProvider.decodeStations(from: data) + Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) + return stations } } private struct RelayResponse: Codable { + /// Envelope metadata surfaced in Settings → About so the user can see + /// which relay source is live and how fresh the GOV.UK data is. + let source: String? + let stationsCount: Int? + let dataUpdated: String? + + enum CodingKeys: String, CodingKey { + case source, stations + case stationsCount = "stations_count" + case dataUpdated = "data_updated" + } + struct RelayStation: Codable { let id: String? let name: String? @@ -117,6 +151,15 @@ private struct RelayResponse: Codable { let stations: [RelayStation] } +/// Envelope metadata from the relay response — which data source is live +/// (api/csv) and the freshest price-update timestamp the GOV.UK server +/// itself reports for the dataset. Displayed in Settings → About. +struct RelayMeta: Codable, Equatable { + let source: String? + let stationCount: Int? + let dataUpdated: String? +} + // MARK: - Sample provider (offline fallback only) /// Representative stations spread across ENGLAND (one cluster per region) so diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 61c5107..4344a9a 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -264,6 +264,9 @@ struct FuelStore { static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) + static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" + static let stationCountKey = "fuelboard.stationCount" // String — station count + static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time // MARK: Stations // The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only — @@ -503,6 +506,34 @@ struct FuelStore { saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) } + // MARK: Relay metadata — shown in Settings → About. Written after each + // successful full fetch so the About section reflects the live source. + + static func saveRelayMeta(_ meta: RelayMeta) { + if let source = meta.source { + saveString(source, service: relaySourceKey) + } + if let count = meta.stationCount { + saveString(String(count), service: stationCountKey) + } + if let updated = meta.dataUpdated { + saveString(updated, service: dataUpdatedKey) + } + } + + static func loadRelaySource() -> String? { + loadString(service: relaySourceKey) + } + + static func loadStationCount() -> Int? { + guard let raw = loadString(service: stationCountKey) else { return nil } + return Int(raw) + } + + static func loadDataUpdated() -> String? { + loadString(service: dataUpdatedKey) + } + /// True when the cached data is fresh enough that a scheduled auto-refresh /// should be skipped (twice-a-day policy). static var isCacheFresh: Bool {