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/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? 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/WhereCore/Sources/MissingDays.swift b/Where/WhereCore/Sources/MissingDays.swift new file mode 100644 index 0000000..96e80c4 --- /dev/null +++ b/Where/WhereCore/Sources/MissingDays.swift @@ -0,0 +1,122 @@ +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 + } + + /// 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, + 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/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift new file mode 100644 index 0000000..9584e55 --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -0,0 +1,318 @@ +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 + + /// 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. + /// + /// - 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 +} + +/// 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. +public final class UserNotificationReminderScheduler: LoggingReminderScheduling, + @unchecked Sendable +{ + private let center: any NotificationReminderCenter + 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 = UNUserNotificationCenterAdapter(center: center) + self.calendar = calendar + } + + init( + notificationCenter: any NotificationReminderCenter, + calendar: Calendar, + ) { + center = notificationCenter + 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 isAuthorized() async -> Bool { + switch await center.authorizationStatus() { + case .authorized, .provisional, .ephemeral: + true + default: + false + } + } + + public func reconcile( + badgeCount: Int, + scheduleDays: [Date], + reminderTime: ReminderTime, + enabled: Bool, + ) async { + guard enabled else { + await removeAllOwnedReminders() + await setBadge(0) + return + } + + switch await center.authorizationStatus() { + case .authorized, .provisional, .ephemeral: + break + default: + await removeAllOwnedReminders() + await setBadge(0) + 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 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 } + 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 deliveredIDs = await center.deliveredNotificationIdentifiers() + let staleDelivered = deliveredIDs.filter { isOwned($0) && desiredIDs[$0] == nil } + if !staleDelivered.isEmpty { + await center.removeDeliveredNotifications(withIdentifiers: staleDelivered) + } + + // 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) + } + + 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 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 { + await center.removePendingNotificationRequests(withIdentifiers: pendingIDs) + } + let deliveredIDs = await center.deliveredNotificationIdentifiers() + let ownedDeliveredIDs = deliveredIDs.filter(isOwned) + if !ownedDeliveredIDs.isEmpty { + await center.removeDeliveredNotifications(withIdentifiers: ownedDeliveredIDs) + } + } + + 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) + } +} + +protocol NotificationReminderCenter { + 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] { + let delivered = await center.deliveredNotifications() + return delivered.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/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" diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index e1034d5..0585142 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 @@ -129,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 @@ -149,7 +178,10 @@ 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() guard ingestTask == nil else { return } let stream = locationSource.sampleStream ingestTask = Task { [weak self] in @@ -165,9 +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) } + 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 @@ -191,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)", @@ -205,6 +241,7 @@ public actor WhereController { enqueueForRetry(sample) } } + return persistedDays } /// Number of samples currently waiting to be re-persisted. Exposed @@ -245,4 +282,96 @@ 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(changedDays: Set) async { + guard reminderConfig.enabled else { return } + let today = aggregator.calendar.startOfDay(for: now()) + let changedDayNeedsReconcile = changedDays.contains { $0 != today } + guard todayCoveredByReconcile != today || changedDayNeedsReconcile 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 { calendar.startOfDay(for: $0.date) }) + // 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: MissingDays.backlogCutoff(asOf: now(), calendar: calendar), + 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: backlog.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/LoggingReminderSchedulerTests.swift b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift new file mode 100644 index 0000000..794fdf6 --- /dev/null +++ b/Where/WhereCore/Tests/LoggingReminderSchedulerTests.swift @@ -0,0 +1,143 @@ +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 = try #require(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 = try #require(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) + } +} + +extension UNNotificationRequest { + fileprivate var reminderHour: Int? { + (trigger as? UNCalendarNotificationTrigger)?.dateComponents.hour + } + + fileprivate 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: + true + default: + false + } + } + + func authorizationStatus() async -> UNAuthorizationStatus { + status + } + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + pendingRequests + } + + func deliveredNotificationIdentifiers() async -> [String] { + deliveredIdentifiers + } + + func add(_ request: UNNotificationRequest) async throws { + pendingRequests.removeAll { $0.identifier == request.identifier } + pendingRequests.append(request) + addedRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { + removedPendingIdentifiers.append(contentsOf: identifiers) + pendingRequests.removeAll { identifiers.contains($0.identifier) } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async { + deliveredIdentifiers.removeAll { identifiers.contains($0) } + } + + func setBadgeCount(_ count: Int) async throws { + badgeCounts.append(count) + } +} diff --git a/Where/WhereCore/Tests/MissingDaysTests.swift b/Where/WhereCore/Tests/MissingDaysTests.swift new file mode 100644 index 0000000..2640fab --- /dev/null +++ b/Where/WhereCore/Tests/MissingDaysTests.swift @@ -0,0 +1,196 @@ +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: 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() { + 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), + ]) + } +} diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index 72dac76..81a0cd2 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,160 @@ 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) + // 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) + #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 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) + // 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() + source.emit(LocationSample( + timestamp: iso("2026-01-05T12:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .gpsSignificantChange, + )) + + // 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() + } + + @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 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) + + // 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 == 67) + } + + @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], + ) + // Backlog (Jan 1–4) minus the logged Jan 1 leaves 3. + #expect(await spy.lastBadgeCount == 3) + + try await controller.clearYear(2026) + + // Clearing puts all four past days back into the backlog. + #expect(await spy.lastBadgeCount == 4) + } + + @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 == 4) + } + + @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 == 4) + + 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 +465,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. diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index ac53f34..18af181 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,40 @@ 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)) + // 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: MissingDays.backlogCutoff(asOf: now(), calendar: Self.calendar), + 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 +133,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 +150,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 +206,18 @@ public final class WhereModel { observeAuthorizationChanges() await reconcileTracking() await refresh() + 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 @@ -264,6 +346,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..4e94a97 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, @@ -96,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/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 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/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() } + } } } 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/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 8edb1c5..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") } @@ -338,6 +378,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 { 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() diff --git a/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift new file mode 100644 index 0000000..8e7f025 --- /dev/null +++ b/Where/WhereUI/Tests/WhereModelMissingDaysTests.swift @@ -0,0 +1,92 @@ +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 missingDaysSurfacePastGapsAndExcludeToday() 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 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), + ]) + #expect(model.missingDayCount == 2) + #expect(!model.missingDays.contains { $0.start == Self.day(2026, 1, 5) }) + } + + @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)