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
+240 -14
View File
@@ -17,6 +17,43 @@ import MapKit
import UIKit
import WidgetKit
struct RoadDistanceFillDebugStatus: Codable, Equatable {
var state: State = .idle
var reason: String = "Waiting"
var updatedAt: Date = Date()
var originLat: Double?
var originLng: Double?
var stationPoolCount: Int = 0
var priorityCount: Int = 0
var candidateCount: Int = 0
var routedCount: Int = 0
var cachedCount: Int = 0
var failedCount: Int = 0
var complete: Bool = false
var capped: Bool = false
var elapsedSeconds: Double?
var movedMeters: Double?
var throttleSecondsRemaining: Double?
enum State: String, Codable {
case idle
case gated
case running
case completed
}
var summary: String {
switch state {
case .idle: return "Idle — \(reason)"
case .gated: return "Gated — \(reason)"
case .running: return "Running — \(reason)"
case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx."
}
}
static let idle = RoadDistanceFillDebugStatus()
}
enum RoadDistanceService {
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
/// whole search area in ONE pass, because a 12-per-pass budget could never
@@ -37,11 +74,47 @@ enum RoadDistanceService {
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
/// every station a widget/Live Activity/list can actually show gets routed.
static let maxRadiusKM: Double = 25
/// Don't route again more often than this (minutes).
/// Don't route again more often than this (minutes) once the set is whole.
static let throttleMinutes: Double = 10
/// Retry window for a set that is still INCOMPLETE — a pass cut short by
/// backgrounding the app must be continued promptly, not in ten minutes.
static let incompleteRetrySeconds: Double = 30
/// Recompute when the user moves more than this (metres) from the last
/// source location.
static let moveThresholdMeters: Double = 400
static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle
@MainActor
static func currentDebugStatus() -> RoadDistanceFillDebugStatus {
debugStatus
}
@MainActor
private static func publishDebug(_ status: RoadDistanceFillDebugStatus) {
debugStatus = status
NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil)
}
private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async {
await MainActor.run { publishDebug(status) }
}
private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State,
reason: String,
stations: [FuelStation],
lat: Double,
lng: Double,
priorityCount: Int) -> RoadDistanceFillDebugStatus {
RoadDistanceFillDebugStatus(
state: state,
reason: reason,
updatedAt: Date(),
originLat: lat,
originLng: lng,
stationPoolCount: stations.count,
priorityCount: priorityCount
)
}
/// Refreshes the cached road distances for the in-radius stations around
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
@@ -56,15 +129,47 @@ enum RoadDistanceService {
// "Accurate distances" OFF (the default) means the browse layer never
// serves a routed value, so any routing would be pure cost with nothing
// visible to show for it: no pass, no requests, no widget reloads.
guard FuelStore.accurateDistancesEnabled else { return }
guard FuelStore.accurateDistancesEnabled else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "Accurate distances off",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
// Only fill while the app is on screen. The browse layer is the only
// consumer, so a background fill would spend radio and battery routing a
// position nothing is displaying — worst case, a long drive.
guard await appIsActive() else { return }
guard await refreshGate.begin() else { return }
guard await appIsActive() else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "App not active",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
guard await refreshGate.begin() else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "Fill already running",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
defer { Task { await refreshGate.end() } }
guard !stations.isEmpty else { return }
guard !stations.isEmpty else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "No stations loaded",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
let existing = FuelStore.loadRoadDistances()
// Entries can only be merged when they were routed from (near enough)
@@ -83,7 +188,38 @@ enum RoadDistanceService {
if !forcePass, let cache = existing {
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
// An INCOMPLETE set still has work to do, so it is not throttled for
// the full ten minutes. The browse layer refuses to serve a partial
// set (every row reads straight-line + "Approx." until the set is
// whole), and a pass is routinely cut short by backgrounding the app
// — so a ten-minute throttle on an incomplete set is what made
// "Approx." look permanent: reopen the app inside the window and the
// pass that would finish the job never even started, and refreshing
// the prices could not help. Retry soon instead.
let incomplete = cache.complete != true
let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60
if elapsed < throttleWindow && movedMeters < moveThresholdMeters {
var status = baseDebugStatus(state: .gated,
reason: incomplete ? "Throttled — incomplete set, retrying"
: "Throttled",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.cachedCount = cache.entries.count
status.complete = !incomplete
status.movedMeters = movedMeters
status.throttleSecondsRemaining = throttleWindow - elapsed
if incomplete,
await repairCompletenessIfNothingLeftToRoute(cache, stations: stations,
lat: lat, lng: lng) {
status.complete = true
status.reason = "Throttled — stale incomplete flag repaired"
}
await updateDebug(status)
if status.complete {
await notifyDistanceSurfaces()
}
return
}
}
@@ -107,12 +243,44 @@ enum RoadDistanceService {
consider(station)
}
guard !candidates.isEmpty else { return }
let passStart = Date()
guard !candidates.isEmpty else {
var status = baseDebugStatus(state: .gated,
reason: "No unrouted candidates",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.cachedCount = existing?.entries.count ?? 0
var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng)
if !complete, let existing,
await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) {
complete = true
status.reason = "No unrouted candidates — stale incomplete flag repaired"
}
status.complete = complete
await updateDebug(status)
if status.complete {
await notifyDistanceSurfaces()
}
return
}
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
var running = baseDebugStatus(state: .running,
reason: "Routing \(candidates.count) stations",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
running.candidateCount = candidates.count
running.cachedCount = entries.count
running.capped = candidates.count >= fillCapacity
await updateDebug(running)
let passStart = Date()
var routedCount = 0
for station in candidates {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
@@ -131,6 +299,18 @@ enum RoadDistanceService {
}
}
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
var status = baseDebugStatus(state: .gated,
reason: "Route pass added no distances",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.candidateCount = candidates.count
status.routedCount = routedCount
status.failedCount = candidates.count - routedCount
status.cachedCount = entries.count
status.elapsedSeconds = Date().timeIntervalSince(passStart)
await updateDebug(status)
return
}
@@ -151,6 +331,20 @@ enum RoadDistanceService {
}
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries,
complete: !unresolved)
var completed = baseDebugStatus(state: .completed,
reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"),
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
completed.candidateCount = candidates.count
completed.routedCount = routedCount
completed.failedCount = candidates.count - routedCount
completed.cachedCount = entries.count
completed.complete = !unresolved
completed.capped = capped
completed.elapsedSeconds = Date().timeIntervalSince(passStart)
await updateDebug(completed)
#if DEBUG
// `FB-ROUTE` prints what a pass actually cost (stations, route calls,
// wall-clock, unroutable pins, whether the set is now complete) — the
@@ -161,12 +355,44 @@ enum RoadDistanceService {
+ "cached=\(entries.count) failed=\(candidates.count - routedCount) "
+ "complete=\(!unresolved)")
#endif
// Wake the widgets so the new road distances surface immediately, and
// tell the app so its rows re-read the distance they are showing.
WidgetCenter.shared.reloadAllTimelines()
await MainActor.run {
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
await notifyDistanceSurfaces()
}
/// A cache can hold a route for EVERY station in the search area and still be
/// flagged incomplete — a previous pass hit the capacity cap, or was
/// interrupted between routing and writing. Nothing would ever clear that
/// flag on its own (the next pass finds nothing left to route, and the
/// throttle path returns without writing), and every row would stay on
/// straight-line + "Approx." while its routed value sat in the cache. When
/// nothing in the area is left unrouted, the set IS complete: correct the
/// flag so the routed values can be served.
private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache,
stations: [FuelStation],
lat: Double,
lng: Double) async -> Bool {
guard cache.complete != true, !stations.isEmpty else { return false }
let nothingLeftToRoute = !stations.contains { station in
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
}
guard nothingLeftToRoute else { return false }
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
entries: cache.entries, complete: true)
#if DEBUG
print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) "
+ "(every in-radius station already routed — no re-routing needed)")
#endif
return true
}
@MainActor
private static func notifyDistanceSurfacesOnMain() {
WidgetCenter.shared.reloadAllTimelines()
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
}
private static func notifyDistanceSurfaces() async {
await MainActor.run { notifyDistanceSurfacesOnMain() }
}
private static let refreshGate = RoadDistanceRefreshGate()