Clarify Best Value route confidence

This commit is contained in:
FuelBoard Contributor
2026-10-13 10:30:41 +01:00
parent d8a85b7aa0
commit 9aa8a2ede1
10 changed files with 338 additions and 46 deletions
+91 -24
View File
@@ -244,38 +244,43 @@ struct ContentView: View {
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) }
let baselineID = baseline?.id
let settings = fuelSavingsSettings
let amount = sessionFuelSavingsAmount
let fuel = selectedFuel
var journeys: [String: FuelSavingsJourney] = [:]
for station in stations {
if journeys[station.id] == nil,
let journey = savingsJourneyMiles(for: station, location: location) {
journeys[station.id] = journey
}
}
return { station in
FuelSavingsEstimator.availability(
station: station,
fuel: selectedFuel,
fuel: fuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journey: savingsJourneyMiles(for: station, location: location),
baselineJourney: baselineJourney
settings: settings,
amount: amount,
journey: journeys[station.id],
baselineJourney: baselineID.flatMap { journeys[$0] } ?? 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`).
/// The nearest station that sells the selected fuel — the Best Value
/// comparison baseline. It is chosen with the SAME journey values used by
/// the savings calculation, so a cached road route can change the baseline
/// and keep the "more driving than closest" labels synchronized with the
/// cost calculation. When no road route is cached, the journey falls back to
/// straight-line out-and-back, and the result is marked as an estimate.
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
return FuelSavingsEstimator.baselineStation(
stations,
fuel: selectedFuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
}
/// The sort the UI and the list actually use.
@@ -296,11 +301,19 @@ struct ContentView: View {
/// 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 / FuelSavingsCalculator.metresPerMile, basis: .roadRoute)
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
for: station, userLat: location.lat, userLng: location.lng
) ? .confirmed : .estimated
return FuelSavingsJourney(
miles: roadMeters / FuelSavingsCalculator.metresPerMile,
basis: .roadRoute,
confidence: confidence
)
}
return FuelSavingsJourney(
miles: savingsFallbackMiles(for: station, location: location),
basis: .straightLine
basis: .straightLine,
confidence: .estimated
)
}
@@ -335,6 +348,57 @@ struct ContentView: View {
}
#if DEBUG
private func journeyDiagnostics(for station: FuelStation, baseline: FuelStation?) -> FuelSavingsJourneyDiagnostics? {
guard let location else { return nil }
let stationDistance = FuelStore.roadDistanceDiagnostics(for: station, userLat: location.lat, userLng: location.lng)
let baselineDiagnostics = baseline.map { FuelStore.roadDistanceDiagnostics(for: $0, userLat: location.lat, userLng: location.lng) }
let journey = savingsJourneyMiles(for: station, location: location)
let baselineJourney = baseline.flatMap { savingsJourneyMiles(for: $0, location: location) }
let availability = FuelSavingsEstimator.availability(
station: station,
fuel: selectedFuel,
baseline: baseline,
settings: fuelSavingsSettings,
amount: sessionFuelSavingsAmount,
journey: journey,
baselineJourney: baselineJourney
)
let result = availability?.result
func miles(_ value: Double?) -> String {
value.map { String(format: "%.3f mi", $0) } ?? "—"
}
func meters(_ value: Double?) -> String {
value.map { String(format: "%.0f m", $0) } ?? "—"
}
func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate"
}
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
let rows: [(String, String)] = [
("Station", "\(station.name) (\(station.id))"),
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
("Outward road", meters(stationDistance.outwardMeters)),
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
("Total journey", meters(stationDistance.totalJourneyMeters)),
("Cache", stationDistance.hasValidCacheEntry ? "valid" : "missing/invalid"),
("Baseline", baseline.map { "\($0.name) (\($0.id))" } ?? "—"),
("Baseline distance", baselineDiagnostics.map { "\(distanceUnit.format($0.displayDistanceKM)) · \(source($0.displaySource))" } ?? "—"),
("Baseline total", meters(baselineDiagnostics?.totalJourneyMeters)),
("Station journey", miles(journey?.miles)),
("Baseline journey", miles(baselineJourney?.miles)),
("Additional journey", miles(result?.extraDistanceMiles)),
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
("Fuel price", price),
("Baseline price", baselinePrice),
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
]
return FuelSavingsJourneyDiagnostics(rows: rows)
}
/// 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?
@@ -443,6 +507,7 @@ struct ContentView: View {
fuelSavings: savingsLookup(baseline: nearestSavingsBaseline)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
savingsBaselineStationName: nearestSavingsBaseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: nearestSavingsBaseline),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
)
@@ -1033,6 +1098,7 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
@@ -1053,6 +1119,7 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
+4
View File
@@ -25,6 +25,7 @@ struct FavouritesView: View {
/// the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -84,6 +85,7 @@ struct FavouritesView: View {
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
savingsBaselineStationName: String? = nil,
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -98,6 +100,7 @@ struct FavouritesView: View {
_fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation
self.savingsBaselineStationName = savingsBaselineStationName
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -219,6 +222,7 @@ struct FavouritesView: View {
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
isFavourite: true,
+15 -4
View File
@@ -92,9 +92,11 @@ struct FuelSavingsSummaryView: View {
// it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) {
capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.caption2)
@@ -114,9 +116,11 @@ struct FuelSavingsSummaryView: View {
// matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.subheadline)
@@ -130,8 +134,8 @@ struct FuelSavingsSummaryView: View {
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))
if result.distanceBasis.isEstimate {
Text("Estimated from straight-line distance — refined once the route is measured.")
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -157,6 +161,13 @@ struct FuelSavingsSummaryView: View {
}
}
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
let headline = result.headline()
return result.journeyConfidence.isEstimated && result.status != .baseline
? "Approximate: \(headline.replacingOccurrences(of: "~", with: ""))"
: headline
}
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
+19
View File
@@ -2,6 +2,12 @@ import SwiftUI
import MapKit
import UIKit
/// DEBUG-only explanation of the Best Value numbers for one station. It carries
/// no precise user coordinates and is never persisted.
struct FuelSavingsJourneyDiagnostics {
let rows: [(label: String, value: String)]
}
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
/// Shows a small map preview, the address, distance from the current location,
/// and a Directions button that opens Apple Maps — the same destination a direct
@@ -14,6 +20,7 @@ struct StationDetailView: View {
var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped
@@ -86,6 +93,18 @@ struct StationDetailView: View {
}
}
#if DEBUG
if let journeyDiagnostics {
Section("Best Value Diagnostics") {
ForEach(journeyDiagnostics.rows, id: \.label) { row in
LabeledContent(row.label, value: row.value)
.font(.caption)
.monospacedDigit()
}
}
}
#endif
Section {
Button {
if let url = station.mapsDirectionsURL {
+2
View File
@@ -20,6 +20,7 @@ struct StationsView: View {
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
@@ -317,6 +318,7 @@ struct StationsView: View {
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)