From 101bb44625dc9e77af310961112463e13a6922c8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 20:38:52 -0400 Subject: [PATCH 01/16] Split WhereSession into a coordinator + scope-tiered children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhereSession had grown into an ~850-line observable that mixed always-on concerns with UI mirrors and lived for the whole logged-in lifetime, so its store-change subscription drove report/data-issue refreshes even on a headless background relaunch nothing renders. Decompose it by lifetime scope: - WhereSession stays as a slim always-on coordinator: tracking/authorization, launch-time reminder/summary application (reading WherePreferences), widget republish, and erase. No presentation state, no store-change subscription. - ReportModel (scene-scoped, @State in a new MainTabs container) owns the report/selectedYear/ranking/missing-days, the day-write intents, the drift threshold, and the Resolve badge count. It owns the store-change subscription, started on scene .active and cancelled on background — closing the leak. - ResolveModel (view-scoped in ResolutionView) owns the data-issue list + dismiss; BackupModel and RemindersSettingsModel (view-scoped in SettingsView) own backup progress and the reminder/summary editing surface. Injection is hybrid: ambient app state (WhereModel, the WhereSession coordinator) flows through the environment and is read non-optional so broken wiring is a loud crash; scoped models are passed explicitly via initializers so their wiring is compile-checked. Launch drops the loadYear step and no longer wires the subscription in syncAuth (the report loads with the scene). Tests move 1:1 with their concern (Backup/Reminders/Resolve/Report + a coordinator WhereSessionTests), and Where/AGENTS.md + the root AGENTS.md are updated to describe the new tiers and injection rule. Co-authored-by: Cursor --- AGENTS.md | 2 +- Where/AGENTS.md | 123 ++-- .../WhereUI/Sources/Launch/WhereLaunch.swift | 12 +- Where/WhereUI/Sources/MainTabs.swift | 66 ++ Where/WhereUI/Sources/Model/ReportModel.swift | 341 +++++++++ Where/WhereUI/Sources/Model/WhereModel.swift | 15 +- .../WhereUI/Sources/Model/WhereSession.swift | 649 ++---------------- .../Sources/Preview/PreviewSupport.swift | 74 +- .../Sources/Primary/CalendarView.swift | 53 +- .../Primary/PresenceTimelineView.swift | 14 +- .../WhereUI/Sources/Primary/PrimaryView.swift | 43 +- .../Resolution/AbruptChangeDetailView.swift | 20 +- .../Sources/Resolution/ResolutionView.swift | 65 +- .../Sources/Resolution/ResolveModel.swift | 89 +++ Where/WhereUI/Sources/RootView.swift | 28 +- .../Sources/Secondary/DayRelabelView.swift | 15 +- .../Sources/Secondary/RegionDaysView.swift | 14 +- .../Sources/Secondary/SecondaryView.swift | 32 +- .../Sources/Settings/BackupModel.swift | 118 ++++ .../Sources/Settings/ManualDayEntryView.swift | 27 +- .../Settings/RemindersSettingsModel.swift | 162 +++++ .../Sources/Settings/SettingsView.swift | 90 ++- .../WhereUI/Sources/Shared/YearSelector.swift | 13 +- ...ckupTests.swift => BackupModelTests.swift} | 32 +- .../Tests/RemindersSettingsModelTests.swift | 68 ++ Where/WhereUI/Tests/ReportModelTests.swift | 272 ++++++++ Where/WhereUI/Tests/ResolveModelTests.swift | 70 ++ Where/WhereUI/Tests/ScreenHostingTests.swift | 73 +- .../Tests/SwiftDataInspectorWiringTests.swift | 4 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 1 - Where/WhereUI/Tests/WhereResetTests.swift | 76 +- .../Tests/WhereSessionDataIssueTests.swift | 146 ---- .../Tests/WhereSessionMissingDaysTests.swift | 112 --- .../Tests/WhereSessionRefreshTests.swift | 163 ----- .../Tests/WhereSessionSummaryTests.swift | 87 --- Where/WhereUI/Tests/WhereSessionTests.swift | 162 +++++ 36 files changed, 1886 insertions(+), 1445 deletions(-) create mode 100644 Where/WhereUI/Sources/MainTabs.swift create mode 100644 Where/WhereUI/Sources/Model/ReportModel.swift create mode 100644 Where/WhereUI/Sources/Resolution/ResolveModel.swift create mode 100644 Where/WhereUI/Sources/Settings/BackupModel.swift create mode 100644 Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift rename Where/WhereUI/Tests/{WhereSessionBackupTests.swift => BackupModelTests.swift} (69%) create mode 100644 Where/WhereUI/Tests/RemindersSettingsModelTests.swift create mode 100644 Where/WhereUI/Tests/ReportModelTests.swift create mode 100644 Where/WhereUI/Tests/ResolveModelTests.swift delete mode 100644 Where/WhereUI/Tests/WhereSessionDataIssueTests.swift delete mode 100644 Where/WhereUI/Tests/WhereSessionMissingDaysTests.swift delete mode 100644 Where/WhereUI/Tests/WhereSessionRefreshTests.swift delete mode 100644 Where/WhereUI/Tests/WhereSessionSummaryTests.swift create mode 100644 Where/WhereUI/Tests/WhereSessionTests.swift diff --git a/AGENTS.md b/AGENTS.md index 72520183..4ed7ce81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,7 @@ 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` / +The Where app does this with `ReportModel.LoadState` (`idle` / `loading` / `loaded` / `failed(String)`) 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 diff --git a/Where/AGENTS.md b/Where/AGENTS.md index f901e896..4763921c 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -33,9 +33,12 @@ Where/ 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`, `WhereSession`) that mirror service state and - expose UI intent methods. It depends on `WhereCore` and is what the - app target imports. It is **not** the domain model — see [Layering](#layering). + models — a slim always-on `WhereSession` coordinator plus scope-tiered + children (scene-scoped `ReportModel`, view-scoped `ResolveModel` / + `BackupModel` / `RemindersSettingsModel`), all over the process-level + `WhereModel`. They mirror service state and expose UI intent methods. It + depends on `WhereCore` and is what the app target imports. It is **not** + the domain model — see [Layering](#layering). - **`WhereTesting`** is a UIKit-only helper library for hosted unit tests (`show(_:perform:)`, `waitFor(...)`, `recursiveDescription`). It is meant for test bundles, not production code. @@ -250,11 +253,11 @@ When adding copy, add the key to the catalog first, then reference it from - **Day ranges are inclusive.** `Date.calendarDays(through:in:)` normalizes both endpoints to start-of-day and walks `first ... last` inclusively; an empty range means `end` fell before `start`. -- **Inject `Calendar`, don't reach for globals.** Logged-in UI reads - `WhereSession.calendar` (Gregorian, current time zone); layout types like - `CalendarMonth` carry the calendar they were built with. Prefer - `calendar.component(...)` and `calendar.range(of:in:for:)` over hardcoding - `7` weekdays or day counts. +- **Inject `Calendar`, don't reach for globals.** The scene's `ReportModel` + owns the calendar (Gregorian, current time zone) for its missing-day math; + layout types like `CalendarMonth` carry the calendar they were built with. + Prefer `calendar.component(...)` and `calendar.range(of:in:for:)` over + hardcoding `7` weekdays or day counts. - **Core layout APIs throw on failure.** When calendar metadata can't be derived (e.g. weekday count), throw rather than silently returning a default — callers surface `ContentUnavailableView` and log, not `!`. @@ -284,27 +287,28 @@ 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 (`refresh()`, `dismiss(_:)`, `select(year:)`). 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` (`WhereSession` coordinator + scope-tiered children, `WhereModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods (`refresh()`, `dismiss(_:)`, `select(year:)`). Orchestrates `WhereServices`; does not reimplement Core rules. | +| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to the scoped models/coordinator. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. | **Data resolution** is the reference shape: `DataIssueScanner` + detectors live in -Core; `WhereSession.refreshDataIssues` / `dismiss(_:)` mirror and trigger; +Core; the scene `ReportModel` mirrors the badge *count* (`refreshDataIssueCount`) +and the view-scoped `ResolveModel` mirrors the *list* + `dismiss(_:)`; [`ResolutionView`](WhereUI/Sources/Resolution/ResolutionView.swift) only lists, routes by `IssueResolution`, and forwards dismiss. **One read path.** Every committed write emits a single store-change signal (`WhereStore.changes()`), and readers refresh purely off it rather than at each -write site. `WhereSession.observeDataChanges()` subscribes to -`services.dataChangeUpdates()` and re-pulls its report + data-issue scan on every +write site. The scene's `ReportModel.observeDataChanges()` subscribes to +`services.dataChangeUpdates()` and re-pulls its report + badge count on every ping; `DataIssueScanner` drops its cache on the same signal. So the write intent methods (`setManualDay`, `overrideDay`, …) just commit — they don't refresh inline — and live GPS ingestion and CloudKit remote imports refresh the UI the -same way a manual edit does. `select(year:)` / `appBecameActive()` still refresh -explicitly (navigation / lifecycle, not writes). +same way a manual edit does. `select(year:)` refreshes explicitly (navigation, +not a write), as does the scene's `.task`/`scenePhase` `activate()`. 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`). +serve the UI, on the coordinator / a scoped model — still not in a `View`). ## View models & launch (`WhereUI`) @@ -318,32 +322,57 @@ Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (read its process and is built *before* the store opens so a background relaunch can wire CoreLocation first. - [`WhereSession`](WhereUI/Sources/Model/WhereSession.swift) (`@MainActor - @Observable`) – logged-in view model over a **non-optional** `WhereServices`: - `selectedYear`, `report`, `loadState`, tracking/authorization, reminder + - summary settings, backup, **data issues**. Created by the launch's - `open-store` step (`startSession()`), dropped on reset (`endSession()`), and - read by logged-in views via `@Environment(WhereSession.self)` — so there are - no `guard let session` checks sprinkled through the UI. Exposes intent - methods that delegate to Core (`refresh()` → `ReportReader`, `dismiss(_:)` → - `DayJournal` + `DataIssueScanner`, etc.) and holds thin mirrors (e.g. - `dataIssues` from `services.resolution.issues(...)`). A long-lived - `observeDataChanges()` (wired into `start()` and the `syncAuth` launch step - alongside the authorization observer) subscribes to the store-change signal - and re-pulls report + data issues, so write intents just commit without - refreshing inline (see [One read path](#layering)). + @Observable`) – the **always-on coordinator** over a **non-optional** + `WhereServices`, holding **no presentation state**: tracking/authorization + (`isTracking`, `startTracking`, `requestPermission`, `observeAuthorizationChanges`), + the launch-time reminder/summary *application* (`applyReminderConfiguration` / + `applySummaryConfiguration`, reading `WherePreferences`), `refreshWidgetSnapshot`, + and `eraseSession`. Created by the launch's `open-store` step + (`startSession()`), dropped on reset (`endSession()`), and read by + always-on views via `@Environment(WhereSession.self)`. It vends + `services` / `preferences` / `now` so `MainTabs` and the tabs can build their + scoped models. +- **Scope-tiered children** decompose the old god-object by *lifetime*, so + presentation state isn't retained (or refreshed) when its UI is gone: + - [`ReportModel`](WhereUI/Sources/Model/ReportModel.swift) (**scene-scoped**, + `@State` in `MainTabs`) – `selectedYear`, `report`, `loadState`, `ranking`, + missing days, the day-write intents (`setManualDay`, `overrideDay`, …), + `driftThreshold`, and the Resolve **badge count** (`dataIssueCount`). Owns + the store-change subscription (`observeDataChanges()`), started on scene + `.active` (`activate()`) and cancelled on background (`deactivate()`) — + this is the leak fix: a headless background relaunch never drives a + `refresh()` no UI consumes. + - [`ResolveModel`](WhereUI/Sources/Resolution/ResolveModel.swift) + (**view-scoped**, `@State` in `ResolutionView`) – the data-issue **list** + + `dismiss(_:)`, re-scanned from a `.task(id:)` on the injected report. + - [`BackupModel`](WhereUI/Sources/Settings/BackupModel.swift) + + [`RemindersSettingsModel`](WhereUI/Sources/Settings/RemindersSettingsModel.swift) + (**view-scoped**, `@State` in `SettingsView`) – backup export/import + progress, and the reminder/summary editing surface (toggles/pickers, all + persisting through the shared `WherePreferences` the coordinator re-applies). +- **Injection rule (hybrid):** ambient app-lifetime state goes through the + environment (`WhereModel`, the `WhereSession` coordinator — read *non-optional* + so broken wiring is a loud crash the mandatory `#Preview`s catch); scoped + models are passed explicitly via initializers (`report:`), so their wiring is + compile-checked and their lifetime is visible. - [`WhereLaunch`](WhereUI/Sources/Launch/WhereLaunch.swift) – assembles the cold-launch `LifecycleSteps` and its reverse `resetSequence` (erase + reset), with steps named by the typed `LaunchStepID` enum (a parity test guards step - order against `WhereSession.start()`). The nested `WhereBootstrap` owns the - service assembly: `prepareLocation()` runs as the runner's synchronous + order against `WhereSession.start()`). The report loads with the scene, not a + launch step, so there's no `loadYear` step and `syncAuth` no longer wires the + store-change subscription. The nested `WhereBootstrap` owns the service + assembly: `prepareLocation()` runs as the runner's synchronous `initializePrerequisites` (installs `CLLocationManager` so a queued background event isn't lost), and `makeServices()` opens the store off the main actor. -- [`RootView`](WhereUI/Sources/RootView.swift) wraps the real `TabView` in a - `LifecycleContainer`, gating `enterForeground()` on `scenePhase == .active` so - a headless background launch stays UI-less. Tabs: Primary, Calendar, +- [`RootView`](WhereUI/Sources/RootView.swift) wraps + [`MainTabs`](WhereUI/Sources/MainTabs.swift) in a `LifecycleContainer`, gating + `enterForeground()` on `scenePhase == .active` so a headless background launch + stays UI-less. `MainTabs` owns the scene `ReportModel` (`@State`, keyed on the + session's identity so a reset rebuilds it) and drives its `activate()` / + `deactivate()` from `scenePhase`. Tabs: Primary, Elsewhere, **Resolve** ([`ResolutionView`](WhereUI/Sources/Resolution/ResolutionView.swift) — missing days, border drift, abrupt changes; badge shows - `session.dataIssueCount`), Settings. + `report.dataIssueCount`), Settings. [`AppDelegate`](Where/Sources/AppDelegate.swift) picks the `LifecycleReason` from `launchOptions` and drives the runner. @@ -361,17 +390,19 @@ states. for the canonical shape). - Don't construct services, stores, or location sources inline. Pull fixtures from - [`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) — - `loadedSession()`, `emptySession()`, `elsewhereOnlySession()`, - `missingDaysSession()`, `resolutionSession()`, `loadedModel()`, and - `sampleReport()` are all synchronous, in-memory, and never touch disk, - CloudKit, or CoreLocation. Add a new helper there rather than hand-rolling - `WhereServices` in a `#Preview`. -- Inject whatever the view reads from the environment: logged-in views read - `@Environment(WhereSession.self)`, so pass a `*Session()` fixture - (`.environment(PreviewSupport.loadedSession())`); the app-level shell - (onboarding, Settings reset) reads `WhereModel`, so pass - `.environment(PreviewSupport.loadedModel())`. + [`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) — the + coordinator `loadedSession()`; the scene models `loadedReportModel()`, + `emptyReportModel()`, `elsewhereOnlyReportModel()`, `missingDaysReportModel()`; + the seeded `resolveModel(seededWithIssues:)`; plus `loadedModel()` and + `sampleReport()` — all synchronous, in-memory, and never touch disk, CloudKit, + or CoreLocation. Add a new helper there rather than hand-rolling `WhereServices` + in a `#Preview`. +- Match the [injection rule](#view-models--launch-whereui): pass scoped models + explicitly (`ReportModel` via `report:`, a seeded `ResolveModel` via the + DEBUG `init(report:resolve:)` seam), and inject ambient app state through the + environment — the app-level shell (onboarding, Settings reset) reads + `WhereModel` (`.environment(PreviewSupport.loadedModel())`) and Settings/ + onboarding also read the coordinator (`.environment(PreviewSupport.loadedSession())`). - Cover the states that matter, not just the happy path — empty, loaded, and any distinct edge state (e.g. missing-days, elsewhere-only) each deserve a preview when the view renders them differently. 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..819603f7 --- /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 ``ReportModel`` 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: ReportModel + @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: ReportModel( + 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/ReportModel.swift b/Where/WhereUI/Sources/Model/ReportModel.swift new file mode 100644 index 00000000..424164cd --- /dev/null +++ b/Where/WhereUI/Sources/Model/ReportModel.swift @@ -0,0 +1,341 @@ +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 `ReportModel` 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 ReportModel { + /// 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-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) + + /// GPS border-drift detection threshold (device setting). Persists and forces + /// a badge recount on change; the Resolve list re-scans on its next load + /// (the scanner cache is keyed by `(year, threshold)`). + public var driftThreshold: DriftThreshold { + get { DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default } + set { + guard newValue.rawValue != preferences.driftThresholdMeters else { return } + preferences.driftThresholdMeters = newValue.rawValue + Task { await refreshDataIssueCount(force: true) } + } + } + + /// 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. + public var missingDays: [MissingDayRange] { + guard let report, isViewingCurrentYear else { return [] } + let present = Set(report.days.map(\.date)) + return MissingDays.missingRanges( + year: report.year, + through: MissingDays.backlogCutoff(asOf: now(), calendar: calendar), + present: present, + calendar: calendar, + ) + } + + /// Total number of unlogged days behind `missingDays`. + public var missingDayCount: Int { + missingDays.reduce(0) { $0 + $1.dayCount } + } + + /// 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 { + 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. + 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 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 + 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 refresh() + await refreshDataIssueCount(force: 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 refresh() + await refreshDataIssueCount(force: 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 refresh() + await refreshDataIssueCount(force: true) + } + + /// 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), + driftThresholdMeters: Double(preferences.driftThresholdMeters), + 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(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(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. Empty 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)) ?? [:] + } +} + +#if DEBUG + @_spi(Testing) extension ReportModel { + /// 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/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 6aa5ca56..78eb13ab 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 `ReportModel` 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 `ReportModel` 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..c1060022 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -6,41 +6,32 @@ 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 ``ReportModel`` (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 `ReportModel` (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 - } - /// Whether background GPS ingestion is currently attached. Reflects reality /// (authorization + the user's intent), not just the last button tap. public private(set) var isTracking = false @@ -53,128 +44,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 `ReportModel`. + 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 `ReportModel` (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 +69,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 +91,15 @@ 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) - } - - /// 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 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, - ) - } - - /// 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() }, ) { 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 +110,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 `ReportModel` 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 `ReportModel` + /// 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 +203,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 +219,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 +265,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 +284,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 +302,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 `ReportModel` 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 +330,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/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 74175832..288a904f 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 + /// `ReportModel` now — so the report/year previews take a + /// `*ReportModel()` 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 loadedReportModel() -> ReportModel { + ReportModel(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 emptyReportModel() -> ReportModel { + ReportModel( 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 elsewhereOnlyReportModel() -> ReportModel { 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 ReportModel( 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 missingDaysReportModel() -> ReportModel { 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 ReportModel( 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 `ReportModel` 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..89b058db 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: ReportModel + @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,14 +50,14 @@ 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(message) = report.loadState { ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { @@ -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.loadedReportModel()) } #Preview("Focused") { - CalendarView(focusedRegion: .california) - .environment(PreviewSupport.loadedSession()) + CalendarView(focusedRegion: .california, report: PreviewSupport.loadedReportModel()) } #Preview("Empty") { - CalendarView() - .environment(PreviewSupport.emptySession()) + CalendarView(report: PreviewSupport.emptyReportModel()) } #Preview("Missing days") { - CalendarView() - .environment(PreviewSupport.missingDaysSession()) + CalendarView(report: PreviewSupport.missingDaysReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 10c2b29c..ee5b5f00 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: ReportModel + @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: ReportModel 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.loadedReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 6a734818..f21c6b3e 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: ReportModel @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,8 +95,8 @@ 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): @@ -109,11 +106,11 @@ struct PrimaryView: View { Text(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.loadedReportModel()) } #Preview("Empty") { - PrimaryView() - .environment(PreviewSupport.emptySession()) + PrimaryView(report: PreviewSupport.emptyReportModel()) } #Preview("Missing days") { - PrimaryView() - .environment(PreviewSupport.missingDaysSession()) + PrimaryView(report: PreviewSupport.missingDaysReportModel()) } #Preview("Elsewhere only") { - PrimaryView() - .environment(PreviewSupport.elsewhereOnlySession()) + PrimaryView(report: PreviewSupport.elsewhereOnlyReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index 50803842..e8fad158 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: ReportModel + 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.loadedReportModel(), + 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..43a042dd 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -2,22 +2,49 @@ 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 `ReportModel` owns the badge *count*; this view owns the +/// list via a view-scoped `ResolveModel`, re-scanned from a `.task(id:)` keyed +/// on the injected report (so it refreshes on appear, on any committed write, +/// and on a year switch). struct ResolutionView: View { - @Environment(WhereSession.self) private var session + let report: ReportModel + @State private var resolve: ResolveModel + + init(report: ReportModel) { + 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: ReportModel, resolve: ResolveModel) { + self.report = report + _resolve = State(initialValue: resolve) + } + #endif var body: some View { NavigationStack { screen .navigationTitle(Strings.resolutionTitle) .navigationBarTitleDisplayMode(.inline) + .task(id: report.report) { + 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): @@ -27,7 +54,7 @@ struct ResolutionView: View { Text(message) } case .idle, .loaded, .loading: - if session.dataIssues.isEmpty { + if resolve.dataIssues.isEmpty { ContentUnavailableView { Label(Strings.resolutionEmptyTitle, systemImage: "checkmark.seal") } description: { @@ -46,7 +73,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 +88,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 +101,9 @@ struct ResolutionView: View { } private struct IssueRow: View { - @Environment(WhereSession.self) private var session - let issue: any DataIssue + let report: ReportModel + let resolve: ResolveModel var body: some View { NavigationLink { @@ -96,7 +123,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 +135,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 +181,16 @@ private struct IssueRow: View { #if DEBUG #Preview("Loaded") { - ResolutionView() - .environment(PreviewSupport.resolutionSession()) + ResolutionView( + report: PreviewSupport.loadedReportModel(), + resolve: PreviewSupport.resolveModel(), + ) } #Preview("Empty") { - ResolutionView() - .environment(PreviewSupport.loadedSession()) + ResolutionView( + report: PreviewSupport.loadedReportModel(), + 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..baff3ace --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -0,0 +1,89 @@ +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 `ReportModel` 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 injected report, so the list re-scans on +/// appear, on any committed write (the report value changes), and on a year +/// switch — 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] = [] + + 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)", + ) + } + } + + 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 `ReportModel` + // 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 + } + } +#endif diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index b2f7c512..3fe5db0b 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -39,25 +39,17 @@ 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 `ReportModel` and gets a fresh one whenever a reset + // rebuilds the session (keyed on its identity). + if let session = model.session { + MainTabs( + session: session, + initialReport: model.initialReport, + selectedYear: model.initialSelectedYear, + ) + .id(ObjectIdentifier(session)) } - .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..d03a737d 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: ReportModel @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: ReportModel, 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.loadedReportModel(), + ) } } #endif diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 0a2ed048..3e484e67 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: ReportModel /// 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.elsewhereOnlyReportModel()) } } #endif diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index d08c4b38..1d74f35e 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: ReportModel /// 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,8 +43,8 @@ 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): @@ -54,7 +54,7 @@ struct SecondaryView: View { Text(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.loadedReportModel()) } #Preview("Empty") { - SecondaryView() - .environment(PreviewSupport.emptySession()) + SecondaryView(report: PreviewSupport.emptyReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift new file mode 100644 index 00000000..b325f2d1 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -0,0 +1,118 @@ +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 } } + } + + /// 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? + + 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 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. 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 + /// `ReportModel` 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..e1127192 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: ReportModel + @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: ReportModel, 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.loadedReportModel()) } - .environment(PreviewSupport.loadedSession()) } #Preview("Prefill range") { NavigationStack { - ManualDayEntryView(prefill: MissingDayRange( - start: Date(timeIntervalSince1970: 0), - end: Date(timeIntervalSince1970: 86400 * 4), - dayCount: 5, - )) + ManualDayEntryView( + report: PreviewSupport.missingDaysReportModel(), + 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..cefb94f7 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: ReportModel + @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: ReportModel) { + 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.loadedReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) } diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift index 78298dbd..3012019e 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 `ReportModel`. struct YearSelector: View { - @Environment(WhereSession.self) private var session + let report: ReportModel 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.loadedReportModel()) } #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..1d242c6c --- /dev/null +++ b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift @@ -0,0 +1,68 @@ +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` and reconciles against the (no-op) scheduler, so a fresh +/// model over the same preferences reads the saved values back. +@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 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)) + } +} diff --git a/Where/WhereUI/Tests/ReportModelTests.swift b/Where/WhereUI/Tests/ReportModelTests.swift new file mode 100644 index 00000000..40f24710 --- /dev/null +++ b/Where/WhereUI/Tests/ReportModelTests.swift @@ -0,0 +1,272 @@ +import Foundation +import Testing +import WhereCore +import WhereTesting +@testable import WhereUI + +/// Covers `ReportModel`: 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 ReportModelTests { + 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 `ReportModel` 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 = ReportModel(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 = ReportModel(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 = ReportModel(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], + ) + } + } + + // 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 ReportModel( + 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 ReportModel( + 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 = ReportModel(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 = ReportModel( + services: services, + selectedYear: 2026, + preferences: preferences, + ) + + report.driftThreshold = .km25 + #expect(preferences.driftThresholdMeters == DriftThreshold.km25.rawValue) + } + + // 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 = ReportModel(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], + ) + + await waitUntil { 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: ReportModel? + do { + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + let report = ReportModel(services: services, selectedYear: 2026) + weakReport = report + report.observeDataChanges() + #expect(weakReport != nil) + } + #expect(weakReport == 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/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift new file mode 100644 index 00000000..db8b8efe --- /dev/null +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -0,0 +1,70 @@ +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: 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()), + ) + + let issue = BorderDriftIssue( + day: DayPresence(date: date(year: 2026, month: 3, day: 1), regions: [.other]), + nearestRegion: .california, + distanceMeters: 1000, + ) + resolve.setDataIssues([issue]) + #expect(resolve.dataIssues.count == 1) + + await resolve.dismiss(issue) + #expect(!resolve.dataIssues.contains { $0.id == issue.id }) + + let keys = try await store.dismissedIssueKeys() + #expect(keys.contains(issue.id.storageKey)) + } +} diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 096a3b3b..86988647 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 `ReportModel` 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.loadedReportModel() + 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.loadedReportModel() + 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.loadedReportModel()) .environment(model) .environment(session) try show(UIHostingController(rootView: rootView)) { hosted in @@ -36,40 +39,41 @@ struct ScreenHostingTests { } @Test func primaryViewHostsWithElsewhereOnlyData() throws { - let session = PreviewSupport.elsewhereOnlySession() - try show(UIHostingController(rootView: PrimaryView().environment(session))) { hosted in + let report = PreviewSupport.elsewhereOnlyReportModel() + 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.missingDaysReportModel() + 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.loadedReportModel(), 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.loadedReportModel(), 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 + // `elsewhereOnlyReportModel` 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.elsewhereOnlyReportModel() + let rootView = NavigationStack { RegionDaysView(region: .other, report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } @@ -85,27 +89,24 @@ struct ScreenHostingTests { } @Test func dayRelabelViewHosts() throws { - let session = PreviewSupport.loadedSession() + let report = PreviewSupport.loadedReportModel() 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.loadedReportModel() + 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.loadedReportModel() + try show(UIHostingController(rootView: CalendarView(report: report))) { hosted in #expect(hosted.view != nil) } } @@ -118,19 +119,17 @@ struct ScreenHostingTests { } @Test func manualDayEntryViewHostsDefault() throws { - let session = PreviewSupport.loadedSession() - let rootView = NavigationStack { ManualDayEntryView() } - .environment(session) + let report = PreviewSupport.loadedReportModel() + 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.missingDaysReportModel() + 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/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..56c8c8bc 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 = ReportModel(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 = ReportModel(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 = ReportModel(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) + } +} From 79cc13373857c1939da91f490174693602c14330 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 21:26:47 -0400 Subject: [PATCH 02/16] Rescan the Resolve list on a drift-threshold change After the WhereSession split, changing the drift threshold in Settings only recomputed the Resolve badge count (on ReportModel), not the issue list: the view-scoped ResolveModel re-scanned only via .task(id: report.report), which a threshold change doesn't mutate. The badge and list could then visibly disagree until the next committed write or year switch. WherePreferences isn't observable by design, so reading the threshold through it can't drive a body re-run. Mirror it in an observable stored property on ReportModel (as RemindersSettingsModel already does for its preferences), and key the Resolve list's scan .task on a single DataIssueScanInputs value (year + report + threshold). The list and the badge now recompute on the same triggers by construction, and the scanner cache keyed by (year, threshold) makes the two concurrent scans converge regardless of order. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/ReportModel.swift | 41 ++++++++++++++++--- .../Sources/Resolution/ResolutionView.swift | 7 ++-- .../Sources/Resolution/ResolveModel.swift | 7 ++-- Where/WhereUI/Tests/ReportModelTests.swift | 29 +++++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/Where/WhereUI/Sources/Model/ReportModel.swift b/Where/WhereUI/Sources/Model/ReportModel.swift index 424164cd..df210bec 100644 --- a/Where/WhereUI/Sources/Model/ReportModel.swift +++ b/Where/WhereUI/Sources/Model/ReportModel.swift @@ -31,6 +31,14 @@ public final class ReportModel { case failed(String) } + /// 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 @@ -61,18 +69,39 @@ public final class ReportModel { private static let logger = WhereLog.channel(.session) - /// GPS border-drift detection threshold (device setting). Persists and forces - /// a badge recount on change; the Resolve list re-scans on its next load - /// (the scanner cache is keyed by `(year, threshold)`). + /// 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 { DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default } + get { driftThresholdStorage } set { - guard newValue.rawValue != preferences.driftThresholdMeters else { return } + 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 { @@ -154,6 +183,8 @@ public final class ReportModel { 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 diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 43a042dd..a5cd4e30 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -4,8 +4,9 @@ import WhereCore /// Lists data-quality issues for the selected year and routes each to its fix /// flow. The scene's `ReportModel` owns the badge *count*; this view owns the /// list via a view-scoped `ResolveModel`, re-scanned from a `.task(id:)` keyed -/// on the injected report (so it refreshes on appear, on any committed write, -/// and on a year switch). +/// 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 { let report: ReportModel @State private var resolve: ResolveModel @@ -32,7 +33,7 @@ struct ResolutionView: View { screen .navigationTitle(Strings.resolutionTitle) .navigationBarTitleDisplayMode(.inline) - .task(id: report.report) { + .task(id: report.dataIssueScanInputs) { await resolve.load( year: report.selectedYear, primaryRegions: report.ranking.primary.map(\.region), diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index baff3ace..9ef56ada 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -11,9 +11,10 @@ import WhereCore /// The tab-bar badge *count* lives on the scene-scoped `ReportModel` 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 injected report, so the list re-scans on -/// appear, on any committed write (the report value changes), and on a year -/// switch — all while sharing the scanner's cache with the badge recount. +/// 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 { diff --git a/Where/WhereUI/Tests/ReportModelTests.swift b/Where/WhereUI/Tests/ReportModelTests.swift index 40f24710..5daf8a8a 100644 --- a/Where/WhereUI/Tests/ReportModelTests.swift +++ b/Where/WhereUI/Tests/ReportModelTests.swift @@ -205,6 +205,35 @@ struct ReportModelTests { #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 = ReportModel( + 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 From ded27e12ca2cd7162357bd2f136f2701d191f461 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 21:41:59 -0400 Subject: [PATCH 03/16] Guard the background-rescan pause/resume contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the store-change subscription onto the scene-scoped ReportModel means a backgrounded scene stops refreshing. ReportModelTests covered observeDataChanges() refreshing on a write, but nothing asserted the pause/resume contract, so a future edit could silently reintroduce the leak and stay green. Add three tests: activate() subscribes and pulls (and stays live for a later committed write); deactivate() stops refreshing on a write — proven against a live probe on the same store, which makes the negative assertion deterministic (once the probe reacts to the shared ping, the paused model has demonstrably seen and ignored it); and a subsequent activate() re-subscribes and pulls the gap accumulated while backgrounded. Co-authored-by: Cursor --- Where/WhereUI/Tests/ReportModelTests.swift | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/Where/WhereUI/Tests/ReportModelTests.swift b/Where/WhereUI/Tests/ReportModelTests.swift index 5daf8a8a..525a9e2d 100644 --- a/Where/WhereUI/Tests/ReportModelTests.swift +++ b/Where/WhereUI/Tests/ReportModelTests.swift @@ -287,6 +287,103 @@ struct ReportModelTests { #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 = ReportModel(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 = ReportModel(services: services, selectedYear: 2026, now: { now }) + await report.activate() + #expect(report.report?.days.count == 0) + report.deactivate() + + let probe = ReportModel(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 = ReportModel(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, From c9630912128e8e768491a9053d4d6fdf02360626 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 21:43:47 -0400 Subject: [PATCH 04/16] Key the scene on a monotonic session id, not its address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootView keyed MainTabs (and thus the scene-scoped ReportModel) on ObjectIdentifier(session), which is only unique among live objects: a session freed and a new one allocated at the same address could collide, leaving the scene un-rebuilt across a reset. Unlikely in practice — the old session is retained via the environment until the new tree replaces it — but the guarantee shouldn't rest on allocator behavior. Give WhereSession a process-unique, monotonically increasing SessionID minted at init and key MainTabs on session.id. A monotonic token is never reused within the process, so a rebuilt session always forces a fresh scene. Co-authored-by: Cursor --- Where/AGENTS.md | 3 ++- .../WhereUI/Sources/Model/WhereSession.swift | 25 +++++++++++++++++++ Where/WhereUI/Sources/RootView.swift | 6 +++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 4763921c..583689c0 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -368,7 +368,8 @@ Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (read its [`MainTabs`](WhereUI/Sources/MainTabs.swift) in a `LifecycleContainer`, gating `enterForeground()` on `scenePhase == .active` so a headless background launch stays UI-less. `MainTabs` owns the scene `ReportModel` (`@State`, keyed on the - session's identity so a reset rebuilds it) and drives its `activate()` / + session's monotonic `id` — never reused within the process, so a reset rebuilds + it without an address-collision risk) and drives its `activate()` / `deactivate()` from `scenePhase`. Tabs: Primary, Elsewhere, **Resolve** ([`ResolutionView`](WhereUI/Sources/Resolution/ResolutionView.swift) — missing days, border drift, abrupt changes; badge shows diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index c1060022..7685c62b 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -32,6 +32,14 @@ import WhereCore @MainActor @Observable public final class WhereSession { + /// A process-unique, monotonically increasing identity for this session. + /// `RootView` keys `MainTabs` (and thus the scene-scoped `ReportModel`) 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. public private(set) var isTracking = false @@ -91,12 +99,29 @@ public final class WhereSession { set { preferences.wantsTracking = newValue } } + /// 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 + } + + /// Monotonic source for `SessionID`s. `@MainActor`-isolated (the enclosing + /// class is), so minting from `init` needs no extra synchronization. + private static var nextRawID = 0 + + private static func mintID() -> SessionID { + defer { nextRawID += 1 } + return SessionID(value: nextRawID) + } + /// Build a coordinator over an already-assembled service layer. public init( services: WhereServices, preferences: WherePreferences = WherePreferences(), now: @escaping @Sendable () -> Date = { Date() }, ) { + id = Self.mintID() self.services = services self.preferences = preferences self.now = now diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 3fe5db0b..476000d9 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -41,14 +41,16 @@ public struct RootView: View { ) { // At `.ready` the session is always present; `MainTabs` owns the // scene-scoped `ReportModel` and gets a fresh one whenever a reset - // rebuilds the session (keyed on its identity). + // 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(ObjectIdentifier(session)) + .id(session.id) } } .environment(model) From d66421afca8b2c6084bf0edb318f766eff13996e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 21:46:32 -0400 Subject: [PATCH 05/16] Test that reminder/summary edits reconcile the scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemindersSettingsModelTests covered the preferences round-trip but not that a setter actually drives services.reminders.configure / services.summary.configure down to the scheduler. The coordinator's launch/foreground apply path is tested in WhereSessionTests, but the live edit on the Settings surface wasn't — so a setter could stop reconciling and stay green. Inject spy schedulers and assert each setter reaches the reconciler: enabling prompts for authorization and reconciles on, disabling reconciles off without a prompt, and a time edit re-reconciles with the new time — for both the reminder and the daily-summary schedule. Co-authored-by: Cursor --- .../Tests/RemindersSettingsModelTests.swift | 194 +++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift index 1d242c6c..12c2f450 100644 --- a/Where/WhereUI/Tests/RemindersSettingsModelTests.swift +++ b/Where/WhereUI/Tests/RemindersSettingsModelTests.swift @@ -6,8 +6,10 @@ import WhereUI /// Covers `RemindersSettingsModel` — the Settings editing surface for the /// reminder + daily-summary schedules. Setting a property persists to the shared -/// `WherePreferences` and reconciles against the (no-op) scheduler, so a fresh -/// model over the same preferences reads the saved values back. +/// `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 { @@ -24,6 +26,19 @@ struct RemindersSettingsModelTests { ) } + 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) } @@ -65,4 +80,179 @@ struct RemindersSettingsModelTests { #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 + } } From d1d9445be97f3486de18568bf9149bfdabd782af Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 22:52:00 -0400 Subject: [PATCH 06/16] Hold the Resolve list on a spinner until its first scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scene-scoped ReportModel scans for the tab badge on activate(), but the view-scoped ResolveModel only scans from ResolutionView's .task after the first render — so a populated Resolve tab could flash the "all clear" empty state for a frame while the badge already showed a non-zero count. Track hasLoaded on ResolveModel (set once the first scan lands, or when a fixture is seeded) and hold a spinner in ResolutionView until then, rather than the empty state. It stays true afterward, so later re-scans update the list in place with no spinner flicker. Co-authored-by: Cursor --- .../Sources/Resolution/ResolutionView.swift | 8 +++- .../Sources/Resolution/ResolveModel.swift | 13 ++++++ Where/WhereUI/Tests/ResolveModelTests.swift | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index a5cd4e30..dd410964 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -55,7 +55,13 @@ struct ResolutionView: View { Text(message) } case .idle, .loaded, .loading: - if resolve.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: { diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index 9ef56ada..0a56b4b0 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -22,6 +22,14 @@ public final class ResolveModel { /// 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) @@ -60,6 +68,10 @@ public final class ResolveModel { "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 { @@ -85,6 +97,7 @@ public final class ResolveModel { public func setDataIssues(_ issues: [any DataIssue]) { dataIssues = issues isSeeded = true + hasLoaded = true } } #endif diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index db8b8efe..5666e726 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -67,4 +67,46 @@ struct ResolveModelTests { 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) + } } From 692e7372ab5a08e169f151ea5a679676fc4fd393 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 22:52:34 -0400 Subject: [PATCH 07/16] Recount the Resolve badge off the same threshold as the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshDataIssueCount read the drift threshold straight from preferences.driftThresholdMeters, while the Resolve list scans on dataIssueScanInputs, which reads the observable driftThresholdStorage mirror. The two are kept in lockstep by the sole setter today, so this was already consistent — but reading the mirror in both places makes the badge/list agreement hold by construction rather than resting on a shared invariant, and it survives a future second writer of the preference. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/ReportModel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Model/ReportModel.swift b/Where/WhereUI/Sources/Model/ReportModel.swift index df210bec..1302ed33 100644 --- a/Where/WhereUI/Sources/Model/ReportModel.swift +++ b/Where/WhereUI/Sources/Model/ReportModel.swift @@ -253,7 +253,10 @@ public final class ReportModel { let issues = try await services.resolution.issues( year: requestedYear, primaryRegions: ranking.primary.map(\.region), - driftThresholdMeters: Double(preferences.driftThresholdMeters), + // 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 } From ebfe8c677715e930675980818c9706aed612daaa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 30 Jun 2026 22:53:35 -0400 Subject: [PATCH 08/16] Log instead of swallowing errors in the Elsewhere location reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportModel.locations(in:) and representativeCoordinates() (migrated verbatim from WhereSession) used try? and returned []/[:] on failure, discarding the error — against the repo's "never silently swallow" rule, where an empty result reads as "no locations" indistinguishable from success. Replace with do/catch that logs a warning and returns empty, so the Elsewhere drill-in still renders nothing on failure but the failure is observable in the log rather than passing silently. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/ReportModel.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Where/WhereUI/Sources/Model/ReportModel.swift b/Where/WhereUI/Sources/Model/ReportModel.swift index 1302ed33..ba9936ec 100644 --- a/Where/WhereUI/Sources/Model/ReportModel.swift +++ b/Where/WhereUI/Sources/Model/ReportModel.swift @@ -351,17 +351,32 @@ public final class ReportModel { } /// The raw coordinates recorded inside `region` during the selected year, - /// grouped by day, for the Elsewhere drill-in's map and place names. Empty on - /// failure, so the view can simply render nothing. + /// 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] { - await (try? services.reports.locations(in: region, year: selectedYear)) ?? [] + 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. - /// Empty on failure. + /// Logs and returns empty on failure (see `locations(in:)`). public func representativeCoordinates() async -> [Region: Coordinate] { - await (try? services.reports.representativeCoordinates(for: selectedYear)) ?? [:] + do { + return try await services.reports.representativeCoordinates(for: selectedYear) + } catch { + Self.logger.warning( + "Failed to load representative coordinates for \(selectedYear): \(error.localizedDescription)", + ) + return [:] + } } } From c54278ce29613d173503459cef226b1479b1812e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 1 Jul 2026 11:22:57 -0400 Subject: [PATCH 09/16] Fix a race in manualWriteRefreshesViaDataChangeObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-change observer re-pulls the report and then the badge count in two sequential awaits. The test waited only for the report to repopulate before asserting dataIssueCount > 0, so it could read the stale 0 in the window between the two awaits — fast enough to pass locally, but it lost the race on the loaded CI runner. Wait for the final state (report populated AND count > 0) instead of the intermediate one, so the assertion can't observe the gap. Co-authored-by: Cursor --- Where/WhereUI/Tests/ReportModelTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Tests/ReportModelTests.swift b/Where/WhereUI/Tests/ReportModelTests.swift index 525a9e2d..575f94b4 100644 --- a/Where/WhereUI/Tests/ReportModelTests.swift +++ b/Where/WhereUI/Tests/ReportModelTests.swift @@ -261,7 +261,11 @@ struct ReportModelTests { regions: [.california], ) - await waitUntil { report.report?.days.isEmpty == false } + // 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) } From 164685f8b30ffba319d6f0bfa02d7150e8df1e18 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:11:01 -0400 Subject: [PATCH 10/16] Rename ReportModel to YearReportModel for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scene-scoped model is specifically the selected *year*'s report and everything derived from it, so `YearReportModel` reads more clearly than the generic `ReportModel`. Pure rename (type, file, preview factories, and docs) — no behavior change. Addresses PR review: "should this be `YearReportModel` or similar?" Co-authored-by: Cursor --- AGENTS.md | 2 +- Where/AGENTS.md | 18 +++++----- Where/WhereUI/Sources/MainTabs.swift | 6 ++-- Where/WhereUI/Sources/Model/WhereModel.swift | 4 +-- .../WhereUI/Sources/Model/WhereSession.swift | 16 ++++----- ...eportModel.swift => YearReportModel.swift} | 6 ++-- .../Sources/Preview/PreviewSupport.swift | 22 ++++++------ .../Sources/Primary/CalendarView.swift | 10 +++--- .../Primary/PresenceTimelineView.swift | 6 ++-- .../WhereUI/Sources/Primary/PrimaryView.swift | 10 +++--- .../Resolution/AbruptChangeDetailView.swift | 4 +-- .../Sources/Resolution/ResolutionView.swift | 14 ++++---- .../Sources/Resolution/ResolveModel.swift | 4 +-- Where/WhereUI/Sources/RootView.swift | 2 +- .../Sources/Secondary/DayRelabelView.swift | 6 ++-- .../Sources/Secondary/RegionDaysView.swift | 4 +-- .../Sources/Secondary/SecondaryView.swift | 6 ++-- .../Sources/Settings/BackupModel.swift | 2 +- .../Sources/Settings/ManualDayEntryView.swift | 8 ++--- .../Sources/Settings/SettingsView.swift | 6 ++-- .../WhereUI/Sources/Shared/YearSelector.swift | 6 ++-- Where/WhereUI/Tests/ScreenHostingTests.swift | 36 +++++++++++-------- Where/WhereUI/Tests/WhereResetTests.swift | 6 ++-- ...Tests.swift => YearReportModelTests.swift} | 36 +++++++++---------- 24 files changed, 123 insertions(+), 117 deletions(-) rename Where/WhereUI/Sources/Model/{ReportModel.swift => YearReportModel.swift} (99%) rename Where/WhereUI/Tests/{ReportModelTests.swift => YearReportModelTests.swift} (92%) diff --git a/AGENTS.md b/AGENTS.md index 4ed7ce81..72f33702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,7 @@ 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 `ReportModel.LoadState` (`idle` / `loading` / +The Where app does this with `YearReportModel.LoadState` (`idle` / `loading` / `loaded` / `failed(String)`) 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 diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 583689c0..3cc8a154 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -34,7 +34,7 @@ Where/ Bundled region polygons (`Resources/*.geojson`) ship here. - **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models — a slim always-on `WhereSession` coordinator plus scope-tiered - children (scene-scoped `ReportModel`, view-scoped `ResolveModel` / + children (scene-scoped `YearReportModel`, view-scoped `ResolveModel` / `BackupModel` / `RemindersSettingsModel`), all over the process-level `WhereModel`. They mirror service state and expose UI intent methods. It depends on `WhereCore` and is what the app target imports. It is **not** @@ -253,7 +253,7 @@ When adding copy, add the key to the catalog first, then reference it from - **Day ranges are inclusive.** `Date.calendarDays(through:in:)` normalizes both endpoints to start-of-day and walks `first ... last` inclusively; an empty range means `end` fell before `start`. -- **Inject `Calendar`, don't reach for globals.** The scene's `ReportModel` +- **Inject `Calendar`, don't reach for globals.** The scene's `YearReportModel` owns the calendar (Gregorian, current time zone) for its missing-day math; layout types like `CalendarMonth` carry the calendar they were built with. Prefer `calendar.component(...)` and `calendar.range(of:in:for:)` over @@ -291,14 +291,14 @@ easy. | **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to the scoped models/coordinator. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. | **Data resolution** is the reference shape: `DataIssueScanner` + detectors live in -Core; the scene `ReportModel` mirrors the badge *count* (`refreshDataIssueCount`) +Core; the scene `YearReportModel` mirrors the badge *count* (`refreshDataIssueCount`) and the view-scoped `ResolveModel` mirrors the *list* + `dismiss(_:)`; [`ResolutionView`](WhereUI/Sources/Resolution/ResolutionView.swift) only lists, routes by `IssueResolution`, and forwards dismiss. **One read path.** Every committed write emits a single store-change signal (`WhereStore.changes()`), and readers refresh purely off it rather than at each -write site. The scene's `ReportModel.observeDataChanges()` subscribes to +write site. The scene's `YearReportModel.observeDataChanges()` subscribes to `services.dataChangeUpdates()` and re-pulls its report + badge count on every ping; `DataIssueScanner` drops its cache on the same signal. So the write intent methods (`setManualDay`, `overrideDay`, …) just commit — they don't refresh @@ -334,7 +334,7 @@ Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (read its scoped models. - **Scope-tiered children** decompose the old god-object by *lifetime*, so presentation state isn't retained (or refreshed) when its UI is gone: - - [`ReportModel`](WhereUI/Sources/Model/ReportModel.swift) (**scene-scoped**, + - [`YearReportModel`](WhereUI/Sources/Model/YearReportModel.swift) (**scene-scoped**, `@State` in `MainTabs`) – `selectedYear`, `report`, `loadState`, `ranking`, missing days, the day-write intents (`setManualDay`, `overrideDay`, …), `driftThreshold`, and the Resolve **badge count** (`dataIssueCount`). Owns @@ -367,7 +367,7 @@ Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (read its - [`RootView`](WhereUI/Sources/RootView.swift) wraps [`MainTabs`](WhereUI/Sources/MainTabs.swift) in a `LifecycleContainer`, gating `enterForeground()` on `scenePhase == .active` so a headless background launch - stays UI-less. `MainTabs` owns the scene `ReportModel` (`@State`, keyed on the + stays UI-less. `MainTabs` owns the scene `YearReportModel` (`@State`, keyed on the session's monotonic `id` — never reused within the process, so a reset rebuilds it without an address-collision risk) and drives its `activate()` / `deactivate()` from `scenePhase`. Tabs: Primary, Elsewhere, @@ -392,14 +392,14 @@ states. - Don't construct services, stores, or location sources inline. Pull fixtures from [`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) — the - coordinator `loadedSession()`; the scene models `loadedReportModel()`, - `emptyReportModel()`, `elsewhereOnlyReportModel()`, `missingDaysReportModel()`; + coordinator `loadedSession()`; the scene models `loadedYearReportModel()`, + `emptyYearReportModel()`, `elsewhereOnlyYearReportModel()`, `missingDaysYearReportModel()`; the seeded `resolveModel(seededWithIssues:)`; plus `loadedModel()` and `sampleReport()` — all synchronous, in-memory, and never touch disk, CloudKit, or CoreLocation. Add a new helper there rather than hand-rolling `WhereServices` in a `#Preview`. - Match the [injection rule](#view-models--launch-whereui): pass scoped models - explicitly (`ReportModel` via `report:`, a seeded `ResolveModel` via the + explicitly (`YearReportModel` via `report:`, a seeded `ResolveModel` via the DEBUG `init(report:resolve:)` seam), and inject ambient app state through the environment — the app-level shell (onboarding, Settings reset) reads `WhereModel` (`.environment(PreviewSupport.loadedModel())`) and Settings/ diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 819603f7..379b00ef 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -2,7 +2,7 @@ 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 ``ReportModel`` as +/// `.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. @@ -10,14 +10,14 @@ import WhereCore /// The four tabs receive the report by explicit init injection (compile-checked /// wiring); the always-on `WhereSession` coordinator stays in the environment. struct MainTabs: View { - @State private var report: ReportModel + @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: ReportModel( + _report = State(initialValue: YearReportModel( services: session.services, report: initialReport, selectedYear: selectedYear, diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 78eb13ab..0e871f1f 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -35,11 +35,11 @@ public final class WhereModel { let preferences: WherePreferences private let now: @Sendable () -> Date - /// The year the scene's `ReportModel` opens on. Always the current year in + /// 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 `ReportModel` with, so a + /// 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? diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 7685c62b..e145f05f 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -15,7 +15,7 @@ import WhereCore /// 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 ``ReportModel`` (owned by +/// 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``; @@ -27,13 +27,13 @@ import WhereCore /// 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 `ReportModel` (and the tabs their +/// 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 { /// A process-unique, monotonically increasing identity for this session. - /// `RootView` keys `MainTabs` (and thus the scene-scoped `ReportModel`) on + /// `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 @@ -62,11 +62,11 @@ public final class WhereSession { /// 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 `ReportModel`. + /// thread it into the scene's `YearReportModel`. let preferences: WherePreferences /// The coordinator's notion of "now". Not used by the coordinator itself; it - /// vends the injected clock to the scene's `ReportModel` (calendar / + /// 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 @@ -144,7 +144,7 @@ public final class WhereSession { /// 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 `ReportModel` owns that and starts it when the UI appears. + /// scene's `YearReportModel` owns that and starts it when the UI appears. public func start() async { await syncAuthorization() observeAuthorizationChanges() @@ -159,7 +159,7 @@ public final class WhereSession { /// 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 `ReportModel` + /// and the widget snapshot (calendar-day rollover). The scene's `YearReportModel` /// separately re-pulls the report on `.active`. public func appBecameActive() async { await syncAuthorization() @@ -331,7 +331,7 @@ public final class WhereSession { /// 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 coordinator only mirrors the outcome. The - /// scene's `ReportModel` is torn down and rebuilt by the relaunch, so no + /// 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 diff --git a/Where/WhereUI/Sources/Model/ReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift similarity index 99% rename from Where/WhereUI/Sources/Model/ReportModel.swift rename to Where/WhereUI/Sources/Model/YearReportModel.swift index ba9936ec..153a8b0f 100644 --- a/Where/WhereUI/Sources/Model/ReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -9,7 +9,7 @@ import WhereCore /// Resolve tab badge reads. /// /// Unlike `WhereSession` — the always-on coordinator that lives for the whole -/// logged-in lifetime — a `ReportModel` is created by `MainTabs` only once the +/// 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 @@ -21,7 +21,7 @@ import WhereCore /// threading the coordinator through. @MainActor @Observable -public final class ReportModel { +public final class YearReportModel { /// Where the current year's data is in its load lifecycle. `failed` carries /// a user-presentable message. public enum LoadState: Equatable { @@ -381,7 +381,7 @@ public final class ReportModel { } #if DEBUG - @_spi(Testing) extension ReportModel { + @_spi(Testing) extension YearReportModel { /// Inject a badge count for previews/tests without seeding raw samples. public func setDataIssueCount(_ count: Int) { dataIssueCount = count diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 288a904f..37fbfd73 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -62,8 +62,8 @@ /// 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 - /// `ReportModel` now — so the report/year previews take a - /// `*ReportModel()` fixture instead. + /// `YearReportModel` now — so the report/year previews take a + /// `*YearReportModel()` fixture instead. @MainActor public static func loadedSession() -> WhereSession { WhereSession(services: previewServices()) @@ -75,15 +75,15 @@ /// and in-memory services behind it. Synchronous, so it drops straight /// into `#Preview`. @MainActor - public static func loadedReportModel() -> ReportModel { - ReportModel(services: previewServices(), report: sampleReport(), selectedYear: year) + 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 emptyReportModel() -> ReportModel { - ReportModel( + public static func emptyYearReportModel() -> YearReportModel { + YearReportModel( services: previewServices(), report: YearReport(year: year, days: [], totals: [:]), selectedYear: year, @@ -94,7 +94,7 @@ /// but nothing ranks as "primary". Exercises the Primary tab's distinct /// "nothing in your headline spots" state. @MainActor - public static func elsewhereOnlyReportModel() -> ReportModel { + 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))! @@ -104,7 +104,7 @@ regions: [.other], ) } - return ReportModel( + return YearReportModel( services: previewServices(), report: YearReport(year: year, days: days, totals: [.other: days.count]), selectedYear: year, @@ -115,7 +115,7 @@ /// before a fixed "today", so missing-day detection has real gaps to /// render. @MainActor - public static func missingDaysReportModel() -> ReportModel { + 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))! @@ -129,7 +129,7 @@ regions: [.california], ) } - return ReportModel( + return YearReportModel( services: previewServices(), report: YearReport(year: year, days: days, totals: [.california: days.count]), selectedYear: year, @@ -174,7 +174,7 @@ /// A ready-to-render app model with the sample report injected and /// in-memory services behind it (so its `session` is built up front and - /// `MainTabs` seeds its `ReportModel` with the sample report). + /// `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 89b058db..fb648731 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -12,7 +12,7 @@ struct CalendarView: View { /// region. `nil` shows every region's dots. var focusedRegion: Region? - let report: ReportModel + let report: YearReportModel @Environment(\.dismiss) private var dismiss @@ -318,18 +318,18 @@ private struct DayCell: View { #if DEBUG #Preview("Loaded") { - CalendarView(report: PreviewSupport.loadedReportModel()) + CalendarView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Focused") { - CalendarView(focusedRegion: .california, report: PreviewSupport.loadedReportModel()) + CalendarView(focusedRegion: .california, report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - CalendarView(report: PreviewSupport.emptyReportModel()) + CalendarView(report: PreviewSupport.emptyYearReportModel()) } #Preview("Missing days") { - CalendarView(report: PreviewSupport.missingDaysReportModel()) + CalendarView(report: PreviewSupport.missingDaysYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index ee5b5f00..9403235f 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -5,7 +5,7 @@ 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 { - let report: ReportModel + let report: YearReportModel @Environment(\.dismiss) private var dismiss @@ -27,7 +27,7 @@ struct PresenceTimelineView: View { /// When `scrollToMonth` is set, scrolls to the first stint overlapping that /// month on appear. struct PresenceTimelineList: View { - let report: ReportModel + let report: YearReportModel var scrollToMonth: Date? @@ -123,6 +123,6 @@ private struct StintRow: View { #if DEBUG #Preview { - PresenceTimelineView(report: PreviewSupport.loadedReportModel()) + PresenceTimelineView(report: PreviewSupport.loadedYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index f21c6b3e..3b185f97 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 { - let report: ReportModel + let report: YearReportModel @State private var showingTimeline = false @State private var showingCalendar = false @@ -230,18 +230,18 @@ private struct PassportMasthead: View { #if DEBUG #Preview("Loaded") { - PrimaryView(report: PreviewSupport.loadedReportModel()) + PrimaryView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - PrimaryView(report: PreviewSupport.emptyReportModel()) + PrimaryView(report: PreviewSupport.emptyYearReportModel()) } #Preview("Missing days") { - PrimaryView(report: PreviewSupport.missingDaysReportModel()) + PrimaryView(report: PreviewSupport.missingDaysYearReportModel()) } #Preview("Elsewhere only") { - PrimaryView(report: PreviewSupport.elsewhereOnlyReportModel()) + PrimaryView(report: PreviewSupport.elsewhereOnlyYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index e8fad158..dd02a10d 100644 --- a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift @@ -7,7 +7,7 @@ struct AbruptChangeDetailView: View { @Environment(\.dismiss) private var dismiss let issue: any DataIssue - let report: ReportModel + let report: YearReportModel let resolve: ResolveModel var body: some View { @@ -97,7 +97,7 @@ struct AbruptChangeDetailView: View { regions: [.newYork], ), ), - report: PreviewSupport.loadedReportModel(), + report: PreviewSupport.loadedYearReportModel(), resolve: PreviewSupport.resolveModel(), ) } diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index dd410964..5f304d42 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -2,16 +2,16 @@ import SwiftUI import WhereCore /// Lists data-quality issues for the selected year and routes each to its fix -/// flow. The scene's `ReportModel` owns the badge *count*; this view owns the +/// 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 { - let report: ReportModel + let report: YearReportModel @State private var resolve: ResolveModel - init(report: ReportModel) { + init(report: YearReportModel) { self.report = report _resolve = State(initialValue: ResolveModel( services: report.services, @@ -22,7 +22,7 @@ struct ResolutionView: View { #if DEBUG /// Preview/test seam: inject a `ResolveModel` seeded via /// `@_spi(Testing) setDataIssues` so the list renders without raw samples. - init(report: ReportModel, resolve: ResolveModel) { + init(report: YearReportModel, resolve: ResolveModel) { self.report = report _resolve = State(initialValue: resolve) } @@ -109,7 +109,7 @@ struct ResolutionView: View { private struct IssueRow: View { let issue: any DataIssue - let report: ReportModel + let report: YearReportModel let resolve: ResolveModel var body: some View { @@ -189,14 +189,14 @@ private struct IssueRow: View { #if DEBUG #Preview("Loaded") { ResolutionView( - report: PreviewSupport.loadedReportModel(), + report: PreviewSupport.loadedYearReportModel(), resolve: PreviewSupport.resolveModel(), ) } #Preview("Empty") { ResolutionView( - report: PreviewSupport.loadedReportModel(), + report: PreviewSupport.loadedYearReportModel(), resolve: PreviewSupport.resolveModel(seededWithIssues: false), ) } diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index 0a56b4b0..2747bb53 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -8,7 +8,7 @@ import WhereCore /// `@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 `ReportModel` instead +/// 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 @@ -79,7 +79,7 @@ public final class ResolveModel { 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 `ReportModel` + // 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 { diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 476000d9..d42fa9f8 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -40,7 +40,7 @@ public struct RootView: View { failure: { LifecycleFailureView(failure: $0, retry: $1) }, ) { // At `.ready` the session is always present; `MainTabs` owns the - // scene-scoped `ReportModel` and gets a fresh one whenever a reset + // 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. diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index d03a737d..8425c489 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -10,13 +10,13 @@ struct DayRelabelView: View { @Environment(\.dismiss) private var dismiss let day: DayPresence - let report: ReportModel + let report: YearReportModel @State private var regionSelection: RegionSelectionState @State private var saveError = SaveErrorAlertState() @State private var isSaving = false - init(day: DayPresence, report: ReportModel, initialRegions: Set? = nil) { + init(day: DayPresence, report: YearReportModel, initialRegions: Set? = nil) { self.day = day self.report = report _regionSelection = State( @@ -117,7 +117,7 @@ struct DayRelabelView: View { NavigationStack { DayRelabelView( day: DayPresence(date: .now, regions: [.other]), - report: PreviewSupport.loadedReportModel(), + report: PreviewSupport.loadedYearReportModel(), ) } } diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 3e484e67..681a5e04 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -9,7 +9,7 @@ import WhereCore /// labeled with the place it reverse-geocodes to. struct RegionDaysView: View { let region: Region - let report: ReportModel + 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 @@ -201,7 +201,7 @@ private struct DayRow: View { #if DEBUG #Preview { NavigationStack { - RegionDaysView(region: .other, report: PreviewSupport.elsewhereOnlyReportModel()) + RegionDaysView(region: .other, report: PreviewSupport.elsewhereOnlyYearReportModel()) } } #endif diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 1d74f35e..de6bed8a 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 { - let report: ReportModel + let report: YearReportModel /// Reverse-geocoded "where" teaser per region, loaded asynchronously so /// each card can show the place you turned up most. Empty in @@ -110,10 +110,10 @@ struct SecondaryView: View { #if DEBUG #Preview("Loaded") { - SecondaryView(report: PreviewSupport.loadedReportModel()) + SecondaryView(report: PreviewSupport.loadedYearReportModel()) } #Preview("Empty") { - SecondaryView(report: PreviewSupport.emptyReportModel()) + SecondaryView(report: PreviewSupport.emptyYearReportModel()) } #endif diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index b325f2d1..218860b0 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -75,7 +75,7 @@ public final class BackupModel { /// 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 - /// `ReportModel` re-pulls the report + badge count — no inline refresh here. + /// `YearReportModel` re-pulls the report + badge count — no inline refresh here. public func importBackup( from url: URL, strategy: BackupCoordinator.ImportStrategy, diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index e1127192..2ac42847 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -6,7 +6,7 @@ import WhereCore /// and unions with whatever GPS recorded (see /// `DayJournal.addManualDay` / `addManualDays`). struct ManualDayEntryView: View { - let report: ReportModel + let report: YearReportModel @Environment(\.dismiss) private var dismiss @@ -35,7 +35,7 @@ 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(report: ReportModel, 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) @@ -176,14 +176,14 @@ struct ManualDayEntryView: View { #if DEBUG #Preview("Default") { NavigationStack { - ManualDayEntryView(report: PreviewSupport.loadedReportModel()) + ManualDayEntryView(report: PreviewSupport.loadedYearReportModel()) } } #Preview("Prefill range") { NavigationStack { ManualDayEntryView( - report: PreviewSupport.missingDaysReportModel(), + report: PreviewSupport.missingDaysYearReportModel(), prefill: MissingDayRange( start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: 86400 * 4), diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index cefb94f7..34911518 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -17,7 +17,7 @@ struct SettingsView: View { // 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: ReportModel + let report: YearReportModel @State private var backup: BackupModel @State private var reminders: RemindersSettingsModel @@ -38,7 +38,7 @@ struct SettingsView: View { @State private var showImportSuccess = false @State private var lastImportSummary: BackupCoordinator.ImportSummary? - init(report: ReportModel) { + init(report: YearReportModel) { self.report = report _backup = State(initialValue: BackupModel(services: report.services)) _reminders = State(initialValue: RemindersSettingsModel( @@ -467,7 +467,7 @@ struct SettingsView: View { #if DEBUG #Preview { - SettingsView(report: PreviewSupport.loadedReportModel()) + 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 3012019e..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 scene's `ReportModel`. +/// and drives the scene's `YearReportModel`. struct YearSelector: View { - let report: ReportModel + let report: YearReportModel private var years: [Int] { let current = WhereModel.currentYear @@ -38,6 +38,6 @@ struct YearSelector: View { #if DEBUG #Preview { - YearSelector(report: PreviewSupport.loadedReportModel()) + YearSelector(report: PreviewSupport.loadedYearReportModel()) } #endif diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 86988647..1a61a181 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -7,19 +7,19 @@ import WhereTesting /// Hosts each top-level screen in a real window with seeded preview data to /// confirm the Liquid Glass layouts mount without crashing. Report/year screens -/// take a `ReportModel` explicitly (constructor injection); the always-on views +/// 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 report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func secondaryViewHostsWithData() throws { - let report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() try show(UIHostingController(rootView: SecondaryView(report: report))) { hosted in #expect(hosted.view != nil) } @@ -30,7 +30,7 @@ struct ScreenHostingTests { // + inspector) from the environment, and takes the scene report explicitly. let model = PreviewSupport.loadedModel() let session = PreviewSupport.loadedSession() - let rootView = SettingsView(report: PreviewSupport.loadedReportModel()) + let rootView = SettingsView(report: PreviewSupport.loadedYearReportModel()) .environment(model) .environment(session) try show(UIHostingController(rootView: rootView)) { hosted in @@ -39,14 +39,14 @@ struct ScreenHostingTests { } @Test func primaryViewHostsWithElsewhereOnlyData() throws { - let report = PreviewSupport.elsewhereOnlyReportModel() + let report = PreviewSupport.elsewhereOnlyYearReportModel() try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func primaryViewHostsWithMissingDays() throws { - let report = PreviewSupport.missingDaysReportModel() + let report = PreviewSupport.missingDaysYearReportModel() try show(UIHostingController(rootView: PrimaryView(report: report))) { hosted in #expect(hosted.view != nil) } @@ -55,7 +55,10 @@ struct ScreenHostingTests { @Test func resolutionViewHostsWithIssues() throws { let resolve = PreviewSupport.resolveModel() #expect(!resolve.dataIssues.isEmpty) - let rootView = ResolutionView(report: PreviewSupport.loadedReportModel(), resolve: resolve) + let rootView = ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: resolve, + ) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } @@ -63,16 +66,19 @@ struct ScreenHostingTests { @Test func resolutionViewHostsEmpty() throws { let resolve = PreviewSupport.resolveModel(seededWithIssues: false) - let rootView = ResolutionView(report: PreviewSupport.loadedReportModel(), resolve: resolve) + let rootView = ResolutionView( + report: PreviewSupport.loadedYearReportModel(), + resolve: resolve, + ) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } @Test func regionDaysViewHostsWithData() throws { - // `elsewhereOnlyReportModel` 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 report = PreviewSupport.elsewhereOnlyReportModel() + let report = PreviewSupport.elsewhereOnlyYearReportModel() let rootView = NavigationStack { RegionDaysView(region: .other, report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -89,7 +95,7 @@ struct ScreenHostingTests { } @Test func dayRelabelViewHosts() throws { - let report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() let day = DayPresence(date: .now, regions: [.other]) let rootView = NavigationStack { DayRelabelView(day: day, report: report) } try show(UIHostingController(rootView: rootView)) { hosted in @@ -98,14 +104,14 @@ struct ScreenHostingTests { } @Test func presenceTimelineViewHostsWithData() throws { - let report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() try show(UIHostingController(rootView: PresenceTimelineView(report: report))) { hosted in #expect(hosted.view != nil) } } @Test func calendarViewHostsWithData() throws { - let report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() try show(UIHostingController(rootView: CalendarView(report: report))) { hosted in #expect(hosted.view != nil) } @@ -119,7 +125,7 @@ struct ScreenHostingTests { } @Test func manualDayEntryViewHostsDefault() throws { - let report = PreviewSupport.loadedReportModel() + let report = PreviewSupport.loadedYearReportModel() let rootView = NavigationStack { ManualDayEntryView(report: report) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -127,7 +133,7 @@ struct ScreenHostingTests { } @Test func manualDayEntryViewHostsPrefill() throws { - let report = PreviewSupport.missingDaysReportModel() + 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 diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 56c8c8bc..54f1d0a1 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -102,7 +102,7 @@ struct WhereResetTests { model.completeOnboarding() let session = try #require(model.session) // The scene's report model shares the coordinator's services (its store). - let report = ReportModel(services: services, preferences: preferences) + let report = YearReportModel(services: services, preferences: preferences) await session.start() #expect(session.isTracking) // .always authorization resumed GPS @@ -184,7 +184,7 @@ struct WhereResetTests { #expect(launcher.phase.isReady) let session = try #require(model.session) - let report = ReportModel(services: services, preferences: preferences) + let report = YearReportModel(services: services, preferences: preferences) try await report.setManualDay(date: Date(), regions: [.california]) await report.refresh() #expect(report.trackedDayCount == 1) @@ -227,7 +227,7 @@ struct WhereResetTests { let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run() - let report = ReportModel(services: services, preferences: preferences) + let report = YearReportModel(services: services, preferences: preferences) try await report.setManualDay(date: Date(), regions: [.california]) await report.refresh() #expect(report.trackedDayCount == 1) diff --git a/Where/WhereUI/Tests/ReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift similarity index 92% rename from Where/WhereUI/Tests/ReportModelTests.swift rename to Where/WhereUI/Tests/YearReportModelTests.swift index 575f94b4..b376c76e 100644 --- a/Where/WhereUI/Tests/ReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -4,18 +4,18 @@ import WhereCore import WhereTesting @testable import WhereUI -/// Covers `ReportModel`: the year report load (out-of-order year fetches, failed +/// 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 ReportModelTests { +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 `ReportModel` uses (gregorian, current + /// 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) @@ -58,7 +58,7 @@ struct ReportModelTests { regions: [.california], ) - let report = ReportModel(services: services, selectedYear: 2026) + let report = YearReportModel(services: services, selectedYear: 2026) await store.enableFirstSamplesGate() // Start the 2024 fetch; it suspends inside the gated `samples(in:)`. @@ -89,7 +89,7 @@ struct ReportModelTests { reminderScheduler: NoopLoggingReminderScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) - let report = ReportModel(services: services, selectedYear: 2026) + let report = YearReportModel(services: services, selectedYear: 2026) await #expect(throws: ManualSaveFailure.self) { try await report.setManualDay( @@ -112,7 +112,7 @@ struct ReportModelTests { reminderScheduler: NoopLoggingReminderScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) - let report = ReportModel(services: services, selectedYear: 2026) + let report = YearReportModel(services: services, selectedYear: 2026) await #expect(throws: ManualSaveFailure.self) { try await report.setManualDays( @@ -128,7 +128,7 @@ struct ReportModelTests { @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 ReportModel( + let report = try YearReportModel( services: makeServices(), report: YearReport( year: 2026, @@ -151,7 +151,7 @@ struct ReportModelTests { @Test func missingDaysAreEmptyWhenViewingAPastYear() throws { let today = Self.day(2026, 6, 1) - let report = try ReportModel( + let report = try YearReportModel( services: makeServices(), report: YearReport(year: 2025, days: [], totals: [:]), selectedYear: 2025, @@ -174,7 +174,7 @@ struct ReportModelTests { widgetRefresher: NoopWidgetTimelineRefresher(), now: { now }, ) - let report = ReportModel(services: services, selectedYear: 2026, now: { now }) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), @@ -195,7 +195,7 @@ struct ReportModelTests { widgetRefresher: NoopWidgetTimelineRefresher(), ) let preferences = WherePreferences(store: InMemoryKeyValueStore()) - let report = ReportModel( + let report = YearReportModel( services: services, selectedYear: 2026, preferences: preferences, @@ -220,7 +220,7 @@ struct ReportModelTests { widgetRefresher: NoopWidgetTimelineRefresher(), ) let preferences = WherePreferences(store: InMemoryKeyValueStore()) - let report = ReportModel( + let report = YearReportModel( services: services, report: YearReport(year: 2026, days: [], totals: [:]), selectedYear: 2026, @@ -249,7 +249,7 @@ struct ReportModelTests { widgetRefresher: NoopWidgetTimelineRefresher(), now: { now }, ) - let report = ReportModel(services: services, selectedYear: 2026, 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. @@ -275,7 +275,7 @@ struct ReportModelTests { /// `for await` (a quiet store emits nothing on its own). @Test func deinitsWhileObservingDataChanges() throws { let store = try TestStore() - weak var weakReport: ReportModel? + weak var weakReport: YearReportModel? do { let services = WhereServices( store: store, @@ -283,7 +283,7 @@ struct ReportModelTests { reminderScheduler: NoopLoggingReminderScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) - let report = ReportModel(services: services, selectedYear: 2026) + let report = YearReportModel(services: services, selectedYear: 2026) weakReport = report report.observeDataChanges() #expect(weakReport != nil) @@ -310,7 +310,7 @@ struct ReportModelTests { regions: [.california], ) - let report = ReportModel(services: services, selectedYear: 2026, now: { now }) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) await report.activate() #expect(report.report?.days.count == 1) #expect(report.loadState == .loaded) @@ -339,12 +339,12 @@ struct ReportModelTests { now: { now }, ) - let report = ReportModel(services: services, selectedYear: 2026, now: { now }) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) await report.activate() #expect(report.report?.days.count == 0) report.deactivate() - let probe = ReportModel(services: services, selectedYear: 2026, now: { now }) + let probe = YearReportModel(services: services, selectedYear: 2026, now: { now }) await probe.activate() try await services.journal.addManualDay( @@ -371,7 +371,7 @@ struct ReportModelTests { now: { now }, ) - let report = ReportModel(services: services, selectedYear: 2026, now: { now }) + let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) await report.activate() #expect(report.report?.days.count == 0) report.deactivate() From c75f5cbf88455e77f55a5c8c685526e15ed24aed Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:16:48 -0400 Subject: [PATCH 11/16] Move year date math out of the view model into WhereCore The missing-day derivation (present-day set + backlog cutoff, current-year only) now lives on `YearReport.missingDay{Keys,Ranges,Count}(asOf:calendar:)`, and the year-length math on `Calendar.dayCount(ofYear:)`. `YearReportModel` just supplies "now" + the calendar, so its `missingDays` / `missingDayKeys` / `missingDayCount` / `daysInSelectedYear` are one-liners with no date math. `daysInSelectedYear` also drops the hardcoded `365`: the count is derived from the calendar's day-of-year range, with the literal now only an asserted release fallback for a calendar that can't resolve a date. Addresses PR review: move the date math onto `report`; don't hardcode 365. Co-authored-by: Cursor --- .../WhereCore/Sources/Calendar+DayCount.swift | 22 ++++++++ .../Sources/YearReport+MissingDays.swift | 46 ++++++++++++++++ .../Tests/Calendar+DayCountTests.swift | 23 ++++++++ .../Tests/YearReport+MissingDaysTests.swift | 54 +++++++++++++++++++ .../Sources/Model/YearReportModel.swift | 37 +++---------- .../Sources/Primary/RegionSummaryCard.swift | 2 +- 6 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 Where/WhereCore/Sources/Calendar+DayCount.swift create mode 100644 Where/WhereCore/Sources/YearReport+MissingDays.swift create mode 100644 Where/WhereCore/Tests/Calendar+DayCountTests.swift create mode 100644 Where/WhereCore/Tests/YearReport+MissingDaysTests.swift 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/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/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 153a8b0f..9d7cd8e1 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -116,21 +116,15 @@ public final class YearReportModel { /// 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. + /// 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] { - guard let report, isViewingCurrentYear else { return [] } - let present = Set(report.days.map(\.date)) - return MissingDays.missingRanges( - year: report.year, - through: MissingDays.backlogCutoff(asOf: now(), calendar: calendar), - present: present, - calendar: calendar, - ) + report?.missingDayRanges(asOf: now(), calendar: calendar) ?? [] } /// Total number of unlogged days behind `missingDays`. public var missingDayCount: Int { - missingDays.reduce(0) { $0 + $1.dayCount } + report?.missingDayCount(asOf: now(), calendar: calendar) ?? 0 } /// The model's notion of "now", forwarded for calendar and missing-day math. @@ -140,32 +134,13 @@ public final class YearReportModel { /// 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()) + 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 { - 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 + calendar.dayCount(ofYear: selectedYear) } /// Build a report model over an already-assembled service layer. `report` is 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 From 269757863144b3a3f860733edfb55e61f6120cef Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:20:56 -0400 Subject: [PATCH 12/16] Own the backup export file lifecycle in WhereCore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export staging-directory bookkeeping — track the last export and delete it when the next one starts — moves from `BackupModel` into the `BackupCoordinator` actor, which already owns export. This also fixes a silently-swallowed `try?` on the cleanup (now logged) and makes the cleanup survive the Settings view being torn down, since the coordinator lives for the app. `BackupModel` keeps only the export/import UI state. Addresses PR review: push more of this "fat" view model down into WhereCore. Co-authored-by: Cursor --- .../WhereCore/Sources/BackupCoordinator.swift | 37 +++++++++++++++++-- .../Tests/BackupCoordinatorTests.swift | 17 +++++++++ .../Sources/Settings/BackupModel.swift | 14 +------ 3 files changed, 53 insertions(+), 15 deletions(-) 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/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/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index 218860b0..4b4ced1a 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -37,12 +37,6 @@ public final class BackupModel { set { if !newValue { backupError = nil } } } - /// 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? - private let services: WhereServices private static let logger = WhereLog.channel(.session) @@ -52,17 +46,13 @@ public final class BackupModel { /// 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. + /// 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? { - 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 { From fff422cc935e358fec3e1f05b0ece0b5f6edcb1d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:25:18 -0400 Subject: [PATCH 13/16] Give YearReportModel.LoadState a typed failure reason `LoadState.failed` now carries a typed, `Equatable` `LoadError` (`reportUnavailable` / `clearFailed`) instead of a bare `String`, so the failing operation is named in the type and tests can match the case. The error screens read `error.message`; the value stays a `String` (not `any Error`) so `LoadState` remains `Equatable` for the refresh guards. Adds a `failSamples()` hook to `TestStore` and a test that a failed load parks in `.reportUnavailable`. Addresses PR review: "should this hold an error instead?" Co-authored-by: Cursor --- .../Sources/Model/YearReportModel.swift | 30 +++++++++++++++---- .../Sources/Primary/CalendarView.swift | 4 +-- .../WhereUI/Sources/Primary/PrimaryView.swift | 4 +-- .../Sources/Resolution/ResolutionView.swift | 4 +-- .../Sources/Secondary/SecondaryView.swift | 4 +-- Where/WhereUI/Tests/Support/TestStore.swift | 12 ++++++++ .../WhereUI/Tests/YearReportModelTests.swift | 22 ++++++++++++++ 7 files changed, 67 insertions(+), 13 deletions(-) diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 9d7cd8e1..537c744b 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -22,13 +22,33 @@ import WhereCore @MainActor @Observable public final class YearReportModel { - /// Where the current year's data is in its load lifecycle. `failed` carries - /// a user-presentable message. + /// Where the current year's data is in its load lifecycle. public enum LoadState: Equatable { case idle case loading case loaded - case failed(String) + /// 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 @@ -267,7 +287,7 @@ public final class YearReportModel { } } catch { guard requestedYear == selectedYear else { return } - loadState = .failed(error.localizedDescription) + loadState = .failed(.reportUnavailable(message: error.localizedDescription)) Self.logger.warning( "Failed to load year report for \(requestedYear): \(error.localizedDescription)", ) @@ -311,7 +331,7 @@ public final class YearReportModel { // `observeDataChanges()` re-pulls; no inline refresh needed. try await services.journal.clearYear(selectedYear) } catch { - loadState = .failed(error.localizedDescription) + loadState = .failed(.clearFailed(message: error.localizedDescription)) Self.logger.warning( "Failed to clear year \(selectedYear): \(error.localizedDescription)", ) diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index fb648731..daea6457 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -57,11 +57,11 @@ struct CalendarView: View { } } else if report.loadState == .loading { ProgressView(Strings.primaryLoading) - } else if case let .failed(message) = report.loadState { + } else if case let .failed(error) = report.loadState { ContentUnavailableView { Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(message) + Text(error.message) } } else { ContentUnavailableView { diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 3b185f97..807cdcf1 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -99,11 +99,11 @@ struct PrimaryView: View { 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 report.ranking.primary.isEmpty { diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 5f304d42..378a6acf 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -48,11 +48,11 @@ struct ResolutionView: View { 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 !resolve.hasLoaded { diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index de6bed8a..634bbe5b 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -47,11 +47,11 @@ struct SecondaryView: View { 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 report.ranking.secondary.isEmpty { 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/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index b376c76e..da7c303c 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -123,6 +123,28 @@ struct YearReportModelTests { } } + /// 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 { From 6ac4e085df8c614fdfb70294b54481b384c7dc1e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:27:06 -0400 Subject: [PATCH 14/16] Fold the paired report+badge refresh into refreshAll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activate()`, `select(year:)`, and the store-change observer all pulled a fresh report and recounted the Resolve badge back to back; give that pairing a name (`refreshAll(forceDataIssueCount:)`). `refresh()` and `refreshDataIssueCount(force:)` stay separately callable — the drift-threshold setter recomputes only the count, with no report re-pull. Addresses PR review: "any reason refresh and refreshDataIssueCount are separate?" Co-authored-by: Cursor --- .../WhereUI/Sources/Model/YearReportModel.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 537c744b..1a4bb6d8 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -195,8 +195,7 @@ public final class YearReportModel { /// is set up at most once until `deactivate()`. public func activate() async { observeDataChanges() - await refresh() - await refreshDataIssueCount(force: false) + await refreshAll(forceDataIssueCount: false) } /// Stop observing committed writes. Called by `MainTabs` when the scene goes @@ -220,8 +219,7 @@ public final class YearReportModel { dataChangeTask = Task { @MainActor [weak self] in for await _ in updates { guard let self else { break } - await refresh() - await refreshDataIssueCount(force: true) + await refreshAll(forceDataIssueCount: true) } } } @@ -233,8 +231,17 @@ public final class YearReportModel { // 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: true) + await refreshDataIssueCount(force: forceDataIssueCount) } /// Recompute the Resolve badge count for the selected year. Uses the cached From 7050bd6a8ea32ee52182d254f9a3c98ba5325def Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:30:20 -0400 Subject: [PATCH 15/16] Drive the ResolveModel dismiss test through a real scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dismissWritesToStoreAndRemovesRow` seeded a fixture via `setDataIssues` and then dismissed it, so the `isSeeded` short-circuit meant the test never ran the actual `load(...)` scan. Seed the store with two calendar-adjacent, disjoint manual days instead — the scanner returns a genuine dismissible abrupt-change issue, and dismiss now exercises the production path. The seam keeps its own narrow test (`seedingMarksTheModelLoaded`) and stays preview-only. Addresses PR review: "tests aren't exercising this path — any way to trigger it instead of the isSeeded override?" Co-authored-by: Cursor --- Where/WhereUI/Tests/ResolveModelTests.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index 5666e726..06def6aa 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -40,7 +40,7 @@ struct ResolveModelTests { @Test func dismissWritesToStoreAndRemovesRow() async throws { let store = try TestStore() - let now = date(year: 2026, month: 2, day: 10) + let now = date(year: 2026, month: 6, day: 15) let services = WhereServices( store: store, locationSource: ScriptedLocationSource(), @@ -53,14 +53,21 @@ struct ResolveModelTests { preferences: WherePreferences(store: InMemoryKeyValueStore()), ) - let issue = BorderDriftIssue( - day: DayPresence(date: date(year: 2026, month: 3, day: 1), regions: [.other]), - nearestRegion: .california, - distanceMeters: 1000, + // 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], ) - resolve.setDataIssues([issue]) - #expect(resolve.dataIssues.count == 1) + 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 }) From 07a88c88c64dfcd36955a749b6de634545c0236a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:31:06 -0400 Subject: [PATCH 16/16] File deferred WhereSession-split follow-ups in TODOs Two review threads flagged follow-up work that's out of scope for this PR: compiler-checked coordinator wiring (vs the silent `@Environment` lookup) and a further split of `YearReportModel` (presentation state vs write-intent/drill-in surface). Record both under P2 so they're tracked rather than lost in the PR. Co-authored-by: Cursor --- Where/TODOs.md | 2 ++ 1 file changed, 2 insertions(+) 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)