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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user