Add Worth the Drive fuel savings with road-routed estimates

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.
This commit is contained in:
FuelBoard Contributor
2026-10-11 14:34:53 +01:00
parent 1044d009ae
commit d7a0ecb79a
17 changed files with 1865 additions and 46 deletions
+301 -12
View File
@@ -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<Void, Never>?
/// 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=<text>` 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<String> {
@@ -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)