From 02216acf0eebc7655706daa3fc4050d7551228fd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:39:12 -0400 Subject: [PATCH 01/10] Add ManualEntryAudit value type and DayPresence.audit field 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 --- Where/WhereCore/Sources/DayPresence.swift | 22 +++++++-- .../WhereCore/Sources/ManualEntryAudit.swift | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 Where/WhereCore/Sources/ManualEntryAudit.swift diff --git a/Where/WhereCore/Sources/DayPresence.swift b/Where/WhereCore/Sources/DayPresence.swift index 08d50eaf..3d6e3ba9 100644 --- a/Where/WhereCore/Sources/DayPresence.swift +++ b/Where/WhereCore/Sources/DayPresence.swift @@ -15,27 +15,41 @@ public struct DayPresence: Hashable, Sendable, Codable { /// the aggregator's own report-output days always leave it `false`. public let isAuthoritative: Bool - public init(date: Date, regions: Set, isAuthoritative: Bool = false) { + /// Audit metadata for a *user-made* entry: when it was made, why, and where + /// the device was at the time. `nil` for GPS-derived days and the + /// aggregator's own report-output days (which are never user entries) as + /// well as manual records written before this field existed. + public let audit: ManualEntryAudit? + + public init( + date: Date, + regions: Set, + isAuthoritative: Bool = false, + audit: ManualEntryAudit? = nil, + ) { self.date = date self.regions = regions self.isAuthoritative = isAuthoritative + self.audit = audit } private enum CodingKeys: String, CodingKey { case date case regions case isAuthoritative + case audit } /// Custom decode so records and backup archives written before - /// `isAuthoritative` existed (v1 manifests, pre-existing manual days) - /// decode as additive (non-authoritative) instead of failing on the - /// missing key. + /// `isAuthoritative` / `audit` existed (v1 manifests, pre-existing manual + /// days) decode as additive (non-authoritative) with no audit instead of + /// failing on the missing keys. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) date = try container.decode(Date.self, forKey: .date) regions = try container.decode(Set.self, forKey: .regions) isAuthoritative = try container .decodeIfPresent(Bool.self, forKey: .isAuthoritative) ?? false + audit = try container.decodeIfPresent(ManualEntryAudit.self, forKey: .audit) } } diff --git a/Where/WhereCore/Sources/ManualEntryAudit.swift b/Where/WhereCore/Sources/ManualEntryAudit.swift new file mode 100644 index 00000000..a8760db0 --- /dev/null +++ b/Where/WhereCore/Sources/ManualEntryAudit.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Audit metadata attached to a user-made manual day entry (a backfill or an +/// authoritative override), retained so a residency/day-count audit can later +/// answer *when* the correction was made, *why* (the note), and *where the +/// device physically was* at the time. +/// +/// This describes the *act of making the entry*, not the day it corrects — the +/// day and its regions live on `DayPresence`. `note` and `location` are each +/// independently optional: the user may leave the reason blank, and a GPS fix +/// may be unobtainable (permission/timeout), in which case the audit still +/// honestly records `recordedAt` with a `nil` `location` rather than a faked +/// coordinate. +public struct ManualEntryAudit: Hashable, Sendable, Codable { + /// When the manual entry was made (not the day it applies to). + public let recordedAt: Date + /// The user's free-text reason for the entry. `nil` (or empty) when none + /// was given. + public let note: String? + /// Where the device was when the entry was made, when a fix was available. + public let location: CapturedLocation? + + public init(recordedAt: Date, note: String?, location: CapturedLocation?) { + self.recordedAt = recordedAt + self.note = note + self.location = location + } +} + +/// A GPS fix captured at a single moment — the "where were you when you made +/// this entry" half of `ManualEntryAudit`. Kept as a plain value type so the +/// model layer stays CoreLocation-free (see `Coordinate`). +public struct CapturedLocation: Hashable, Sendable, Codable { + public let coordinate: Coordinate + public let horizontalAccuracy: Double + /// The timestamp reported by the location fix itself, which can differ + /// slightly from `ManualEntryAudit.recordedAt`. + public let timestamp: Date + + public init(coordinate: Coordinate, horizontalAccuracy: Double, timestamp: Date) { + self.coordinate = coordinate + self.horizontalAccuracy = horizontalAccuracy + self.timestamp = timestamp + } +} From 3b9b3846e868fc4792876e4d078d594215b9ff4f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:40:02 -0400 Subject: [PATCH 02/10] Persist manual-entry audit metadata on SDManualDay 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 --- .../Sources/Persistence/SwiftDataStore.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 9726305a..7ff0403a 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -411,6 +411,10 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// authoritative relabel: the row stays authoritative and the backfilled /// regions union in. Every other case replaces wholesale (authoritative /// overrides and additive-over-additive alike), preserving prior behavior. + /// + /// The incoming write's `audit` always wins: it reflects the most recent + /// manual action (its note, its capture-time GPS), so even when regions + /// can't be downgraded the audit trail tracks the latest edit. private static func resolved(incoming day: DayPresence, existing: SDManualDay) -> DayPresence { guard existing.isAuthoritative ?? false, !day.isAuthoritative else { return day } let existingRegions = Set((existing.regionRaws ?? []).compactMap { Region(rawValue: $0) }) @@ -418,6 +422,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { date: day.date, regions: existingRegions.union(day.regions), isAuthoritative: true, + audit: day.audit, ) } @@ -694,6 +699,18 @@ final class SDManualDay { /// pre-existing rows decode as additive (`false`). var isAuthoritative: Bool? + // Audit metadata for a user-made entry (`ManualEntryAudit`). All optional so + // the CloudKit mirror stays lightweight-migration-safe; rows written before + // audit existed (and GPS-derived days) decode with no audit. `auditRecordedAt` + // is the presence-of-audit discriminator — the location fields are populated + // together only when a fix was captured. + var note: String? + var auditRecordedAt: Date? + var auditLatitude: Double? + var auditLongitude: Double? + var auditAccuracy: Double? + var auditLocationTimestamp: Date? + init() {} convenience init(value: DayPresence) { @@ -705,6 +722,16 @@ final class SDManualDay { dateKey = value.date regionRaws = value.regions.map(\.rawValue).sorted() isAuthoritative = value.isAuthoritative + applyAudit(value.audit) + } + + private func applyAudit(_ audit: ManualEntryAudit?) { + note = audit?.note + auditRecordedAt = audit?.recordedAt + auditLatitude = audit?.location?.coordinate.latitude + auditLongitude = audit?.location?.coordinate.longitude + auditAccuracy = audit?.location?.horizontalAccuracy + auditLocationTimestamp = audit?.location?.timestamp } func toValue() -> DayPresence? { @@ -713,6 +740,29 @@ final class SDManualDay { date: dateKey, regions: Set((regionRaws ?? []).compactMap { Region(rawValue: $0) }), isAuthoritative: isAuthoritative ?? false, + audit: auditValue(), + ) + } + + private func auditValue() -> ManualEntryAudit? { + guard let auditRecordedAt else { return nil } + return ManualEntryAudit( + recordedAt: auditRecordedAt, + note: note, + location: capturedLocation(), + ) + } + + private func capturedLocation() -> CapturedLocation? { + guard let auditLatitude, + let auditLongitude, + let auditAccuracy, + let auditLocationTimestamp + else { return nil } + return CapturedLocation( + coordinate: Coordinate(latitude: auditLatitude, longitude: auditLongitude), + horizontalAccuracy: auditAccuracy, + timestamp: auditLocationTimestamp, ) } } From b43ea521a90ec38ede570823d361d5debc5e3a2c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:41:45 -0400 Subject: [PATCH 03/10] Add best-effort one-shot GPS capture to LocationSource `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 --- .../Sources/Location/CoreLocationSource.swift | 67 +++++++++++++++++++ .../Sources/Location/LocationSource.swift | 23 +++++++ .../WhereCore/Sources/LocationIngestor.swift | 9 +++ 3 files changed, 99 insertions(+) diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index e505e4ec..66c42df5 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -50,6 +50,16 @@ public final class CoreLocationSource: NSObject, LocationSource { /// thus permanently strand — the first. private var pendingPermissionContinuations: [CheckedContinuation] = [] + /// Waiters for an in-flight `requestCurrentLocation()`. Overlapping callers + /// coalesce onto the next delivered fix (or the shared timeout / failure); + /// only the first triggers `requestLocation()`. Every waiter is resumed + /// together, so a second caller can't strand the first. + private var pendingLocationContinuations: [CheckedContinuation] = [] + + /// 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) + override public init() { // The "create stream, capture its continuation" two-step is // the idiomatic Swift `AsyncStream` initializer pattern when @@ -76,6 +86,42 @@ public final class CoreLocationSource: NSObject, LocationSource { manager.stopMonitoringVisits() } + public func requestCurrentLocation() async -> LocationSample? { + // Best-effort: without a granted status `requestLocation()` just fails, + // so short-circuit to "no fix" rather than starting a doomed request. + switch manager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + break + case .denied, .restricted, .notDetermined: + return nil + @unknown default: + return nil + } + + return await withCheckedContinuation { continuation in + pendingLocationContinuations.append(continuation) + guard pendingLocationContinuations.count == 1 else { return } + 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) + self?.resolvePendingLocation(nil) + } + } + } + + /// Resume (and clear) every coalesced one-shot location waiter with the same + /// result. Cleared before resuming so a fix delivered after the timeout (or + /// vice-versa) is a no-op rather than a double-resume. + private func resolvePendingLocation(_ sample: LocationSample?) { + guard !pendingLocationContinuations.isEmpty else { return } + let waiters = pendingLocationContinuations + pendingLocationContinuations.removeAll() + for waiter in waiters { + waiter.resume(returning: sample) + } + } + public func currentAuthorization() async -> LocationAuthorizationStatus { Self.map(manager.authorizationStatus) } @@ -179,6 +225,7 @@ extension CoreLocationSource: CLLocationManagerDelegate { _: CLLocationManager, didUpdateLocations locations: [CLLocation], ) { + var latest: LocationSample? for location in locations { let sample = LocationSample( timestamp: location.timestamp, @@ -190,6 +237,26 @@ extension CoreLocationSource: CLLocationManagerDelegate { source: .gpsSignificantChange, ) sampleContinuation.yield(sample) + latest = sample + } + // A one-shot `requestCurrentLocation()` is delivered here too; satisfy + // any pending waiter with the freshest fix in this batch. + if let latest { + Task { @MainActor [weak self] in + self?.resolvePendingLocation(latest) + } + } + } + + public nonisolated func locationManager( + _: CLLocationManager, + didFailWithError _: any Error, + ) { + // `requestLocation()` reports failures here. Resolve any one-shot waiter + // with "no fix" (best-effort audit capture) rather than leaving it to + // wait out the full timeout. + Task { @MainActor [weak self] in + self?.resolvePendingLocation(nil) } } diff --git a/Where/WhereCore/Sources/Location/LocationSource.swift b/Where/WhereCore/Sources/Location/LocationSource.swift index 0220face..0ad422b0 100644 --- a/Where/WhereCore/Sources/Location/LocationSource.swift +++ b/Where/WhereCore/Sources/Location/LocationSource.swift @@ -40,6 +40,16 @@ public protocol LocationSource: AnyObject, Sendable { func start() async func stop() async + /// Best-effort one-shot GPS fix for "where is the device *right now*". + /// + /// Unlike the passive `sampleStream` (Visits + significant-change, which can + /// be minutes stale), this actively asks for a fresh fix — used to stamp a + /// manual entry's audit trail with where it was made. Returns `nil` rather + /// than throwing when a fix can't be obtained (permission not granted, + /// timeout, or a location error): the capture is audit metadata, so an + /// absent fix is recorded honestly instead of blocking the entry. + func requestCurrentLocation() async -> LocationSample? + /// The current authorization status, read on demand. func currentAuthorization() async -> LocationAuthorizationStatus @@ -72,6 +82,9 @@ public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { private let lock = NSLock() private var _status: LocationAuthorizationStatus + /// What the next `requestCurrentLocation()` returns. Defaults to `nil` (no + /// fix available) so tests opt in to a captured location explicitly. + private var _nextRequestedLocation: LocationSample? /// - Parameters: /// - permissionResult: what the next call to `requestPermission()` @@ -94,6 +107,16 @@ public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { public func start() async {} public func stop() async {} + public func requestCurrentLocation() async -> LocationSample? { + lock.withLock { _nextRequestedLocation } + } + + /// 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?) { + lock.withLock { _nextRequestedLocation = sample } + } + public func currentAuthorization() async -> LocationAuthorizationStatus { lock.withLock { _status } } diff --git a/Where/WhereCore/Sources/LocationIngestor.swift b/Where/WhereCore/Sources/LocationIngestor.swift index e9b25697..5f05f827 100644 --- a/Where/WhereCore/Sources/LocationIngestor.swift +++ b/Where/WhereCore/Sources/LocationIngestor.swift @@ -192,6 +192,15 @@ public actor LocationIngestor { try await locationSource.requestPermission() } + /// Best-effort one-shot GPS fix for "where is the device right now", used to + /// stamp a manual entry's audit trail. Returns `nil` when no fix is + /// available (permission not granted, timeout); the caller records the entry + /// either way. Routed through the ingestor so the UI never touches the + /// `LocationSource` directly. + public func currentLocation() async -> LocationSample? { + await locationSource.requestCurrentLocation() + } + /// The current location authorization status. public func authorizationStatus() async -> LocationAuthorizationStatus { await locationSource.currentAuthorization() From 39a3591f6cbd3fb6ee4a3675ecfbc3d5bb00ff91 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:47:25 -0400 Subject: [PATCH 04/10] Thread ManualEntryAudit through DayJournal write API 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 --- Where/WhereCore/Sources/DayJournal.swift | 23 ++++++++++++---- Where/WhereCore/Tests/DayJournalTests.swift | 5 ++++ Where/WhereCore/Tests/SimulatedYear.swift | 2 +- .../WhereCore/Tests/SimulatedYearTests.swift | 6 ++++- .../WhereCore/Tests/WhereServicesTests.swift | 26 ++++++++++++++++--- .../Sources/Model/YearReportModel.swift | 11 +++++--- Where/WhereUI/Tests/BackupModelTests.swift | 1 + Where/WhereUI/Tests/ResolveModelTests.swift | 3 +++ .../Tests/SwiftDataInspectorWiringTests.swift | 1 + .../WhereUI/Tests/YearReportModelTests.swift | 6 +++++ 10 files changed, 70 insertions(+), 14 deletions(-) diff --git a/Where/WhereCore/Sources/DayJournal.swift b/Where/WhereCore/Sources/DayJournal.swift index 25c2109a..4bacd529 100644 --- a/Where/WhereCore/Sources/DayJournal.swift +++ b/Where/WhereCore/Sources/DayJournal.swift @@ -59,9 +59,13 @@ public actor DayJournal { await widgets.publish() } - public func addManualDay(date: Date, regions: Set) async throws { + public func addManualDay( + date: Date, + regions: Set, + audit: ManualEntryAudit?, + ) async throws { let key = aggregator.calendar.startOfDay(for: date) - let presence = DayPresence(date: key, regions: regions) + let presence = DayPresence(date: key, regions: regions, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reminders.reconcile() await widgets.publish() @@ -75,9 +79,13 @@ public actor DayJournal { /// `addManualDay`, this does not union with GPS — it's the "correct a wrong /// attribution" path. The raw GPS samples are left untouched, so the fix is /// non-destructive and undone by `clearManualDay(date:)`. - public func overrideDay(date: Date, regions: Set) async throws { + public func overrideDay( + date: Date, + regions: Set, + audit: ManualEntryAudit?, + ) async throws { let key = aggregator.calendar.startOfDay(for: date) - let presence = DayPresence(date: key, regions: regions, isAuthoritative: true) + let presence = DayPresence(date: key, regions: regions, isAuthoritative: true, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reminders.reconcile() await widgets.publish() @@ -110,15 +118,20 @@ public actor DayJournal { from start: Date, through end: Date, regions: Set, + audit: ManualEntryAudit?, ) async throws { // `calendarDays` returns an immutable array, so the `@Sendable` // transaction body captures a `let` rather than a mutable cursor across // the concurrency boundary. let dayKeys = start.calendarDays(through: end, in: aggregator.calendar) guard !dayKeys.isEmpty else { return } + // One audit stamps every day in the range — it records the single act of + // entry, not a per-day fact. try await store.perform { for day in dayKeys { - try await store.setManualDay(DayPresence(date: day, regions: regions)) + try await store.setManualDay( + DayPresence(date: day, regions: regions, audit: audit), + ) } } await reminders.reconcile() diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index 7658b05a..55ac4b90 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -129,6 +129,7 @@ struct DayJournalTests { try await h.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-03-03T12:00:00-08:00"), regions: [.newYork], + audit: nil, ) let report = try await h.reader.yearReport(for: 2026) @@ -144,6 +145,7 @@ struct DayJournalTests { try await h.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) let report = try await h.reader.yearReport(for: 2026) @@ -158,6 +160,7 @@ struct DayJournalTests { try await h.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) try await h.journal .clearManualDay(date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00")) @@ -172,6 +175,7 @@ struct DayJournalTests { from: WhereCoreTestSupport.iso("2026-02-10T09:00:00-08:00"), through: WhereCoreTestSupport.iso("2026-02-14T20:00:00-08:00"), regions: [.newYork], + audit: nil, ) let report = try await h.reader.yearReport(for: 2026) @@ -185,6 +189,7 @@ struct DayJournalTests { from: WhereCoreTestSupport.iso("2026-02-14T00:00:00-08:00"), through: WhereCoreTestSupport.iso("2026-02-10T00:00:00-08:00"), regions: [.california], + audit: nil, ) #expect(try await h.reader.yearReport(for: 2026).days.isEmpty) diff --git a/Where/WhereCore/Tests/SimulatedYear.swift b/Where/WhereCore/Tests/SimulatedYear.swift index dbad5b0c..ea9cf894 100644 --- a/Where/WhereCore/Tests/SimulatedYear.swift +++ b/Where/WhereCore/Tests/SimulatedYear.swift @@ -194,7 +194,7 @@ enum SimulatedYear { } for d in 8 ... 12 { let date = calendar.date(from: DateComponents(year: year, month: 11, day: d)) ?? Date() - try? await services.journal.addManualDay(date: date, regions: [.california]) + try? await services.journal.addManualDay(date: date, regions: [.california], audit: nil) } for d in 15 ... 30 { await emitNoon(month: 11, day: d, lat: sf.lat, lng: sf.lng) diff --git a/Where/WhereCore/Tests/SimulatedYearTests.swift b/Where/WhereCore/Tests/SimulatedYearTests.swift index b0f63e9a..7d27ad89 100644 --- a/Where/WhereCore/Tests/SimulatedYearTests.swift +++ b/Where/WhereCore/Tests/SimulatedYearTests.swift @@ -118,7 +118,11 @@ struct SimulatedYearTests { month: 11, day: 13, )) ?? Date() - try await services.journal.addManualDay(date: date, regions: [.california, .newYork]) + try await services.journal.addManualDay( + date: date, + regions: [.california, .newYork], + audit: nil, + ) let after = try await services.reports.yearReport(for: SimulatedYear.year) #expect(after.days.count == before.days.count + 1) diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index c0e3f189..9f51bb75 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -138,6 +138,7 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -153,8 +154,8 @@ struct WhereServicesTests { @Test func manualDayReplacesOnSecondCall() async throws { let (services, _, _) = try Self.makeServices() let date = WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00") - try await services.journal.addManualDay(date: date, regions: [.california]) - try await services.journal.addManualDay(date: date, regions: [.newYork]) + try await services.journal.addManualDay(date: date, regions: [.california], audit: nil) + try await services.journal.addManualDay(date: date, regions: [.newYork], audit: nil) let report = try await services.reports.yearReport(for: 2026) #expect(report.days.count == 1) @@ -178,6 +179,7 @@ struct WhereServicesTests { try await services.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -193,6 +195,7 @@ struct WhereServicesTests { try await services.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) // The override is non-destructive: the GPS sample is still on disk, so @@ -213,6 +216,7 @@ struct WhereServicesTests { try await services.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) try await services.journal .clearManualDay(date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00")) @@ -245,12 +249,14 @@ struct WhereServicesTests { try await services.journal.overrideDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.california], + audit: nil, ) // A later range backfill (Canada) sweeps over the same corrected day. try await services.journal.addManualDays( from: WhereCoreTestSupport.iso("2026-07-01T00:00:00-07:00"), through: WhereCoreTestSupport.iso("2026-07-07T00:00:00-07:00"), regions: [.canada], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -271,6 +277,7 @@ struct WhereServicesTests { from: WhereCoreTestSupport.iso("2026-02-10T09:00:00-08:00"), through: WhereCoreTestSupport.iso("2026-02-14T20:00:00-08:00"), regions: [.newYork], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -286,6 +293,7 @@ struct WhereServicesTests { from: WhereCoreTestSupport.iso("2026-02-14T00:00:00-08:00"), through: WhereCoreTestSupport.iso("2026-02-10T00:00:00-08:00"), regions: [.california], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -299,6 +307,7 @@ struct WhereServicesTests { from: WhereCoreTestSupport.iso("2026-02-10T06:00:00-08:00"), through: WhereCoreTestSupport.iso("2026-02-10T23:00:00-08:00"), regions: [.california], + audit: nil, ) let report = try await services.reports.yearReport(for: 2026) @@ -548,6 +557,7 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), regions: [.newYork], + audit: nil, ) } @@ -604,6 +614,7 @@ struct WhereServicesTests { try await destination.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-02-02T10:00:00-08:00"), regions: [.canada], + audit: nil, ) _ = try await destination.backup.importBackup(from: url, strategy: .replace) @@ -736,6 +747,7 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-03-03T12:00:00-08:00"), regions: [.california], + audit: nil, ) #expect(await spy.lastBadgeCount == 67) @@ -750,6 +762,7 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), regions: [.california], + audit: nil, ) // Backlog (Jan 1–4) minus the logged Jan 1 leaves 3. #expect(await spy.lastBadgeCount == 3) @@ -769,6 +782,7 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), regions: [.california], + audit: nil, ) // Backlog (Jan 1–4) minus the logged Jan 1 leaves 3. #expect(await spy.lastBadgeCount == 3) @@ -819,18 +833,22 @@ struct WhereServicesTests { try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), regions: [.california], + audit: nil, ) try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-02T12:00:00-08:00"), regions: [.california], + audit: nil, ) try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-03T12:00:00-08:00"), regions: [.california], + audit: nil, ) try await services.journal.addManualDay( date: WhereCoreTestSupport.iso("2026-01-04T12:00:00-08:00"), regions: [.newYork], + audit: nil, ) await services.summary.configure(enabled: true, time: .defaultMorning) @@ -904,8 +922,8 @@ struct WhereServicesTests { #expect(first?.totals == [.california: 1]) let day = WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00") - try await services.journal.addManualDay(date: day, regions: [.newYork]) - try await services.journal.overrideDay(date: day, regions: [.california]) + try await services.journal.addManualDay(date: day, regions: [.newYork], audit: nil) + try await services.journal.overrideDay(date: day, regions: [.california], audit: nil) try await services.journal.clearManualDay(date: day) try await services.journal.clearYear(2026) #expect(await refresher.publishCount == 5) diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 1a4bb6d8..22f54a05 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -307,7 +307,7 @@ public final class YearReportModel { /// 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) + try await services.journal.addManualDay(date: date, regions: regions, audit: nil) } /// Persist a manual day range. Throws on persistence failure (see @@ -317,13 +317,18 @@ public final class YearReportModel { through end: Date, regions: Set, ) async throws { - try await services.journal.addManualDays(from: start, through: end, regions: regions) + try await services.journal.addManualDays( + from: start, + through: end, + regions: regions, + audit: nil, + ) } /// 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) + try await services.journal.overrideDay(date: date, regions: regions, audit: nil) } /// Undo a day's manual override/backfill, restoring the GPS-detected regions diff --git a/Where/WhereUI/Tests/BackupModelTests.swift b/Where/WhereUI/Tests/BackupModelTests.swift index b8eb78e6..3071546a 100644 --- a/Where/WhereUI/Tests/BackupModelTests.swift +++ b/Where/WhereUI/Tests/BackupModelTests.swift @@ -18,6 +18,7 @@ struct BackupModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 3, day: 1), regions: [.california], + audit: nil, ) try await services.journal.addEvidence( Evidence( diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index 06def6aa..468d8c7b 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -31,6 +31,7 @@ struct ResolveModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), regions: [.california], + audit: nil, ) await resolve.load(year: 2026, primaryRegions: [.california]) @@ -60,10 +61,12 @@ struct ResolveModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 3, day: 1), regions: [.california], + audit: nil, ) try await services.journal.addManualDay( date: date(year: 2026, month: 3, day: 2), regions: [.newYork], + audit: nil, ) await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) diff --git a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift index b4dc3db6..5841bf6b 100644 --- a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift +++ b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift @@ -41,6 +41,7 @@ struct SwiftDataInspectorWiringTests { try await services.journal.addManualDay( date: date(year: 2026, month: 3, day: 1), regions: [.california], + audit: nil, ) let session = WhereSession(services: services) diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index da7c303c..7a7ac6a3 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -52,10 +52,12 @@ struct YearReportModelTests { try await services.journal.addManualDay( date: date(year: 2024, month: 3, day: 1), regions: [.newYork], + audit: nil, ) try await services.journal.addManualDay( date: date(year: 2026, month: 3, day: 1), regions: [.california], + audit: nil, ) let report = YearReportModel(services: services, selectedYear: 2026) @@ -201,6 +203,7 @@ struct YearReportModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), regions: [.california], + audit: nil, ) await report.refresh() await report.refreshDataIssueCount(force: true) @@ -330,6 +333,7 @@ struct YearReportModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), regions: [.california], + audit: nil, ) let report = YearReportModel(services: services, selectedYear: 2026, now: { now }) @@ -372,6 +376,7 @@ struct YearReportModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), regions: [.california], + audit: nil, ) await waitUntil { probe.report?.days.count == 1 } @@ -402,6 +407,7 @@ struct YearReportModelTests { try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), regions: [.california], + audit: nil, ) #expect(report.report?.days.count == 0) From 50a2cdf5bef318bf4baa5ed7356fa25a77170fa3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:48:13 -0400 Subject: [PATCH 05/10] Capture note + GPS audit in manual-entry view-model intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Sources/Model/YearReportModel.swift | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 22f54a05..6eb32987 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -304,31 +304,58 @@ public final class YearReportModel { /// 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, audit: nil) + /// Captures an audit trail (`note` + best-effort capture-time GPS) for the + /// entry. 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, note: String? = nil) async throws { + let audit = await makeEntryAudit(note: note) + try await services.journal.addManualDay(date: date, regions: regions, audit: audit) } /// Persist a manual day range. Throws on persistence failure (see - /// `setManualDay(date:regions:)`). + /// `setManualDay(date:regions:note:)`). One audit stamps the whole range. public func setManualDays( from start: Date, through end: Date, regions: Set, + note: String? = nil, ) async throws { + let audit = await makeEntryAudit(note: note) try await services.journal.addManualDays( from: start, through: end, regions: regions, - audit: nil, + audit: audit, ) } /// 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, audit: nil) + /// Captures an audit trail (`note` + best-effort capture-time GPS). + public func overrideDay(date: Date, regions: Set, note: String? = nil) async throws { + let audit = await makeEntryAudit(note: note) + try await services.journal.overrideDay(date: date, regions: regions, audit: audit) + } + + /// Assemble the audit trail for a manual entry: stamp "now", normalize the + /// note (blank/whitespace becomes `nil`), and attach a best-effort one-shot + /// GPS fix for where the entry was made. A missing fix is recorded honestly + /// (`location == nil`) rather than blocking the entry. + private func makeEntryAudit(note: String?) async -> ManualEntryAudit { + let sample = await services.ingestor.currentLocation() + let location = sample.map { sample in + CapturedLocation( + coordinate: sample.coordinate, + horizontalAccuracy: sample.horizontalAccuracy, + timestamp: sample.timestamp, + ) + } + let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) + return ManualEntryAudit( + recordedAt: now(), + note: (trimmed?.isEmpty ?? true) ? nil : trimmed, + location: location, + ) } /// Undo a day's manual override/backfill, restoring the GPS-detected regions From cf29cd7e1ec368625d3ef49199c57ec6cac320f5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:51:54 -0400 Subject: [PATCH 06/10] Add note field + audit read-back to manual-entry forms 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 --- .../Sources/Resources/Localizable.xcstrings | 88 +++++++++++++++++++ .../Sources/Secondary/DayRelabelView.swift | 70 +++++++++++++++ .../Sources/Settings/ManualDayEntryView.swift | 17 ++++ Where/WhereUI/Sources/Shared/Strings.swift | 54 ++++++++++++ 4 files changed, 229 insertions(+) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index bb60bb1c..6b150f8b 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -85,6 +85,61 @@ } } }, + "audit.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entry record" + } + } + } + }, + "audit.location" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Made at" + } + } + } + }, + "audit.location.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not captured" + } + } + } + }, + "audit.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + } + } + }, + "audit.recordedAt" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recorded" + } + } + } + }, "calendar.day.accessibility" : { "comment" : "Accessibility label for a calendar day, including the day of the week and the names of the regions that day.", "extractionState" : "extracted_with_value", @@ -320,6 +375,39 @@ } } }, + "manual.note.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saved with this entry for auditing — explain why you made this change." + } + } + } + }, + "manual.note.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reason" + } + } + } + }, + "manual.note.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a note (optional)" + } + } + } + }, "manual.range.footer" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index 8425c489..551a5dad 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -13,6 +13,7 @@ struct DayRelabelView: View { let report: YearReportModel @State private var regionSelection: RegionSelectionState + @State private var note = "" @State private var saveError = SaveErrorAlertState() @State private var isSaving = false @@ -47,6 +48,22 @@ struct DayRelabelView: View { Text(Strings.relabelRegionsFooter) } + Section { + TextField( + Strings.manualNotePlaceholder, + text: $note, + axis: .vertical, + ) + .lineLimit(3, reservesSpace: true) + .disabled(isSaving) + } header: { + Text(Strings.manualNoteHeader) + } footer: { + Text(Strings.manualNoteFooter) + } + + auditSection + Section { Button(Strings.relabelReset, role: .destructive) { reset() } .disabled(isSaving) @@ -74,10 +91,40 @@ struct DayRelabelView: View { } } + /// Read-only record of the last manual entry for this day (when it came from + /// an override): when it was made, its note, and where the device was at the + /// time — the audit trail retained for residency reviews. + @ViewBuilder + private var auditSection: some View { + if let audit = day.audit { + Section { + LabeledContent(Strings.auditRecordedAt, value: recordedAtText(audit.recordedAt)) + if let note = audit.note { + LabeledContent(Strings.auditNote, value: note) + } + LabeledContent(Strings.auditLocation, value: locationText(audit.location)) + } header: { + Text(Strings.auditHeader) + } + } + } + private var dateText: String { day.date.formatted(.dateTime.month(.abbreviated).day().year()) } + private func recordedAtText(_ date: Date) -> String { + date.formatted(.dateTime.month(.abbreviated).day().year().hour().minute()) + } + + private func locationText(_ location: CapturedLocation?) -> String { + guard let location else { return Strings.auditLocationUnavailable } + return Strings.auditCoordinate( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + ) + } + private func save() { isSaving = true saveError.message = nil @@ -86,6 +133,7 @@ struct DayRelabelView: View { try await report.overrideDay( date: day.date, regions: regionSelection.selectedRegions, + note: note, ) dismiss() } catch { @@ -121,4 +169,26 @@ struct DayRelabelView: View { ) } } + + #Preview("With audit record") { + NavigationStack { + DayRelabelView( + day: DayPresence( + date: .now, + regions: [.california], + isAuthoritative: true, + audit: ManualEntryAudit( + recordedAt: .now, + note: "Corrected after reviewing my boarding pass.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + timestamp: .now, + ), + ), + ), + report: PreviewSupport.loadedYearReportModel(), + ) + } + } #endif diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 2ac42847..2b77154d 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -30,6 +30,7 @@ struct ManualDayEntryView: View { @State private var startDate = Date() @State private var endDate = Date() @State private var regionSelection = RegionSelectionState() + @State private var note = "" @State private var saveError = SaveErrorAlertState() @State private var isSaving = false @@ -86,6 +87,20 @@ struct ManualDayEntryView: View { } footer: { Text(Strings.manualRegionsFooter) } + + Section { + TextField( + Strings.manualNotePlaceholder, + text: $note, + axis: .vertical, + ) + .lineLimit(3, reservesSpace: true) + .disabled(isSaving) + } header: { + Text(Strings.manualNoteHeader) + } footer: { + Text(Strings.manualNoteFooter) + } } .navigationTitle(Strings.manualTitle) .navigationBarTitleDisplayMode(.inline) @@ -155,12 +170,14 @@ struct ManualDayEntryView: View { try await report.setManualDay( date: startDate, regions: regionSelection.selectedRegions, + note: note, ) case .range: try await report.setManualDays( from: startDate, through: endDate, regions: regionSelection.selectedRegions, + note: note, ) } dismiss() diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 6304c1ca..66ce9060 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -224,6 +224,40 @@ enum Strings { ) } + // MARK: Manual-entry audit (read-back) + + static var auditHeader: String { + String(localized: "audit.header", defaultValue: "Entry record", bundle: .module) + } + + static var auditRecordedAt: String { + String(localized: "audit.recordedAt", defaultValue: "Recorded", bundle: .module) + } + + static var auditNote: String { + String(localized: "audit.note", defaultValue: "Note", bundle: .module) + } + + static var auditLocation: String { + String(localized: "audit.location", defaultValue: "Made at", bundle: .module) + } + + static var auditLocationUnavailable: String { + String( + localized: "audit.location.unavailable", + defaultValue: "Not captured", + bundle: .module, + ) + } + + /// A "37.77490, -122.41940"-style coordinate label. No catalog entry is + /// needed; the number style is locale-driven, like `driftThresholdLabel`. + static func auditCoordinate(latitude: Double, longitude: Double) -> String { + let lat = latitude.formatted(.number.precision(.fractionLength(5))) + let lon = longitude.formatted(.number.precision(.fractionLength(5))) + return "\(lat), \(lon)" + } + // MARK: Onboarding static var onboardingWelcomeTitle: String { @@ -807,6 +841,26 @@ enum Strings { localized("manual.saveError.title") } + static var manualNoteHeader: String { + String(localized: "manual.note.header", defaultValue: "Reason", bundle: .module) + } + + static var manualNotePlaceholder: String { + String( + localized: "manual.note.placeholder", + defaultValue: "Add a note (optional)", + bundle: .module, + ) + } + + static var manualNoteFooter: String { + String( + localized: "manual.note.footer", + defaultValue: "Saved with this entry for auditing — explain why you made this change.", + bundle: .module, + ) + } + static func manualRangeFooter(count: Int) -> String { String( localized: "manual.range.footer", From 7ee60ea17710762b11ffb2a0fad3828b74ba2d37 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:54:44 -0400 Subject: [PATCH 07/10] Add on-device 24h location summarizer (Foundation Models) 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 --- .../FoundationModelSummaryGenerator.swift | 69 ++++++++++ .../RecentActivitySummarizer.swift | 120 ++++++++++++++++++ Where/WhereCore/Sources/WhereLog.swift | 1 + Where/WhereCore/Sources/WhereServices.swift | 12 ++ 4 files changed, 202 insertions(+) create mode 100644 Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift create mode 100644 Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift diff --git a/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift new file mode 100644 index 00000000..64bd2422 --- /dev/null +++ b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift @@ -0,0 +1,69 @@ +import Foundation +import FoundationModels +import LogKit + +/// On-device `ActivitySummaryGenerating` backed by Apple's Foundation Models. +/// Runs entirely on device (no network, no data leaves the phone), which suits +/// summarizing location history. Reports an `ActivitySummaryUnavailableError` +/// when the system model can't run so the UI can guide the user rather than +/// showing a generic failure. +public struct FoundationModelSummaryGenerator: ActivitySummaryGenerating { + private static let logger = WhereLog.channel(.recentActivitySummarizer) + + public init() {} + + public func summarize(_ input: RecentActivityInput) async throws -> String { + switch SystemLanguageModel.default.availability { + case .available: + break + case let .unavailable(reason): + throw ActivitySummaryUnavailableError(reason: Self.map(reason)) + } + + let session = LanguageModelSession(instructions: Self.instructions) + let response = try await session.respond(to: Self.prompt(for: input)) + return response.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static let instructions = """ + You summarize a person's recent location history for a residency/day-count \ + audit log. You are given timestamped GPS readings and the tracked region \ + each reading falls in. Write a concise, factual, 2-3 sentence summary of \ + where the person appears to have been over the period. Mention the main \ + regions and the rough times or transitions between them. Do not invent \ + places or motives, do not give advice, and do not add a preamble — return \ + only the summary. + """ + + /// Render the structured window into a compact prompt the model can read. + /// Times use the current locale/time zone so the summary reads naturally. + private static func prompt(for input: RecentActivityInput) -> String { + let readings = input.stops + .map { stop in + let time = stop.timestamp.formatted(date: .abbreviated, time: .shortened) + let coordinate = + "\(stop.coordinate.latitude.formatted(.number.precision(.fractionLength(4)))), " + + "\(stop.coordinate.longitude.formatted(.number.precision(.fractionLength(4))))" + return "- \(time): \(stop.region.localizedName) (\(coordinate))" + } + .joined(separator: "\n") + + return """ + These are the device's location readings over the last 24 hours, oldest first: + \(readings) + + Summarize where the person was over this period. + """ + } + + private static func map( + _ reason: SystemLanguageModel.Availability.UnavailableReason, + ) -> ActivitySummaryUnavailableReason { + switch reason { + case .deviceNotEligible: .deviceNotEligible + case .appleIntelligenceNotEnabled: .appleIntelligenceNotEnabled + case .modelNotReady: .modelNotReady + @unknown default: .unknown + } + } +} diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift new file mode 100644 index 00000000..73fd5442 --- /dev/null +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift @@ -0,0 +1,120 @@ +import Foundation +import LogKit + +/// One attributed reading in a recent-activity window: when the device was +/// somewhere, which tracked region that coordinate fell in, and the raw +/// coordinate. The unit fed to the summary generator. +public struct RecentActivityStop: Hashable, Sendable { + public let timestamp: Date + public let region: Region + public let coordinate: Coordinate + + public init(timestamp: Date, region: Region, coordinate: Coordinate) { + self.timestamp = timestamp + self.region = region + self.coordinate = coordinate + } +} + +/// The structured input a `ActivitySummaryGenerating` turns into prose: the +/// window it covers plus the attributed stops within it, oldest first. +public struct RecentActivityInput: Hashable, Sendable { + public let interval: DateInterval + public let stops: [RecentActivityStop] + + public init(interval: DateInterval, stops: [RecentActivityStop]) { + self.interval = interval + self.stops = stops + } +} + +/// The outcome of a recent-activity summary. Distinguishes a real generated +/// summary from an empty window so the UI can show a distinct "nothing tracked" +/// state rather than an empty string that reads like a failure. +public enum RecentActivitySummary: Sendable, Equatable { + case summary(String) + case empty +} + +/// Why an on-device summary can't be produced right now. Mirrors the reasons +/// the system language model reports so the UI can guide the user (e.g. enable +/// Apple Intelligence) instead of showing a generic error. +public enum ActivitySummaryUnavailableReason: Hashable, Sendable { + case deviceNotEligible + case appleIntelligenceNotEnabled + case modelNotReady + case unknown +} + +/// Thrown by a generator when the on-device model is unavailable. A typed error +/// (not a benign default) so the summary surfaces an honest, actionable state. +public struct ActivitySummaryUnavailableError: Error, Hashable, Sendable { + public let reason: ActivitySummaryUnavailableReason + + public init(reason: ActivitySummaryUnavailableReason) { + self.reason = reason + } +} + +/// Seam over the text generator so `RecentActivitySummarizer` can be unit-tested +/// with a stub while production wires the on-device Foundation Models generator. +/// Implementations throw `ActivitySummaryUnavailableError` when the model can't +/// run and rethrow any generation failure — never a silent empty summary. +public protocol ActivitySummaryGenerating: Sendable { + func summarize(_ input: RecentActivityInput) async throws -> String +} + +/// Produces a natural-language summary of the last 24 hours of tracked +/// locations using an on-device language model. Reads the raw samples in the +/// window, attributes each to a `Region`, and hands the structured input to an +/// injected generator. Failures (an unavailable model, a generation error) +/// propagate so the caller can surface an honest state. +public actor RecentActivitySummarizer { + /// The look-back window the summary covers. + public static let window: TimeInterval = 24 * 60 * 60 + + private let store: any WhereStore + private let attributor: RegionAttributor + private let generator: any ActivitySummaryGenerating + private let now: @Sendable () -> Date + + private static let logger = WhereLog.channel(.recentActivitySummarizer) + + init( + store: any WhereStore, + attributor: RegionAttributor, + generator: any ActivitySummaryGenerating, + now: @escaping @Sendable () -> Date, + ) { + self.store = store + self.attributor = attributor + self.generator = generator + self.now = now + } + + /// Summarize the last 24 hours of tracked locations. Returns `.empty` when + /// nothing was recorded in the window; otherwise attributes each sample to a + /// region and asks the generator for prose. Throws on read failure, an + /// unavailable model, or a generation error. + public func summary() async throws -> RecentActivitySummary { + let end = now() + let interval = DateInterval(start: end.addingTimeInterval(-Self.window), end: end) + let samples = try await store.samples(in: interval) + guard !samples.isEmpty else { + Self.logger.info("Recent-activity summary skipped: no samples in the last 24h") + return .empty + } + let stops = samples.map { sample in + RecentActivityStop( + timestamp: sample.timestamp, + region: attributor.region(at: sample.coordinate), + coordinate: sample.coordinate, + ) + } + let text = try await generator.summarize( + RecentActivityInput(interval: interval, stops: stops), + ) + Self.logger.info("Recent-activity summary generated from \(stops.count) stop(s)") + return .summary(text) + } +} diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift index bb2bba74..fe3d2f56 100644 --- a/Where/WhereCore/Sources/WhereLog.swift +++ b/Where/WhereCore/Sources/WhereLog.swift @@ -29,6 +29,7 @@ public enum WhereLog { case locationOutbox = "LocationOutbox" case loggingReminderScheduler = "LoggingReminderScheduler" case model = "WhereModel" + case recentActivitySummarizer = "RecentActivitySummarizer" case regionAttributor = "RegionAttributor" case reminderReconciler = "ReminderReconciler" case session = "WhereSession" diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index c0ae48af..9491f9c5 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -28,6 +28,10 @@ public struct WhereServices: Sendable { public let backup: BackupCoordinator /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner + /// On-device summary of the last 24 hours of tracked locations. Named + /// distinctly from `summary` (the daily notification recap) — this one is an + /// on-demand Foundation Models narrative. + public let recentActivity: RecentActivitySummarizer /// The persistence boundary, retained so `dataChangeUpdates()` can hand out /// the store's `changes()` stream — the single read-refresh signal every /// write origin (manual edit, live GPS, remote sync) funnels through. @@ -48,6 +52,7 @@ public struct WhereServices: Sendable { summaryScheduler: any DailySummaryScheduling = UserNotificationDailySummaryScheduler(), widgetRefresher: any WidgetTimelineRefreshing = WidgetCenterTimelineRefresher(), locationOutbox: any LocationOutbox = NoOpLocationOutbox(), + activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(), now: @escaping @Sendable () -> Date = { Date() }, ) { let reports = ReportReader(store: store, aggregator: aggregator, attributor: attributor) @@ -121,6 +126,12 @@ public struct WhereServices: Sendable { now: now, storeChanges: store.changes(), ) + let recentActivity = RecentActivitySummarizer( + store: store, + attributor: attributor, + generator: activitySummaryGenerator, + now: now, + ) self.reports = reports self.reminders = reminders @@ -130,6 +141,7 @@ public struct WhereServices: Sendable { self.journal = journal self.backup = backup self.resolution = resolution + self.recentActivity = recentActivity self.store = store modelContainer = (store as? SwiftDataStore)?.inspectorContainer } From 5c9c8685b5c997ff703923876c80a05f3ef3ec54 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:58:31 -0400 Subject: [PATCH 08/10] Add recent-activity summary sheet to the Primary tab 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 --- .../Sources/Model/RecentActivityModel.swift | 67 ++++++++ .../Sources/Preview/PreviewSupport.swift | 13 ++ .../WhereUI/Sources/Primary/PrimaryView.swift | 15 ++ .../Primary/RecentActivitySummaryView.swift | 118 +++++++++++++++ .../Sources/Resources/Localizable.xcstrings | 143 ++++++++++++++++++ Where/WhereUI/Sources/Shared/Strings.swift | 99 ++++++++++++ 6 files changed, 455 insertions(+) create mode 100644 Where/WhereUI/Sources/Model/RecentActivityModel.swift create mode 100644 Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift diff --git a/Where/WhereUI/Sources/Model/RecentActivityModel.swift b/Where/WhereUI/Sources/Model/RecentActivityModel.swift new file mode 100644 index 00000000..5e1a930b --- /dev/null +++ b/Where/WhereUI/Sources/Model/RecentActivityModel.swift @@ -0,0 +1,67 @@ +import Foundation +import LogKit +import Observation +import WhereCore + +/// View-scoped model for the "last 24 hours" on-device summary. Mirrors the +/// `RecentActivitySummarizer` output into a `LoadState` the sheet renders, +/// distinguishing a real summary from an empty window, an unavailable model, +/// and an outright failure so the UI can respond to each honestly. +@MainActor +@Observable +public final class RecentActivityModel { + /// Where the summary is in its load lifecycle. + public enum LoadState: Equatable { + case idle + case loading + case loaded(String) + /// The window held no tracked locations — distinct from a blank summary. + case empty + /// The on-device model can't run; carries the reason so the UI can guide + /// the user (e.g. enable Apple Intelligence). + case unavailable(ActivitySummaryUnavailableReason) + /// Generation failed; carries a user-presentable message. + case failed(String) + } + + public private(set) var loadState: LoadState = .idle + + private let services: WhereServices + private static let logger = WhereLog.channel(.recentActivitySummarizer) + + init(services: WhereServices) { + self.services = services + } + + /// Generate (or regenerate) the summary. Maps an unavailable model and a + /// generation failure to distinct states and logs both — never a silent + /// empty result that reads like success. + public func load() async { + loadState = .loading + do { + switch try await services.recentActivity.summary() { + case let .summary(text): + loadState = .loaded(text) + case .empty: + loadState = .empty + } + } catch let error as ActivitySummaryUnavailableError { + loadState = .unavailable(error.reason) + Self.logger.warning( + "Recent-activity summary unavailable: \(String(describing: error.reason))", + ) + } catch { + loadState = .failed(error.localizedDescription) + Self.logger.warning( + "Recent-activity summary failed: \(error.localizedDescription)", + ) + } + } + + #if DEBUG + /// Force a state for previews/tests without running the generator. + func previewLoad(_ state: LoadState) { + loadState = state + } + #endif +} diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 37fbfd73..ea90a52c 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -170,6 +170,19 @@ return resolve } + // MARK: - Recent activity (24h summary sheet) + + /// A recent-activity model forced into a chosen state (no generator run), + /// so the summary sheet's states drop straight into a `#Preview`. + @MainActor + public static func recentActivityModel( + state: RecentActivityModel.LoadState, + ) -> RecentActivityModel { + let model = RecentActivityModel(services: previewServices()) + model.previewLoad(state) + return model + } + // MARK: - Models (app-level shell) /// A ready-to-render app model with the sample report injected and diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 807cdcf1..c9c448b8 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -8,6 +8,7 @@ struct PrimaryView: View { @State private var showingTimeline = false @State private var showingCalendar = false + @State private var showingRecentActivity = false @State private var calendarFocus: CalendarFocus? /// Drives the passport's tilt-reactive holographic sheen. Started/stopped @@ -38,6 +39,17 @@ struct PrimaryView: View { .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingRecentActivity = true + } label: { + Label( + Strings.primaryRecentActivity, + systemImage: "sparkles", + ) + } + .accessibilityIdentifier("where_recent_activity_button") + } ToolbarItem(placement: .topBarTrailing) { Button { showingTimeline = true @@ -67,6 +79,9 @@ struct PrimaryView: View { } .onAppear { tilt.start() } .onDisappear { tilt.stop() } + .sheet(isPresented: $showingRecentActivity) { + RecentActivitySummaryView(report: report) + } .sheet(isPresented: $showingTimeline) { PresenceTimelineView(report: report) } diff --git a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift new file mode 100644 index 00000000..cc636718 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift @@ -0,0 +1,118 @@ +import SwiftUI +import WhereCore + +/// A sheet showing an on-device, AI-generated summary of the last 24 hours of +/// tracked locations. Presented from the Primary tab. Renders each +/// `RecentActivityModel.LoadState` distinctly — a real summary, an empty +/// window, an unavailable model (with guidance), or a failure — and offers a +/// refresh. +struct RecentActivitySummaryView: View { + @Environment(\.dismiss) private var dismiss + + @State private var model: RecentActivityModel + + init(report: YearReportModel) { + _model = State(initialValue: RecentActivityModel(services: report.services)) + } + + #if DEBUG + /// Preview seam: inject a model already in a chosen state. + init(model: RecentActivityModel) { + _model = State(initialValue: model) + } + #endif + + var body: some View { + NavigationStack { + content + .navigationTitle(Strings.recentActivityTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.commonDone) { dismiss() } + } + ToolbarItem(placement: .topBarLeading) { + Button { + Task { await model.load() } + } label: { + Label(Strings.recentActivityRefresh, systemImage: "arrow.clockwise") + } + .disabled(model.loadState == .loading) + } + } + .task { + if model.loadState == .idle { await model.load() } + } + } + } + + @ViewBuilder + private var content: some View { + switch model.loadState { + case .idle, .loading: + ProgressView(Strings.recentActivityLoading) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .loaded(text): + summary(text) + case .empty: + ContentUnavailableView { + Label(Strings.recentActivityEmptyTitle, systemImage: "location.slash") + } description: { + Text(Strings.recentActivityEmptyDescription) + } + case let .unavailable(reason): + ContentUnavailableView { + Label(Strings.recentActivityUnavailableTitle, systemImage: "sparkles.slash") + } description: { + Text(Strings.recentActivityUnavailableMessage(reason)) + } + case let .failed(message): + ContentUnavailableView { + Label( + Strings.recentActivityFailedTitle, + systemImage: "exclamationmark.triangle", + ) + } description: { + Text(message) + } + } + } + + private func summary(_ text: String) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: UIConstants.Spacings.medium) { + Text(text) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + Text(Strings.recentActivityFooter) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding() + } + } +} + +#if DEBUG + #Preview("Loaded") { + RecentActivitySummaryView( + model: PreviewSupport.recentActivityModel( + state: .loaded( + "You spent the morning in California near San Francisco, then traveled to New York in the early evening, where the most recent readings place you.", + ), + ), + ) + } + + #Preview("Empty") { + RecentActivitySummaryView(model: PreviewSupport.recentActivityModel(state: .empty)) + } + + #Preview("Unavailable") { + RecentActivitySummaryView( + model: PreviewSupport.recentActivityModel( + state: .unavailable(.appleIntelligenceNotEnabled), + ), + ) + } +#endif diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 6b150f8b..91369aa7 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -843,6 +843,17 @@ } } }, + "primary.recentActivity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent activity" + } + } + } + }, "primary.timeline" : { "extractionState" : "manual", "localizations" : { @@ -865,6 +876,138 @@ } } }, + "recentActivity.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No locations were recorded in the last 24 hours." + } + } + } + }, + "recentActivity.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing tracked" + } + } + } + }, + "recentActivity.failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't summarize" + } + } + } + }, + "recentActivity.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An on-device summary of where you've been in the last 24 hours. Your location never leaves your device." + } + } + } + }, + "recentActivity.loading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Summarizing…" + } + } + } + }, + "recentActivity.refresh" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh" + } + } + } + }, + "recentActivity.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last 24 hours" + } + } + } + }, + "recentActivity.unavailable.appleIntelligenceNotEnabled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn on Apple Intelligence in Settings to generate summaries." + } + } + } + }, + "recentActivity.unavailable.deviceNotEligible" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device doesn't support on-device summaries." + } + } + } + }, + "recentActivity.unavailable.modelNotReady" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The on-device model is still getting ready. Try again shortly." + } + } + } + }, + "recentActivity.unavailable.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Summaries unavailable" + } + } + } + }, + "recentActivity.unavailable.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device summaries aren't available right now." + } + } + } + }, "regionMap.empty.description" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 66ce9060..a112df5d 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -70,6 +70,14 @@ enum Strings { localized("primary.timeline") } + static var primaryRecentActivity: String { + String( + localized: "primary.recentActivity", + defaultValue: "Recent activity", + bundle: .module, + ) + } + /// Accessibility hint on a Primary region card: tapping it opens that /// region's calendar, filtered to the days spent there. static var primaryCardCalendarHint: String { @@ -1166,6 +1174,97 @@ enum Strings { .formatted(.measurement(width: .abbreviated, usage: .asProvided)) } + // MARK: Recent activity (24h on-device summary) + + static var recentActivityTitle: String { + String(localized: "recentActivity.title", defaultValue: "Last 24 hours", bundle: .module) + } + + static var recentActivityFooter: String { + String( + localized: "recentActivity.footer", + defaultValue: "An on-device summary of where you've been in the last 24 hours. Your location never leaves your device.", + bundle: .module, + ) + } + + static var recentActivityLoading: String { + String( + localized: "recentActivity.loading", + defaultValue: "Summarizing…", + bundle: .module, + ) + } + + static var recentActivityRefresh: String { + String(localized: "recentActivity.refresh", defaultValue: "Refresh", bundle: .module) + } + + static var recentActivityEmptyTitle: String { + String( + localized: "recentActivity.empty.title", + defaultValue: "Nothing tracked", + bundle: .module, + ) + } + + static var recentActivityEmptyDescription: String { + String( + localized: "recentActivity.empty.description", + defaultValue: "No locations were recorded in the last 24 hours.", + bundle: .module, + ) + } + + static var recentActivityFailedTitle: String { + String( + localized: "recentActivity.failed.title", + defaultValue: "Couldn't summarize", + bundle: .module, + ) + } + + static var recentActivityUnavailableTitle: String { + String( + localized: "recentActivity.unavailable.title", + defaultValue: "Summaries unavailable", + bundle: .module, + ) + } + + /// User-facing explanation for why the on-device model can't produce a + /// summary, keyed off the typed reason so the copy can guide the user. + static func recentActivityUnavailableMessage( + _ reason: ActivitySummaryUnavailableReason, + ) -> String { + switch reason { + case .deviceNotEligible: + String( + localized: "recentActivity.unavailable.deviceNotEligible", + defaultValue: "This device doesn't support on-device summaries.", + bundle: .module, + ) + case .appleIntelligenceNotEnabled: + String( + localized: "recentActivity.unavailable.appleIntelligenceNotEnabled", + defaultValue: "Turn on Apple Intelligence in Settings to generate summaries.", + bundle: .module, + ) + case .modelNotReady: + String( + localized: "recentActivity.unavailable.modelNotReady", + defaultValue: "The on-device model is still getting ready. Try again shortly.", + bundle: .module, + ) + case .unknown: + String( + localized: "recentActivity.unavailable.unknown", + defaultValue: "On-device summaries aren't available right now.", + bundle: .module, + ) + } + } + // MARK: Widgets static var widgetTodayTitle: String { From b2a75c690c03d9dedde943a6ea13aae5cd99b187 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:07:28 -0400 Subject: [PATCH 09/10] Document and test manual-entry audit + 24h summary 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 --- Where/AGENTS.md | 18 ++- .../WhereCore/Tests/BackupServiceTests.swift | 34 +++++ Where/WhereCore/Tests/DayJournalTests.swift | 52 ++++++++ .../Tests/LocationIngestorTests.swift | 25 ++++ .../Tests/RecentActivitySummarizerTests.swift | 118 ++++++++++++++++++ .../WhereCore/Tests/SwiftDataStoreTests.swift | 91 ++++++++++++++ Where/WhereCore/Tests/WhereLogTests.swift | 3 +- .../Tests/RecentActivityModelTests.swift | 110 ++++++++++++++++ Where/WhereUI/Tests/ScreenHostingTests.swift | 38 ++++++ 9 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 Where/WhereCore/Tests/RecentActivitySummarizerTests.swift create mode 100644 Where/WhereUI/Tests/RecentActivityModelTests.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 220bf5d8..fe770fef 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -72,7 +72,23 @@ Rules the code enforces and agents must preserve: degraded-but-handled, `error`/`fault` = outright failure; hot paths (per-sample persist, widget throttle) stay quiet by design. - **Location comes through the `LocationSource` protocol** — production is - `CoreLocationSource`; tests and previews use `ScriptedLocationSource`. + `CoreLocationSource`; tests and previews use `ScriptedLocationSource`. Besides + the passive `sampleStream`, it offers a best-effort one-shot + `requestCurrentLocation()` (re-exposed as `LocationIngestor.currentLocation()`) + used to stamp manual entries; it returns `nil` rather than throwing when no + fix is available. +- **Manual entries carry a `ManualEntryAudit`** (when made, an optional note, + and a best-effort capture-time `CapturedLocation`). The view-model intents + (`YearReportModel.setManualDay` / `setManualDays` / `overrideDay`) assemble it + from a `note:` plus `currentLocation()`; `DayJournal`'s write methods take an + explicit `audit:` (no default) and persist it on `DayPresence` / + `SDManualDay`. An additive backfill can't downgrade an authoritative row's + regions, but the newer audit always wins. `DayRelabelView` shows it read-only. +- **`WhereServices.recentActivity`** is a standalone, on-demand + `RecentActivitySummarizer` that summarizes the last 24h of locations on device + via Foundation Models (behind the `ActivitySummaryGenerating` seam). It is + distinct from `WhereServices.summary` (the daily notification recap); model + unavailability surfaces as a typed reason, never a silent empty summary. ## Localization diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 10de0aa8..da6c4e50 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -140,6 +140,40 @@ struct BackupServiceTests { let decoded = try decoder.decode(DayPresence.self, from: Data(json.utf8)) #expect(decoded.isAuthoritative == false) #expect(decoded.regions == [.newYork]) + // A manifest predating audit must decode with no audit, not fail. + #expect(decoded.audit == nil) + } + + @Test func auditManualDaySurvivesArchiveRoundTrip() throws { + let service = BackupService() + let manualDays = [ + DayPresence( + date: Date(timeIntervalSince1970: 1_700_000_000), + regions: [.california], + isAuthoritative: true, + audit: ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 1_700_050_000), + note: "Reviewed my calendar.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + timestamp: Date(timeIntervalSince1970: 1_700_050_000), + ), + ), + ), + ] + let url = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: manualDays, + blobs: [:], + exportedAt: Self.exportDate, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try service.readArchive(at: url) + #expect(result.archive.manualDays == manualDays) + #expect(result.archive.manualDays.first?.audit == manualDays.first?.audit) } @Test func manifestRoundTripsThroughJSON() throws { diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index 55ac4b90..13ce9795 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -234,6 +234,58 @@ struct DayJournalTests { #expect(try await h.journal.evidenceBlob(for: evidence.id) == blob) } + @Test func addManualDayStampsAudit() async throws { + let h = try Self.makeHarness() + let audit = Self.sampleAudit + try await h.journal.addManualDay( + date: WhereCoreTestSupport.iso("2026-03-03T12:00:00-08:00"), + regions: [.newYork], + audit: audit, + ) + + let stored = try await h.store.allManualDays() + #expect(stored.first?.audit == audit) + } + + @Test func overrideDayStampsAudit() async throws { + let h = try Self.makeHarness() + let audit = Self.sampleAudit + try await h.journal.overrideDay( + date: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), + regions: [.newYork], + audit: audit, + ) + + let stored = try await h.store.allManualDays() + #expect(stored.first?.isAuthoritative == true) + #expect(stored.first?.audit == audit) + } + + @Test func addManualDaysStampsTheSameAuditOnEveryDayInRange() async throws { + let h = try Self.makeHarness() + let audit = Self.sampleAudit + try await h.journal.addManualDays( + from: WhereCoreTestSupport.iso("2026-02-10T09:00:00-08:00"), + through: WhereCoreTestSupport.iso("2026-02-12T20:00:00-08:00"), + regions: [.newYork], + audit: audit, + ) + + let stored = try await h.store.allManualDays() + #expect(stored.count == 3) + #expect(stored.allSatisfy { $0.audit == audit }) + } + + private static let sampleAudit = ManualEntryAudit( + recordedAt: WhereCoreTestSupport.iso("2026-07-04T15:00:05-07:00"), + note: "Corrected from my travel log.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 10, + timestamp: WhereCoreTestSupport.iso("2026-07-04T15:00:00-07:00"), + ), + ) + private func sample(at isoString: String) -> LocationSample { LocationSample( id: UUID(), diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index ec5b0d99..afe17fe3 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -73,6 +73,31 @@ struct LocationIngestorTests { #expect(await !(ingestor.isActive)) } + @Test func currentLocationForwardsTheSourceFix() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource() + let recorder = OutcomeRecorder() + let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) + let fix = LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-05-01T12:00:00-07:00"), + coordinate: Coordinate(latitude: 37.3349, longitude: -122.0090), + horizontalAccuracy: 6, + source: .gpsSignificantChange, + ) + source.setNextRequestedLocation(fix) + + #expect(await ingestor.currentLocation() == fix) + } + + @Test func currentLocationIsNilWhenSourceHasNoFix() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource() + let recorder = OutcomeRecorder() + let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) + + #expect(await ingestor.currentLocation() == nil) + } + @Test func liveSampleIsPersistedAndReported() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: .always) diff --git a/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift b/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift new file mode 100644 index 00000000..1c210a10 --- /dev/null +++ b/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import WhereCore + +/// Covers the 24h windowing, region attribution, empty-window handling, and +/// error propagation of `RecentActivitySummarizer` against a scripted generator +/// (the on-device Foundation Models path is device-only and not unit-tested). +struct RecentActivitySummarizerTests { + private enum StubError: Error { case boom } + + /// Scripted `ActivitySummaryGenerating` that records the input it was handed + /// and returns a canned outcome, so tests can assert both the summary and + /// what the summarizer fed the model. + private actor StubGenerator: ActivitySummaryGenerating { + enum Outcome { + case text(String) + case unavailable(ActivitySummaryUnavailableReason) + case failure + } + + private let outcome: Outcome + private(set) var receivedInput: RecentActivityInput? + + init(_ outcome: Outcome) { + self.outcome = outcome + } + + func summarize(_ input: RecentActivityInput) async throws -> String { + receivedInput = input + switch outcome { + case let .text(text): return text + case let .unavailable(reason): throw ActivitySummaryUnavailableError(reason: reason) + case .failure: throw StubError.boom + } + } + } + + private static let now = WhereCoreTestSupport.iso("2026-05-02T12:00:00-07:00") + + private static func makeSummarizer( + store: SwiftDataStore, + generator: StubGenerator, + ) -> RecentActivitySummarizer { + RecentActivitySummarizer( + store: store, + attributor: .shared, + generator: generator, + now: { now }, + ) + } + + private static func californiaSample(at date: Date) -> LocationSample { + LocationSample( + timestamp: date, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } + + @Test func emptyWindowReturnsEmptyWithoutCallingGenerator() async throws { + let store = try SwiftDataStore.inMemory() + let generator = StubGenerator(.text("unused")) + let summarizer = Self.makeSummarizer(store: store, generator: generator) + + #expect(try await summarizer.summary() == .empty) + #expect(await generator.receivedInput == nil) + } + + @Test func summarizesOnlyStopsInsideThe24hWindow() async throws { + let store = try SwiftDataStore.inMemory() + // One reading an hour ago (in window) and one 30 hours ago (out of it). + let inWindow = Self.californiaSample(at: Self.now.addingTimeInterval(-60 * 60)) + let outOfWindow = Self.californiaSample(at: Self.now.addingTimeInterval(-30 * 60 * 60)) + try await store.perform { + try await store.add(sample: inWindow) + try await store.add(sample: outOfWindow) + } + let generator = StubGenerator(.text("You were in California.")) + let summarizer = Self.makeSummarizer(store: store, generator: generator) + + #expect(try await summarizer.summary() == .summary("You were in California.")) + let input = try #require(await generator.receivedInput) + #expect(input.stops.count == 1) + #expect(input.stops.first?.region == .california) + #expect(input.stops.first?.timestamp == inWindow.timestamp) + } + + @Test func unavailableModelPropagatesTypedError() async throws { + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store + .add(sample: Self.californiaSample(at: Self.now.addingTimeInterval(-3600))) + } + let generator = StubGenerator(.unavailable(.appleIntelligenceNotEnabled)) + let summarizer = Self.makeSummarizer(store: store, generator: generator) + + await #expect( + throws: ActivitySummaryUnavailableError(reason: .appleIntelligenceNotEnabled), + ) { + _ = try await summarizer.summary() + } + } + + @Test func generatorFailurePropagates() async throws { + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store + .add(sample: Self.californiaSample(at: Self.now.addingTimeInterval(-3600))) + } + let generator = StubGenerator(.failure) + let summarizer = Self.makeSummarizer(store: store, generator: generator) + + await #expect(throws: StubError.self) { + _ = try await summarizer.summary() + } + } +} diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 859df1c3..1bd13453 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -97,6 +97,97 @@ struct SwiftDataStoreTests { #expect(afterUpdate.count == 1) #expect(afterUpdate.first?.regions == [.newYork]) } + + @Test func auditRoundTripsThroughAManualDay() async throws { + let store = try SwiftDataStore.inMemory() + let date = Date(timeIntervalSince1970: 0) + let audit = ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 1000), + note: "Filed after reviewing receipts.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 8, + timestamp: Date(timeIntervalSince1970: 990), + ), + ) + + try await store.perform { + try await store.setManualDay( + DayPresence(date: date, regions: [.newYork], isAuthoritative: true, audit: audit), + ) + } + + let stored = try await store.allManualDays() + #expect(stored.first?.audit == audit) + } + + @Test func auditWithoutLocationRoundTripsAsNoteOnly() async throws { + let store = try SwiftDataStore.inMemory() + let date = Date(timeIntervalSince1970: 0) + let audit = ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 1000), + note: "No GPS fix was available.", + location: nil, + ) + + try await store.perform { + try await store.setManualDay(DayPresence( + date: date, + regions: [.california], + audit: audit, + )) + } + + let stored = try await store.allManualDays() + #expect(stored.first?.audit == audit) + #expect(stored.first?.audit?.location == nil) + } + + /// An additive backfill can't downgrade an authoritative row's regions, but + /// its (newer) audit must still win — the trail tracks the latest action. + @Test func additiveBackfillOverAuthoritativeKeepsIncomingAudit() async throws { + let store = try SwiftDataStore.inMemory() + let date = Date(timeIntervalSince1970: 0) + let firstAudit = ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 100), + note: "Original override.", + location: nil, + ) + let laterAudit = ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 200), + note: "Later backfill sweep.", + location: nil, + ) + + try await store.perform { + try await store.setManualDay( + DayPresence( + date: date, + regions: [.california], + isAuthoritative: true, + audit: firstAudit, + ), + ) + } + try await store.perform { + try await store.setManualDay( + DayPresence( + date: date, + regions: [.newYork], + isAuthoritative: false, + audit: laterAudit, + ), + ) + } + + let stored = try await store.allManualDays() + #expect(stored.count == 1) + // Regions can't be downgraded (stays authoritative, unions in the backfill)... + #expect(stored.first?.isAuthoritative == true) + #expect(stored.first?.regions == [.california, .newYork]) + // ...but the newer audit wins. + #expect(stored.first?.audit == laterAudit) + } } /// Awaits the first `changes()` ping, returning `false` if none arrives within diff --git a/Where/WhereCore/Tests/WhereLogTests.swift b/Where/WhereCore/Tests/WhereLogTests.swift index c328c55c..e7446493 100644 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ b/Where/WhereCore/Tests/WhereLogTests.swift @@ -23,7 +23,8 @@ func categoryRawValuesMatchTypeNames() { // so Console.app filters keep working after the migration. #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") - #expect(WhereLog.Category.allCases.count == 17) + #expect(WhereLog.Category.recentActivitySummarizer.rawValue == "RecentActivitySummarizer") + #expect(WhereLog.Category.allCases.count == 18) } @Test diff --git a/Where/WhereUI/Tests/RecentActivityModelTests.swift b/Where/WhereUI/Tests/RecentActivityModelTests.swift new file mode 100644 index 00000000..5f9753e3 --- /dev/null +++ b/Where/WhereUI/Tests/RecentActivityModelTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +/// Covers `RecentActivityModel`'s mapping of the summarizer's output (and its +/// failures) to a `LoadState`, against a scripted generator and an in-memory +/// store. +@MainActor +struct RecentActivityModelTests { + private enum StubError: Error { case boom } + + /// Scripted `ActivitySummaryGenerating`. Immutable, so `Sendable`. + private struct StubGenerator: ActivitySummaryGenerating { + enum Outcome { + case text(String) + case unavailable(ActivitySummaryUnavailableReason) + case failure + } + + let outcome: Outcome + + func summarize(_: RecentActivityInput) async throws -> String { + switch outcome { + case let .text(text): return text + case let .unavailable(reason): throw ActivitySummaryUnavailableError(reason: reason) + case .failure: throw StubError.boom + } + } + } + + private static let now = Calendar.current.date( + from: DateComponents(year: 2026, month: 5, day: 2, hour: 12), + )! + + private func makeServices(store: TestStore, generator: StubGenerator) -> WhereServices { + WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + activitySummaryGenerator: generator, + now: { Self.now }, + ) + } + + private func seedRecentSample(_ services: WhereServices) async throws { + try await services.journal.ingest(LocationSample( + timestamp: Self.now.addingTimeInterval(-3600), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + )) + } + + @Test func loadWithNoSamplesIsEmpty() async throws { + let store = try TestStore() + let services = makeServices( + store: store, + generator: StubGenerator(outcome: .text("unused")), + ) + let model = RecentActivityModel(services: services) + + await model.load() + + #expect(model.loadState == .empty) + } + + @Test func loadWithSamplesShowsGeneratedSummary() async throws { + let store = try TestStore() + let services = makeServices( + store: store, + generator: StubGenerator(outcome: .text("You spent the day in California.")), + ) + try await seedRecentSample(services) + let model = RecentActivityModel(services: services) + + await model.load() + + #expect(model.loadState == .loaded("You spent the day in California.")) + } + + @Test func loadMapsUnavailableModelToUnavailableState() async throws { + let store = try TestStore() + let services = makeServices( + store: store, + generator: StubGenerator(outcome: .unavailable(.appleIntelligenceNotEnabled)), + ) + try await seedRecentSample(services) + let model = RecentActivityModel(services: services) + + await model.load() + + #expect(model.loadState == .unavailable(.appleIntelligenceNotEnabled)) + } + + @Test func loadMapsGenerationFailureToFailedState() async throws { + let store = try TestStore() + let services = makeServices(store: store, generator: StubGenerator(outcome: .failure)) + try await seedRecentSample(services) + let model = RecentActivityModel(services: services) + + await model.load() + + guard case .failed = model.loadState else { + Issue.record("Expected .failed, got \(model.loadState)") + return + } + } +} diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 1a61a181..85d4d5d7 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -103,6 +103,44 @@ struct ScreenHostingTests { } } + @Test func dayRelabelViewHostsWithAuditRecord() throws { + let report = PreviewSupport.loadedYearReportModel() + let day = DayPresence( + date: .now, + regions: [.california], + isAuthoritative: true, + audit: ManualEntryAudit( + recordedAt: .now, + note: "Corrected after reviewing my boarding pass.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + timestamp: .now, + ), + ), + ) + let rootView = NavigationStack { DayRelabelView(day: day, report: report) } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func recentActivitySummaryViewHostsEachState() throws { + for state in [ + RecentActivityModel.LoadState.loaded("You were in California, then New York."), + .empty, + .unavailable(.appleIntelligenceNotEnabled), + .failed("Something went wrong."), + ] { + let rootView = RecentActivitySummaryView( + model: PreviewSupport.recentActivityModel(state: state), + ) + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + } + @Test func presenceTimelineViewHostsWithData() throws { let report = PreviewSupport.loadedYearReportModel() try show(UIHostingController(rootView: PresenceTimelineView(report: report))) { hosted in From 14b1f1ab9a4de96fd411a35532b31f005c69d9bf Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 01:13:13 -0400 Subject: [PATCH 10/10] Add save-time progress UI and animate summary state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Where/AGENTS.md | 7 ++++ .../Primary/RecentActivitySummaryView.swift | 9 +++++ .../Sources/Resources/Localizable.xcstrings | 11 ++++++ .../Sources/Secondary/DayRelabelView.swift | 39 ++++++++++++++----- .../Sources/Settings/ManualDayEntryView.swift | 15 ++++++- .../Sources/Shared/SavingStatusRow.swift | 27 +++++++++++++ Where/WhereUI/Sources/Shared/Strings.swift | 8 ++++ 7 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 Where/WhereUI/Sources/Shared/SavingStatusRow.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index fe770fef..f3d794ab 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -138,6 +138,13 @@ shell, the `WhereSession` coordinator for logged-in views). Cover the states that matter — empty, loaded, and distinct edge states — not just the happy path. +Animate transitions between distinct states in a way that fits the surface and +its content — don't hard-cut. A view that swaps on a `LoadState` (or shows an +in-flight status) should fade/move rather than snap (e.g. `.transition(.opacity)` +on each `switch` arm plus `.animation(_:value:)`, or `.animation(_:value:)` on a +form that reveals a saving row). See `RecentActivitySummaryView` and the +manual-entry forms. + ## Adding things - **New library target:** add to root [`Package.swift`](../Package.swift) diff --git a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift index cc636718..01dc962f 100644 --- a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift +++ b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift @@ -25,6 +25,7 @@ struct RecentActivitySummaryView: View { var body: some View { NavigationStack { content + .animation(.smooth, value: model.loadState) .navigationTitle(Strings.recentActivityTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -46,26 +47,33 @@ struct RecentActivitySummaryView: View { } } + /// Each state fades into the next (see `.animation` in `body`) rather than + /// hard-cutting — a crossfade suits swapping between a spinner, prose, and a + /// `ContentUnavailableView`. @ViewBuilder private var content: some View { switch model.loadState { case .idle, .loading: ProgressView(Strings.recentActivityLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) case let .loaded(text): summary(text) + .transition(.opacity) case .empty: ContentUnavailableView { Label(Strings.recentActivityEmptyTitle, systemImage: "location.slash") } description: { Text(Strings.recentActivityEmptyDescription) } + .transition(.opacity) case let .unavailable(reason): ContentUnavailableView { Label(Strings.recentActivityUnavailableTitle, systemImage: "sparkles.slash") } description: { Text(Strings.recentActivityUnavailableMessage(reason)) } + .transition(.opacity) case let .failed(message): ContentUnavailableView { Label( @@ -75,6 +83,7 @@ struct RecentActivitySummaryView: View { } description: { Text(message) } + .transition(.opacity) } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index e46e125f..0beb9145 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -475,6 +475,17 @@ } } }, + "manual.saving.status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capturing location…" + } + } + } + }, "manual.singleDay.footer" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index 551a5dad..27c2f74c 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -15,7 +15,15 @@ struct DayRelabelView: View { @State private var regionSelection: RegionSelectionState @State private var note = "" @State private var saveError = SaveErrorAlertState() - @State private var isSaving = false + @State private var pending: PendingWrite? + + /// Which async write is in flight. Saving captures a one-shot GPS fix (see + /// `LocationSource.requestCurrentLocation()`) so it can take a moment and + /// warrants a visible status; resetting just clears the day and is quick. + private enum PendingWrite { + case saving + case resetting + } init(day: DayPresence, report: YearReportModel, initialRegions: Set? = nil) { self.day = day @@ -26,7 +34,7 @@ struct DayRelabelView: View { } private var canSave: Bool { - !regionSelection.selectedRegions.isEmpty && !isSaving + !regionSelection.selectedRegions.isEmpty && pending == nil && regionSelection.selectedRegions != day.regions } @@ -55,28 +63,39 @@ struct DayRelabelView: View { axis: .vertical, ) .lineLimit(3, reservesSpace: true) - .disabled(isSaving) + .disabled(pending != nil) } header: { Text(Strings.manualNoteHeader) } footer: { Text(Strings.manualNoteFooter) } + if pending == .saving { + Section { + SavingStatusRow(text: Strings.manualSavingStatus) + } + } + auditSection Section { Button(Strings.relabelReset, role: .destructive) { reset() } - .disabled(isSaving) + .disabled(pending != nil) } footer: { Text(Strings.relabelResetFooter) } } + .animation(.default, value: pending) .navigationTitle(Strings.relabelTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.manualSave) { save() } - .disabled(!canSave) + if pending == .saving { + ProgressView() + } else { + Button(Strings.manualSave) { save() } + .disabled(!canSave) + } } } .alert( @@ -126,7 +145,7 @@ struct DayRelabelView: View { } private func save() { - isSaving = true + pending = .saving saveError.message = nil Task { do { @@ -139,13 +158,13 @@ struct DayRelabelView: View { } catch { // Keep the form up so the user can retry; the save didn't land. saveError.message = error.localizedDescription - isSaving = false + pending = nil } } } private func reset() { - isSaving = true + pending = .resetting saveError.message = nil Task { do { @@ -154,7 +173,7 @@ struct DayRelabelView: View { } catch { // Keep the form up so the user can retry; nothing was cleared. saveError.message = error.localizedDescription - isSaving = false + pending = nil } } } diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 2b77154d..fb03937b 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -101,7 +101,14 @@ struct ManualDayEntryView: View { } footer: { Text(Strings.manualNoteFooter) } + + if isSaving { + Section { + SavingStatusRow(text: Strings.manualSavingStatus) + } + } } + .animation(.default, value: isSaving) .navigationTitle(Strings.manualTitle) .navigationBarTitleDisplayMode(.inline) .onChange(of: startDate) { _, newValue in @@ -109,8 +116,12 @@ struct ManualDayEntryView: View { } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.manualSave) { save() } - .disabled(!canSave) + if isSaving { + ProgressView() + } else { + Button(Strings.manualSave) { save() } + .disabled(!canSave) + } } } .alert( diff --git a/Where/WhereUI/Sources/Shared/SavingStatusRow.swift b/Where/WhereUI/Sources/Shared/SavingStatusRow.swift new file mode 100644 index 00000000..6cecce75 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/SavingStatusRow.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Inline "work in progress" row for manual-day forms. Shown while a save is +/// in flight so the up-to-a-few-seconds one-shot GPS capture (see +/// `LocationSource.requestCurrentLocation()`) has visible feedback rather than +/// leaving the user on a silently disabled Save button. +struct SavingStatusRow: View { + let text: String + + var body: some View { + HStack(spacing: UIConstants.Spacings.small) { + ProgressView() + Text(text) + .foregroundStyle(.secondary) + } + } +} + +#if DEBUG + #Preview { + Form { + Section { + SavingStatusRow(text: "Capturing location…") + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index e8f583c9..e9c50b34 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -864,6 +864,14 @@ enum Strings { ) } + static var manualSavingStatus: String { + String( + localized: "manual.saving.status", + defaultValue: "Capturing location…", + bundle: .module, + ) + } + static func manualRangeFooter(count: Int) -> String { String( localized: "manual.range.footer",