diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 220bf5d8..f3d794ab 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 @@ -122,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/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/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/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() 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 + } +} 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, ) } } 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 } 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 7658b05a..13ce9795 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) @@ -229,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/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/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/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/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/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 1a4bb6d8..6eb32987 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -304,26 +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) + /// 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 { - try await services.journal.addManualDays(from: start, through: end, regions: regions) + let audit = await makeEntryAudit(note: note) + try await services.journal.addManualDays( + from: start, + through: end, + regions: regions, + 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) + /// 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 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 b60fe931..9aac274a 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 region cards' tilt-reactive holographic sheen. Started/stopped @@ -34,6 +35,17 @@ struct PrimaryView: View { // the floating toolbar buttons rather than behind an opaque bar. .toolbarBackground(.hidden, 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 @@ -63,6 +75,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..01dc962f --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift @@ -0,0 +1,127 @@ +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 + .animation(.smooth, value: model.loadState) + .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() } + } + } + } + + /// 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( + Strings.recentActivityFailedTitle, + systemImage: "exclamationmark.triangle", + ) + } description: { + Text(message) + } + .transition(.opacity) + } + } + + 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 6d12c219..0beb9145 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" : { @@ -387,6 +475,17 @@ } } }, + "manual.saving.status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capturing location…" + } + } + } + }, "manual.singleDay.footer" : { "extractionState" : "manual", "localizations" : { @@ -755,6 +854,17 @@ } } }, + "primary.recentActivity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent activity" + } + } + } + }, "primary.timeline" : { "extractionState" : "manual", "localizations" : { @@ -766,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/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index 8425c489..27c2f74c 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -13,8 +13,17 @@ 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 + @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 @@ -25,7 +34,7 @@ struct DayRelabelView: View { } private var canSave: Bool { - !regionSelection.selectedRegions.isEmpty && !isSaving + !regionSelection.selectedRegions.isEmpty && pending == nil && regionSelection.selectedRegions != day.regions } @@ -47,19 +56,46 @@ struct DayRelabelView: View { Text(Strings.relabelRegionsFooter) } + Section { + TextField( + Strings.manualNotePlaceholder, + text: $note, + axis: .vertical, + ) + .lineLimit(3, reservesSpace: true) + .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( @@ -74,30 +110,61 @@ 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 + pending = .saving saveError.message = nil Task { do { try await report.overrideDay( date: day.date, regions: regionSelection.selectedRegions, + note: note, ) dismiss() } 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 { @@ -106,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 } } } @@ -121,4 +188,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..fb03937b 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,7 +87,28 @@ 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) + } + + if isSaving { + Section { + SavingStatusRow(text: Strings.manualSavingStatus) + } + } } + .animation(.default, value: isSaving) .navigationTitle(Strings.manualTitle) .navigationBarTitleDisplayMode(.inline) .onChange(of: startDate) { _, newValue in @@ -94,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( @@ -155,12 +181,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/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 69083026..e9c50b34 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -65,6 +65,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 { @@ -219,6 +227,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 { @@ -802,6 +844,34 @@ 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 var manualSavingStatus: String { + String( + localized: "manual.saving.status", + defaultValue: "Capturing location…", + bundle: .module, + ) + } + static func manualRangeFooter(count: Int) -> String { String( localized: "manual.range.footer", @@ -1107,6 +1177,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 { 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/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/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/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 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)