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
+53
View File
@@ -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