From 7a8a9940a98c352165adfd9f8989e92a0e2edeca Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 01:29:26 -0400 Subject: [PATCH 1/3] Hide empty tabs, badge the app icon, and notify about data issues (Where) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide the Resolve and Elsewhere tabs when they have no content, but only once the user navigates away — while a tab is selected it stays put so resolving the last issue never bounces you off it. The app icon badge now folds the current-year data-issue count into the missing-day backlog, and a new local notification (gated by an issueAlertsEnabled preference, with a Settings toggle) nags about unresolved issues, mirroring the daily-summary scheduler/reconciler pair. DayJournal now invalidates the scanner and re-reconciles the badge and alert after every write, including dismiss/restore, which previously committed without re-badging. Co-authored-by: Cursor --- .../Sources/DataIssueAlertReconciler.swift | 90 +++++++++ .../DataResolution/DataIssueScanner.swift | 21 ++ Where/WhereCore/Sources/DayJournal.swift | 29 +++ Where/WhereCore/Sources/Region+Ordering.swift | 18 ++ .../Sources/ReminderReconciler.swift | 92 +++++++-- .../Reminders/DataIssueAlertScheduler.swift | 187 ++++++++++++++++++ .../Reminders/LoggingReminderScheduler.swift | 11 +- .../Sources/Resources/Localizable.xcstrings | 34 ++++ Where/WhereCore/Sources/WhereLog.swift | 2 + .../WhereCore/Sources/WherePreferences.swift | 9 + Where/WhereCore/Sources/WhereServices.swift | 45 ++++- .../Tests/DataIssueAlertReconcilerTests.swift | 159 +++++++++++++++ Where/WhereCore/Tests/DayJournalTests.swift | 15 ++ .../Tests/ReminderReconcilerTests.swift | 150 +++++++++++--- ...ficationDataIssueAlertSchedulerTests.swift | 151 ++++++++++++++ Where/WhereCore/Tests/WhereLogTests.swift | 2 +- .../WhereCore/Tests/WhereServicesTests.swift | 77 ++++++-- .../WhereUI/Sources/Launch/WhereLaunch.swift | 14 +- Where/WhereUI/Sources/MainTabs.swift | 40 +++- .../WhereUI/Sources/Model/RegionRanking.swift | 8 +- .../WhereUI/Sources/Model/WhereSession.swift | 36 +++- .../Sources/Resources/Localizable.xcstrings | 44 +++++ .../Settings/RemindersSettingsModel.swift | 44 ++++- .../Sources/Settings/SettingsView.swift | 29 +++ Where/WhereUI/Sources/Shared/Strings.swift | 32 +++ .../Tests/RemindersSettingsModelTests.swift | 83 ++++++++ Where/WhereUI/Tests/WhereLaunchTests.swift | 2 + Where/WhereUI/Tests/WhereResetTests.swift | 2 + Where/WhereUI/Tests/WhereSessionTests.swift | 2 + .../Tests/WhereSessionTrackingTests.swift | 1 + 30 files changed, 1347 insertions(+), 82 deletions(-) create mode 100644 Where/WhereCore/Sources/DataIssueAlertReconciler.swift create mode 100644 Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift create mode 100644 Where/WhereCore/Tests/DataIssueAlertReconcilerTests.swift create mode 100644 Where/WhereCore/Tests/UserNotificationDataIssueAlertSchedulerTests.swift 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 54c5d647..4d06facd 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -125,6 +125,27 @@ 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` from the year's own + /// totals — the same rule the Resolve tab uses — so the count matches what + /// the tab would show. Reads the report to rank regions; callers that already + /// hold a report should call `issues(...)` with `Region.primaryRegions(...)` + /// directly to avoid the extra read. + 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 25c2109a..60f84182 100644 --- a/Where/WhereCore/Sources/DayJournal.swift +++ b/Where/WhereCore/Sources/DayJournal.swift @@ -13,6 +13,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) @@ -21,11 +26,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 } @@ -63,7 +72,9 @@ public actor DayJournal { let key = aggregator.calendar.startOfDay(for: date) let presence = DayPresence(date: key, regions: regions) 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)", @@ -79,7 +90,9 @@ public actor DayJournal { let key = aggregator.calendar.startOfDay(for: date) let presence = DayPresence(date: key, regions: regions, isAuthoritative: true) 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)", @@ -93,7 +106,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))", @@ -121,7 +136,9 @@ public actor DayJournal { try await store.setManualDay(DayPresence(date: day, regions: regions)) } } + 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)", @@ -133,7 +150,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)") } @@ -145,7 +164,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") } @@ -169,10 +190,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 c16a18cc..83c7db41 100644 --- a/Where/WhereCore/Sources/Region+Ordering.swift +++ b/Where/WhereCore/Sources/Region+Ordering.swift @@ -37,4 +37,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..1608facb 100644 --- a/Where/WhereCore/Sources/ReminderReconciler.swift +++ b/Where/WhereCore/Sources/ReminderReconciler.swift @@ -12,6 +12,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 +30,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 +47,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 +96,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 +111,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 +133,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 +153,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 +169,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..2851bf0a --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -0,0 +1,187 @@ +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 +} + +/// A `DataIssueAlertScheduling` that does nothing. For SwiftUI previews and +/// view-model tests that need a controller without touching +/// `UNUserNotificationCenter`. Reports unauthorized so the UI's "denied" +/// affordances stay exercisable. +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 {} +} + +/// 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 + } + } + + 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 e229cb07..048910d6 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" + } + } + } + }, "region.california" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift index bb2bba74..118fe1c3 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..86d6c153 100644 --- a/Where/WhereCore/Sources/WherePreferences.swift +++ b/Where/WhereCore/Sources/WherePreferences.swift @@ -75,6 +75,14 @@ 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) } + } + /// GPS border-drift detection threshold in meters. Defaults to 10 km. public var driftThresholdMeters: Int { get { @@ -107,6 +115,7 @@ public final class WherePreferences { case summaryEnabled = "where.summaryEnabled" case summaryHour = "where.summaryHour" case summaryMinute = "where.summaryMinute" + case issueAlertsEnabled = "where.issueAlertsEnabled" case driftThresholdMeters = "where.driftThresholdMeters" } } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index c0ae48af..c84c4372 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -20,6 +20,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. @@ -46,14 +50,30 @@ 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(), 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, ) @@ -63,6 +83,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. @@ -90,6 +116,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() @@ -98,6 +129,7 @@ public struct WhereServices: Sendable { } } else if !outcome.changedDays.isEmpty { await reminders.reconcile() + await issueAlerts.reconcile() if outcome.needsFullWidgetRebuild { await widgets.publish() } @@ -108,23 +140,16 @@ 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(), - ) 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 7658b05a..1de4dcf1 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -55,10 +55,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, ) @@ -78,6 +91,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 99ff2ea9..52f461ce 100644 --- a/Where/WhereCore/Tests/ReminderReconcilerTests.swift +++ b/Where/WhereCore/Tests/ReminderReconcilerTests.swift @@ -5,65 +5,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( @@ -71,8 +102,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) } } @@ -80,6 +182,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 { @@ -93,12 +196,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 c328c55c..02e0f528 100644 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ b/Where/WhereCore/Tests/WhereLogTests.swift @@ -23,7 +23,7 @@ func categoryRawValuesMatchTypeNames() { // so Console.app filters keep working after the migration. #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") - #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 c0e3f189..95b0b110 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -641,7 +641,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) @@ -660,7 +665,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) @@ -672,7 +682,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). @@ -698,7 +713,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( @@ -728,7 +748,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) @@ -745,7 +770,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"), @@ -764,7 +794,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"), @@ -785,8 +820,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) @@ -799,10 +844,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..89474058 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,33 @@ 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) + // 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. + if !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.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 78cdf481..42604c12 100644 --- a/Where/WhereUI/Sources/Model/RegionRanking.swift +++ b/Where/WhereUI/Sources/Model/RegionRanking.swift @@ -39,10 +39,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/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 6d12c219..beb7f52b 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -1927,6 +1927,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" : { 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 34911518..c1aea60a 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -57,6 +57,7 @@ struct SettingsView: View { trackingSection remindersSection summarySection + issueAlertsSection resolutionSection appIconSection manualEntrySection @@ -246,6 +247,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 diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 69083026..76b734e6 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -624,6 +624,38 @@ 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 settingsDataHeader: String { localized("settings.data.header") } diff --git a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift index 12c2f450..12d85cad 100644 --- a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift +++ b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift @@ -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..1ea04d5a 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -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..0d842476 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -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..864b9f3d 100644 --- a/Where/WhereUI/Tests/WhereSessionTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTests.swift @@ -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..1aa86fcb 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -23,6 +23,7 @@ struct WhereSessionTrackingTests { locationSource: source, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) let session = WhereSession(services: services, preferences: preferences) From 3492a3a296fdd614d88167f924b1556c25157d09 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 14:50:49 -0400 Subject: [PATCH 2/3] Address PR review: SPI-gate the issue-alert Noop and document the scheduler Mark NoopDataIssueAlertScheduler @_spi(Testing) behind #if DEBUG per the testing-hook convention (it's used only by tests, not previews) and import it via @_spi(Testing) where consumed. Document the concrete reconcile(...) on the UserNotification scheduler, and clarify in currentIssueCount's doc that the "primary" rule is the shared Region.primaryRegions helper (also used by RegionRanking), so no ranking logic is duplicated. Co-authored-by: Cursor --- .../DataResolution/DataIssueScanner.swift | 14 ++++--- .../Reminders/DataIssueAlertScheduler.swift | 41 ++++++++++++------- Where/WhereCore/Tests/DayJournalTests.swift | 2 +- .../Tests/RemindersSettingsModelTests.swift | 2 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 2 +- Where/WhereUI/Tests/WhereResetTests.swift | 2 +- Where/WhereUI/Tests/WhereSessionTests.swift | 2 +- .../Tests/WhereSessionTrackingTests.swift | 2 +- 8 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index 4d06facd..3ad233aa 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -127,11 +127,15 @@ public actor DataIssueScanner { /// 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` from the year's own - /// totals — the same rule the Resolve tab uses — so the count matches what - /// the tab would show. Reads the report to rank regions; callers that already - /// hold a report should call `issues(...)` with `Region.primaryRegions(...)` - /// directly to avoid the extra read. + /// `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, diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift index 2851bf0a..80fb41c3 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -29,23 +29,30 @@ public protocol DataIssueAlertScheduling: Sendable { func reconcile(enabled: Bool, time: ReminderTime, body: String) async } -/// A `DataIssueAlertScheduling` that does nothing. For SwiftUI previews and -/// view-model tests that need a controller without touching -/// `UNUserNotificationCenter`. Reports unauthorized so the UI's "denied" -/// affordances stay exercisable. -public struct NoopDataIssueAlertScheduler: DataIssueAlertScheduling { - public init() {} +#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 requestAuthorization() async -> Bool { - false - } + public func isAuthorized() async -> Bool { + false + } - public func isAuthorized() async -> Bool { - false + public func reconcile(enabled _: Bool, time _: ReminderTime, body _: String) async {} } - - 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 @@ -90,6 +97,12 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu } } + /// 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() diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index 1de4dcf1..e969a540 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -1,6 +1,6 @@ import Foundation 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 diff --git a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift index 12d85cad..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 diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 1ea04d5a..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 diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 0d842476..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 diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift index 864b9f3d..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 diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index 1aa86fcb..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 From 21cf1f9a37dff6f7fd8c241dee34d01271bb1ff6 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 20:02:01 -0400 Subject: [PATCH 3/3] Add a setting to keep the Elsewhere/Resolve tabs when empty New hideEmptyTabs preference (default true, so the tab bar stays focused out of the box) with a Settings > Tabs toggle. When off, MainTabs keeps both the Elsewhere and Resolve tabs permanently visible instead of hiding them once they have nothing to show. Mirrored as an observable on YearReportModel the way driftThreshold is, so flipping it re-evaluates the tab bar immediately. Co-authored-by: Cursor --- .../WhereCore/Sources/WherePreferences.swift | 10 ++++++ Where/WhereUI/Sources/MainTabs.swift | 16 +++++---- .../Sources/Model/YearReportModel.swift | 18 ++++++++++ .../Sources/Resources/Localizable.xcstrings | 33 +++++++++++++++++++ .../Sources/Settings/SettingsView.swift | 14 ++++++++ Where/WhereUI/Sources/Shared/Strings.swift | 24 ++++++++++++++ .../WhereUI/Tests/YearReportModelTests.swift | 29 ++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) diff --git a/Where/WhereCore/Sources/WherePreferences.swift b/Where/WhereCore/Sources/WherePreferences.swift index 86d6c153..5e6f978f 100644 --- a/Where/WhereCore/Sources/WherePreferences.swift +++ b/Where/WhereCore/Sources/WherePreferences.swift @@ -83,6 +83,15 @@ public final class WherePreferences { 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 { @@ -116,6 +125,7 @@ public final class WherePreferences { case summaryHour = "where.summaryHour" case summaryMinute = "where.summaryMinute" case issueAlertsEnabled = "where.issueAlertsEnabled" + case hideEmptyTabs = "where.hideEmptyTabs" case driftThresholdMeters = "where.driftThresholdMeters" } } diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 89474058..4ca3b55d 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -44,11 +44,15 @@ struct MainTabs: View { PrimaryView(report: report) } - // 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. - if !report.ranking.secondary.isEmpty || selection == .elsewhere { + // 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", @@ -58,7 +62,7 @@ struct MainTabs: View { } } - if report.dataIssueCount > 0 || selection == .resolution { + if !report.hideEmptyTabs || report.dataIssueCount > 0 || selection == .resolution { Tab(Strings.tabResolution, systemImage: "checklist", value: TabID.resolution) { ResolutionView(report: report) } diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 6eb32987..6061d9e2 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -113,6 +113,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 @@ -180,6 +197,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 12b5ad3d..8ccf55aa 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2268,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/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index c1aea60a..5b946fd1 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -59,6 +59,7 @@ struct SettingsView: View { summarySection issueAlertsSection resolutionSection + tabsSection appIconSection manualEntrySection backupSection @@ -290,6 +291,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 297dd80b..088ca238 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -698,6 +698,30 @@ enum Strings { ) } + 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/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