Skip to content

Manual-entry audit trail + on-device 24h location summary#64

Merged
kyleve merged 11 commits into
mainfrom
where-override-audit-data
Jul 6, 2026
Merged

Manual-entry audit trail + on-device 24h location summary#64
kyleve merged 11 commits into
mainfrom
where-override-audit-data

Conversation

@kyleve

@kyleve kyleve commented Jul 6, 2026

Copy link
Copy Markdown
Owner

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.

  • New ManualEntryAudit / CapturedLocation value types; DayPresence gains an optional audit (Codable back-compat via decodeIfPresent).
  • SDManualDay persists audit as optional columns (CloudKit lightweight-migration-safe); incoming audit wins in conflict resolution. Round-trips through backup.
  • One-shot LocationSource.requestCurrentLocation() (CoreLocationSource uses requestLocation() with a 10s upper-bound timeout, resolving as soon as a fix or error arrives; ScriptedLocationSource for tests), exposed via LocationIngestor.currentLocation().
  • DayJournal write API threads an explicit audit parameter; YearReportModel intents take a note, capture GPS, and assemble the audit.
  • DayRelabelView shows a read-only audit read-back; both forms gained a note field.
  • While a save is in flight, both forms show a "Capturing location…" progress row and swap Save for a spinner (rather than silently disabling Save) so the one-shot GPS capture has visible feedback. DayRelabelView models 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.

  • RecentActivitySummarizer actor + ActivitySummaryGenerating seam; FoundationModelSummaryGenerator checks SystemLanguageModel.default.availability and surfaces actionable unavailable reasons.
  • RecentActivityModel (LoadState) + RecentActivitySummaryView, presented from a Primary-tab toolbar sheet.
  • The summary sheet crossfades between load states (loading → summary → empty/unavailable/failed) instead of hard-cutting.

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 latest main (floating home toolbar / masthead removal).

Test plan

  • ./swiftformat --lint clean
  • ./ide --no-open regenerates
  • tuist test Stuff-iOS-Tests passes
  • Manual: add an override/backfill with a note, confirm the "Capturing location…" progress shows and the audit read-back shows time/note/location
  • Manual: open the recent-activity sheet on an Apple-Intelligence-capable device; verify graceful unavailable states elsewhere

Made with Cursor

kyleve and others added 10 commits July 5, 2026 23:39
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)

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.

Confirm we show a waiting / countdown UI for this

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.

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)

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.

Does this mean we always wait for 10s? This seems non-optimal; can we instead base this on the GPS resolution we have?

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.

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?) {

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.

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...

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.

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 {

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.

Do these states fade nicely?

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.

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.

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.

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.

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.

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>
@kyleve kyleve merged commit 47bd270 into main Jul 6, 2026
3 checks passed
kyleve added a commit that referenced this pull request Jul 8, 2026
…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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant