Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions Where/WhereCore/Sources/Preferences/WherePreferences.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -124,5 +156,6 @@ public final class WherePreferences {
case summaryMinute = "where.summaryMinute"
case issueAlertsEnabled = "where.issueAlertsEnabled"
case driftThresholdMeters = "where.driftThresholdMeters"
case lastSeenLocationDayCounts = "where.lastSeenLocationDayCounts"
}
}
60 changes: 60 additions & 0 deletions Where/WhereCore/Tests/WherePreferencesTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions Where/WhereUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
9 changes: 7 additions & 2 deletions Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets ensure this only happens for increases, not just changes.

feedbackTrigger += 1
}
}
}
40 changes: 39 additions & 1 deletion Where/WhereUI/Sources/Primary/LocationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading