Manual-entry audit trail + on-device 24h location summary#64
Conversation
Groundwork for retaining audit metadata on manual day entries: when a manual backfill/override was made, why (a note), and where the device physically was at the time. Adds `ManualEntryAudit`/`CapturedLocation` value types and an optional `audit` on `DayPresence`, decoded with `decodeIfPresent` so pre-existing records and backup archives keep decoding. Plan step: audit-value-types. Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror ManualEntryAudit onto the SwiftData row (note + captured lat/lon/accuracy/timestamp), all optional so the CloudKit mirror stays lightweight-migration-safe and legacy rows decode with no audit. The incoming write's audit wins in the additive-over-authoritative merge so the trail always reflects the latest manual action. Plan step: audit-persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
`requestCurrentLocation()` actively asks CoreLocation for a fresh fix (coalesced waiters, 10s timeout, resolves on fix or error) to stamp a manual entry's audit trail with where it was made. Returns nil rather than throwing when no fix is available, so an entry is never blocked. ScriptedLocationSource gains an injectable fix for tests; LocationIngestor re-exposes it as currentLocation() so UI never touches the source. Plan step: gps-oneshot. Co-authored-by: Cursor <cursoragent@cursor.com>
overrideDay/addManualDay/addManualDays now take an explicit `audit` argument (no default, per the no-hidden-defaults Core rule) and stamp it onto the DayPresence they persist; a range backfill stamps one audit across every day. Existing direct callers pass `audit: nil`; the view model wires real audit next. Plan step: journal-audit-api. Co-authored-by: Cursor <cursoragent@cursor.com>
setManualDay/setManualDays/overrideDay now accept an optional `note` and build a ManualEntryAudit — stamping now, normalizing the note, and attaching a best-effort one-shot GPS fix (via LocationIngestor) for where the entry was made — before forwarding to DayJournal. A missing fix is recorded honestly rather than blocking the entry. Plan step: viewmodel-intents. Co-authored-by: Cursor <cursoragent@cursor.com>
DayRelabelView and ManualDayEntryView gain a "Reason" note field passed through on save; DayRelabelView also shows a read-only "Entry record" section (recorded time, note, capture-time coordinate) when a day carries an audit trail. Adds the localized copy + catalog entries and a preview covering the audit read-back. Plan step: ui-forms. Co-authored-by: Cursor <cursoragent@cursor.com>
RecentActivitySummarizer reads the last 24h of samples, attributes each to a region, and asks an injected ActivitySummaryGenerating for prose; production wires FoundationModelSummaryGenerator (on-device, mapping model unavailability to a typed, actionable error). An empty window returns a distinct `.empty` rather than a blank string. Wired into WhereServices as `recentActivity` (named apart from the daily-notification `summary`). Plan step: summarizer-service. Co-authored-by: Cursor <cursoragent@cursor.com>
RecentActivityModel mirrors the summarizer into a LoadState (idle/loading/loaded/empty/unavailable/failed); RecentActivitySummaryView renders each state with a refresh and is presented from a new Primary-tab toolbar button. Adds a PreviewSupport factory (forced states via a DEBUG seam), localized copy + catalog entries, and previews. Plan step: summary-ui. Co-authored-by: Cursor <cursoragent@cursor.com>
Adds Swift Testing coverage: DayPresence audit Codable back-compat + archive round-trip, SwiftDataStore audit persistence and incoming-audit- wins merge, DayJournal audit stamping, LocationIngestor.currentLocation forwarding, RecentActivitySummarizer (empty/windowing/unavailable/failure via a stub generator), RecentActivityModel state transitions, and hosting of the summary sheet + audit read-back. Updates the WhereLog category count and documents the new collaborators in Where/AGENTS.md. Plan step: docs-tests-verify. Co-authored-by: Cursor <cursoragent@cursor.com>
…data Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # Where/WhereUI/Sources/Primary/PrimaryView.swift # Where/WhereUI/Sources/Resources/Localizable.xcstrings
|
|
||
| /// How long to wait for a one-shot fix before giving up and recording no | ||
| /// captured location. Kept short so a manual entry's Save isn't held up. | ||
| private static let currentLocationTimeout: Duration = .seconds(10) |
There was a problem hiding this comment.
Confirm we show a waiting / countdown UI for this
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Good catch — we did not. Both DayRelabelView and ManualDayEntryView only disabled Save during the write. Fixed in 14b1f1a: while a save is in flight we now show a "Capturing location…" status row (spinner + label) and swap the Save button for a spinner, so the up-to-a-few-seconds GPS capture has visible feedback. No literal countdown — a spinner + label reads better than a ticking timer. DayRelabelView also now models the in-flight write as an enum (saving vs. resetting) so the location status only shows for the GPS-capturing save, not the quick reset.
| manager.requestLocation() | ||
| // Bound the wait so a Save never hangs on a slow/absent fix. | ||
| Task { @MainActor [weak self] in | ||
| try? await Task.sleep(for: Self.currentLocationTimeout) |
There was a problem hiding this comment.
Does this mean we always wait for 10s? This seems non-optimal; can we instead base this on the GPS resolution we have?
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
No — 10s is only an upper bound. resolvePendingLocation(_:) fires as soon as didUpdateLocations delivers a fix (or didFailWithError fails), whichever is first; the Task.sleep just guarantees Save never hangs on an absent fix. What we do not do today is accuracy-gating — we take the first fix requestLocation() returns (CoreLocation's own best-effort-within-a-short-window) rather than waiting for horizontalAccuracy < N. Happy to add accuracy-aware resolution as a follow-up if you want it; left as-is for now.
|
|
||
| /// Set the fix the next `requestCurrentLocation()` will return (or `nil` to | ||
| /// simulate no fix). Mirrors how `emit(_:)` scripts the passive stream. | ||
| public func setNextRequestedLocation(_ sample: LocationSample?) { |
There was a problem hiding this comment.
Is this only for tests? If not, wondering if we should restructure a bit so the "passive" location is different from the otherwise observed location...
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Yes — setNextRequestedLocation is test/preview-only; it lives on ScriptedLocationSource (the test double), not on the protocol. The passive-vs-active split you're after is already modeled at the protocol level: sampleStream is the passive observed stream (Visits + significant-change, can be minutes stale) and requestCurrentLocation() is the active one-shot fresh fix (documented in LocationSource, lines 43–51). Left as-is — let me know if you want a stronger type-level separation than two protocol members.
| } | ||
|
|
||
| @ViewBuilder | ||
| private var content: some View { |
There was a problem hiding this comment.
Do these states fade nicely?
There was a problem hiding this comment.
If not, this is something we should add to agents.md in a terse way; transition between states in a pleasant way relevant to the UI and content.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
They did not — it was a bare switch with no transitions, so states hard-cut. Fixed in 14b1f1a: each arm now has .transition(.opacity) with .animation(.smooth, value: model.loadState), so loading → summary → unavailable crossfade.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 14b1f1a — added a terse convention to Where/AGENTS.md under "SwiftUI views & previews": animate transitions between distinct states to fit the surface, do not hard-cut (with the switch-arm .transition + .animation(_:value:) pattern and the saving-row example).
Address PR #64 review feedback: - Manual-entry saves capture a one-shot GPS fix that can take a moment, so both DayRelabelView and ManualDayEntryView now show a "Capturing location…" status row and swap the Save button for a spinner while the write is in flight, instead of silently disabling Save. DayRelabelView models the in-flight write as an enum (saving vs. resetting) so only the GPS-capturing save shows the location status. - RecentActivitySummaryView crossfades between its load states rather than hard-cutting, and Where/AGENTS.md gains a terse convention to animate state transitions in a way that fits the surface. Co-authored-by: Cursor <cursoragent@cursor.com>
…ere) (#65) ## Summary Makes the tab bar and app-icon badge reflect "things that need you," and adds a notification for unresolved data issues — plus a setting to keep the conditional tabs always visible. - **Conditional tabs** (`MainTabs.swift`): the `TabView` uses a `selection` binding + `TabID` enum. Elsewhere and Resolve appear when they have content **or** are the currently selected tab, so resolving the last issue (or emptying Elsewhere) never bounces you off the tab you're on — the "All clear" state stays visible and the tab drops out the next time you switch tabs. - **"Hide empty tabs" setting** (default on): a new `hideEmptyTabs` preference (mirrored observably on `YearReportModel`, like `driftThreshold`) with a Settings → Tabs toggle. Turning it off keeps the Elsewhere and Resolve tabs permanently in the tab bar instead of hiding them when empty. - **App-icon badge** now = missing-day backlog (when reminders are enabled) + current-year data-issue count (when issue alerts are enabled). Issues badge the icon even when logging reminders are off (`ReminderReconciler` + `LoggingReminderScheduler` disabled-branch now applies the passed badge instead of clearing to 0). Per design this may double-count missing days, which appear in both. - **New local notification** for unresolved data issues, fired at the evening reminder time and cleared when the count hits 0 — a mirror of the daily-summary trio: `DataIssueAlertScheduling` + `UserNotificationDataIssueAlertScheduler` + `NoopDataIssueAlertScheduler` (SPI-gated, test-only), driven by `DataIssueAlertReconciler`. - **New `issueAlertsEnabled` preference** (default on) gating both the notification and the data-issue portion of the badge, independent of `remindersEnabled`, with a Settings toggle (`RemindersSettingsModel`, `SettingsView`, strings + xcstrings) and a launch/foreground config step (`WhereSession`, `WhereLaunch`). - **Headless issue count**: `DataIssueScanner.currentIssueCount(...)` derives primary regions internally via the shared `Region.primaryRegions(in:count:)` helper that `RegionRanking` also reuses, so the headless count matches the Resolve tab's ranking (no ranking logic is duplicated; only the cold notification path re-reads the report — the hot badge path in `ReminderReconciler` reuses its in-hand report). - **Freshness wiring**: `DayJournal` now invalidates the scanner and re-reconciles both the badge and the alert after every write, including `dismissIssue`/`restoreIssue` (which previously committed without re-badging). ## Test plan - [x] `WhereCoreTests` — new `DataIssueAlertReconcilerTests` and `UserNotificationDataIssueAlertSchedulerTests`; updated `ReminderReconcilerTests` (combined badge + reminders-off badge), `DayJournalTests`, `WhereServicesTests`, `WhereLogTests`. - [x] `WhereUITests` — new `hideEmptyTabsDefaultsOnAndPersistsAcrossModels`; updated `RemindersSettingsModelTests` (issue-alert toggle), `WhereLaunchTests` (new launch step), plus `NoopDataIssueAlertScheduler` injected across session/reset/tracking tests. - [x] Full `WhereCoreTests` + `WhereUITests` bundles pass on the iOS 17 simulator via `tuist test Stuff-iOS-Tests`. - [ ] Manual on device/simulator: enable issue alerts, seed an issue → verify the icon badges and the notification is delivered; resolve/dismiss it → verify both clear. Toggle "Hide empty tabs" off → verify Elsewhere/Resolve stay visible when empty. ### Notes for reviewers - Two "no issues" reconciler tests were rewritten after they exposed that `MissingDaysDetector` flags the whole *elapsed* current year regardless of primary regions — so an empty/cleared store mid-year still has a missing-days issue. They now use genuinely-zero-issue scenarios (an empty store at the very start of the year, and a resolve-by-dismissing-every-issue flow). - Review feedback addressed: `NoopDataIssueAlertScheduler` is `@_spi(Testing)` behind `#if DEBUG` (per the testing-hook convention), the concrete `reconcile(...)` is documented, and the `currentIssueCount` doc clarifies the shared-helper (non-duplication) design. - Merged latest `main` twice along the way (`#64` manual-entry audit trail, `#66` RegionKit extraction); the `WhereLog.Category` count now lands at 19, and `ReminderReconciler`'s `Region.primaryRegions` use picked up `import RegionKit`.
Summary
Two related additions to the Where app:
1. Manual-entry audit trail (GPS + note)
Every user-made day entry — both the authoritative override (
DayRelabelView) and the additive backfill (ManualDayEntryView) — now records when the correction was made, an optional free-text note (why), and a best-effort GPS fix for where the device was at that moment.ManualEntryAudit/CapturedLocationvalue types;DayPresencegains an optionalaudit(Codable back-compat viadecodeIfPresent).SDManualDaypersists audit as optional columns (CloudKit lightweight-migration-safe); incoming audit wins in conflict resolution. Round-trips through backup.LocationSource.requestCurrentLocation()(CoreLocationSourceusesrequestLocation()with a 10s upper-bound timeout, resolving as soon as a fix or error arrives;ScriptedLocationSourcefor tests), exposed viaLocationIngestor.currentLocation().DayJournalwrite API threads an explicitauditparameter;YearReportModelintents take anote, capture GPS, and assemble the audit.DayRelabelViewshows a read-only audit read-back; both forms gained a note field.DayRelabelViewmodels the in-flight write as an enum (saving vs. resetting) so the status only shows for the GPS-capturing save.2. On-device 24-hour location summary
A standalone, on-demand Foundation Models feature summarizing the last 24h of locations — not stored on any record.
RecentActivitySummarizeractor +ActivitySummaryGeneratingseam;FoundationModelSummaryGeneratorchecksSystemLanguageModel.default.availabilityand surfaces actionable unavailable reasons.RecentActivityModel(LoadState) +RecentActivitySummaryView, presented from a Primary-tab toolbar sheet.Docs (
Where/AGENTS.md) and Swift Testing coverage added throughout, including a terse convention to animate state transitions to fit the surface. Includes a merge of latestmain(floating home toolbar / masthead removal).Test plan
./swiftformat --lintclean./ide --no-openregeneratestuist test Stuff-iOS-TestspassesMade with Cursor