From d7a0ecb79a6147157e66c6f4e3dc56cbb0391e6f Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 11 Oct 2026 14:34:53 +0100 Subject: [PATCH] Add Worth the Drive fuel savings with road-routed estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.). The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving. Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests. --- FuelBoard/ContentView.swift | 313 +++++++++++++- FuelBoard/FavouritesView.swift | 38 +- FuelBoard/FuelSavingsViews.swift | 140 +++++++ FuelBoard/KeyboardSupport.swift | 41 ++ FuelBoard/RoadDistanceService.swift | 98 ++++- FuelBoard/SettingsView.swift | 94 +++++ FuelBoard/StationDetailView.swift | 13 + FuelBoard/StationsView.swift | 45 +- .../Sources/FuelBoardShared/FuelSavings.swift | 1 + .../FuelSavingsEstimateTests.swift | 377 +++++++++++++++++ .../FuelSavingsRoutingTests.swift | 37 ++ .../FuelSavingsTests.swift | 93 +++++ .../FuelStoreFileCacheTests.swift | 69 ++++ FuelBoardWidgets/FuelPriceWidget.swift | 5 +- Shared/FuelSavings.swift | 388 ++++++++++++++++++ Shared/FuelStore.swift | 145 ++++++- Shared/MirrorFuelProvider.swift | 14 + 17 files changed, 1865 insertions(+), 46 deletions(-) create mode 100644 FuelBoard/FuelSavingsViews.swift create mode 100644 FuelBoard/KeyboardSupport.swift create mode 120000 FuelBoardTests/Sources/FuelBoardShared/FuelSavings.swift create mode 100644 FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift create mode 100644 FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsRoutingTests.swift create mode 100644 FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift create mode 100644 FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreFileCacheTests.swift create mode 100644 Shared/FuelSavings.swift diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 4741c88..ef723e7 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -22,6 +22,8 @@ struct ContentView: View { @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle() @State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction() + @State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings() + @State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount @State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @@ -63,15 +65,42 @@ struct ContentView: View { private func refreshRoadDistancesIfNeeded() { let origin = location ?? FuelStore.loadLocation() guard let origin else { return } + let priority = worthTheDriveRoutingPriorities(location: origin) Task { await RoadDistanceService.refreshIfNeeded( stations: stations, lat: origin.lat, - lng: origin.lng + lng: origin.lng, + prioritising: priority ) } } + /// The stations worth spending Apple Maps route requests on: the best + /// Worth-the-Drive candidates by estimated net saving, plus the comparison + /// baseline (every other row is measured against it). + /// + /// Ranking is by net saving, so these are typically FURTHER out than the + /// nearest stations — the ones nearest-only routing never reached, which is + /// why their rows stayed on straight-line estimates. + private func worthTheDriveRoutingPriorities(location: Coordinate) -> [FuelStation] { + guard fuelSavingsSettings.isConfigured else { return [] } + let baseline = nearestSavingsBaseline + let ranked = FuelSavingsEstimator.ranked( + poolStations, + fuel: selectedFuel, + baseline: baseline, + settings: fuelSavingsSettings, + amount: sessionFuelSavingsAmount, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } + ) + var priorities = Array(ranked.prefix(RoadDistanceService.priorityCandidates)) + if let baseline, !priorities.contains(where: { $0.id == baseline.id }) { + priorities.insert(baseline, at: 0) + } + return priorities + } + private func ensureBackgroundMotionTrackingIfNeeded() { if alertsEnabled || liveActivityEnabled { locationManager.startBackgroundTracking() @@ -113,11 +142,11 @@ struct ContentView: View { @State private var bannerOffset: CGFloat = 0 @State private var bannerClearTask: Task? - /// The pool the list draws from. In Cheapest mode the chosen miles radius - /// bounds it ("best price within X miles"); in Closest mode the radius is - /// redundant — the whole country sorted nearest-first, because "nearest" - /// must never answer with an empty state. STRICT: no fallback to - /// out-of-radius stations in Cheapest mode. + /// The pool the list draws from. In Cheapest and Worth-the-Drive modes the + /// chosen miles radius bounds it ("best price within X miles"); in Closest + /// mode the radius is redundant — the whole country sorted nearest-first, + /// because "nearest" must never answer with an empty state. STRICT: no + /// fallback to out-of-radius stations in radius-bound modes. private var poolStations: [FuelStation] { let selling = stations.filter { $0.prices[selectedFuel] != nil } guard let location else { return selling } @@ -180,7 +209,7 @@ struct ContentView: View { private var sortedStations: [FuelStation] { let pool = poolStations - switch sortMode { + switch effectiveSortMode { case .closest: guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } } return pool.sorted { lhs, rhs in @@ -200,9 +229,165 @@ struct ContentView: View { return lhs.distanceKM(to: location.lat, lng2: location.lng) < rhs.distanceKM(to: location.lat, lng2: location.lng) } + case .worthTheDrive: + return sortedForWorthTheDrive(pool) } } + /// Row/detail savings lookup with the comparison baseline resolved ONCE. + /// + /// `nearestSavingsBaseline` scans the whole station list, and StationsView + /// asks for savings once per visible row (plus again for the detail sheet), + /// so handing the view a per-station method would rescan ~8k stations + /// dozens of times per render. + private func savingsLookup(baseline: FuelStation?) -> (FuelStation) -> FuelSavingsAvailability? { + guard fuelSavingsSettings.isConfigured else { return { _ in nil } } + guard let location else { return { _ in .unavailable("Location unavailable") } } + let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) } + return { station in + FuelSavingsEstimator.availability( + station: station, + fuel: selectedFuel, + baseline: baseline, + settings: fuelSavingsSettings, + amount: sessionFuelSavingsAmount, + journey: savingsJourneyMiles(for: station, location: location), + baselineJourney: baselineJourney + ) + } + } + + /// The nearest station that sells the selected fuel — the Worth-the-Drive + /// comparison baseline. + /// + /// Single O(n) pass on purpose: `min(by:)` re-evaluates its closure twice + /// per comparison, and each evaluation reads the road-distance cache. With + /// the full UK list that is thousands of redundant lookups per render, all + /// on the main thread during scene creation (see `loadRoadDistances`). + private var nearestSavingsBaseline: FuelStation? { + guard let location else { return nil } + var bestStation: FuelStation? + var bestMiles = Double.infinity + for station in stations where station.prices[selectedFuel] != nil { + let miles = savingsFallbackMiles(for: station, location: location) + if miles < bestMiles { + bestMiles = miles + bestStation = station + } + } + return bestStation + } + + /// The sort the UI and the list actually use. + /// + /// The Worth-the-Drive sort is only offered while Fuel Savings is + /// configured, but the chosen sort is persisted — so a stored + /// `.worthTheDrive` would otherwise keep the fill-up control on screen (and + /// leave the picker on a mode it no longer lists) after the feature is + /// switched off. Fall back to Cheapest for RENDER ONLY: the stored + /// preference is left untouched, so re-enabling the feature restores the + /// user's choice. + private var effectiveSortMode: SortMode { + (sortMode == .worthTheDrive && !fuelSavingsSettings.isConfigured) ? .cheapest : sortMode + } + + /// Journey (out and back) miles for a station: the measured road route when + /// one has been fetched, otherwise a straight-line out-and-back estimate, so + /// that every station the app lists can carry a saving. + private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? { + if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) { + return FuelSavingsJourney(miles: roadMeters / 1609.34, basis: .roadRoute) + } + return FuelSavingsJourney( + miles: savingsFallbackMiles(for: station, location: location), + basis: .straightLine + ) + } + + private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double { + FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) * 0.621371 * 2 + } + + private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] { + guard fuelSavingsSettings.isConfigured, let location else { + #if DEBUG + print("FB-DRIVE skipped configured=\(fuelSavingsSettings.isConfigured) location=\(self.location == nil ? "none" : "ok") pool=\(pool.count)") + #endif + return pool.sorted { ($0.prices[selectedFuel] ?? .infinity) < ($1.prices[selectedFuel] ?? .infinity) } + } + // Every station in the pool is ranked, with a straight-line estimate + // until its road route has been measured. Ranking only a road-route + // shortlist hid cheaper stations a few miles further out and left later + // pages with no savings (see `FuelSavingsEstimator.ranked`). + let baseline = nearestSavingsBaseline + let ordered = FuelSavingsEstimator.ranked( + pool, + fuel: selectedFuel, + baseline: baseline, + settings: fuelSavingsSettings, + amount: sessionFuelSavingsAmount, + journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } + ) + #if DEBUG + logWorthTheDriveSummary(ordered, baseline: baseline, location: location) + #endif + return ordered + } + + #if DEBUG + /// Last FB-DRIVE line printed, so the ranking (which runs on every render) + /// only reports when the result actually changes. + private static var lastDriveLogLine: String? + + /// One summary line per ranking pass (FB-DRIVE) so a device console shows + /// whether the Worth the Drive list is actually carrying savings for the + /// whole pool — the regression was silent otherwise. + private func logWorthTheDriveSummary(_ ordered: [FuelStation], + baseline: FuelStation?, + location: Coordinate) { + let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) } + var rows: [(station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)] = [] + for station in ordered { + // Capture the journey ONCE and print that same value: routing runs + // in the background, so re-reading it for the log would mix a stale + // journey into a fresh figure and make the numbers unreadable. + let journey = savingsJourneyMiles(for: station, location: location) + guard let availability = FuelSavingsEstimator.availability( + station: station, + fuel: selectedFuel, + baseline: baseline, + settings: fuelSavingsSettings, + amount: sessionFuelSavingsAmount, + journey: journey, + baselineJourney: baselineJourney + ), let result = availability.result else { continue } + rows.append((station, result, journey)) + } + func describe(_ row: (station: FuelStation, result: FuelSavingsResult, journey: FuelSavingsJourney?)) -> String { + let price = row.station.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a" + return "\"\(row.station.name)\" p=\(price) j=\(String(format: "%.1f", row.journey?.miles ?? 0))mi(\(row.journey?.basis.rawValue ?? "none")) gross=\(String(format: "%.2f", row.result.grossSavingGBP)) extra=\(String(format: "%.2f", row.result.extraDrivingCostGBP)) extraMi=\(String(format: "%.1f", row.result.extraDistanceMiles)) net=\(String(format: "%.2f", row.result.netSavingGBP)) \"\(row.result.headline())\"" + } + let baselineLine: String + if let baseline, let baselineJourney { + let price = baseline.prices[selectedFuel].map { String(format: "%.1fp", $0) } ?? "n/a" + baselineLine = "baseline=\"\(baseline.name)\" p=\(price) j=\(String(format: "%.1f", baselineJourney.miles))mi(\(baselineJourney.basis.rawValue))" + } else { + baselineLine = "baseline=none" + } + let leaders = rows.prefix(3).map(describe).joined(separator: " | ") + let line = "FB-DRIVE pool=\(ordered.count) withSavings=\(rows.count) \(baselineLine) | \(leaders)" + guard ContentView.lastDriveLogLine != line else { return } + ContentView.lastDriveLogLine = line + print(line) + // `FB_DRIVE_FOCUS=` prints one named station's row too, so a + // specific complaint can be checked against real routed numbers. + if let focus = ProcessInfo.processInfo.environment["FB_DRIVE_FOCUS"], !focus.isEmpty, + let row = rows.first(where: { $0.station.name.localizedCaseInsensitiveContains(focus) }) { + print("FB-DRIVE focus \(describe(row))") + } + } + #endif + /// Station IDs favourited for the CURRENTLY selected fuel — the star on a /// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites). private var favouriteIDs: Set { @@ -320,6 +505,26 @@ struct ContentView: View { selectedFuel = f FuelStore.saveSelectedFuel(f) } + // QA temp: `FB_DRIVE_DEBUG=1` (DEBUG builds only) forces the + // optional Fuel Savings feature ON and selects the Worth the Drive + // sort, so the ranking can be exercised from the device console + // (FB-DRIVE) without tapping through Settings. + // + // It must NOT substitute the stored MPG / fill amount: doing that + // planted the DEFAULT values (45 MPG) over the stored ones and made + // every logged figure wrong — the exact opposite of the hook's + // purpose. Only `enabled` is overridden; the store is still the + // source for MPG and fill. + // + // An ENVIRONMENT variable, not a launch argument: devicectl clusters + // dash-options per character, so a word like `-worthTheDrive` is + // parsed as its own `-t` (--timeout) and the launch is rejected. + if ProcessInfo.processInfo.environment["FB_DRIVE_DEBUG"] == "1" { + var forced = FuelStore.loadFuelSavingsSettings() + forced.enabled = true + fuelSavingsSettings = forced + sortMode = .worthTheDrive + } // `-forceOfflineDump` / `-forceConnectionProblem` simulate the two // failure legs for the screenshot harness. The auto-refresh below // is skipped so the banner stays up (a live fetch would clear it). @@ -335,6 +540,11 @@ struct ContentView: View { } #else showWidgetMock = false +#endif + // Diagnostic only: `effectiveSortMode` already handles a stored + // `.worthTheDrive` that the feature can no longer back. +#if DEBUG + print("FB-SAVINGS launch storedSort=\(sortMode.displayName) effective=\(effectiveSortMode.displayName) configured=\(fuelSavingsSettings.isConfigured) enabled=\(fuelSavingsSettings.enabled) mpg=\(fuelSavingsSettings.mpg) amount=\(fuelSavingsSettings.preferredAmount.value)") #endif // Onboarding runs first on a fresh install — it owns the initial // permission prompts (location, notifications, and the data/local @@ -399,6 +609,13 @@ struct ContentView: View { Task { await refresh(force: true) } } } + .onChange(of: fuelSavingsSettings) { old, new in + // Fuel Savings changed in Settings while the app was running: the + // effective sort follows automatically (no stored sort is touched). +#if DEBUG + print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)") +#endif + } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { // Never start location tracking while onboarding is on screen — @@ -754,12 +971,13 @@ struct ContentView: View { /// The Stations tab, extracted from `body` so the TabView expression stays /// within the compiler's type-check budget. private var stationsTab: some View { - StationsView( + let baselineStation = nearestSavingsBaseline + return StationsView( stations: displayedStations, totalCount: sortedStations.count, isLoading: isLoading, selectedFuel: $selectedFuel, - sortMode: $sortMode, + sortMode: Binding(get: { effectiveSortMode }, set: { sortMode = $0 }), stationLimit: $stationLimit, distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, @@ -768,6 +986,10 @@ struct ContentView: View { location: location, favouriteIDs: favouriteIDs, stationTapAction: stationTapAction, + fuelSavingsSettings: fuelSavingsSettings, + fuelSavingsAmount: $sessionFuelSavingsAmount, + savingsForStation: savingsLookup(baseline: baselineStation), + savingsBaselineStationName: baselineStation?.name, onToggleFavourite: toggleFavourite, onRefresh: { await refresh(force: true) } ) @@ -776,13 +998,18 @@ struct ContentView: View { /// The Favourites tab, extracted from `body` for the same type-check /// budget reason. private var favouritesTab: some View { - FavouritesView( + let baselineStation = nearestSavingsBaseline + return FavouritesView( favourites: refreshedFavourites, selectedFuel: selectedFuel, location: location, distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, stationTapAction: stationTapAction, + fuelSavingsSettings: fuelSavingsSettings, + fuelSavingsAmount: $sessionFuelSavingsAmount, + savingsForStation: savingsLookup(baseline: baselineStation), + savingsBaselineStationName: baselineStation?.name, onToggleFavourite: toggleFavourite, onReorder: reorderFavourites, onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } }, @@ -822,6 +1049,7 @@ struct ContentView: View { distanceUnit: $distanceUnit, priceDisplayStyle: $priceDisplayStyle, stationTapAction: $stationTapAction, + fuelSavingsSettings: $fuelSavingsSettings, dataRefreshMode: $dataRefreshMode, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, @@ -874,12 +1102,56 @@ struct ContentView: View { await refresh(force: true) } + /// Shows whatever prices we already have BEFORE the network call settles, so + /// a refresh never hides data behind a full-screen spinner. It deliberately + /// does NOT touch `dataStatus`: a fetch that is still in flight must not + /// flash an offline/connection banner, and a successful fetch clears the + /// state anyway. The catch path below labels the state honestly ONLY if the + /// fetch really fails. + private func showFallbackStationsWhileRefreshing() { + guard stations.isEmpty else { return } + if let cached = MirrorFuelProvider.cachedStations( + near: location?.lat, + lng: location?.lng, + fuel: selectedFuel, + radiusKM: nil + ), !cached.isEmpty { + stations = cached + return + } + let saved = FuelStore.loadStations() + if !saved.isEmpty { + stations = saved + return + } + // Bundled snapshot — real but possibly dated. Shown so the list is + // never blank while the fetch is in flight; if the fetch fails, the + // catch path re-labels it with the honest bundled-dump date. + if let dump = BundledDumpProvider.stations, !dump.isEmpty { + stations = dump + } + } + /// Fetches fresh prices, but only when the cache is stale — unless /// `force` is true (pull-to-refresh is the manual override). private func refresh(force: Bool = false) async { - if !force, FuelStore.isCacheFresh { + // Freshness ALONE must never skip the fetch: `lastRefresh` is stored + // keychain-first (survives reinstall) while the station file lives in + // the App Group container (wiped on reinstall). A "fresh" cache with + // no cached stations used to short-circuit here and leave the list + // empty for ever after every reinstall. Only skip when there is + // genuinely something to show. + let cachedCount = FuelStore.loadStations().count + if !force, FuelStore.isCacheFresh, cachedCount > 0 { + #if DEBUG + print("FB-REFRESH skip: cache fresh, \(cachedCount) cached stations") + #endif return // data already fresh — skip network entirely } + #if DEBUG + print("FB-REFRESH start force=\(force) cached=\(cachedCount)") + #endif + showFallbackStationsWhileRefreshing() isLoading = true defer { isLoading = false } do { @@ -890,6 +1162,9 @@ struct ContentView: View { radiusKM: nil // full-UK dump; device filters by miles radius ) stations = fetched + #if DEBUG + print("FB-REFRESH ok: \(fetched.count) stations from \(LiveChainProvider.lastSource ?? "?")") + #endif FuelStore.saveStations(fetched) FuelStore.saveLastRefresh() if force, FuelStore.hasPendingWatchRefreshRequest { @@ -918,6 +1193,9 @@ struct ContentView: View { // Live data restored — any status banner no longer applies. dataStatus = .live } catch { + #if DEBUG + print("FB-REFRESH fail: \(error)") + #endif if force, FuelStore.hasPendingWatchRefreshRequest { FuelStore.markWatchRefreshHandled() } @@ -959,7 +1237,11 @@ struct StationRow: View { let baselinePrice: Double? let isTopResult: Bool let isFavourite: Bool + /// Label for the cheapest-pump-price badge ("TOP" in price/distance order, + /// "CHEAPEST" when the list is ranked by net saving). + var topBadgeText: String = "TOP" var tapAction: StationTapAction = .openMap + var fuelSavings: FuelSavingsAvailability? var onToggleFavourite: () -> Void = {} var onShowDetails: (FuelStation) -> Void = { _ in } @@ -1025,7 +1307,11 @@ struct StationRow: View { .lineLimit(1) .truncationMode(.tail) if isTopResult { - Text("TOP") + // The badge always marks the CHEAPEST PUMP PRICE (never + // "best saving"), so in Worth the Drive order — where + // the top row is the best net saving — it may sit lower + // down the list, and says so. + Text(topBadgeText) .font(.caption2.bold()) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -1050,6 +1336,9 @@ struct StationRow: View { .foregroundStyle(.secondary) .monospacedDigit() } + if fuelSavings != nil { + FuelSavingsSummaryView(availability: fuelSavings, amount: .init(value: 0, unit: .pounds), baselineStationName: nil, compact: true) + } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index 6289da6..b4ae646 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -15,6 +15,16 @@ struct FavouritesView: View { let distanceUnit: DistanceUnit let priceDisplayStyle: PriceDisplayStyle let stationTapAction: StationTapAction + /// Fuel Savings config. When it is configured this tab shows the SAME + /// fill-up control and per-row estimate as the Stations list: one session + /// amount is shared, so adjusting it in either list updates both, and it + /// still never touches the Settings default. + let fuelSavingsSettings: FuelSavingsSettings + @Binding var fuelSavingsAmount: FuelSavingsFillAmount + /// Estimated saving for a favourite, against the nearest eligible station — + /// the same baseline and definition the Stations list uses. + var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } + var savingsBaselineStationName: String? var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } /// Persists a reordered favourites array (after drag-and-drop). var onReorder: ([FavouriteEntry]) -> Void = { _ in } @@ -70,6 +80,10 @@ struct FavouritesView: View { distanceUnit: DistanceUnit, priceDisplayStyle: PriceDisplayStyle, stationTapAction: StationTapAction, + fuelSavingsSettings: FuelSavingsSettings = .defaults, + fuelSavingsAmount: Binding, + savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil }, + savingsBaselineStationName: String? = nil, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }, onHistoryUnavailable: (() -> Void)? = nil, @@ -80,6 +94,10 @@ struct FavouritesView: View { self.distanceUnit = distanceUnit self.priceDisplayStyle = priceDisplayStyle self.stationTapAction = stationTapAction + self.fuelSavingsSettings = fuelSavingsSettings + _fuelSavingsAmount = fuelSavingsAmount + self.savingsForStation = savingsForStation + self.savingsBaselineStationName = savingsBaselineStationName self.onToggleFavourite = onToggleFavourite self.onReorder = onReorder self.onHistoryUnavailable = onHistoryUnavailable @@ -124,6 +142,17 @@ struct FavouritesView: View { ) } + if fuelSavingsSettings.isConfigured { + Section { + FuelSavingsAdjustmentView( + amount: $fuelSavingsAmount, + referencePricePencePerLitre: cheapestPrice + ) + } footer: { + Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation")) + } + } + Section { ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in StationRow( @@ -133,9 +162,11 @@ struct FavouritesView: View { distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, baselinePrice: cheapestPrice, - isTopResult: index == 0, + isTopResult: station.id == cheapest?.id, isFavourite: activeFuelFavouriteIDs.contains(station.id), + topBadgeText: "CHEAPEST", tapAction: stationTapAction, + fuelSavings: savingsForStation(station), onToggleFavourite: { onToggleFavourite(station, activeFuel) }, onShowDetails: { detailStation = $0 } ) @@ -183,7 +214,10 @@ struct FavouritesView: View { StationDetailView( station: station, location: location, - distanceUnit: distanceUnit + distanceUnit: distanceUnit, + fuelSavings: savingsForStation(station), + fuelSavingsAmount: fuelSavingsAmount, + savingsBaselineStationName: savingsBaselineStationName ) } .onAppear { diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift new file mode 100644 index 0000000..df0f392 --- /dev/null +++ b/FuelBoard/FuelSavingsViews.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FuelSavingsAdjustmentView: View { + @Binding var amount: FuelSavingsFillAmount + let referencePricePencePerLitre: Double? + + @State private var textValue: String = "" + + private var range: ClosedRange { + amount.unit == .pounds ? 10...100 : 10...80 + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("How much are you filling up?") + .font(.subheadline.weight(.semibold)) + Picker("Fill-up unit", selection: unitBinding) { + ForEach(FuelSavingsFillUnit.allCases) { unit in + Text(unit.displayName).tag(unit) + } + } + .pickerStyle(.segmented) + HStack { + Slider(value: sliderBinding, in: range, step: amount.unit == .pounds ? 5 : 1) + TextField(amount.unit == .pounds ? "£" : "Litres", text: valueBinding) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 72) + .textFieldStyle(.roundedBorder) + } + Text("Changes the estimate for this session only — your Settings default stays unchanged.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .onAppear { syncText() } + .onChange(of: amount.value) { _, _ in syncText() } + .onChange(of: amount.unit) { _, _ in syncText() } + } + + private var unitBinding: Binding { + Binding( + get: { amount.unit }, + set: { newUnit in + guard newUnit != amount.unit else { return } + if let referencePricePencePerLitre, + let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) { + amount = converted + } else { + amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit) + } + syncText() + } + ) + } + + private var sliderBinding: Binding { + Binding( + get: { min(max(amount.value, range.lowerBound), range.upperBound) }, + set: { amount.value = min(max($0, range.lowerBound), range.upperBound) } + ) + } + + private var valueBinding: Binding { + Binding( + get: { textValue }, + set: { raw in + textValue = raw + if let value = Double(raw), value > 0 { + amount.value = min(value, FuelSavingsCalculator.maximumFillAmount(for: amount.unit)) + } + } + ) + } + + private func syncText() { + let value = amount.value + textValue = value.rounded() == value ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +struct FuelSavingsSummaryView: View { + let availability: FuelSavingsAvailability? + let amount: FuelSavingsFillAmount + let baselineStationName: String? + var compact: Bool = false + + var body: some View { + switch availability { + case .available(let result): + VStack(alignment: .leading, spacing: compact ? 2 : 8) { + Label(result.status.displayName, systemImage: symbol(for: result.status)) + .font(compact ? .caption.weight(.semibold) : .headline) + Text(result.headline(compact: compact)) + .font(compact ? .caption2.bold().monospacedDigit() : .subheadline.bold().monospacedDigit()) + if !compact { + if result.status == .baseline { + Text("Your closest station with a price for this fuel — every other saving is measured against it.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary) + LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP)) + LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP)) + LabeledContent("Extra distance", value: String(format: "%.1f miles", result.extraDistanceMiles)) + if result.distanceBasis.isEstimate { + Text("Estimated from straight-line distance — refined once the route is measured.") + .font(.caption2) + .foregroundStyle(.secondary) + } + Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + case .unavailable(let reason): + Text(compact ? "Savings unavailable" : "Worth the Drive unavailable: \(reason)") + .font(compact ? .caption2 : .caption) + .foregroundStyle(.secondary) + case nil: + EmptyView() + } + } + + private var amountText: String { + switch amount.unit { + case .pounds: return String(format: "£%.0f", amount.value) + case .litres: return String(format: "%.0f litres", amount.value) + } + } + + private func symbol(for status: FuelSavingsStatus) -> String { + switch status { + case .worthTheDrive: return "checkmark.circle.fill" + case .breakEven: return "equal.circle.fill" + case .notWorthTheDrive: return "xmark.circle.fill" + case .baseline: return "location.circle.fill" + } + } +} diff --git a/FuelBoard/KeyboardSupport.swift b/FuelBoard/KeyboardSupport.swift new file mode 100644 index 0000000..a89f2f5 --- /dev/null +++ b/FuelBoard/KeyboardSupport.swift @@ -0,0 +1,41 @@ +import SwiftUI +import UIKit + +extension View { + /// Puts a Done button above the keyboard. + /// + /// The numeric fields (Settings → Average fuel economy and Typical + /// fill-up, and the Worth the Drive fill-up box in the station list) use + /// `.decimalPad`, which has NO Return key. Without this there is no way to + /// dismiss the keyboard once such a field is tapped, and a + /// `TextField(value:format:)` only commits the typed value when editing + /// ends — so the number never stuck either. + /// + /// Apply ONCE per screen, to the list or form containing the fields: the + /// keyboard toolbar belongs to the focused field, so a single attachment + /// covers every field inside it. Attaching it to each field would show + /// several Done buttons at once. + /// + /// - Parameter action: clears the screen's focus state. Prefer this where a + /// `@FocusState` exists; the default resigns the current first responder, + /// which works for fields inside subviews that have no focus binding. + func keyboardDoneButton(action: (() -> Void)? = nil) -> some View { + toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + if let action { + action() + } else { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } + } + } + } +} diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift index b2f8412..246eb6f 100644 --- a/FuelBoard/RoadDistanceService.swift +++ b/FuelBoard/RoadDistanceService.swift @@ -16,9 +16,16 @@ import WidgetKit enum RoadDistanceService { /// Upper bound on stations routed per pass, so a pass stays a bounded set of - /// route calls. Raised from 12 so stations past the old nearest-12 cutoff - /// still get real road distances instead of a straight-line fallback. - static let candidatesPerPass = 40 + /// route calls. Worth-the-Drive now asks for outward AND return routes, so + /// this must stay conservative; a larger value can make first launch feel + /// unresponsive while MapKit drains dozens of route requests. + static let candidatesPerPass = 12 + /// How many Worth-the-Drive candidates may claim slots in a pass. Those + /// rows are ranked by NET SAVING and are typically FURTHER out than the + /// nearest stations — routing only the nearest stations (the old behaviour) + /// meant they never got a measured route and stayed on straight-line + /// estimates forever. + static let priorityCandidates = 6 /// Only route stations within this straight-line radius (km). Covers the /// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so /// every station a widget/Live Activity/list can actually show gets routed. @@ -31,11 +38,34 @@ enum RoadDistanceService { /// Refreshes the cached road distances for the in-radius stations around /// `lat`/`lng`. Throttled by time + distance; safe to call on every fix. - static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async { + /// + /// - Parameter priorityStations: stations to route BEFORE the nearest ones + /// (the Worth-the-Drive candidates and the comparison baseline), so the + /// numbers the user actually reads are road-routed rather than estimated. + static func refreshIfNeeded(stations: [FuelStation], + lat: Double, + lng: Double, + prioritising priorityStations: [FuelStation] = []) async { + guard await refreshGate.begin() else { return } + defer { Task { await refreshGate.end() } } + guard !stations.isEmpty else { return } + let existing = FuelStore.loadRoadDistances() + // Entries can only be merged when they were routed from (near enough) + // this same origin — the read path rejects anything else anyway. + let originCompatible = existing.map { + haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters + } ?? false + // Throttle: keep cached values when fresh and the user hasn't moved far. - if let cache = FuelStore.loadRoadDistances() { + // `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook). +#if DEBUG + let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1" +#else + let forcePass = false +#endif + if !forcePass, let cache = existing { let elapsed = Date().timeIntervalSince1970 - cache.updatedAt let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng) if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters { @@ -43,33 +73,55 @@ enum RoadDistanceService { } } - // Candidate stations: the nearest-by-straight-line subset that the UI - // could actually display, capped so a pass stays bounded. - let nearest = stations - .sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } - .prefix(candidatesPerPass) - .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } + // Candidates: Worth-the-Drive priorities first, then the nearest + // stations. Stations that already have a route from this origin are + // skipped so the per-pass budget goes on the ones still missing one. + var candidates: [FuelStation] = [] + var seen = Set() + func consider(_ station: FuelStation) { + guard candidates.count < candidatesPerPass else { return } + guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return } + guard seen.insert(station.id).inserted else { return } + guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return } + candidates.append(station) + } + for station in priorityStations.prefix(priorityCandidates) { consider(station) } + let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } + for station in nearest { + if candidates.count >= candidatesPerPass { break } + consider(station) + } + + guard !candidates.isEmpty else { return } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) - var entries: [String: CachedRoadDistance] = [:] - for station in nearest { + var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:] + for station in candidates { let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) - if let meters = await roadMeters(from: origin, to: dest) { + async let outward = roadMeters(from: origin, to: dest) + async let back = roadMeters(from: dest, to: origin) + if let meters = await outward { // Store the exact pin that was routed so the display layer can // refuse to serve this value if the station later appears with // a different coordinate (corrected pin / other data source). + let returnMeters = await back entries[station.id] = CachedRoadDistance(meters: meters, lat: station.lat, - lng: station.lng) + lng: station.lng, + returnMeters: returnMeters) } } - guard !entries.isEmpty else { return } + guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else { + return + } FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries) // Wake the widgets so the new road distances surface immediately. WidgetCenter.shared.reloadAllTimelines() } + private static let refreshGate = RoadDistanceRefreshGate() + /// Driving distance (metres) between two coordinates via Apple Maps routing. private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? { let request = MKDirections.Request() @@ -96,3 +148,17 @@ enum RoadDistanceService { return r * 2 * atan2(sqrt(a), sqrt(1 - a)) } } + +private actor RoadDistanceRefreshGate { + private var isRefreshing = false + + func begin() -> Bool { + guard !isRefreshing else { return false } + isRefreshing = true + return true + } + + func end() { + isRefreshing = false + } +} diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 5bbf400..e7f010b 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -19,6 +19,7 @@ struct SettingsView: View { @Binding var distanceUnit: DistanceUnit @Binding var priceDisplayStyle: PriceDisplayStyle @Binding var stationTapAction: StationTapAction + @Binding var fuelSavingsSettings: FuelSavingsSettings @Binding var dataRefreshMode: DataRefreshMode /// The fuel + radius currently configured for alerts (mirrors the Alerts /// tab) so the test notification matches what real alerts will say. @@ -70,6 +71,16 @@ struct SettingsView: View { @State private var mediumWidgetDiag: String? @State private var installedWidgets: String = "" + /// Which numeric field in the Fuel Savings section is being edited. Those + /// fields use `.decimalPad`, which has no Return key, so the keyboard's + /// Done button clears this to dismiss AND commit the typed value. + @FocusState private var focusedSavingsField: SavingsField? + + private enum SavingsField: Hashable { + case averageMPG + case fillAmount + } + private func refreshWidgetDiag() { smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget") mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent") @@ -137,6 +148,45 @@ struct SettingsView: View { Text("Tapping a station either opens directions straight away or shows its details first.") } + Section { + Toggle("Enable Worth the Drive", isOn: fuelSavingsEnabledBinding) + HStack { + Text("Average fuel economy") + Spacer() + TextField("45", value: fuelSavingsMPGBinding, format: .number.precision(.fractionLength(0...1))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused($focusedSavingsField, equals: .averageMPG) + Text("MPG") + .foregroundStyle(.secondary) + } + Picker("Fill-up unit", selection: fuelSavingsUnitBinding) { + ForEach(FuelSavingsFillUnit.allCases) { unit in + Text(unit.displayName).tag(unit) + } + } + .pickerStyle(.segmented) + HStack { + Text("Typical fill-up") + Spacer() + TextField("40", value: fuelSavingsAmountBinding, format: .number.precision(.fractionLength(0...1))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 90) + .focused($focusedSavingsField, equals: .fillAmount) + } + if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled { + Text("Enter a realistic UK MPG and fill-up amount before Worth the Drive appears in sorting.") + .font(.caption) + .foregroundStyle(.orange) + } + } header: { + Text("Fuel Savings") + } footer: { + Text("Worth the Drive estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.") + } + Section { Picker("Data checking", selection: $dataRefreshMode) { ForEach(DataRefreshMode.allCases) { mode in @@ -446,6 +496,9 @@ struct SettingsView: View { } } + // Numeric entry must be escapable: `.decimalPad` has no Return key. + .keyboardDoneButton { focusedSavingsField = nil } + .scrollDismissesKeyboard(.interactively) .navigationTitle("Settings") .onAppear { Task { await tipStore.load() } @@ -565,6 +618,47 @@ struct SettingsView: View { return "\(version) (\(build))" } + private var fuelSavingsEnabledBinding: Binding { + Binding( + get: { fuelSavingsSettings.enabled }, + set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() } + ) + } + + private var fuelSavingsMPGBinding: Binding { + Binding( + get: { fuelSavingsSettings.mpg }, + set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() } + ) + } + + private var fuelSavingsAmountBinding: Binding { + Binding( + get: { fuelSavingsSettings.preferredAmount.value }, + set: { + fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit)) + persistFuelSavingsSettings() + } + ) + } + + private var fuelSavingsUnitBinding: Binding { + Binding( + get: { fuelSavingsSettings.preferredAmount.unit }, + set: { newUnit in + fuelSavingsSettings.preferredAmount.unit = newUnit + if !fuelSavingsSettings.preferredAmount.isValid { + fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30 + } + persistFuelSavingsSettings() + } + ) + } + + private func persistFuelSavingsSettings() { + FuelStore.saveFuelSavingsSettings(fuelSavingsSettings) + } + /// "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 { diff --git a/FuelBoard/StationDetailView.swift b/FuelBoard/StationDetailView.swift index d375273..232766c 100644 --- a/FuelBoard/StationDetailView.swift +++ b/FuelBoard/StationDetailView.swift @@ -10,6 +10,9 @@ struct StationDetailView: View { let station: FuelStation let location: Coordinate? let distanceUnit: DistanceUnit + var fuelSavings: FuelSavingsAvailability? = nil + var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount + var savingsBaselineStationName: String? = nil @Environment(\.dismiss) private var dismiss @State private var showingReportIssueConfirmation = false @@ -54,6 +57,16 @@ struct StationDetailView: View { .background(Capsule().fill(Color.orange.opacity(0.14))) } } + Section { + FuelSavingsSummaryView( + availability: fuelSavings, + amount: fuelSavingsAmount, + baselineStationName: savingsBaselineStationName + ) + } header: { + Text("Worth the drive?") + } + Section { Button { if let url = station.mapsDirectionsURL { diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index d51bc74..7cebb0b 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -16,6 +16,10 @@ struct StationsView: View { let location: Coordinate? let favouriteIDs: Set let stationTapAction: StationTapAction + let fuelSavingsSettings: FuelSavingsSettings + @Binding var fuelSavingsAmount: FuelSavingsFillAmount + var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } + var savingsBaselineStationName: String? var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onRefresh: () async -> Void = {} @@ -41,7 +45,12 @@ struct StationsView: View { let miles = distanceUnit.displayMiles(stationLimit) let unit = distanceUnit.label(for: Double(miles)) let fuel = selectedFuel.displayName - let mode = sortMode == .closest ? "Closest" : "Cheapest" + let mode: String + switch sortMode { + case .closest: mode = "Closest" + case .cheapest: mode = "Cheapest" + case .worthTheDrive: mode = "Worth the Drive" + } let tapHint = stationTapAction == .openMap ? "tap a station for directions." : "tap a station for details." @@ -92,7 +101,7 @@ struct StationsView: View { Section("Stations") { Picker("Sort by", selection: $sortMode) { - ForEach(SortMode.allCases) { mode in + ForEach(availableSortModes) { mode in Text(mode.displayName).tag(mode) } } @@ -102,7 +111,17 @@ struct StationsView: View { } .padding(.vertical, 2) - if isLoading { + // Only offered while the feature is actually configured — a + // stale stored sort mode must not keep the control on screen + // for a switched-off feature. + if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured { + FuelSavingsAdjustmentView( + amount: $fuelSavingsAmount, + referencePricePencePerLitre: baselinePrice + ) + } + + if isLoading && stations.isEmpty { HStack(spacing: 10) { ProgressView() Text("Fetching prices…") @@ -133,7 +152,9 @@ struct StationsView: View { baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), + topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP", tapAction: stationTapAction, + fuelSavings: savingsForStation(station), onToggleFavourite: { onToggleFavourite(station, selectedFuel) }, onShowDetails: { detailStation = $0 } ) @@ -180,6 +201,11 @@ struct StationsView: View { // large title at rest, scrolls it away and fades the compact // inline title in. No custom toolbar needed — the native mechanism // only renders text, which is exactly what we want here. + // The Worth the Drive fill-up box uses `.decimalPad`, which has no + // Return key — give the keyboard a Done button and let a scroll + // dismiss it too. + .keyboardDoneButton() + .scrollDismissesKeyboard(.interactively) .navigationTitle("FuelBoard") .navigationBarTitleDisplayMode(.large) .onAppear { @@ -273,12 +299,23 @@ struct StationsView: View { StationDetailView( station: station, location: location, - distanceUnit: distanceUnit + distanceUnit: distanceUnit, + fuelSavings: savingsForStation(station), + fuelSavingsAmount: fuelSavingsAmount, + savingsBaselineStationName: savingsBaselineStationName ) } } } + private var availableSortModes: [SortMode] { + var modes: [SortMode] = [.closest, .cheapest] + if fuelSavingsSettings.isConfigured { + modes.append(.worthTheDrive) + } + return modes + } + /// One price-rating card for the Key sheet: accent icon, title + blurb, /// and the rating threshold in the accent colour (mirrors the tip cards). private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View { diff --git a/FuelBoardTests/Sources/FuelBoardShared/FuelSavings.swift b/FuelBoardTests/Sources/FuelBoardShared/FuelSavings.swift new file mode 120000 index 0000000..02e2ed3 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/FuelSavings.swift @@ -0,0 +1 @@ +../../../Shared/FuelSavings.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift new file mode 100644 index 0000000..0bb3d49 --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -0,0 +1,377 @@ +import XCTest +#if SWIFT_PACKAGE +@testable import FuelBoardShared +#endif + +/// Ranking rules for the Worth the Drive sort. +/// +/// Regression: the sort used to rank only a shortlist of the nearest stations +/// whose ROAD ROUTES had been measured, and everything else was listed as +/// unavailable. Cheaper stations a few miles further out — the exact stations +/// the sort exists to surface — were dropped from the top of the list, and +/// stations on later pages showed no savings at all. +final class FuelSavingsEstimateTests: XCTestCase { + private let settings = FuelSavingsSettings( + enabled: true, + mpg: 45, + preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds) + ) + private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) + + private func station(_ id: String, + price: Double?, + lat: Double = 51.5, + lng: Double = -0.1) -> FuelStation { + FuelStation( + id: id, + name: id, + brand: "X", + address: "", + postcode: "", + lat: lat, + lng: lng, + prices: price.map { [FuelType.e10: $0] } ?? [:], + priceUpdated: nil + ) + } + + /// Straight-line out-and-back estimate, as the app builds it when no road + /// route has been measured yet. + private func straightLine(_ miles: Double) -> FuelSavingsJourney { + FuelSavingsJourney(miles: miles, basis: .straightLine) + } + + func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() { + // Nearest station: 1 mile away at 145.9p. The station the user can see + // in the Cheapest tab: 3.5 miles away at 138.9p. + let baseline = station("baseline", price: 145.9) + let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55) + let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) } + let pool = [baseline, candidate] + filler + + let ranked = FuelSavingsEstimator.ranked( + pool, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journeyMiles: { s in + switch s.id { + case "baseline": return self.straightLine(2.0) + case "cheaper-3.5": return self.straightLine(7.0) + default: return self.straightLine(20.0) + } + } + ) + + XCTAssertEqual(ranked.count, pool.count) + XCTAssertEqual(ranked.first?.id, "cheaper-3.5", + "a cheaper station 3.5 miles away must rank above the nearest station") + + let result = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journey: straightLine(7.0), + baselineJourney: straightLine(2.0) + )?.result + + XCTAssertNotNil(result, "the station must show a saving, not an unavailable state") + XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0) + XCTAssertEqual(result?.status, .worthTheDrive) + XCTAssertEqual(result?.distanceBasis, .straightLine, + "unmeasured routes are reported as straight-line estimates") + } + + func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() { + // 40 stations, but only the first one has a measured road route — the + // old implementation capped the whole ranking to 12 shortlisted + // stations and returned "Outside route shortlist" for the rest. + let baseline = station("baseline", price: 145.9) + let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) } + + let ranked = FuelSavingsEstimator.ranked( + pool, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journeyMiles: { s in + s.id == "baseline" + ? FuelSavingsJourney(miles: 2.0, basis: .roadRoute) + : self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6)) + } + ) + + XCTAssertEqual(ranked.count, pool.count) + + for station in pool where station.id != "baseline" { + let availability = FuelSavingsEstimator.availability( + station: station, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journey: straightLine(10.0), + baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute) + ) + XCTAssertNotNil(availability?.result, + "\(station.id) should carry an estimated saving, not an unavailable state") + } + } + + func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() { + let baseline = station("baseline", price: 145.9) + let candidate = station("candidate", price: 138.9) + + let result = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute), + baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute) + )?.result + + XCTAssertEqual(result?.distanceBasis, .roadRoute) + XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate) + } + + func testMixedBasisIsReportedAsAnEstimate() { + let baseline = station("baseline", price: 145.9) + let candidate = station("candidate", price: 138.9) + + // Baseline measured, candidate not: the comparison is only as good as + // its weakest leg, so it must be labelled an estimate. + let result = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journey: straightLine(7.0), + baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute) + )?.result + + XCTAssertEqual(result?.distanceBasis, .straightLine) + } + + func testStationWithoutAPriceIsUnavailable() { + let baseline = station("baseline", price: 145.9) + let noPrice = station("no-price", price: nil) + + let availability = FuelSavingsEstimator.availability( + station: noPrice, + fuel: .e10, + baseline: baseline, + settings: settings, + amount: amount, + journey: straightLine(4.0), + baselineJourney: straightLine(2.0) + ) + + XCTAssertEqual(availability, .unavailable("No price")) + } + + func testFeatureOffProducesNoSavingsRow() { + let baseline = station("baseline", price: 145.9) + let candidate = station("candidate", price: 138.9) + + let availability = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: .defaults, + amount: amount, + journey: straightLine(4.0), + baselineJourney: straightLine(2.0) + ) + + XCTAssertNil(availability) + } + + // MARK: - Required wording and baseline labelling + + private var briefSettings: FuelSavingsSettings { + FuelSavingsSettings(enabled: true, + mpg: 30, + preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)) + } + + private var briefAmount: FuelSavingsFillAmount { + FuelSavingsFillAmount(value: 60, unit: .pounds) + } + + /// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf + /// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi). + /// + /// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is + /// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the + /// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles + /// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33. + func testCheaperStationFurtherAwayIsWorseOffAfterTravel() { + let baseline = station("rontec-shelf", price: 171.9) + let candidate = station("shell-whetley-hill", price: 167.9) + + let availability = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journey: straightLine(7.6), // 3.8 mi out and back + baselineJourney: straightLine(0.8) // 0.4 mi out and back + ) + + guard let result = availability?.result else { + return XCTFail("expected an estimate, got \(String(describing: availability))") + } + XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005, + "the £60 budget buys litres at the BASELINE pump price") + XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005) + XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001, + "only the return-trip DELTA counts — the shared journey is never counted twice") + XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005) + XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005) + XCTAssertEqual(result.status, .notWorthTheDrive) + XCTAssertEqual(result.headline(compact: true), "£0.33 worse off after travel (est.)") + } + + func testMeasuredRoadRoutesDropTheEstimateMarker() { + let baseline = station("baseline", price: 171.9) + let candidate = station("candidate", price: 167.9) + + let availability = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute), + baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute) + ) + + XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute) + XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel") + XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel") + } + + func testBaselineRowIsLabelledClosestStation() { + let baseline = station("nearest", price: 171.9) + + let availability = FuelSavingsEstimator.availability( + station: baseline, + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journey: straightLine(0.8), + baselineJourney: straightLine(0.8) + ) + + XCTAssertEqual(availability?.result?.status, .baseline) + XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station") + XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station") + XCTAssertEqual(availability?.result?.netSavingGBP, 0) + XCTAssertEqual(availability?.result?.extraDistanceMiles, 0) + } + + func testPositiveResultHeadlineSaysSaveAfterTravel() { + // 4p cheaper AND almost the same distance, so the saving survives travel. + let baseline = station("baseline", price: 160.0) + let candidate = station("candidate", price: 150.0) + + let availability = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journey: straightLine(6.4), + baselineJourney: straightLine(6.0) + ) + + guard let result = availability?.result else { + return XCTFail("expected an estimate, got \(String(describing: availability))") + } + XCTAssertEqual(result.status, .worthTheDrive) + // £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra + // return miles cost ~£0.09. Net ≈ £3.66. + XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005) + XCTAssertEqual(result.headline(compact: true), "Save £3.66 after travel (est.)") + } + + func testNeutralResultHeadlineIsBreakEven() { + // 0.1p cheaper over the same distance: inside the break-even tolerance. + let baseline = station("baseline", price: 150.0) + let candidate = station("candidate", price: 149.9) + + let availability = FuelSavingsEstimator.availability( + station: candidate, + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journey: straightLine(6.0), + baselineJourney: straightLine(6.0) + ) + + XCTAssertEqual(availability?.result?.status, .breakEven) + XCTAssertEqual(availability?.result?.headline(compact: true), "Break even") + XCTAssertEqual(availability?.result?.headline(), "Break even") + } + + /// The exact strings the brief asks for. + func testHeadlineWordingMatchesTheBrief() { + XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station") + XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even") + + let saving = FuelSavingsResult(fillLitres: 34.9, + grossSavingGBP: 1.40, + extraDrivingCostGBP: 0.88, + extraDistanceMiles: 3.5, + netSavingGBP: 0.52, + status: .worthTheDrive, + distanceBasis: .roadRoute) + XCTAssertEqual(saving.headline(), "Save £0.52 after travel") + + let loss = FuelSavingsResult(fillLitres: 34.9, + grossSavingGBP: 1.40, + extraDrivingCostGBP: 1.71, + extraDistanceMiles: 6.8, + netSavingGBP: -0.31, + status: .notWorthTheDrive, + distanceBasis: .roadRoute) + XCTAssertEqual(loss.headline(), "£0.31 worse off after travel") + } + + /// The sort must order by NET saving, not pump price: a pricier station can + /// win when the cheaper one is far enough away. + func testSortingRanksByNetSavingNotPumpPrice() { + let baseline = station("baseline", price: 171.9) + let cheapButFar = station("cheap-but-far", price: 167.9) + let pricierButNear = station("pricier-but-near", price: 169.9) + + let ranked = FuelSavingsEstimator.ranked( + [baseline, cheapButFar, pricierButNear], + fuel: .e10, + baseline: baseline, + settings: briefSettings, + amount: briefAmount, + journeyMiles: { s in + switch s.id { + case "baseline": return self.straightLine(0.8) + case "cheap-but-far": return self.straightLine(7.6) + default: return self.straightLine(1.2) + } + } + ) + + XCTAssertEqual(ranked.first?.id, "pricier-but-near", + "net saving decides the order, not the pump price") + XCTAssertEqual(ranked.last?.id, "cheap-but-far") + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsRoutingTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsRoutingTests.swift new file mode 100644 index 0000000..9edfeea --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsRoutingTests.swift @@ -0,0 +1,37 @@ +import XCTest +#if SWIFT_PACKAGE +@testable import FuelBoardShared +#endif + +final class FuelSavingsRoutingCacheTests: XCTestCase { + override func setUp() { + super.setUp() + FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:]) + } + + private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation { + FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: 140], priceUpdated: nil) + } + + func testJourneyDistanceUsesSeparateOutAndBackRoadRoutes() { + let s = station("a", 51.5074, -0.1278) + FuelStore.saveRoadDistances( + sourceLat: 51.6, + sourceLng: -0.1, + entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278, returnMeters: 3_700)] + ) + + XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_900) + } + + func testLegacyOneWayCacheDoublesForJourneyDistance() { + let s = station("a", 51.5074, -0.1278) + FuelStore.saveRoadDistances( + sourceLat: 51.6, + sourceLng: -0.1, + entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278)] + ) + + XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_400) + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift new file mode 100644 index 0000000..3f8528e --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -0,0 +1,93 @@ +import XCTest +#if SWIFT_PACKAGE +@testable import FuelBoardShared +#endif + +final class FuelSavingsCalculatorTests: XCTestCase { + func testUKMPGConversionUsesImperialGallons() { + XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001) + } + + func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws { + let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) + let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) + XCTAssertEqual(litres, 25.0, accuracy: 0.0001) + } + + func testLitresAmountPassesThrough() throws { + let amount = FuelSavingsFillAmount(value: 35, unit: .litres) + let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) + XCTAssertEqual(litres, 35, accuracy: 0.0001) + } + + func testNetSavingSubtractsAdditionalDrivingFuelCost() throws { + let result = try FuelSavingsCalculator.calculate( + mpg: 45, + amount: FuelSavingsFillAmount(value: 40, unit: .pounds), + baselinePricePencePerLitre: 160, + candidatePricePencePerLitre: 150, + baselineJourneyMiles: 2, + candidateJourneyMiles: 8 + ) + XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001) + XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001) + XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001) + XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001) + XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001) + XCTAssertEqual(result.status, .worthTheDrive) + } + + func testNegativeAndBreakEvenOutcomes() throws { + let negative = try FuelSavingsCalculator.calculate( + mpg: 45, + amount: FuelSavingsFillAmount(value: 10, unit: .litres), + baselinePricePencePerLitre: 150, + candidatePricePencePerLitre: 149, + baselineJourneyMiles: 1, + candidateJourneyMiles: 20 + ) + XCTAssertEqual(negative.status, .notWorthTheDrive) + XCTAssertLessThan(negative.netSavingGBP, 0) + + let even = try FuelSavingsCalculator.calculate( + mpg: 45, + amount: FuelSavingsFillAmount(value: 10, unit: .litres), + baselinePricePencePerLitre: 150, + candidatePricePencePerLitre: 150, + baselineJourneyMiles: 2, + candidateJourneyMiles: 2 + ) + XCTAssertEqual(even.status, .breakEven) + } + + func testInvalidInputsRejected() { + XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0)) + XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) + XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) + } + + func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws { + let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds) + let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160) + XCTAssertEqual(litres.unit, .litres) + XCTAssertEqual(litres.value, 25, accuracy: 0.0001) + let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160) + XCTAssertEqual(back.value, 40, accuracy: 0.0001) + } + + func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() { + let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150) + let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149) + let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140) + XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"]) + } +} + +final class FuelSavingsSettingsTests: XCTestCase { + func testSettingsValidationRequiresEnabledMPGAndFillAmount() { + XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) + XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) + XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured) + XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreFileCacheTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreFileCacheTests.swift new file mode 100644 index 0000000..e3f2f77 --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreFileCacheTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import FuelBoardShared + +/// Shared payload storage must survive an install whose provisioning profile +/// has NO `com.apple.security.application-groups` entitlement (free/personal +/// team and SideStore installs) — the exact situation this test runner is in. +/// +/// Regression guard: `appGroupFileURL` used to return nil without a group +/// container, so every save silently failed and every load returned empty. That +/// left `loadStations()` permanently empty (letting the freshness gate skip the +/// fetch for ever, i.e. a blank station list) and re-downloaded the ~2.8 MB +/// dump on every refresh. Storage now falls back to the app's own container. +final class FuelStoreFileCacheTests: XCTestCase { + + func testSharedFileURLExistsWithoutAppGroupContainer() { + XCTAssertNotNil( + FuelStore.appGroupFileURL("fuelboard.test.probe.json"), + "storage must resolve to a writable location even when the App Group container is unprovisioned" + ) + } + + func testSharedFileRoundTripsWithoutAppGroupContainer() throws { + let name = "fuelboard.test.roundtrip.json" + let payload = Data("hello-cache".utf8) + + XCTAssertTrue( + FuelStore.saveAppGroupFile(payload, name: name), + "save must succeed without an App Group container" + ) + XCTAssertEqual(FuelStore.loadAppGroupFile(name), payload) + + if let url = FuelStore.appGroupFileURL(name) { + try? FileManager.default.removeItem(at: url) + } + } + + /// The dump cache is what stops a ~2.8 MB re-download on every refresh, so + /// it must round-trip too — not just the station list. + func testDumpCacheRoundTripsWithoutAppGroupContainer() { + let day = "2026-01-01" + let data = Data(repeating: 7, count: 4096) + + MirrorFuelProvider.saveDumpCache(day: day, data: data) + let cache = MirrorFuelProvider.loadDumpCache() + + XCTAssertEqual(cache?.day, day, "cached dump day must survive a round-trip") + XCTAssertEqual(cache?.data, data, "cached dump payload must survive a round-trip") + + if let url = FuelStore.appGroupFileURL(MirrorFuelProvider.liveDumpCacheFileName) { + try? FileManager.default.removeItem(at: url) + } + } + + /// The road-distance cache is memoized in memory because re-reading it per + /// station (it lives in the keychain) stalled the main thread past the iOS + /// launch watchdog. A save must therefore invalidate the memo — a stale + /// in-memory cache would show wrong distances after a route refresh. + func testRoadDistanceMemoRefreshesAfterSave() { + FuelStore.saveRoadDistances(sourceLat: 51.0, sourceLng: -1.0, entries: [:]) + XCTAssertEqual(FuelStore.loadRoadDistances()?.sourceLat, 51.0) + + FuelStore.saveRoadDistances(sourceLat: 52.0, sourceLng: -2.0, entries: [:]) + + XCTAssertEqual( + FuelStore.loadRoadDistances()?.sourceLat, 52.0, + "load must reflect the newest save, not a stale memoized cache" + ) + } +} diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index eee9e23..8f12de6 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -242,8 +242,9 @@ struct FuelPriceTimelineProvider 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit) + } +} + +struct FuelSavingsSettings: Codable, Equatable { + var enabled: Bool + var mpg: Double + var preferredAmount: FuelSavingsFillAmount + + static let defaultMPG: Double = 45 + static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds) + + static var defaults: FuelSavingsSettings { + FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount) + } + + var isConfigured: Bool { + enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid + } +} + +enum FuelSavingsStatus: String, Codable, Equatable { + case worthTheDrive + case breakEven + case notWorthTheDrive + /// The comparison baseline itself — the station every other saving is + /// measured against. Shown as "Closest station", never "Break even". + case baseline + + var displayName: String { + switch self { + case .worthTheDrive: return "Worth the drive" + case .breakEven: return "Break even" + case .notWorthTheDrive: return "Not worth the drive" + case .baseline: return FuelSavingsResult.baselineHeadline + } + } +} + +/// How the journey distances behind an estimate were obtained. +enum FuelSavingsDistanceBasis: String, Codable, Equatable { + /// Measured road route, out and back, from the routing service. + case roadRoute + /// Straight-line out-and-back estimate, used until a route is measured. + case straightLine + + var isEstimate: Bool { self == .straightLine } +} + +struct FuelSavingsResult: Codable, Equatable { + let fillLitres: Double + let grossSavingGBP: Double + let extraDrivingCostGBP: Double + let extraDistanceMiles: Double + let netSavingGBP: Double + let status: FuelSavingsStatus + let distanceBasis: FuelSavingsDistanceBasis + + init(fillLitres: Double, + grossSavingGBP: Double, + extraDrivingCostGBP: Double, + extraDistanceMiles: Double, + netSavingGBP: Double, + status: FuelSavingsStatus, + distanceBasis: FuelSavingsDistanceBasis = .roadRoute) { + self.fillLitres = fillLitres + self.grossSavingGBP = grossSavingGBP + self.extraDrivingCostGBP = extraDrivingCostGBP + self.extraDistanceMiles = extraDistanceMiles + self.netSavingGBP = netSavingGBP + self.status = status + self.distanceBasis = distanceBasis + } +} + +/// Money formatting for savings strings, in one place so the exact wording the +/// UI shows is testable. +enum FuelSavingsFormat { + static func money(_ value: Double) -> String { + String(format: "£%.2f", value) + } +} + +extension FuelSavingsResult { + /// Baseline row label — required wording. + static let baselineHeadline = "Closest station" + /// Neutral result label — required wording. + static let breakEvenHeadline = "Break even" + + /// The same estimate relabelled as the comparison baseline: nothing to save + /// or lose against itself, and no extra travel. + func asBaseline() -> FuelSavingsResult { + FuelSavingsResult( + fillLitres: fillLitres, + grossSavingGBP: 0, + extraDrivingCostGBP: 0, + extraDistanceMiles: 0, + netSavingGBP: 0, + status: .baseline, + distanceBasis: distanceBasis + ) + } + + /// Row headline, e.g. "Save £0.52 after travel", "£0.31 worse off after + /// travel", "Break even", "Closest station". + /// + /// `compact` (list rows) also marks a straight-line estimate with "(est.)"; + /// the detail view spells that out in full instead. + func headline(compact: Bool = false) -> String { + let estimate = (compact && distanceBasis.isEstimate) ? " (est.)" : "" + switch status { + case .baseline: + return Self.baselineHeadline + case .breakEven: + return Self.breakEvenHeadline + case .worthTheDrive: + return "Save \(FuelSavingsFormat.money(netSavingGBP)) after travel\(estimate)" + case .notWorthTheDrive: + return "\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel\(estimate)" + } + } +} + +/// One station's journey distance, plus how it was obtained. +struct FuelSavingsJourney: Equatable { + let miles: Double + let basis: FuelSavingsDistanceBasis +} + +enum FuelSavingsAvailability: Equatable { + case available(FuelSavingsResult) + case unavailable(String) + + var result: FuelSavingsResult? { + if case .available(let result) = self { return result } + return nil + } +} + +struct FuelSavingsRankedStation: Equatable { + let stationID: String + let result: FuelSavingsAvailability + let distanceMiles: Double + let pricePencePerLitre: Double +} + +enum FuelSavingsError: Error, Equatable { + case invalidMPG + case invalidAmount + case invalidPrice + case invalidDistance +} + +enum FuelSavingsCalculator { + static let imperialGallonLitres = 4.54609 + static let breakEvenToleranceGBP = 0.05 + static let minimumMPG: Double = 5 + static let maximumMPG: Double = 200 + + static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double { + switch unit { + case .pounds: return 500 + case .litres: return 250 + } + } + + static func isValidMPG(_ mpg: Double) -> Bool { + mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG + } + + static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double { + distanceMiles / mpg * imperialGallonLitres + } + + static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double { + guard amount.isValid else { throw FuelSavingsError.invalidAmount } + switch amount.unit { + case .litres: + return amount.value + case .pounds: + guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else { + throw FuelSavingsError.invalidPrice + } + return amount.value / (baselinePricePencePerLitre / 100.0) + } + } + + static func convert(amount: FuelSavingsFillAmount, + to unit: FuelSavingsFillUnit, + referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount { + guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else { + throw FuelSavingsError.invalidPrice + } + guard amount.isValid else { throw FuelSavingsError.invalidAmount } + guard amount.unit != unit else { return amount } + switch (amount.unit, unit) { + case (.pounds, .litres): + return FuelSavingsFillAmount( + value: amount.value / (referencePricePencePerLitre / 100.0), + unit: .litres + ) + case (.litres, .pounds): + return FuelSavingsFillAmount( + value: amount.value * (referencePricePencePerLitre / 100.0), + unit: .pounds + ) + case (.pounds, .pounds), (.litres, .litres): + return amount + } + } + + static func calculate(mpg: Double, + amount: FuelSavingsFillAmount, + baselinePricePencePerLitre: Double, + candidatePricePencePerLitre: Double, + baselineJourneyMiles: Double, + candidateJourneyMiles: Double, + distanceBasis: FuelSavingsDistanceBasis = .roadRoute, + breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult { + guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG } + guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0, + candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else { + throw FuelSavingsError.invalidPrice + } + guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite, + baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else { + throw FuelSavingsError.invalidDistance + } + let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre) + let baselinePriceGBP = baselinePricePencePerLitre / 100.0 + let candidatePriceGBP = candidatePricePencePerLitre / 100.0 + let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP) + let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles) + let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg) + let extraFuelCost = extraFuelLitres * candidatePriceGBP + let netSaving = grossSaving - extraFuelCost + let status: FuelSavingsStatus + if netSaving > breakEvenToleranceGBP { + status = .worthTheDrive + } else if netSaving < -breakEvenToleranceGBP { + status = .notWorthTheDrive + } else { + status = .breakEven + } + return FuelSavingsResult( + fillLitres: fillLitres, + grossSavingGBP: grossSaving, + extraDrivingCostGBP: extraFuelCost, + extraDistanceMiles: extraDistance, + netSavingGBP: netSaving, + status: status, + distanceBasis: distanceBasis + ) + } + + static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] { + stations.sorted { lhs, rhs in + switch (lhs.result.result, rhs.result.result) { + case let (l?, r?): + if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP } + if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles } + if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre } + return lhs.stationID < rhs.stationID + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles } + return lhs.stationID < rhs.stationID + } + } + } +} + +/// Turns stations into savings estimates. +/// +/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking +/// rules are unit tested directly. +enum FuelSavingsEstimator { + /// Savings for a single station against `baseline`, or nil when there is + /// nothing to show at all (feature off). + static func availability(station: FuelStation, + fuel: FuelType, + baseline: FuelStation?, + settings: FuelSavingsSettings, + amount: FuelSavingsFillAmount, + journey: FuelSavingsJourney?, + baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? { + guard settings.isConfigured else { return nil } + guard let baseline, let baselineJourney else { return .unavailable("No comparison station") } + guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") } + guard let price = station.prices[fuel] else { return .unavailable("No price") } + guard let journey else { return .unavailable("Distance unavailable") } + // Both legs must be measured for the estimate to count as a road-route + // one; otherwise it is reported as a straight-line estimate. + let basis: FuelSavingsDistanceBasis = + (journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine + do { + let result = try FuelSavingsCalculator.calculate( + mpg: settings.mpg, + amount: amount, + baselinePricePencePerLitre: baselinePrice, + candidatePricePencePerLitre: price, + baselineJourneyMiles: baselineJourney.miles, + candidateJourneyMiles: journey.miles, + distanceBasis: basis + ) + // The baseline compares against itself: label it as the closest + // station rather than a meaningless "Break even". + return .available(station.id == baseline.id ? result.asBaseline() : result) + } catch { + return .unavailable("Distance unavailable") + } + } + + /// Ranks a whole pool for the Worth the Drive sort. + /// + /// EVERY priced station in the pool gets an estimate, not just the handful + /// whose road routes happen to have been measured: journeys that have no + /// measured route fall back to a straight-line out-and-back estimate (see + /// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper + /// stations that were a few miles further away — the exact stations the + /// sort exists to surface — and left later pages with no savings at all. + static func ranked(_ stations: [FuelStation], + fuel: FuelType, + baseline: FuelStation?, + settings: FuelSavingsSettings, + amount: FuelSavingsFillAmount, + journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] { + var journeys: [String: FuelSavingsJourney] = [:] + journeys.reserveCapacity(stations.count) + for station in stations { + if let journey = journeyMiles(station) { + journeys[station.id] = journey + } + } + let baselineJourney = baseline.flatMap { journeys[$0.id] } + let ranked = stations.map { station -> FuelSavingsRankedStation in + let journey = journeys[station.id] + let availability = availability(station: station, + fuel: fuel, + baseline: baseline, + settings: settings, + amount: amount, + journey: journey, + baselineJourney: baselineJourney) ?? .unavailable("Unavailable") + return FuelSavingsRankedStation( + stationID: station.id, + result: availability, + distanceMiles: journey?.miles ?? .greatestFiniteMagnitude, + pricePencePerLitre: station.prices[fuel] ?? .infinity + ) + } + let order = Dictionary( + uniqueKeysWithValues: FuelSavingsCalculator.sortedForWorthTheDrive(ranked) + .enumerated() + .map { ($0.element.stationID, $0.offset) } + ) + return stations.sorted { lhs, rhs in + let left = order[lhs.id] ?? Int.max + let right = order[rhs.id] ?? Int.max + if left != right { return left < right } + return lhs.id < rhs.id + } + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index f0ed5bd..6fa6875 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -45,6 +45,7 @@ struct FavouriteAlertSnapshot: Codable, Equatable { enum SortMode: String, Codable, CaseIterable, Identifiable { case cheapest case closest + case worthTheDrive var id: String { rawValue } @@ -52,6 +53,7 @@ enum SortMode: String, Codable, CaseIterable, Identifiable { switch self { case .cheapest: return "Cheapest" case .closest: return "Closest" + case .worthTheDrive: return "Worth the Drive" } } } @@ -419,15 +421,47 @@ struct FuelStore { static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" static let stationCountKey = "fuelboard.stationCount" // String — station count static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time + static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled" + static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg" + static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount" + static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit" // Large shared payloads belong in the App Group container, not // UserDefaults/CFPreferences (which has a roughly 4 MB limit). static let stationsFileName = "fuelboard.stations.json" + /// Where a shared payload is written: the App Group container when the + /// provisioning profile grants it, otherwise the app's OWN container. + /// + /// This fallback is essential in the field. Free/personal-team and SideStore + /// profiles do NOT include the `com.apple.security.application-groups` + /// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)` + /// returns nil at runtime. Without this fallback NOTHING persisted on those + /// installs: `loadStations()` was permanently empty (so the freshness gate + /// could skip the fetch for ever and leave a blank list) and the ~2.8 MB + /// dump was re-downloaded on every refresh. The app's own container is not + /// shared with the widget/watch extension, but the app itself must keep a + /// working cache. static func appGroupFileURL(_ name: String) -> URL? { - FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)? - .appendingPathComponent(name, isDirectory: false) + let fm = FileManager.default + if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite), + fm.fileExists(atPath: group.path) { + // Only use the App Group container when the system actually + // provisioned it at install time (i.e. the profile carries the + // `com.apple.security.application-groups` entitlement). Never try to + // create it: when the entitlement is missing the path either does + // not exist or is not writable, and probing it that way failed every + // write instead of falling back. + return group.appendingPathComponent(name, isDirectory: false) + } + guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + if !fm.fileExists(atPath: dir.path), + (try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil { + return nil + } + return dir.appendingPathComponent(name, isDirectory: false) } static func loadAppGroupFile(_ name: String) -> Data? { @@ -467,6 +501,10 @@ struct FuelStore { } static func loadStations() -> [FuelStation] { + #if DEBUG + let probe = appGroupFileURL(stationsFileName) + print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)") + #endif if let data = loadAppGroupFile(stationsFileName), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { @@ -500,7 +538,10 @@ struct FuelStore { static func saveStations(_ stations: [FuelStation]) { if let data = try? JSONEncoder().encode(stations) { - _ = saveAppGroupFile(data, name: stationsFileName) + let ok = saveAppGroupFile(data, name: stationsFileName) + #if DEBUG + print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")") + #endif } } @@ -632,6 +673,41 @@ struct FuelStore { saveString(action.rawValue, service: stationTapActionKey) } + // MARK: Fuel savings — Worth the Drive? + + static func loadFuelSavingsSettings() -> FuelSavingsSettings { + let enabled = loadString(service: fuelSavingsEnabledKey) == "1" + let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG + let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value + let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit +#if DEBUG + // The two stores are mirrors and can DISAGREE (the keychain survives + // app deletion, the app-group defaults do not). Print both raw copies + // plus which one won, so a setting that flips between launches is + // diagnosable instead of guesswork. + func raw(_ service: String) -> (keychain: String, appGroup: String) { + let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-" + let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-" + return (keychain, appGroup) + } + let enabledRaw = raw(fuelSavingsEnabledKey) + let mpgRaw = raw(fuelSavingsMPGKey) + print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]") +#endif + return FuelSavingsSettings( + enabled: enabled, + mpg: mpg, + preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit) + ) + } + + static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) { + saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey) + saveString(String(settings.mpg), service: fuelSavingsMPGKey) + saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey) + saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) + } + /// Superscript digit glyphs for the pounds & pence format's small raised /// third digit (the forecourt style: "£1.29⁹"). private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] @@ -1148,20 +1224,41 @@ struct FuelStore { /// catches any real pin change. static let roadDistancePinToleranceDegrees: Double = 1e-4 + /// In-memory copy of the road-distance cache. + /// + /// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC + /// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is + /// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading + /// it on every call meant tens of thousands of synchronous XPC round-trips + /// on the main thread during scene creation, which stalled past the 20 s iOS + /// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per + /// process, reuse, and refresh on save. + private static var roadDistanceMemo: RoadDistanceCache? + /// Distinguishes "not loaded yet" from "loaded, and there is no cache". + private static var roadDistanceMemoLoaded = false static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) { let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, updatedAt: Date().timeIntervalSince1970, entries: entries) if let data = try? JSONEncoder().encode(cache) { saveString(data.base64EncodedString(), service: roadDistancesKey) } + roadDistanceMemo = cache + roadDistanceMemoLoaded = true } static func loadRoadDistances() -> RoadDistanceCache? { - guard let raw = loadString(service: roadDistancesKey), - let data = Data(base64Encoded: raw), - let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) - else { return nil } - return cache + if roadDistanceMemoLoaded { return roadDistanceMemo } + let decoded: RoadDistanceCache? + if let raw = loadString(service: roadDistancesKey), + let data = Data(base64Encoded: raw), + let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) { + decoded = cache + } else { + decoded = nil + } + roadDistanceMemo = decoded + roadDistanceMemoLoaded = true + return decoded } /// Cached road distance (metres) to a station from the user's location, or @@ -1169,6 +1266,26 @@ struct FuelStore { /// is now / the station's own pin doesn't match the coordinate that was /// routed. static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { + roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters + } + + /// Complete out-and-back routed journey distance (metres). New caches store + /// the return route separately because Apple Maps may choose a different + /// return path; legacy one-way caches safely fall back to doubling outward. + static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { + guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil } + return entry.meters + (entry.returnMeters ?? entry.meters) + } + + /// Whether a usable routed distance is already cached for this station from + /// (near enough) this origin. The routing pass uses it to spend its per-pass + /// budget on stations that still need a route instead of re-routing the + /// same nearest handful forever. + static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool { + roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil + } + + private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? { guard let cache = loadRoadDistances(), let entry = cache.entries[station.id] else { return nil } // The cache is only valid near the location it was built from. @@ -1185,7 +1302,7 @@ struct FuelStore { // live / cached station sets from injecting each other's road metres. guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees, abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil } - return entry.meters + return entry } /// Distance (km) to display for a station: cached ROAD distance when @@ -1266,6 +1383,14 @@ struct CachedRoadDistance: Codable { let meters: Double let lat: Double let lng: Double + let returnMeters: Double? + + init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) { + self.meters = meters + self.lat = lat + self.lng = lng + self.returnMeters = returnMeters + } } /// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres + diff --git a/Shared/MirrorFuelProvider.swift b/Shared/MirrorFuelProvider.swift index 3ff3410..f40e465 100644 --- a/Shared/MirrorFuelProvider.swift +++ b/Shared/MirrorFuelProvider.swift @@ -81,6 +81,14 @@ struct MirrorFuelProvider: FuelPriceProviding { ) } + static func cachedStations(near lat: Double? = nil, lng: Double? = nil, + fuel: FuelType = .e10, radiusKM: Double? = nil) -> [FuelStation]? { + guard let cache = loadDumpCache(), + let stations = try? FuelPriceProvider.decodeStations(from: cache.data) else { return nil } + latestMeta = FuelPriceProvider.decodeRelayMeta(from: cache.data) + return focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) + } + func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { // 1. Tiny pointer fetch — the freshest snapshot day. guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL), @@ -104,10 +112,16 @@ struct MirrorFuelProvider: FuelPriceProviding { throw FuelProviderError.mirrorUnavailable } Self.saveDumpCache(day: day, data: fetched) + #if DEBUG + print("FB-STORAGE dump cached day=\(day) bytes=\(fetched.count) reuse=\(Self.canReuseCache(cachedDay: Self.loadDumpCache()?.day, latestDay: day))") + #endif data = fetched } let stations = try FuelPriceProvider.decodeStations(from: data) Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) + #if DEBUG + print("FB-MIRROR day=\(day) bytes=\(data.count) decoded=\(stations.count)") + #endif return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) } }