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
+57 -26
View File
@@ -116,21 +116,23 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
}
private func makeEntry(configuration: Configuration) async -> FuelPriceEntry {
let entry = await makeEntryCore(configuration: configuration)
writeDiagBeacon(entry: entry)
let (entry, originSource) = await makeEntryCore(configuration: configuration)
writeDiagBeacon(entry: entry, originSource: originSource)
return entry
}
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain
/// (app-readable, one slot per intent type) and records what a widget
/// timeline produced for Settings → Widget Diagnostics. Deliberately
/// outside the timeline result — can never affect rendering.
private func writeDiagBeacon(entry: FuelPriceEntry) {
/// outside the timeline result — can never affect rendering. No user
/// coordinates are written, only which fix decided the distances.
private func writeDiagBeacon(entry: FuelPriceEntry, originSource: String) {
let d = entry.data
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
let first = d.stations.first
let json = """
{"intent":"\(intentType)","source":"\(d.locationSource)",\
"origin":"\(originSource)",\
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
@@ -140,31 +142,58 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// App Store build must not contain a private LAN relay fallback.
}
private func makeEntryCore(configuration: Configuration) async -> FuelPriceEntry {
private func makeEntryCore(configuration: Configuration) async -> (FuelPriceEntry, String) {
// Per-widget config: fuel + sort + distance come from THIS widget instance.
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
let isFavourites = configuration.sort == .favourites
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
let radiusKM = Double(configuration.distance.miles) * 1.60934
// 1) Try a fresh location fix (bounded to a few seconds).
var location = await WidgetLocationFetcher.shared.currentLocation().map {
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
// accuracy is kept: the extension runs its own CLLocationManager and is
// routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
// in the day), which must not be read as "the user moved away from the
// route cache".
let fix = await WidgetLocationFetcher.shared.currentLocation()
var location = fix.map {
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
}
let fixAccuracy = fix?.horizontalAccuracy
var source = "live"
// 2) Fall back to the app's cached fix.
// 2) Fall back to the app's cached fix, then to our own last fix.
if location == nil, let cached = FuelStore.loadLocation() {
location = cached
source = "cached"
}
if location == nil, let own = FuelStore.loadWidgetLocation() {
location = own.coordinate
source = "widgetCached"
}
if location == nil {
source = "none"
}
// Persist a live fix so the app shows fresh coords on next launch.
// Persist OUR fix under its own key. It must never be written over the
// app's GPS fix: that stored coordinate is what the app seeds itself
// with, and it is the fix the route cache is anchored to.
if let location {
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
FuelStore.saveWidgetLocation(lat: location.lat, lng: location.lng)
}
// The origin every distance on this face is measured from. Routed values
// are anchored to the app's fix, so when our own fix is coarse and lands
// away from that anchor this labels from the app's fix instead of
// collapsing every row to straight-line + "Approx." — see
// FuelStore.distanceDisplayOrigin.
var origin: Coordinate?
var originSource = "none"
if let location {
let resolved = FuelStore.distanceDisplayOrigin(candidateLat: location.lat,
candidateLng: location.lng,
candidateAccuracyMeters: fixAccuracy)
origin = Coordinate(lat: resolved.lat, lng: resolved.lng)
originSource = resolved.source
}
let unit = FuelStore.loadDistanceUnit()
@@ -190,8 +219,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
.map(\.station)
var refreshed: [FuelStation] = fuelFavourites
if let location,
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
if let origin,
let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
}
@@ -206,8 +235,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// previously left the widget stuck on sample data). Falls back to the
// shared cache, then to samples.
var stations: [FuelStation] = []
if let location {
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
if let origin {
if let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
stations = fetched
}
} else {
@@ -223,12 +252,14 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
}
if stations.isEmpty { stations = FuelStore.loadStations() }
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
if let location {
if let origin {
// STRICT: cached data fetched around another location must never
// leak out-of-radius stations into the widget.
// leak out-of-radius stations into the widget. Measured from the
// SAME origin the rows are labelled with, so the radius and the
// displayed distances can never disagree.
stations = stations.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
userLat: origin.lat, userLng: origin.lng)
}
}
let filtered = stations.filter { $0.prices[fuel] != nil }
@@ -236,9 +267,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
case .closest:
// Nearest first; price only breaks ties.
ordered = filtered.sorted { lhs, rhs in
if let location {
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng)
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
if let origin {
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
if lDist != rDist { return lDist < rDist }
}
return lhs.prices[fuel]! < rhs.prices[fuel]!
@@ -250,23 +281,23 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
if let location {
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
if let origin {
return FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
}
return false
}
}
}
return FuelPriceEntry(
return (FuelPriceEntry(
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: location, locationSource: source,
location: origin, locationSource: source,
unit: unit
)
)
), originSource)
}
/// One focused fetch around the widget's location. Public HTTPS mirror only;