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
+29 -1
View File
@@ -12,6 +12,9 @@ struct AlertsView: View {
@Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
@Binding var bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@@ -150,6 +153,16 @@ struct AlertsView: View {
Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
Text(mode.displayName).tag(mode)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
@@ -164,6 +177,18 @@ struct AlertsView: View {
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if enabled && monitoredCount == 0 {
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
.font(.caption)
@@ -172,7 +197,10 @@ struct AlertsView: View {
} header: {
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
} footer: {
Text(String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased()))
let modeDescription = alertMode == .bestValue
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
Text(modeDescription)
}
if enabled, let lastAlert {
+217 -34
View File
@@ -20,6 +20,7 @@ struct ContentView: View {
/// views, so nothing else would invalidate the list and a freshly completed
/// fill would not appear until some other state changed.
@State private var roadDistanceTick = 0
@State private var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = RoadDistanceService.currentDebugStatus()
/// Set from `FB_DIST_FOCUS`/`-distFocus` so a road-distance fill can re-print
/// the affected station's display value once the new distances land.
@State private var distFocusQuery: String?
@@ -36,6 +37,8 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
@@ -43,11 +46,57 @@ struct ContentView: View {
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
@State private var location: Coordinate? = {
/// The device's own last TRUSTWORTHY fix — raw CoreLocation output (subject
/// to LocationManager's junk-fix guard). Used for the network fetch hint and
/// for saving the position; never for labelling distances.
@State private var deviceLocation: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
}()
/// Horizontal accuracy (metres) of `deviceLocation` when it came from a live
/// fix; nil when the value was seeded from the saved location at launch.
@State private var deviceLocationAccuracy: Double?
/// The single basis every DISTANCE the user sees is measured from — list
/// rows, the radius filter, sorting, the savings/journey figures and the
/// Live Activity all read this one origin, so no two of them can disagree
/// about where the user is.
///
/// Resolved through `FuelStore.distanceDisplayOrigin` rather than used raw:
/// a coarse Wi-Fi/cell fix (or one that contradicts the route cache's
/// anchor) must not silently re-base the UI — that is the bug where a row
/// showed an "Approx." marker beside the routed figure. When the live fix
/// is untrustworthy, this stays on the fix the cached routes were built
/// around; when the user has genuinely moved, it follows them.
private var location: Coordinate? {
guard let deviceLocation else { return nil }
let origin = FuelStore.distanceDisplayOrigin(
candidateLat: deviceLocation.lat,
candidateLng: deviceLocation.lng,
candidateAccuracyMeters: deviceLocationAccuracy
)
return Coordinate(lat: origin.lat, lng: origin.lng)
}
#if DEBUG
/// The origin decision behind `location`, for the `FB_DIST_FOCUS` probe.
private var displayOriginDiagnostics: (candidate: Coordinate?, accuracy: Double?,
source: String, candidateOffsetM: Double?,
anchor: (lat: Double, lng: Double)?) {
guard let deviceLocation else { return (nil, nil, "noFix", nil, nil) }
let resolved = FuelStore.distanceDisplayOrigin(
candidateLat: deviceLocation.lat, candidateLng: deviceLocation.lng,
candidateAccuracyMeters: deviceLocationAccuracy
)
let cache = FuelStore.loadRoadDistances()
return (deviceLocation, deviceLocationAccuracy, resolved.source,
cache.map { FuelStore.distanceMetersBetween(lat1: $0.sourceLat, lng1: $0.sourceLng,
lat2: deviceLocation.lat, lng2: deviceLocation.lng) },
cache.map { ($0.sourceLat, $0.sourceLng) })
}
#endif
// MARK: - QA overrides (DEBUG)
/// QA hooks: `-stationRadius 10` / `-sortMode cheapest` override the stored
@@ -88,10 +137,32 @@ struct ContentView: View {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
}
private var effectiveAlertsMode: AlertMode {
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
}
private func ensureBestValueAlertModeIsAvailable() {
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
}
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
monitor.update(
stations: stations,
favourites: refreshedFavourites,
fuel: overrideFuel ?? alertsFuel,
radiusKM: effectiveAlertsRadiusKM,
alertMode: effectiveAlertsMode,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: sessionFuelSavingsAmount
)
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
self.updateAlertMonitor()
// The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
@@ -591,7 +662,11 @@ struct ContentView: View {
/// (see `FB_DIST_FOCUS`). Called once a fix is available and again whenever a
/// road-distance fill lands: that before/after is how the accurate-distances
/// toggle is verified on a real device, where no screenshot tool exists.
static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?) {
static func logDistanceFocus(query: String, stations: [FuelStation], location: Coordinate?,
originNote: String = "") {
if !originNote.isEmpty {
print("FB-DIST-ORIGIN \(originNote)")
}
guard let loc = location else {
print("FB-DIST no location fix (stations=\(stations.count))")
return
@@ -611,6 +686,21 @@ struct ContentView: View {
+ "shown=\(String(format: "%.3f", shown.km))km approx=\(shown.isApproximate)")
}
}
/// One line describing where the displayed distances are measured FROM:
/// the raw device fix and its accuracy, the route cache's anchor and their
/// offset, and which branch `distanceDisplayOrigin` took. Printed by
/// `FB_DIST_FOCUS` so a device can prove which basis the list used.
private var distanceFocusOriginNote: String {
let d = displayOriginDiagnostics
let candidate = d.candidate.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
let accuracy = d.accuracy.map { String(format: "%.0fm", $0) } ?? "nil"
let anchor = d.anchor.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
let offset = d.candidateOffsetM.map { String(format: "%.0fm", $0) } ?? "nil"
let served = location.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none"
return "candidate=\(candidate) acc=\(accuracy) anchor=\(anchor) "
+ "candidateOffset=\(offset) resolved=\(d.source) served=\(served)"
}
#endif
#if DEBUG
@@ -699,7 +789,8 @@ struct ContentView: View {
roadDistanceTick &+= 1
#if DEBUG
if let query = distFocusQuery {
ContentView.logDistanceFocus(query: query, stations: stations, location: location)
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
#endif
}
@@ -711,6 +802,9 @@ struct ContentView: View {
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
@@ -783,7 +877,48 @@ struct ContentView: View {
if location != nil, !stations.isEmpty { break }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
ContentView.logDistanceFocus(query: query, stations: stations, location: location)
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
}
// `FB_STALE_INCOMPLETE=1` rewrites the cached route set with the
// complete flag cleared — the state a capped or interrupted pass
// leaves behind. Launching in it must not show "Approx." for ever:
// the app has to notice that nothing is left to route and repair the
// flag. DEBUG-only verification hook.
if ProcessInfo.processInfo.environment["FB_STALE_INCOMPLETE"] == "1",
let cache = FuelStore.loadRoadDistances() {
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
entries: cache.entries, complete: false)
print("FB-COMPLETE forced stale incomplete cached=\(cache.entries.count) "
+ "(expect repair on the next fill check)")
Task { @MainActor in
// Let a fix and the station pool land, then run the fill
// check the app makes on every fix/foreground.
try? await Task.sleep(nanoseconds: 5_000_000_000)
refreshRoadDistancesIfNeeded()
}
}
// `FB_FAKE_FIX="lat,lng,accuracyM"` pushes ONE synthetic fix through
// the real LocationManager path (guard included), then re-prints the
// origin probe. A phone that is sitting still cannot produce the
// coarse Wi-Fi fix that flips every row to "Approx.", so this is how
// that case is verified on-device instead of guessed at.
if let fake = ProcessInfo.processInfo.environment["FB_FAKE_FIX"], !fake.isEmpty {
let parts = fake.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
if parts.count == 3, let lat = Double(parts[0]), let lng = Double(parts[1]),
let accuracy = Double(parts[2]) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 4_000_000_000)
locationManager.applyFix(lat: lat, lng: lng, accuracy: accuracy)
print("FB-FIX applied lat=\(lat) lng=\(lng) acc=\(accuracy)m "
+ "manager=\(locationManager.current.map { String(format: "%.5f,%.5f", $0.lat, $0.lng) } ?? "none")")
try? await Task.sleep(nanoseconds: 2_000_000_000)
if let query = distFocusQuery {
ContentView.logDistanceFocus(query: query, stations: stations, location: location,
originNote: distanceFocusOriginNote)
}
}
}
}
// `-seedFavourite <substring>` pins the first matching station
@@ -911,8 +1046,7 @@ struct ContentView: View {
// foreground-only onChange isn't the only update path.
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Compute road distances early (throttled; falls back to the
@@ -951,11 +1085,11 @@ struct ContentView: View {
// or the choice wouldn't take effect until the next launch.
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
ensureBestValueAlertModeIsAvailable()
locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
updateLiveActivity()
Task { await refresh(force: true) }
}
@@ -966,6 +1100,8 @@ struct ContentView: View {
#if DEBUG
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
#endif
ensureBestValueAlertModeIsAvailable()
updateAlertMonitor()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
@@ -975,8 +1111,7 @@ struct ContentView: View {
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
}
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
updateLiveActivity()
refreshRoadDistancesIfNeeded()
if FuelStore.hasPendingWatchRefreshRequest {
@@ -989,14 +1124,14 @@ struct ContentView: View {
}
.onChange(of: locationManager.current) { _, newLocation in
if let newLocation {
location = newLocation
deviceLocation = newLocation
deviceLocationAccuracy = locationManager.currentAccuracy
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
// Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
@@ -1021,36 +1156,45 @@ struct ContentView: View {
WidgetCenter.shared.reloadAllTimelines()
// Follow-search surfaces mirror this distance — re-target.
if alertsFollowsSearch {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
updateLiveActivity()
}
.onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue)
monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
ensureBackgroundMotionTrackingIfNeeded()
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
.onChange(of: alertsFuel) { _, newValue in
// Alerts fuel is independent of the Stations-tab selection —
// changing it re-targets geofences to stations selling that fuel.
FuelStore.saveAlertsFuel(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: newValue, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor(fuel: newValue)
}
.onChange(of: alertsMode) { _, newValue in
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
updateAlertMonitor()
return
}
FuelStore.saveAlertsMode(newValue)
updateAlertMonitor()
}
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
updateAlertMonitor()
}
.onChange(of: alertsFollowsSearch) { _, newValue in
// Follow search = the geofence mirrors the Stations-tab distance
// (capped at 8 mi); picking any fixed radius clears it.
FuelStore.saveAlertsFollowsSearch(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
@@ -1086,8 +1230,7 @@ struct ContentView: View {
updateLiveActivity()
// Follow-search alerts also move with the unit.
if alertsFollowsSearch {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
}
.onChange(of: priceDisplayStyle) { _, newValue in
@@ -1387,6 +1530,9 @@ struct ContentView: View {
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
alertMode: $alertsMode,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
bestValueConfigured: fuelSavingsSettings.isConfigured,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch,
@@ -1428,6 +1574,7 @@ struct ContentView: View {
alertLog: monitor.alertLog,
regionEventCount: monitor.regionEventCount,
debugFenceIdentifier: monitor.debugFenceIdentifier,
roadDistanceDebugStatus: roadDistanceDebugStatus,
onDebugFence: { monitor.registerDebugFenceAroundMe() },
onClearDebugFence: { monitor.clearDebugFence() }
)
@@ -1444,8 +1591,7 @@ struct ContentView: View {
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
/// Drag-and-drop reorder from the Favourites tab — persists the new order
@@ -1456,8 +1602,7 @@ struct ContentView: View {
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
}
private func handlePendingWatchRefreshRequest() async {
@@ -1582,8 +1727,7 @@ struct ContentView: View {
}
}
// Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateAlertMonitor()
// Fresh prices → refresh the Live Activity pill too.
updateLiveActivity()
}
@@ -1786,6 +1930,13 @@ struct StationRow: View {
@MainActor
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
@Published var current: Coordinate?
/// Horizontal accuracy (metres) of the fix behind `current`. Published
/// alongside it so a surface can tell a real GPS fix from a Wi-Fi guess.
@Published var currentAccuracy: Double?
/// How imprecise a fix may be before it is refused outright (see
/// `didUpdateLocations`). Wi-Fi/cell fixes routinely land at 500 m–3 km;
/// a GPS fix is tens of metres, so this only ever rejects the former.
static let maxTrustedFixAccuracyMeters: Double = 300
/// Called after every location fix (foreground AND background wake-ups).
/// The app uses it to re-register geofences around the new position —
/// SwiftUI's `.onChange` never runs in the background, so this delegate
@@ -1847,7 +1998,39 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
applyFix(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude,
accuracy: loc.horizontalAccuracy)
}
/// One position report. Real delegate fixes and the DEBUG `FB_FAKE_FIX`
/// probe both land here, so the probe exercises the guard below instead of
/// bypassing it.
///
/// Junk-fix guard: CoreLocation hands over Wi-Fi/cell fixes that can sit
/// kilometres out, plus negative accuracies meaning "invalid". Adopting one
/// as the user's position re-bases EVERY distance on screen — the fix lands
/// outside the route cache's 600 m ring, so each row falls back to the
/// straight line marked "Approx." even though its routed figure was
/// correct, and refreshing never helped because the junk fix kept arriving.
/// A coarse fix that stays near the position we already have is noise, so
/// the last trustworthy fix is kept; a coarse fix in another city is
/// evidence of real travel and IS accepted, or the app would go on serving
/// the old city's distances.
func applyFix(lat: Double, lng: Double, accuracy: Double) {
guard accuracy > 0 else { return }
let basis = current ?? FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
if let basis,
accuracy > Self.maxTrustedFixAccuracyMeters,
FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)
<= FuelStore.roadDistanceCoarseFixRadiusMeters {
#if DEBUG
print("FB-FIX rejected coarse fix acc=\(String(format: "%.0f", accuracy))m "
+ "offset=\(String(format: "%.0f", FuelStore.distanceMetersBetween(lat1: basis.lat, lng1: basis.lng, lat2: lat, lng2: lng)))m")
#endif
return
}
currentAccuracy = accuracy
current = Coordinate(lat: lat, lng: lng)
// Drive the widget: every significant move re-orders the widget list
// around the new position immediately, instead of waiting for the
// widget's own 5-minute timeline tick. Throttled — WidgetKit ignores
+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
+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()
+31
View File
@@ -54,6 +54,7 @@ struct SettingsView: View {
var regionEventCount: Int = 0
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
var debugFenceIdentifier: String?
var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
var onDebugFence: () -> Void = {}
/// Clears the armed fence (monitor.clearDebugFence).
@@ -400,6 +401,36 @@ struct SettingsView: View {
Text("Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.")
}
Section {
LabeledContent("Status", value: roadDistanceDebugStatus.summary)
LabeledContent("Updated", value: roadDistanceDebugStatus.updatedAt.formatted(date: .omitted, time: .standard))
if let lat = roadDistanceDebugStatus.originLat,
let lng = roadDistanceDebugStatus.originLng {
LabeledContent("Origin", value: String(format: "%.5f, %.5f", lat, lng))
}
LabeledContent("Pool", value: "\(roadDistanceDebugStatus.stationPoolCount)")
LabeledContent("Candidates", value: "\(roadDistanceDebugStatus.candidateCount)")
LabeledContent("Cached", value: "\(roadDistanceDebugStatus.cachedCount)")
if roadDistanceDebugStatus.routedCount > 0 || roadDistanceDebugStatus.failedCount > 0 {
LabeledContent("Routed", value: "\(roadDistanceDebugStatus.routedCount)")
LabeledContent("Failed", value: "\(roadDistanceDebugStatus.failedCount)")
}
if let elapsed = roadDistanceDebugStatus.elapsedSeconds {
LabeledContent("Elapsed", value: String(format: "%.1fs", elapsed))
}
if let moved = roadDistanceDebugStatus.movedMeters {
LabeledContent("Moved", value: String(format: "%.0f m", moved))
}
if let remaining = roadDistanceDebugStatus.throttleSecondsRemaining {
LabeledContent("Throttle", value: String(format: "%.0f s remaining", max(0, remaining)))
}
LabeledContent("Ready", value: roadDistanceDebugStatus.complete ? "yes" : "no")
} header: {
Text("Road distance fill")
} footer: {
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
}
Section {
if monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
+3
View File
@@ -11,6 +11,9 @@ extension Notification.Name {
/// Posted when "Accurate distances" is switched, so the browse layer can
/// re-render and start a fill from where the user actually is.
static let fuelBoardAccurateDistancesChanged = Notification.Name("FuelBoardAccurateDistancesChanged")
/// Posted whenever the road-distance fill starts, finishes, or is gated.
/// Settings → Debug subscribes so the tester can see why Approx. remains.
static let fuelBoardRoadDistanceDebugUpdated = Notification.Name("FuelBoardRoadDistanceDebugUpdated")
}
@MainActor
+6
View File
@@ -192,6 +192,12 @@
"Price-drop alerts" = "Price-drop alerts";
"Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise." = "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.";
"Cheapest station" = "Cheapest station";
"Alert type" = "Alert type";
"Best Value" = "Best Value";
"Minimum saving" = "Minimum saving";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.";
"Proximity alerts" = "Proximity alerts";
"When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.";
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";