From a04b1fae56ff4b582d2ac883629318330a813113 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:08:46 -0700 Subject: [PATCH 01/13] Add MissingDays calculator and ranges Closes the "MissingDays calculator + MissingDayRange" plan step: pure WhereCore logic that finds unlogged days in [Jan 1, through] for a year and collapses them into consecutive ranges, with tests covering boundaries, leap years, clamping, and range collapsing. Co-authored-by: Cursor --- Where/WhereCore/Sources/MissingDays.swift | 112 +++++++++++++ Where/WhereCore/Tests/MissingDaysTests.swift | 157 +++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 Where/WhereCore/Sources/MissingDays.swift create mode 100644 Where/WhereCore/Tests/MissingDaysTests.swift diff --git a/Where/WhereCore/Sources/MissingDays.swift b/Where/WhereCore/Sources/MissingDays.swift new file mode 100644 index 0000000..882e3bc --- /dev/null +++ b/Where/WhereCore/Sources/MissingDays.swift @@ -0,0 +1,112 @@ +import Foundation + +/// A maximal run of consecutive calendar days that have no recorded presence — +/// e.g. "Jan 3 – Jan 7, 5 days". Used to surface logging gaps in the UI and to +/// drive the backfill flow. +public struct MissingDayRange: Hashable, Sendable, Identifiable { + /// Start-of-day key of the first missing day in the run. + public let start: Date + /// Start-of-day key of the last missing day in the run. + public let end: Date + public let dayCount: Int + + public var id: Date { + start + } + + public init(start: Date, end: Date, dayCount: Int) { + self.start = start + self.end = end + self.dayCount = dayCount + } +} + +/// Pure rules for finding the calendar days the user *should* have logged but +/// didn't. A day "counts as missed" when it falls in the inclusive window +/// `[Jan 1 of the year, through]` and has no `DayPresence`. No I/O. +public enum MissingDays { + /// Start-of-day keys in `[Jan 1 of year, min(through, Dec 31 of year)]` + /// (inclusive) that are absent from `present`. + /// + /// `present` should be the set of start-of-day keys that already have any + /// recorded presence (GPS or manual). It is normalized to start-of-day in + /// `calendar` defensively, so callers don't have to pre-normalize. Pass the + /// same `calendar` (identifier + timezone) that produced the `present` + /// keys, otherwise day boundaries won't line up. + public static func missingDayKeys( + year: Int, + through: Date, + present: Set, + calendar: Calendar, + ) -> [Date] { + guard + let firstOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1)), + let firstOfNextYear = calendar.date( + from: DateComponents(year: year + 1, month: 1, day: 1), + ), + let lastOfYear = calendar.date(byAdding: .day, value: -1, to: firstOfNextYear) + else { return [] } + + let start = calendar.startOfDay(for: firstOfYear) + // Clamp the upper bound to this year so a `through` in a later year + // doesn't pull next year's days into the result. + let requested = calendar.startOfDay(for: through) + let last = min(requested, calendar.startOfDay(for: lastOfYear)) + guard start <= last else { return [] } + + let normalizedPresent = Set(present.map { calendar.startOfDay(for: $0) }) + return start + .calendarDays(through: last, in: calendar) + .filter { !normalizedPresent.contains($0) } + } + + /// Collapse a set of start-of-day keys into maximal consecutive runs, + /// sorted ascending by start date. Keys are normalized and de-duplicated in + /// `calendar` first, so adjacent calendar days fold into one range. + public static func ranges(_ keys: [Date], calendar: Calendar) -> [MissingDayRange] { + let sorted = Set(keys.map { calendar.startOfDay(for: $0) }).sorted() + guard var runStart = sorted.first else { return [] } + + var previous = runStart + var count = 1 + var result: [MissingDayRange] = [] + for date in sorted.dropFirst() { + if isConsecutive(previous, date, calendar: calendar) { + previous = date + count += 1 + } else { + result.append(MissingDayRange(start: runStart, end: previous, dayCount: count)) + runStart = date + previous = date + count = 1 + } + } + result.append(MissingDayRange(start: runStart, end: previous, dayCount: count)) + return result + } + + /// Convenience: the missing days for a year, already collapsed into ranges. + public static func missingRanges( + year: Int, + through: Date, + present: Set, + calendar: Calendar, + ) -> [MissingDayRange] { + let keys = missingDayKeys( + year: year, + through: through, + present: present, + calendar: calendar, + ) + return ranges(keys, calendar: calendar) + } + + private static func isConsecutive( + _ previous: Date, + _ candidate: Date, + calendar: Calendar, + ) -> Bool { + guard let next = calendar.date(byAdding: .day, value: 1, to: previous) else { return false } + return calendar.isDate(next, inSameDayAs: candidate) + } +} diff --git a/Where/WhereCore/Tests/MissingDaysTests.swift b/Where/WhereCore/Tests/MissingDaysTests.swift new file mode 100644 index 0000000..673a7fb --- /dev/null +++ b/Where/WhereCore/Tests/MissingDaysTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import WhereCore + +struct MissingDaysTests { + private static var calendar: Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return cal + } + + private static func day(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + // MARK: missingDayKeys + + @Test func emptyPresenceMissesEveryDayThroughTheGivenDay() { + let through = Self.day(2026, 1, 5) + let missing = MissingDays.missingDayKeys( + year: 2026, + through: through, + present: [], + calendar: Self.calendar, + ) + #expect(missing == [ + Self.day(2026, 1, 1), + Self.day(2026, 1, 2), + Self.day(2026, 1, 3), + Self.day(2026, 1, 4), + Self.day(2026, 1, 5), + ]) + } + + @Test func presentDaysAreExcludedAndThroughDayIsInclusive() { + let through = Self.day(2026, 1, 4) + let present: Set = [Self.day(2026, 1, 2), Self.day(2026, 1, 4)] + let missing = MissingDays.missingDayKeys( + year: 2026, + through: through, + present: present, + calendar: Self.calendar, + ) + #expect(missing == [Self.day(2026, 1, 1), Self.day(2026, 1, 3)]) + } + + @Test func presenceKeysAreNormalizedToStartOfDay() throws { + // A "present" key carrying a mid-day time should still exclude that day. + let midday = try #require( + Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 2, hour: 15)), + ) + let missing = MissingDays.missingDayKeys( + year: 2026, + through: Self.day(2026, 1, 3), + present: [midday], + calendar: Self.calendar, + ) + #expect(missing == [Self.day(2026, 1, 1), Self.day(2026, 1, 3)]) + } + + @Test func throughInALaterYearClampsToDecemberThirtyFirst() { + let through = Self.day(2027, 6, 15) + let missing = MissingDays.missingDayKeys( + year: 2026, + through: through, + present: [], + calendar: Self.calendar, + ) + #expect(missing.first == Self.day(2026, 1, 1)) + #expect(missing.last == Self.day(2026, 12, 31)) + // 2026 is not a leap year. + #expect(missing.count == 365) + } + + @Test func throughBeforeStartOfYearYieldsNothing() { + let missing = MissingDays.missingDayKeys( + year: 2026, + through: Self.day(2025, 12, 31), + present: [], + calendar: Self.calendar, + ) + #expect(missing.isEmpty) + } + + @Test func leapYearIncludesFebruaryTwentyNinth() { + let missing = MissingDays.missingDayKeys( + year: 2024, + through: Self.day(2024, 12, 31), + present: [], + calendar: Self.calendar, + ) + #expect(missing.contains(Self.day(2024, 2, 29))) + #expect(missing.count == 366) + } + + // MARK: ranges + + @Test func rangesCollapseConsecutiveDaysAndSplitOnGaps() { + let keys = [ + Self.day(2026, 1, 1), + Self.day(2026, 1, 2), + Self.day(2026, 1, 3), + Self.day(2026, 1, 7), + Self.day(2026, 1, 10), + Self.day(2026, 1, 11), + ] + let ranges = MissingDays.ranges(keys, calendar: Self.calendar) + #expect(ranges == [ + MissingDayRange(start: Self.day(2026, 1, 1), end: Self.day(2026, 1, 3), dayCount: 3), + MissingDayRange(start: Self.day(2026, 1, 7), end: Self.day(2026, 1, 7), dayCount: 1), + MissingDayRange(start: Self.day(2026, 1, 10), end: Self.day(2026, 1, 11), dayCount: 2), + ]) + } + + @Test func rangesAcrossMonthBoundaryStayContiguous() { + let keys = [ + Self.day(2026, 1, 30), + Self.day(2026, 1, 31), + Self.day(2026, 2, 1), + ] + let ranges = MissingDays.ranges(keys, calendar: Self.calendar) + #expect(ranges == [ + MissingDayRange(start: Self.day(2026, 1, 30), end: Self.day(2026, 2, 1), dayCount: 3), + ]) + } + + @Test func rangesDeduplicateAndSortUnorderedInput() { + let keys = [ + Self.day(2026, 3, 2), + Self.day(2026, 3, 1), + Self.day(2026, 3, 2), + ] + let ranges = MissingDays.ranges(keys, calendar: Self.calendar) + #expect(ranges == [ + MissingDayRange(start: Self.day(2026, 3, 1), end: Self.day(2026, 3, 2), dayCount: 2), + ]) + } + + @Test func emptyKeysYieldNoRanges() { + #expect(MissingDays.ranges([], calendar: Self.calendar).isEmpty) + } + + @Test func missingRangesConvenienceMatchesManualComposition() { + let present: Set = [Self.day(2026, 1, 2)] + let through = Self.day(2026, 1, 4) + let ranges = MissingDays.missingRanges( + year: 2026, + through: through, + present: present, + calendar: Self.calendar, + ) + #expect(ranges == [ + MissingDayRange(start: Self.day(2026, 1, 1), end: Self.day(2026, 1, 1), dayCount: 1), + MissingDayRange(start: Self.day(2026, 1, 3), end: Self.day(2026, 1, 4), dayCount: 2), + ]) + } +} From 1620fd98450db15d5e9cb4ad92565b88a2c041ee Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:13:31 -0700 Subject: [PATCH 02/13] Add logging reminder scheduler Closes the "ReminderTime + LoggingReminderScheduling + scheduler" plan step: ReminderTime value, the scheduling protocol, and a UserNotifications-backed implementation that schedules per-day cancellable reminders and drives the app-icon badge. Notification copy added to the WhereCore string catalog. Pure groundwork: exercised by the controller wiring + spy tests in the next step, so there are no standalone tests here. Verified to compile via `tuist build Where`. Co-authored-by: Cursor --- .../Reminders/LoggingReminderScheduler.swift | 200 ++++++++++++++++++ .../Sources/Resources/Localizable.xcstrings | 22 ++ 2 files changed, 222 insertions(+) create mode 100644 Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift new file mode 100644 index 0000000..bea2c8e --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -0,0 +1,200 @@ +import Foundation +import os +import UserNotifications + +/// Time of day (in the user's calendar) at which the daily "log before the day +/// ends" reminder fires. Defaults to 8 PM. +public struct ReminderTime: Hashable, Sendable { + public let hour: Int + public let minute: Int + + public init(hour: Int, minute: Int) { + self.hour = hour + self.minute = minute + } + + /// 8 PM — late enough that a passive day's GPS has usually landed, early + /// enough to still act before midnight. + public static let defaultEvening = ReminderTime(hour: 20, minute: 0) +} + +/// Schedules the per-day local notifications and app-icon badge that nudge the +/// user to log a day before it ends. Behind a protocol so `WhereController` can +/// drive it deterministically from tests. +public protocol LoggingReminderScheduling: Sendable { + /// Ask the system for permission to post alerts/sounds/badges. Returns + /// whether the app is authorized afterward. Safe to call repeatedly. + func requestAuthorization() async -> Bool + + /// Reconcile scheduled reminders and the app-icon badge against the current + /// picture. + /// + /// - Parameters: + /// - badgeCount: total unlogged days this year (the backlog). Shown as the + /// app-icon badge when `enabled`, cleared to 0 otherwise. + /// - 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 + /// delivered reminder removed — this is the "recorded successfully, + /// remove the notification" path. + /// - reminderTime: time of day each reminder fires. + /// - enabled: master switch. When `false`, every reminder this app + /// scheduled is cancelled and the badge cleared. + func reconcile( + badgeCount: Int, + scheduleDays: [Date], + reminderTime: ReminderTime, + enabled: Bool, + ) async +} + +/// Production `LoggingReminderScheduling` backed by `UNUserNotificationCenter`. +/// Only touches notification requests it owns (matched by identifier prefix) so +/// it never disturbs notifications from elsewhere in the app. +public final class UserNotificationReminderScheduler: LoggingReminderScheduling, + @unchecked Sendable +{ + private let center: UNUserNotificationCenter + private let calendar: Calendar + + private static let identifierPrefix = "com.stuff.where.logging-reminder" + private static let logger = Logger( + subsystem: "com.stuff.where", + category: "LoggingReminderScheduler", + ) + + public init( + center: UNUserNotificationCenter = .current(), + calendar: Calendar = { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = .current + return cal + }(), + ) { + self.center = center + self.calendar = calendar + } + + 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, privacy: .public)", + ) + return false + } + } + + public func reconcile( + badgeCount: Int, + scheduleDays: [Date], + reminderTime: ReminderTime, + enabled: Bool, + ) async { + guard enabled else { + await removeAllOwnedReminders() + await setBadge(0) + return + } + + // Without authorization we can neither post alerts nor set the badge, + // so there's nothing useful to reconcile. + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + break + default: + return + } + + let desiredIDs = Dictionary( + scheduleDays.map { (identifier(for: $0), $0) }, + uniquingKeysWith: { first, _ in first }, + ) + + // Cancel reminders we own that are no longer wanted (the day got logged + // or fell out of the window). + let pending = await center.pendingNotificationRequests() + let pendingIDs = Set(pending.map(\.identifier)) + let stalePending = pendingIDs.filter { isOwned($0) && desiredIDs[$0] == nil } + if !stalePending.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: Array(stalePending)) + } + + let delivered = await center.deliveredNotifications() + let staleDelivered = delivered + .map(\.request.identifier) + .filter { isOwned($0) && desiredIDs[$0] == nil } + if !staleDelivered.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: staleDelivered) + } + + // Schedule the wanted reminders that aren't already pending. + for (id, day) in desiredIDs where !pendingIDs.contains(id) { + await scheduleReminder(identifier: id, day: day, time: reminderTime) + } + + await setBadge(badgeCount) + } + + private func scheduleReminder(identifier: String, day: Date, time: ReminderTime) async { + var components = calendar.dateComponents([.year, .month, .day], from: day) + components.hour = time.hour + components.minute = time.minute + + let content = UNMutableNotificationContent() + content.title = String(localized: "reminder.notification.title", bundle: .module) + content.body = String(localized: "reminder.notification.body", bundle: .module) + content.sound = .default + + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: trigger, + ) + do { + try await center.add(request) + } catch { + Self.logger.error( + "Failed to schedule reminder \(identifier, privacy: .public): \(error.localizedDescription, privacy: .public)", + ) + } + } + + private func removeAllOwnedReminders() async { + let pending = await center.pendingNotificationRequests() + let pendingIDs = pending.map(\.identifier).filter(isOwned) + if !pendingIDs.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: pendingIDs) + } + let delivered = await center.deliveredNotifications() + let deliveredIDs = delivered.map(\.request.identifier).filter(isOwned) + if !deliveredIDs.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: deliveredIDs) + } + } + + private func setBadge(_ count: Int) async { + do { + try await center.setBadgeCount(max(0, count)) + } catch { + Self.logger.error( + "Failed to set badge count: \(error.localizedDescription, privacy: .public)", + ) + } + } + + private func identifier(for day: Date) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: day) + let year = components.year ?? 0 + let month = components.month ?? 0 + let dayOfMonth = components.day ?? 0 + return "\(Self.identifierPrefix).\(year)-\(month)-\(dayOfMonth)" + } + + private func isOwned(_ identifier: String) -> Bool { + identifier.hasPrefix(Self.identifierPrefix) + } +} diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index 826da5c..c9f69c4 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -55,6 +55,28 @@ } } } + }, + "reminder.notification.body" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where before the day ends so we don't miss logging today." + } + } + } + }, + "reminder.notification.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log today's location" + } + } + } } }, "version" : "1.0" From cf2c4fa16a4ac1c9c043ea0a00a2ba27a5828e1c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:19:38 -0700 Subject: [PATCH 03/13] Wire logging reminders into WhereController Closes the "controller reminder wiring" plan step: WhereController now takes an injectable reminder scheduler + clock, exposes configureReminders / notification-auth passthroughs, and reconciles the badge + per-day reminders after GPS ingest, manual entry, and startGPS. A frozen-clock spy scheduler drives deterministic tests for the backlog badge, the today-logged cancellation path, and enable/disable. Also adds isAuthorized() to the scheduler protocol and declares an explicit WhereCoreTests scheme so the bundle can run on its own (the autogenerated scheme has no working test action). Co-authored-by: Cursor --- Project.swift | 11 +- .../Reminders/LoggingReminderScheduler.swift | 15 ++ Where/WhereCore/Sources/WhereController.swift | 120 ++++++++++++++ .../Tests/WhereControllerTests.swift | 152 ++++++++++++++++++ 4 files changed, 293 insertions(+), 5 deletions(-) diff --git a/Project.swift b/Project.swift index 5225ab8..dcc88c3 100644 --- a/Project.swift +++ b/Project.swift @@ -129,12 +129,13 @@ let project = Project( sources: ["Where/WhereUI/Tests/**"], ), ], - // Tuist's autogeneration doesn't emit standalone schemes for these two - // unit-test bundles (only the aggregate `Stuff-Workspace` scheme covers - // them), so declare them explicitly. This lets `tuist test WhereTests` / - // `tuist test WhereUITests` target a single bundle without building the - // whole workspace. + // Tuist's autogeneration doesn't emit working standalone test actions for + // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme + // runs them), so declare them explicitly. This lets `tuist test + // WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests` + // target a single bundle without building the whole workspace. schemes: [ + testScheme(name: "WhereCoreTests"), testScheme(name: "WhereTests"), testScheme(name: "WhereUITests"), ], diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index bea2c8e..c749c15 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -26,6 +26,11 @@ public protocol LoggingReminderScheduling: Sendable { /// whether the app is authorized afterward. Safe to call repeatedly. func requestAuthorization() async -> Bool + /// Whether the app is currently authorized to post notifications and set + /// the badge. Lets the UI route the user to Settings when they've enabled + /// reminders but denied the system permission. + func isAuthorized() async -> Bool + /// Reconcile scheduled reminders and the app-icon badge against the current /// picture. /// @@ -86,6 +91,16 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, } } + public func isAuthorized() async -> Bool { + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + } + public func reconcile( badgeCount: Int, scheduleDays: [Date], diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index e1034d5..b482a7d 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -19,9 +19,31 @@ public actor WhereController { private let locationSource: any LocationSource private let attributor: RegionAttributor private let aggregator: DayAggregator + private let reminderScheduler: any LoggingReminderScheduling + private let now: @Sendable () -> Date private var ingestTask: Task? + /// User intent for the daily "log before the day ends" reminder. Disabled + /// until the UI calls `configureReminders(enabled:time:)`, so a freshly + /// constructed controller never schedules anything on its own. + private var reminderConfig = ReminderConfiguration() + + /// The start-of-day we last reconciled while today already had presence. + /// Lets the GPS ingest path skip a full re-scan once today is covered + /// (significant-change events can fire many times a day). + private var todayCoveredByReconcile: Date? + + /// How many days past today the per-day reminders are scheduled ahead, so a + /// stretch where the app never runs still has reminders queued. Today plus + /// this many days. + private static let reminderWindowDays = 6 + + private struct ReminderConfiguration { + var enabled = false + var time: ReminderTime = .defaultEvening + } + /// Whether the underlying location monitoring is currently active. Tracked /// separately from `ingestTask` because the ingestion task outlives a /// `stopGPS()` pause (see `startGPS()` for why). @@ -46,11 +68,15 @@ public actor WhereController { locationSource: any LocationSource, attributor: RegionAttributor = .shared, aggregator: DayAggregator = DayAggregator(), + reminderScheduler: any LoggingReminderScheduling = UserNotificationReminderScheduler(), + now: @escaping @Sendable () -> Date = { Date() }, ) { self.store = store self.locationSource = locationSource self.attributor = attributor self.aggregator = aggregator + self.reminderScheduler = reminderScheduler + self.now = now } deinit { @@ -73,6 +99,7 @@ public actor WhereController { let key = aggregator.calendar.startOfDay(for: date) let presence = DayPresence(date: key, regions: regions) try await store.perform { try await store.setManualDay(presence) } + await reconcileReminders() } /// Assert `regions` for every calendar day in the inclusive range @@ -96,6 +123,7 @@ public actor WhereController { try await store.setManualDay(DayPresence(date: day, regions: regions)) } } + await reconcileReminders() } // MARK: - Evidence @@ -150,6 +178,9 @@ public actor WhereController { // Flush anything that failed to persist before this session // started, before we (re)attach the stream consumer. await drainRetryQueue() + // Refresh the badge / reminders against whatever the resumed session + // already knows about (e.g. after a background relaunch). + await reconcileReminders() guard ingestTask == nil else { return } let stream = locationSource.sampleStream ingestTask = Task { [weak self] in @@ -168,6 +199,7 @@ public actor WhereController { await drainRetryQueue() do { try await store.perform { try await store.add(sample: sample) } + await reconcileRemindersAfterIngest() } catch { // Persistence failures (SwiftData save, CloudKit, etc.) // are surfaced via `os.Logger` instead of being silently @@ -245,4 +277,92 @@ public actor WhereController { public var isTrackingActive: Bool { isMonitoring } + + // MARK: - Logging reminders + + /// 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 configureReminders(enabled: Bool, time: ReminderTime) async { + reminderConfig = ReminderConfiguration(enabled: enabled, time: time) + if enabled { + _ = await reminderScheduler.requestAuthorization() + } + await reconcileReminders() + } + + /// Explicitly drive the notification permission prompt (e.g. from a + /// Settings toggle). Returns whether the app is authorized afterward. + @discardableResult + public func requestNotificationAuthorization() async -> Bool { + await reminderScheduler.requestAuthorization() + } + + /// Whether the app is currently authorized to post reminders / set the + /// badge, so the UI can surface an "open Settings" affordance. + public func notificationAuthorizationGranted() async -> Bool { + await reminderScheduler.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. + private func reconcileRemindersAfterIngest() async { + guard reminderConfig.enabled else { return } + let today = aggregator.calendar.startOfDay(for: now()) + guard todayCoveredByReconcile != today else { return } + await reconcileReminders() + } + + /// 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. This + /// is the single source of truth for "recorded successfully -> drop today's + /// reminder and lower the badge". + private func reconcileReminders() async { + guard reminderConfig.enabled else { + await reminderScheduler.reconcile( + badgeCount: 0, + scheduleDays: [], + reminderTime: reminderConfig.time, + enabled: false, + ) + todayCoveredByReconcile = nil + return + } + + let calendar = aggregator.calendar + let today = calendar.startOfDay(for: now()) + let year = calendar.component(.year, from: today) + do { + let report = try await yearReport(for: year) + let present = Set(report.days.map(\.date)) + let missing = MissingDays.missingDayKeys( + year: year, + through: today, + present: present, + calendar: calendar, + ) + let windowEnd = calendar.date( + byAdding: .day, + value: Self.reminderWindowDays, + to: today, + ) ?? today + let scheduleDays = today + .calendarDays(through: windowEnd, in: calendar) + .filter { !present.contains($0) } + await reminderScheduler.reconcile( + badgeCount: missing.count, + scheduleDays: scheduleDays, + reminderTime: reminderConfig.time, + enabled: true, + ) + todayCoveredByReconcile = present.contains(today) ? today : nil + } catch { + Self.logger.error( + "Failed to reconcile logging reminders: \(error.localizedDescription, privacy: .public)", + ) + } + } } diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index 72dac76..be579c0 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -22,6 +22,30 @@ struct WhereControllerTests { return (controller, store, source) } + private static var pacificCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = pacific + return calendar + } + + /// Build a controller with a spy scheduler and a frozen `now` so the + /// reminder/badge reconciliation is deterministic. + private static func makeReminderController( + now: Date, + scheduler: SpyReminderScheduler, + ) throws -> (WhereController, SwiftDataStore, ScriptedLocationSource) { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource() + let controller = WhereController( + store: store, + locationSource: source, + aggregator: makeAggregator(), + reminderScheduler: scheduler, + now: { now }, + ) + return (controller, store, source) + } + @Test func ingestStoresSamplesAndReportsThem() async throws { let (controller, _, _) = try Self.makeController() let sf = LocationSample( @@ -267,6 +291,95 @@ struct WhereControllerTests { let fetchedBlob = try await controller.evidenceBlob(for: evidence.id) #expect(fetchedBlob == blob) } + + // MARK: - Logging reminders + + @Test func configureRemindersEnabledRequestsAuthAndBadgesTheBacklog() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) + + await controller.configureReminders(enabled: true, time: .defaultEvening) + + #expect(await spy.authorizationRequests == 1) + #expect(await spy.lastEnabled == true) + // Jan 1–5 are all unlogged. + #expect(await spy.lastBadgeCount == 5) + // Rolling window is today + 6 days (Jan 5–11), all still unlogged. + #expect(await spy.lastScheduleDays.count == 7) + let today = Self.pacificCalendar.startOfDay(for: now) + #expect(await spy.lastScheduleDays.contains(today)) + } + + @Test func configureRemindersDisabledClearsBadgeAndSchedulesNothing() async throws { + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController( + now: iso("2026-01-05T09:00:00-08:00"), + scheduler: spy, + ) + + await controller.configureReminders(enabled: false, time: .defaultEvening) + + #expect(await spy.authorizationRequests == 0) + #expect(await spy.lastEnabled == false) + #expect(await spy.lastBadgeCount == 0) + #expect(await spy.lastScheduleDays.isEmpty) + } + + @Test func loggingTodayViaGPSDropsItsReminderAndLowersBadge() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, source) = try Self.makeReminderController(now: now, scheduler: spy) + await controller.configureReminders(enabled: true, time: .defaultEvening) + + let today = Self.pacificCalendar.startOfDay(for: now) + #expect(await spy.lastBadgeCount == 5) + #expect(await spy.lastScheduleDays.contains(today)) + + await controller.startGPS() + source.emit(LocationSample( + timestamp: iso("2026-01-05T12:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .gpsSignificantChange, + )) + + try await waitUntil { await spy.lastBadgeCount == 4 } + #expect(await !spy.lastScheduleDays.contains(today)) + + await controller.stopGPS() + } + + @Test func manualDayLoggingLowersTheBadge() async throws { + let now = iso("2026-03-10T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) + await controller.configureReminders(enabled: true, time: .defaultEvening) + + // Jan 1 – Mar 10 inclusive is the 69th day of 2026 (a non-leap year). + #expect(await spy.lastBadgeCount == 69) + + try await controller.addManualDay( + date: iso("2026-03-03T12:00:00-08:00"), + regions: [.california], + ) + + #expect(await spy.lastBadgeCount == 68) + } + + @Test func disablingRemindersAfterEnablingClearsEverything() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) + + await controller.configureReminders(enabled: true, time: .defaultEvening) + #expect(await spy.lastBadgeCount == 5) + + await controller.configureReminders(enabled: false, time: .defaultEvening) + #expect(await spy.lastEnabled == false) + #expect(await spy.lastBadgeCount == 0) + #expect(await spy.lastScheduleDays.isEmpty) + } } private func iso(_ string: String) -> Date { @@ -287,6 +400,45 @@ private func waitUntil( Issue.record("waitUntil timed out") } +/// Records the calls `WhereController` makes to the reminder scheduler so +/// tests can assert the badge count, scheduled days, and enabled state without +/// touching `UNUserNotificationCenter`. +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 lastReminderTime: ReminderTime? + private(set) var lastEnabled: Bool? + private let authorized: Bool + + init(authorized: Bool = true) { + self.authorized = authorized + } + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return authorized + } + + func isAuthorized() async -> Bool { + authorized + } + + func reconcile( + badgeCount: Int, + scheduleDays: [Date], + reminderTime: ReminderTime, + enabled: Bool, + ) async { + reconcileCount += 1 + lastBadgeCount = badgeCount + lastScheduleDays = scheduleDays + lastReminderTime = reminderTime + lastEnabled = enabled + } +} + private struct ToggleFailingStoreError: Error {} /// `WhereStore` that lets a test toggle whether `add(sample:)` succeeds. From 37fa52c84aa6d44ae5871ba43c5482947248c2b7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:25:55 -0700 Subject: [PATCH 04/13] Expose missing days + reminder settings on WhereModel Closes the "extend WhereModel" plan step: persisted remindersEnabled / reminderTime (default on, 8 PM), setters that push the config to the controller on change, configureReminders on start(), and missingDays/missingDayCount derived from the loaded report for the current year. Adds tests for the computation + settings persistence. Adds a NoopLoggingReminderScheduler in WhereCore (alongside the existing ScriptedLocationSource preview/test seam) and wires it into PreviewSupport and the view-model tests so they never touch UNUserNotificationCenter. Co-authored-by: Cursor --- .../Reminders/LoggingReminderScheduler.swift | 23 +++++ Where/WhereUI/Sources/Model/WhereModel.swift | 94 +++++++++++++++++++ .../Sources/Preview/PreviewSupport.swift | 3 + .../Tests/WhereModelMissingDaysTests.swift | 91 ++++++++++++++++++ .../Tests/WhereModelRefreshTests.swift | 18 +++- .../Tests/WhereModelTrackingTests.swift | 1 + 6 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 Where/WhereUI/Tests/WhereModelMissingDaysTests.swift diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index c749c15..044e3b9 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -53,6 +53,29 @@ public protocol LoggingReminderScheduling: Sendable { ) async } +/// A `LoggingReminderScheduling` 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 NoopLoggingReminderScheduler: LoggingReminderScheduling { + public init() {} + + public func requestAuthorization() async -> Bool { + false + } + + public func isAuthorized() async -> Bool { + false + } + + public func reconcile( + badgeCount _: Int, + scheduleDays _: [Date], + reminderTime _: ReminderTime, + enabled _: Bool, + ) async {} +} + /// Production `LoggingReminderScheduling` backed by `UNUserNotificationCenter`. /// Only touches notification requests it owns (matched by identifier prefix) so /// it never disturbs notifications from elsewhere in the app. diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index ac53f34..aafbe66 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -34,9 +34,22 @@ public final class WhereModel { /// so the UI can offer to open Settings. public var permissionDenied = false + /// Whether the daily "log before the day ends" reminder is enabled. Persists + /// across launches; defaults to on so the safety net is active out of the box. + public private(set) var remindersEnabled: Bool + + /// Time of day the daily reminder fires. + public private(set) var reminderTime: ReminderTime + + /// Whether the system has granted notification permission. Lets the Settings + /// UI route the user to the system Settings app when they've enabled + /// reminders but denied permission. + public private(set) var notificationsAuthorized = false + private var controller: WhereController? private var authorizationTask: Task? private let defaults: UserDefaults + private let now: @Sendable () -> Date /// Persisted user intent to track in the background. Effective tracking is /// this AND `.always` authorization; we default to `true` so that, once the @@ -47,6 +60,9 @@ public final class WhereModel { } private static let wantsTrackingKey = "where.wantsBackgroundTracking" + private static let remindersEnabledKey = "where.remindersEnabled" + private static let reminderHourKey = "where.reminderHour" + private static let reminderMinuteKey = "where.reminderMinute" /// Primary/secondary split of the current report, or an empty ranking /// while nothing is loaded. @@ -60,6 +76,38 @@ public final class WhereModel { report?.days.count ?? 0 } + /// Unlogged days this year (Jan 1 through today), collapsed into ranges, for + /// the warning banner and the backfill flow. Empty unless the user is + /// viewing the current year, since past years can't gain "today" coverage by + /// opening the app. + public var missingDays: [MissingDayRange] { + guard let report, isViewingCurrentYear else { return [] } + let present = Set(report.days.map(\.date)) + return MissingDays.missingRanges( + year: report.year, + through: now(), + present: present, + calendar: Self.calendar, + ) + } + + /// Total number of unlogged days behind `missingDays`. + public var missingDayCount: Int { + missingDays.reduce(0) { $0 + $1.dayCount } + } + + private var isViewingCurrentYear: Bool { + selectedYear == Self.calendar.component(.year, from: now()) + } + + /// Gregorian calendar in the current time zone — matches the day keys the + /// aggregator produces in `report.days`, so the missing-day math lines up. + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + /// Number of calendar days in the selected year (365, or 366 in a leap /// year). Region cards scale their ambient progress bar against this rather /// than a hardcoded 365. @@ -83,9 +131,13 @@ public final class WhereModel { public init( selectedYear: Int = WhereModel.currentYear, defaults: UserDefaults = .standard, + now: @escaping @Sendable () -> Date = { Date() }, ) { self.selectedYear = selectedYear self.defaults = defaults + self.now = now + remindersEnabled = Self.loadRemindersEnabled(from: defaults) + reminderTime = Self.loadReminderTime(from: defaults) } /// Preview/test seam: inject an already-built controller (and optionally a @@ -96,14 +148,30 @@ public final class WhereModel { report: YearReport? = nil, selectedYear: Int = WhereModel.currentYear, defaults: UserDefaults = .standard, + now: @escaping @Sendable () -> Date = { Date() }, ) { self.controller = controller self.report = report self.selectedYear = selectedYear self.defaults = defaults + self.now = now + remindersEnabled = Self.loadRemindersEnabled(from: defaults) + reminderTime = Self.loadReminderTime(from: defaults) loadState = report == nil ? .idle : .loaded } + private static func loadRemindersEnabled(from defaults: UserDefaults) -> Bool { + defaults.object(forKey: remindersEnabledKey) as? Bool ?? true + } + + private static func loadReminderTime(from defaults: UserDefaults) -> ReminderTime { + let hour = defaults.object(forKey: reminderHourKey) as? Int ?? ReminderTime.defaultEvening + .hour + let minute = defaults.object(forKey: reminderMinuteKey) as? Int + ?? ReminderTime.defaultEvening.minute + return ReminderTime(hour: hour, minute: minute) + } + /// Synchronously build the production controller (SwiftData + /// CoreLocation) if it doesn't exist yet. Idempotent. /// @@ -136,6 +204,7 @@ public final class WhereModel { observeAuthorizationChanges() await reconcileTracking() await refresh() + await applyReminderConfiguration() } /// Read the current authorization status from the controller into our @@ -264,6 +333,31 @@ public final class WhereModel { isTracking = false } + /// Turn the daily reminder on or off. Persists the intent, then pushes the + /// new configuration to the controller (which prompts for notification + /// permission when enabling and reconciles the badge / scheduled reminders). + public func setRemindersEnabled(_ enabled: Bool) async { + remindersEnabled = enabled + defaults.set(enabled, forKey: Self.remindersEnabledKey) + await applyReminderConfiguration() + } + + /// Change the time of day the reminder fires. Persists and reconciles. + public func setReminderTime(_ time: ReminderTime) async { + reminderTime = time + defaults.set(time.hour, forKey: Self.reminderHourKey) + defaults.set(time.minute, forKey: Self.reminderMinuteKey) + await applyReminderConfiguration() + } + + /// Push the current reminder intent to the controller and refresh whether + /// the system has granted notification permission. + private func applyReminderConfiguration() async { + guard let controller else { return } + await controller.configureReminders(enabled: remindersEnabled, time: reminderTime) + notificationsAuthorized = await controller.notificationAuthorizationGranted() + } + public func clearSelectedYear() async { guard let controller else { return } do { diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index a1bab68..afe246a 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -49,6 +49,7 @@ let controller = WhereController( store: try! SwiftDataStore.inMemory(), locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), ) return WhereModel( controller: controller, @@ -64,6 +65,7 @@ let controller = WhereController( store: try! SwiftDataStore.inMemory(), locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), ) return WhereModel( controller: controller, @@ -89,6 +91,7 @@ let controller = WhereController( store: try! SwiftDataStore.inMemory(), locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), ) return WhereModel( controller: controller, diff --git a/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift new file mode 100644 index 0000000..8f4fed6 --- /dev/null +++ b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +/// Covers the missing-day computation the banner / backfill read, and the +/// persistence of the reminder settings. +@MainActor +struct WhereModelMissingDaysTests { + /// Build dates in the same calendar `WhereModel` uses (gregorian, current + /// time zone), so the day keys line up regardless of the host machine. + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + private static func day(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + private func ephemeralDefaults() -> UserDefaults { + let suite = "test.WhereModelMissingDays.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private func makeController() throws -> WhereController { + try WhereController( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + ) + } + + @Test func missingDaysForCurrentYearSurfaceTheGapsThroughToday() throws { + let today = Self.day(2026, 1, 5) + let present = [Self.day(2026, 1, 2), Self.day(2026, 1, 4)] + let report = YearReport( + year: 2026, + days: present.map { DayPresence(date: $0, regions: [.california]) }, + totals: [.california: present.count], + ) + let model = try WhereModel( + controller: makeController(), + report: report, + selectedYear: 2026, + now: { today }, + ) + + // Jan 1, Jan 3, and Jan 5 (today, unlogged) are each isolated gaps. + #expect(model.missingDays.map(\.start) == [ + Self.day(2026, 1, 1), + Self.day(2026, 1, 3), + Self.day(2026, 1, 5), + ]) + #expect(model.missingDayCount == 3) + } + + @Test func missingDaysAreEmptyWhenViewingAPastYear() throws { + let today = Self.day(2026, 6, 1) + let model = try WhereModel( + controller: makeController(), + report: YearReport(year: 2025, days: [], totals: [:]), + selectedYear: 2025, + now: { today }, + ) + + #expect(model.missingDays.isEmpty) + #expect(model.missingDayCount == 0) + } + + @Test func reminderSettingsDefaultOnAndPersistAcrossModels() async throws { + let defaults = ephemeralDefaults() + let model = try WhereModel(controller: makeController(), defaults: defaults) + + #expect(model.remindersEnabled) + #expect(model.reminderTime == ReminderTime.defaultEvening) + + await model.setRemindersEnabled(false) + await model.setReminderTime(ReminderTime(hour: 7, minute: 30)) + #expect(!model.remindersEnabled) + #expect(model.reminderTime == ReminderTime(hour: 7, minute: 30)) + + // A fresh model sharing the same defaults reads back the saved values. + let reloaded = try WhereModel(controller: makeController(), defaults: defaults) + #expect(!reloaded.remindersEnabled) + #expect(reloaded.reminderTime == ReminderTime(hour: 7, minute: 30)) + } +} diff --git a/Where/WhereUI/Tests/WhereModelRefreshTests.swift b/Where/WhereUI/Tests/WhereModelRefreshTests.swift index e73a019..9f73d25 100644 --- a/Where/WhereUI/Tests/WhereModelRefreshTests.swift +++ b/Where/WhereUI/Tests/WhereModelRefreshTests.swift @@ -17,7 +17,11 @@ struct WhereModelRefreshTests { @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { let store = try TestStore() - let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let controller = WhereController( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + ) // Seed each year with a distinct region so we can tell which report won. try await controller.addManualDay( @@ -54,7 +58,11 @@ struct WhereModelRefreshTests { @Test func failedManualSaveThrowsAndLeavesLoadStateAlone() async throws { let store = try TestStore() await store.failManualDays() - let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let controller = WhereController( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + ) let model = WhereModel(controller: controller, selectedYear: 2026) await #expect(throws: ManualSaveFailure.self) { @@ -72,7 +80,11 @@ struct WhereModelRefreshTests { @Test func failedManualRangeSaveThrows() async throws { let store = try TestStore() await store.failManualDays() - let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let controller = WhereController( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + ) let model = WhereModel(controller: controller, selectedYear: 2026) await #expect(throws: ManualSaveFailure.self) { diff --git a/Where/WhereUI/Tests/WhereModelTrackingTests.swift b/Where/WhereUI/Tests/WhereModelTrackingTests.swift index 1c84691..5717427 100644 --- a/Where/WhereUI/Tests/WhereModelTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereModelTrackingTests.swift @@ -23,6 +23,7 @@ struct WhereModelTrackingTests { let controller = try WhereController( store: SwiftDataStore.inMemory(), locationSource: source, + reminderScheduler: NoopLoggingReminderScheduler(), ) let model = WhereModel(controller: controller, defaults: defaults) return (model, source) From caaf105d1485f16f65484d052d46dc068fac3c14 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:29:13 -0700 Subject: [PATCH 05/13] Add MissingDaysView backfill flow Closes the "MissingDaysView + prefill" plan step: a sheet listing the year's unlogged day ranges, each opening a ManualDayEntryView prefilled with that range (new ManualDayEntryView(prefill:) init that seeds the single-day vs range mode and dates). Adds the missing-days strings (plus the banner strings the next step consumes) and a missingDaysModel preview fixture. Co-authored-by: Cursor --- .../Sources/Preview/PreviewSupport.swift | 31 +++++++ .../Sources/Primary/MissingDaysView.swift | 91 +++++++++++++++++++ .../Sources/Settings/ManualDayEntryView.swift | 9 ++ Where/WhereUI/Sources/Shared/Strings.swift | 68 ++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 Where/WhereUI/Sources/Primary/MissingDaysView.swift diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index afe246a..4e94a97 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -99,5 +99,36 @@ selectedYear: year, ) } + + /// A model whose current year has several unlogged stretches before a + /// fixed "today", so the missing-day banner and `MissingDaysView` have + /// real gaps to render. + @MainActor + public static func missingDaysModel() -> WhereModel { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + let today = calendar.date(from: DateComponents(year: year, month: 2, day: 10))! + + // A handful of scattered logged days, leaving gaps in between. + let loggedOffsets = [0, 1, 2, 8, 9, 20] + let days = loggedOffsets.map { offset in + DayPresence( + date: calendar.date(byAdding: .day, value: offset, to: startOfYear)!, + regions: [.california], + ) + } + let controller = WhereController( + store: try! SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + ) + return WhereModel( + controller: controller, + report: YearReport(year: year, days: days, totals: [.california: days.count]), + selectedYear: year, + now: { today }, + ) + } } #endif diff --git a/Where/WhereUI/Sources/Primary/MissingDaysView.swift b/Where/WhereUI/Sources/Primary/MissingDaysView.swift new file mode 100644 index 0000000..00c98d6 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/MissingDaysView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import WhereCore + +/// Lists the calendar days this year with no recorded presence as tappable +/// ranges. Selecting one opens a prefilled `ManualDayEntryView` so the user can +/// backfill where they were. Presented as a sheet from the Primary tab's +/// warning banner. +struct MissingDaysView: View { + @Environment(WhereModel.self) private var model + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + content + .navigationTitle(Strings.missingDaysTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.missingDaysDone) { dismiss() } + } + } + } + } + + @ViewBuilder + private var content: some View { + if model.missingDays.isEmpty { + ContentUnavailableView { + Label(Strings.missingDaysEmptyTitle, systemImage: "checkmark.circle") + } description: { + Text(Strings.missingDaysEmptyDescription) + } + } else { + List { + Section { + ForEach(model.missingDays) { range in + NavigationLink { + ManualDayEntryView(prefill: range) + } label: { + MissingDayRow(range: range) + } + } + } header: { + Text(Strings.missingDaysHeader) + } footer: { + Text(Strings.missingDaysFooter) + } + } + .accessibilityIdentifier("where_missing_days_list") + } + } +} + +/// One row: the date span that's missing and how many days it covers. +private struct MissingDayRow: View { + let range: MissingDayRange + + var body: some View { + HStack(spacing: UIConstants.Spacings.large) { + Image(systemName: "calendar.badge.exclamationmark") + .foregroundStyle(.orange) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { + Text(dateRange) + .font(.headline) + Text(Strings.dayCount(range.dayCount)) + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .padding(.vertical, UIConstants.Spacings.xSmall) + .accessibilityElement(children: .combine) + } + + private var dateRange: String { + let format = Date.FormatStyle.dateTime.month(.abbreviated).day() + if Calendar.current.isDate(range.start, inSameDayAs: range.end) { + return range.start.formatted(format) + } + return "\(range.start.formatted(format)) – \(range.end.formatted(format))" + } +} + +#if DEBUG + #Preview { + MissingDaysView() + .environment(PreviewSupport.missingDaysModel()) + } +#endif diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 813c3c7..48fa001 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -32,6 +32,15 @@ struct ManualDayEntryView: View { @State private var isSaving = false @State private var saveError: String? + /// Open with the dates (and single-day vs range mode) preselected — used by + /// the backfill flow so tapping a missing range lands on a populated form. + init(prefill: MissingDayRange? = nil) { + guard let prefill else { return } + _mode = State(initialValue: prefill.dayCount > 1 ? .range : .singleDay) + _startDate = State(initialValue: prefill.start) + _endDate = State(initialValue: prefill.end) + } + private var dayCount: Int { let calendar = Calendar.current let start = calendar.startOfDay(for: startDate) diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 8edb1c5..eede1aa 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -338,6 +338,74 @@ enum Strings { ) } + // MARK: Missing days + + static var missingDaysTitle: String { + String(localized: "missingDays.title", defaultValue: "Missing days", bundle: .module) + } + + static var missingDaysDone: String { + String(localized: "missingDays.done", defaultValue: "Done", bundle: .module) + } + + static var missingDaysHeader: String { + String(localized: "missingDays.header", defaultValue: "Days to backfill", bundle: .module) + } + + static var missingDaysFooter: String { + String( + localized: "missingDays.footer", + defaultValue: "Tap a stretch to record where you were. Today is included until something logs it.", + bundle: .module, + ) + } + + static var missingDaysEmptyTitle: String { + String(localized: "missingDays.empty.title", defaultValue: "All caught up", bundle: .module) + } + + static var missingDaysEmptyDescription: String { + String( + localized: "missingDays.empty.description", + defaultValue: "Every day this year has something logged.", + bundle: .module, + ) + } + + // MARK: Missing-day banner + + static var missingBannerTitle: String { + String( + localized: "missing.banner.title", + defaultValue: "Missing days this year", + bundle: .module, + ) + } + + static func missingBannerSubtitle(count: Int) -> String { + if count == 1 { + String( + localized: "missing.banner.subtitle.one", + defaultValue: "1 day still needs a location. Tap to fill it in.", + bundle: .module, + ) + } else { + String( + localized: "missing.banner.subtitle.other", + defaultValue: "\(count) days still need a location. Tap to fill them in.", + bundle: .module, + ) + } + } + + static var missingBannerAccessibilityHint: String { + String( + localized: "missing.banner.accessibilityHint", + defaultValue: "Opens the list of days that still need logging.", + bundle: .module, + ) + } + // MARK: Helpers private static func localized(_ key: String.LocalizationValue) -> String { From 733a3ef869d93c86992a570b1c26b31533f5133e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:32:32 -0700 Subject: [PATCH 06/13] Add missing-days warning banner to Primary tab Closes the "warning banner" plan step: when the current year has unlogged days, the Primary tab shows a tappable Liquid Glass banner above the content that opens MissingDaysView to backfill them. Hidden when there's nothing missing. Co-authored-by: Cursor --- .../WhereUI/Sources/Primary/PrimaryView.swift | 102 ++++++++++++++---- 1 file changed, 84 insertions(+), 18 deletions(-) diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index ae578f3..1569d12 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -7,31 +7,45 @@ struct PrimaryView: View { @Environment(WhereModel.self) private var model @State private var showingTimeline = false + @State private var showingMissingDays = false var body: some View { NavigationStack { - screen - .navigationTitle(Strings.primaryTitle) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showingTimeline = true - } label: { - Label( - Strings.primaryTimeline, - systemImage: "calendar.day.timeline.left", - ) - } - .accessibilityIdentifier("where_timeline_button") + VStack(spacing: 0) { + if model.missingDayCount > 0 { + MissingDaysBanner(count: model.missingDayCount) { + showingMissingDays = true } - ToolbarItem(placement: .topBarTrailing) { - YearSelector() + .padding(.horizontal) + .padding(.bottom, UIConstants.Spacings.medium) + } + screen + } + .navigationTitle(Strings.primaryTitle) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingTimeline = true + } label: { + Label( + Strings.primaryTimeline, + systemImage: "calendar.day.timeline.left", + ) } + .accessibilityIdentifier("where_timeline_button") } - .sheet(isPresented: $showingTimeline) { - PresenceTimelineView() - .environment(model) + ToolbarItem(placement: .topBarTrailing) { + YearSelector() } + } + .sheet(isPresented: $showingTimeline) { + PresenceTimelineView() + .environment(model) + } + .sheet(isPresented: $showingMissingDays) { + MissingDaysView() + .environment(model) + } } } @@ -124,6 +138,53 @@ struct PrimaryView: View { } } +/// A tappable Liquid Glass warning that some days this year aren't logged yet, +/// shown atop the Primary tab. Opens `MissingDaysView` to backfill them. +private struct MissingDaysBanner: View { + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: UIConstants.Spacings.large) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title3) + .foregroundStyle(.orange) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { + Text(Strings.missingBannerTitle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(Strings.missingBannerSubtitle(count: count)) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: UIConstants.Spacings.small) + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .padding(UIConstants.Padding.compactCard) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect( + .regular.tint(.orange.opacity(0.18)), + in: RoundedRectangle( + cornerRadius: UIConstants.CornerRadius.compactCard, + style: .continuous, + ), + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("where_missing_days_banner") + .accessibilityHint(Strings.missingBannerAccessibilityHint) + } +} + #if DEBUG #Preview("Loaded") { PrimaryView() @@ -134,4 +195,9 @@ struct PrimaryView: View { PrimaryView() .environment(PreviewSupport.emptyModel()) } + + #Preview("Missing days") { + PrimaryView() + .environment(PreviewSupport.missingDaysModel()) + } #endif From 42a558ee1d1a012df7f5d919a0914c0eaccb155d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:34:25 -0700 Subject: [PATCH 07/13] Add Reminders section to Settings Closes the "Reminders settings" plan step: a toggle for the daily logging reminder plus an hour/minute time picker, both routed through WhereModel so changes reconcile the scheduled reminders and badge. When reminders are on but notifications are denied, the section surfaces an "Allow notifications" affordance that deep-links to system Settings. Co-authored-by: Cursor --- .../Sources/Settings/SettingsView.swift | 66 +++++++++++++++++++ Where/WhereUI/Sources/Shared/Strings.swift | 40 +++++++++++ 2 files changed, 106 insertions(+) diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index e6b382b..b1efb9b 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -16,6 +16,7 @@ struct SettingsView: View { NavigationStack { Form { trackingSection + remindersSection manualEntrySection dataSection } @@ -76,6 +77,71 @@ struct SettingsView: View { } } + private var remindersSection: some View { + Section { + Toggle(isOn: remindersBinding) { + Label(Strings.settingsRemindersToggle, systemImage: "bell.badge") + } + + if model.remindersEnabled { + DatePicker( + Strings.settingsReminderTime, + selection: reminderTimeBinding, + displayedComponents: .hourAndMinute, + ) + + if !model.notificationsAuthorized { + Button { + openSystemSettings() + } label: { + Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + } + } + } + } header: { + Text(Strings.settingsRemindersHeader) + } footer: { + Text(remindersFooter) + } + } + + private var remindersFooter: String { + if model.remindersEnabled, !model.notificationsAuthorized { + return Strings.settingsRemindersDeniedFooter + } + return Strings.settingsRemindersFooter + } + + private var remindersBinding: Binding { + Binding( + get: { model.remindersEnabled }, + set: { isOn in + Task { await model.setRemindersEnabled(isOn) } + }, + ) + } + + private var reminderTimeBinding: Binding { + Binding( + get: { + Calendar.current.date( + bySettingHour: model.reminderTime.hour, + minute: model.reminderTime.minute, + second: 0, + of: Date(), + ) ?? Date() + }, + set: { newValue in + let components = Calendar.current.dateComponents([.hour, .minute], from: newValue) + let time = ReminderTime( + hour: components.hour ?? ReminderTime.defaultEvening.hour, + minute: components.minute ?? ReminderTime.defaultEvening.minute, + ) + Task { await model.setReminderTime(time) } + }, + ) + } + private var manualEntrySection: some View { Section { NavigationLink { diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index eede1aa..fd606e9 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -218,6 +218,46 @@ enum Strings { localized("settings.manual.footer") } + static var settingsRemindersHeader: String { + String(localized: "settings.reminders.header", defaultValue: "Reminders", bundle: .module) + } + + static var settingsRemindersToggle: String { + String( + localized: "settings.reminders.toggle", + defaultValue: "Daily logging reminder", + bundle: .module, + ) + } + + static var settingsReminderTime: String { + String(localized: "settings.reminders.time", defaultValue: "Remind me at", bundle: .module) + } + + static var settingsRemindersFooter: String { + String( + localized: "settings.reminders.footer", + defaultValue: "If a day hasn't been logged, we'll nudge you before it ends and badge the app. The reminder clears itself once the day is recorded.", + bundle: .module, + ) + } + + static var settingsRemindersOpenSettings: String { + String( + localized: "settings.reminders.openSettings", + defaultValue: "Allow notifications", + bundle: .module, + ) + } + + static var settingsRemindersDeniedFooter: String { + String( + localized: "settings.reminders.deniedFooter", + defaultValue: "Notifications are turned off for Where, so reminders and the badge can't appear. Turn them on in Settings.", + bundle: .module, + ) + } + static var settingsDataHeader: String { localized("settings.data.header") } From f505a28132a1717c5aefcabff9cfa856aa89f633 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 15:38:56 -0700 Subject: [PATCH 08/13] Foreground reminders, string catalog, banner tests Closes the "strings + AppDelegate delegate + UI tests" plan step: - AppDelegate becomes the UNUserNotificationCenterDelegate so logging reminders present (banner/sound/badge) even while Where is open. - Adds the missing-days, banner, and reminder-settings keys to the WhereUI string catalog so the new copy is translatable. - Adds hosting tests covering the Primary banner branch and MissingDaysView with real ranges. Co-authored-by: Cursor --- Where/Where/Sources/AppDelegate.swift | 14 +- .../Sources/Resources/Localizable.xcstrings | 176 ++++++++++++++++++ Where/WhereUI/Tests/ScreenHostingTests.swift | 17 ++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 6b02ebd..b231428 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -1,5 +1,6 @@ import os import UIKit +import UserNotifications import WhereUI /// Owns the app's single `WhereModel` and wires location up at process launch @@ -12,7 +13,7 @@ import WhereUI /// CoreLocation deliver the pending event, and re-starting GPS continues /// background tracking when the user intends it and Always is granted. @MainActor -final class AppDelegate: NSObject, UIApplicationDelegate { +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { let model = WhereModel() private let logger = Logger(subsystem: "com.stuff.where", category: "AppDelegate") @@ -25,6 +26,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { logger.info("Relaunched by CoreLocation for a background location event") } + // Present logging reminders even while the app is foregrounded so the + // nudge isn't silently swallowed when the user already has Where open. + UNUserNotificationCenter.current().delegate = self + // Build the controller + CLLocationManager synchronously so a location // event delivered during launch isn't lost, then (re)register // monitoring and reconcile tracking off the main thread. @@ -32,4 +37,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate { Task { await model.start() } return true } + + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + ) async -> UNNotificationPresentationOptions { + [.banner, .sound, .badge] + } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index dd162f4..dafe8d6 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -234,6 +234,116 @@ } } }, + "missing.banner.accessibilityHint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the list of days that still need logging." + } + } + } + }, + "missing.banner.subtitle.one" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 day still needs a location. Tap to fill it in." + } + } + } + }, + "missing.banner.subtitle.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days still need a location. Tap to fill them in." + } + } + } + }, + "missing.banner.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing days this year" + } + } + } + }, + "missingDays.done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "missingDays.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every day this year has something logged." + } + } + } + }, + "missingDays.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All caught up" + } + } + } + }, + "missingDays.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a stretch to record where you were. Today is included until something logs it." + } + } + } + }, + "missingDays.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Days to backfill" + } + } + } + }, + "missingDays.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing days" + } + } + } + }, "primary.caption.homeBase" : { "extractionState" : "manual", "localizations" : { @@ -621,6 +731,72 @@ } } }, + "settings.reminders.deniedFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications are turned off for Where, so reminders and the badge can't appear. Turn them on in Settings." + } + } + } + }, + "settings.reminders.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If a day hasn't been logged, we'll nudge you before it ends and badge the app. The reminder clears itself once the day is recorded." + } + } + } + }, + "settings.reminders.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reminders" + } + } + } + }, + "settings.reminders.openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow notifications" + } + } + } + }, + "settings.reminders.time" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remind me at" + } + } + } + }, + "settings.reminders.toggle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily logging reminder" + } + } + } + }, "settings.status.alwaysPaused" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 0070c25..63298aa 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -35,6 +35,23 @@ struct ScreenHostingTests { } } + @Test func primaryViewHostsTheMissingDaysBanner() throws { + let model = PreviewSupport.missingDaysModel() + // The fixture must have gaps, otherwise the banner branch never renders. + #expect(model.missingDayCount > 0) + try show(UIHostingController(rootView: PrimaryView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func missingDaysViewHostsWithRanges() throws { + let model = PreviewSupport.missingDaysModel() + #expect(!model.missingDays.isEmpty) + try show(UIHostingController(rootView: MissingDaysView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } + @Test func presenceTimelineViewHostsWithData() throws { let model = PreviewSupport.loadedModel() try show(UIHostingController(rootView: PresenceTimelineView() From 2d58239f4b504f9e497c333d4b092ebec8175954 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 22:56:15 +0000 Subject: [PATCH 09/13] Fix reminder reconciliation edge cases Co-authored-by: Kyle Van Essen --- .../Reminders/LoggingReminderScheduler.swift | 122 +++++++++++++--- Where/WhereCore/Sources/WhereController.swift | 22 +-- .../Tests/LoggingReminderSchedulerTests.swift | 135 ++++++++++++++++++ .../Tests/WhereControllerTests.swift | 61 ++++++++ Where/WhereUI/Sources/Model/WhereModel.swift | 11 ++ Where/WhereUI/Sources/RootView.swift | 5 + 6 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 044e3b9..7cbf563 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -82,7 +82,7 @@ public struct NoopLoggingReminderScheduler: LoggingReminderScheduling { public final class UserNotificationReminderScheduler: LoggingReminderScheduling, @unchecked Sendable { - private let center: UNUserNotificationCenter + private let center: any NotificationReminderCenter private let calendar: Calendar private static let identifierPrefix = "com.stuff.where.logging-reminder" @@ -99,7 +99,15 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, return cal }(), ) { - self.center = center + self.center = UNUserNotificationCenterAdapter(center: center) + self.calendar = calendar + } + + init( + notificationCenter: any NotificationReminderCenter, + calendar: Calendar, + ) { + center = notificationCenter self.calendar = calendar } @@ -115,8 +123,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, } public func isAuthorized() async -> Bool { - let settings = await center.notificationSettings() - switch settings.authorizationStatus { + switch await center.authorizationStatus() { case .authorized, .provisional, .ephemeral: return true default: @@ -136,13 +143,12 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, return } - // Without authorization we can neither post alerts nor set the badge, - // so there's nothing useful to reconcile. - let settings = await center.notificationSettings() - switch settings.authorizationStatus { + switch await center.authorizationStatus() { case .authorized, .provisional, .ephemeral: break default: + await removeAllOwnedReminders() + await setBadge(0) return } @@ -154,22 +160,30 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, // Cancel reminders we own that are no longer wanted (the day got logged // or fell out of the window). let pending = await center.pendingNotificationRequests() - let pendingIDs = Set(pending.map(\.identifier)) + let pendingByID = Dictionary( + pending.map { ($0.identifier, $0) }, + uniquingKeysWith: { first, _ in first }, + ) + let pendingIDs = Set(pendingByID.keys) let stalePending = pendingIDs.filter { isOwned($0) && desiredIDs[$0] == nil } - if !stalePending.isEmpty { - center.removePendingNotificationRequests(withIdentifiers: Array(stalePending)) + let pendingWithWrongTime = desiredIDs.keys.filter { id in + guard let request = pendingByID[id] else { return false } + return !matchesReminderTime(request, reminderTime) + } + let pendingToRemove = Set(stalePending).union(pendingWithWrongTime) + if !pendingToRemove.isEmpty { + await center.removePendingNotificationRequests(withIdentifiers: Array(pendingToRemove)) } - let delivered = await center.deliveredNotifications() - let staleDelivered = delivered - .map(\.request.identifier) - .filter { isOwned($0) && desiredIDs[$0] == nil } + let deliveredIDs = await center.deliveredNotificationIdentifiers() + let staleDelivered = deliveredIDs.filter { isOwned($0) && desiredIDs[$0] == nil } if !staleDelivered.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: staleDelivered) + await center.removeDeliveredNotifications(withIdentifiers: staleDelivered) } - // Schedule the wanted reminders that aren't already pending. - for (id, day) in desiredIDs where !pendingIDs.contains(id) { + // Schedule new reminders, and replace existing requests whose trigger + // time no longer matches the user's setting. + for (id, day) in desiredIDs where !pendingIDs.contains(id) || pendingToRemove.contains(id) { await scheduleReminder(identifier: id, day: day, time: reminderTime) } @@ -201,16 +215,24 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, } } + private func matchesReminderTime(_ 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 removeAllOwnedReminders() async { let pending = await center.pendingNotificationRequests() let pendingIDs = pending.map(\.identifier).filter(isOwned) if !pendingIDs.isEmpty { - center.removePendingNotificationRequests(withIdentifiers: pendingIDs) + await center.removePendingNotificationRequests(withIdentifiers: pendingIDs) } - let delivered = await center.deliveredNotifications() - let deliveredIDs = delivered.map(\.request.identifier).filter(isOwned) - if !deliveredIDs.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: deliveredIDs) + let deliveredIDs = await center.deliveredNotificationIdentifiers() + let ownedDeliveredIDs = deliveredIDs.filter(isOwned) + if !ownedDeliveredIDs.isEmpty { + await center.removeDeliveredNotifications(withIdentifiers: ownedDeliveredIDs) } } @@ -236,3 +258,57 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, identifier.hasPrefix(Self.identifierPrefix) } } + +protocol NotificationReminderCenter: Sendable { + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func authorizationStatus() async -> UNAuthorizationStatus + func pendingNotificationRequests() async -> [UNNotificationRequest] + func deliveredNotificationIdentifiers() async -> [String] + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async + func setBadgeCount(_ count: Int) async throws +} + +private final class UNUserNotificationCenterAdapter: NotificationReminderCenter, + @unchecked Sendable +{ + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + try await center.requestAuthorization(options: options) + } + + func authorizationStatus() async -> UNAuthorizationStatus { + let settings = await center.notificationSettings() + return settings.authorizationStatus + } + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + await center.pendingNotificationRequests() + } + + func deliveredNotificationIdentifiers() async -> [String] { + await center.deliveredNotifications().map(\.request.identifier) + } + + func add(_ request: UNNotificationRequest) async throws { + try await center.add(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { + center.removePendingNotificationRequests(withIdentifiers: identifiers) + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + + func setBadgeCount(_ count: Int) async throws { + try await center.setBadgeCount(count) + } +} diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index b482a7d..3a56019 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -157,6 +157,7 @@ public actor WhereController { public func clearYear(_ year: Int) async throws { let interval = aggregator.yearInterval(year: year) try await store.perform { try await store.clear(in: interval) } + await reconcileReminders() } // MARK: - GPS lifecycle @@ -177,7 +178,7 @@ public actor WhereController { await locationSource.start() // Flush anything that failed to persist before this session // started, before we (re)attach the stream consumer. - await drainRetryQueue() + _ = await drainRetryQueue() // Refresh the badge / reminders against whatever the resumed session // already knows about (e.g. after a background relaunch). await reconcileReminders() @@ -196,10 +197,11 @@ public actor WhereController { /// on failure. Drains any backlog first so a single transient /// outage doesn't permanently reorder samples on disk. private func processIngestedSample(_ sample: LocationSample) async { - await drainRetryQueue() + var changedDays = await drainRetryQueue() do { try await store.perform { try await store.add(sample: sample) } - await reconcileRemindersAfterIngest() + changedDays.insert(aggregator.calendar.startOfDay(for: sample.timestamp)) + await reconcileRemindersAfterIngest(changedDays: changedDays) } catch { // Persistence failures (SwiftData save, CloudKit, etc.) // are surfaced via `os.Logger` instead of being silently @@ -223,13 +225,15 @@ public actor WhereController { /// Try to flush every queued sample exactly once. Anything that /// still fails is re-queued at the tail; the next call gets the /// chance to retry it. - private func drainRetryQueue() async { - guard !retryQueue.isEmpty else { return } + private func drainRetryQueue() async -> Set { + guard !retryQueue.isEmpty else { return [] } let pending = retryQueue retryQueue.removeAll(keepingCapacity: true) + var persistedDays: Set = [] for sample in pending { do { try await store.perform { try await store.add(sample: sample) } + persistedDays.insert(aggregator.calendar.startOfDay(for: sample.timestamp)) } catch { Self.logger.error( "Retry still failing for GPS sample \(sample.id, privacy: .public): \(error.localizedDescription, privacy: .public)", @@ -237,6 +241,7 @@ public actor WhereController { enqueueForRetry(sample) } } + return persistedDays } /// Number of samples currently waiting to be re-persisted. Exposed @@ -308,10 +313,11 @@ public actor WhereController { /// 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. - private func reconcileRemindersAfterIngest() async { + private func reconcileRemindersAfterIngest(changedDays: Set) async { guard reminderConfig.enabled else { return } let today = aggregator.calendar.startOfDay(for: now()) - guard todayCoveredByReconcile != today else { return } + let changedDayNeedsReconcile = changedDays.contains { $0 != today } + guard todayCoveredByReconcile != today || changedDayNeedsReconcile else { return } await reconcileReminders() } @@ -337,7 +343,7 @@ public actor WhereController { let year = calendar.component(.year, from: today) do { let report = try await yearReport(for: year) - let present = Set(report.days.map(\.date)) + let present = Set(report.days.map { calendar.startOfDay(for: $0.date) }) let missing = MissingDays.missingDayKeys( year: year, through: today, diff --git a/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift new file mode 100644 index 0000000..56c20e7 --- /dev/null +++ b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +import UserNotifications +@testable import WhereCore + +struct LoggingReminderSchedulerTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + } + + @Test func changingReminderTimeReplacesExistingPendingRequest() async throws { + let center = FakeNotificationReminderCenter() + let scheduler = UserNotificationReminderScheduler( + notificationCenter: center, + calendar: Self.calendar, + ) + let day = Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))! + + await scheduler.reconcile( + badgeCount: 1, + scheduleDays: [day], + reminderTime: .defaultEvening, + enabled: true, + ) + let firstRequest = try #require(center.pendingRequests.first) + #expect(firstRequest.reminderHour == 20) + #expect(firstRequest.reminderMinute == 0) + + await scheduler.reconcile( + badgeCount: 1, + scheduleDays: [day], + reminderTime: ReminderTime(hour: 7, minute: 30), + enabled: true, + ) + + let updatedRequest = try #require(center.pendingRequests.first) + #expect(updatedRequest.identifier == firstRequest.identifier) + #expect(updatedRequest.reminderHour == 7) + #expect(updatedRequest.reminderMinute == 30) + #expect(center.addedRequests.count == 2) + #expect(center.removedPendingIdentifiers.contains(firstRequest.identifier)) + } + + @Test func revokedAuthorizationClearsOwnedRequestsAndBadge() async throws { + let center = FakeNotificationReminderCenter() + let scheduler = UserNotificationReminderScheduler( + notificationCenter: center, + calendar: Self.calendar, + ) + let day = Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))! + + await scheduler.reconcile( + badgeCount: 1, + scheduleDays: [day], + reminderTime: .defaultEvening, + enabled: true, + ) + #expect(center.pendingRequests.count == 1) + #expect(center.badgeCounts.last == 1) + + center.status = .denied + await scheduler.reconcile( + badgeCount: 1, + scheduleDays: [day], + reminderTime: .defaultEvening, + enabled: true, + ) + + #expect(center.pendingRequests.isEmpty) + #expect(center.badgeCounts.last == 0) + } +} + +private extension UNNotificationRequest { + var reminderHour: Int? { + (trigger as? UNCalendarNotificationTrigger)?.dateComponents.hour + } + + var reminderMinute: Int? { + (trigger as? UNCalendarNotificationTrigger)?.dateComponents.minute + } +} + +private final class FakeNotificationReminderCenter: NotificationReminderCenter, + @unchecked Sendable +{ + var status: UNAuthorizationStatus = .authorized + private(set) var pendingRequests: [UNNotificationRequest] = [] + private(set) var addedRequests: [UNNotificationRequest] = [] + private(set) var removedPendingIdentifiers: [String] = [] + private(set) var badgeCounts: [Int] = [] + private var deliveredIdentifiers: [String] = [] + + func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + return true + default: + return 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(_ count: Int) async throws { + badgeCounts.append(count) + } +} diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index be579c0..e664f93 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -350,6 +350,36 @@ struct WhereControllerTests { await controller.stopGPS() } + @Test func loggingPastDayViaGPSAfterTodayIsCoveredStillReconciles() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, source) = try Self.makeReminderController(now: now, scheduler: spy) + await controller.configureReminders(enabled: true, time: .defaultEvening) + + await controller.startGPS() + source.emit(LocationSample( + timestamp: iso("2026-01-05T12:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .gpsSignificantChange, + )) + try await waitUntil { await spy.lastBadgeCount == 4 } + + // Visits can arrive late with their original timestamp. Even after the + // controller knows today is covered, filling a past gap must lower the + // backlog badge. + source.emit(LocationSample( + timestamp: iso("2026-01-03T12:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .gpsVisit, + )) + + try await waitUntil { await spy.lastBadgeCount == 3 } + + await controller.stopGPS() + } + @Test func manualDayLoggingLowersTheBadge() async throws { let now = iso("2026-03-10T09:00:00-08:00") let spy = SpyReminderScheduler() @@ -367,6 +397,37 @@ struct WhereControllerTests { #expect(await spy.lastBadgeCount == 68) } + @Test func clearCurrentYearReconcilesTheBadge() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) + await controller.configureReminders(enabled: true, time: .defaultEvening) + + try await controller.addManualDay( + date: iso("2026-01-01T12:00:00-08:00"), + regions: [.california], + ) + #expect(await spy.lastBadgeCount == 4) + + try await controller.clearYear(2026) + + #expect(await spy.lastBadgeCount == 5) + } + + @Test func changingReminderTimeReconcilesWithTheNewTime() async throws { + let now = iso("2026-01-05T09:00:00-08:00") + let spy = SpyReminderScheduler() + let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) + + await controller.configureReminders(enabled: true, time: .defaultEvening) + await controller.configureReminders(enabled: true, time: ReminderTime(hour: 7, minute: 30)) + + #expect(await spy.authorizationRequests == 2) + #expect(await spy.reconcileCount == 2) + #expect(await spy.lastReminderTime == ReminderTime(hour: 7, minute: 30)) + #expect(await spy.lastBadgeCount == 5) + } + @Test func disablingRemindersAfterEnablingClearsEverything() async throws { let now = iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index aafbe66..c03d7eb 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -207,6 +207,17 @@ public final class WhereModel { await applyReminderConfiguration() } + /// Refresh state that can change while the app is away, including + /// notification permission edits made in Settings and calendar-day rollover. + public func appBecameActive() async { + bootstrap() + guard controller != nil else { return } + await syncAuthorization() + await reconcileTracking() + await refresh() + await applyReminderConfiguration() + } + /// Read the current authorization status from the controller into our /// observable state. Does not surface the permission alert — that's /// reserved for explicit user actions. diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index bd266ab..980f8f3 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -5,6 +5,7 @@ import WhereCore /// Owns the single `WhereModel`, builds the live controller on appear, and /// hands the model down through the environment. public struct RootView: View { + @Environment(\.scenePhase) private var scenePhase @State private var model: WhereModel /// Inject the app-owned model that was built at launch (so CoreLocation is @@ -36,6 +37,10 @@ public struct RootView: View { .tabBarMinimizeBehavior(.onScrollDown) .environment(model) .task { await model.start() } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + Task { await model.appBecameActive() } + } } } From 0e6f1125f7c524eba8ab76808147988ffd257e53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 22:58:11 +0000 Subject: [PATCH 10/13] Tighten reminder scheduler test seam Co-authored-by: Kyle Van Essen --- .../Sources/Reminders/LoggingReminderScheduler.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 7cbf563..87ee6a5 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -259,7 +259,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, } } -protocol NotificationReminderCenter: Sendable { +protocol NotificationReminderCenter { func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool func authorizationStatus() async -> UNAuthorizationStatus func pendingNotificationRequests() async -> [UNNotificationRequest] @@ -293,7 +293,8 @@ private final class UNUserNotificationCenterAdapter: NotificationReminderCenter, } func deliveredNotificationIdentifiers() async -> [String] { - await center.deliveredNotifications().map(\.request.identifier) + let delivered = await center.deliveredNotifications() + return delivered.map(\.request.identifier) } func add(_ request: UNNotificationRequest) async throws { From 9b58b123848c95e06e2c74def366006b301259cb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 30 May 2026 11:51:51 -0700 Subject: [PATCH 11/13] Run swiftformat --- .../Reminders/LoggingReminderScheduler.swift | 9 +++++--- .../Tests/LoggingReminderSchedulerTests.swift | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 87ee6a5..9584e55 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -125,9 +125,9 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, public func isAuthorized() async -> Bool { switch await center.authorizationStatus() { case .authorized, .provisional, .ephemeral: - return true + true default: - return false + false } } @@ -215,7 +215,10 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, } } - private func matchesReminderTime(_ request: UNNotificationRequest, _ time: ReminderTime) -> Bool { + private func matchesReminderTime( + _ request: UNNotificationRequest, + _ time: ReminderTime, + ) -> Bool { guard let trigger = request.trigger as? UNCalendarNotificationTrigger else { return false } diff --git a/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift index 56c20e7..794fdf6 100644 --- a/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift +++ b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift @@ -16,7 +16,11 @@ struct LoggingReminderSchedulerTests { notificationCenter: center, calendar: Self.calendar, ) - let day = Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))! + let day = try #require(Self.calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 5, + ))) await scheduler.reconcile( badgeCount: 1, @@ -49,7 +53,11 @@ struct LoggingReminderSchedulerTests { notificationCenter: center, calendar: Self.calendar, ) - let day = Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))! + let day = try #require(Self.calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 5, + ))) await scheduler.reconcile( badgeCount: 1, @@ -73,12 +81,12 @@ struct LoggingReminderSchedulerTests { } } -private extension UNNotificationRequest { - var reminderHour: Int? { +extension UNNotificationRequest { + fileprivate var reminderHour: Int? { (trigger as? UNCalendarNotificationTrigger)?.dateComponents.hour } - var reminderMinute: Int? { + fileprivate var reminderMinute: Int? { (trigger as? UNCalendarNotificationTrigger)?.dateComponents.minute } } @@ -96,9 +104,9 @@ private final class FakeNotificationReminderCenter: NotificationReminderCenter, func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool { switch status { case .authorized, .provisional, .ephemeral: - return true + true default: - return false + false } } From 62d00b33a778ba8a589f78abbe6dfd180242fd88 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 3 Jun 2026 14:43:06 -0400 Subject: [PATCH 12/13] Exclude today from the missed-day backlog The badge and in-app banner counted today as missed from midnight, so opening the app each morning warned about today before passive GPS or the user had any chance to log it. Add MissingDays.backlogCutoff(asOf:) (yesterday) and use it for the badge count and WhereModel.missingDays, so the backlog covers only genuinely missed past days. The per-day notification window still includes today, so the evening nudge to "log before the day ends" is unchanged. Adjust the reminder tests for the through-yesterday counts and reframe the log-today case around cancelling today's scheduled reminder rather than lowering the badge. Co-authored-by: Cursor --- Where/WhereCore/Sources/MissingDays.swift | 10 +++++ Where/WhereCore/Sources/WhereController.swift | 9 +++-- Where/WhereCore/Tests/MissingDaysTests.swift | 39 +++++++++++++++++++ .../Tests/WhereControllerTests.swift | 32 ++++++++------- Where/WhereUI/Sources/Model/WhereModel.swift | 4 +- .../Tests/WhereModelMissingDaysTests.swift | 9 +++-- 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/Where/WhereCore/Sources/MissingDays.swift b/Where/WhereCore/Sources/MissingDays.swift index 882e3bc..96e80c4 100644 --- a/Where/WhereCore/Sources/MissingDays.swift +++ b/Where/WhereCore/Sources/MissingDays.swift @@ -85,6 +85,16 @@ public enum MissingDays { return result } + /// The last day that counts toward the *backlog* of missed days as of + /// `now`: the day before today. Today is still "pending" — the user can log + /// it before the day ends — so it's surfaced by the forward-looking reminder + /// rather than counted as already missed. Pass this as `through` for the + /// badge / banner / backfill so they don't warn about today every morning. + public static func backlogCutoff(asOf now: Date, calendar: Calendar) -> Date { + let today = calendar.startOfDay(for: now) + return calendar.date(byAdding: .day, value: -1, to: today) ?? today + } + /// Convenience: the missing days for a year, already collapsed into ranges. public static func missingRanges( year: Int, diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index 3a56019..0585142 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -344,9 +344,12 @@ public actor WhereController { do { let report = try await yearReport(for: year) let present = Set(report.days.map { calendar.startOfDay(for: $0.date) }) - let missing = MissingDays.missingDayKeys( + // The badge backlog is *past* misses only — today is still loggable, + // so it's covered by the forward-looking reminder below rather than + // counted as missed (otherwise the app would warn every morning). + let backlog = MissingDays.missingDayKeys( year: year, - through: today, + through: MissingDays.backlogCutoff(asOf: now(), calendar: calendar), present: present, calendar: calendar, ) @@ -359,7 +362,7 @@ public actor WhereController { .calendarDays(through: windowEnd, in: calendar) .filter { !present.contains($0) } await reminderScheduler.reconcile( - badgeCount: missing.count, + badgeCount: backlog.count, scheduleDays: scheduleDays, reminderTime: reminderConfig.time, enabled: true, diff --git a/Where/WhereCore/Tests/MissingDaysTests.swift b/Where/WhereCore/Tests/MissingDaysTests.swift index 673a7fb..2640fab 100644 --- a/Where/WhereCore/Tests/MissingDaysTests.swift +++ b/Where/WhereCore/Tests/MissingDaysTests.swift @@ -93,6 +93,45 @@ struct MissingDaysTests { #expect(missing.count == 366) } + // MARK: backlogCutoff + + @Test func backlogCutoffIsTheStartOfTheDayBeforeToday() throws { + let now = try #require( + Self.calendar.date(from: DateComponents(year: 2026, month: 1, day: 5, hour: 9)), + ) + let cutoff = MissingDays.backlogCutoff(asOf: now, calendar: Self.calendar) + #expect(cutoff == Self.day(2026, 1, 4)) + } + + @Test func backlogExcludesTodayEvenWhenUnlogged() { + let now = Self.day(2026, 1, 5) + let missing = MissingDays.missingDayKeys( + year: 2026, + through: MissingDays.backlogCutoff(asOf: now, calendar: Self.calendar), + present: [], + calendar: Self.calendar, + ) + // Jan 1–4 are missed; today (Jan 5) is still pending, not in the backlog. + #expect(missing == [ + Self.day(2026, 1, 1), + Self.day(2026, 1, 2), + Self.day(2026, 1, 3), + Self.day(2026, 1, 4), + ]) + #expect(!missing.contains(Self.day(2026, 1, 5))) + } + + @Test func backlogIsEmptyOnNewYearsDay() { + let now = Self.day(2026, 1, 1) + let missing = MissingDays.missingDayKeys( + year: 2026, + through: MissingDays.backlogCutoff(asOf: now, calendar: Self.calendar), + present: [], + calendar: Self.calendar, + ) + #expect(missing.isEmpty) + } + // MARK: ranges @Test func rangesCollapseConsecutiveDaysAndSplitOnGaps() { diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index e664f93..81a0cd2 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -303,8 +303,8 @@ struct WhereControllerTests { #expect(await spy.authorizationRequests == 1) #expect(await spy.lastEnabled == true) - // Jan 1–5 are all unlogged. - #expect(await spy.lastBadgeCount == 5) + // Backlog is past misses only: Jan 1–4 (today, Jan 5, is still pending). + #expect(await spy.lastBadgeCount == 4) // Rolling window is today + 6 days (Jan 5–11), all still unlogged. #expect(await spy.lastScheduleDays.count == 7) let today = Self.pacificCalendar.startOfDay(for: now) @@ -326,14 +326,15 @@ struct WhereControllerTests { #expect(await spy.lastScheduleDays.isEmpty) } - @Test func loggingTodayViaGPSDropsItsReminderAndLowersBadge() async throws { + @Test func loggingTodayViaGPSCancelsItsScheduledReminder() async throws { let now = iso("2026-01-05T09:00:00-08:00") let spy = SpyReminderScheduler() let (controller, _, source) = try Self.makeReminderController(now: now, scheduler: spy) await controller.configureReminders(enabled: true, time: .defaultEvening) let today = Self.pacificCalendar.startOfDay(for: now) - #expect(await spy.lastBadgeCount == 5) + // Today is a forward nudge, not part of the backlog (Jan 1–4 = 4 days). + #expect(await spy.lastBadgeCount == 4) #expect(await spy.lastScheduleDays.contains(today)) await controller.startGPS() @@ -344,8 +345,9 @@ struct WhereControllerTests { source: .gpsSignificantChange, )) - try await waitUntil { await spy.lastBadgeCount == 4 } - #expect(await !spy.lastScheduleDays.contains(today)) + // Logging today removes today's reminder; the past backlog is unchanged. + try await waitUntil { await !spy.lastScheduleDays.contains(today) } + #expect(await spy.lastBadgeCount == 4) await controller.stopGPS() } @@ -380,21 +382,21 @@ struct WhereControllerTests { await controller.stopGPS() } - @Test func manualDayLoggingLowersTheBadge() async throws { + @Test func manualDayLoggingAPastDayLowersTheBadge() async throws { let now = iso("2026-03-10T09:00:00-08:00") let spy = SpyReminderScheduler() let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) await controller.configureReminders(enabled: true, time: .defaultEvening) - // Jan 1 – Mar 10 inclusive is the 69th day of 2026 (a non-leap year). - #expect(await spy.lastBadgeCount == 69) + // Backlog is Jan 1 – Mar 9 (today, Mar 10, is excluded): 68 days of 2026. + #expect(await spy.lastBadgeCount == 68) try await controller.addManualDay( date: iso("2026-03-03T12:00:00-08:00"), regions: [.california], ) - #expect(await spy.lastBadgeCount == 68) + #expect(await spy.lastBadgeCount == 67) } @Test func clearCurrentYearReconcilesTheBadge() async throws { @@ -407,11 +409,13 @@ struct WhereControllerTests { date: iso("2026-01-01T12:00:00-08:00"), regions: [.california], ) - #expect(await spy.lastBadgeCount == 4) + // Backlog (Jan 1–4) minus the logged Jan 1 leaves 3. + #expect(await spy.lastBadgeCount == 3) try await controller.clearYear(2026) - #expect(await spy.lastBadgeCount == 5) + // Clearing puts all four past days back into the backlog. + #expect(await spy.lastBadgeCount == 4) } @Test func changingReminderTimeReconcilesWithTheNewTime() async throws { @@ -425,7 +429,7 @@ struct WhereControllerTests { #expect(await spy.authorizationRequests == 2) #expect(await spy.reconcileCount == 2) #expect(await spy.lastReminderTime == ReminderTime(hour: 7, minute: 30)) - #expect(await spy.lastBadgeCount == 5) + #expect(await spy.lastBadgeCount == 4) } @Test func disablingRemindersAfterEnablingClearsEverything() async throws { @@ -434,7 +438,7 @@ struct WhereControllerTests { let (controller, _, _) = try Self.makeReminderController(now: now, scheduler: spy) await controller.configureReminders(enabled: true, time: .defaultEvening) - #expect(await spy.lastBadgeCount == 5) + #expect(await spy.lastBadgeCount == 4) await controller.configureReminders(enabled: false, time: .defaultEvening) #expect(await spy.lastEnabled == false) diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index c03d7eb..18af181 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -83,9 +83,11 @@ public final class WhereModel { public var missingDays: [MissingDayRange] { guard let report, isViewingCurrentYear else { return [] } let present = Set(report.days.map(\.date)) + // Through yesterday: today is still loggable, so it isn't a "missed" day + // yet — the evening reminder covers it instead of the banner/backfill. return MissingDays.missingRanges( year: report.year, - through: now(), + through: MissingDays.backlogCutoff(asOf: now(), calendar: Self.calendar), present: present, calendar: Self.calendar, ) diff --git a/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift index 8f4fed6..8e7f025 100644 --- a/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift +++ b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift @@ -34,7 +34,7 @@ struct WhereModelMissingDaysTests { ) } - @Test func missingDaysForCurrentYearSurfaceTheGapsThroughToday() throws { + @Test func missingDaysSurfacePastGapsAndExcludeToday() throws { let today = Self.day(2026, 1, 5) let present = [Self.day(2026, 1, 2), Self.day(2026, 1, 4)] let report = YearReport( @@ -49,13 +49,14 @@ struct WhereModelMissingDaysTests { now: { today }, ) - // Jan 1, Jan 3, and Jan 5 (today, unlogged) are each isolated gaps. + // Jan 1 and Jan 3 are past gaps. Jan 5 (today) is still loggable, so it + // isn't surfaced even though it's unlogged. #expect(model.missingDays.map(\.start) == [ Self.day(2026, 1, 1), Self.day(2026, 1, 3), - Self.day(2026, 1, 5), ]) - #expect(model.missingDayCount == 3) + #expect(model.missingDayCount == 2) + #expect(!model.missingDays.contains { $0.start == Self.day(2026, 1, 5) }) } @Test func missingDaysAreEmptyWhenViewingAPastYear() throws { From 117f30798ea0787d238be4c4210c12bb955b46de Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 3 Jun 2026 15:21:29 -0400 Subject: [PATCH 13/13] Add TODOs.md for Where app --- Where/TODOs.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Where/TODOs.md diff --git a/Where/TODOs.md b/Where/TODOs.md new file mode 100644 index 0000000..48acd02 --- /dev/null +++ b/Where/TODOs.md @@ -0,0 +1,30 @@ +# App todos + +## Usage +- Tag issues with conventional commit semantics: feat, fix, refactor, perf, test, docs + - Eg "- [ ] feat: Add log viewer to settings page" + +## P0s (Must do) +- [ ] Performance pass (How often is the app booting? Can we only do it on changes of say, 1km or more?) +- [ ] Schedule local push notifications if we haven’t recorded for the day yet +- [ ] Add snapshot images to a new test target + +## P1s (Should do) +- [ ] Rewrite controller layer to be a state machine so invariants can’t exist +- [ ] SwiftData browser +- [ ] Do we live refresh the Primary UI / Elsewhere UI? Or regularly? +- [ ] Export / import system (JSON? Zip?) +- [ ] Schedule local push notifications if we haven’t recorded for the day yet +- [ ] What’s with all the `.accessibilityIdentifier(…)` modifiers, do we need them? +- [ ] Remove `caption(forRank rank: Int) -> String?`, I don’t want the caption +- [ ] Remove get/set closure-based bindings +- [ ] Add a UI that represents where you currently are? Maybe a border on the current location card? + +## P2s (Nice to have) +- [ ] The `guard let controller else { return }` in the WhereModel in WhereUI is weird +- [ ] Raw data browser (similar to SD browser) +- [ ] Clean up and centralize loggers into a logging module? We have several separate loggers +- [ ] Move `let calendar = Calendar.current` into a var on the controller? There’s a few of these +- [ ] Move test only code behind @_spi +- [ ] Add comments to strings in xcstrings files +- [ ] Can we code-gen the strings.swift file somehow?