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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 18 additions & 5 deletions Where/WhereCore/Sources/DayJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ public actor DayJournal {
await widgets.publish()
}

public func addManualDay(date: Date, regions: Set<Region>) async throws {
public func addManualDay(
date: Date,
regions: Set<Region>,
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()
Expand All @@ -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<Region>) async throws {
public func overrideDay(
date: Date,
regions: Set<Region>,
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()
Expand Down Expand Up @@ -110,15 +118,20 @@ public actor DayJournal {
from start: Date,
through end: Date,
regions: Set<Region>,
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()
Expand Down
22 changes: 18 additions & 4 deletions Where/WhereCore/Sources/DayPresence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Region>, 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<Region>,
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<Region>.self, forKey: .regions)
isAuthoritative = try container
.decodeIfPresent(Bool.self, forKey: .isAuthoritative) ?? false
audit = try container.decodeIfPresent(ManualEntryAudit.self, forKey: .audit)
}
}
67 changes: 67 additions & 0 deletions Where/WhereCore/Sources/Location/CoreLocationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ public final class CoreLocationSource: NSObject, LocationSource {
/// thus permanently strand — the first.
private var pendingPermissionContinuations: [CheckedContinuation<Void, Error>] = []

/// 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<LocationSample?, Never>] = []

/// How long to wait for a one-shot fix before giving up and recording no
/// captured location. Kept short so a manual entry's Save isn't held up.
private static let currentLocationTimeout: Duration = .seconds(10)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirm we show a waiting / countdown UI for this

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

Good catch — we did not. Both DayRelabelView and ManualDayEntryView only disabled Save during the write. Fixed in 14b1f1a: while a save is in flight we now show a "Capturing location…" status row (spinner + label) and swap the Save button for a spinner, so the up-to-a-few-seconds GPS capture has visible feedback. No literal countdown — a spinner + label reads better than a ticking timer. DayRelabelView also now models the in-flight write as an enum (saving vs. resetting) so the location status only shows for the GPS-capturing save, not the quick reset.


override public init() {
// The "create stream, capture its continuation" two-step is
// the idiomatic Swift `AsyncStream` initializer pattern when
Expand All @@ -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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

No — 10s is only an upper bound. resolvePendingLocation(_:) fires as soon as didUpdateLocations delivers a fix (or didFailWithError fails), whichever is first; the Task.sleep just guarantees Save never hangs on an absent fix. What we do not do today is accuracy-gating — we take the first fix requestLocation() returns (CoreLocation's own best-effort-within-a-short-window) rather than waiting for horizontalAccuracy < N. Happy to add accuracy-aware resolution as a follow-up if you want it; left as-is for now.

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)
}
Expand Down Expand Up @@ -179,6 +225,7 @@ extension CoreLocationSource: CLLocationManagerDelegate {
_: CLLocationManager,
didUpdateLocations locations: [CLLocation],
) {
var latest: LocationSample?
for location in locations {
let sample = LocationSample(
timestamp: location.timestamp,
Expand All @@ -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)
}
}

Expand Down
23 changes: 23 additions & 0 deletions Where/WhereCore/Sources/Location/LocationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()`
Expand All @@ -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?) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Is this only for tests? If not, wondering if we should restructure a bit so the "passive" location is different from the otherwise observed location...

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

Yes — setNextRequestedLocation is test/preview-only; it lives on ScriptedLocationSource (the test double), not on the protocol. The passive-vs-active split you're after is already modeled at the protocol level: sampleStream is the passive observed stream (Visits + significant-change, can be minutes stale) and requestCurrentLocation() is the active one-shot fresh fix (documented in LocationSource, lines 43–51). Left as-is — let me know if you want a stronger type-level separation than two protocol members.

lock.withLock { _nextRequestedLocation = sample }
}

public func currentAuthorization() async -> LocationAuthorizationStatus {
lock.withLock { _status }
}
Expand Down
9 changes: 9 additions & 0 deletions Where/WhereCore/Sources/LocationIngestor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions Where/WhereCore/Sources/ManualEntryAudit.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading