diff --git a/AGENTS.md b/AGENTS.md index f8c89530..50628b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,8 +196,8 @@ properties only when the values are genuinely independent, and let that be the exception you can justify, not the reflex. A good type makes the illegal states impossible to spell and the legal ones obvious. -The Where app does this with `WhereSession.LoadState` (`idle` / `loading` / -`loaded` / `failed(String)`) rather than juggling `isLoading` + `error` + +The Where app does this with `YearReportModel.LoadState` (`idle` / `loading` / +`loaded` / `failed(LoadError)`) rather than juggling `isLoading` + `error` + `data`, and `CalendarView` keeps one `Result<[CalendarMonth], Error>?` instead of separate `months` and `layoutError` properties — success and failure can't both be set, and "not loaded yet" is the `nil`. diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 6431ccc0..220bf5d8 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -30,9 +30,12 @@ Where/ - **`WhereCore`** is the domain layer: pure Swift + Foundation + SwiftData + CoreLocation; it must **not** import SwiftUI or UIKit. Bundled region polygons (`Resources/*.geojson`) ship here. -- **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models - (`WhereModel` app-level, `WhereSession` logged-in over `WhereServices`). It - is **not** the domain model — see [Layering](#layering). +- **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models — + the app-level `WhereModel`, the always-on `WhereSession` coordinator (no + presentation state), and its scope-tiered children (scene-scoped + `YearReportModel`, view-scoped `ResolveModel` / `BackupModel` / + `RemindersSettingsModel`). It is **not** the domain model — see + [Layering](#layering). ## Layering @@ -42,12 +45,13 @@ must not grow business logic just because SwiftUI makes it easy. | Layer | Where | Owns | |-------|-------|------| | **Domain / services** | `WhereCore` (`WhereServices` collaborators) | Rules, detection, aggregation, persistence, side effects (reminders, widgets, backup). Unit-test here. | -| **View model** | `WhereUI` (`WhereSession`, `WhereModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. Orchestrates `WhereServices`; does not reimplement Core rules. | -| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to session/model. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. | +| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator + scope-tiered `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. Orchestrates `WhereServices`; does not reimplement Core rules. | +| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to the coordinator / scoped models. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. | When in doubt: if the behavior would still be correct without SwiftUI, it -belongs in `WhereCore` (or, for logged-in orchestration that exists only to -serve the UI, on `WhereSession` — still not in a `View`). +belongs in `WhereCore` (or, for orchestration that exists only to serve the +UI, on the `WhereSession` coordinator or a scoped model — still not in a +`View`). Rules the code enforces and agents must preserve: @@ -59,7 +63,8 @@ Rules the code enforces and agents must preserve: - **One read path.** Every committed write (manual edit, live GPS, CloudKit remote import) pings the single store-change signal (`WhereStore.changes()`), and readers refresh purely off it — write intents just commit, they don't - refresh inline. Launch is driven by + refresh inline. The scene's `YearReportModel` subscribes while it's active; + `DataIssueScanner` drops its cache on the same signal. Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (see `WhereLaunch` in WhereUI). - **All logging goes through `WhereLog.channel(_:)`** with a typed `WhereLog.Category` case, never a raw string. Messages log as `.public`, so @@ -91,9 +96,11 @@ literals in SwiftUI `Text` or `errorDescription`. - **Year bounds are half-open** (`[Jan 1 year, Jan 1 year+1)`); **day ranges are inclusive** (`Date.calendarDays(through:in:)`). -- **Inject `Calendar`, don't reach for globals** — logged-in UI reads - `WhereSession.calendar`; layout types carry the calendar they were built - with. Prefer calendar APIs over hardcoding day/weekday counts. +- **Inject `Calendar`, don't reach for globals** — the scene's + `YearReportModel` owns the calendar (Gregorian, current time zone) its + missing-day math uses; layout types carry the calendar they were built with. + Prefer calendar APIs over hardcoding day/weekday counts (`Calendar.dayCount` + derives 365/366 rather than assuming a length). - **Core layout APIs throw on failure**; views surface `ContentUnavailableView` + log, never `!`. - Layout constants live in `UIConstants`, shared date-range copy in @@ -108,10 +115,12 @@ Every previewable component in `WhereUI` (any `View`, `Widget`, or wrapped in `#if DEBUG` at the bottom. Don't construct services, stores, or location sources inline — pull fixtures from [`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) (synchronous, -in-memory, never touch disk/CloudKit/CoreLocation) and inject what the view -reads from the environment (`WhereSession` for logged-in views, `WhereModel` -for the app shell). Cover the states that matter — empty, loaded, and distinct -edge states — not just the happy path. +in-memory, never touch disk/CloudKit/CoreLocation). Pass scoped models +explicitly (a `YearReportModel` via `report:`, a seeded `ResolveModel`) and +inject ambient app state through the environment (`WhereModel` for the app +shell, the `WhereSession` coordinator for logged-in views). Cover the states +that matter — empty, loaded, and distinct edge states — not just the happy +path. ## Adding things diff --git a/Where/TODOs.md b/Where/TODOs.md index 92180515..b141f573 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -37,6 +37,8 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a - Add a UI that represents where you currently are? Maybe a border on the current location card? ## P2s (Nice to have) +- refactor: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. +- refactor: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split. - The `guard let controller else { return }` in the WhereModel in WhereUI is weird - refactor: Move `RegionDays` / `RegionRanking` down from `WhereUI` into `WhereCore` so `DataIssueScanner` can derive primary regions itself instead of `WhereSession` passing `primaryRegions` in. Reverses the current "ranking is a presentation concept" placement; check the widget/UI call sites still compile. - Raw data browser (similar to SD browser) diff --git a/Where/WhereCore/Sources/BackupCoordinator.swift b/Where/WhereCore/Sources/BackupCoordinator.swift index aca58593..887b8f75 100644 --- a/Where/WhereCore/Sources/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/BackupCoordinator.swift @@ -1,4 +1,5 @@ import Foundation +import LogKit /// Owns backup export/import over the `BackupService` and the store, publishing /// the widget snapshot after an import lands new data. @@ -41,6 +42,15 @@ public actor BackupCoordinator { private let store: any WhereStore private let backupService = BackupService() private let widgets: WidgetSnapshotPublisher + private static let logger = WhereLog.channel(.backupService) + + /// Staging directory of the most recent export. Each archive lands in its + /// own temporary directory; the share sheet copies the file it needs out of + /// ours and gives no dismissal hook to clean up after, so we purge the + /// previous export lazily when the next one starts (bounding us to one stale + /// archive on disk). Actor-isolated, so it survives the UI that triggered + /// the export being torn down. + private var previousExportDirectory: URL? init(store: any WhereStore, widgets: WidgetSnapshotPublisher) { self.store = store @@ -48,9 +58,12 @@ public actor BackupCoordinator { } /// Serialize the entire store (all four tables plus evidence blobs) to a - /// `.zip` in the temporary directory and return its URL. The caller owns the - /// file: share it, then delete it (or its parent directory). + /// `.zip` in a fresh temporary directory and return its URL, first purging + /// the previous export's directory. The caller shares the file; the next + /// export (or process exit) reclaims the disk. public func exportBackup() async throws -> URL { + purgePreviousExport() + let samples = try await store.allSamples() let evidence = try await store.allEvidence() let manualDays = try await store.allManualDays() @@ -62,7 +75,7 @@ public actor BackupCoordinator { } } let backupService = backupService - return try await Task.detached(priority: .utility) { + let url = try await Task.detached(priority: .utility) { try backupService.makeArchiveFile( samples: samples, evidence: evidence, @@ -71,6 +84,24 @@ public actor BackupCoordinator { blobs: blobs, ) }.value + previousExportDirectory = url.deletingLastPathComponent() + return url + } + + /// Delete the previous export's staging directory if we still have one. A + /// failure here is non-fatal — a leftover temp directory only wastes a + /// little disk — so it's logged rather than thrown, and never blocks the new + /// export. + private func purgePreviousExport() { + guard let previous = previousExportDirectory else { return } + previousExportDirectory = nil + do { + try FileManager.default.removeItem(at: previous) + } catch { + Self.logger.warning( + "Failed to remove previous backup export directory: \(error.localizedDescription)", + ) + } } /// Read a backup `.zip` and write its contents back into the store inside a diff --git a/Where/WhereCore/Sources/Calendar+DayCount.swift b/Where/WhereCore/Sources/Calendar+DayCount.swift new file mode 100644 index 00000000..87f13a89 --- /dev/null +++ b/Where/WhereCore/Sources/Calendar+DayCount.swift @@ -0,0 +1,22 @@ +import Foundation + +extension Calendar { + /// The number of days in `year` (365, or 366 in a leap year), evaluated in + /// this calendar. Derived from the calendar's own day-of-year range rather + /// than assuming a fixed length, so leap years come out right. + /// + /// The `366`/`365` split falls out of `range(of:.day, in:.year, for:)`; the + /// literal fallback is an unreachable release safety net for a calendar that + /// can't even resolve a mid-year date (an impossible state we assert on in + /// debug rather than paper over). + public func dayCount(ofYear year: Int) -> Int { + guard + let midYear = date(from: DateComponents(year: year, month: 6, day: 15)), + let range = range(of: .day, in: .year, for: midYear) + else { + assertionFailure("Calendar could not resolve the day range for year \(year)") + return 365 + } + return range.count + } +} diff --git a/Where/WhereCore/Sources/YearReport+MissingDays.swift b/Where/WhereCore/Sources/YearReport+MissingDays.swift new file mode 100644 index 00000000..3a6b8081 --- /dev/null +++ b/Where/WhereCore/Sources/YearReport+MissingDays.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Missing-day derivation for a loaded `YearReport`. These adapt the pure +/// `MissingDays` rules to a specific report + reference date, so callers (the +/// scene's year model, the calendar) read a one-liner instead of re-deriving +/// the present-day set and backlog cutoff themselves. +/// +/// "Missing" is only meaningful for the *current* year: a past year can't gain +/// today's coverage, so these return empty for it. Today itself is excluded +/// (it's still loggable) via `MissingDays.backlogCutoff`. +extension YearReport { + /// Whether this report's `year` is the calendar year containing `now`. + public func isCurrentYear(asOf now: Date, calendar: Calendar) -> Bool { + year == calendar.component(.year, from: now) + } + + /// Start-of-day keys for days in this report's year that still need logging + /// as of `now` (Jan 1 through yesterday). Empty for a past year. + public func missingDayKeys(asOf now: Date, calendar: Calendar) -> Set { + guard isCurrentYear(asOf: now, calendar: calendar) else { return [] } + return Set(MissingDays.missingDayKeys( + year: year, + through: MissingDays.backlogCutoff(asOf: now, calendar: calendar), + present: Set(days.map(\.date)), + calendar: calendar, + )) + } + + /// Unlogged days in this report's year (as of `now`), collapsed into + /// consecutive ranges for the warning banner and backfill flow. Empty for a + /// past year. + public func missingDayRanges(asOf now: Date, calendar: Calendar) -> [MissingDayRange] { + guard isCurrentYear(asOf: now, calendar: calendar) else { return [] } + return MissingDays.missingRanges( + year: year, + through: MissingDays.backlogCutoff(asOf: now, calendar: calendar), + present: Set(days.map(\.date)), + calendar: calendar, + ) + } + + /// Total number of unlogged days behind `missingDayRanges(asOf:calendar:)`. + public func missingDayCount(asOf now: Date, calendar: Calendar) -> Int { + missingDayRanges(asOf: now, calendar: calendar).reduce(0) { $0 + $1.dayCount } + } +} diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index ede3a63d..31b9f1d1 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -143,6 +143,23 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) } + /// The coordinator owns the export staging directory's lifecycle: starting a + /// new export purges the previous one, so at most one archive sits on disk. + @Test func exportPurgesThePreviousExportDirectory() async throws { + let harness = try Self.makeHarness() + try await Self.seed(harness.store) + + let first = try await harness.coordinator.exportBackup() + let firstDirectory = first.deletingLastPathComponent() + #expect(FileManager.default.fileExists(atPath: first.path)) + + let second = try await harness.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: second.deletingLastPathComponent()) } + + #expect(!FileManager.default.fileExists(atPath: firstDirectory.path)) + #expect(FileManager.default.fileExists(atPath: second.path)) + } + @Test func importReportsProgressUpToCompletion() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) diff --git a/Where/WhereCore/Tests/Calendar+DayCountTests.swift b/Where/WhereCore/Tests/Calendar+DayCountTests.swift new file mode 100644 index 00000000..78aebea8 --- /dev/null +++ b/Where/WhereCore/Tests/Calendar+DayCountTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +import WhereCore + +struct CalendarDayCountTests { + private let calendar = WhereCoreTestSupport.calendar() + + @Test func commonYearHas365Days() { + #expect(calendar.dayCount(ofYear: 2025) == 365) + #expect(calendar.dayCount(ofYear: 2023) == 365) + } + + @Test func leapYearHas366Days() { + #expect(calendar.dayCount(ofYear: 2024) == 366) + #expect(calendar.dayCount(ofYear: 2000) == 366) + } + + /// 1900 is divisible by 100 but not 400, so the Gregorian calendar skips its + /// leap day — the derived count must reflect that, not a naive `year % 4`. + @Test func gregorianCentennialSkipsLeapDay() { + #expect(calendar.dayCount(ofYear: 1900) == 365) + } +} diff --git a/Where/WhereCore/Tests/YearReport+MissingDaysTests.swift b/Where/WhereCore/Tests/YearReport+MissingDaysTests.swift new file mode 100644 index 00000000..8e08f841 --- /dev/null +++ b/Where/WhereCore/Tests/YearReport+MissingDaysTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +import WhereCore + +struct YearReportMissingDaysTests { + private let calendar = WhereCoreTestSupport.calendar() + + private func day(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.startOfDay(for: calendar.date(from: DateComponents( + year: year, + month: month, + day: day, + ))!) + } + + /// A current-year report surfaces the past gaps (Jan 1 through yesterday) and + /// excludes today, which is still loggable. + @Test func currentYearSurfacesPastGapsExcludingToday() { + let now = day(2026, 1, 5) + let report = YearReport( + year: 2026, + days: [ + DayPresence(date: day(2026, 1, 2), regions: [.california]), + DayPresence(date: day(2026, 1, 4), regions: [.california]), + ], + totals: [.california: 2], + ) + + // Present: Jan 2 & Jan 4. Missing through Jan 4 (yesterday): Jan 1 & Jan 3. + #expect(report.missingDayRanges(asOf: now, calendar: calendar).map(\.start) == [ + day(2026, 1, 1), + day(2026, 1, 3), + ]) + #expect(report.missingDayCount(asOf: now, calendar: calendar) == 2) + #expect(report.missingDayKeys(asOf: now, calendar: calendar) == [ + day(2026, 1, 1), + day(2026, 1, 3), + ]) + // Today (Jan 5) is never surfaced — it can still be logged. + #expect(!report.missingDayKeys(asOf: now, calendar: calendar).contains(day(2026, 1, 5))) + } + + /// A past year can't gain today's coverage, so it has no missing days even + /// with nothing logged. + @Test func pastYearHasNoMissingDays() { + let now = day(2026, 6, 15) + let report = YearReport(year: 2025, days: [], totals: [:]) + + #expect(!report.isCurrentYear(asOf: now, calendar: calendar)) + #expect(report.missingDayRanges(asOf: now, calendar: calendar).isEmpty) + #expect(report.missingDayCount(asOf: now, calendar: calendar) == 0) + #expect(report.missingDayKeys(asOf: now, calendar: calendar).isEmpty) + } +} diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index aa8cb24e..279cba47 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -16,14 +16,13 @@ public enum LaunchStepID: String { /// First-run onboarding gate. Foreground-only, so a headless background /// relaunch skips it. case onboarding - /// Read location authorization into the session and start observing live - /// changes — both authorization updates and the store's data-change signal - /// (live GPS ingestion / remote sync the UI mirrors). + /// Read location authorization into the coordinator and start observing live + /// authorization changes. The report + data-issue scan (and their + /// store-change subscription) load with the scene now, not here — so a + /// headless background relaunch never drives a refresh no UI consumes. case syncAuth = "sync-auth" /// Start or stop GPS ingestion to match the user's intent + authorization. case reconcileTracking = "reconcile-tracking" - /// Load the selected year's report into the session. - case loadYear = "load-year" /// Push the logging-reminder schedule + backlog badge to the reconciler. case reminders /// Push the daily-summary recap to the reconciler. @@ -122,13 +121,10 @@ public enum WhereLaunch { LifecycleStep.work(LaunchStepID.syncAuth) { _ in await model.session?.syncAuthorization() model.session?.observeAuthorizationChanges() - model.session?.observeDataChanges() } LifecycleStep.work(LaunchStepID.reconcileTracking) { _ in await model.session?.reconcileTracking() } - LifecycleStep - .work(LaunchStepID.loadYear) { _ in await model.session?.refresh() } LifecycleStep.work(LaunchStepID.reminders) { _ in await model.session?.applyReminderConfiguration() } diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift new file mode 100644 index 00000000..379b00ef --- /dev/null +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -0,0 +1,66 @@ +import SwiftUI +import WhereCore + +/// The logged-in tab bar — the launch *destination* once the runner reaches +/// `.ready`, not a launch step. Owns the scene-scoped ``YearReportModel`` as +/// `@State`, drives its store-change subscription from `scenePhase` (active → +/// subscribe + pull, background → cancel — closing the headless-relaunch rescan +/// leak), and renders the Resolve badge from its count. +/// +/// The four tabs receive the report by explicit init injection (compile-checked +/// wiring); the always-on `WhereSession` coordinator stays in the environment. +struct MainTabs: View { + @State private var report: YearReportModel + @Environment(\.scenePhase) private var scenePhase + + /// Build the scene's report model from the coordinator's service layer. + /// `initialReport` / `selectedYear` are the preview/test seam threaded from + /// `WhereModel`; both are nil / the current year in the app. + init(session: WhereSession, initialReport: YearReport?, selectedYear: Int) { + _report = State(initialValue: YearReportModel( + services: session.services, + report: initialReport, + selectedYear: selectedYear, + preferences: session.preferences, + now: session.now, + )) + } + + var body: some View { + TabView { + Tab(Strings.tabPrimary, systemImage: "star.fill") { + PrimaryView(report: report) + } + + Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { + SecondaryView(report: report) + } + + Tab(Strings.tabResolution, systemImage: "checklist") { + ResolutionView(report: report) + } + .badge(report.dataIssueCount) + + Tab(Strings.tabSettings, systemImage: "gearshape.fill") { + SettingsView(report: report) + } + } + .tabBarMinimizeBehavior(.onScrollDown) + // Subscribe + pull once the scene is on screen, and again whenever it + // returns to the foreground; cancel the subscription on background so a + // backgrounded scene drives no refreshes. + .task { await report.activate() } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + Task { await report.activate() } + case .background: + report.deactivate() + case .inactive: + break + @unknown default: + break + } + } + } +} diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 6aa5ca56..0e871f1f 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -34,7 +34,15 @@ public final class WhereModel { /// same store. let preferences: WherePreferences private let now: @Sendable () -> Date - private let initialSelectedYear: Int + + /// The year the scene's `YearReportModel` opens on. Always the current year in + /// the app; a preview/test can pin it via the services init. + let initialSelectedYear: Int + + /// Preview/test seam: a report `MainTabs` seeds its `YearReportModel` with, so a + /// `#Preview` renders populated content without a live store. Nil in the app + /// (the scene loads from the store once it appears). + let initialReport: YearReport? private static let logger = WhereLog.channel(.model) @@ -65,6 +73,7 @@ public final class WhereModel { self.preferences = preferences self.now = now initialSelectedYear = WhereModel.currentYear + initialReport = nil } /// Preview/test seam: inject already-built services (and optionally a @@ -83,10 +92,9 @@ public final class WhereModel { self.preferences = preferences self.now = now initialSelectedYear = selectedYear + initialReport = report session = WhereSession( services: services, - report: report, - selectedYear: selectedYear, preferences: preferences, now: now, ) @@ -118,7 +126,6 @@ public final class WhereModel { guard session == nil, let services else { return } session = WhereSession( services: services, - selectedYear: initialSelectedYear, preferences: preferences, now: now, ) diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index d63142d0..e145f05f 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -6,40 +6,39 @@ import Observation #endif import WhereCore -/// The logged-in, services-backed state of the Where app: the selected year, -/// the loaded `YearReport`, GPS / permission state, the reminder + daily-summary -/// settings, and backup progress. Every mutation funnels through a -/// `WhereServices` collaborator so the views stay free of persistence and -/// CoreLocation details. +/// The always-on, logged-in coordinator for the Where app: GPS / permission +/// state, the launch-time reminder + daily-summary *application*, the widget +/// snapshot republish, and the erase/reset pass-through. Every mutation funnels +/// through a `WhereServices` collaborator so the views stay free of persistence +/// and CoreLocation details. +/// +/// It deliberately holds **no presentation state**. Everything scoped to the +/// visible UI lives in child observables the scene / views own: +/// - the selected year's `YearReport`, ranking, missing days, day-write intents, +/// and the Resolve badge count → scene-scoped ``YearReportModel`` (owned by +/// `MainTabs`, created only once the real UI is on screen); +/// - the Resolve issue list → view-scoped ``ResolveModel``; +/// - the reminder/summary editing surface → view-scoped ``RemindersSettingsModel``; +/// - backup export/import progress → view-scoped ``BackupModel``. /// /// A session only exists once the store is open: `WhereModel` creates it in the -/// launch's `open-store` step and drops it on reset, so — unlike the old -/// `WhereModel` — `services` is non-optional and there are no pre-attach nil -/// guards. Logged-in views read it via `@Environment(WhereSession.self)`; the -/// `TabView` renders only at `.ready` and onboarding runs after `open-store`, -/// so the session is always present wherever those views appear. +/// launch's `open-store` step and drops it on reset, so `services` is +/// non-optional and there are no pre-attach nil guards. Logged-in views read it +/// via `@Environment(WhereSession.self)`; the `TabView` renders only at `.ready` +/// and onboarding runs after `open-store`, so the session is always present +/// wherever those views appear. It also vends `services` / `preferences` / `now` +/// so `MainTabs` can build the scene's `YearReportModel` (and the tabs their +/// view-scoped models) from the injected coordinator. @MainActor @Observable public final class WhereSession { - /// Where the current year's data is in its load lifecycle. `failed` - /// carries a user-presentable message. - public enum LoadState: Equatable { - case idle - case loading - case loaded - case failed(String) - } - - public private(set) var selectedYear: Int - public private(set) var report: YearReport? - public private(set) var loadState: LoadState = .idle - - /// Unresolved data-quality issues for the selected year (Resolve tab + badge). - public private(set) var dataIssues: [any DataIssue] = [] - - public var dataIssueCount: Int { - dataIssues.count - } + /// A process-unique, monotonically increasing identity for this session. + /// `RootView` keys `MainTabs` (and thus the scene-scoped `YearReportModel`) on + /// it, so a reset that rebuilds the session forces a fresh scene. Unlike an + /// address-derived `ObjectIdentifier`, a monotonic token is never reused + /// within the process — a session freed and reallocated at the same address + /// gets a new token, so the scene can't fail to rebuild on a collision. + public let id: SessionID /// Whether background GPS ingestion is currently attached. Reflects reality /// (authorization + the user's intent), not just the last button tap. @@ -53,128 +52,24 @@ public final class WhereSession { /// 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. Settable so SwiftUI can drive it through a plain key-path binding; - /// the setter persists the intent and reconciles the schedule/badge. - public var remindersEnabled: Bool { - get { remindersEnabledStorage } - set { - guard newValue != remindersEnabledStorage else { return } - remindersEnabledStorage = newValue - preferences.remindersEnabled = newValue - Task { await applyReminderConfiguration() } - } - } - - /// Time of day the daily reminder fires. Persists and reconciles on change. - public var reminderTime: ReminderTime { - get { reminderTimeStorage } - set { - guard newValue != reminderTimeStorage else { return } - reminderTimeStorage = newValue - preferences.reminderTime = newValue - Task { await applyReminderConfiguration() } - } - } - - /// `Date`-typed projection of `reminderTime` for the Settings `DatePicker`, - /// which works in `Date`. Lets the view bind `$session.reminderTimeOfDay` - /// directly instead of building a closure binding; writes round-trip back - /// into `reminderTime` (and its persistence/reconcile). - public var reminderTimeOfDay: Date { - get { - calendar.date( - bySettingHour: reminderTime.hour, - minute: reminderTime.minute, - second: 0, - of: now(), - ) ?? now() - } - set { - let components = calendar.dateComponents([.hour, .minute], from: newValue) - reminderTime = ReminderTime( - hour: components.hour ?? ReminderTime.defaultEvening.hour, - minute: components.minute ?? ReminderTime.defaultEvening.minute, - ) - } - } - - /// Whether the daily summary recap notification is enabled. Persists across - /// launches; defaults to on so the year-to-date recap arrives out of the - /// box. Settable so SwiftUI can drive it through a plain key-path binding; - /// the setter persists the intent and reconciles the scheduled summary. - public var summaryEnabled: Bool { - get { summaryEnabledStorage } - set { - guard newValue != summaryEnabledStorage else { return } - summaryEnabledStorage = newValue - preferences.summaryEnabled = newValue - Task { await applySummaryConfiguration() } - } - } - - /// Time of day the daily summary fires. Persists and reconciles on change. - public var summaryTime: ReminderTime { - get { summaryTimeStorage } - set { - guard newValue != summaryTimeStorage else { return } - summaryTimeStorage = newValue - preferences.summaryTime = newValue - Task { await applySummaryConfiguration() } - } - } - - /// `Date`-typed projection of `summaryTime` for the Settings `DatePicker`, - /// mirroring `reminderTimeOfDay`. Writes round-trip into `summaryTime` (and - /// its persistence/reconcile). - public var summaryTimeOfDay: Date { - get { - calendar.date( - bySettingHour: summaryTime.hour, - minute: summaryTime.minute, - second: 0, - of: now(), - ) ?? now() - } - set { - let components = calendar.dateComponents([.hour, .minute], from: newValue) - summaryTime = ReminderTime( - hour: components.hour ?? ReminderTime.defaultMorning.hour, - minute: components.minute ?? ReminderTime.defaultMorning.minute, - ) - } - } - - /// Observed backing storage for `remindersEnabled` / `reminderTime`. The - /// public computed properties layer persistence + reconciliation onto their - /// setters, which a stored property can't express. - private var remindersEnabledStorage: Bool - private var reminderTimeStorage: ReminderTime - - /// Observed backing storage for `summaryEnabled` / `summaryTime`, mirroring - /// the reminder storage above. - private var summaryEnabledStorage: Bool - private var summaryTimeStorage: ReminderTime + /// The services every mutation funnels through. Non-optional: a session only + /// exists once `WhereModel` has assembled the service layer. Exposed so + /// `MainTabs` / the tabs can build their scoped models from the injected + /// coordinator. + let services: WhereServices - /// GPS border-drift detection threshold (device setting). - public var driftThreshold: DriftThreshold { - get { DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default } - set { - guard newValue.rawValue != preferences.driftThresholdMeters else { return } - preferences.driftThresholdMeters = newValue.rawValue - Task { await refreshDataIssues(force: true) } - } - } + /// The persisted user intent (tracking, reminder/summary schedules) the + /// coordinator applies at launch/foreground. Owned by `WhereModel` and shared + /// by reference so onboarding (model), the coordinator, and the view-scoped + /// editing models all read/write the same store. Exposed so `MainTabs` can + /// thread it into the scene's `YearReportModel`. + let preferences: WherePreferences - /// 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 + /// The coordinator's notion of "now". Not used by the coordinator itself; it + /// vends the injected clock to the scene's `YearReportModel` (calendar / + /// missing-day math) so previews/tests can pin a deterministic date. + let now: @Sendable () -> Date - /// The services every mutation funnels through. Non-optional: a session only - /// exists once `WhereModel` has assembled the service layer. - let services: WhereServices /// `@ObservationIgnored` (it's plumbing, not observable UI state) and /// `nonisolated(unsafe)` so the `deinit` can cancel it. The unsafety is sound: /// every read/write is on the main actor except the `deinit`, which by @@ -182,23 +77,6 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? - /// Long-lived subscription to `services.dataChangeUpdates()` — the store's - /// single "data changed" signal (local commits + CloudKit remote imports). - /// Same `@ObservationIgnored` / `nonisolated(unsafe)` rationale as - /// `authorizationTask`. - @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? - - /// The persisted user intent (tracking, reminder/summary schedules) the - /// session mirrors into its observable storage. Owned by `WhereModel` and - /// shared by reference so onboarding (model) and the logged-in UI (session) - /// read/write the same store. - let preferences: WherePreferences - private let now: @Sendable () -> Date - - /// Gregorian calendar in the current time zone — matches the day keys the - /// aggregator produces in `report.days`, so the missing-day math lines up. - let calendar: Calendar - private static let logger = WhereLog.channel(.session) /// The authorization the degradation warning was last evaluated against. @@ -221,100 +99,32 @@ public final class WhereSession { set { preferences.wantsTracking = newValue } } - /// Primary/secondary split of the current report, or an empty ranking - /// while nothing is loaded. - public var ranking: RegionRanking { - guard let report else { return RegionRanking(primary: [], secondary: []) } - return RegionRanking(report: report) + /// A process-unique session identity. A typed token rather than a raw `Int` + /// so it can't be transposed with any other counter, and `Hashable` so + /// SwiftUI's `.id(_:)` can key a subtree on it. + public struct SessionID: Hashable, Sendable { + fileprivate let value: Int } - /// Total distinct days with any tracked presence in the loaded year. - public var trackedDayCount: Int { - report?.days.count ?? 0 - } + /// Monotonic source for `SessionID`s. `@MainActor`-isolated (the enclosing + /// class is), so minting from `init` needs no extra synchronization. + private static var nextRawID = 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: calendar), - present: present, - calendar: calendar, - ) + private static func mintID() -> SessionID { + defer { nextRawID += 1 } + return SessionID(value: nextRawID) } - /// Total number of unlogged days behind `missingDays`. - public var missingDayCount: Int { - missingDays.reduce(0) { $0 + $1.dayCount } - } - - /// The session's notion of "now", forwarded for calendar and missing-day - /// math in views and tests. - public var referenceDate: Date { - now() - } - - /// Start-of-day keys for days that still need logging in the loaded year. - public var missingDayKeys: Set { - guard let report, isViewingCurrentYear else { return [] } - return Set(MissingDays.missingDayKeys( - year: report.year, - through: MissingDays.backlogCutoff(asOf: now(), calendar: calendar), - present: Set(report.days.map(\.date)), - calendar: calendar, - )) - } - - private var isViewingCurrentYear: Bool { - selectedYear == calendar.component(.year, from: now()) - } - - /// 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. - public var daysInSelectedYear: Int { - let calendar = Calendar.current - guard - let midYear = calendar.date(from: DateComponents( - year: selectedYear, - month: 6, - day: 15, - )), - let range = calendar.range(of: .day, in: .year, for: midYear) - else { return 365 } - return range.count - } - - /// Build a session over an already-assembled service layer. `report` is the - /// preview/test seam: a non-nil value lands `loadState` at `.loaded` so - /// `#Preview`s render content synchronously without driving `start()`. + /// Build a coordinator over an already-assembled service layer. public init( services: WhereServices, - report: YearReport? = nil, - selectedYear: Int = WhereModel.currentYear, preferences: WherePreferences = WherePreferences(), now: @escaping @Sendable () -> Date = { Date() }, ) { + id = Self.mintID() self.services = services - self.report = report - self.selectedYear = selectedYear self.preferences = preferences self.now = now - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .current - self.calendar = calendar - remindersEnabledStorage = preferences.remindersEnabled - reminderTimeStorage = preferences.reminderTime - summaryEnabledStorage = preferences.summaryEnabled - summaryTimeStorage = preferences.summaryTime - loadState = report == nil ? .idle : .loaded } /// Cancel the authorization observer when the session is dropped (e.g. the @@ -325,40 +135,37 @@ public final class WhereSession { /// until the next status change resumes it. deinit { authorizationTask?.cancel() - dataChangeTask?.cancel() } - /// Sync authorization, resume tracking if appropriate, then load the - /// selected year. Safe to call repeatedly; the authorization observer is - /// only set up once. + /// Sync authorization, resume tracking if appropriate, apply the reminder / + /// summary schedules, and republish the widget snapshot. Safe to call + /// repeatedly; the authorization observer is only set up once. /// - /// This is the imperative equivalent of `WhereLaunch.sequence`'s work steps, - /// kept for previews/tests that drive the session directly without a - /// `LifecycleRunner`. + /// This is the imperative equivalent of `WhereLaunch.sequence`'s coordinator + /// work steps, kept for previews/tests that drive the coordinator directly + /// without a `LifecycleRunner`. Report/data-issue loading is *not* here — the + /// scene's `YearReportModel` owns that and starts it when the UI appears. public func start() async { await syncAuthorization() observeAuthorizationChanges() - observeDataChanges() await reconcileTracking() - await refresh() await applyReminderConfiguration() await applySummaryConfiguration() - await refreshDataIssues(force: false) // Republish the widget snapshot from whatever is already on disk so a // cold launch with no writes this session doesn't leave the widget // blank or showing the previous day's "today". await refreshWidgetSnapshot() } - /// Refresh state that can change while the app is away, including - /// notification permission edits made in Settings and calendar-day rollover. + /// Refresh state that can change while the app is away: authorization + + /// tracking, the reminder/summary schedules (notification permission edits), + /// and the widget snapshot (calendar-day rollover). The scene's `YearReportModel` + /// separately re-pulls the report on `.active`. public func appBecameActive() async { await syncAuthorization() await reconcileTracking() - await refresh() await applyReminderConfiguration() await applySummaryConfiguration() - await refreshDataIssues(force: false) // The calendar day may have rolled over while backgrounded; recompute // so the widget's "today" reflects the current day rather than stale // foreground state. @@ -421,27 +228,6 @@ public final class WhereSession { } } - /// Subscribe to the store's data-change signal so the report and data-issue - /// scan the UI mirrors refresh on any committed write — live GPS ingestion - /// and CloudKit remote imports the session never sees, *and* the session's - /// own manual edits/dismissals. Those write methods commit, the store pings, - /// and this observer re-pulls — so they don't refresh inline. Idempotent. - func observeDataChanges() { - guard dataChangeTask == nil else { return } - // Subscribe *synchronously*, before spawning the loop: a write that - // commits the instant after this returns can't slip its ping in ahead of - // the subscription. (The stream also buffers the newest pending ping, so - // one that lands before the loop's first `await` is still delivered.) - let updates = services.dataChangeUpdates() - dataChangeTask = Task { @MainActor [weak self] in - for await _ in updates { - guard let self else { break } - await refresh() - await refreshDataIssues(force: true) - } - } - } - /// Start or stop GPS ingestion so it matches the user's intent and the /// current authorization. Tracking only runs with Always authorization. A /// launch step (see `WhereLaunch.sequence`). @@ -458,151 +244,6 @@ public final class WhereSession { } } - public func select(year: Int) async { - guard year != selectedYear else { return } - Self.logger.info("Selected year \(year)") - selectedYear = year - // Drop the previous year's report so views fall back to their loading - // state instead of rendering stale data under the new year's label. - report = nil - await refresh() - await refreshDataIssues(force: true) - } - - func refreshDataIssues(force: Bool) async { - // Capture the year this scan is for. `WhereSession` is reentrant while - // awaiting the scan, so a concurrent `select(year:)` (or an out-of-band - // refresh from `observeDataChanges()`) can change `selectedYear` - // mid-flight; without this guard a slower older scan could install its - // issues under the newer year's label. Mirrors `refresh()`. - let requestedYear = selectedYear - do { - let issues = try await services.resolution.issues( - year: requestedYear, - primaryRegions: ranking.primary.map(\.region), - driftThresholdMeters: Double(preferences.driftThresholdMeters), - force: force, - ) - guard requestedYear == selectedYear else { return } - dataIssues = issues - } catch { - // Surface the failure in the log and keep the last good list rather - // than silently blanking the tab + badge (which would read as "all - // clear"). The report's own `loadState` already covers the common - // case where the shared store is unreadable. - Self.logger.warning( - "Failed to scan for data issues: \(error.localizedDescription)", - ) - } - } - - public func dismiss(_ issue: any DataIssue) async { - guard issue.isDismissible else { return } - do { - try await services.journal.dismissIssue(key: issue.id.storageKey) - // Optimistically drop the row for instant feedback; the committed - // write pings the store-change signal, so `observeDataChanges()` - // re-pulls the authoritative scan a beat later. - dataIssues.removeAll { $0.id == issue.id } - } catch { - Self.logger.warning( - "Failed to dismiss data issue \(issue.id.storageKey): \(error.localizedDescription)", - ) - } - } - - public func refresh() async { - // Capture the year this fetch is for. `WhereSession` is reentrant while - // awaiting `yearReport`, so a rapid second `select(year:)` can start a - // newer fetch that finishes first; without this guard a slower older - // fetch could install its report under the newer year's label. - let requestedYear = selectedYear - // Only surface the loading state when there's nothing on screen yet — an - // initial load or a year switch (which nils `report` first). The - // observer re-pulls on *every* commit, so a background refresh keeps the - // current report visible (no spinner flicker) and, with the equality - // guards below, makes no observable mutation at all when nothing changed - // — an unrelated commit (e.g. a dismissal that didn't touch presence) - // shouldn't re-render the report views. - if report == nil { loadState = .loading } - do { - let report = try await services.reports.yearReport(for: requestedYear) - guard requestedYear == selectedYear else { return } - let changed = self.report != report - if changed { self.report = report } - if loadState != .loaded { loadState = .loaded } - if changed { - Self.logger - .info("Year report loaded for \(requestedYear) (\(report.days.count) day(s))") - } - } catch { - guard requestedYear == selectedYear else { return } - loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load year report for \(requestedYear): \(error.localizedDescription)", - ) - } - } - - /// Persist a single manual day. Throws on persistence failure so the - /// caller (the entry form) can keep itself open and show the error inline - /// instead of dismissing as if the save succeeded. - /// - /// No inline refresh: the committed write pings the store-change signal, so - /// `observeDataChanges()` re-pulls the report + data-issue scan. The other - /// write methods below follow the same pattern. - public func setManualDay(date: Date, regions: Set) async throws { - try await services.journal.addManualDay(date: date, regions: regions) - } - - /// Persist a manual day range. Throws on persistence failure (see - /// `setManualDay(date:regions:)`). - public func setManualDays( - from start: Date, - through end: Date, - regions: Set, - ) async throws { - try await services.journal.addManualDays(from: start, through: end, regions: regions) - } - - /// Authoritatively set a day's regions, *replacing* whatever was - /// attributed to it (the Elsewhere "fix this day" path) rather than - /// unioning. Throws on persistence failure so the editor can stay open and - /// surface the error instead of dismissing as if the save succeeded. - public func overrideDay(date: Date, regions: Set) async throws { - try await services.journal.overrideDay(date: date, regions: regions) - } - - /// Undo a day's manual override/backfill, restoring the GPS-detected - /// regions (the relabel "reset to GPS" action). Throws on persistence - /// failure so the editor can stay open and surface the error. - public func clearManualDay(date: Date) async throws { - try await services.journal.clearManualDay(date: date) - } - - /// The days in the loaded report whose presence includes `region`, sorted - /// ascending (matching `report.days`). Powers the Elsewhere drill-in list - /// so the user can see where a region's check-ins landed and correct them. - public func days(in region: Region) -> [DayPresence] { - guard let report else { return [] } - return report.days.filter { $0.regions.contains(region) } - } - - /// The raw coordinates recorded inside `region` during the selected year, - /// grouped by day, for the Elsewhere drill-in's map and place names. - /// Returns an empty array (rather than throwing) on failure, so the view - /// can simply render nothing. - public func locations(in region: Region) async -> [RegionDayLocations] { - await (try? services.reports.locations(in: region, year: selectedYear)) ?? [] - } - - /// One representative coordinate per region for the selected year (the - /// most heavily sampled spot in each), for the Elsewhere cards' place-name - /// teaser. Empty on failure. - public func representativeCoordinates() async -> [Region: Coordinate] { - await (try? services.reports.representativeCoordinates(for: selectedYear)) ?? [:] - } - /// Explicitly (re)request location access, e.g. from the "Grant location /// access" button. Drives the system prompt when possible, then syncs the /// status and reconciles tracking so the UI reflects the outcome. @@ -649,13 +290,16 @@ public final class WhereSession { Self.logger.info("Stopped background tracking") } - /// Push the current reminder intent to the reminder reconciler and refresh - /// whether the system has granted notification permission. A launch step - /// (see `WhereLaunch.sequence`). + /// Push the persisted reminder intent to the reminder reconciler and warn if + /// notifications are enabled but unauthorized. Reads `WherePreferences` + /// directly (the single source of truth the `RemindersSettingsModel` also + /// writes), so it re-applies whatever the user last chose. A launch step + /// (see `WhereLaunch.sequence`); also runs on every foreground. func applyReminderConfiguration() async { - await services.reminders.configure(enabled: remindersEnabled, time: reminderTime) - notificationsAuthorized = await services.reminders.isAuthorized() - if remindersEnabled, !notificationsAuthorized { + let enabled = preferences.remindersEnabled + await services.reminders.configure(enabled: enabled, time: preferences.reminderTime) + let authorized = await services.reminders.isAuthorized() + if enabled, !authorized { if !warnedRemindersUnauthorized { Self.logger.warning("Logging reminders enabled but notifications not authorized") warnedRemindersUnauthorized = true @@ -665,14 +309,15 @@ public final class WhereSession { } } - /// Push the current daily-summary intent to the summary reconciler and - /// refresh whether the system has granted notification permission. - /// Notification permission is global, so it shares `notificationsAuthorized` - /// with the logging reminder. A launch step (see `WhereLaunch.sequence`). + /// Push the persisted daily-summary intent to the summary reconciler and warn + /// if enabled but unauthorized. Reads `WherePreferences` directly, mirroring + /// `applyReminderConfiguration()`. A launch step (see `WhereLaunch.sequence`); + /// also runs on every foreground. func applySummaryConfiguration() async { - await services.summary.configure(enabled: summaryEnabled, time: summaryTime) - notificationsAuthorized = await services.reminders.isAuthorized() - if summaryEnabled, !notificationsAuthorized { + let enabled = preferences.summaryEnabled + await services.summary.configure(enabled: enabled, time: preferences.summaryTime) + let authorized = await services.reminders.isAuthorized() + if enabled, !authorized { if !warnedSummaryUnauthorized { Self.logger.warning("Daily summary enabled but notifications not authorized") warnedSummaryUnauthorized = true @@ -682,139 +327,21 @@ public final class WhereSession { } } - public func clearSelectedYear() async { - do { - // The committed write pings the store-change signal, so - // `observeDataChanges()` re-pulls the report + data-issue scan; no - // inline refresh needed (see "One read path" in Where/AGENTS.md). - try await services.journal.clearYear(selectedYear) - } catch { - loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to clear year \(selectedYear): \(error.localizedDescription)", - ) - } - } - - /// Erase all persisted data and reset this session's observable state to a + /// Erase all persisted data and reset the coordinator's observable state to a /// clean slate. A thin pass-through to `WhereServices.reset()`, which owns /// *what* gets cleared (GPS stop + store wipe + reminder/badge reconcile + - /// empty widget snapshot); the session only mirrors the outcome. The data - /// half of the reset/erase teardown (see `WhereLaunch.resetSequence`); - /// throws on persistence failure so the reset step parks the launcher in - /// `.failed` rather than silently half-erasing. + /// empty widget snapshot); the coordinator only mirrors the outcome. The + /// scene's `YearReportModel` is torn down and rebuilt by the relaunch, so no + /// report/issue state needs clearing here. The data half of the reset/erase + /// teardown (see `WhereLaunch.resetSequence`); throws on persistence failure + /// so the reset step parks the launcher in `.failed` rather than silently + /// half-erasing. public func eraseSession() async throws { try await services.reset() isTracking = false - report = nil - dataIssues = [] - loadState = .idle Self.logger.info("Erased session and reset state") } - // MARK: - Backup - - /// Where a backup export/import is in its lifecycle, so the UI can show a - /// spinner and disable the relevant row while work is in flight. - public enum BackupState: Equatable { - case idle - case exporting - case importing - } - - public private(set) var backupState: BackupState = .idle - - /// Fraction (`0...1`) of the in-flight import that has been written, for a - /// determinate progress bar. Reset to `0` whenever an import isn't running. - public private(set) var backupProgress: Double = 0 - - /// Staging directory of the most recent export. The share sheet copies the - /// file it needs out of our temporary directory, and `ShareLink` gives us - /// no dismissal hook to clean up after, so the previous export is deleted - /// lazily when the next one starts (bounding us to one stale archive). - private var previousExportDirectory: URL? - - /// Last backup failure, surfaced as an alert. Mutable so the alert binding - /// can clear it on dismiss (mirrors `permissionDenied`). - public var backupError: String? - - /// Drives the backup-error alert. Reads `true` while `backupError` holds a - /// message and clears it when the alert is dismissed, so the view can bind - /// straight to it (`$session.isShowingBackupError`) instead of building a - /// closure-based `Binding`. `backupError` stays the single source of truth. - public var isShowingBackupError: Bool { - get { backupError != nil } - set { if !newValue { backupError = nil } } - } - - /// Build a backup `.zip` of the entire database and return its URL for the - /// share sheet, or `nil` if the export failed (in which case `backupError` - /// is set). The caller is responsible for the returned temporary file. - public func exportBackup() async -> URL? { - if let previous = previousExportDirectory { - try? FileManager.default.removeItem(at: previous) - previousExportDirectory = nil - } - backupState = .exporting - defer { backupState = .idle } - do { - let url = try await services.backup.exportBackup() - previousExportDirectory = url.deletingLastPathComponent() - Self.logger.info("Exported backup archive") - return url - } catch { - backupError = error.localizedDescription - Self.logger.warning("Backup export failed: \(error.localizedDescription)") - return nil - } - } - - /// Import a backup file with the chosen merge/replace strategy, refreshing - /// the current year afterward. Returns the import summary on success, or - /// `nil` on failure (with `backupError` set). - public func importBackup( - from url: URL, - strategy: BackupCoordinator.ImportStrategy, - ) async -> BackupCoordinator.ImportSummary? { - backupState = .importing - backupProgress = 0 - defer { - backupState = .idle - backupProgress = 0 - } - - // The backup coordinator reports progress from its own executor; funnel - // it through an ordered stream and apply it to `backupProgress` on the - // main actor so SwiftUI sees in-order, hop-free updates. - let (progress, continuation) = AsyncStream.makeStream() - let observer = Task { @MainActor [weak self] in - for await fraction in progress { - self?.backupProgress = fraction - } - } - defer { observer.cancel() } - - do { - let summary = try await services.backup.importBackup(from: url, strategy: strategy) { - continuation.yield($0) - } - continuation.finish() - await observer.value - // The import commits through the store, which pings the - // store-change signal; `observeDataChanges()` re-pulls the report + - // data-issue scan, so no inline refresh is needed here. - Self.logger.info( - "Imported backup (\(summary.sampleCount) samples, \(summary.evidenceCount) evidence, \(summary.manualDayCount) manual days, \(summary.dismissedIssueCount) dismissals)", - ) - return summary - } catch { - continuation.finish() - backupError = error.localizedDescription - Self.logger.warning("Backup import failed: \(error.localizedDescription)") - return nil - } - } - /// Drives the background-tracking `Toggle`. Reads the live `isTracking` /// state; assigning kicks off the matching async start/stop so the view can /// bind straight to it (`$session.trackingEnabled`) instead of building a @@ -828,13 +355,6 @@ public final class WhereSession { } #if DEBUG - @_spi(Testing) extension WhereSession { - /// Inject issues for previews/tests without seeding raw samples. - public func setDataIssues(_ issues: [any DataIssue]) { - dataIssues = issues - } - } - extension WhereSession { /// A read-only SwiftData inspector over the live store, for the DEBUG-only /// developer entry point in Settings. `nil` when the backing store isn't diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift new file mode 100644 index 00000000..1a4bb6d8 --- /dev/null +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -0,0 +1,392 @@ +import Foundation +import LogKit +import Observation +import WhereCore + +/// The scene-scoped presentation model for the selected year: the loaded +/// `YearReport` and everything derived from it (ranking, missing days, the +/// calendar inputs), the day-write intents, and the data-issue *count* the +/// Resolve tab badge reads. +/// +/// Unlike `WhereSession` — the always-on coordinator that lives for the whole +/// logged-in lifetime — a `YearReportModel` is created by `MainTabs` only once the +/// real UI is on screen (the launch's `.ready` state) and torn down with it. It +/// owns the store's data-change subscription, started on scene `.active` +/// (`activate()`) and cancelled on background (`deactivate()`), so a headless +/// background relaunch never drives a `refresh()` no UI consumes. +/// +/// `services` / `preferences` / `now` / `calendar` are exposed so the +/// view-scoped models a tab builds (`BackupModel`, `RemindersSettingsModel`, +/// `ResolveModel`) can be constructed from the injected `report` without also +/// threading the coordinator through. +@MainActor +@Observable +public final class YearReportModel { + /// Where the current year's data is in its load lifecycle. + public enum LoadState: Equatable { + case idle + case loading + case loaded + /// Carries a typed reason (see `LoadError`) rather than a bare message, + /// so a caller can branch on *what* failed and tests can match the case. + case failed(LoadError) + } + + /// Why the selected year's data isn't available. Holds the underlying + /// failure's user-presentable `message` as a `String` (not `any Error`) so + /// `LoadState` stays `Equatable` for the refresh guards and tests, while + /// still naming which operation failed. + public enum LoadError: Error, Equatable { + /// Loading the year report failed. + case reportUnavailable(message: String) + /// Clearing the selected year failed. + case clearFailed(message: String) + + /// The underlying failure's user-presentable description. + public var message: String { + switch self { + case let .reportUnavailable(message), let .clearFailed(message): + message + } + } + } + + /// Identity of the inputs a data-issue scan depends on; see + /// `dataIssueScanInputs`. + struct DataIssueScanInputs: Equatable { + let year: Int + let report: YearReport? + let driftThreshold: DriftThreshold + } + + public private(set) var selectedYear: Int + public private(set) var report: YearReport? + public private(set) var loadState: LoadState = .idle + + /// Unresolved data-issue count for the selected year — the Resolve tab badge. + /// The full issue list lives on the view-scoped `ResolveModel`; only this + /// count is kept here because the badge must render before the Resolve tab + /// is ever materialized. + public private(set) var dataIssueCount = 0 + + /// The services every read/write funnels through. Exposed so sibling + /// view-scoped models can be built from the injected `report`. + let services: WhereServices + /// Persisted user intent. Exposed for the same reason as `services`. + let preferences: WherePreferences + /// The model's notion of "now", forwarded for calendar / missing-day math. + let now: @Sendable () -> Date + + /// Gregorian calendar in the current time zone — matches the day keys the + /// aggregator produces in `report.days`, so the missing-day math lines up. + let calendar: Calendar + + /// Long-lived subscription to `services.dataChangeUpdates()` while the scene + /// is active. `@ObservationIgnored` (plumbing, not UI state) and + /// `nonisolated(unsafe)` so `deinit` can cancel it; every other access is on + /// the main actor, and `deinit` runs with no other live references. + @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? + + private static let logger = WhereLog.channel(.session) + + /// Observed mirror of `preferences.driftThresholdMeters`, which isn't itself + /// observable (`WherePreferences` is a plain defaults wrapper — callers that + /// need observation mirror it). Kept in sync by the `driftThreshold` setter so + /// a change re-evaluates dependent views' `body`; without it `ResolutionView` + /// couldn't key its scan on the threshold and the Resolve list would drift out + /// of sync with the badge count. + private var driftThresholdStorage: DriftThreshold + + /// GPS border-drift detection threshold (device setting). The setter persists + /// it, forces a badge recount, and — through the observed mirror — re-keys + /// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on + /// its next unrelated load. The scanner cache is keyed by `(year, threshold)`, + /// so the recount and the list both recompute for the new threshold no matter + /// which of the two concurrent scans runs first. + public var driftThreshold: DriftThreshold { + get { driftThresholdStorage } + set { + guard newValue != driftThresholdStorage else { return } + driftThresholdStorage = newValue + preferences.driftThresholdMeters = newValue.rawValue + Task { await refreshDataIssueCount(force: true) } + } + } + + /// The inputs that determine a data-issue scan's result: the selected year, + /// the loaded report (any committed write re-pulls it; a year switch nils + /// then reloads it), and the drift threshold. `ResolutionView` keys its scan + /// `.task(id:)` on this, so the Resolve list re-scans on exactly the triggers + /// the badge count recomputes on — the two can't drift apart. + var dataIssueScanInputs: DataIssueScanInputs { + DataIssueScanInputs(year: selectedYear, report: report, driftThreshold: driftThreshold) + } + + /// Primary/secondary split of the current report, or an empty ranking while + /// nothing is loaded. + public var ranking: RegionRanking { + guard let report else { return RegionRanking(primary: [], secondary: []) } + return RegionRanking(report: report) + } + + /// Total distinct days with any tracked presence in the loaded year. + public var trackedDayCount: Int { + 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 viewing the + /// current year, since past years can't gain "today" coverage. The + /// derivation lives on `YearReport`; this just supplies "now" + the calendar. + public var missingDays: [MissingDayRange] { + report?.missingDayRanges(asOf: now(), calendar: calendar) ?? [] + } + + /// Total number of unlogged days behind `missingDays`. + public var missingDayCount: Int { + report?.missingDayCount(asOf: now(), calendar: calendar) ?? 0 + } + + /// The model's notion of "now", forwarded for calendar and missing-day math. + public var referenceDate: Date { + now() + } + + /// Start-of-day keys for days that still need logging in the loaded year. + public var missingDayKeys: Set { + report?.missingDayKeys(asOf: now(), calendar: 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. + public var daysInSelectedYear: Int { + calendar.dayCount(ofYear: selectedYear) + } + + /// Build a report model over an already-assembled service layer. `report` is + /// the preview/test seam: a non-nil value lands `loadState` at `.loaded` so + /// `#Preview`s render content synchronously without driving `activate()`. + public init( + services: WhereServices, + report: YearReport? = nil, + selectedYear: Int = WhereModel.currentYear, + preferences: WherePreferences = WherePreferences(), + now: @escaping @Sendable () -> Date = { Date() }, + ) { + self.services = services + self.report = report + self.selectedYear = selectedYear + self.preferences = preferences + self.now = now + driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) + ?? .default + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + self.calendar = calendar + loadState = report == nil ? .idle : .loaded + } + + deinit { + dataChangeTask?.cancel() + } + + /// Start observing committed writes and pull fresh data. Called by `MainTabs` + /// when the scene becomes active. Safe to call repeatedly — the subscription + /// is set up at most once until `deactivate()`. + public func activate() async { + observeDataChanges() + await refreshAll(forceDataIssueCount: false) + } + + /// Stop observing committed writes. Called by `MainTabs` when the scene goes + /// to the background, so a backgrounded scene drives no refreshes; the next + /// `activate()` re-subscribes and pulls (covering the background→foreground + /// gap). + public func deactivate() { + dataChangeTask?.cancel() + dataChangeTask = nil + } + + /// Subscribe to the store's data-change signal so the report and the badge + /// count refresh on any committed write — live GPS ingestion, CloudKit + /// remote imports, and the scene's own edits. Idempotent. + func observeDataChanges() { + guard dataChangeTask == nil else { return } + // Subscribe synchronously, before spawning the loop, so a write that + // commits the instant after this returns can't slip its ping in ahead of + // the subscription. + let updates = services.dataChangeUpdates() + dataChangeTask = Task { @MainActor [weak self] in + for await _ in updates { + guard let self else { break } + await refreshAll(forceDataIssueCount: true) + } + } + } + + public func select(year: Int) async { + guard year != selectedYear else { return } + Self.logger.info("Selected year \(year)") + selectedYear = year + // Drop the previous year's report so views fall back to their loading + // state instead of rendering stale data under the new year's label. + report = nil + await refreshAll(forceDataIssueCount: true) + } + + /// Pull a fresh year report *and* recompute the Resolve badge count — the + /// common case after any committed write or a (re)activation. `refresh()` + /// and `refreshDataIssueCount(force:)` stay separately callable rather than + /// folded together, because the drift-threshold change recomputes only the + /// count (no report re-pull); this just names the pairing the shared sites use. + func refreshAll(forceDataIssueCount: Bool) async { + await refresh() + await refreshDataIssueCount(force: forceDataIssueCount) + } + + /// Recompute the Resolve badge count for the selected year. Uses the cached + /// scan (shared with the Resolve list) unless `force`. + func refreshDataIssueCount(force: Bool) async { + // Capture the year this scan is for; the model is reentrant while + // awaiting, so a concurrent `select(year:)` could otherwise install a + // count under the wrong year's label. + let requestedYear = selectedYear + do { + let issues = try await services.resolution.issues( + year: requestedYear, + primaryRegions: ranking.primary.map(\.region), + // Read the observable mirror (same value the `dataIssueScanInputs` + // key the Resolve list scans on uses), so the badge recount and + // the list can't scan against different thresholds. + driftThresholdMeters: Double(driftThreshold.rawValue), + force: force, + ) + guard requestedYear == selectedYear else { return } + if dataIssueCount != issues.count { dataIssueCount = issues.count } + } catch { + // Surface the failure and keep the last good count rather than + // silently blanking the badge. + Self.logger.warning( + "Failed to scan for data issues: \(error.localizedDescription)", + ) + } + } + + public func refresh() async { + // Capture the year this fetch is for; the model is reentrant while + // awaiting `yearReport`, so a rapid second `select(year:)` could install + // a stale report under the newer year's label. + let requestedYear = selectedYear + // Only surface the loading state when there's nothing on screen yet — an + // initial load or a year switch (which nils `report` first). A background + // refresh keeps the current report visible (no spinner flicker), and the + // equality guards below make an unrelated commit a no-op. + if report == nil { loadState = .loading } + do { + let report = try await services.reports.yearReport(for: requestedYear) + guard requestedYear == selectedYear else { return } + let changed = self.report != report + if changed { self.report = report } + if loadState != .loaded { loadState = .loaded } + if changed { + Self.logger + .info("Year report loaded for \(requestedYear) (\(report.days.count) day(s))") + } + } catch { + guard requestedYear == selectedYear else { return } + loadState = .failed(.reportUnavailable(message: error.localizedDescription)) + Self.logger.warning( + "Failed to load year report for \(requestedYear): \(error.localizedDescription)", + ) + } + } + + /// Persist a single manual day. Throws on persistence failure so the caller + /// (the entry form) can keep itself open and show the error inline. + /// + /// No inline refresh: the committed write pings the store-change signal, so + /// `observeDataChanges()` re-pulls the report + badge count. + public func setManualDay(date: Date, regions: Set) async throws { + try await services.journal.addManualDay(date: date, regions: regions) + } + + /// Persist a manual day range. Throws on persistence failure (see + /// `setManualDay(date:regions:)`). + public func setManualDays( + from start: Date, + through end: Date, + regions: Set, + ) async throws { + try await services.journal.addManualDays(from: start, through: end, regions: regions) + } + + /// Authoritatively set a day's regions, *replacing* whatever was attributed + /// to it (the Elsewhere "fix this day" path). Throws on persistence failure. + public func overrideDay(date: Date, regions: Set) async throws { + try await services.journal.overrideDay(date: date, regions: regions) + } + + /// Undo a day's manual override/backfill, restoring the GPS-detected regions + /// (the relabel "reset to GPS" action). Throws on persistence failure. + public func clearManualDay(date: Date) async throws { + try await services.journal.clearManualDay(date: date) + } + + public func clearSelectedYear() async { + do { + // The committed write pings the store-change signal, so + // `observeDataChanges()` re-pulls; no inline refresh needed. + try await services.journal.clearYear(selectedYear) + } catch { + loadState = .failed(.clearFailed(message: error.localizedDescription)) + Self.logger.warning( + "Failed to clear year \(selectedYear): \(error.localizedDescription)", + ) + } + } + + /// The days in the loaded report whose presence includes `region`, sorted + /// ascending (matching `report.days`). Powers the Elsewhere drill-in list. + public func days(in region: Region) -> [DayPresence] { + guard let report else { return [] } + return report.days.filter { $0.regions.contains(region) } + } + + /// The raw coordinates recorded inside `region` during the selected year, + /// grouped by day, for the Elsewhere drill-in's map and place names. Logs and + /// returns empty on failure — the view renders nothing, but the failure still + /// surfaces in the log rather than passing silently as "no locations". + public func locations(in region: Region) async -> [RegionDayLocations] { + do { + return try await services.reports.locations(in: region, year: selectedYear) + } catch { + Self.logger.warning( + "Failed to load locations for \(region.rawValue) in \(selectedYear): \(error.localizedDescription)", + ) + return [] + } + } + + /// One representative coordinate per region for the selected year (the most + /// heavily sampled spot in each), for the Elsewhere cards' place-name teaser. + /// Logs and returns empty on failure (see `locations(in:)`). + public func representativeCoordinates() async -> [Region: Coordinate] { + do { + return try await services.reports.representativeCoordinates(for: selectedYear) + } catch { + Self.logger.warning( + "Failed to load representative coordinates for \(selectedYear): \(error.localizedDescription)", + ) + return [:] + } + } +} + +#if DEBUG + @_spi(Testing) extension YearReportModel { + /// Inject a badge count for previews/tests without seeding raw samples. + public func setDataIssueCount(_ count: Int) { + dataIssueCount = count + } + } +#endif diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 74175832..37fbfd73 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -57,32 +57,44 @@ ) } - // MARK: - Sessions (logged-in views) + // MARK: - Coordinator (always-on views) - /// A ready-to-render session with the sample report injected and - /// in-memory services behind it. Synchronous, so it drops straight into - /// `#Preview`. + /// A ready-to-render `WhereSession` coordinator over in-memory services, + /// for the always-on views that read `@Environment(WhereSession.self)` + /// (Settings, onboarding). It holds no report — that lives on + /// `YearReportModel` now — so the report/year previews take a + /// `*YearReportModel()` fixture instead. @MainActor public static func loadedSession() -> WhereSession { - WhereSession(services: previewServices(), report: sampleReport(), selectedYear: year) + WhereSession(services: previewServices()) } - /// An empty session (in-memory services, no data) for empty-state + // MARK: - Report models (scene / report + year views) + + /// A ready-to-render scene report model with the sample report injected + /// and in-memory services behind it. Synchronous, so it drops straight + /// into `#Preview`. + @MainActor + public static func loadedYearReportModel() -> YearReportModel { + YearReportModel(services: previewServices(), report: sampleReport(), selectedYear: year) + } + + /// An empty report model (in-memory services, no data) for empty-state /// previews. @MainActor - public static func emptySession() -> WhereSession { - WhereSession( + public static func emptyYearReportModel() -> YearReportModel { + YearReportModel( services: previewServices(), report: YearReport(year: year, days: [], totals: [:]), selectedYear: year, ) } - /// A session whose only tracked days are in `.other` — there's data, but - /// nothing ranks as "primary". Exercises the Primary tab's distinct + /// A report model whose only tracked days are in `.other` — there's data, + /// but nothing ranks as "primary". Exercises the Primary tab's distinct /// "nothing in your headline spots" state. @MainActor - public static func elsewhereOnlySession() -> WhereSession { + public static func elsewhereOnlyYearReportModel() -> YearReportModel { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! @@ -92,18 +104,18 @@ regions: [.other], ) } - return WhereSession( + return YearReportModel( services: previewServices(), report: YearReport(year: year, days: days, totals: [.other: days.count]), selectedYear: year, ) } - /// A session whose current year has several unlogged stretches before a - /// fixed "today", so missing-day detection and the Resolve tab have real - /// gaps to render. + /// A report model whose current year has several unlogged stretches + /// before a fixed "today", so missing-day detection has real gaps to + /// render. @MainActor - public static func missingDaysSession() -> WhereSession { + public static func missingDaysYearReportModel() -> YearReportModel { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! @@ -117,7 +129,7 @@ regions: [.california], ) } - return WhereSession( + return YearReportModel( services: previewServices(), report: YearReport(year: year, days: days, totals: [.california: days.count]), selectedYear: year, @@ -125,18 +137,16 @@ ) } - /// A session with injected data-resolution issues (one per category) for - /// Resolve tab previews. - @MainActor - public static func resolutionSession() -> WhereSession { + // MARK: - Resolve model (Resolve tab) + + /// One data-resolution issue per category, for Resolve tab previews/tests. + public static func sampleDataIssues() -> [any DataIssue] { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! let start = calendar.date(from: DateComponents(year: year, month: 3, day: 1))! let day2 = calendar.date(byAdding: .day, value: 1, to: start)! let day3 = calendar.date(byAdding: .day, value: 2, to: start)! - - let session = loadedSession() - session.setDataIssues([ + return [ MissingDaysIssue(range: MissingDayRange(start: start, end: start, dayCount: 1)), BorderDriftIssue( day: DayPresence(date: day2, regions: [.other]), @@ -147,14 +157,24 @@ earlierDay: DayPresence(date: day2, regions: [.california]), laterDay: DayPresence(date: day3, regions: [.newYork]), ), - ]) - return session + ] + } + + /// A Resolve model seeded (via the `@_spi(Testing)` seam) with one issue + /// per category, so Resolve previews/tests render a populated list without + /// raw samples to scan. Pass `seededWithIssues: false` for the empty state. + @MainActor + public static func resolveModel(seededWithIssues: Bool = true) -> ResolveModel { + let resolve = ResolveModel(services: previewServices(), preferences: WherePreferences()) + if seededWithIssues { resolve.setDataIssues(sampleDataIssues()) } + return resolve } // MARK: - Models (app-level shell) /// A ready-to-render app model with the sample report injected and - /// in-memory services behind it (so its `session` is built up front). + /// in-memory services behind it (so its `session` is built up front and + /// `MainTabs` seeds its `YearReportModel` with the sample report). /// Synchronous, so it drops straight into `#Preview`. @MainActor public static func loadedModel() -> WhereModel { diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index 067d8822..daea6457 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -12,7 +12,8 @@ struct CalendarView: View { /// region. `nil` shows every region's dots. var focusedRegion: Region? - @Environment(WhereSession.self) private var session + let report: YearReportModel + @Environment(\.dismiss) private var dismiss @State private var timelineTarget: TimelineMonthTarget? @@ -38,7 +39,7 @@ struct CalendarView: View { var body: some View { NavigationStack { Group { - if let report = session.report { + if let yearReport = report.report { Group { switch monthsLoad { case let .success(months): @@ -49,18 +50,18 @@ struct CalendarView: View { ProgressView(Strings.primaryLoading) } } - .task(id: calendarLoadID(report: report)) { - let result = loadCalendarMonths(from: report) + .task(id: calendarLoadID(report: yearReport)) { + let result = loadCalendarMonths(from: yearReport) guard !Task.isCancelled else { return } monthsLoad = result } - } else if session.loadState == .loading { + } else if report.loadState == .loading { ProgressView(Strings.primaryLoading) - } else if case let .failed(message) = session.loadState { + } else if case let .failed(error) = report.loadState { ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(message) + Text(error.message) } } else { ContentUnavailableView { @@ -70,7 +71,7 @@ struct CalendarView: View { } .onAppear { Self.logger.warning( - "Calendar opened without a year report (loadState: \(session.loadState))", + "Calendar opened without a year report (loadState: \(report.loadState))", ) } } @@ -83,8 +84,8 @@ struct CalendarView: View { } } .navigationDestination(item: $timelineTarget) { target in - PresenceTimelineList(scrollToMonth: target.startOfMonth) - .navigationTitle(Strings.timelineTitle(year: session.selectedYear)) + PresenceTimelineList(report: report, scrollToMonth: target.startOfMonth) + .navigationTitle(Strings.timelineTitle(year: report.selectedYear)) .navigationBarTitleDisplayMode(.inline) } } @@ -92,27 +93,27 @@ struct CalendarView: View { private var navigationTitle: String { if let focusedRegion { - Strings.calendarRegionTitle(region: focusedRegion, year: session.selectedYear) + Strings.calendarRegionTitle(region: focusedRegion, year: report.selectedYear) } else { - Strings.calendarTitle(year: session.selectedYear) + Strings.calendarTitle(year: report.selectedYear) } } - private func calendarLoadID(report: YearReport) -> CalendarLoadID { + private func calendarLoadID(report yearReport: YearReport) -> CalendarLoadID { CalendarLoadID( - report: report, - missingDayKeys: session.missingDayKeys, - referenceDay: session.calendar.startOfDay(for: session.referenceDate), + report: yearReport, + missingDayKeys: report.missingDayKeys, + referenceDay: report.calendar.startOfDay(for: report.referenceDate), focusedRegion: focusedRegion, ) } - private func loadCalendarMonths(from report: YearReport) -> Result<[CalendarMonth], Error> { + private func loadCalendarMonths(from yearReport: YearReport) -> Result<[CalendarMonth], Error> { Result { - try report.calendarMonths( - calendar: session.calendar, - referenceDate: session.referenceDate, - missingDates: session.missingDayKeys, + try yearReport.calendarMonths( + calendar: report.calendar, + referenceDate: report.referenceDate, + missingDates: report.missingDayKeys, focusedRegion: focusedRegion, ) } @@ -317,22 +318,18 @@ private struct DayCell: View { #if DEBUG #Preview("Loaded") { - CalendarView() - .environment(PreviewSupport.loadedSession()) + CalendarView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Focused") { - CalendarView(focusedRegion: .california) - .environment(PreviewSupport.loadedSession()) + CalendarView(focusedRegion: .california, report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - CalendarView() - .environment(PreviewSupport.emptySession()) + CalendarView(report: PreviewSupport.emptyYearReportModel()) } #Preview("Missing days") { - CalendarView() - .environment(PreviewSupport.missingDaysSession()) + CalendarView(report: PreviewSupport.missingDaysYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 10c2b29c..9403235f 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -5,13 +5,14 @@ import WhereCore /// year — "California, Jan 1 – Feb 3", "New York, Feb 3 – Mar 10", and so on. /// Presented as a sheet from the Primary tab. struct PresenceTimelineView: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { - PresenceTimelineList() - .navigationTitle(Strings.timelineTitle(year: session.selectedYear)) + PresenceTimelineList(report: report) + .navigationTitle(Strings.timelineTitle(year: report.selectedYear)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -26,12 +27,12 @@ struct PresenceTimelineView: View { /// When `scrollToMonth` is set, scrolls to the first stint overlapping that /// month on appear. struct PresenceTimelineList: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel var scrollToMonth: Date? private var stints: [RegionStint] { - guard let report = session.report else { return [] } + guard let report = report.report else { return [] } return PresenceTimeline.stints(from: report) } @@ -122,7 +123,6 @@ private struct StintRow: View { #if DEBUG #Preview { - PresenceTimelineView() - .environment(PreviewSupport.loadedSession()) + PresenceTimelineView(report: PreviewSupport.loadedYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 6a734818..807cdcf1 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -4,7 +4,7 @@ import WhereCore /// Home tab: the regions you spend the most days in for the selected year, /// shown as prominent Liquid Glass cards. struct PrimaryView: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel @State private var showingTimeline = false @State private var showingCalendar = false @@ -61,23 +61,20 @@ struct PrimaryView: View { .accessibilityIdentifier("where_calendar_button") } ToolbarItem(placement: .topBarTrailing) { - YearSelector() + YearSelector(report: report) } } } .onAppear { tilt.start() } .onDisappear { tilt.stop() } .sheet(isPresented: $showingTimeline) { - PresenceTimelineView() - .environment(session) + PresenceTimelineView(report: report) } .sheet(isPresented: $showingCalendar) { - CalendarView() - .environment(session) + CalendarView(report: report) } .sheet(item: $calendarFocus) { focus in - CalendarView(focusedRegion: focus.region) - .environment(session) + CalendarView(focusedRegion: focus.region, report: report) } } @@ -98,22 +95,22 @@ struct PrimaryView: View { @ViewBuilder private var screen: some View { - switch session.loadState { - case .loading where session.report == nil: + switch report.loadState { + case .loading where report.report == nil: ProgressView(Strings.primaryLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) - case let .failed(message): + case let .failed(error): ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(message) + Text(error.message) } case .idle, .loaded, .loading: - if session.ranking.primary.isEmpty { + if report.ranking.primary.isEmpty { // Distinguish "nothing tracked at all" from "tracked days // exist, but only in non-headline regions" (e.g. all in // `.other`) — otherwise the latter wrongly reads as empty. - if session.trackedDayCount == 0 { + if report.trackedDayCount == 0 { emptyState } else { elsewhereOnlyState @@ -128,15 +125,15 @@ struct PrimaryView: View { ScrollView { GlassEffectContainer(spacing: UIConstants.Spacings.xxLarge) { VStack(spacing: UIConstants.Spacings.xxLarge) { - ForEach(session.ranking.primary) { item in + ForEach(report.ranking.primary) { item in Button { calendarFocus = CalendarFocus(region: item.region) } label: { RegionSummaryCard( regionDays: item, interactive: true, - yearLength: session.daysInSelectedYear, - year: session.selectedYear, + yearLength: report.daysInSelectedYear, + year: report.selectedYear, tilt: tilt, ) } @@ -154,7 +151,7 @@ struct PrimaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.primaryEmptyTitle(year: session.selectedYear), systemImage: "map") + Label(Strings.primaryEmptyTitle(year: report.selectedYear), systemImage: "map") } description: { Text(Strings.primaryEmptyDescription) } @@ -164,7 +161,7 @@ struct PrimaryView: View { ContentUnavailableView { Label(Strings.primaryElsewhereOnlyTitle, systemImage: "globe.americas") } description: { - Text(Strings.primaryElsewhereOnlyDescription(count: session.trackedDayCount)) + Text(Strings.primaryElsewhereOnlyDescription(count: report.trackedDayCount)) } } } @@ -233,22 +230,18 @@ private struct PassportMasthead: View { #if DEBUG #Preview("Loaded") { - PrimaryView() - .environment(PreviewSupport.loadedSession()) + PrimaryView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - PrimaryView() - .environment(PreviewSupport.emptySession()) + PrimaryView(report: PreviewSupport.emptyYearReportModel()) } #Preview("Missing days") { - PrimaryView() - .environment(PreviewSupport.missingDaysSession()) + PrimaryView(report: PreviewSupport.missingDaysYearReportModel()) } #Preview("Elsewhere only") { - PrimaryView() - .environment(PreviewSupport.elsewhereOnlySession()) + PrimaryView(report: PreviewSupport.elsewhereOnlyYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 602a168f..951e3704 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -21,7 +21,7 @@ struct RegionSummaryCard: View { /// Calendar days in the year being summarized; the ambient bar is drawn as /// a fraction of this. Callers pass the selected year's real length - /// (`WhereSession.daysInSelectedYear`); the default is only for previews. + /// (`YearReportModel.daysInSelectedYear`); the default is only for previews. var yearLength = 365 /// The calendar year being summarized, inked onto the entry stamp. Callers diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index 50803842..dd02a10d 100644 --- a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift @@ -4,10 +4,11 @@ import WhereCore /// Detail screen for an abrupt location change: explains the likely missing /// travel day and offers relabel paths for either adjacent day. struct AbruptChangeDetailView: View { - @Environment(WhereSession.self) private var session @Environment(\.dismiss) private var dismiss let issue: any DataIssue + let report: YearReportModel + let resolve: ResolveModel var body: some View { if let payload = travelDayPayload { @@ -19,7 +20,11 @@ struct AbruptChangeDetailView: View { Section(Strings.resolutionAbruptDetailEarlierHeader) { daySummary(payload.earlier) NavigationLink { - DayRelabelView(day: payload.earlier, initialRegions: payload.suggested) + DayRelabelView( + day: payload.earlier, + report: report, + initialRegions: payload.suggested, + ) } label: { Text(Strings.resolutionAbruptDetailRelabelEarlier) } @@ -28,7 +33,11 @@ struct AbruptChangeDetailView: View { Section(Strings.resolutionAbruptDetailLaterHeader) { daySummary(payload.later) NavigationLink { - DayRelabelView(day: payload.later, initialRegions: payload.suggested) + DayRelabelView( + day: payload.later, + report: report, + initialRegions: payload.suggested, + ) } label: { Text(Strings.resolutionAbruptDetailRelabelLater) } @@ -37,7 +46,7 @@ struct AbruptChangeDetailView: View { Section { Button(Strings.resolutionAbruptDetailBothRight) { Task { - await session.dismiss(issue) + await resolve.dismiss(issue) dismiss() } } @@ -88,8 +97,9 @@ struct AbruptChangeDetailView: View { regions: [.newYork], ), ), + report: PreviewSupport.loadedYearReportModel(), + resolve: PreviewSupport.resolveModel(), ) } - .environment(PreviewSupport.loadedSession()) } #endif diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index d823762c..378a6acf 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -2,32 +2,66 @@ import SwiftUI import WhereCore /// Lists data-quality issues for the selected year and routes each to its fix -/// flow. Badge count comes from `session.dataIssueCount`. +/// flow. The scene's `YearReportModel` owns the badge *count*; this view owns the +/// list via a view-scoped `ResolveModel`, re-scanned from a `.task(id:)` keyed +/// on the report's `dataIssueScanInputs` (so it refreshes on appear, on any +/// committed write, on a year switch, and on a drift-threshold change — the same +/// triggers that recompute the badge count). struct ResolutionView: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel + @State private var resolve: ResolveModel + + init(report: YearReportModel) { + self.report = report + _resolve = State(initialValue: ResolveModel( + services: report.services, + preferences: report.preferences, + )) + } + + #if DEBUG + /// Preview/test seam: inject a `ResolveModel` seeded via + /// `@_spi(Testing) setDataIssues` so the list renders without raw samples. + init(report: YearReportModel, resolve: ResolveModel) { + self.report = report + _resolve = State(initialValue: resolve) + } + #endif var body: some View { NavigationStack { screen .navigationTitle(Strings.resolutionTitle) .navigationBarTitleDisplayMode(.inline) + .task(id: report.dataIssueScanInputs) { + await resolve.load( + year: report.selectedYear, + primaryRegions: report.ranking.primary.map(\.region), + ) + } } } @ViewBuilder private var screen: some View { - switch session.loadState { - case .loading where session.report == nil: + switch report.loadState { + case .loading where report.report == nil: ProgressView(Strings.primaryLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) - case let .failed(message): + case let .failed(error): ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(message) + Text(error.message) } case .idle, .loaded, .loading: - if session.dataIssues.isEmpty { + if !resolve.hasLoaded { + // The report is loaded but this tab's own scan hasn't landed + // yet; show a spinner rather than flash "all clear" under a + // non-zero badge. + ProgressView(Strings.primaryLoading) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if resolve.dataIssues.isEmpty { ContentUnavailableView { Label(Strings.resolutionEmptyTitle, systemImage: "checkmark.seal") } description: { @@ -46,7 +80,7 @@ struct ResolutionView: View { if !issues.isEmpty { Section { ForEach(issues, id: \.id) { issue in - IssueRow(issue: issue) + IssueRow(issue: issue, report: report, resolve: resolve) } } header: { Label( @@ -61,7 +95,7 @@ struct ResolutionView: View { } private func issues(in category: DataIssueCategory) -> [any DataIssue] { - session.dataIssues.filter { $0.category == category } + resolve.dataIssues.filter { $0.category == category } } private func sectionIcon(_ category: DataIssueCategory) -> String { @@ -74,9 +108,9 @@ struct ResolutionView: View { } private struct IssueRow: View { - @Environment(WhereSession.self) private var session - let issue: any DataIssue + let report: YearReportModel + let resolve: ResolveModel var body: some View { NavigationLink { @@ -96,7 +130,7 @@ private struct IssueRow: View { .swipeActions(edge: .trailing, allowsFullSwipe: true) { if issue.isDismissible { Button(role: .destructive) { - Task { await session.dismiss(issue) } + Task { await resolve.dismiss(issue) } } label: { Label(Strings.resolutionDismiss, systemImage: "xmark") } @@ -108,11 +142,11 @@ private struct IssueRow: View { private var destination: some View { switch issue.resolution { case let .backfill(range): - ManualDayEntryView(prefill: range) + ManualDayEntryView(report: report, prefill: range) case let .relabelDay(day, suggested, _): - DayRelabelView(day: day, initialRegions: suggested) + DayRelabelView(day: day, report: report, initialRegions: suggested) case .markTravelDay: - AbruptChangeDetailView(issue: issue) + AbruptChangeDetailView(issue: issue, report: report, resolve: resolve) } } @@ -154,12 +188,16 @@ private struct IssueRow: View { #if DEBUG #Preview("Loaded") { - ResolutionView() - .environment(PreviewSupport.resolutionSession()) + ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: PreviewSupport.resolveModel(), + ) } #Preview("Empty") { - ResolutionView() - .environment(PreviewSupport.loadedSession()) + ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: PreviewSupport.resolveModel(seededWithIssues: false), + ) } #endif diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift new file mode 100644 index 00000000..2747bb53 --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -0,0 +1,103 @@ +import Foundation +import LogKit +import Observation +import WhereCore + +/// View-scoped model for the Resolve tab: the full list of unresolved +/// data-quality issues for the selected year, plus the dismiss action. Owned as +/// `@State` by `ResolutionView`, so it's created when the Resolve tab is first +/// shown and torn down with it. +/// +/// The tab-bar badge *count* lives on the scene-scoped `YearReportModel` instead +/// (it must render before this tab is ever materialized); this model owns the +/// list the screen shows. `ResolutionView` drives `load(year:primaryRegions:)` +/// from a `.task(id:)` keyed on the report's `dataIssueScanInputs`, so the list +/// re-scans on appear, on any committed write, on a year switch, and on a +/// drift-threshold change — all while sharing the scanner's cache with the badge +/// recount. +@MainActor +@Observable +public final class ResolveModel { + /// Unresolved data-quality issues for the selected year, grouped and sorted + /// by the scanner. + public private(set) var dataIssues: [any DataIssue] = [] + + /// Whether the first scan has completed (or a fixture was seeded). Until it + /// has, `ResolutionView` shows a spinner rather than the "all clear" empty + /// state — otherwise a populated tab (whose badge already scanned) would + /// flash empty for the frame before this model's own `load` lands. Once true + /// it stays true, so later re-scans update the list in place without a + /// spinner flicker. + public private(set) var hasLoaded = false + + private let services: WhereServices + private let preferences: WherePreferences + private static let logger = WhereLog.channel(.session) + + #if DEBUG + /// Set by the `@_spi(Testing)` seeder so `load(...)` doesn't clobber + /// preview/test fixtures with an empty scan of a store that has no raw + /// samples. Never compiled into release. + private var isSeeded = false + #endif + + public init(services: WhereServices, preferences: WherePreferences) { + self.services = services + self.preferences = preferences + } + + /// Scan for data issues in `year`. Uses the cached scan (shared with the + /// badge recount) unless the store has changed since. `primaryRegions` + /// tunes the border-drift relabel suggestions. + public func load(year: Int, primaryRegions: [Region]) async { + #if DEBUG + if isSeeded { return } + #endif + do { + let issues = try await services.resolution.issues( + year: year, + primaryRegions: primaryRegions, + driftThresholdMeters: Double(preferences.driftThresholdMeters), + force: false, + ) + dataIssues = issues + } catch { + // Surface the failure and keep the last good list rather than + // silently blanking the tab (which would read as "all clear"). + Self.logger.warning( + "Failed to scan for data issues: \(error.localizedDescription)", + ) + } + // Mark loaded even on failure so the view leaves the spinner (the error + // was logged and the last good list preserved); a stuck spinner would be + // its own bug. + hasLoaded = true + } + + public func dismiss(_ issue: any DataIssue) async { + guard issue.isDismissible else { return } + do { + try await services.journal.dismissIssue(key: issue.id.storageKey) + // Optimistically drop the row for instant feedback; the committed + // write pings the store-change signal, so the scene's `YearReportModel` + // recomputes the badge count a beat later. + dataIssues.removeAll { $0.id == issue.id } + } catch { + Self.logger.warning( + "Failed to dismiss data issue \(issue.id.storageKey): \(error.localizedDescription)", + ) + } + } +} + +#if DEBUG + @_spi(Testing) extension ResolveModel { + /// Inject issues for previews/tests without seeding raw samples. Marks the + /// model seeded so a subsequent `load(...)` leaves the fixture in place. + public func setDataIssues(_ issues: [any DataIssue]) { + dataIssues = issues + isSeeded = true + hasLoaded = true + } + } +#endif diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index b2f7c512..d42fa9f8 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -39,25 +39,19 @@ public struct RootView: View { splash: { LaunchSplashView() }, failure: { LifecycleFailureView(failure: $0, retry: $1) }, ) { - TabView { - Tab(Strings.tabPrimary, systemImage: "star.fill") { - PrimaryView() - } - - Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { - SecondaryView() - } - - Tab(Strings.tabResolution, systemImage: "checklist") { - ResolutionView() - } - .badge(model.session?.dataIssueCount ?? 0) - - Tab(Strings.tabSettings, systemImage: "gearshape.fill") { - SettingsView() - } + // At `.ready` the session is always present; `MainTabs` owns the + // scene-scoped `YearReportModel` and gets a fresh one whenever a reset + // rebuilds the session. Keyed on the session's monotonic `id` (never + // reused within the process) rather than its address, so a rebuilt + // session can't collide with a freed one and skip the rebuild. + if let session = model.session { + MainTabs( + session: session, + initialReport: model.initialReport, + selectedYear: model.initialSelectedYear, + ) + .id(session.id) } - .tabBarMinimizeBehavior(.onScrollDown) } .environment(model) // The logged-in session appears once `open-store` builds it. Injected diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index d377032d..8425c489 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -7,17 +7,18 @@ import WhereCore /// be removed. The raw GPS samples are left untouched (see /// `DayJournal.overrideDay`), so the fix is reversible. struct DayRelabelView: View { - @Environment(WhereSession.self) private var session @Environment(\.dismiss) private var dismiss let day: DayPresence + let report: YearReportModel @State private var regionSelection: RegionSelectionState @State private var saveError = SaveErrorAlertState() @State private var isSaving = false - init(day: DayPresence, initialRegions: Set? = nil) { + init(day: DayPresence, report: YearReportModel, initialRegions: Set? = nil) { self.day = day + self.report = report _regionSelection = State( initialValue: RegionSelectionState(selectedRegions: initialRegions ?? day.regions), ) @@ -82,7 +83,7 @@ struct DayRelabelView: View { saveError.message = nil Task { do { - try await session.overrideDay( + try await report.overrideDay( date: day.date, regions: regionSelection.selectedRegions, ) @@ -100,7 +101,7 @@ struct DayRelabelView: View { saveError.message = nil Task { do { - try await session.clearManualDay(date: day.date) + try await report.clearManualDay(date: day.date) dismiss() } catch { // Keep the form up so the user can retry; nothing was cleared. @@ -114,8 +115,10 @@ struct DayRelabelView: View { #if DEBUG #Preview("Other region") { NavigationStack { - DayRelabelView(day: DayPresence(date: .now, regions: [.other])) - .environment(PreviewSupport.loadedSession()) + DayRelabelView( + day: DayPresence(date: .now, regions: [.other]), + report: PreviewSupport.loadedYearReportModel(), + ) } } #endif diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 0a2ed048..681a5e04 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -8,9 +8,8 @@ import WhereCore /// each tappable to correct a wrong attribution via `DayRelabelView` and /// labeled with the place it reverse-geocodes to. struct RegionDaysView: View { - @Environment(WhereSession.self) private var session - let region: Region + let report: YearReportModel /// Raw per-day coordinates for this region, loaded asynchronously from the /// store. Drives the map pins and each row's representative point. Empty @@ -19,7 +18,7 @@ struct RegionDaysView: View { @State private var coordinatesByDay: [Date: [Coordinate]] = [:] private var days: [DayPresence] { - session.days(in: region) + report.days(in: region) } var body: some View { @@ -28,7 +27,7 @@ struct RegionDaysView: View { .navigationBarTitleDisplayMode(.inline) // Keyed on the report (not just the year) so the map reloads after a // relabel changes which days count for this region. - .task(id: session.report) { await loadLocations() } + .task(id: report.report) { await loadLocations() } } private func loadLocations() async { @@ -36,7 +35,7 @@ struct RegionDaysView: View { // GPS coordinates are not, so a relabeled-away day could otherwise keep // a stale pin on the map. Restrict pins/points to days still in the list. let listedDates = Set(days.map(\.date)) - let locations = await session.locations(in: region) + let locations = await report.locations(in: region) .filter { listedDates.contains($0.date) } guard !Task.isCancelled else { return } coordinatesByDay = Dictionary( @@ -98,7 +97,7 @@ struct RegionDaysView: View { Section { ForEach(days, id: \.date) { day in NavigationLink { - DayRelabelView(day: day) + DayRelabelView(day: day, report: report) } label: { DayRow(day: day, coordinate: coordinatesByDay[day.date]?.first) } @@ -202,8 +201,7 @@ private struct DayRow: View { #if DEBUG #Preview { NavigationStack { - RegionDaysView(region: .other) - .environment(PreviewSupport.elsewhereOnlySession()) + RegionDaysView(region: .other, report: PreviewSupport.elsewhereOnlyYearReportModel()) } } #endif diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index d08c4b38..634bbe5b 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -4,7 +4,7 @@ import WhereCore /// Elsewhere tab: every region outside your primary spots, shown as compact /// Liquid Glass cards for the selected year. struct SecondaryView: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel /// Reverse-geocoded "where" teaser per region, loaded asynchronously so /// each card can show the place you turned up most. Empty in @@ -17,20 +17,20 @@ struct SecondaryView: View { .navigationTitle(Strings.secondaryTitle) .toolbar { ToolbarItem(placement: .topBarTrailing) { - YearSelector() + YearSelector(report: report) } } } - .task(id: session.report) { await loadPlaceNames() } + .task(id: report.report) { await loadPlaceNames() } } /// Pick each secondary region's most-sampled spot and reverse-geocode it, /// so the cards gain a "Paris, France"-style teaser. One geocode per /// region; results are cached by `LocationNamer`. private func loadPlaceNames() async { - let coordinates = await session.representativeCoordinates() + let coordinates = await report.representativeCoordinates() var names: [Region: String] = [:] - for item in session.ranking.secondary { + for item in report.ranking.secondary { guard let coordinate = coordinates[item.region] else { continue } names[item.region] = await LocationNamer.shared.name(for: coordinate) } @@ -43,18 +43,18 @@ struct SecondaryView: View { @ViewBuilder private var screen: some View { - switch session.loadState { - case .loading where session.report == nil: + switch report.loadState { + case .loading where report.report == nil: ProgressView(Strings.secondaryLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) - case let .failed(message): + case let .failed(error): ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(message) + Text(error.message) } case .idle, .loaded, .loading: - if session.ranking.secondary.isEmpty { + if report.ranking.secondary.isEmpty { emptyState } else { content @@ -65,24 +65,24 @@ struct SecondaryView: View { private var content: some View { ScrollView { VStack(alignment: .leading, spacing: UIConstants.Spacings.xLarge) { - Text(Strings.secondaryHeader(year: session.selectedYear)) + Text(Strings.secondaryHeader(year: report.selectedYear)) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) GlassEffectContainer(spacing: UIConstants.Spacings.large) { VStack(spacing: UIConstants.Spacings.large) { - ForEach(session.ranking.secondary) { item in + ForEach(report.ranking.secondary) { item in NavigationLink { - RegionDaysView(region: item.region) + RegionDaysView(region: item.region, report: report) } label: { RegionSummaryCard( regionDays: item, caption: caption(for: item), places: placeNames[item.region], compact: true, - yearLength: session.daysInSelectedYear, - year: session.selectedYear, + yearLength: report.daysInSelectedYear, + year: report.selectedYear, ) } .buttonStyle(.plain) @@ -110,12 +110,10 @@ struct SecondaryView: View { #if DEBUG #Preview("Loaded") { - SecondaryView() - .environment(PreviewSupport.loadedSession()) + SecondaryView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - SecondaryView() - .environment(PreviewSupport.emptySession()) + SecondaryView(report: PreviewSupport.emptyYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift new file mode 100644 index 00000000..4b4ced1a --- /dev/null +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -0,0 +1,108 @@ +import Foundation +import LogKit +import Observation +import WhereCore + +/// View-scoped model for the Settings backup section: export/import progress and +/// the error alert. Owned as `@State` by `SettingsView`, so it's created when the +/// Settings tab is first shown and torn down with it — nothing here needs to +/// outlive the screen that drives it. +@MainActor +@Observable +public final class BackupModel { + /// Where a backup export/import is in its lifecycle, so the UI can show a + /// spinner and disable the relevant row while work is in flight. + public enum BackupState: Equatable { + case idle + case exporting + case importing + } + + public private(set) var backupState: BackupState = .idle + + /// Fraction (`0...1`) of the in-flight import that has been written, for a + /// determinate progress bar. Reset to `0` whenever an import isn't running. + public private(set) var backupProgress: Double = 0 + + /// Last backup failure, surfaced as an alert. Mutable so the alert binding + /// can clear it on dismiss. + public var backupError: String? + + /// Drives the backup-error alert. Reads `true` while `backupError` holds a + /// message and clears it when dismissed, so the view can bind straight to it + /// (`$backup.isShowingBackupError`). `backupError` stays the single source of + /// truth. + public var isShowingBackupError: Bool { + get { backupError != nil } + set { if !newValue { backupError = nil } } + } + + private let services: WhereServices + private static let logger = WhereLog.channel(.session) + + public init(services: WhereServices) { + self.services = services + } + + /// Build a backup `.zip` of the entire database and return its URL for the + /// share sheet, or `nil` if the export failed (in which case `backupError` is + /// set). The `BackupCoordinator` owns the temporary file's lifecycle — it + /// reclaims the previous export's directory when the next export starts. + public func exportBackup() async -> URL? { + backupState = .exporting + defer { backupState = .idle } + do { + let url = try await services.backup.exportBackup() + Self.logger.info("Exported backup archive") + return url + } catch { + backupError = error.localizedDescription + Self.logger.warning("Backup export failed: \(error.localizedDescription)") + return nil + } + } + + /// Import a backup file with the chosen merge/replace strategy. Returns the + /// import summary on success, or `nil` on failure (with `backupError` set). + /// The committed import pings the store-change signal, so the scene's + /// `YearReportModel` re-pulls the report + badge count — no inline refresh here. + public func importBackup( + from url: URL, + strategy: BackupCoordinator.ImportStrategy, + ) async -> BackupCoordinator.ImportSummary? { + backupState = .importing + backupProgress = 0 + defer { + backupState = .idle + backupProgress = 0 + } + + // The backup coordinator reports progress from its own executor; funnel + // it through an ordered stream and apply it on the main actor so SwiftUI + // sees in-order, hop-free updates. + let (progress, continuation) = AsyncStream.makeStream() + let observer = Task { @MainActor [weak self] in + for await fraction in progress { + self?.backupProgress = fraction + } + } + defer { observer.cancel() } + + do { + let summary = try await services.backup.importBackup(from: url, strategy: strategy) { + continuation.yield($0) + } + continuation.finish() + await observer.value + Self.logger.info( + "Imported backup (\(summary.sampleCount) samples, \(summary.evidenceCount) evidence, \(summary.manualDayCount) manual days, \(summary.dismissedIssueCount) dismissals)", + ) + return summary + } catch { + continuation.finish() + backupError = error.localizedDescription + Self.logger.warning("Backup import failed: \(error.localizedDescription)") + return nil + } + } +} diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 73029d01..2ac42847 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -6,7 +6,8 @@ import WhereCore /// and unions with whatever GPS recorded (see /// `DayJournal.addManualDay` / `addManualDays`). struct ManualDayEntryView: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel + @Environment(\.dismiss) private var dismiss private enum EntryMode: Hashable, CaseIterable, Identifiable { @@ -34,7 +35,8 @@ struct ManualDayEntryView: View { /// 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) { + init(report: YearReportModel, prefill: MissingDayRange? = nil) { + self.report = report guard let prefill else { return } _mode = State(initialValue: prefill.dayCount > 1 ? .range : .singleDay) _startDate = State(initialValue: prefill.start) @@ -150,12 +152,12 @@ struct ManualDayEntryView: View { do { switch mode { case .singleDay: - try await session.setManualDay( + try await report.setManualDay( date: startDate, regions: regionSelection.selectedRegions, ) case .range: - try await session.setManualDays( + try await report.setManualDays( from: startDate, through: endDate, regions: regionSelection.selectedRegions, @@ -174,19 +176,20 @@ struct ManualDayEntryView: View { #if DEBUG #Preview("Default") { NavigationStack { - ManualDayEntryView() + ManualDayEntryView(report: PreviewSupport.loadedYearReportModel()) } - .environment(PreviewSupport.loadedSession()) } #Preview("Prefill range") { NavigationStack { - ManualDayEntryView(prefill: MissingDayRange( - start: Date(timeIntervalSince1970: 0), - end: Date(timeIntervalSince1970: 86400 * 4), - dayCount: 5, - )) + ManualDayEntryView( + report: PreviewSupport.missingDaysYearReportModel(), + prefill: MissingDayRange( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 86400 * 4), + dayCount: 5, + ), + ) } - .environment(PreviewSupport.missingDaysSession()) } #endif diff --git a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift new file mode 100644 index 00000000..0fd04f68 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift @@ -0,0 +1,162 @@ +import Foundation +import LogKit +import Observation +import WhereCore + +/// View-scoped model for the Settings reminder + daily-summary section: the +/// enable toggles, the time pickers, and whether notifications are authorized. +/// Owned as `@State` by `SettingsView`, so it's created when the Settings tab is +/// first shown and torn down with it. +/// +/// This is the *editing* surface. The launch-time / foreground *application* of +/// the same schedules (reading `WherePreferences` and pushing to the reconcilers) +/// stays on the always-on `WhereSession` coordinator, since it must run whether +/// or not Settings is on screen. Both write through the shared `WherePreferences` +/// (the single source of truth): an edit here persists and reconciles the +/// scheduler live; the coordinator re-applies the persisted intent every launch +/// and foreground. +@MainActor +@Observable +public final class RemindersSettingsModel { + /// Whether the daily "log before the day ends" reminder is enabled. Persists + /// across launches; the setter persists the intent and reconciles the + /// schedule. + public var remindersEnabled: Bool { + get { remindersEnabledStorage } + set { + guard newValue != remindersEnabledStorage else { return } + remindersEnabledStorage = newValue + preferences.remindersEnabled = newValue + Task { await applyReminderConfiguration() } + } + } + + /// Time of day the daily reminder fires. Persists and reconciles on change. + public var reminderTime: ReminderTime { + get { reminderTimeStorage } + set { + guard newValue != reminderTimeStorage else { return } + reminderTimeStorage = newValue + preferences.reminderTime = newValue + Task { await applyReminderConfiguration() } + } + } + + /// `Date`-typed projection of `reminderTime` for the Settings `DatePicker`, + /// which works in `Date`. Lets the view bind `$reminders.reminderTimeOfDay` + /// directly; writes round-trip back into `reminderTime`. + public var reminderTimeOfDay: Date { + get { + calendar.date( + bySettingHour: reminderTime.hour, + minute: reminderTime.minute, + second: 0, + of: now(), + ) ?? now() + } + set { + let components = calendar.dateComponents([.hour, .minute], from: newValue) + reminderTime = ReminderTime( + hour: components.hour ?? ReminderTime.defaultEvening.hour, + minute: components.minute ?? ReminderTime.defaultEvening.minute, + ) + } + } + + /// Whether the daily summary recap notification is enabled. Persists across + /// launches; the setter persists the intent and reconciles the summary. + public var summaryEnabled: Bool { + get { summaryEnabledStorage } + set { + guard newValue != summaryEnabledStorage else { return } + summaryEnabledStorage = newValue + preferences.summaryEnabled = newValue + Task { await applySummaryConfiguration() } + } + } + + /// Time of day the daily summary fires. Persists and reconciles on change. + public var summaryTime: ReminderTime { + get { summaryTimeStorage } + set { + guard newValue != summaryTimeStorage else { return } + summaryTimeStorage = newValue + preferences.summaryTime = newValue + Task { await applySummaryConfiguration() } + } + } + + /// `Date`-typed projection of `summaryTime`, mirroring `reminderTimeOfDay`. + public var summaryTimeOfDay: Date { + get { + calendar.date( + bySettingHour: summaryTime.hour, + minute: summaryTime.minute, + second: 0, + of: now(), + ) ?? now() + } + set { + let components = calendar.dateComponents([.hour, .minute], from: newValue) + summaryTime = ReminderTime( + hour: components.hour ?? ReminderTime.defaultMorning.hour, + minute: components.minute ?? ReminderTime.defaultMorning.minute, + ) + } + } + + /// Whether the system has granted notification permission. Lets the Settings + /// UI route the user to the system Settings app when they've enabled + /// reminders/summary but denied permission. Refreshed on appear and after + /// each configuration apply. + public private(set) var notificationsAuthorized = false + + /// Observed backing storage for the reminder toggle/time. The public computed + /// properties layer persistence + reconciliation onto their setters, which a + /// stored property can't express. + private var remindersEnabledStorage: Bool + private var reminderTimeStorage: ReminderTime + /// Observed backing storage for the summary toggle/time, mirroring the + /// reminder storage above. + private var summaryEnabledStorage: Bool + private var summaryTimeStorage: ReminderTime + + private let services: WhereServices + private let preferences: WherePreferences + private let now: @Sendable () -> Date + private let calendar: Calendar + private static let logger = WhereLog.channel(.session) + + public init( + services: WhereServices, + preferences: WherePreferences, + now: @escaping @Sendable () -> Date = { Date() }, + ) { + self.services = services + self.preferences = preferences + self.now = now + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + self.calendar = calendar + remindersEnabledStorage = preferences.remindersEnabled + reminderTimeStorage = preferences.reminderTime + summaryEnabledStorage = preferences.summaryEnabled + summaryTimeStorage = preferences.summaryTime + } + + /// Refresh whether the system has granted notification permission. Called + /// when Settings appears, in case the user changed it in the Settings app. + public func refreshNotificationAuthorization() async { + notificationsAuthorized = await services.reminders.isAuthorized() + } + + private func applyReminderConfiguration() async { + await services.reminders.configure(enabled: remindersEnabled, time: reminderTime) + await refreshNotificationAuthorization() + } + + private func applySummaryConfiguration() async { + await services.summary.configure(enabled: summaryEnabled, time: summaryTime) + await refreshNotificationAuthorization() + } +} diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index b889ce19..34911518 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -12,8 +12,15 @@ import WhereCore /// whole-database backup export/import, and the destructive "erase a year" /// action. struct SettingsView: View { - // Most settings live on the logged-in session; `model` is kept only to - // drive the reset sequence (which rebuilds the session from scratch). + // The scene's report model drives the year-scoped rows (clear-year, drift + // threshold); the always-on `WhereSession` coordinator (environment) drives + // tracking/permission + the DEBUG inspector; `model` (environment) drives + // the reset sequence (which rebuilds the session from scratch). The reminder + // and backup editing surfaces are view-scoped models owned here. + let report: YearReportModel + @State private var backup: BackupModel + @State private var reminders: RemindersSettingsModel + @Environment(WhereModel.self) private var model @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL @@ -31,8 +38,19 @@ struct SettingsView: View { @State private var showImportSuccess = false @State private var lastImportSummary: BackupCoordinator.ImportSummary? + init(report: YearReportModel) { + self.report = report + _backup = State(initialValue: BackupModel(services: report.services)) + _reminders = State(initialValue: RemindersSettingsModel( + services: report.services, + preferences: report.preferences, + now: report.now, + )) + } + var body: some View { @Bindable var session = session + @Bindable var backup = backup NavigationStack { Form { @@ -50,6 +68,10 @@ struct SettingsView: View { #endif } .navigationTitle(Strings.settingsTitle) + // Notification permission can change in the Settings app while we're + // away; refresh it when the screen appears so the "open Settings" + // affordance is accurate. + .task { await reminders.refreshNotificationAuthorization() } .sheet(isPresented: $showAppIcon) { AppIconView() } @@ -94,8 +116,8 @@ struct SettingsView: View { } .alert( Strings.settingsBackupErrorTitle, - isPresented: $session.isShowingBackupError, - presenting: session.backupError, + isPresented: $backup.isShowingBackupError, + presenting: backup.backupError, ) { _ in Button(Strings.commonOK, role: .cancel) {} } message: { message in @@ -153,20 +175,20 @@ struct SettingsView: View { } private var remindersSection: some View { - @Bindable var session = session + @Bindable var reminders = reminders return Section { - Toggle(isOn: $session.remindersEnabled) { + Toggle(isOn: $reminders.remindersEnabled) { Label(Strings.settingsRemindersToggle, systemImage: "bell.badge") } - if session.remindersEnabled { + if reminders.remindersEnabled { DatePicker( Strings.settingsReminderTime, - selection: $session.reminderTimeOfDay, + selection: $reminders.reminderTimeOfDay, displayedComponents: .hourAndMinute, ) - if !session.notificationsAuthorized { + if !reminders.notificationsAuthorized { Button { openSystemSettings() } label: { @@ -182,27 +204,27 @@ struct SettingsView: View { } private var remindersFooter: String { - if session.remindersEnabled, !session.notificationsAuthorized { + if reminders.remindersEnabled, !reminders.notificationsAuthorized { return Strings.settingsRemindersDeniedFooter } return Strings.settingsRemindersFooter } private var summarySection: some View { - @Bindable var session = session + @Bindable var reminders = reminders return Section { - Toggle(isOn: $session.summaryEnabled) { + Toggle(isOn: $reminders.summaryEnabled) { Label(Strings.settingsSummaryToggle, systemImage: "chart.bar.doc.horizontal") } - if session.summaryEnabled { + if reminders.summaryEnabled { DatePicker( Strings.settingsSummaryTime, - selection: $session.summaryTimeOfDay, + selection: $reminders.summaryTimeOfDay, displayedComponents: .hourAndMinute, ) - if !session.notificationsAuthorized { + if !reminders.notificationsAuthorized { Button { openSystemSettings() } label: { @@ -218,17 +240,17 @@ struct SettingsView: View { } private var summaryFooter: String { - if session.summaryEnabled, !session.notificationsAuthorized { + if reminders.summaryEnabled, !reminders.notificationsAuthorized { return Strings.settingsSummaryDeniedFooter } return Strings.settingsSummaryFooter } private var resolutionSection: some View { - @Bindable var session = session + @Bindable var report = report return Section { - Picker(Strings.settingsResolutionHeader, selection: $session.driftThreshold) { + Picker(Strings.settingsResolutionHeader, selection: $report.driftThreshold) { ForEach(DriftThreshold.allCases, id: \.self) { threshold in Text(Strings.driftThresholdLabel(kilometers: threshold.rawValue / 1000)) .tag(threshold) @@ -256,7 +278,7 @@ struct SettingsView: View { private var manualEntrySection: some View { Section { NavigationLink { - ManualDayEntryView() + ManualDayEntryView(report: report) } label: { Label(Strings.settingsManualLink, systemImage: "calendar.badge.plus") } @@ -278,18 +300,18 @@ struct SettingsView: View { ) { Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") } - .disabled(session.backupState != .idle) + .disabled(backup.backupState != .idle) Button { showImporter = true } label: { - if session.backupState == .importing { + if backup.backupState == .importing { importProgressLabel } else { Label(Strings.settingsBackupImport, systemImage: "square.and.arrow.down") } } - .disabled(session.backupState != .idle) + .disabled(backup.backupState != .idle) } header: { Text(Strings.settingsBackupHeader) } footer: { @@ -298,20 +320,20 @@ struct SettingsView: View { } /// Determinate progress for an in-flight import, driven by - /// `session.backupProgress` as the backup coordinator writes each row. + /// `backup.backupProgress` as the backup coordinator writes each row. private var importProgressLabel: some View { VStack(alignment: .leading, spacing: 4) { Label(Strings.settingsBackupImporting, systemImage: "square.and.arrow.down") - ProgressView(value: session.backupProgress) + ProgressView(value: backup.backupProgress) } } /// Lazily-built backup for `ShareLink`. The closure runs only when the - /// share sheet resolves the item; a failed export sets `session.backupError` + /// share sheet resolves the item; a failed export sets `backup.backupError` /// (surfacing the alert) and throws to abort the share. private var backupArchiveFile: BackupArchiveFile { - BackupArchiveFile { [session] in - guard let url = await session.exportBackup() else { + BackupArchiveFile { [backup] in + guard let url = await backup.exportBackup() else { throw CocoaError(.fileWriteUnknown) } return url @@ -324,13 +346,13 @@ struct SettingsView: View { pendingImportURL = url showStrategyDialog = true case let .failure(error): - session.backupError = error.localizedDescription + backup.backupError = error.localizedDescription } } private func runImport(url: URL, strategy: BackupCoordinator.ImportStrategy) { Task { - if let summary = await session.importBackup(from: url, strategy: strategy) { + if let summary = await backup.importBackup(from: url, strategy: strategy) { lastImportSummary = summary showImportSuccess = true } @@ -351,21 +373,21 @@ struct SettingsView: View { titleVisibility: .visible, ) { Button(eraseTitle, role: .destructive) { - Task { await session.clearSelectedYear() } + Task { await report.clearSelectedYear() } } Button(Strings.settingsDataCancel, role: .cancel) {} } message: { - Text(Strings.settingsDataConfirmMessage(year: session.selectedYear)) + Text(Strings.settingsDataConfirmMessage(year: report.selectedYear)) } } header: { Text(Strings.settingsDataHeader) } footer: { - Text(Strings.settingsDataFooter(year: session.selectedYear)) + Text(Strings.settingsDataFooter(year: report.selectedYear)) } } private var eraseTitle: String { - Strings.settingsDataErase(year: session.selectedYear) + Strings.settingsDataErase(year: report.selectedYear) } /// Whole-app teardown: wipes every year's data and returns to first-run @@ -445,7 +467,7 @@ struct SettingsView: View { #if DEBUG #Preview { - SettingsView() + SettingsView(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) } diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift index 78298dbd..92482427 100644 --- a/Where/WhereUI/Sources/Shared/YearSelector.swift +++ b/Where/WhereUI/Sources/Shared/YearSelector.swift @@ -2,9 +2,9 @@ import SwiftUI import WhereCore /// Toolbar control for choosing which calendar year the reports cover. Reads -/// and drives the shared `WhereSession`. +/// and drives the scene's `YearReportModel`. struct YearSelector: View { - @Environment(WhereSession.self) private var session + let report: YearReportModel private var years: [Int] { let current = WhereModel.currentYear @@ -15,9 +15,9 @@ struct YearSelector: View { Menu { ForEach(years, id: \.self) { year in Button { - Task { await session.select(year: year) } + Task { await report.select(year: year) } } label: { - if year == session.selectedYear { + if year == report.selectedYear { Label { Text(yearText(year)) } icon: { Image(systemName: "checkmark") } } else { Text(yearText(year)) @@ -25,7 +25,7 @@ struct YearSelector: View { } } } label: { - Text(yearText(session.selectedYear)) + Text(yearText(report.selectedYear)) } .accessibilityIdentifier("where_year_selector") } @@ -38,7 +38,6 @@ struct YearSelector: View { #if DEBUG #Preview { - YearSelector() - .environment(PreviewSupport.loadedSession()) + YearSelector(report: PreviewSupport.loadedYearReportModel()) } #endif diff --git a/Where/WhereUI/Tests/WhereSessionBackupTests.swift b/Where/WhereUI/Tests/BackupModelTests.swift similarity index 69% rename from Where/WhereUI/Tests/WhereSessionBackupTests.swift rename to Where/WhereUI/Tests/BackupModelTests.swift index 4348b00a..b8eb78e6 100644 --- a/Where/WhereUI/Tests/WhereSessionBackupTests.swift +++ b/Where/WhereUI/Tests/BackupModelTests.swift @@ -3,11 +3,11 @@ import Testing import WhereCore @testable import WhereUI -/// Exercises `WhereSession`'s backup export/import bridging: a successful -/// round-trip across two independent stores, and the failure path that -/// surfaces `backupError` without leaving the session stuck "working". +/// Exercises `BackupModel`'s export/import bridging: a successful round-trip +/// across two independent stores, and the failure path that surfaces +/// `backupError` without leaving the model stuck "working". @MainActor -struct WhereSessionBackupTests { +struct BackupModelTests { private func date(year: Int, month: Int, day: Int) -> Date { Calendar.current.date( from: DateComponents(year: year, month: month, day: day, hour: 12), @@ -31,31 +31,31 @@ struct WhereSessionBackupTests { try await services.journal.dismissIssue(key: "borderDrift:1700000000") } - @Test func exportThenImportRoundTripsThroughTheSession() async throws { + @Test func exportThenImportRoundTripsThroughTheModel() async throws { let sourceStore = try SwiftDataStore.inMemory() let source = WhereServices(store: sourceStore, locationSource: ScriptedLocationSource()) try await seed(source) - let sourceSession = WhereSession(services: source, selectedYear: 2026) + let sourceBackup = BackupModel(services: source) - let url = try #require(await sourceSession.exportBackup()) + let url = try #require(await sourceBackup.exportBackup()) defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - #expect(sourceSession.backupState == .idle) - #expect(sourceSession.backupError == nil) + #expect(sourceBackup.backupState == .idle) + #expect(sourceBackup.backupError == nil) let destinationStore = try SwiftDataStore.inMemory() let destination = WhereServices( store: destinationStore, locationSource: ScriptedLocationSource(), ) - let destinationSession = WhereSession(services: destination, selectedYear: 2026) + let destinationBackup = BackupModel(services: destination) let summary = try #require( - await destinationSession.importBackup(from: url, strategy: .merge), + await destinationBackup.importBackup(from: url, strategy: .merge), ) #expect(summary.evidenceCount == 1) #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) - #expect(destinationSession.backupState == .idle) + #expect(destinationBackup.backupState == .idle) #expect(try await destinationStore.allEvidence() == sourceStore.allEvidence()) #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) @@ -67,16 +67,16 @@ struct WhereSessionBackupTests { @Test func importingABogusFileSetsBackupError() async throws { let store = try SwiftDataStore.inMemory() let services = WhereServices(store: store, locationSource: ScriptedLocationSource()) - let session = WhereSession(services: services, selectedYear: 2026) + let backup = BackupModel(services: services) let bogus = FileManager.default.temporaryDirectory .appendingPathComponent("\(UUID().uuidString).zip") try Data("not a backup".utf8).write(to: bogus) defer { try? FileManager.default.removeItem(at: bogus) } - let summary = await session.importBackup(from: bogus, strategy: .replace) + let summary = await backup.importBackup(from: bogus, strategy: .replace) #expect(summary == nil) - #expect(session.backupError != nil) - #expect(session.backupState == .idle) + #expect(backup.backupError != nil) + #expect(backup.backupState == .idle) } } diff --git a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift new file mode 100644 index 00000000..12c2f450 --- /dev/null +++ b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing +import WhereCore +import WhereTesting +import WhereUI + +/// Covers `RemindersSettingsModel` — the Settings editing surface for the +/// reminder + daily-summary schedules. Setting a property persists to the shared +/// `WherePreferences` (so a fresh model over the same preferences reads the saved +/// values back) *and* reconciles the live schedule: spy schedulers confirm each +/// setter pushes the intent — enable (with an authorization prompt), disable, and +/// a time edit — down through the reconciler. +@MainActor +struct RemindersSettingsModelTests { + private func makePreferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + private func makeServices() throws -> WhereServices { + try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + } + + private func makeServices( + reminderScheduler: any LoggingReminderScheduling, + summaryScheduler: any DailySummaryScheduling, + ) throws -> WhereServices { + try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: reminderScheduler, + summaryScheduler: summaryScheduler, + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + } + + private func makeModel(preferences: WherePreferences) throws -> RemindersSettingsModel { + try RemindersSettingsModel(services: makeServices(), preferences: preferences) + } + + @Test func reminderSettingsDefaultOnAndPersistAcrossModels() throws { + let preferences = makePreferences() + let model = try makeModel(preferences: preferences) + + #expect(model.remindersEnabled) + #expect(model.reminderTime == ReminderTime.defaultEvening) + + // Setting the key-path-bindable properties persists synchronously (the + // reconcile they kick off runs against the no-op scheduler). + model.remindersEnabled = false + model.reminderTime = ReminderTime(hour: 7, minute: 30) + #expect(!model.remindersEnabled) + #expect(model.reminderTime == ReminderTime(hour: 7, minute: 30)) + + // A fresh model sharing the same preferences reads back the saved values. + let reloaded = try makeModel(preferences: preferences) + #expect(!reloaded.remindersEnabled) + #expect(reloaded.reminderTime == ReminderTime(hour: 7, minute: 30)) + } + + @Test func summarySettingsDefaultOnAndPersistAcrossModels() throws { + let preferences = makePreferences() + let model = try makeModel(preferences: preferences) + + #expect(model.summaryEnabled) + #expect(model.summaryTime == ReminderTime.defaultMorning) + + model.summaryEnabled = false + model.summaryTime = ReminderTime(hour: 9, minute: 15) + #expect(!model.summaryEnabled) + #expect(model.summaryTime == ReminderTime(hour: 9, minute: 15)) + + // A fresh model sharing the same preferences reads back the saved values. + let reloaded = try makeModel(preferences: preferences) + #expect(!reloaded.summaryEnabled) + #expect(reloaded.summaryTime == ReminderTime(hour: 9, minute: 15)) + } + + // MARK: - Setter reconciliation reaches the schedulers + + /// Enabling reminders prompts for authorization and reconciles the schedule + /// on — the persistence round-trip above can't see that the setter actually + /// drove the reconciler. + @Test func enablingRemindersRequestsAuthorizationAndReconciles() async throws { + let preferences = makePreferences() + preferences.remindersEnabled = false + let reminderSpy = SpyReminderScheduler() + let services = try makeServices( + reminderScheduler: reminderSpy, + summaryScheduler: NoopDailySummaryScheduler(), + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.remindersEnabled = true + + await waitUntil { await reminderSpy.reconcileCount >= 1 } + #expect(await reminderSpy.authorizationRequests >= 1) + #expect(await reminderSpy.lastEnabled == true) + #expect(await reminderSpy.lastReminderTime == ReminderTime.defaultEvening) + } + + /// Disabling reconciles the schedule off and never prompts (disabling can't + /// need permission). + @Test func disablingRemindersReconcilesOffWithoutAuthorization() async throws { + let preferences = makePreferences() + let reminderSpy = SpyReminderScheduler() + let services = try makeServices( + reminderScheduler: reminderSpy, + summaryScheduler: NoopDailySummaryScheduler(), + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.remindersEnabled = false + + await waitUntil { await reminderSpy.reconcileCount >= 1 } + #expect(await reminderSpy.lastEnabled == false) + #expect(await reminderSpy.authorizationRequests == 0) + } + + /// Editing the time re-reconciles with the new time (still enabled). + @Test func changingReminderTimeReconcilesWithTheNewTime() async throws { + let preferences = makePreferences() + let reminderSpy = SpyReminderScheduler() + let services = try makeServices( + reminderScheduler: reminderSpy, + summaryScheduler: NoopDailySummaryScheduler(), + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + let newTime = ReminderTime(hour: 7, minute: 30) + model.reminderTime = newTime + + await waitUntil { await reminderSpy.lastReminderTime == newTime } + #expect(await reminderSpy.lastEnabled == true) + } + + @Test func enablingSummaryRequestsAuthorizationAndReconciles() async throws { + let preferences = makePreferences() + preferences.summaryEnabled = false + let summarySpy = SpyDailySummaryScheduler() + let services = try makeServices( + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: summarySpy, + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.summaryEnabled = true + + await waitUntil { await summarySpy.reconcileCount >= 1 } + #expect(await summarySpy.authorizationRequests >= 1) + #expect(await summarySpy.lastEnabled == true) + #expect(await summarySpy.lastTime == ReminderTime.defaultMorning) + } + + @Test func disablingSummaryReconcilesOffWithoutAuthorization() async throws { + let preferences = makePreferences() + let summarySpy = SpyDailySummaryScheduler() + let services = try makeServices( + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: summarySpy, + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + model.summaryEnabled = false + + await waitUntil { await summarySpy.reconcileCount >= 1 } + #expect(await summarySpy.lastEnabled == false) + #expect(await summarySpy.authorizationRequests == 0) + } + + @Test func changingSummaryTimeReconcilesWithTheNewTime() async throws { + let preferences = makePreferences() + let summarySpy = SpyDailySummaryScheduler() + let services = try makeServices( + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: summarySpy, + ) + let model = RemindersSettingsModel(services: services, preferences: preferences) + + let newTime = ReminderTime(hour: 9, minute: 15) + model.summaryTime = newTime + + await waitUntil { await summarySpy.lastTime == newTime } + #expect(await summarySpy.lastEnabled == true) + } + + /// The setters reconcile off an unstructured `Task`, so poll the (actor) spy + /// rather than assuming the reconcile has landed by the next line. + private func waitUntil( + timeout: Duration = .seconds(2), + _ predicate: () async -> Bool, + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if await predicate() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(await predicate(), "condition was not met before timeout") + } +} + +/// Records the calls `RemindersSettingsModel` funnels through the reminder +/// reconciler into its scheduler, so a setter's reconcile can be asserted +/// without touching `UNUserNotificationCenter`. +private actor SpyReminderScheduler: LoggingReminderScheduling { + private(set) var authorizationRequests = 0 + private(set) var reconcileCount = 0 + private(set) var lastEnabled: Bool? + private(set) var lastReminderTime: ReminderTime? + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile( + badgeCount _: Int, + scheduleDays _: [Date], + reminderTime: ReminderTime, + enabled: Bool, + ) async { + reconcileCount += 1 + lastReminderTime = reminderTime + lastEnabled = enabled + } +} + +/// The daily-summary counterpart to `SpyReminderScheduler`. +private actor SpyDailySummaryScheduler: DailySummaryScheduling { + private(set) var authorizationRequests = 0 + private(set) var reconcileCount = 0 + private(set) var lastEnabled: Bool? + private(set) var lastTime: ReminderTime? + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile(enabled: Bool, time: ReminderTime, body _: String) async { + reconcileCount += 1 + lastEnabled = enabled + lastTime = time + } +} diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift new file mode 100644 index 00000000..06def6aa --- /dev/null +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +import WhereCore +import WhereTesting +@_spi(Testing) @testable import WhereUI + +/// Covers `ResolveModel` — the Resolve tab's issue list and dismiss action. +@MainActor +struct ResolveModelTests { + private func date(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date( + from: DateComponents(year: year, month: month, day: day, hour: 12), + )! + } + + @Test func loadPopulatesMissingDayIssues() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + try await services.journal.addManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + await resolve.load(year: 2026, primaryRegions: [.california]) + + #expect(!resolve.dataIssues.isEmpty) + #expect(resolve.dataIssues.contains { $0.category == .missingDays }) + } + + @Test func dismissWritesToStoreAndRemovesRow() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 6, day: 15) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + // Two calendar-adjacent days with disjoint regions produce a real, + // dismissible abrupt-change issue, so `dismiss` runs against an issue the + // scanner actually returned from `load(...)` — no seeded fixture, no + // `setDataIssues` short-circuit. + try await services.journal.addManualDay( + date: date(year: 2026, month: 3, day: 1), + regions: [.california], + ) + try await services.journal.addManualDay( + date: date(year: 2026, month: 3, day: 2), + regions: [.newYork], + ) + await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) + + let issue = try #require(resolve.dataIssues.first { $0.isDismissible }) + await resolve.dismiss(issue) + #expect(!resolve.dataIssues.contains { $0.id == issue.id }) + + let keys = try await store.dismissedIssueKeys() + #expect(keys.contains(issue.id.storageKey)) + } + + /// The empty-state guard: `hasLoaded` starts false and flips once the first + /// scan lands, so `ResolutionView` can hold a spinner instead of flashing + /// "all clear" under a non-zero badge. + @Test func loadMarksTheModelLoaded() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + #expect(!resolve.hasLoaded) + await resolve.load(year: 2026, primaryRegions: [.california]) + #expect(resolve.hasLoaded) + } + + /// Seeding a fixture also counts as loaded, so the seeded "empty" preview + /// renders its empty state rather than a stuck spinner. + @Test func seedingMarksTheModelLoaded() throws { + let store = try TestStore() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + resolve.setDataIssues([]) + #expect(resolve.hasLoaded) + } +} diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 096a3b3b..1a61a181 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -6,28 +6,31 @@ import WhereTesting @testable import WhereUI /// Hosts each top-level screen in a real window with seeded preview data to -/// confirm the Liquid Glass layouts mount without crashing. +/// confirm the Liquid Glass layouts mount without crashing. Report/year screens +/// take a `YearReportModel` explicitly (constructor injection); the always-on views +/// (Settings) also read the `WhereSession` coordinator from the environment. @MainActor struct ScreenHostingTests { @Test func primaryViewHostsWithData() throws { - let session = PreviewSupport.loadedSession() - try show(UIHostingController(rootView: PrimaryView().environment(session))) { hosted in + let report = PreviewSupport.loadedYearReportModel() + try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func secondaryViewHostsWithData() throws { - let session = PreviewSupport.loadedSession() - try show(UIHostingController(rootView: SecondaryView().environment(session))) { hosted in + let report = PreviewSupport.loadedYearReportModel() + try show(UIHostingController(rootView: SecondaryView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func settingsViewHosts() throws { - // Settings reads both the app model (reset) and the logged-in session. + // Settings reads the app model (reset) and the logged-in session (tracking + // + inspector) from the environment, and takes the scene report explicitly. let model = PreviewSupport.loadedModel() let session = PreviewSupport.loadedSession() - let rootView = SettingsView() + let rootView = SettingsView(report: PreviewSupport.loadedYearReportModel()) .environment(model) .environment(session) try show(UIHostingController(rootView: rootView)) { hosted in @@ -36,40 +39,47 @@ struct ScreenHostingTests { } @Test func primaryViewHostsWithElsewhereOnlyData() throws { - let session = PreviewSupport.elsewhereOnlySession() - try show(UIHostingController(rootView: PrimaryView().environment(session))) { hosted in + let report = PreviewSupport.elsewhereOnlyYearReportModel() + try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } } - @Test func primaryViewHostsWithMissingDaysSession() throws { - let session = PreviewSupport.missingDaysSession() - try show(UIHostingController(rootView: PrimaryView().environment(session))) { hosted in + @Test func primaryViewHostsWithMissingDays() throws { + let report = PreviewSupport.missingDaysYearReportModel() + try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func resolutionViewHostsWithIssues() throws { - let session = PreviewSupport.resolutionSession() - #expect(session.dataIssueCount > 0) - try show(UIHostingController(rootView: ResolutionView().environment(session))) { hosted in + let resolve = PreviewSupport.resolveModel() + #expect(!resolve.dataIssues.isEmpty) + let rootView = ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: resolve, + ) + try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } @Test func resolutionViewHostsEmpty() throws { - let session = PreviewSupport.loadedSession() - try show(UIHostingController(rootView: ResolutionView().environment(session))) { hosted in + let resolve = PreviewSupport.resolveModel(seededWithIssues: false) + let rootView = ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: resolve, + ) + try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } @Test func regionDaysViewHostsWithData() throws { - // `elsewhereOnlySession` has only `.other` days, so the drill-in list + // `elsewhereOnlyYearReportModel` has only `.other` days, so the drill-in list // for that region has rows to render. - let session = PreviewSupport.elsewhereOnlySession() - let rootView = NavigationStack { RegionDaysView(region: .other) } - .environment(session) + let report = PreviewSupport.elsewhereOnlyYearReportModel() + let rootView = NavigationStack { RegionDaysView(region: .other, report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } @@ -85,27 +95,24 @@ struct ScreenHostingTests { } @Test func dayRelabelViewHosts() throws { - let session = PreviewSupport.loadedSession() + let report = PreviewSupport.loadedYearReportModel() let day = DayPresence(date: .now, regions: [.other]) - let rootView = NavigationStack { DayRelabelView(day: day) } - .environment(session) + let rootView = NavigationStack { DayRelabelView(day: day, report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } @Test func presenceTimelineViewHostsWithData() throws { - let session = PreviewSupport.loadedSession() - try show(UIHostingController(rootView: PresenceTimelineView() - .environment(session))) - { hosted in + let report = PreviewSupport.loadedYearReportModel() + try show(UIHostingController(rootView: PresenceTimelineView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func calendarViewHostsWithData() throws { - let session = PreviewSupport.loadedSession() - try show(UIHostingController(rootView: CalendarView().environment(session))) { hosted in + let report = PreviewSupport.loadedYearReportModel() + try show(UIHostingController(rootView: CalendarView(report: report))) { hosted in #expect(hosted.view != nil) } } @@ -118,19 +125,17 @@ struct ScreenHostingTests { } @Test func manualDayEntryViewHostsDefault() throws { - let session = PreviewSupport.loadedSession() - let rootView = NavigationStack { ManualDayEntryView() } - .environment(session) + let report = PreviewSupport.loadedYearReportModel() + let rootView = NavigationStack { ManualDayEntryView(report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } @Test func manualDayEntryViewHostsPrefill() throws { - let session = PreviewSupport.missingDaysSession() - let range = try #require(session.missingDays.first) - let rootView = NavigationStack { ManualDayEntryView(prefill: range) } - .environment(session) + let report = PreviewSupport.missingDaysYearReportModel() + let range = try #require(report.missingDays.first) + let rootView = NavigationStack { ManualDayEntryView(report: report, prefill: range) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 1326f59f..1cd94cc6 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -4,6 +4,10 @@ import WhereCore /// Thrown by `TestStore.setManualDay` when failure injection is enabled. struct ManualSaveFailure: Error, Equatable {} +/// Thrown by `TestStore.samples(in:)` when failure injection is enabled, so a +/// year-report load can be forced to fail. +struct SampleReadFailure: Error, Equatable {} + /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds /// two hooks the view-model tests need: /// @@ -23,6 +27,7 @@ actor TestStore: WhereStore { private var arrival: CheckedContinuation? private var shouldFailManualDay = false + private var shouldFailSamples = false init() throws { backing = try SwiftDataStore.inMemory() @@ -49,6 +54,12 @@ actor TestStore: WhereStore { shouldFailManualDay = true } + /// Makes `samples(in:)` throw, so a `refresh()`'s year-report load fails and + /// the model's `.failed` load state is exercisable. + func failSamples() { + shouldFailSamples = true + } + // MARK: - WhereStore func perform(_ block: @Sendable () async throws -> T) async throws -> T { @@ -64,6 +75,7 @@ actor TestStore: WhereStore { } func samples(in interval: DateInterval) async throws -> [LocationSample] { + if shouldFailSamples { throw SampleReadFailure() } if gateFirstSamplesCall, !firstSamplesSeen { firstSamplesSeen = true arrival?.resume() diff --git a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift index f863ffd8..b4dc3db6 100644 --- a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift +++ b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift @@ -16,7 +16,7 @@ struct SwiftDataInspectorWiringTests { } @Test func configurationModelTypesMatchTheLiveSchema() throws { - let session = WhereSession(services: PreviewSupport.previewServices(), selectedYear: 2026) + let session = WhereSession(services: PreviewSupport.previewServices()) let configuration = try #require(session.swiftDataInspectorConfiguration) let schemaNames = Set(configuration.container.schema.entities.map(\.name)) @@ -43,7 +43,7 @@ struct SwiftDataInspectorWiringTests { regions: [.california], ) - let session = WhereSession(services: services, selectedYear: 2026) + let session = WhereSession(services: services) let configuration = try #require(session.swiftDataInspectorConfiguration) let rootView = NavigationStack { diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 51a57bae..215343fb 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -57,7 +57,6 @@ struct WhereLaunchTests { .onboarding, .syncAuth, .reconcileTracking, - .loadYear, .reminders, .summary, .widgetSnapshot, diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 29431ddd..54f1d0a1 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -79,10 +79,11 @@ struct WhereResetTests { @Test func resetPreferencesRestoresFirstInstallDefaults() throws { let preferences = makePreferences() let model = try makeModel(preferences: preferences) - let session = try #require(model.session) model.completeOnboarding() - session.remindersEnabled = false - session.summaryEnabled = false + // Turn the reminder/summary schedules off through the shared preferences + // (the editing surface, `RemindersSettingsModel`, writes here). + preferences.remindersEnabled = false + preferences.summaryEnabled = false #expect(model.hasOnboarded) model.resetPreferences() @@ -90,34 +91,32 @@ struct WhereResetTests { // Removing the keys lets the default-valued getters report first-install // state again: onboarding returns and reminders/summary default back on. #expect(!model.hasOnboarded) - // A fresh session re-reads the now-cleared preferences and sees the - // restored defaults (the live session caches its values, so the reset - // is observed by the rebuilt session the relaunch creates). - let reloaded = try WhereSession(services: makeServices(), preferences: preferences) - #expect(reloaded.remindersEnabled) - #expect(reloaded.summaryEnabled) + #expect(preferences.remindersEnabled) + #expect(preferences.summaryEnabled) } @Test func eraseAllDataClearsTheStoreAndStopsTracking() async throws { - let model = try makeModel(status: .always, preferences: makePreferences()) + let preferences = makePreferences() + let services = try makeServices(status: .always) + let model = WhereModel(services: services, preferences: preferences) model.completeOnboarding() let session = try #require(model.session) + // The scene's report model shares the coordinator's services (its store). + let report = YearReportModel(services: services, preferences: preferences) await session.start() #expect(session.isTracking) // .always authorization resumed GPS - try await session.setManualDay(date: Date(), regions: [.california]) - // The write path no longer refreshes inline; the committed write pings - // the store-change signal and the session's observer re-pulls. - try await waitUntil { session.trackedDayCount == 1 } + try await report.setManualDay(date: Date(), regions: [.california]) + await report.refresh() + #expect(report.trackedDayCount == 1) try await model.eraseAllData() #expect(!session.isTracking) - #expect(session.trackedDayCount == 0) // The store really is empty, not just the in-memory report: a reload // from disk finds nothing. - await session.refresh() - #expect(session.trackedDayCount == 0) + await report.refresh() + #expect(report.trackedDayCount == 0) } @Test func endSessionDropsTheSessionAndRelaunchRebuildsIt() async throws { @@ -176,15 +175,19 @@ struct WhereResetTests { } @Test func resetReturnsToOnboardingWithDataErased() async throws { - let model = try makeModel(status: .always, preferences: makePreferences()) + let preferences = makePreferences() + let services = try makeServices(status: .always) + let model = WhereModel(services: services, preferences: preferences) model.completeOnboarding() let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run() #expect(launcher.phase.isReady) let session = try #require(model.session) - try await session.setManualDay(date: Date(), regions: [.california]) - try await waitUntil { session.trackedDayCount == 1 } + let report = YearReportModel(services: services, preferences: preferences) + try await report.setManualDay(date: Date(), regions: [.california]) + await report.refresh() + #expect(report.trackedDayCount == 1) // reset re-drives the launch, which parks on onboarding again (now that // hasOnboarded is cleared), so reset() doesn't return until onboarding @@ -206,24 +209,28 @@ struct WhereResetTests { await task.value #expect(launcher.phase.isReady) - // load-year reran against the now-empty store. - #expect(model.session?.trackedDayCount == 0) + // The store was wiped: a fresh report read against it finds nothing. + await report.refresh() + #expect(report.trackedDayCount == 0) } @Test func resetDropsSessionClearsPreferencesAndReturnsToOnboarding() async throws { // The end-to-end reset cycle: a user who has onboarded, turned the // reminders/summary off, and logged a day runs "Erase all data & reset". let preferences = makePreferences() - let model = try makeModel(status: .always, preferences: preferences) + let services = try makeServices(status: .always) + let model = WhereModel(services: services, preferences: preferences) let original = try #require(model.session) model.completeOnboarding() - original.remindersEnabled = false - original.summaryEnabled = false + preferences.remindersEnabled = false + preferences.summaryEnabled = false let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run() - try await original.setManualDay(date: Date(), regions: [.california]) - try await waitUntil { original.trackedDayCount == 1 } + let report = YearReportModel(services: services, preferences: preferences) + try await report.setManualDay(date: Date(), regions: [.california]) + await report.refresh() + #expect(report.trackedDayCount == 1) // Drive the reset and finish the onboarding it re-drives into. let task = Task { @MainActor in @@ -231,20 +238,21 @@ struct WhereResetTests { } try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } - // Mid-relaunch: the session was dropped and rebuilt fresh, preferences - // were cleared (onboarding gate reopened), and the rebuilt session reads - // the restored reminder/summary defaults — not the off state above. + // Mid-relaunch: the session was dropped and rebuilt fresh, and the + // preferences were cleared (onboarding gate reopened; the reminder/summary + // schedules default back on) — not the off state above. let rebuilt = try #require(model.session) #expect(rebuilt !== original) #expect(!model.hasOnboarded) - #expect(rebuilt.remindersEnabled) - #expect(rebuilt.summaryEnabled) + #expect(preferences.remindersEnabled) + #expect(preferences.summaryEnabled) launcher.phase.runningBridge?.complete() await task.value #expect(launcher.phase.isReady) - // The store was wiped: the rebuilt session's reloaded report is empty. - #expect(model.session?.trackedDayCount == 0) + // The store was wiped: a fresh report read against it is empty. + await report.refresh() + #expect(report.trackedDayCount == 0) } @Test func resetFailureParksLauncherAndKeepsPreferences() async throws { diff --git a/Where/WhereUI/Tests/WhereSessionDataIssueTests.swift b/Where/WhereUI/Tests/WhereSessionDataIssueTests.swift deleted file mode 100644 index de5a637d..00000000 --- a/Where/WhereUI/Tests/WhereSessionDataIssueTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Testing -import WhereCore -import WhereTesting -@_spi(Testing) @testable import WhereUI - -@MainActor -struct WhereSessionDataIssueTests { - private func date(year: Int, month: Int, day: Int) -> Date { - Calendar.current.date( - from: DateComponents(year: year, month: month, day: day, hour: 12), - )! - } - - @Test func refreshDataIssues_populatesMissingDays() async throws { - let store = try TestStore() - let now = date(year: 2026, month: 2, day: 10) - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - now: { now }, - ) - let session = WhereSession(services: services, selectedYear: 2026, now: { now }) - - try await services.journal.addManualDay( - date: date(year: 2026, month: 1, day: 1), - regions: [.california], - ) - await session.refresh() - await session.refreshDataIssues(force: true) - - #expect(session.dataIssueCount > 0) - #expect(session.dataIssues.contains { $0.category == .missingDays }) - } - - @Test func dismiss_writesToStoreAndRemovesRow() async throws { - let store = try TestStore() - let now = date(year: 2026, month: 2, day: 10) - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - now: { now }, - ) - let session = WhereSession(services: services, selectedYear: 2026, now: { now }) - - let issue = BorderDriftIssue( - day: DayPresence(date: date(year: 2026, month: 3, day: 1), regions: [.other]), - nearestRegion: .california, - distanceMeters: 1000, - ) - session.setDataIssues([issue]) - #expect(session.dataIssueCount == 1) - - await session.dismiss(issue) - #expect(!session.dataIssues.contains { $0.id == issue.id }) - - let keys = try await store.dismissedIssueKeys() - #expect(keys.contains(issue.id.storageKey)) - } - - @Test func driftThresholdChangePersists() throws { - let store = try TestStore() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - let preferences = WherePreferences(store: InMemoryKeyValueStore()) - let session = WhereSession( - services: services, - selectedYear: 2026, - preferences: preferences, - ) - - session.driftThreshold = .km25 - #expect(preferences.driftThresholdMeters == DriftThreshold.km25.rawValue) - } - - /// The write path no longer refreshes inline: a manual edit commits, the - /// store pings `changes()`, and the data-change observer re-pulls the report - /// + data-issue scan. Proves the single read path keeps the UI honest. - @Test func manualWriteRefreshesViaDataChangeObserver() async throws { - let store = try TestStore() - let now = date(year: 2026, month: 2, day: 10) - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - now: { now }, - ) - let session = WhereSession(services: services, selectedYear: 2026, now: { now }) - - // Observer only — no `refresh()`/`refreshDataIssues()` called here, so - // any state change must arrive through the committed write's ping. - session.observeDataChanges() - #expect(session.report == nil) - - try await session.setManualDay( - date: date(year: 2026, month: 1, day: 1), - regions: [.california], - ) - - await waitUntil { session.dataIssues.contains { $0.category == .missingDays } } - #expect(session.report?.days.isEmpty == false) - } - - /// Guards against a retain cycle through the long-lived data-change observer: - /// it captures `[weak self]` and `deinit` cancels it, so dropping the last - /// strong reference deallocates the session even while the task is parked in - /// `for await` (a quiet store emits nothing on its own). - @Test func deinitsWhileObservingDataChanges() throws { - let store = try TestStore() - weak var weakSession: WhereSession? - do { - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - let session = WhereSession(services: services, selectedYear: 2026) - weakSession = session - session.observeDataChanges() - #expect(weakSession != nil) - } - #expect(weakSession == nil) - } - - private func waitUntil( - timeout: Duration = .seconds(2), - _ predicate: () -> Bool, - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if predicate() { return } - try? await Task.sleep(for: .milliseconds(5)) - } - #expect(predicate(), "condition was not met before timeout") - } -} diff --git a/Where/WhereUI/Tests/WhereSessionMissingDaysTests.swift b/Where/WhereUI/Tests/WhereSessionMissingDaysTests.swift deleted file mode 100644 index be59a5ca..00000000 --- a/Where/WhereUI/Tests/WhereSessionMissingDaysTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation -import Testing -import WhereCore -import WhereTesting -@testable import WhereUI - -/// Covers the missing-day computation the banner / backfill read, and the -/// persistence of the reminder and daily-summary settings. -@MainActor -struct WhereSessionMissingDaysTests { - /// Build dates in the same calendar `WhereSession` 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 makePreferences() -> WherePreferences { - WherePreferences(store: InMemoryKeyValueStore()) - } - - private func makeServices() throws -> WhereServices { - try WhereServices( - store: SwiftDataStore.inMemory(), - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - summaryScheduler: NoopDailySummaryScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - } - - @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 session = try WhereSession( - services: makeServices(), - 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(session.missingDays.map(\.start) == [ - Self.day(2026, 1, 1), - Self.day(2026, 1, 3), - ]) - #expect(session.missingDayCount == 2) - #expect(!session.missingDays.contains { $0.start == Self.day(2026, 1, 5) }) - } - - @Test func missingDaysAreEmptyWhenViewingAPastYear() throws { - let today = Self.day(2026, 6, 1) - let session = try WhereSession( - services: makeServices(), - report: YearReport(year: 2025, days: [], totals: [:]), - selectedYear: 2025, - now: { today }, - ) - - #expect(session.missingDays.isEmpty) - #expect(session.missingDayCount == 0) - } - - @Test func reminderSettingsDefaultOnAndPersistAcrossSessions() throws { - let preferences = makePreferences() - let session = try WhereSession(services: makeServices(), preferences: preferences) - - #expect(session.remindersEnabled) - #expect(session.reminderTime == ReminderTime.defaultEvening) - - // Setting the key-path-bindable properties persists synchronously (the - // reconcile they kick off runs against the no-op scheduler). - session.remindersEnabled = false - session.reminderTime = ReminderTime(hour: 7, minute: 30) - #expect(!session.remindersEnabled) - #expect(session.reminderTime == ReminderTime(hour: 7, minute: 30)) - - // A fresh session sharing the same preferences reads back the saved values. - let reloaded = try WhereSession(services: makeServices(), preferences: preferences) - #expect(!reloaded.remindersEnabled) - #expect(reloaded.reminderTime == ReminderTime(hour: 7, minute: 30)) - } - - @Test func summarySettingsDefaultOnAndPersistAcrossSessions() throws { - let preferences = makePreferences() - let session = try WhereSession(services: makeServices(), preferences: preferences) - - #expect(session.summaryEnabled) - #expect(session.summaryTime == ReminderTime.defaultMorning) - - session.summaryEnabled = false - session.summaryTime = ReminderTime(hour: 9, minute: 15) - #expect(!session.summaryEnabled) - #expect(session.summaryTime == ReminderTime(hour: 9, minute: 15)) - - // A fresh session sharing the same preferences reads back the saved values. - let reloaded = try WhereSession(services: makeServices(), preferences: preferences) - #expect(!reloaded.summaryEnabled) - #expect(reloaded.summaryTime == ReminderTime(hour: 9, minute: 15)) - } -} diff --git a/Where/WhereUI/Tests/WhereSessionRefreshTests.swift b/Where/WhereUI/Tests/WhereSessionRefreshTests.swift deleted file mode 100644 index 473b71cf..00000000 --- a/Where/WhereUI/Tests/WhereSessionRefreshTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -import Foundation -import Testing -import WhereCore -@testable import WhereUI - -/// Covers `WhereSession`'s refresh/save error handling that the PR review bots -/// flagged: out-of-order year fetches must not install stale data, and a -/// failed manual save must surface as an error rather than silently -/// "succeeding". -@MainActor -struct WhereSessionRefreshTests { - private func date(year: Int, month: Int, day: Int) -> Date { - Calendar.current.date( - from: DateComponents(year: year, month: month, day: day, hour: 12), - )! - } - - @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { - let store = try TestStore() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - - // Seed each year with a distinct region so we can tell which report won. - try await services.journal.addManualDay( - date: date(year: 2024, month: 3, day: 1), - regions: [.newYork], - ) - try await services.journal.addManualDay( - date: date(year: 2026, month: 3, day: 1), - regions: [.california], - ) - - let session = WhereSession(services: services, selectedYear: 2026) - await store.enableFirstSamplesGate() - - // Start the 2024 fetch; it suspends inside the gated `samples(in:)`. - let stale = Task { await session.select(year: 2024) } - await store.awaitFirstSamplesCall() - - // The 2026 fetch runs to completion while 2024 is still in flight. - await session.select(year: 2026) - #expect(session.report?.year == 2026) - - // Now let the slower 2024 fetch finish — it must be discarded. - await store.releaseFirstSamplesCall() - await stale.value - - #expect(session.selectedYear == 2026) - #expect(session.report?.year == 2026) - #expect(session.report?.totals[.california] == 1) - #expect(session.report?.totals[.newYork] == nil) - #expect(session.loadState == .loaded) - } - - @Test func failedManualSaveThrowsAndLeavesLoadStateAlone() async throws { - let store = try TestStore() - await store.failManualDays() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - let session = WhereSession(services: services, selectedYear: 2026) - - await #expect(throws: ManualSaveFailure.self) { - try await session.setManualDay( - date: self.date(year: 2026, month: 1, day: 2), - regions: [.california], - ) - } - - // A failed save must not flip the whole screen into the error state; - // the form surfaces the error inline instead. - #expect(session.loadState == .idle) - } - - @Test func failedManualRangeSaveThrows() async throws { - let store = try TestStore() - await store.failManualDays() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - let session = WhereSession(services: services, selectedYear: 2026) - - await #expect(throws: ManualSaveFailure.self) { - try await session.setManualDays( - from: self.date(year: 2026, month: 1, day: 2), - through: self.date(year: 2026, month: 1, day: 4), - regions: [.california], - ) - } - } - - // MARK: - Widget snapshot republishing on launch / activation - - /// The widget extension only reads the published snapshot file, so opening - /// the app with data already on disk must republish — otherwise the widget - /// stays blank/stale until the next write. - @Test func startPublishesWidgetSnapshotFromExistingData() async throws { - let (session, refresher) = try await makePublishingSession() - await session.start() - #expect(await refresher.publishCount == 1) - #expect(await refresher.lastSnapshot?.totals == [.california: 1]) - } - - /// Returning to the foreground (e.g. on a new calendar day) recomputes and - /// republishes too. - @Test func appBecameActiveRepublishesWidgetSnapshot() async throws { - let (session, refresher) = try await makePublishingSession() - await session.appBecameActive() - #expect(await refresher.publishCount == 1) - #expect(await refresher.lastSnapshot?.totals == [.california: 1]) - } - - /// A session whose services already hold one California day (seeded - /// straight into the store so nothing is published yet), wired to a spy - /// refresher and a fixed "now" so the year report is deterministic. - private func makePublishingSession() async throws -> (WhereSession, SpyWidgetRefresher) { - let store = try TestStore() - // Seed straight into the store (inside `perform`, as the store - // requires) so nothing is published before the session's lifecycle hook. - let seed = DayPresence(date: date(year: 2026, month: 3, day: 1), regions: [.california]) - try await store.perform { try await store.setManualDay(seed) } - let refresher = SpyWidgetRefresher() - let now = date(year: 2026, month: 3, day: 15) - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - summaryScheduler: NoopDailySummaryScheduler(), - widgetRefresher: refresher, - now: { now }, - ) - return (WhereSession(services: services, selectedYear: 2026), refresher) - } -} - -/// Captures the snapshots published through the widget publisher so the -/// session's launch/activation hooks can be checked for republishing widget -/// data. -private actor SpyWidgetRefresher: WidgetTimelineRefreshing { - private(set) var publishedSnapshots: [WidgetSnapshot] = [] - - var publishCount: Int { - publishedSnapshots.count - } - - var lastSnapshot: WidgetSnapshot? { - publishedSnapshots.last - } - - func publish(_ snapshot: WidgetSnapshot) async { - publishedSnapshots.append(snapshot) - } -} diff --git a/Where/WhereUI/Tests/WhereSessionSummaryTests.swift b/Where/WhereUI/Tests/WhereSessionSummaryTests.swift deleted file mode 100644 index 7ba92dc9..00000000 --- a/Where/WhereUI/Tests/WhereSessionSummaryTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -import Testing -import WhereCore -import WhereTesting -import WhereUI - -/// Covers that the session's launch / foreground lifecycle hooks actually drive -/// the daily-summary configuration down to the summary reconciler's scheduler. -@MainActor -struct WhereSessionSummaryTests { - private func makePreferences() -> WherePreferences { - WherePreferences(store: InMemoryKeyValueStore()) - } - - private func makeSession( - preferences: WherePreferences, - scheduler: SpyDailySummaryScheduler, - ) throws -> WhereSession { - let services = try WhereServices( - store: SwiftDataStore.inMemory(), - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - summaryScheduler: scheduler, - widgetRefresher: NoopWidgetTimelineRefresher(), - ) - return WhereSession(services: services, preferences: preferences) - } - - @Test func startConfiguresDailySummary() async throws { - let spy = SpyDailySummaryScheduler() - let session = try makeSession(preferences: makePreferences(), scheduler: spy) - - await session.start() - - #expect(await spy.authorizationRequests >= 1) - #expect(await spy.reconcileCount >= 1) - #expect(await spy.lastEnabled == true) - } - - @Test func appBecameActiveConfiguresDailySummary() async throws { - let spy = SpyDailySummaryScheduler() - let session = try makeSession(preferences: makePreferences(), scheduler: spy) - - await session.appBecameActive() - - #expect(await spy.reconcileCount >= 1) - #expect(await spy.lastEnabled == true) - } - - @Test func startWithSummaryDisabledReconcilesDisabled() async throws { - let spy = SpyDailySummaryScheduler() - let session = try makeSession(preferences: makePreferences(), scheduler: spy) - // Disabling persists synchronously, so the launch hook sees it off and - // never requests authorization. - session.summaryEnabled = false - - await session.start() - - #expect(await spy.authorizationRequests == 0) - #expect(await spy.lastEnabled == false) - } -} - -/// Records the calls the session funnels into the daily-summary scheduler so -/// the lifecycle wiring can be asserted without touching -/// `UNUserNotificationCenter`. -private actor SpyDailySummaryScheduler: DailySummaryScheduling { - private(set) var authorizationRequests = 0 - private(set) var reconcileCount = 0 - private(set) var lastEnabled: Bool? - private(set) var lastBody: String? - - func requestAuthorization() async -> Bool { - authorizationRequests += 1 - return true - } - - func isAuthorized() async -> Bool { - true - } - - func reconcile(enabled: Bool, time _: ReminderTime, body: String) async { - reconcileCount += 1 - lastEnabled = enabled - lastBody = body - } -} diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift new file mode 100644 index 00000000..1f0e30b8 --- /dev/null +++ b/Where/WhereUI/Tests/WhereSessionTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +import WhereCore +import WhereTesting +import WhereUI + +/// Covers the always-on coordinator's launch / foreground lifecycle hooks: that +/// `start()` and `appBecameActive()` drive the persisted daily-summary intent +/// down to the reconciler's scheduler, and republish the widget snapshot from +/// whatever is already on disk. Tracking / authorization lives in +/// `WhereSessionTrackingTests`. +@MainActor +struct WhereSessionTests { + private func date(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date( + from: DateComponents(year: year, month: month, day: day, hour: 12), + )! + } + + private func makePreferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + private func makeSession( + preferences: WherePreferences, + scheduler: SpyDailySummaryScheduler, + ) throws -> WhereSession { + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: scheduler, + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + return WhereSession(services: services, preferences: preferences) + } + + // MARK: - Daily-summary configuration + + @Test func startConfiguresDailySummary() async throws { + let spy = SpyDailySummaryScheduler() + let session = try makeSession(preferences: makePreferences(), scheduler: spy) + + await session.start() + + #expect(await spy.authorizationRequests >= 1) + #expect(await spy.reconcileCount >= 1) + #expect(await spy.lastEnabled == true) + } + + @Test func appBecameActiveConfiguresDailySummary() async throws { + let spy = SpyDailySummaryScheduler() + let session = try makeSession(preferences: makePreferences(), scheduler: spy) + + await session.appBecameActive() + + #expect(await spy.reconcileCount >= 1) + #expect(await spy.lastEnabled == true) + } + + @Test func startWithSummaryDisabledReconcilesDisabled() async throws { + let preferences = makePreferences() + // The coordinator reads the persisted intent directly; disabling it in + // preferences (what `RemindersSettingsModel` writes) means the launch + // hook sees it off and never requests authorization. + preferences.summaryEnabled = false + let spy = SpyDailySummaryScheduler() + let session = try makeSession(preferences: preferences, scheduler: spy) + + await session.start() + + #expect(await spy.authorizationRequests == 0) + #expect(await spy.lastEnabled == false) + } + + // MARK: - Widget snapshot republishing on launch / activation + + /// The widget extension only reads the published snapshot file, so opening + /// the app with data already on disk must republish — otherwise the widget + /// stays blank/stale until the next write. + @Test func startPublishesWidgetSnapshotFromExistingData() async throws { + let (session, refresher) = try await makePublishingSession() + await session.start() + #expect(await refresher.publishCount == 1) + #expect(await refresher.lastSnapshot?.totals == [.california: 1]) + } + + /// Returning to the foreground (e.g. on a new calendar day) recomputes and + /// republishes too. + @Test func appBecameActiveRepublishesWidgetSnapshot() async throws { + let (session, refresher) = try await makePublishingSession() + await session.appBecameActive() + #expect(await refresher.publishCount == 1) + #expect(await refresher.lastSnapshot?.totals == [.california: 1]) + } + + /// A session whose services already hold one California day (seeded + /// straight into the store so nothing is published yet), wired to a spy + /// refresher and a fixed "now" so the year report is deterministic. + private func makePublishingSession() async throws -> (WhereSession, SpyWidgetRefresher) { + let store = try TestStore() + // Seed straight into the store (inside `perform`, as the store + // requires) so nothing is published before the session's lifecycle hook. + let seed = DayPresence(date: date(year: 2026, month: 3, day: 1), regions: [.california]) + try await store.perform { try await store.setManualDay(seed) } + let refresher = SpyWidgetRefresher() + let now = date(year: 2026, month: 3, day: 15) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + widgetRefresher: refresher, + now: { now }, + ) + return (WhereSession(services: services), refresher) + } +} + +/// Records the calls the session funnels into the daily-summary scheduler so +/// the lifecycle wiring can be asserted without touching +/// `UNUserNotificationCenter`. +private actor SpyDailySummaryScheduler: DailySummaryScheduling { + private(set) var authorizationRequests = 0 + private(set) var reconcileCount = 0 + private(set) var lastEnabled: Bool? + private(set) var lastBody: String? + + func requestAuthorization() async -> Bool { + authorizationRequests += 1 + return true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile(enabled: Bool, time _: ReminderTime, body: String) async { + reconcileCount += 1 + lastEnabled = enabled + lastBody = body + } +} + +/// Captures the snapshots published through the widget publisher so the +/// session's launch/activation hooks can be checked for republishing widget +/// data. +private actor SpyWidgetRefresher: WidgetTimelineRefreshing { + private(set) var publishedSnapshots: [WidgetSnapshot] = [] + + var publishCount: Int { + publishedSnapshots.count + } + + var lastSnapshot: WidgetSnapshot? { + publishedSnapshots.last + } + + func publish(_ snapshot: WidgetSnapshot) async { + publishedSnapshots.append(snapshot) + } +} diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift new file mode 100644 index 00000000..da7c303c --- /dev/null +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -0,0 +1,424 @@ +import Foundation +import Testing +import WhereCore +import WhereTesting +@testable import WhereUI + +/// Covers `YearReportModel`: the year report load (out-of-order year fetches, failed +/// manual saves), the missing-day computation the banner / backfill read, the +/// Resolve badge count, and the store-change observer that keeps them honest. +@MainActor +struct YearReportModelTests { + private func date(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date( + from: DateComponents(year: year, month: month, day: day, hour: 12), + )! + } + + /// Build dates in the same calendar `YearReportModel` 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 makeServices() throws -> WhereServices { + try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + } + + // MARK: - Year load / stale fetches / save errors + + @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { + let store = try TestStore() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + + // Seed each year with a distinct region so we can tell which report won. + try await services.journal.addManualDay( + date: date(year: 2024, month: 3, day: 1), + regions: [.newYork], + ) + try await services.journal.addManualDay( + date: date(year: 2026, month: 3, day: 1), + regions: [.california], + ) + + let report = YearReportModel(services: services, selectedYear: 2026) + await store.enableFirstSamplesGate() + + // Start the 2024 fetch; it suspends inside the gated `samples(in:)`. + let stale = Task { await report.select(year: 2024) } + await store.awaitFirstSamplesCall() + + // The 2026 fetch runs to completion while 2024 is still in flight. + await report.select(year: 2026) + #expect(report.report?.year == 2026) + + // Now let the slower 2024 fetch finish — it must be discarded. + await store.releaseFirstSamplesCall() + await stale.value + + #expect(report.selectedYear == 2026) + #expect(report.report?.year == 2026) + #expect(report.report?.totals[.california] == 1) + #expect(report.report?.totals[.newYork] == nil) + #expect(report.loadState == .loaded) + } + + @Test func failedManualSaveThrowsAndLeavesLoadStateAlone() async throws { + let store = try TestStore() + await store.failManualDays() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let report = YearReportModel(services: services, selectedYear: 2026) + + await #expect(throws: ManualSaveFailure.self) { + try await report.setManualDay( + date: self.date(year: 2026, month: 1, day: 2), + regions: [.california], + ) + } + + // A failed save must not flip the whole screen into the error state; + // the form surfaces the error inline instead. + #expect(report.loadState == .idle) + } + + @Test func failedManualRangeSaveThrows() async throws { + let store = try TestStore() + await store.failManualDays() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let report = YearReportModel(services: services, selectedYear: 2026) + + await #expect(throws: ManualSaveFailure.self) { + try await report.setManualDays( + from: self.date(year: 2026, month: 1, day: 2), + through: self.date(year: 2026, month: 1, day: 4), + regions: [.california], + ) + } + } + + /// A failed year-report load parks `loadState` at `.failed(.reportUnavailable)` + /// — the typed reason the error screens present — rather than a bare string. + @Test func failedReportLoadParksInReportUnavailable() async throws { + let store = try TestStore() + await store.failSamples() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let report = YearReportModel(services: services, selectedYear: 2026) + + await report.refresh() + + guard case let .failed(.reportUnavailable(message)) = report.loadState else { + Issue.record("expected .failed(.reportUnavailable), got \(report.loadState)") + return + } + #expect(!message.isEmpty) + } + + // MARK: - Missing days + + @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 = try YearReportModel( + services: makeServices(), + report: YearReport( + year: 2026, + days: present.map { DayPresence(date: $0, regions: [.california]) }, + totals: [.california: present.count], + ), + 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(report.missingDays.map(\.start) == [ + Self.day(2026, 1, 1), + Self.day(2026, 1, 3), + ]) + #expect(report.missingDayCount == 2) + #expect(!report.missingDays.contains { $0.start == Self.day(2026, 1, 5) }) + } + + @Test func missingDaysAreEmptyWhenViewingAPastYear() throws { + let today = Self.day(2026, 6, 1) + let report = try YearReportModel( + services: makeServices(), + report: YearReport(year: 2025, days: [], totals: [:]), + selectedYear: 2025, + now: { today }, + ) + + #expect(report.missingDays.isEmpty) + #expect(report.missingDayCount == 0) + } + + // MARK: - Data-issue badge count + + @Test func refreshDataIssueCountCountsMissingDays() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) + + try await services.journal.addManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + await report.refresh() + await report.refreshDataIssueCount(force: true) + + #expect(report.dataIssueCount > 0) + } + + @Test func driftThresholdChangePersists() throws { + let store = try TestStore() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: preferences, + ) + + report.driftThreshold = .km25 + #expect(preferences.driftThresholdMeters == DriftThreshold.km25.rawValue) + } + + /// The Resolve list keys its scan `.task(id:)` on `dataIssueScanInputs`, so a + /// drift-threshold change must change that identity — otherwise the list keeps + /// a stale scan while the badge count moves and the two visibly disagree. The + /// mirror also has to be observable (it can't read straight through the + /// non-observable `WherePreferences`) or a dependent view's `body` would never + /// re-run to re-key the task. + @Test func dataIssueScanInputsTrackTheDriftThreshold() throws { + let store = try TestStore() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let report = YearReportModel( + services: services, + report: YearReport(year: 2026, days: [], totals: [:]), + selectedYear: 2026, + preferences: preferences, + ) + + let before = report.dataIssueScanInputs + report.driftThreshold = .km25 + + #expect(report.dataIssueScanInputs != before) + #expect(report.dataIssueScanInputs.driftThreshold == .km25) + } + + // MARK: - Store-change observer + + /// The write path no longer refreshes inline: a manual edit commits, the + /// store pings `changes()`, and the data-change observer re-pulls the report + /// + badge count. Proves the single read path keeps the UI honest. + @Test func manualWriteRefreshesViaDataChangeObserver() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) + + // Observer only — no `refresh()` called here, so any state change must + // arrive through the committed write's ping. + report.observeDataChanges() + #expect(report.report == nil) + + try await report.setManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + + // The observer re-pulls the report first, then the badge count, so wait + // for the *count* to land rather than just the report — otherwise this + // races the two awaits and can read a stale 0 count between them. + await waitUntil { report.report?.days.isEmpty == false && report.dataIssueCount > 0 } + #expect(report.report?.days.isEmpty == false) + #expect(report.dataIssueCount > 0) + } + + /// Guards against a retain cycle through the long-lived data-change observer: + /// it captures `[weak self]` and `deinit` cancels it, so dropping the last + /// strong reference deallocates the model even while the task is parked in + /// `for await` (a quiet store emits nothing on its own). + @Test func deinitsWhileObservingDataChanges() throws { + let store = try TestStore() + weak var weakReport: YearReportModel? + do { + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let report = YearReportModel(services: services, selectedYear: 2026) + weakReport = report + report.observeDataChanges() + #expect(weakReport != nil) + } + #expect(weakReport == nil) + } + + // MARK: - Scene activate / deactivate (the background-rescan leak fix) + + /// `activate()` subscribes and pulls, so the scene shows fresh data the moment + /// it appears and stays live for later writes without a second `activate()`. + @Test func activatePullsFreshDataAndStaysLive() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + try await services.journal.addManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) + await report.activate() + #expect(report.report?.days.count == 1) + #expect(report.loadState == .loaded) + + // Still subscribed — a later committed write re-pulls on its own. + try await report.setManualDay( + date: date(year: 2026, month: 1, day: 2), + regions: [.california], + ) + await waitUntil { report.report?.days.count == 2 } + } + + /// The leak fix: once the scene backgrounds, `deactivate()` cancels the + /// subscription so a committed write drives no refresh. Proven against a live + /// probe sharing the same store — both subscribe to the same fan-out, so by + /// the time the probe reacts to the write the paused model has already seen + /// (and ignored) the same ping. + @Test func deactivateStopsRefreshingOnWrites() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) + await report.activate() + #expect(report.report?.days.count == 0) + report.deactivate() + + let probe = YearReportModel(services: services, selectedYear: 2026, now: { now }) + await probe.activate() + + try await services.journal.addManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + await waitUntil { probe.report?.days.count == 1 } + + // The paused model never re-pulled off the same signal. + #expect(report.report?.days.count == 0) + } + + /// Reactivating on foreground re-subscribes *and* pulls, so a write that + /// landed while the scene was backgrounded (live GPS, a remote sync) is caught + /// up — the deactivate/activate pair can't strand the UI on stale data. + @Test func reactivatingAfterBackgroundPullsTheGap() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 2, day: 10) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) + await report.activate() + #expect(report.report?.days.count == 0) + report.deactivate() + + // A write while "backgrounded" — the paused model can't observe it. + try await services.journal.addManualDay( + date: date(year: 2026, month: 1, day: 1), + regions: [.california], + ) + #expect(report.report?.days.count == 0) + + // Foregrounding re-subscribes and pulls the gap. + await report.activate() + #expect(report.report?.days.count == 1) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + _ predicate: () -> Bool, + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if predicate() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(predicate(), "condition was not met before timeout") + } +}