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