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