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:
@@ -403,7 +403,9 @@ struct ContentView: View {
|
||||
debugStatus: monitor.debugStatus,
|
||||
appCheapest: appCheapestStatus,
|
||||
regionError: monitor.lastRegionError,
|
||||
monitoredCount: monitor.monitoredStationIDs.count
|
||||
monitoredCount: monitor.monitoredStationIDs.count,
|
||||
alertLog: monitor.alertLog,
|
||||
regionEventCount: monitor.regionEventCount
|
||||
)
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ struct SettingsView: View {
|
||||
/// Live geofence count (monitor.monitoredStationIDs.count) — Debug-only
|
||||
/// status; the user-facing Alerts tab no longer exposes fence plumbing.
|
||||
var monitoredCount: Int = 0
|
||||
/// Live alert trace (monitor.alertLog) — every stage the alert chain
|
||||
/// reached this session (region event / gate / scheduled). Debug-only.
|
||||
var alertLog: [AlertLogEntry] = []
|
||||
/// Region events received this session (monitor.regionEventCount).
|
||||
var regionEventCount: Int = 0
|
||||
|
||||
@StateObject private var tipStore = TipStore()
|
||||
@State private var showTipAlert = false
|
||||
@@ -228,6 +233,34 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Geofence monitoring")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Region events this session", value: "\(regionEventCount)")
|
||||
if alertLog.isEmpty {
|
||||
Text("No alert activity yet — drive across a geofence boundary, or tap the test-alert button above to exercise the chain without a geofence.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(alertLog) { entry in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Image(systemName: alertSymbol(for: entry.kind))
|
||||
.foregroundStyle(alertColor(for: entry.kind))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(entry.text)
|
||||
.font(.caption2)
|
||||
.textSelection(.enabled)
|
||||
Text(entry.date, format: .dateTime.hour().minute().second())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Alert trace")
|
||||
} footer: {
|
||||
Text("One line per stage the live chain reached: a geofence entry, each gate that passed or blocked the alert (dedup, cheapest-within-radius, fresh fetch), then the scheduled alert. If the trace shows 'alert scheduled' but no banner appears, the failure is in notification delivery itself (permission or background delivery), not the alert logic.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -442,6 +475,26 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alert trace rendering
|
||||
|
||||
private func alertSymbol(for kind: AlertLogEntry.Kind) -> String {
|
||||
switch kind {
|
||||
case .entry: return "location.circle.fill"
|
||||
case .gate: return "arrow.right.circle"
|
||||
case .error: return "xmark.octagon.fill"
|
||||
case .fired: return "bell.badge.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func alertColor(for kind: AlertLogEntry.Kind) -> Color {
|
||||
switch kind {
|
||||
case .entry: return .green
|
||||
case .gate: return .orange
|
||||
case .error: return .red
|
||||
case .fired: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
private func sendTestAlert() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
Task { @MainActor in
|
||||
|
||||
Reference in New Issue
Block a user