Settings About: data source section (connection, station count, GOV.UK update time)
- Relay envelope now carries source (api|csv), stations_count, and data_updated (freshest price_last_updated the GOV.UK server reports, independent of relay sync time). - App decodes the new envelope metadata (RelayMeta), persists it after each full fetch, and shows it in Settings → About → Data source: Connection (API/CSV), Stations count, Data updated (local-formatted). - Older relays without the fields decode cleanly (nil meta → em-dash). - 2 new tests for meta decode + absent-meta tolerance; 37/37 pass.
This commit is contained in:
@@ -293,6 +293,11 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
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
|
// Keep the keychain favourites fresh with the new prices — the
|
||||||
// widget's Favourites mode reads them from keychain (the only
|
// widget's Favourites mode reads them from keychain (the only
|
||||||
// channel shared on SideStore free), so stale star-time snapshots
|
// channel shared on SideStore free), so stale star-time snapshots
|
||||||
|
|||||||
@@ -121,6 +121,33 @@ struct SettingsView: View {
|
|||||||
Text("A small tip helps keep the data relay and app development going. Thank you!")
|
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 {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Version")
|
Text("Version")
|
||||||
@@ -252,6 +279,40 @@ struct SettingsView: View {
|
|||||||
return "\(version) (\(build))"
|
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.
|
/// 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
|
/// Nothing in the UI advertises this — the Debug section simply appears or
|
||||||
/// disappears. Deliberately NOT a visible switch so end users never see it.
|
/// disappears. Deliberately NOT a visible switch so end users never see it.
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ final class PriceGuardTests: XCTestCase {
|
|||||||
XCTAssertEqual(stations[0].prices[.diesel], 142.9)
|
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 {
|
func testOutOfBandPricesDropped() throws {
|
||||||
let json = """
|
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}}]}
|
{"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}}]}
|
||||||
|
|||||||
@@ -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)
|
// MARK: - Relay provider (default)
|
||||||
@@ -51,6 +66,11 @@ enum FuelPriceProvider {
|
|||||||
struct RelayFuelProvider: FuelPriceProviding {
|
struct RelayFuelProvider: FuelPriceProviding {
|
||||||
var baseURL = URL(string: "http://192.168.1.131:8789")!
|
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] {
|
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 components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
|
||||||
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
|
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 {
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
throw FuelProviderError.relayUnavailable
|
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 {
|
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 {
|
struct RelayStation: Codable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let name: String?
|
let name: String?
|
||||||
@@ -117,6 +151,15 @@ private struct RelayResponse: Codable {
|
|||||||
let stations: [RelayStation]
|
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)
|
// MARK: - Sample provider (offline fallback only)
|
||||||
|
|
||||||
/// Representative stations spread across ENGLAND (one cluster per region) so
|
/// Representative stations spread across ENGLAND (one cluster per region) so
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ struct FuelStore {
|
|||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
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
|
// MARK: Stations
|
||||||
// The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only —
|
// 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)
|
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
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
/// should be skipped (twice-a-day policy).
|
/// should be skipped (twice-a-day policy).
|
||||||
static var isCacheFresh: Bool {
|
static var isCacheFresh: Bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user