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:
@@ -253,6 +253,11 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
let pricePencePerLitre: Double
|
||||
}
|
||||
|
||||
struct BestValueAlertCandidate: Equatable {
|
||||
let station: FuelStation
|
||||
let result: FuelSavingsResult
|
||||
}
|
||||
|
||||
enum FuelSavingsError: Error, Equatable {
|
||||
case invalidMPG
|
||||
case invalidAmount
|
||||
@@ -417,6 +422,64 @@ enum FuelSavingsCalculator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func bestValueAlertCandidate(
|
||||
stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
settings: FuelSavingsSettings,
|
||||
amount: FuelSavingsFillAmount,
|
||||
minimumNetSavingGBP: Double,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
||||
) -> BestValueAlertCandidate? {
|
||||
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||
guard !sellers.isEmpty else { return nil }
|
||||
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
||||
var best: BestValueAlertCandidate?
|
||||
var bestDistance = Double.greatestFiniteMagnitude
|
||||
var bestPrice = Double.infinity
|
||||
var baselineJourneyCache: FuelSavingsJourney?
|
||||
var baselineJourneyLoaded = false
|
||||
for station in sellers {
|
||||
let journey = journeyMiles(station)
|
||||
let baselineJourney: FuelSavingsJourney? = {
|
||||
guard let baseline else { return nil }
|
||||
if baseline.id == station.id { return journey }
|
||||
if !baselineJourneyLoaded {
|
||||
baselineJourneyCache = journeyMiles(baseline)
|
||||
baselineJourneyLoaded = true
|
||||
}
|
||||
return baselineJourneyCache
|
||||
}()
|
||||
guard case .available(let result) = FuelSavingsEstimator.availability(
|
||||
station: station,
|
||||
fuel: fuel,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: journey,
|
||||
baselineJourney: baselineJourney
|
||||
), result.status == .worthTheDrive,
|
||||
result.netSavingGBP >= minimumNetSavingGBP else { continue }
|
||||
let distance = journey?.miles ?? .greatestFiniteMagnitude
|
||||
let price = station.prices[fuel] ?? .infinity
|
||||
guard let current = best else {
|
||||
best = BestValueAlertCandidate(station: station, result: result)
|
||||
bestDistance = distance
|
||||
bestPrice = price
|
||||
continue
|
||||
}
|
||||
if result.netSavingGBP > current.result.netSavingGBP ||
|
||||
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
|
||||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
|
||||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
|
||||
best = BestValueAlertCandidate(station: station, result: result)
|
||||
bestDistance = distance
|
||||
bestPrice = price
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns stations into savings estimates.
|
||||
|
||||
@@ -62,6 +62,20 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum AlertMode: String, Codable, CaseIterable, Identifiable {
|
||||
case cheapest
|
||||
case bestValue
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .cheapest: return "Cheapest station"
|
||||
case .bestValue: return "Best Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAG value rating for a station's price against the cheapest available.
|
||||
/// Thumb rules: within 1.5p = green (great value), within 4p = amber (okay),
|
||||
/// beyond that = red (pricey). Deliberately coarse so it reads at a glance.
|
||||
@@ -421,6 +435,8 @@ struct FuelStore {
|
||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||
static let alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value
|
||||
static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £
|
||||
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
||||
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
|
||||
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
|
||||
@@ -585,6 +601,29 @@ struct FuelStore {
|
||||
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
|
||||
}
|
||||
|
||||
/// A fix the WIDGET EXTENSION obtained on its own, kept apart from
|
||||
/// `locationKey`.
|
||||
///
|
||||
/// The extension's fix comes from its own `requestLocation()` and is often
|
||||
/// coarse; writing it over `locationKey` replaced the app's GPS fix with a
|
||||
/// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is
|
||||
/// what the app seeds itself with on launch and what
|
||||
/// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route
|
||||
/// cache — so a widget fix must never be mistaken for the app's position.
|
||||
static let widgetLocationKey = "fuelboard.widgetLocation"
|
||||
|
||||
static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) {
|
||||
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey)
|
||||
}
|
||||
|
||||
static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? {
|
||||
let raw = loadString(service: widgetLocationKey)
|
||||
guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }),
|
||||
parts.count == 3 else { return nil }
|
||||
return (Coordinate(lat: parts[0], lng: parts[1]),
|
||||
Date(timeIntervalSince1970: parts[2]))
|
||||
}
|
||||
|
||||
// MARK: Selected fuel
|
||||
|
||||
static func loadSelectedFuel() -> FuelType {
|
||||
@@ -613,6 +652,34 @@ struct FuelStore {
|
||||
saveString(fuel.rawValue, service: alertsFuelKey)
|
||||
}
|
||||
|
||||
static func loadAlertsMode() -> AlertMode {
|
||||
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
||||
return mode
|
||||
}
|
||||
return .cheapest
|
||||
}
|
||||
|
||||
static func saveAlertsMode(_ mode: AlertMode) {
|
||||
saveString(mode.rawValue, service: alertsModeKey)
|
||||
}
|
||||
|
||||
static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0]
|
||||
|
||||
static func loadAlertsBestValueThresholdGBP() -> Double {
|
||||
if let raw = loadString(service: alertsBestValueThresholdKey),
|
||||
let value = Double(raw),
|
||||
bestValueAlertThresholdOptionsGBP.contains(value) {
|
||||
return value
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
static func saveAlertsBestValueThresholdGBP(_ threshold: Double) {
|
||||
let snapped = bestValueAlertThresholdOptionsGBP
|
||||
.min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0
|
||||
saveString(String(snapped), service: alertsBestValueThresholdKey)
|
||||
}
|
||||
|
||||
// MARK: Sort mode
|
||||
|
||||
static func loadSortMode() -> SortMode {
|
||||
@@ -1343,6 +1410,78 @@ struct FuelStore {
|
||||
<= roadDistanceOriginToleranceMeters
|
||||
}
|
||||
|
||||
/// How imprecise a fix must be before its distance from the route cache's
|
||||
/// anchor stops being read as "the user moved". The widget extension runs its
|
||||
/// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and
|
||||
/// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
|
||||
/// in the day) — hundreds of metres from the app's GPS anchor.
|
||||
static let roadDistanceCoarseFixAccuracyMeters: Double = 150
|
||||
|
||||
/// How far a coarse fix may sit from the anchor before it is taken as genuine
|
||||
/// travel rather than fix noise.
|
||||
static let roadDistanceCoarseFixRadiusMeters: Double = 3_000
|
||||
|
||||
/// Where a surface should read displayed distances FROM, and whether routed
|
||||
/// distances are usable there.
|
||||
///
|
||||
/// Routed distances are anchored to the fix that produced them (the app's GPS
|
||||
/// fix). A surface in another process — the widget extension — gets its OWN
|
||||
/// fix, which can sit hundreds of metres from that anchor. Reading that as
|
||||
/// "the user moved away from the cache" downgraded EVERY row in the widget to
|
||||
/// straight-line + "Approx." while the app, whose fix IS the anchor, showed
|
||||
/// routed values: one station read differently on two faces, and refreshing
|
||||
/// never fixed it because each new widget fix landed outside the ring again.
|
||||
/// Resolve the origin once, here, so every surface labels from the same place.
|
||||
struct DistanceDisplayOrigin: Equatable {
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
/// True when the caller may serve routed distances from this origin.
|
||||
let routed: Bool
|
||||
/// Which fix decided it — diagnostics only.
|
||||
let source: String
|
||||
}
|
||||
|
||||
static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double,
|
||||
candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin {
|
||||
let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
|
||||
routed: false, source: "straightLine")
|
||||
guard accurateDistancesEnabled,
|
||||
let cache = loadRoadDistances(), cache.complete == true else { return straightLine }
|
||||
|
||||
let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng)
|
||||
if candidateOffset <= roadDistanceOriginToleranceMeters {
|
||||
return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
|
||||
routed: true, source: "candidate")
|
||||
}
|
||||
|
||||
// The candidate fix is outside the ring. A precise fix there means the
|
||||
// user really has moved, and the honest answer is the straight line from
|
||||
// where they are. A COARSE fix (or one that reported no accuracy) is fix
|
||||
// noise, as is any disagreement under a kilometre — neither is good
|
||||
// evidence that the routes are stale, so fall back to the app's own fix,
|
||||
// which is the one the cached routes were built around.
|
||||
let accuracy = candidateAccuracyMeters ?? -1
|
||||
let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters
|
||||
guard (fixIsCoarse || candidateOffset <= 1_000),
|
||||
candidateOffset <= roadDistanceCoarseFixRadiusMeters,
|
||||
let appFix = loadLocationWithDate(),
|
||||
haversineMetres(cache.sourceLat, cache.sourceLng,
|
||||
appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters,
|
||||
// The stored fix must belong to this cache, not predate it by hours.
|
||||
appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine }
|
||||
|
||||
return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng,
|
||||
routed: true, source: "appFix")
|
||||
}
|
||||
|
||||
/// Metres between two coordinates — the same great-circle measure the
|
||||
/// origin/anchor tests use, exposed so a surface can report how far its fix
|
||||
/// sits from the anchor (diagnostics only).
|
||||
static func distanceMetersBetween(lat1: Double, lng1: Double,
|
||||
lat2: Double, lng2: Double) -> Double {
|
||||
haversineMetres(lat1, lng1, lat2, lng2)
|
||||
}
|
||||
|
||||
private static func haversineMetres(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
|
||||
@@ -27,6 +27,11 @@ final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
/// Requests the current location. Returns nil on denial, timeout, or
|
||||
/// no-permission — callers should fall back to cached data.
|
||||
///
|
||||
/// The `CLLocation` is returned intact so callers can read
|
||||
/// `horizontalAccuracy`: the extension is often handed a coarse Wi-Fi/cell
|
||||
/// fix, and a coarse fix must not be mistaken for the user having moved away
|
||||
/// from the app's routed distances (see `FuelStore.distanceDisplayOrigin`).
|
||||
func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? {
|
||||
guard CLLocationManager.locationServicesEnabled() else { return nil }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user