Stop Approx. distances beside routed values; add Best Value alert mode

Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
  within 3 km of the position already held; a coarse fix in another city is
  real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
  used by rows, radius filter, sorting, savings figures and the Live Activity,
  so a stray fix can no longer re-base the list outside the route cache's 600 m
  ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
  full 10-minute throttle, and repair a stale incomplete flag once every
  in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
  labels, and keeps its own fix in fuelboard.widgetLocation instead of
  overwriting the app's GPS fix.

Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
  pipeline, with a persisted threshold; reverts to cheapest when the feature is
  unconfigured and is not selectable while disabled.

Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
This commit is contained in:
FuelBoard Contributor
2026-10-13 22:04:00 +01:00
parent 2a2823c180
commit 6f177837a5
13 changed files with 1194 additions and 99 deletions
+63
View File
@@ -253,6 +253,11 @@ struct FuelSavingsRankedStation: Equatable {
let pricePencePerLitre: Double
}
struct BestValueAlertCandidate: Equatable {
let station: FuelStation
let result: FuelSavingsResult
}
enum FuelSavingsError: Error, Equatable {
case invalidMPG
case invalidAmount
@@ -417,6 +422,64 @@ enum FuelSavingsCalculator {
}
}
}
static func bestValueAlertCandidate(
stations: [FuelStation],
fuel: FuelType,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double,
journeyMiles: (FuelStation) -> FuelSavingsJourney?
) -> BestValueAlertCandidate? {
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
let sellers = stations.filter { $0.prices[fuel] != nil }
guard !sellers.isEmpty else { return nil }
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
var best: BestValueAlertCandidate?
var bestDistance = Double.greatestFiniteMagnitude
var bestPrice = Double.infinity
var baselineJourneyCache: FuelSavingsJourney?
var baselineJourneyLoaded = false
for station in sellers {
let journey = journeyMiles(station)
let baselineJourney: FuelSavingsJourney? = {
guard let baseline else { return nil }
if baseline.id == station.id { return journey }
if !baselineJourneyLoaded {
baselineJourneyCache = journeyMiles(baseline)
baselineJourneyLoaded = true
}
return baselineJourneyCache
}()
guard case .available(let result) = FuelSavingsEstimator.availability(
station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey,
baselineJourney: baselineJourney
), result.status == .worthTheDrive,
result.netSavingGBP >= minimumNetSavingGBP else { continue }
let distance = journey?.miles ?? .greatestFiniteMagnitude
let price = station.prices[fuel] ?? .infinity
guard let current = best else {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
continue
}
if result.netSavingGBP > current.result.netSavingGBP ||
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
}
}
return best
}
}
/// Turns stations into savings estimates.
+139
View File
@@ -62,6 +62,20 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
}
}
enum AlertMode: String, Codable, CaseIterable, Identifiable {
case cheapest
case bestValue
var id: String { rawValue }
var displayName: String {
switch self {
case .cheapest: return "Cheapest station"
case .bestValue: return "Best Value"
}
}
}
/// RAG value rating for a station's price against the cheapest available.
/// Thumb rules: within 1.5p = green (great value), within 4p = amber (okay),
/// beyond that = red (pricey). Deliberately coarse so it reads at a glance.
@@ -421,6 +435,8 @@ struct FuelStore {
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value
static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
@@ -585,6 +601,29 @@ struct FuelStore {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
}
/// A fix the WIDGET EXTENSION obtained on its own, kept apart from
/// `locationKey`.
///
/// The extension's fix comes from its own `requestLocation()` and is often
/// coarse; writing it over `locationKey` replaced the app's GPS fix with a
/// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is
/// what the app seeds itself with on launch and what
/// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route
/// cache — so a widget fix must never be mistaken for the app's position.
static let widgetLocationKey = "fuelboard.widgetLocation"
static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey)
}
static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? {
let raw = loadString(service: widgetLocationKey)
guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }),
parts.count == 3 else { return nil }
return (Coordinate(lat: parts[0], lng: parts[1]),
Date(timeIntervalSince1970: parts[2]))
}
// MARK: Selected fuel
static func loadSelectedFuel() -> FuelType {
@@ -613,6 +652,34 @@ struct FuelStore {
saveString(fuel.rawValue, service: alertsFuelKey)
}
static func loadAlertsMode() -> AlertMode {
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
return mode
}
return .cheapest
}
static func saveAlertsMode(_ mode: AlertMode) {
saveString(mode.rawValue, service: alertsModeKey)
}
static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0]
static func loadAlertsBestValueThresholdGBP() -> Double {
if let raw = loadString(service: alertsBestValueThresholdKey),
let value = Double(raw),
bestValueAlertThresholdOptionsGBP.contains(value) {
return value
}
return 1.0
}
static func saveAlertsBestValueThresholdGBP(_ threshold: Double) {
let snapped = bestValueAlertThresholdOptionsGBP
.min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0
saveString(String(snapped), service: alertsBestValueThresholdKey)
}
// MARK: Sort mode
static func loadSortMode() -> SortMode {
@@ -1343,6 +1410,78 @@ struct FuelStore {
<= roadDistanceOriginToleranceMeters
}
/// How imprecise a fix must be before its distance from the route cache's
/// anchor stops being read as "the user moved". The widget extension runs its
/// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and
/// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
/// in the day) — hundreds of metres from the app's GPS anchor.
static let roadDistanceCoarseFixAccuracyMeters: Double = 150
/// How far a coarse fix may sit from the anchor before it is taken as genuine
/// travel rather than fix noise.
static let roadDistanceCoarseFixRadiusMeters: Double = 3_000
/// Where a surface should read displayed distances FROM, and whether routed
/// distances are usable there.
///
/// Routed distances are anchored to the fix that produced them (the app's GPS
/// fix). A surface in another process — the widget extension — gets its OWN
/// fix, which can sit hundreds of metres from that anchor. Reading that as
/// "the user moved away from the cache" downgraded EVERY row in the widget to
/// straight-line + "Approx." while the app, whose fix IS the anchor, showed
/// routed values: one station read differently on two faces, and refreshing
/// never fixed it because each new widget fix landed outside the ring again.
/// Resolve the origin once, here, so every surface labels from the same place.
struct DistanceDisplayOrigin: Equatable {
let lat: Double
let lng: Double
/// True when the caller may serve routed distances from this origin.
let routed: Bool
/// Which fix decided it — diagnostics only.
let source: String
}
static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double,
candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin {
let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
routed: false, source: "straightLine")
guard accurateDistancesEnabled,
let cache = loadRoadDistances(), cache.complete == true else { return straightLine }
let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng)
if candidateOffset <= roadDistanceOriginToleranceMeters {
return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
routed: true, source: "candidate")
}
// The candidate fix is outside the ring. A precise fix there means the
// user really has moved, and the honest answer is the straight line from
// where they are. A COARSE fix (or one that reported no accuracy) is fix
// noise, as is any disagreement under a kilometre — neither is good
// evidence that the routes are stale, so fall back to the app's own fix,
// which is the one the cached routes were built around.
let accuracy = candidateAccuracyMeters ?? -1
let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters
guard (fixIsCoarse || candidateOffset <= 1_000),
candidateOffset <= roadDistanceCoarseFixRadiusMeters,
let appFix = loadLocationWithDate(),
haversineMetres(cache.sourceLat, cache.sourceLng,
appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters,
// The stored fix must belong to this cache, not predate it by hours.
appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine }
return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng,
routed: true, source: "appFix")
}
/// Metres between two coordinates — the same great-circle measure the
/// origin/anchor tests use, exposed so a surface can report how far its fix
/// sits from the anchor (diagnostics only).
static func distanceMetersBetween(lat1: Double, lng1: Double,
lat2: Double, lng2: Double) -> Double {
haversineMetres(lat1, lng1, lat2, lng2)
}
private static func haversineMetres(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
let r = 6371000.0
let dLat = (lat2 - lat1) * .pi / 180
+5
View File
@@ -27,6 +27,11 @@ final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate {
/// Requests the current location. Returns nil on denial, timeout, or
/// no-permission — callers should fall back to cached data.
///
/// The `CLLocation` is returned intact so callers can read
/// `horizontalAccuracy`: the extension is often handed a coarse Wi-Fi/cell
/// fix, and a coarse fix must not be mistaken for the user having moved away
/// from the app's routed distances (see `FuelStore.distanceDisplayOrigin`).
func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? {
guard CLLocationManager.locationServicesEnabled() else { return nil }