Debug: live alert trace — surface every gate decision from the alert chain

ProximityMonitor now records each stage the live chain reaches (Settings →
Debug → Alert trace, in-memory, newest-first, capped at 8):
- didEnterRegion arrival (regionEventCount + entry line) — distinguishes
  'geofence never fires' from 'gate blocked the alert'
- gate misses: station not in loaded list, hourly dedup (with minutes ago),
  no fuel sellers within radius after fresh fetch, entered-not-cheapest
  (with the cheaper station + price)
- the previously-silent fresh-fetch catch now logs the error
- 'alert scheduled' once UNUserNotificationCenter.add is called — if that
  line appears but no banner shows, the failure is delivery/permission,
  not alert logic

SettingsView shows the trace (symbol per kind) + region-event count; the
existing Test-alert button already probes notification permission and
reports denial inline. 83 tests, Release build green.
This commit is contained in:
FuelBoard Contributor
2026-08-15 11:10:10 +01:00
parent d0d25811c7
commit 494412ba99
3 changed files with 116 additions and 7 deletions
+60 -6
View File
@@ -18,6 +18,27 @@ struct DebugLocationStatus: Equatable {
var cheapestDistanceKM: Double?
}
/// One recorded event in the live alert trace (Settings Debug). Lets the
/// tester see exactly which stage of the alert chain ran whether a region
/// event arrived at all, which gate (if any) suppressed the alert, and when
/// an alert was scheduled. In-memory only: resets on relaunch.
struct AlertLogEntry: Identifiable, Equatable {
enum Kind: String, Equatable {
/// A geofence entry event reached the monitor (didEnterRegion).
case entry
/// A gate blocked the alert (dedup, not-cheapest, station missing).
case gate
/// A non-recoverable failure (fresh-price fetch threw).
case error
/// An alert was scheduled with UNUserNotificationCenter.
case fired
}
let id = UUID()
let date: Date
let kind: Kind
let text: String
}
/// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations
/// list computes (selected fuel, stationLimit radius, current location), so
/// the debug "Cheapest in range" row matches the TOP badge in the app
@@ -69,6 +90,12 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Debug so a silently-failed `startMonitoring` (region budget exceeded,
/// auth not granted, radius too large) is visible instead of invisible.
@Published private(set) var lastRegionError: String?
/// Live alert trace (Settings Debug): every stage the chain reached,
/// newest first, capped at 8. Empty = no region event has ever arrived.
@Published private(set) var alertLog: [AlertLogEntry] = []
/// How many `didEnterRegion` callbacks this session proof the geofences
/// actually fire (vs. a gate silently blocking every alert).
@Published private(set) var regionEventCount = 0
private let manager = CLLocationManager()
private var stations: [FuelStation] = []
@@ -182,6 +209,12 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled }
/// Records a stage in the live alert trace, newest first, capped at 8.
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
if alertLog.count > 8 { alertLog.removeLast() }
}
/// Recomputes the Settings Debug location snapshot from current state.
/// "In range" uses the SAME criteria as live alerts: stations selling the
/// monitored fuel within the alert radius of the last known location.
@@ -250,6 +283,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
Task { @MainActor in
// Trace: prove the geofence event itself arrived. This is the
// single point that distinguishes "no region delivery" from
// "delivery fine, a gate blocked the alert".
self.regionEventCount += 1
self.logAlert(.entry, "didEnterRegion — \(region.identifier)")
self.handleEntry(region)
}
}
@@ -277,10 +315,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// Fall back to cached data on background wake (stations may not be
// fetched yet when the app is relaunched by a region event).
if stations.isEmpty { stations = FuelStore.loadStations() }
guard let station = stations.first(where: { $0.id == stationID }) else { return }
guard let station = stations.first(where: { $0.id == stationID }) else {
logAlert(.gate, "region \(stationID) — station not in loaded list (Stations tab not fetched yet?)")
return
}
// Dedup: one alert per station per hour.
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return }
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 {
logAlert(.gate, "\(station.name) — dedup: already alerted \(Int(Date().timeIntervalSince(last) / 60)) min ago")
return
}
Task {
do {
@@ -293,9 +337,16 @@ 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]! }),
cheapest.id == station.id,
let price = cheapest.prices[fuel] else { return }
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(
@@ -306,7 +357,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[stationID] = Date()
} catch {
// Silent geofence state stays valid for next entry.
// Trace the silent catch a relay failure drops the alert
// with no banner, which previously looked like "no alert".
logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)")
}
}
}
@@ -341,6 +394,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
addAlertRequest(content: content, station: station)
}
logAlert(.fired, "alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
}