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
+222 -24
View File
@@ -111,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
private var favourites: [FuelStation] = []
private var fuel: FuelType = .e10
private var radiusKM: Double = 3.0
private var alertMode: AlertMode = .cheapest
private var bestValueThresholdGBP: Double = 1.0
private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
private var fuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
private var lastNotified: [String: Date] = [:] // dedup per station
private var enabled = false
/// Dynamically registered categories for tie-choice notifications. Kept so
@@ -135,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
radiusKM = FuelStore.effectiveAlertsRadiusKM(
followsSearch: FuelStore.loadAlertsFollowsSearch(),
manualKM: FuelStore.loadAlertsRadius())
alertMode = FuelStore.loadAlertsMode()
bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP()
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
fuelSavingsAmount = fuelSavingsSettings.preferredAmount
stations = FuelStore.loadStations()
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
}
@@ -143,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
/// get priority slots (a Diesel favourite must not grab a slot while
/// monitoring Unleaded).
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
func update(
stations: [FuelStation],
favourites: [FavouriteEntry],
fuel: FuelType,
radiusKM: Double,
alertMode: AlertMode = .cheapest,
bestValueThresholdGBP: Double = 1.0,
fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
) {
// Fall back to the shared cache when called before the first fetch
// completes (launch, background region-event wake).
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
@@ -152,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
: favourites.filter { $0.fuel == fuel }.map(\.station)
self.fuel = fuel
self.radiusKM = radiusKM
self.alertMode = alertMode
self.bestValueThresholdGBP = bestValueThresholdGBP
self.fuelSavingsSettings = fuelSavingsSettings
self.fuelSavingsAmount = fuelSavingsAmount
refreshDebugStatus()
@@ -169,18 +190,37 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// Debug section only shows the CURRENT region problem.
lastRegionError = nil
// Favourites first (guaranteed slots), then closest stations, max 18.
// `self.favourites` is already filtered to the monitored fuel.
// Favourites first (guaranteed slots). For Best Value mode, spend the
// remaining iOS region slots on likely savings winners before falling
// back to closest stations; the actual notification still rechecks the
// winner after the geofence wakes the app.
var candidates: [FuelStation] = self.favourites
let favIDs = Set(self.favourites.map(\.id))
let location = FuelStore.loadLocation()
let others = stations
.filter { !favIDs.contains($0.id) }
.sorted { lhs, rhs in
let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil }
let others: [FuelStation]
if alertMode == .bestValue,
fuelSavingsSettings.isConfigured,
let location {
let inRadius = eligibleOthers.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
others = FuelSavingsEstimator.ranked(
inRadius,
fuel: fuel,
baseline: savingsBaseline(in: inRadius, location: location),
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
} else {
others = eligibleOthers.sorted { lhs, rhs in
guard let location else { return false }
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
}
}
candidates.append(contentsOf: others)
var registered: [String] = []
@@ -222,6 +262,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled }
private func savingsBaseline(in stations: [FuelStation], location: Coordinate) -> FuelStation? {
FuelSavingsEstimator.baselineStation(
stations,
fuel: fuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
}
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
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: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) /
FuelSavingsCalculator.kilometresPerMile * 2,
basis: .straightLine,
confidence: .estimated
)
}
/// Compares the latest refreshed favourites against the persisted cheapest-
/// favourite baseline for one fuel and schedules a notification when the
/// winner changes or the current winner drops by a meaningful amount.
@@ -438,25 +505,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
$0.prices[fuel] != nil &&
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
}
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
return
}
guard cheapest.id == station.id else {
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
return
}
// withinRadius guarantees the fuel exists on every candidate.
guard let price = cheapest.prices[fuel] else { return }
// Ties: all stations in radius at the same cheapest price.
let tied = FuelStore.tiedStations(
in: withinRadius, fuel: fuel, price: price,
fromLat: station.lat, lng: station.lng
)
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[stationID] = Date()
switch alertMode {
case .cheapest:
handleCheapestEntry(station: station, withinRadius: withinRadius)
case .bestValue:
handleBestValueEntry(station: station, withinRadius: withinRadius)
}
} catch {
// Trace the silent catch — a relay failure drops the alert
// with no banner, which previously looked like "no alert".
@@ -465,6 +520,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
private func handleCheapestEntry(station: FuelStation, withinRadius: [FuelStation]) {
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
return
}
guard cheapest.id == station.id else {
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
return
}
// withinRadius guarantees the fuel exists on every candidate.
guard let price = cheapest.prices[fuel] else { return }
// Ties: all stations in radius at the same cheapest price.
let tied = FuelStore.tiedStations(
in: withinRadius, fuel: fuel, price: price,
fromLat: station.lat, lng: station.lng
)
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[station.id] = Date()
}
private func handleBestValueEntry(station: FuelStation, withinRadius: [FuelStation]) {
guard fuelSavingsSettings.isConfigured else {
logAlert(.gate, "\(station.name) — Best Value not configured")
return
}
guard let candidate = bestValueCandidate(in: withinRadius, around: station) else {
logAlert(.gate, "\(station.name) — no Best Value candidate clears \(FuelSavingsFormat.money(bestValueThresholdGBP))")
return
}
guard candidate.station.id == station.id else {
logAlert(.gate, "\(station.name) — not Best Value: \(candidate.station.name) is \(candidate.result.headline())")
return
}
fireBestValueAlert(for: candidate.station, result: candidate.result)
lastNotified[station.id] = Date()
}
private func bestValueCandidate(in stations: [FuelStation], around station: FuelStation) -> BestValueAlertCandidate? {
let origin = Coordinate(lat: station.lat, lng: station.lng)
let threshold = bestValueThresholdGBP
let extraMargin = 0.50
let candidate = FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations,
fuel: fuel,
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
minimumNetSavingGBP: threshold,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
)
guard let candidate else { return nil }
if candidate.result.journeyConfidence.isEstimated,
candidate.result.netSavingGBP < threshold + extraMargin {
return nil
}
return candidate
}
private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) {
let brand = station.brand.isEmpty ? station.name : station.brand
let unit = FuelStore.loadDistanceUnit()
@@ -499,6 +613,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
}
private func fireBestValueAlert(for station: FuelStation, result: FuelSavingsResult) {
let brand = station.brand.isEmpty ? station.name : station.brand
let content = UNMutableNotificationContent()
content.sound = .default
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name) could leave you \(result.headline()). Tap for directions."
addAlertRequest(content: content, station: station)
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
lastAlert = "Best Value · \(brand) · \(result.headline())"
}
private func fireFavouriteDropAlert(
current: FavouriteEntry,
previous: FavouriteAlertSnapshot,
@@ -669,6 +794,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// the geofence itself. Uses the monitor's live state (fuel, radius,
/// stations) so the notification reflects the Alerts-tab configuration.
func sendTestNotification() {
switch alertMode {
case .cheapest:
sendCheapestTestNotification()
case .bestValue:
sendBestValueTestNotification()
}
}
private func sendCheapestTestNotification() {
let unit = FuelStore.loadDistanceUnit()
let location = FuelStore.loadLocation()
let sellers = stations.filter { $0.prices[fuel] != nil }
@@ -745,6 +879,70 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
private func sendBestValueTestNotification() {
let unit = FuelStore.loadDistanceUnit()
let location = FuelStore.loadLocation()
let sellers = stations.filter { $0.prices[fuel] != nil }
let inRadius = sellers.filter { station in
guard let location else { return true }
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
let origin = location ?? inRadius.first.map { Coordinate(lat: $0.lat, lng: $0.lng) }
let candidate: BestValueAlertCandidate? = {
guard let origin else { return nil }
return FuelSavingsCalculator.bestValueAlertCandidate(
stations: inRadius,
fuel: fuel,
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
minimumNetSavingGBP: bestValueThresholdGBP,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
)
}()
guard fuelSavingsSettings.isConfigured else {
sendNoCandidateTestNotification(
title: "FuelBoard Best Value test",
body: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.",
result: "Best Value not configured"
)
return
}
guard let candidate else {
let body = sellers.isEmpty
? "No station data loaded yet — open the Stations tab first, then retry."
: "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))."
sendNoCandidateTestNotification(
title: "FuelBoard Best Value test",
body: body,
result: sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))"
)
return
}
let content = UNMutableNotificationContent()
content.sound = .default
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions."
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
addAlertRequest(content: content, station: candidate.station) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
}
private func sendNoCandidateTestNotification(title: String, body: String, result: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "test alert scheduled — \(result)")
lastTestResult = result
}
/// Fires a notification with NO criteria checks at all — no geofence, no
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
/// coupling. Still carries a REAL station (the nearest one selling the