diff --git a/Where/TODOs.md b/Where/TODOs.md index 7c612564..39317e3c 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -46,7 +46,6 @@ The item format and the placement rule live in the root - test(WhereIntents) [quick-win]: The per-intent `perform()` glue — guards, snippet wiring, error→dialog mapping — is untested, because `@Dependency` traps outside the perform flow. Either extract a thin testable seam or say so in `README.md`; the reader/writer seams themselves are now well covered. (audit 2026-07-26) - refactor(WhereShareExtension) [needs-design]: Consolidate the share/add evidence form. `ShareEvidenceView.swift:68` and `AddEvidenceView.swift:37` are parallel implementations over parallel catalog namespaces (`share.form.*` / `evidence.form.*`). (audit 2026-07-26) - perf(WhereCore) [needs-design]: Consider incremental year-report reads or memoization for the widget/reminder/summary hot paths — `ReportReader.yearReport:27` and `WidgetDataReader.snapshot:85` re-aggregate a full year each time. (audit 2026-07-26) -- test(WhereCore) [quick-win]: Add `WherePreferencesTests` over `InMemoryKeyValueStore`. (audit 2026-07-26) - refactor(WhereUI): What's with all the `.accessibilityIdentifier(…)` modifiers, do we need them? (human) - feat(WhereUI): Add a UI that represents where you currently are — maybe a border on the current location card? (human) - refactor(WhereCore) [needs-design]: Per-entity schema versioning + lazy upcasting for CloudKit sync drift. There is intentionally **no** boot-time data migration or on-read legacy recovery (removed pre-release as over-built for a single dev's data). Today a data-shape change relies solely on a one-time manual backup **export → transform (`Tools/upgrade-backup.rb`) → replace-import** to rewrite rows into the current shape; `SD….toValue()` reads only the current shape and drops (fault-logs) a row it can't place (e.g. an `SDManualDay` with no `dayKey`). Gaps this leaves, which a general mechanism should close: an old-build device can sync in an old-shaped entity at any time (not just at launch), and until it's re-imported such a row is dropped on read rather than upcast. Replace with: (agent) @@ -104,6 +103,8 @@ re-recording: # Completed issues +- test(WhereCore) [quick-win]: Add `WherePreferencesTests` over `InMemoryKeyValueStore`. (Resolved 2026-08-05: `WherePreferencesTests` now pins every first-install default, year-isolated Location-card snapshot persistence, and reset clearing both the existing settings and the new presentation history.) + - fix(WhereUI) [quick-win]: `resolution.Empty_iPhone` and `..._dark` baked in the **real-world date** and drifted every day — the reference read "Jan 1 – Jul 25 / 206 days" because that is when it was recorded, and it had been silently wrong every day since, passing only because two digit glyphs are 0.046% of the image. (Resolved: `PreviewSupport.previewServices()` now passes `now: { referenceNow }`, which `WhereServices` already threads into every collaborator including the `DataIssueScanner` that computes the missing-days range. `referenceNow`'s own doc comment names "missing-day math" as a reason it exists, so this was a fixture bug against a documented intent rather than a new pin. The two references were re-recorded once and now read "Jan 1 – Jul 14 / 195 days", derived from the pinned instant. Surfaced by `./test --review`, which reported it at max channel delta 255 while the suite still reported green.) - fix(WhereUI): `resolution.Empty` never rendered the empty state, and its capture raced a live store scan — which turned `main` red (run 30402846712) the first time CI lost that race, baking the `AppIconLoadingView` placeholder over 91.7% of `Empty_iPhone`. `PreviewSupport.resolveModel(seededWithIssues: false)` skipped `setDataIssues` entirely, so the fixture came back with `hasLoaded == false` — which `ResolutionView` can't distinguish from "the first scan hasn't landed" — and the view showed the placeholder until its `.task(id:)` scan of the empty in-memory store returned the whole year as missing days. So the *reference* was that scan's output (a populated list titled "Missing days"), not the all-clear state the case names, and every capture was a race the settle loop can't see: a pixel-stable placeholder settles clean, exactly as in the `root.LoggedIn` entry below. Previously masked by the ~1s that `drainInFlightAnimations` wasted per capture; removing that waste (#151) exposed it. (Resolved: both fixture modes now seed — `setDataIssues([])` for the empty one, which is what marks it loaded *and* `isSeeded`, so the view's `load(...)` is a no-op and the first rendered frame is final. The case is now fully synchronous, independent of the store and of `now`, and the two references were re-recorded once to the "All clear" state — coverage the suite never had, since `WithIssues` already pins the populated list. `ResolveModelTests` gained two guards: the fixture is loaded up front in both modes, and `load(...)` leaves a seeded fixture alone against a store whose scan does find issues.) ## Deferred snapshot-test flakiness diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 0357a0d3..8384d350 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -96,6 +96,9 @@ internal shape. from them and rebuilds on `changes()`; assemble via the async `WhereServices.make(...)` / `forIntents()` so both attribute against the same synced set. `distanceToBoundary` is `nil` outside the tracked set. +- **Location-card history is non-authoritative preference state.** Keep its + snapshots year-keyed by stable `Region` id, and clear them through + `WherePreferences.reset()`; current report totals remain the source of truth. - **`DemoDataBuilder` seeds through the ordinary write paths** (`DayJournal`, `setPrimaryRegions`) — no private door into the store, so a demo exercises the code a real user does. Its data is sized against the *elapsed* year, not diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index a49033d6..74ab3856 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -108,7 +108,8 @@ one it belongs to rather than to a god-object: - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, - reminder / summary schedules) behind a `KeyValueStore`. The store has no + reminder / summary schedules) plus the year-keyed Location-card counts used + for presentation continuity, behind a `KeyValueStore`. The store has no default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real defaults by saying nothing. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b..82745805 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,8 +1,10 @@ import Foundation +import RegionKit /// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — behind a `KeyValueStore` -/// so production uses `UserDefaults` and tests use an in-memory double. +/// intent, and the reminder / daily-summary schedules — plus small pieces of UI +/// continuity state, behind a `KeyValueStore` so production uses `UserDefaults` +/// and tests use an in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to /// `UserDefaults.standard` made the real, process-wide defaults the thing you @@ -99,9 +101,39 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.driftThresholdMeters.rawValue) } } + /// The primary Location-card counts last presented for `year`, or `nil` + /// when that year has no baseline yet. This is non-authoritative UI + /// continuity state: the current report remains the source of truth. + public func lastSeenLocationDayCounts(in year: Int) -> [Region: Int]? { + guard + let snapshots = store.object(forKey: Keys.lastSeenLocationDayCounts.rawValue) + as? [String: [String: Int]], + let rawCounts = snapshots[String(year)] + else { + return nil + } + + return rawCounts.reduce(into: [:]) { counts, entry in + guard let region = Region(rawValue: entry.key) else { return } + counts[region] = entry.value + } + } + + /// Replaces the primary Location-card baseline for `year`, retaining the + /// other years the user has viewed. + public func setLastSeenLocationDayCounts(_ counts: [Region: Int], in year: Int) { + var snapshots = store.object(forKey: Keys.lastSeenLocationDayCounts.rawValue) + as? [String: [String: Int]] ?? [:] + snapshots[String(year)] = Dictionary(uniqueKeysWithValues: counts.map { region, days in + (region.rawValue, days) + }) + store.set(snapshots, forKey: Keys.lastSeenLocationDayCounts.rawValue) + } + /// Clear every persisted preference so the next launch behaves like a fresh /// install: onboarding shows again, background tracking returns to its - /// default intent, and the reminder/summary schedules revert to defaults. + /// default intent, reminder/summary schedules revert to defaults, and UI + /// continuity snapshots are forgotten. /// Removing the keys (rather than writing `false`/`0`) lets the /// default-valued getters report first-install state again. public func reset() { @@ -124,5 +156,6 @@ public final class WherePreferences { case summaryMinute = "where.summaryMinute" case issueAlertsEnabled = "where.issueAlertsEnabled" case driftThresholdMeters = "where.driftThresholdMeters" + case lastSeenLocationDayCounts = "where.lastSeenLocationDayCounts" } } diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 00000000..71df3e7b --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,60 @@ +import RegionKit +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + private func preferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + @Test func firstInstallDefaults() { + let preferences = preferences() + + #expect(preferences.hasOnboarded == false) + #expect(preferences.wantsTracking) + #expect(preferences.remindersEnabled) + #expect(preferences.reminderTime == .defaultEvening) + #expect(preferences.summaryEnabled) + #expect(preferences.summaryTime == .defaultMorning) + #expect(preferences.issueAlertsEnabled) + #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + } + + @Test func locationDayCountsRoundTripIndependentlyByYear() { + let preferences = preferences() + let counts2025: [Region: Int] = [.california: 42, .other: 3] + let counts2026: [Region: Int] = [.newYork: 81, .europeanUnion: 7] + + preferences.setLastSeenLocationDayCounts(counts2025, in: 2025) + preferences.setLastSeenLocationDayCounts(counts2026, in: 2026) + + #expect(preferences.lastSeenLocationDayCounts(in: 2025) == counts2025) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == counts2026) + } + + @Test func resetRestoresEveryDefaultAndClearsLocationCounts() { + let preferences = preferences() + preferences.hasOnboarded = true + preferences.wantsTracking = false + preferences.remindersEnabled = false + preferences.reminderTime = ReminderTime(hour: 9, minute: 15) + preferences.summaryEnabled = false + preferences.summaryTime = ReminderTime(hour: 17, minute: 45) + preferences.issueAlertsEnabled = false + preferences.driftThresholdMeters = 25000 + preferences.setLastSeenLocationDayCounts([.california: 100], in: 2026) + + preferences.reset() + + #expect(preferences.hasOnboarded == false) + #expect(preferences.wantsTracking) + #expect(preferences.remindersEnabled) + #expect(preferences.reminderTime == .defaultEvening) + #expect(preferences.summaryEnabled) + #expect(preferences.summaryTime == .defaultMorning) + #expect(preferences.issueAlertsEnabled) + #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1dec361e..a696c691 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -67,6 +67,10 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) renders relative to *today*, so no reference containing one is stable across days. Views don't read `\.isCapturingSnapshot` to branch themselves; capture handling stays inside the shared component. +- Reconcile `LocationDayCountPresentationModel` only from the visible primary + card surface; another tab, covering sheet, or pushed destination must leave + its persisted baseline untouched so returning can animate and haptically + signal the change. ## Design system — `WhereStylesheet` diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index d9ccdb36..e79647d1 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -75,8 +75,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** - (export/import), and **`RemindersSettingsModel`** (notification prefs). Each - orchestrates `WhereServices`; none reimplements Core rules. + (export/import), **`RemindersSettingsModel`** (notification prefs), and + **`LocationDayCountPresentationModel`** (the last primary-card counts the + user saw). The Location model holds saved values until the card surface is + visible and unobscured, then advances every changed number in one animated + beat, adding one light haptic when any count increased; decreases, first + visits, and newly appearing cards stay silent. Each model keeps its behavior + off the view; none reimplements Core rules. ### Reusable views & styling diff --git a/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift b/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift new file mode 100644 index 00000000..9657b4bc --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift @@ -0,0 +1,79 @@ +import Observation +import RegionKit +import WhereCore + +/// Holds the Location cards at the counts the user last saw until their visible +/// card surface can reconcile to the current report and signal the change once. +@MainActor +@Observable +final class LocationDayCountPresentationModel { + /// Everything that decides whether SwiftUI should restart the card-surface + /// reconciliation task. + struct ReconciliationID: Hashable { + let counts: [RegionDays] + let year: Int + let isVisible: Bool + } + + private let preferences: WherePreferences + private var lastSeenCounts: [Region: Int]? + private var displayedCounts: [Region: Int] + private(set) var year: Int + private(set) var feedbackTrigger = 0 + + init(preferences: WherePreferences, year: Int) { + self.preferences = preferences + self.year = year + let savedCounts = preferences.lastSeenLocationDayCounts(in: year) + lastSeenCounts = savedCounts + displayedCounts = savedCounts ?? [:] + } + + /// Load another year's baseline without marking its current report as seen. + /// `LocationsView` calls this while a year change is happening on another + /// tab, so the saved values are ready before the cards become visible. + func prepare(for year: Int) { + guard year != self.year else { return } + self.year = year + let savedCounts = preferences.lastSeenLocationDayCounts(in: year) + lastSeenCounts = savedCounts + displayedCounts = savedCounts ?? [:] + } + + /// The card value to render before the visible surface reconciles. A card + /// absent from the previous snapshot starts at its current value rather than + /// inventing a zero the user never saw. + func presented(_ current: RegionDays) -> RegionDays { + RegionDays( + region: current.region, + days: displayedCounts[current.region] ?? current.days, + ) + } + + /// When the cards are visible, advance them to the report, persist that + /// presentation, and emit one feedback trigger if any comparable saved value + /// increased. Hidden or obscured surfaces leave their baseline untouched. + func reconcile(_ current: [RegionDays], in year: Int, isVisible: Bool) { + guard isVisible else { return } + prepare(for: year) + + let currentCounts = Dictionary(uniqueKeysWithValues: current.map { item in + (item.region, item.days) + }) + let shouldProvideFeedback = current.contains { item in + guard let previousDays = lastSeenCounts?[item.region] else { return false } + return previousDays < item.days + } + + if displayedCounts != currentCounts { + displayedCounts = currentCounts + } + if lastSeenCounts != currentCounts { + preferences.setLastSeenLocationDayCounts(currentCounts, in: year) + lastSeenCounts = currentCounts + } + if shouldProvideFeedback { + feedbackTrigger += 1 + } + } +} diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 35a13b5a..d7e7dd7c 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -12,6 +12,8 @@ struct LocationsView: View { let report: YearReportModel @State private var showingResolution = false + @State private var isCardSurfaceVisible = false + @State private var dayCountPresentation: LocationDayCountPresentationModel /// Drives the region cards' tilt-reactive light sheen. Started/stopped /// with the view's lifecycle; a no-op on hardware without device motion. @@ -25,6 +27,22 @@ struct LocationsView: View { @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles + private var dayCountReconciliationID: LocationDayCountPresentationModel.ReconciliationID { + LocationDayCountPresentationModel.ReconciliationID( + counts: report.ranking.primary, + year: report.selectedYear, + isVisible: isCardSurfaceVisible && !showingResolution, + ) + } + + init(report: YearReportModel) { + self.report = report + _dayCountPresentation = State(initialValue: LocationDayCountPresentationModel( + preferences: report.preferences, + year: report.selectedYear, + )) + } + var body: some View { NavigationStack { screen @@ -47,6 +65,9 @@ struct LocationsView: View { } .onAppear { tilt.start() } .onDisappear { tilt.stop() } + .onChange(of: report.selectedYear) { _, year in + dayCountPresentation.prepare(for: year) + } .sheet(isPresented: $showingResolution) { ResolutionView(report: report) } @@ -93,11 +114,12 @@ struct LocationsView: View { GlassEffectContainer(spacing: stylesheet.spacing.xxLarge) { VStack(spacing: stylesheet.spacing.xxLarge) { ForEach(report.ranking.primary) { item in + let presentedItem = dayCountPresentation.presented(item) NavigationLink { calendarDestination(item.region) } label: { RegionSummaryCard( - regionDays: item, + regionDays: presentedItem, interactive: true, yearLength: report.daysInSelectedYear, year: report.selectedYear, @@ -157,6 +179,22 @@ struct LocationsView: View { .defaultScrollAnchor(.center) .scrollBounceBehavior(.basedOnSize) .accessibilityIdentifier("where_root_title") + .onAppear { isCardSurfaceVisible = true } + .onDisappear { isCardSurfaceVisible = false } + // The task belongs to the cards, and its ID includes explicit visibility + // so a covering sheet cannot consume their baseline behind itself. + .task(id: dayCountReconciliationID) { + let reconciliation = dayCountReconciliationID + dayCountPresentation.reconcile( + reconciliation.counts, + in: reconciliation.year, + isVisible: reconciliation.isVisible, + ) + } + .sensoryFeedback( + .impact(weight: .light), + trigger: dayCountPresentation.feedbackTrigger, + ) } /// The region's calendar, pushed as a nested view. It's the zoom diff --git a/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift b/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift new file mode 100644 index 00000000..0d049eb1 --- /dev/null +++ b/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift @@ -0,0 +1,128 @@ +import RegionKit +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct LocationDayCountPresentationModelTests { + private func preferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + private func item(_ region: Region, _ days: Int) -> RegionDays { + RegionDays(region: region, days: days) + } + + @Test func firstRevealEstablishesBaselineSilently() { + let preferences = preferences() + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = [item(.california, 148), item(.newYork, 37)] + + #expect(model.presented(current[0]).days == 148) + model.reconcile(current, in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [ + .california: 148, + .newYork: 37, + ]) + } + + @Test func increasedCountRevealsFromSavedValueAndTriggersOnce() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 149) + + #expect(model.presented(current).days == 148) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 148]) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 149) + #expect(model.feedbackTrigger == 1) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 149]) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 1) + } + + @Test func decreasedCountRevealsWithoutFeedback() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 147) + + #expect(model.presented(current).days == 148) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 147) + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 147]) + } + + @Test func multipleChangedCardsProduceOneFeedbackEvent() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([ + .california: 148, + .newYork: 37, + ], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + + model.reconcile([ + item(.california, 149), + item(.newYork, 38), + ], in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 1) + } + + @Test func unchangedAndNewCardsStaySilent() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = [item(.california, 148), item(.newYork, 12)] + + #expect(model.presented(current[1]).days == 12) + model.reconcile(current, in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [ + .california: 148, + .newYork: 12, + ]) + } + + @Test func hiddenReconciliationLeavesCurrentCountsPending() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 151) + + model.reconcile([current], in: 2026, isVisible: false) + + #expect(model.presented(current).days == 148) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 148]) + #expect(model.feedbackTrigger == 0) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 151) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 151]) + } + + @Test func preparingAnotherYearUsesOnlyThatYearsBaseline() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 25], in: 2025) + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + + model.prepare(for: 2025) + + #expect(model.year == 2025) + #expect(model.presented(item(.california, 30)).days == 25) + #expect(model.feedbackTrigger == 0) + } +}