diff --git a/Where/WhereCore/Sources/DataIssueAlertReconciler.swift b/Where/WhereCore/Sources/DataIssueAlertReconciler.swift new file mode 100644 index 00000000..6c07771c --- /dev/null +++ b/Where/WhereCore/Sources/DataIssueAlertReconciler.swift @@ -0,0 +1,90 @@ +import Foundation +import LogKit + +/// Owns the "you have issues to resolve" notification intent and the +/// reconciliation that keeps that notification in sync with the current year's +/// unresolved data-issue count. Mirrors `DailySummaryReconciler`: the schedule +/// re-runs on launch/foreground and after every committed write, so the alert +/// appears once issues exist and clears the moment the last one is resolved or +/// dismissed. +public actor DataIssueAlertReconciler { + private let scheduler: any DataIssueAlertScheduling + private let scanner: DataIssueScanner + private let calendar: Calendar + private let now: @Sendable () -> Date + + private var config = Configuration() + + private struct Configuration { + var enabled = false + var time: ReminderTime = .defaultEvening + var driftThresholdMeters = Double(DriftThreshold.default.rawValue) + } + + private static let logger = WhereLog.channel(.dataIssueAlertReconciler) + + init( + scheduler: any DataIssueAlertScheduling, + scanner: DataIssueScanner, + calendar: Calendar, + now: @escaping @Sendable () -> Date, + ) { + self.scheduler = scheduler + self.scanner = scanner + self.calendar = calendar + self.now = now + } + + /// Set the user's issue-alert intent (enabled + fire time + the drift + /// threshold the scan runs at), request notification permission when + /// enabling, then reconcile. Safe to call on every launch and whenever the + /// user changes the setting. + public func configure(enabled: Bool, time: ReminderTime, driftThresholdMeters: Double) async { + config = Configuration( + enabled: enabled, + time: time, + driftThresholdMeters: driftThresholdMeters, + ) + if enabled { + _ = await scheduler.requestAuthorization() + } + await reconcile() + } + + /// Recompute the current-year unresolved-issue count and push it to the + /// scheduler: schedule the alert while issues remain (and the user has it + /// enabled), clear it otherwise. + func reconcile() async { + guard config.enabled else { + await scheduler.reconcile(enabled: false, time: config.time, body: "") + return + } + + let year = calendar.component(.year, from: now()) + do { + let count = try await scanner.currentIssueCount( + year: year, + driftThresholdMeters: config.driftThresholdMeters, + ) + await scheduler.reconcile( + enabled: count > 0, + time: config.time, + body: Self.body(count: count), + ) + } catch { + Self.logger.error( + "Failed to reconcile issue alerts: \(error.localizedDescription)", + ) + } + } + + /// The alert body, pluralized on the issue count (e.g. "1 issue to resolve" + /// / "3 issues to resolve"). + private static func body(count: Int) -> String { + String( + localized: "dataIssues.notification.body", + defaultValue: "\(count) issues to resolve", + bundle: .module, + ) + } +} diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index e50b7124..417f231d 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -126,6 +126,31 @@ public actor DataIssueScanner { return sorted } + /// Count of unresolved issues for `year`, for headless callers (the app-icon + /// badge, the issue-alert notification) that don't have the UI's + /// `RegionRanking` on hand. Derives `primaryRegions` through the shared + /// `Region.primaryRegions` helper — the *same* definition `RegionRanking` + /// builds the Primary/Elsewhere split from, so the "primary" rule lives in + /// one place and this count can't disagree with what the Resolve tab shows + /// (no ranking logic is duplicated here). This reads the report once to rank + /// regions and `issues(...)` reads it again on a cache miss, so callers that + /// already hold a report (the hot badge path in `ReminderReconciler`) should + /// call `issues(...)` with `Region.primaryRegions(...)` directly to avoid the + /// second read; this convenience is for the cold notification path. + public func currentIssueCount( + year: Int, + driftThresholdMeters: Double, + force: Bool = false, + ) async throws -> Int { + let report = try await reportReader.yearReport(for: year) + return try await issues( + year: year, + primaryRegions: Region.primaryRegions(in: report.totals), + driftThresholdMeters: driftThresholdMeters, + force: force, + ).count + } + /// Drop the cache so the next `issues(...)` recomputes regardless of throttle. public func invalidate() { cache = nil diff --git a/Where/WhereCore/Sources/DayJournal.swift b/Where/WhereCore/Sources/DayJournal.swift index fa3e34eb..1e113f9b 100644 --- a/Where/WhereCore/Sources/DayJournal.swift +++ b/Where/WhereCore/Sources/DayJournal.swift @@ -14,6 +14,11 @@ public actor DayJournal { private let store: any WhereStore private let aggregator: DayAggregator private let reminders: ReminderReconciler + private let issueAlerts: DataIssueAlertReconciler + /// Shared scanner behind the badge/notification issue count. Dropped inline + /// after each committed write so the reconciles below recount from fresh + /// data rather than racing the scanner's async store-change invalidation. + private let issueScanner: DataIssueScanner private let widgets: WidgetSnapshotPublisher private static let logger = WhereLog.channel(.dayJournal) @@ -22,11 +27,15 @@ public actor DayJournal { store: any WhereStore, aggregator: DayAggregator, reminders: ReminderReconciler, + issueAlerts: DataIssueAlertReconciler, + issueScanner: DataIssueScanner, widgets: WidgetSnapshotPublisher, ) { self.store = store self.aggregator = aggregator self.reminders = reminders + self.issueAlerts = issueAlerts + self.issueScanner = issueScanner self.widgets = widgets } @@ -68,7 +77,9 @@ public actor DayJournal { let key = aggregator.calendar.startOfDay(for: date) let presence = DayPresence(date: key, regions: regions, audit: audit) try await store.perform { try await store.setManualDay(presence) } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info( "Added manual day \(Self.dayLogLabel(key, calendar: aggregator.calendar)) with \(regions.count) region(s)", @@ -88,7 +99,9 @@ public actor DayJournal { let key = aggregator.calendar.startOfDay(for: date) let presence = DayPresence(date: key, regions: regions, isAuthoritative: true, audit: audit) try await store.perform { try await store.setManualDay(presence) } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info( "Overrode day \(Self.dayLogLabel(key, calendar: aggregator.calendar)) with \(regions.count) region(s)", @@ -102,7 +115,9 @@ public actor DayJournal { public func clearManualDay(date: Date) async throws { let key = aggregator.calendar.startOfDay(for: date) try await store.perform { try await store.clearManualDay(key) } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info( "Cleared manual overlay for day \(Self.dayLogLabel(key, calendar: aggregator.calendar))", @@ -135,7 +150,9 @@ public actor DayJournal { ) } } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info( "Backfilled \(dayKeys.count) manual day(s) with \(regions.count) region(s)", @@ -147,7 +164,9 @@ public actor DayJournal { public func clearYear(_ year: Int) async throws { let interval = aggregator.yearInterval(year: year) try await store.perform { try await store.clear(in: interval) } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info("Cleared year \(year)") } @@ -159,7 +178,9 @@ public actor DayJournal { /// store immediately rather than relying on a later launch step. public func eraseAllData() async throws { try await store.perform { try await store.clearAll() } + await issueScanner.invalidate() await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() Self.logger.info("Erased all store data") } @@ -183,10 +204,18 @@ public actor DayJournal { public func dismissIssue(key: String) async throws { try await store.perform { try await store.setIssueDismissed(true, key: key) } + // Dismissing removes the issue from the unresolved count, so the badge + // and the "issues to resolve" notification both have to recount. + await issueScanner.invalidate() + await reminders.reconcile() + await issueAlerts.reconcile() } public func restoreIssue(key: String) async throws { try await store.perform { try await store.setIssueDismissed(false, key: key) } + await issueScanner.invalidate() + await reminders.reconcile() + await issueAlerts.reconcile() } private static func dayLogLabel(_ day: Date, calendar: Calendar) -> String { diff --git a/Where/WhereCore/Sources/Region+Ordering.swift b/Where/WhereCore/Sources/Region+Ordering.swift index 016c356e..f668eb83 100644 --- a/Where/WhereCore/Sources/Region+Ordering.swift +++ b/Where/WhereCore/Sources/Region+Ordering.swift @@ -40,4 +40,22 @@ extension Region { return lhsOrder < rhsOrder } } + + /// The "primary" regions for a year's `totals`: the top `count` regions by + /// day count, with `.other` excluded (it's a catch-all bucket, never a + /// headline place). The single definition of "primary", shared by the + /// Primary/Elsewhere split (`RegionRanking`) and the headless data-issue scan + /// that drives the badge and notification — so the count the badge shows + /// can't disagree with what the Resolve tab would compute. + public static func primaryRegions(in totals: [Region: Int], count: Int = 2) -> [Region] { + rankedByDayCount( + totals.filter { $0.value > 0 }, + days: { $0.value }, + region: { $0.key }, + ) + .map(\.key) + .filter { $0 != .other } + .prefix(max(0, count)) + .map(\.self) + } } diff --git a/Where/WhereCore/Sources/ReminderReconciler.swift b/Where/WhereCore/Sources/ReminderReconciler.swift index 06b446a5..be0689a0 100644 --- a/Where/WhereCore/Sources/ReminderReconciler.swift +++ b/Where/WhereCore/Sources/ReminderReconciler.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit /// Owns the daily "log before the day ends" reminder intent and the /// reconciliation that keeps the scheduled reminders + badge in sync with the @@ -12,6 +13,10 @@ import LogKit public actor ReminderReconciler { private let scheduler: any LoggingReminderScheduling private let reportReader: ReportReader + /// Scanner shared with the Resolve tab, used to fold the unresolved + /// data-issue count into the app-icon badge (so the badge means "things that + /// need you", not just missing days). + private let issueScanner: DataIssueScanner private let calendar: Calendar private let now: @Sendable () -> Date private let windowDays: Int @@ -26,6 +31,11 @@ public actor ReminderReconciler { private struct Configuration { var enabled = false var time: ReminderTime = .defaultEvening + /// Whether the data-issue count contributes to the badge. Independent of + /// `enabled`, so issues still badge the icon when logging reminders are off. + var issueAlertsEnabled = false + /// The GPS border-drift threshold the badge's issue scan runs at. + var driftThresholdMeters = Double(DriftThreshold.default.rawValue) } /// How many days past today the per-day reminders are scheduled ahead, so a @@ -38,23 +48,36 @@ public actor ReminderReconciler { init( scheduler: any LoggingReminderScheduling, reportReader: ReportReader, + issueScanner: DataIssueScanner, calendar: Calendar, now: @escaping @Sendable () -> Date, windowDays: Int = ReminderReconciler.defaultWindowDays, ) { self.scheduler = scheduler self.reportReader = reportReader + self.issueScanner = issueScanner self.calendar = calendar self.now = now self.windowDays = windowDays } - /// Set the user's reminder intent (enabled + time of day), request - /// notification permission when enabling, then reconcile the scheduled - /// reminders and badge. Safe to call on every launch and whenever the user - /// changes the setting. - public func configure(enabled: Bool, time: ReminderTime) async { - config = Configuration(enabled: enabled, time: time) + /// Set the user's reminder intent (enabled + time of day) plus the inputs the + /// badge's data-issue contribution needs (whether issue alerts are on and the + /// current drift threshold), request notification permission when enabling, + /// then reconcile the scheduled reminders and badge. Safe to call on every + /// launch and whenever the user changes a setting. + public func configure( + enabled: Bool, + time: ReminderTime, + issueAlertsEnabled: Bool, + driftThresholdMeters: Double, + ) async { + config = Configuration( + enabled: enabled, + time: time, + issueAlertsEnabled: issueAlertsEnabled, + driftThresholdMeters: driftThresholdMeters, + ) if enabled { _ = await scheduler.requestAuthorization() } @@ -74,11 +97,14 @@ public actor ReminderReconciler { await scheduler.isAuthorized() } - /// Cheap reconcile for the GPS ingest path: only runs when reminders are on - /// and today isn't already known to be covered, so a burst of - /// significant-change samples doesn't trigger a full-year scan each time. + /// Cheap reconcile for the GPS ingest path: skips work once today is already + /// known to be covered, so a burst of significant-change samples doesn't + /// trigger a full-year scan each time. Runs when reminders are on *or* when + /// issue alerts are on (the badge's issue count also needs to refresh after a + /// background ingest). When reminders are off `todayCoveredByReconcile` stays + /// nil, so the coverage shortcut simply doesn't apply. func reconcileAfterIngest(changedDays: Set) async { - guard config.enabled else { return } + guard config.enabled || config.issueAlertsEnabled else { return } let today = calendar.startOfDay(for: now()) let changedDayNeedsReconcile = changedDays.contains { $0 != today } guard todayCoveredByReconcile != today || changedDayNeedsReconcile else { return } @@ -86,12 +112,20 @@ public actor ReminderReconciler { } /// Recompute the current-year missing-day picture from the store and push - /// it to the scheduler: the badge is the total unlogged days this year, and - /// a rolling window of upcoming unlogged days gets per-day reminders. + /// it to the scheduler: the badge is the total unlogged days this year plus + /// the unresolved data-issue count, and a rolling window of upcoming unlogged + /// days gets per-day reminders. func reconcile() async { + let today = calendar.startOfDay(for: now()) + let year = calendar.component(.year, from: today) + guard config.enabled else { + // Reminders off: no per-day reminders, but the badge still surfaces + // the unresolved-issue count when issue alerts are on. (No report in + // hand here, so the scan reads its own.) + let issueBadge = await dataIssueBadgeCount(year: year, report: nil) await scheduler.reconcile( - badgeCount: 0, + badgeCount: issueBadge, scheduleDays: [], reminderTime: config.time, enabled: false, @@ -100,8 +134,6 @@ public actor ReminderReconciler { return } - let today = calendar.startOfDay(for: now()) - let year = calendar.component(.year, from: today) do { let report = try await reportReader.yearReport(for: year) let present = Set(report.days.map { calendar.startOfDay(for: $0.date) }) @@ -122,8 +154,11 @@ public actor ReminderReconciler { let scheduleDays = today .calendarDays(through: windowEnd, in: calendar) .filter { !present.contains($0) } + // Reuse the report we already read to derive the ranking the issue + // scan needs, avoiding a second store read on this hot path. + let issueBadge = await dataIssueBadgeCount(year: year, report: report) await scheduler.reconcile( - badgeCount: backlog.count, + badgeCount: backlog.count + issueBadge, scheduleDays: scheduleDays, reminderTime: config.time, enabled: true, @@ -135,4 +170,30 @@ public actor ReminderReconciler { ) } } + + /// The unresolved data-issue count that folds into the badge, or 0 when issue + /// alerts are off. Reuses `report` when the caller already has it (the hot + /// reminder path) and otherwise lets the scanner read its own. Scan failures + /// log and contribute 0 rather than blanking the whole badge. + private func dataIssueBadgeCount(year: Int, report: YearReport?) async -> Int { + guard config.issueAlertsEnabled else { return 0 } + do { + if let report { + return try await issueScanner.issues( + year: year, + primaryRegions: Region.primaryRegions(in: report.totals), + driftThresholdMeters: config.driftThresholdMeters, + ).count + } + return try await issueScanner.currentIssueCount( + year: year, + driftThresholdMeters: config.driftThresholdMeters, + ) + } catch { + Self.logger.warning( + "Failed to scan data issues for badge: \(error.localizedDescription)", + ) + return 0 + } + } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift new file mode 100644 index 00000000..80fb41c3 --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -0,0 +1,200 @@ +import Foundation +import LogKit +import UserNotifications + +/// Schedules the single local notification that nudges the user to open the +/// Resolve tab when there are unresolved data-quality issues. Behind a protocol +/// so `DataIssueAlertReconciler` can drive it deterministically from tests. +public protocol DataIssueAlertScheduling: Sendable { + /// Ask the system for permission to post alerts/sounds. Returns whether the + /// app is authorized afterward. Safe to call repeatedly. + func requestAuthorization() async -> Bool + + /// Whether the app is currently authorized to post notifications, so the UI + /// can route the user to Settings when they've enabled issue alerts but + /// denied the system permission. + func isAuthorized() async -> Bool + + /// Reconcile the scheduled issue-alert notification against the current + /// intent. + /// + /// - Parameters: + /// - enabled: master switch — the user's `issueAlertsEnabled` intent AND + /// there being at least one unresolved issue. When `false`, the owned + /// notification is cancelled (so resolving the last issue clears it). + /// - time: time of day the alert fires. + /// - body: the alert text to show, recomputed by the controller from the + /// current issue count; when it changes the pending request is replaced + /// so the next delivery reflects the current count. + func reconcile(enabled: Bool, time: ReminderTime, body: String) async +} + +#if DEBUG + /// A `DataIssueAlertScheduling` that does nothing, for view-model tests that + /// need a controller without touching `UNUserNotificationCenter`. Reports + /// unauthorized so the UI's "denied" affordances stay exercisable. + /// + /// `@_spi(Testing)` + `#if DEBUG` per the agents.md testing-hook convention: + /// it's test-only scaffolding that mustn't ship in release. Import it with + /// `@_spi(Testing) import WhereCore` (add `@testable` in WhereCore's own + /// tests) and inject it via `WhereServices(issueAlertScheduler:)`. + @_spi(Testing) + public struct NoopDataIssueAlertScheduler: DataIssueAlertScheduling { + public init() {} + + public func requestAuthorization() async -> Bool { + false + } + + public func isAuthorized() async -> Bool { + false + } + + public func reconcile(enabled _: Bool, time _: ReminderTime, body _: String) async {} + } +#endif + +/// Production `DataIssueAlertScheduling` backed by `UNUserNotificationCenter`. +/// Schedules one repeating daily notification under a dedicated identifier, so +/// it never disturbs the logging reminders or the daily summary (which own +/// different prefixes) or anything else in the app. +public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertScheduling, + @unchecked Sendable +{ + private let center: any NotificationReminderCenter + + /// One repeating notification, so a single stable identifier is enough. + private static let identifier = "com.stuff.where.data-issues" + private static let logger = WhereLog.channel(.dataIssueAlertScheduler) + + public init(center: UNUserNotificationCenter = .current()) { + self.center = UNUserNotificationCenterAdapter(center: center) + } + + init(notificationCenter: any NotificationReminderCenter) { + center = notificationCenter + } + + public func requestAuthorization() async -> Bool { + do { + return try await center.requestAuthorization(options: [.alert, .sound, .badge]) + } catch { + Self.logger.error( + "Notification authorization request failed: \(error.localizedDescription)", + ) + return false + } + } + + public func isAuthorized() async -> Bool { + switch await center.authorizationStatus() { + case .authorized, .provisional, .ephemeral: + true + case .notDetermined, .denied: + false + @unknown default: + false + } + } + + /// Bring the owned pending request in line with the intent (see the protocol + /// requirement for the parameter contract): clear it when disabled or when + /// notifications aren't authorized; otherwise leave an already-correct + /// request untouched and only (re)build it when the fire time or body changed + /// — or a stray duplicate crept in — so a steady state doesn't churn the + /// schedule. + public func reconcile(enabled: Bool, time: ReminderTime, body: String) async { + guard enabled else { + await removeAllOwned() + return + } + + switch await center.authorizationStatus() { + case .authorized, .provisional, .ephemeral: + break + case .notDetermined, .denied: + Self.logger.warning( + "Issue alerts enabled but notification authorization not granted; alert disabled", + ) + await removeAllOwned() + return + @unknown default: + Self.logger.warning( + "Issue alerts enabled but notification authorization status is unknown; alert disabled", + ) + await removeAllOwned() + return + } + + let owned = await center.pendingNotificationRequests().filter { isOwned($0.identifier) } + // Leave a correct request in place so we don't churn the schedule on + // every reconcile; only the first owned request can ever be the right + // one, so a stray duplicate forces a rebuild. + if owned.count == 1, + let existing = owned.first, + matchesTime(existing, time), + existing.content.body == body + { + return + } + + let ownedIDs = owned.map(\.identifier) + if !ownedIDs.isEmpty { + await center.removePendingNotificationRequests(withIdentifiers: ownedIDs) + } + await scheduleAlert(time: time, body: body) + } + + private func scheduleAlert(time: ReminderTime, body: String) async { + var components = DateComponents() + components.hour = time.hour + components.minute = time.minute + + let content = UNMutableNotificationContent() + content.title = String(localized: "dataIssues.notification.title", bundle: .module) + content.body = body + content.sound = .default + + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + let request = UNNotificationRequest( + identifier: Self.identifier, + content: content, + trigger: trigger, + ) + do { + try await center.add(request) + Self.logger.info( + "Scheduled issue alert at \(String(format: "%02d:%02d", time.hour, time.minute))", + ) + } catch { + Self.logger.error( + "Failed to schedule issue alert: \(error.localizedDescription)", + ) + } + } + + private func matchesTime(_ request: UNNotificationRequest, _ time: ReminderTime) -> Bool { + guard let trigger = request.trigger as? UNCalendarNotificationTrigger else { + return false + } + return trigger.dateComponents.hour == time.hour + && trigger.dateComponents.minute == time.minute + } + + private func removeAllOwned() async { + let pendingIDs = await center.pendingNotificationRequests() + .map(\.identifier) + .filter(isOwned) + if !pendingIDs.isEmpty { + await center.removePendingNotificationRequests(withIdentifiers: pendingIDs) + } + let deliveredIDs = await center.deliveredNotificationIdentifiers().filter(isOwned) + if !deliveredIDs.isEmpty { + await center.removeDeliveredNotifications(withIdentifiers: deliveredIDs) + } + } + + private func isOwned(_ identifier: String) -> Bool { + identifier.hasPrefix(Self.identifier) + } +} diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 0fff840b..27c334c3 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -39,8 +39,10 @@ public protocol LoggingReminderScheduling: Sendable { /// picture. /// /// - Parameters: - /// - badgeCount: total unlogged days this year (the backlog). Shown as the - /// app-icon badge when `enabled`, cleared to 0 otherwise. + /// - badgeCount: the app-icon badge value the caller computed (unlogged-day + /// backlog plus any decoupled data-issue count). Set verbatim whether or + /// not `enabled`, so a caller that turns reminders off can still surface + /// an issue count; pass 0 to clear. /// - scheduleDays: upcoming days (today + a small buffer) that are still /// unlogged and should each get a reminder at `reminderTime`. A day that /// drops out of this set (because it got logged) has its pending and @@ -141,8 +143,11 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, enabled: Bool, ) async { guard enabled else { + // Reminders are off, but the badge may still carry a decoupled + // data-issue count (see `ReminderReconciler`), so honor the requested + // count rather than forcing it to 0. await removeAllOwnedReminders() - await setBadge(0) + await setBadge(badgeCount) return } diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index fb580873..2c467f3f 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -23,6 +23,40 @@ } } }, + "dataIssues.notification.body" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld issue to resolve" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld issues to resolve" + } + } + } + } + } + } + }, + "dataIssues.notification.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Issues to resolve" + } + } + } + }, "reminder.notification.body" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift index 6ac01392..75409385 100644 --- a/Where/WhereCore/Sources/WhereLog.swift +++ b/Where/WhereCore/Sources/WhereLog.swift @@ -23,6 +23,8 @@ public enum WhereLog { case backupService = "BackupService" case dailySummaryReconciler = "DailySummaryReconciler" case dailySummaryScheduler = "DailySummaryScheduler" + case dataIssueAlertReconciler = "DataIssueAlertReconciler" + case dataIssueAlertScheduler = "DataIssueAlertScheduler" case dayJournal = "DayJournal" case launch = "WhereLaunch" case locationIngestor = "LocationIngestor" diff --git a/Where/WhereCore/Sources/WherePreferences.swift b/Where/WhereCore/Sources/WherePreferences.swift index cd56db1c..5e6f978f 100644 --- a/Where/WhereCore/Sources/WherePreferences.swift +++ b/Where/WhereCore/Sources/WherePreferences.swift @@ -75,6 +75,23 @@ public final class WherePreferences { } } + /// Whether the "you have issues to resolve" notification is enabled, and + /// whether the unresolved-issue count contributes to the app-icon badge. + /// Defaults to `true` so the nudge and badge are active out of the box. + public var issueAlertsEnabled: Bool { + get { store.object(forKey: Keys.issueAlertsEnabled.rawValue) as? Bool ?? true } + set { store.set(newValue, forKey: Keys.issueAlertsEnabled.rawValue) } + } + + /// Whether the Elsewhere and Resolve tabs are hidden while they have nothing + /// to show (no "elsewhere" regions / no unresolved issues). Defaults to + /// `true` so the tab bar stays focused out of the box; turning it off keeps + /// both tabs permanently visible. + public var hideEmptyTabs: Bool { + get { store.object(forKey: Keys.hideEmptyTabs.rawValue) as? Bool ?? true } + set { store.set(newValue, forKey: Keys.hideEmptyTabs.rawValue) } + } + /// GPS border-drift detection threshold in meters. Defaults to 10 km. public var driftThresholdMeters: Int { get { @@ -107,6 +124,8 @@ public final class WherePreferences { case summaryEnabled = "where.summaryEnabled" case summaryHour = "where.summaryHour" case summaryMinute = "where.summaryMinute" + case issueAlertsEnabled = "where.issueAlertsEnabled" + case hideEmptyTabs = "where.hideEmptyTabs" case driftThresholdMeters = "where.driftThresholdMeters" } } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 499c084b..4e507615 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -21,6 +21,10 @@ public struct WhereServices: Sendable { public let reminders: ReminderReconciler /// Daily summary recap intent + reconciliation. public let summary: DailySummaryReconciler + /// "Issues to resolve" notification intent + reconciliation. The + /// unresolved-issue count it tracks also feeds the app-icon badge via + /// `reminders`. + public let issueAlerts: DataIssueAlertReconciler /// Live GPS ingestion: monitoring, retry queue, authorization. public let ingestor: LocationIngestor /// User-sourced writes: manual days, backfills, clears, evidence. @@ -51,15 +55,31 @@ public struct WhereServices: Sendable { aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling = UserNotificationReminderScheduler(), summaryScheduler: any DailySummaryScheduling = UserNotificationDailySummaryScheduler(), + issueAlertScheduler: any DataIssueAlertScheduling = + UserNotificationDataIssueAlertScheduler(), widgetRefresher: any WidgetTimelineRefreshing = WidgetCenterTimelineRefresher(), locationOutbox: any LocationOutbox = NoOpLocationOutbox(), activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(), now: @escaping @Sendable () -> Date = { Date() }, ) { let reports = ReportReader(store: store, aggregator: aggregator, attributor: attributor) + // Built before the reconcilers that consume it: the reminder reconciler + // folds its unresolved-issue count into the app-icon badge, and the + // issue-alert reconciler drives the "issues to resolve" notification off + // it. Subscribes to `store.changes()` and drops its cache on every commit, + // so a `force: false` read stays honest even when no session is alive to + // force a rescan (e.g. a headless background GPS ingest). + let resolution = DataIssueScanner( + reportReader: reports, + attributor: attributor, + calendar: aggregator.calendar, + now: now, + storeChanges: store.changes(), + ) let reminders = ReminderReconciler( scheduler: reminderScheduler, reportReader: reports, + issueScanner: resolution, calendar: aggregator.calendar, now: now, ) @@ -69,6 +89,12 @@ public struct WhereServices: Sendable { calendar: aggregator.calendar, now: now, ) + let issueAlerts = DataIssueAlertReconciler( + scheduler: issueAlertScheduler, + scanner: resolution, + calendar: aggregator.calendar, + now: now, + ) // The reader runs in *this* (app) process and shares the store, calendar, // and attributor so the published snapshot's day/year line up with // everything else reported. @@ -96,6 +122,11 @@ public struct WhereServices: Sendable { outbox: locationOutbox, onPersisted: { outcome in if let sample = outcome.liveSample { + // Hot live-sample path: the reminder reconcile carries the + // badge (including its issue count) and self-throttles once + // today is covered. The issue-alert notification is less + // latency-sensitive and refreshes on the drain/write paths + // and every foreground, so it stays off this per-sample path. await reminders.reconcileAfterIngest(changedDays: outcome.changedDays) if outcome.needsFullWidgetRebuild { await widgets.publish() @@ -104,6 +135,7 @@ public struct WhereServices: Sendable { } } else if !outcome.changedDays.isEmpty { await reminders.reconcile() + await issueAlerts.reconcile() if outcome.needsFullWidgetRebuild { await widgets.publish() } @@ -114,19 +146,11 @@ public struct WhereServices: Sendable { store: store, aggregator: aggregator, reminders: reminders, + issueAlerts: issueAlerts, + issueScanner: resolution, widgets: widgets, ) let backup = BackupCoordinator(store: store, widgets: widgets) - // Subscribes to `store.changes()` and drops its cache on every commit, - // so a `force: false` read stays honest even when no session is alive to - // force a rescan (e.g. a headless background GPS ingest). - let resolution = DataIssueScanner( - reportReader: reports, - attributor: attributor, - calendar: aggregator.calendar, - now: now, - storeChanges: store.changes(), - ) let recentActivity = RecentActivitySummarizer( store: store, attributor: attributor, @@ -137,6 +161,7 @@ public struct WhereServices: Sendable { self.reports = reports self.reminders = reminders self.summary = summary + self.issueAlerts = issueAlerts self.widgets = widgets self.ingestor = ingestor self.journal = journal diff --git a/Where/WhereCore/Tests/DataIssueAlertReconcilerTests.swift b/Where/WhereCore/Tests/DataIssueAlertReconcilerTests.swift new file mode 100644 index 00000000..068e999c --- /dev/null +++ b/Where/WhereCore/Tests/DataIssueAlertReconcilerTests.swift @@ -0,0 +1,159 @@ +import Foundation +import Testing +@testable import WhereCore + +/// Covers the issue-alert intent + notification reconciliation: the alert is +/// scheduled while the current year has unresolved issues (and the user has it +/// enabled) and cleared otherwise. +struct DataIssueAlertReconcilerTests { + private struct Harness { + let reconciler: DataIssueAlertReconciler + let store: SwiftDataStore + let scanner: DataIssueScanner + let spy: SpyDataIssueAlertScheduler + } + + private static func makeReconciler( + now: @escaping @Sendable () -> Date, + ) throws -> Harness { + let store = try SwiftDataStore.inMemory() + let calendar = WhereCoreTestSupport.calendar() + let aggregator = DayAggregator(calendar: calendar, timeZone: WhereCoreTestSupport.pacific) + let reader = ReportReader(store: store, aggregator: aggregator, attributor: .shared) + let scanner = DataIssueScanner( + reportReader: reader, + attributor: .shared, + calendar: calendar, + now: now, + ) + let spy = SpyDataIssueAlertScheduler() + let reconciler = DataIssueAlertReconciler( + scheduler: spy, + scanner: scanner, + calendar: calendar, + now: now, + ) + return Harness(reconciler: reconciler, store: store, scanner: scanner, spy: spy) + } + + private static let threshold = Double(DriftThreshold.default.rawValue) + + /// A January California day makes California a primary region, so the rest of + /// the year reads as missing days — a non-empty issue set the alert fires on. + private static func seedPrimaryRegionWithGap(_ store: SwiftDataStore) async throws { + try await store.perform { + try await store.setManualDay(DayPresence( + date: WhereCoreTestSupport.iso("2026-01-10T00:00:00-08:00"), + regions: [.california], + )) + } + } + + @Test func schedulesAlertWhenCurrentYearHasIssues() async throws { + let now = WhereCoreTestSupport.iso("2026-06-15T12:00:00-07:00") + let h = try Self.makeReconciler(now: { now }) + try await Self.seedPrimaryRegionWithGap(h.store) + + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + driftThresholdMeters: Self.threshold, + ) + + #expect(await h.spy.authorizationRequests == 1) + #expect(await h.spy.lastEnabled == true) + #expect(await h.spy.lastTime == .defaultEvening) + let body = try #require(await h.spy.lastBody) + #expect(body.contains("resolve")) + #expect(!body.contains("%")) + } + + @Test func clearsAlertWhenNoIssues() async throws { + // An empty store at the very start of the year has no elapsed days yet, + // so nothing is missing and there are no unresolved issues to nag about — + // the alert must be reconciled off even though the user has it enabled. + let now = WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00") + let h = try Self.makeReconciler(now: { now }) + + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + driftThresholdMeters: Self.threshold, + ) + + #expect(await h.spy.lastEnabled == false) + } + + @Test func configureDisabledSkipsAuthorizationAndClears() async throws { + let now = WhereCoreTestSupport.iso("2026-06-15T12:00:00-07:00") + let h = try Self.makeReconciler(now: { now }) + try await Self.seedPrimaryRegionWithGap(h.store) + + await h.reconciler.configure( + enabled: false, + time: .defaultEvening, + driftThresholdMeters: Self.threshold, + ) + + #expect(await h.spy.authorizationRequests == 0) + #expect(await h.spy.lastEnabled == false) + #expect(await h.spy.lastBody == "") + } + + @Test func reconcileClearsOnceIssuesAreResolved() async throws { + let now = WhereCoreTestSupport.iso("2026-06-15T12:00:00-07:00") + let h = try Self.makeReconciler(now: { now }) + try await Self.seedPrimaryRegionWithGap(h.store) + + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + driftThresholdMeters: Self.threshold, + ) + #expect(await h.spy.lastEnabled == true) + + // Resolve every outstanding issue by dismissing it — exactly what the + // user does on the Resolve tab — then drop the scanner cache the way a + // committed write would and reconcile again: the alert clears. + let outstanding = try await h.scanner.issues( + year: 2026, + primaryRegions: [.california], + driftThresholdMeters: Self.threshold, + force: true, + ) + #expect(!outstanding.isEmpty) + for issue in outstanding { + try await h.store.perform { + try await h.store.setIssueDismissed(true, key: issue.id.storageKey) + } + } + await h.scanner.invalidate() + await h.reconciler.reconcile() + + #expect(await h.spy.lastEnabled == false) + } +} + +private actor SpyDataIssueAlertScheduler: DataIssueAlertScheduling { + private(set) var authorizationRequests = 0 + private(set) var reconcileCount = 0 + private(set) var lastEnabled: Bool? + private(set) var lastTime: ReminderTime? + private(set) var lastBody: String? + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile(enabled: Bool, time: ReminderTime, body: String) async { + reconcileCount += 1 + lastEnabled = enabled + lastTime = time + lastBody = body + } +} diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index 1b4b3535..a00dce39 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -1,7 +1,7 @@ import Foundation import RegionKit import Testing -@testable import WhereCore +@_spi(Testing) @testable import WhereCore /// Covers the user-sourced writes (ingest, manual-day overlays, range /// backfills, clears, evidence) and the reminder reconcile + widget publish @@ -56,10 +56,23 @@ struct DayJournalTests { timeZone: WhereCoreTestSupport.pacific, ) let reader = ReportReader(store: store, aggregator: aggregator, attributor: .shared) + let scanner = DataIssueScanner( + reportReader: reader, + attributor: .shared, + calendar: WhereCoreTestSupport.calendar(), + now: now, + ) let reminderSpy = SpyReminderScheduler() let reminders = ReminderReconciler( scheduler: reminderSpy, reportReader: reader, + issueScanner: scanner, + calendar: WhereCoreTestSupport.calendar(), + now: now, + ) + let issueAlerts = DataIssueAlertReconciler( + scheduler: NoopDataIssueAlertScheduler(), + scanner: scanner, calendar: WhereCoreTestSupport.calendar(), now: now, ) @@ -79,6 +92,8 @@ struct DayJournalTests { store: store, aggregator: aggregator, reminders: reminders, + issueAlerts: issueAlerts, + issueScanner: scanner, widgets: widgets, ) return Harness( diff --git a/Where/WhereCore/Tests/ReminderReconcilerTests.swift b/Where/WhereCore/Tests/ReminderReconcilerTests.swift index 80b56132..9aedcc3f 100644 --- a/Where/WhereCore/Tests/ReminderReconcilerTests.swift +++ b/Where/WhereCore/Tests/ReminderReconcilerTests.swift @@ -6,65 +6,96 @@ import Testing /// Covers the reminder intent + badge/schedule reconciliation the controller /// delegates every reminder call to. struct ReminderReconcilerTests { + private struct Harness { + let reconciler: ReminderReconciler + let store: SwiftDataStore + let spy: SpyReminderScheduler + let scanner: DataIssueScanner + } + private static func makeReconciler( now: @escaping @Sendable () -> Date, - ) throws -> (ReminderReconciler, SwiftDataStore, SpyReminderScheduler) { + ) throws -> Harness { let store = try SwiftDataStore.inMemory() let aggregator = DayAggregator( calendar: WhereCoreTestSupport.calendar(), timeZone: WhereCoreTestSupport.pacific, ) let reader = ReportReader(store: store, aggregator: aggregator, attributor: .shared) + let scanner = DataIssueScanner( + reportReader: reader, + attributor: .shared, + calendar: WhereCoreTestSupport.calendar(), + now: now, + ) let spy = SpyReminderScheduler() let reconciler = ReminderReconciler( scheduler: spy, reportReader: reader, + issueScanner: scanner, calendar: WhereCoreTestSupport.calendar(), now: now, ) - return (reconciler, store, spy) + return Harness(reconciler: reconciler, store: store, spy: spy, scanner: scanner) } + private static let noIssues = Double(DriftThreshold.default.rawValue) + @Test func configureEnabledRequestsAuthorizationAndReconciles() async throws { let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") - let (reconciler, _, spy) = try Self.makeReconciler(now: { now }) - await reconciler.configure(enabled: true, time: .defaultEvening) + let h = try Self.makeReconciler(now: { now }) + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Self.noIssues, + ) - #expect(await spy.authorizationRequests == 1) - #expect(await spy.reconcileCount == 1) - #expect(await spy.lastEnabled == true) + #expect(await h.spy.authorizationRequests == 1) + #expect(await h.spy.reconcileCount == 1) + #expect(await h.spy.lastEnabled == true) // An empty store this far into the year has a non-empty past backlog. - #expect(await (spy.lastBadgeCount ?? 0) > 0) + #expect(await (h.spy.lastBadgeCount ?? 0) > 0) } @Test func configureDisabledClearsScheduleAndBadge() async throws { let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") - let (reconciler, _, spy) = try Self.makeReconciler(now: { now }) - await reconciler.configure(enabled: false, time: .defaultEvening) + let h = try Self.makeReconciler(now: { now }) + await h.reconciler.configure( + enabled: false, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Self.noIssues, + ) - #expect(await spy.authorizationRequests == 0) - #expect(await spy.lastEnabled == false) - #expect(await spy.lastBadgeCount == 0) + #expect(await h.spy.authorizationRequests == 0) + #expect(await h.spy.lastEnabled == false) + #expect(await h.spy.lastBadgeCount == 0) } @Test func reconcileAfterIngestSkipsWhenTodayCoveredButForcesOnPastChange() async throws { let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") - let (reconciler, store, spy) = try Self.makeReconciler(now: { now }) - try await store.perform { - try await store.add(sample: LocationSample( + let h = try Self.makeReconciler(now: { now }) + try await h.store.perform { + try await h.store.add(sample: LocationSample( timestamp: now, coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 0, source: .gpsSignificantChange, )) } - await reconciler.configure(enabled: true, time: .defaultEvening) - #expect(await spy.reconcileCount == 1) + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Self.noIssues, + ) + #expect(await h.spy.reconcileCount == 1) let today = WhereCoreTestSupport.calendar().startOfDay(for: now) // Today already covered + only today changed → no extra reconcile. - await reconciler.reconcileAfterIngest(changedDays: [today]) - #expect(await spy.reconcileCount == 1) + await h.reconciler.reconcileAfterIngest(changedDays: [today]) + #expect(await h.spy.reconcileCount == 1) // A change on a different day forces a reconcile. let earlier = try #require(WhereCoreTestSupport.calendar().date( @@ -72,8 +103,79 @@ struct ReminderReconcilerTests { value: -3, to: today, )) - await reconciler.reconcileAfterIngest(changedDays: [earlier]) - #expect(await spy.reconcileCount == 2) + await h.reconciler.reconcileAfterIngest(changedDays: [earlier]) + #expect(await h.spy.reconcileCount == 2) + } + + /// Seed a primary region (a January California day) so the scanner reports a + /// non-zero unresolved-issue count, then prove the badge is exactly the + /// backlog plus that count when issue alerts are on — and just the backlog + /// when they're off. + @Test func badgeFoldsInIssueCountWhenIssueAlertsEnabled() async throws { + let now = WhereCoreTestSupport.iso("2026-06-15T12:00:00-07:00") + let h = try Self.makeReconciler(now: { now }) + try await h.store.perform { + try await h.store.setManualDay(DayPresence( + date: WhereCoreTestSupport.iso("2026-01-10T00:00:00-08:00"), + regions: [.california], + )) + } + let threshold = Double(DriftThreshold.default.rawValue) + let issueCount = try await h.scanner.currentIssueCount( + year: 2026, + driftThresholdMeters: threshold, + force: true, + ) + #expect(issueCount > 0) + + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: threshold, + ) + let backlogOnly = try #require(await h.spy.lastBadgeCount) + + await h.reconciler.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: true, + driftThresholdMeters: threshold, + ) + let combined = try #require(await h.spy.lastBadgeCount) + + #expect(combined == backlogOnly + issueCount) + } + + /// With logging reminders off but issue alerts on, the badge still surfaces + /// the issue count (and no per-day reminders are scheduled). + @Test func badgeCarriesIssueCountWhenRemindersOffButAlertsOn() async throws { + let now = WhereCoreTestSupport.iso("2026-06-15T12:00:00-07:00") + let h = try Self.makeReconciler(now: { now }) + try await h.store.perform { + try await h.store.setManualDay(DayPresence( + date: WhereCoreTestSupport.iso("2026-01-10T00:00:00-08:00"), + regions: [.california], + )) + } + let threshold = Double(DriftThreshold.default.rawValue) + let issueCount = try await h.scanner.currentIssueCount( + year: 2026, + driftThresholdMeters: threshold, + force: true, + ) + #expect(issueCount > 0) + + await h.reconciler.configure( + enabled: false, + time: .defaultEvening, + issueAlertsEnabled: true, + driftThresholdMeters: threshold, + ) + + #expect(await h.spy.lastEnabled == false) + #expect(await h.spy.lastScheduleDays.isEmpty) + #expect(await h.spy.lastBadgeCount == issueCount) } } @@ -81,6 +183,7 @@ private actor SpyReminderScheduler: LoggingReminderScheduling { private(set) var authorizationRequests = 0 private(set) var reconcileCount = 0 private(set) var lastBadgeCount: Int? + private(set) var lastScheduleDays: [Date] = [] private(set) var lastEnabled: Bool? func requestAuthorization() async -> Bool { @@ -94,12 +197,13 @@ private actor SpyReminderScheduler: LoggingReminderScheduling { func reconcile( badgeCount: Int, - scheduleDays _: [Date], + scheduleDays: [Date], reminderTime _: ReminderTime, enabled: Bool, ) async { reconcileCount += 1 lastBadgeCount = badgeCount + lastScheduleDays = scheduleDays lastEnabled = enabled } } diff --git a/Where/WhereCore/Tests/UserNotificationDataIssueAlertSchedulerTests.swift b/Where/WhereCore/Tests/UserNotificationDataIssueAlertSchedulerTests.swift new file mode 100644 index 00000000..8345b41a --- /dev/null +++ b/Where/WhereCore/Tests/UserNotificationDataIssueAlertSchedulerTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +import UserNotifications +@testable import WhereCore + +struct UserNotificationDataIssueAlertSchedulerTests { + private static let identifier = "com.stuff.where.data-issues" + + @Test func schedulesRepeatingTriggerAtConfiguredTime() async throws { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + + await scheduler.reconcile( + enabled: true, + time: ReminderTime(hour: 18, minute: 30), + body: "3 issues to resolve", + ) + + let request = try #require(center.pendingRequests.first) + let trigger = try #require(request.trigger as? UNCalendarNotificationTrigger) + #expect(trigger.repeats) + #expect(trigger.dateComponents.hour == 18) + #expect(trigger.dateComponents.minute == 30) + #expect(request.identifier == Self.identifier) + #expect(request.content.body == "3 issues to resolve") + } + + @Test func unchangedReconcileDoesNotReschedule() async { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + let time = ReminderTime(hour: 18, minute: 0) + + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + + #expect(center.addedRequests.count == 1) + } + + @Test func changingBodyReplacesExistingRequest() async throws { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + let time = ReminderTime(hour: 18, minute: 0) + + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + await scheduler.reconcile(enabled: true, time: time, body: "2 issues to resolve") + + let updated = try #require(center.pendingRequests.first) + #expect(updated.content.body == "2 issues to resolve") + #expect(center.addedRequests.count == 2) + } + + @Test func strayDuplicatesAreRebuiltIntoASingleRequest() async throws { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + let content = UNMutableNotificationContent() + try await center.add(UNNotificationRequest( + identifier: "\(Self.identifier).legacy-1", + content: content, + trigger: nil, + )) + try await center.add(UNNotificationRequest( + identifier: "\(Self.identifier).legacy-2", + content: content, + trigger: nil, + )) + + await scheduler.reconcile( + enabled: true, + time: ReminderTime(hour: 18, minute: 0), + body: "1 issue to resolve", + ) + + #expect(center.pendingRequests.count == 1) + #expect(center.pendingRequests.first?.identifier == Self.identifier) + #expect(center.removedPendingIdentifiers.contains("\(Self.identifier).legacy-1")) + #expect(center.removedPendingIdentifiers.contains("\(Self.identifier).legacy-2")) + } + + @Test func disablingClearsOwnedRequest() async { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + let time = ReminderTime(hour: 18, minute: 0) + + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + #expect(center.pendingRequests.count == 1) + + await scheduler.reconcile(enabled: false, time: time, body: "1 issue to resolve") + #expect(center.pendingRequests.isEmpty) + } + + @Test func deniedAuthorizationClearsOwnedRequest() async { + let center = FakeIssueAlertCenter() + let scheduler = UserNotificationDataIssueAlertScheduler(notificationCenter: center) + let time = ReminderTime(hour: 18, minute: 0) + + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + #expect(center.pendingRequests.count == 1) + + center.status = .denied + await scheduler.reconcile(enabled: true, time: time, body: "1 issue to resolve") + #expect(center.pendingRequests.isEmpty) + } +} + +private final class FakeIssueAlertCenter: NotificationReminderCenter, @unchecked Sendable { + var status: UNAuthorizationStatus = .authorized + private(set) var pendingRequests: [UNNotificationRequest] = [] + private(set) var addedRequests: [UNNotificationRequest] = [] + private(set) var removedPendingIdentifiers: [String] = [] + private var deliveredIdentifiers: [String] = [] + + func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + true + case .notDetermined, .denied: + false + @unknown default: + false + } + } + + func authorizationStatus() async -> UNAuthorizationStatus { + status + } + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + pendingRequests + } + + func deliveredNotificationIdentifiers() async -> [String] { + deliveredIdentifiers + } + + func add(_ request: UNNotificationRequest) async throws { + pendingRequests.removeAll { $0.identifier == request.identifier } + pendingRequests.append(request) + addedRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { + removedPendingIdentifiers.append(contentsOf: identifiers) + pendingRequests.removeAll { identifiers.contains($0.identifier) } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async { + deliveredIdentifiers.removeAll { identifiers.contains($0) } + } + + func setBadgeCount(_: Int) async throws {} +} diff --git a/Where/WhereCore/Tests/WhereLogTests.swift b/Where/WhereCore/Tests/WhereLogTests.swift index 9494e961..c932ac51 100644 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ b/Where/WhereCore/Tests/WhereLogTests.swift @@ -24,7 +24,7 @@ func categoryRawValuesMatchTypeNames() { #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") #expect(WhereLog.Category.recentActivitySummarizer.rawValue == "RecentActivitySummarizer") - #expect(WhereLog.Category.allCases.count == 17) + #expect(WhereLog.Category.allCases.count == 19) } @Test diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index defd37d4..3846df87 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -653,7 +653,12 @@ struct WhereServicesTests { let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) #expect(await spy.authorizationRequests == 1) #expect(await spy.lastEnabled == true) @@ -672,7 +677,12 @@ struct WhereServicesTests { scheduler: spy, ) - await services.reminders.configure(enabled: false, time: .defaultEvening) + await services.reminders.configure( + enabled: false, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) #expect(await spy.authorizationRequests == 0) #expect(await spy.lastEnabled == false) @@ -684,7 +694,12 @@ struct WhereServicesTests { let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() let (services, _, source) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) let today = Self.pacificCalendar.startOfDay(for: now) // Today is a forward nudge, not part of the backlog (Jan 1–4 = 4 days). @@ -710,7 +725,12 @@ struct WhereServicesTests { let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() let (services, _, source) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) await services.ingestor.start() source.emit(LocationSample( @@ -740,7 +760,12 @@ struct WhereServicesTests { let now = WhereCoreTestSupport.iso("2026-03-10T09:00:00-08:00") let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) // Backlog is Jan 1 – Mar 9 (today, Mar 10, is excluded): 68 days of 2026. #expect(await spy.lastBadgeCount == 68) @@ -758,7 +783,12 @@ struct WhereServicesTests { let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), @@ -778,7 +808,12 @@ struct WhereServicesTests { let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), @@ -800,8 +835,18 @@ struct WhereServicesTests { let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) - await services.reminders.configure(enabled: true, time: ReminderTime(hour: 7, minute: 30)) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) + await services.reminders.configure( + enabled: true, + time: ReminderTime(hour: 7, minute: 30), + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) #expect(await spy.authorizationRequests == 2) #expect(await spy.reconcileCount == 2) @@ -814,10 +859,20 @@ struct WhereServicesTests { let spy = SpyReminderScheduler() let (services, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) - await services.reminders.configure(enabled: true, time: .defaultEvening) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) #expect(await spy.lastBadgeCount == 4) - await services.reminders.configure(enabled: false, time: .defaultEvening) + await services.reminders.configure( + enabled: false, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) #expect(await spy.lastEnabled == false) #expect(await spy.lastBadgeCount == 0) #expect(await spy.lastScheduleDays.isEmpty) diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index cd80efd2..81e1e921 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -24,10 +24,13 @@ public enum LaunchStepID: String { case syncAuth = "sync-auth" /// Start or stop GPS ingestion to match the user's intent + authorization. case reconcileTracking = "reconcile-tracking" - /// Push the logging-reminder schedule + backlog badge to the reconciler. + /// Push the logging-reminder schedule + badge (backlog + issue count) to the + /// reconciler. case reminders /// Push the daily-summary recap to the reconciler. case summary + /// Push the "issues to resolve" notification intent to its reconciler. + case issueAlerts = "issue-alerts" /// Republish the widget snapshot from whatever is already on disk. case widgetSnapshot = "widget-snapshot" @@ -151,6 +154,9 @@ public enum WhereLaunch { LifecycleStep.work(LaunchStepID.summary) { _ in await model.session?.applySummaryConfiguration() } + LifecycleStep.work(LaunchStepID.issueAlerts) { _ in + await model.session?.applyIssueAlertConfiguration() + } LifecycleStep.work(LaunchStepID.widgetSnapshot) { _ in await model.session?.refreshWidgetSnapshot() } @@ -224,9 +230,9 @@ public final class WhereBootstrap { } } -/// Presents the app's local notifications (logging reminders, the daily summary) -/// even while Where is foregrounded, so a nudge isn't silently swallowed when the -/// user already has the app open. +/// Presents the app's local notifications (logging reminders, the daily summary, +/// the "issues to resolve" alert) even while Where is foregrounded, so a nudge +/// isn't silently swallowed when the user already has the app open. /// /// Registered as the `UNUserNotificationCenter` delegate from `makeLauncher`'s /// `initializePrerequisites` rather than ad hoc in the app delegate, so launch diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 379b00ef..4ca3b55d 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -10,7 +10,19 @@ import WhereCore /// The four tabs receive the report by explicit init injection (compile-checked /// wiring); the always-on `WhereSession` coordinator stays in the environment. struct MainTabs: View { + /// Identity for the tab-bar selection. The Elsewhere and Resolve tabs are + /// conditional (see `body`), so a stable value lets the selection survive a + /// tab appearing/disappearing and lets `body` keep a tab mounted while the + /// user is still on it. + private enum TabID: Hashable { + case primary + case elsewhere + case resolution + case settings + } + @State private var report: YearReportModel + @State private var selection: TabID = .primary @Environment(\.scenePhase) private var scenePhase /// Build the scene's report model from the coordinator's service layer. @@ -27,21 +39,37 @@ struct MainTabs: View { } var body: some View { - TabView { - Tab(Strings.tabPrimary, systemImage: "star.fill") { + TabView(selection: $selection) { + Tab(Strings.tabPrimary, systemImage: "star.fill", value: TabID.primary) { PrimaryView(report: report) } - Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { - SecondaryView(report: report) + // With "hide empty tabs" on (the default), Elsewhere and Resolve + // appear only when they have something to show — but stay mounted + // while they're the current selection, so resolving the last issue + // (or emptying Elsewhere) never yanks the user off the tab they're on; + // the tab drops out the next time they switch away. With the setting + // off, both tabs are always present. + if !report.hideEmptyTabs || !report.ranking.secondary.isEmpty + || selection == .elsewhere + { + Tab( + Strings.tabElsewhere, + systemImage: "globe.americas.fill", + value: TabID.elsewhere, + ) { + SecondaryView(report: report) + } } - Tab(Strings.tabResolution, systemImage: "checklist") { - ResolutionView(report: report) + if !report.hideEmptyTabs || report.dataIssueCount > 0 || selection == .resolution { + Tab(Strings.tabResolution, systemImage: "checklist", value: TabID.resolution) { + ResolutionView(report: report) + } + .badge(report.dataIssueCount) } - .badge(report.dataIssueCount) - Tab(Strings.tabSettings, systemImage: "gearshape.fill") { + Tab(Strings.tabSettings, systemImage: "gearshape.fill", value: TabID.settings) { SettingsView(report: report) } } diff --git a/Where/WhereUI/Sources/Model/RegionRanking.swift b/Where/WhereUI/Sources/Model/RegionRanking.swift index 87f9e8dc..5b2b414f 100644 --- a/Where/WhereUI/Sources/Model/RegionRanking.swift +++ b/Where/WhereUI/Sources/Model/RegionRanking.swift @@ -40,10 +40,10 @@ public struct RegionRanking: Hashable, Sendable { /// live), so it always falls into `secondary` when present. public init(report: YearReport, primaryCount: Int = RegionRanking.primaryCount) { let ranked = RegionRanking.ranked(report: report) - let primary = Array( - ranked.filter { $0.region != .other }.prefix(max(0, primaryCount)), - ) - let primaryRegions = Set(primary.map(\.region)) + // Shared with the headless data-issue scan (`Region.primaryRegions`) so + // the Resolve badge count can't disagree with the Primary/Elsewhere split. + let primaryRegions = Set(Region.primaryRegions(in: report.totals, count: primaryCount)) + let primary = ranked.filter { primaryRegions.contains($0.region) } let secondary = ranked.filter { !primaryRegions.contains($0.region) } self.init(primary: primary, secondary: secondary) } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index e145f05f..c226e036 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -90,6 +90,7 @@ public final class WhereSession { /// rather than on every configuration apply (which also runs per foreground). private var warnedRemindersUnauthorized = false private var warnedSummaryUnauthorized = false + private var warnedIssueAlertsUnauthorized = false /// Persisted user intent to track in the background. Effective tracking is /// this AND `.always` authorization; we default to `true` so that, once the @@ -151,6 +152,7 @@ public final class WhereSession { await reconcileTracking() await applyReminderConfiguration() await applySummaryConfiguration() + await applyIssueAlertConfiguration() // Republish the widget snapshot from whatever is already on disk so a // cold launch with no writes this session doesn't leave the widget // blank or showing the previous day's "today". @@ -166,6 +168,7 @@ public final class WhereSession { await reconcileTracking() await applyReminderConfiguration() await applySummaryConfiguration() + await applyIssueAlertConfiguration() // The calendar day may have rolled over while backgrounded; recompute // so the widget's "today" reflects the current day rather than stale // foreground state. @@ -297,7 +300,15 @@ public final class WhereSession { /// (see `WhereLaunch.sequence`); also runs on every foreground. func applyReminderConfiguration() async { let enabled = preferences.remindersEnabled - await services.reminders.configure(enabled: enabled, time: preferences.reminderTime) + // The reminder reconciler also owns the app-icon badge, whose value folds + // in the unresolved-issue count — so it needs the issue-alert intent and + // the current drift threshold the scan runs at. + await services.reminders.configure( + enabled: enabled, + time: preferences.reminderTime, + issueAlertsEnabled: preferences.issueAlertsEnabled, + driftThresholdMeters: Double(preferences.driftThresholdMeters), + ) let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedRemindersUnauthorized { @@ -327,6 +338,29 @@ public final class WhereSession { } } + /// Push the persisted issue-alert intent to the issue-alert reconciler and + /// warn if enabled but unauthorized. Fires the alert at the evening reminder + /// time and scans at the current drift threshold. Reads `WherePreferences` + /// directly, mirroring `applyReminderConfiguration()`. A launch step (see + /// `WhereLaunch.sequence`); also runs on every foreground. + func applyIssueAlertConfiguration() async { + let enabled = preferences.issueAlertsEnabled + await services.issueAlerts.configure( + enabled: enabled, + time: preferences.reminderTime, + driftThresholdMeters: Double(preferences.driftThresholdMeters), + ) + let authorized = await services.reminders.isAuthorized() + if enabled, !authorized { + if !warnedIssueAlertsUnauthorized { + Self.logger.warning("Issue alerts enabled but notifications not authorized") + warnedIssueAlertsUnauthorized = true + } + } else { + warnedIssueAlertsUnauthorized = false + } + } + /// Erase all persisted data and reset the coordinator's observable state to a /// clean slate. A thin pass-through to `WhereServices.reset()`, which owns /// *what* gets cleared (GPS stop + store wipe + reminder/badge reconcile + diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 3e8764fe..37e7cbaf 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -114,6 +114,23 @@ public final class YearReportModel { } } + /// Observed mirror of `preferences.hideEmptyTabs` (see `driftThresholdStorage` + /// for why the plain-defaults preference is mirrored here). Drives whether + /// `MainTabs` hides the Elsewhere/Resolve tabs while they're empty. + private var hideEmptyTabsStorage: Bool + + /// Whether `MainTabs` hides the Elsewhere and Resolve tabs while they have + /// nothing to show. The setter persists it; the observed mirror re-evaluates + /// the tab bar immediately. Purely presentational — no scan or report re-pull. + public var hideEmptyTabs: Bool { + get { hideEmptyTabsStorage } + set { + guard newValue != hideEmptyTabsStorage else { return } + hideEmptyTabsStorage = newValue + preferences.hideEmptyTabs = newValue + } + } + /// The inputs that determine a data-issue scan's result: the selected year, /// the loaded report (any committed write re-pulls it; a year switch nils /// then reloads it), and the drift threshold. `ResolutionView` keys its scan @@ -181,6 +198,7 @@ public final class YearReportModel { self.now = now driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default + hideEmptyTabsStorage = preferences.hideEmptyTabs var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current self.calendar = calendar diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 0beb9145..8ccf55aa 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2169,6 +2169,50 @@ } } }, + "settings.issueAlerts.deniedFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." + } + } + } + }, + "settings.issueAlerts.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." + } + } + } + }, + "settings.issueAlerts.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Issue alerts" + } + } + } + }, + "settings.issueAlerts.toggle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Issue alerts" + } + } + } + }, "settings.summary.deniedFooter" : { "extractionState" : "manual", "localizations" : { @@ -2224,6 +2268,39 @@ } } }, + "settings.tabs.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." + } + } + } + }, + "settings.tabs.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabs" + } + } + } + }, + "settings.tabs.toggle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide empty tabs" + } + } + } + }, "settings.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift index 0fd04f68..b2e1a736 100644 --- a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift @@ -105,6 +105,20 @@ public final class RemindersSettingsModel { } } + /// Whether the "you have issues to resolve" notification is enabled (and the + /// unresolved-issue count contributes to the app-icon badge). Persists across + /// launches; the setter persists the intent and reconciles both the alert and + /// the badge. + public var issueAlertsEnabled: Bool { + get { issueAlertsEnabledStorage } + set { + guard newValue != issueAlertsEnabledStorage else { return } + issueAlertsEnabledStorage = newValue + preferences.issueAlertsEnabled = newValue + Task { await applyIssueAlertConfiguration() } + } + } + /// Whether the system has granted notification permission. Lets the Settings /// UI route the user to the system Settings app when they've enabled /// reminders/summary but denied permission. Refreshed on appear and after @@ -120,6 +134,8 @@ public final class RemindersSettingsModel { /// reminder storage above. private var summaryEnabledStorage: Bool private var summaryTimeStorage: ReminderTime + /// Observed backing storage for the issue-alert toggle. + private var issueAlertsEnabledStorage: Bool private let services: WhereServices private let preferences: WherePreferences @@ -142,6 +158,7 @@ public final class RemindersSettingsModel { reminderTimeStorage = preferences.reminderTime summaryEnabledStorage = preferences.summaryEnabled summaryTimeStorage = preferences.summaryTime + issueAlertsEnabledStorage = preferences.issueAlertsEnabled } /// Refresh whether the system has granted notification permission. Called @@ -151,7 +168,12 @@ public final class RemindersSettingsModel { } private func applyReminderConfiguration() async { - await services.reminders.configure(enabled: remindersEnabled, time: reminderTime) + await services.reminders.configure( + enabled: remindersEnabled, + time: reminderTime, + issueAlertsEnabled: issueAlertsEnabled, + driftThresholdMeters: Double(preferences.driftThresholdMeters), + ) await refreshNotificationAuthorization() } @@ -159,4 +181,24 @@ public final class RemindersSettingsModel { await services.summary.configure(enabled: summaryEnabled, time: summaryTime) await refreshNotificationAuthorization() } + + /// Reconcile both the issue-alert notification and the reminder reconciler: + /// the alert notification fires at the reminder time, and the badge (owned by + /// the reminder reconciler) folds in the issue count, so a toggle here has to + /// re-push the reminder config too. + private func applyIssueAlertConfiguration() async { + let driftThresholdMeters = Double(preferences.driftThresholdMeters) + await services.issueAlerts.configure( + enabled: issueAlertsEnabled, + time: reminderTime, + driftThresholdMeters: driftThresholdMeters, + ) + await services.reminders.configure( + enabled: remindersEnabled, + time: reminderTime, + issueAlertsEnabled: issueAlertsEnabled, + driftThresholdMeters: driftThresholdMeters, + ) + await refreshNotificationAuthorization() + } } diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 8f8b312f..dbf5b3d6 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -58,7 +58,9 @@ struct SettingsView: View { trackingSection remindersSection summarySection + issueAlertsSection resolutionSection + tabsSection appIconSection manualEntrySection backupSection @@ -247,6 +249,34 @@ struct SettingsView: View { return Strings.settingsSummaryFooter } + private var issueAlertsSection: some View { + @Bindable var reminders = reminders + return Section { + Toggle(isOn: $reminders.issueAlertsEnabled) { + Label(Strings.settingsIssueAlertsToggle, systemImage: "checklist.checked") + } + + if reminders.issueAlertsEnabled, !reminders.notificationsAuthorized { + Button { + openSystemSettings() + } label: { + Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + } + } + } header: { + Text(Strings.settingsIssueAlertsHeader) + } footer: { + Text(issueAlertsFooter) + } + } + + private var issueAlertsFooter: String { + if reminders.issueAlertsEnabled, !reminders.notificationsAuthorized { + return Strings.settingsIssueAlertsDeniedFooter + } + return Strings.settingsIssueAlertsFooter + } + private var resolutionSection: some View { @Bindable var report = report @@ -262,6 +292,19 @@ struct SettingsView: View { } } + private var tabsSection: some View { + @Bindable var report = report + return Section { + Toggle(isOn: $report.hideEmptyTabs) { + Label(Strings.settingsTabsToggle, systemImage: "rectangle.bottomthird.inset.filled") + } + } header: { + Text(Strings.settingsTabsHeader) + } footer: { + Text(Strings.settingsTabsFooter) + } + } + private var appIconSection: some View { Section { Button { diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index ffb1b578..2f1b0dd2 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -667,6 +667,62 @@ enum Strings { ) } + static var settingsIssueAlertsHeader: String { + String( + localized: "settings.issueAlerts.header", + defaultValue: "Issue alerts", + bundle: .module, + ) + } + + static var settingsIssueAlertsToggle: String { + String( + localized: "settings.issueAlerts.toggle", + defaultValue: "Issue alerts", + bundle: .module, + ) + } + + static var settingsIssueAlertsFooter: String { + String( + localized: "settings.issueAlerts.footer", + defaultValue: "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab.", + bundle: .module, + ) + } + + static var settingsIssueAlertsDeniedFooter: String { + String( + localized: "settings.issueAlerts.deniedFooter", + defaultValue: "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings.", + bundle: .module, + ) + } + + static var settingsTabsHeader: String { + String( + localized: "settings.tabs.header", + defaultValue: "Tabs", + bundle: .module, + ) + } + + static var settingsTabsToggle: String { + String( + localized: "settings.tabs.toggle", + defaultValue: "Hide empty tabs", + bundle: .module, + ) + } + + static var settingsTabsFooter: String { + String( + localized: "settings.tabs.footer", + defaultValue: "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar.", + bundle: .module, + ) + } + static var settingsDataHeader: String { localized("settings.data.header") } diff --git a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift index 12c2f450..93db33d3 100644 --- a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift +++ b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import WhereCore +@_spi(Testing) import WhereCore import WhereTesting import WhereUI @@ -22,6 +22,7 @@ struct RemindersSettingsModelTests { locationSource: ScriptedLocationSource(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) } @@ -29,12 +30,14 @@ struct RemindersSettingsModelTests { private func makeServices( reminderScheduler: any LoggingReminderScheduling, summaryScheduler: any DailySummaryScheduling, + issueAlertScheduler: any DataIssueAlertScheduling = NoopDataIssueAlertScheduler(), ) throws -> WhereServices { try WhereServices( store: SwiftDataStore.inMemory(), locationSource: ScriptedLocationSource(), reminderScheduler: reminderScheduler, summaryScheduler: summaryScheduler, + issueAlertScheduler: issueAlertScheduler, widgetRefresher: NoopWidgetTimelineRefresher(), ) } @@ -189,6 +192,65 @@ struct RemindersSettingsModelTests { #expect(await summarySpy.lastEnabled == true) } + // MARK: - Issue alerts + + @Test func issueAlertsDefaultOnAndPersistAcrossModels() throws { + let preferences = makePreferences() + let model = try makeModel(preferences: preferences) + + #expect(model.issueAlertsEnabled) + + model.issueAlertsEnabled = false + #expect(!model.issueAlertsEnabled) + + // A fresh model sharing the same preferences reads back the saved value. + let reloaded = try makeModel(preferences: preferences) + #expect(!reloaded.issueAlertsEnabled) + } + + /// Enabling issue alerts prompts for authorization and reconciles the alert + /// on; it also re-reconciles the reminder reconciler because the badge folds + /// in the issue count. + @Test func enablingIssueAlertsReconcilesAlertAndBadge() async throws { + let preferences = makePreferences() + preferences.issueAlertsEnabled = false + let alertSpy = SpyDataIssueAlertScheduler() + let reminderSpy = SpyReminderScheduler() + let services = try makeServices( + reminderScheduler: reminderSpy, + summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: alertSpy, + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.issueAlertsEnabled = true + + // Enabling requests authorization and reconciles the alert (the empty + // store has no issues yet, so the scheduler is reconciled *off* — the + // observable proof the intent reached it is the auth prompt + reconcile). + await waitUntil { await alertSpy.reconcileCount >= 1 } + #expect(await alertSpy.authorizationRequests >= 1) + // The reminder reconciler re-runs so the badge picks up the issue count. + await waitUntil { await reminderSpy.reconcileCount >= 1 } + } + + @Test func disablingIssueAlertsReconcilesOffWithoutAuthorization() async throws { + let preferences = makePreferences() + let alertSpy = SpyDataIssueAlertScheduler() + let services = try makeServices( + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: alertSpy, + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.issueAlertsEnabled = false + + await waitUntil { await alertSpy.reconcileCount >= 1 } + #expect(await alertSpy.lastEnabled == false) + #expect(await alertSpy.authorizationRequests == 0) + } + /// The setters reconcile off an unstructured `Task`, so poll the (actor) spy /// rather than assuming the reconcile has landed by the next line. private func waitUntil( @@ -256,3 +318,24 @@ private actor SpyDailySummaryScheduler: DailySummaryScheduling { lastTime = time } } + +/// The issue-alert counterpart to `SpyReminderScheduler`. +private actor SpyDataIssueAlertScheduler: DataIssueAlertScheduling { + private(set) var authorizationRequests = 0 + private(set) var reconcileCount = 0 + private(set) var lastEnabled: Bool? + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile(enabled: Bool, time _: ReminderTime, body _: String) async { + reconcileCount += 1 + lastEnabled = enabled + } +} diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 215343fb..2d969b57 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -1,7 +1,7 @@ import Foundation import LifecycleKit import Testing -import WhereCore +@_spi(Testing) import WhereCore import WhereTesting import WhereUI @@ -42,6 +42,7 @@ struct WhereLaunchTests { locationSource: ScriptedLocationSource(authorizationStatus: status), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) return WhereModel(services: services, preferences: preferences) @@ -59,6 +60,7 @@ struct WhereLaunchTests { .reconcileTracking, .reminders, .summary, + .issueAlerts, .widgetSnapshot, ].map { AnyHashable($0) }) } diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index f7b7b290..68e521fc 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -1,7 +1,7 @@ import Foundation import LifecycleKit import Testing -import WhereCore +@_spi(Testing) import WhereCore import WhereTesting import WhereUI @@ -39,6 +39,7 @@ struct WhereResetTests { locationSource: ScriptedLocationSource(authorizationStatus: status), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) } @@ -65,6 +66,7 @@ struct WhereResetTests { locationSource: source, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) return (WhereModel(services: services, preferences: preferences), source) diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift index 1f0e30b8..4c2abf14 100644 --- a/Where/WhereUI/Tests/WhereSessionTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import WhereCore +@_spi(Testing) import WhereCore import WhereTesting import WhereUI @@ -30,6 +30,7 @@ struct WhereSessionTests { locationSource: ScriptedLocationSource(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: scheduler, + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) return WhereSession(services: services, preferences: preferences) @@ -110,6 +111,7 @@ struct WhereSessionTests { locationSource: ScriptedLocationSource(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: refresher, now: { now }, ) diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index 52be2c7e..b5912be3 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import WhereCore +@_spi(Testing) import WhereCore import WhereTesting import WhereUI @@ -23,6 +23,7 @@ struct WhereSessionTrackingTests { locationSource: source, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) let session = WhereSession(services: services, preferences: preferences) diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index 7a7ac6a3..695de9ed 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -230,6 +230,35 @@ struct YearReportModelTests { #expect(preferences.driftThresholdMeters == DriftThreshold.km25.rawValue) } + @Test func hideEmptyTabsDefaultsOnAndPersistsAcrossModels() throws { + let store = try TestStore() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: preferences, + ) + + // Hidden out of the box, and the setter writes through to preferences. + #expect(report.hideEmptyTabs) + report.hideEmptyTabs = false + #expect(!preferences.hideEmptyTabs) + + // A model rebuilt over the same preferences reads the persisted choice. + let rebuilt = YearReportModel( + services: services, + selectedYear: 2026, + preferences: preferences, + ) + #expect(!rebuilt.hideEmptyTabs) + } + /// The Resolve list keys its scan `.task(id:)` on `dataIssueScanInputs`, so a /// drift-threshold change must change that identity — otherwise the list keeps /// a stale scan while the badge count moves and the two visibly disagree. The