Clarify Best Value route confidence
This commit is contained in:
+91
-24
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user