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
6 changes: 4 additions & 2 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ Rules the code enforces and agents must preserve:
- **Location comes through the `LocationSource` protocol** — production is
`CoreLocationSource`; tests and previews use `ScriptedLocationSource`. Besides
the passive `sampleStream`, it offers a best-effort one-shot
`requestCurrentLocation()`, used to stamp manual entries; it returns `nil`
rather than throwing when no fix is available.
`requestCurrentLocation()` — used both to stamp manual entries and to log
today when the app is opened on a day with no GPS sample yet
(`LocationIngestor.captureTodayIfNeeded`, driven by `WhereSession` on launch/
foreground); 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
assemble it; `DayJournal`'s write methods take an explicit `audit:` (no
Expand Down
4 changes: 3 additions & 1 deletion Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ internal shape.
- **`LocationSource` abstracts GPS.** Production is `CoreLocationSource` (Visits
+ significant-change); tests/previews use `ScriptedLocationSource`. The
one-shot `requestCurrentLocation()` returns `nil` (never throws) when no fix
is available — it only stamps a manual entry's audit trail.
is available — it stamps a manual entry's audit trail and backs
`LocationIngestor.captureTodayIfNeeded(now:)`, which persists a fix for today
(via the normal ingest path) when the app opens on a day with no GPS sample.
- **Tracked regions live in the store, not preferences.** They're synced app
data (one `SDTrackedRegion` row per region so concurrent cross-device edits
merge; read as a `Set`, defaulting to the four when unset). `RegionAttribution`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public enum LocationAuthorizationStatus: Sendable, Hashable {
self == .always
}

/// Whether a one-shot foreground fix (`requestCurrentLocation()`) can
/// succeed: any granted status, including When-In-Use. Broader than
/// `allowsBackgroundTracking` because a foreground fix doesn't need Always.
public var allowsForegroundFix: Bool {
self == .whenInUse || self == .always
}

/// Whether the user has actively refused (vs. simply not been asked), so
/// the UI can route to the Settings app instead of re-prompting.
public var isDenied: Bool {
Expand Down
93 changes: 91 additions & 2 deletions Where/WhereCore/Sources/Location/LocationIngestor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ public actor LocationIngestor {

private var ingestTask: Task<Void, Never>?

/// The in-flight one-shot capture spawned by `captureTodayIfNeeded(now:)`,
/// if any. Tracked so overlapping foreground / launch triggers coalesce onto
/// a single fix (single-flight) and so teardown can cancel it. Cleared when
/// the work completes. This spans the (slow, up to ~10s) fix acquisition, so
/// `quiesce()` cancels it but does *not* await it — see `capturePersistTask`.
private var captureTask: Task<Void, Never>?

/// The capture's *persist* step, once a fix is in hand — separate from
/// `captureTask` (which also covers the slow fix) so `quiesce()` can await a
/// commit already in progress without stalling on a slow GPS fix. A single
/// writer (capture is single-flight via `captureTask`), so it never clobbers
/// the stream loop's `inFlightIngest` the way a shared slot would.
private var capturePersistTask: Task<Void, Never>?

/// The persist the stream loop is currently awaiting, if any. Tracked so
/// `quiesce()` can wait for an in-flight write to commit before a teardown
/// wipes the store — gating alone can't, since a persist that already
Expand Down Expand Up @@ -91,6 +105,8 @@ public actor LocationIngestor {

deinit {
ingestTask?.cancel()
captureTask?.cancel()
capturePersistTask?.cancel()
}

/// Begin (or resume) GPS ingestion. Idempotent: a second call while
Expand Down Expand Up @@ -166,8 +182,17 @@ public actor LocationIngestor {
acceptsSamples = false
isMonitoring = false
await locationSource.stop()
// Let an already-started persist (and any retry re-enqueue it performs)
// settle first, then clear the backlog — so nothing re-adds after.
// Cancel the one-shot capture's fix acquisition (best-effort — it isn't
// the store write, so we don't await it and never stall the erase on a
// slow GPS fix). If it was still acquiring, the `acceptsSamples` gate
// stops it persisting; if it had already begun a persist, that is tracked
// on `capturePersistTask` and awaited below.
captureTask?.cancel()
captureTask = nil
// Let an already-started persist — the stream loop's and the capture's,
// each on its own single-writer handle — settle before clearing the
// backlog, so nothing commits into the store the caller is about to wipe.
await capturePersistTask?.value
await inFlightIngest?.value
retryQueue.removeAll()
// Clear the durable mirror too; the store is about to be erased, so the
Expand Down Expand Up @@ -201,6 +226,70 @@ public actor LocationIngestor {
await locationSource.requestCurrentLocation()
}

/// Fill in *today* with a best-effort one-shot GPS fix when the day has no
/// GPS sample yet, so opening the app on a fresh day doesn't leave the
/// calendar blank until passive monitoring (Visits / significant-change)
/// next fires. Non-blocking: the fix (which can take up to ~10s) runs on an
/// internal task so launch / foreground callers aren't held up — the
/// persisted sample refreshes readers via the store's change signal.
///
/// Single-flight: a call while a capture is already in flight is a no-op.
/// Only a *GPS* sample suppresses the fix; a manual entry for today doesn't,
/// since it isn't a passive-tracking data point. Whether to attempt this at
/// all (the user's tracking intent + authorization) is the caller's gate;
/// this stays safe regardless because `requestCurrentLocation()` returns
/// `nil` when no fix is available.
public func captureTodayIfNeeded(now: Date) {
guard captureTask == nil else { return }
captureTask = Task { [weak self] in
await self?.performTodayCapture(now: now)
await self?.clearCaptureTask()
}
}

private func clearCaptureTask() {
captureTask = nil
}

/// The body of `captureTodayIfNeeded(now:)`, run on `captureTask`.
private func performTodayCapture(now: Date) async {
let startOfDay = calendar.startOfDay(for: now)
guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else {
Self.logger.warning("Could not compute today's interval for foreground capture")
return
}
let interval = DateInterval(start: startOfDay, end: endOfDay)
do {
let existing = try await store.samples(in: interval)
if existing.contains(where: \.source.isGPS) { return }
} catch {
// Fail closed: if today's samples can't be read we skip rather than
// risk logging a duplicate fix. Surfaced, not silently swallowed.
Self.logger.warning(
"Skipping foreground capture; could not read today's samples: \(error.localizedDescription)",
)
return
}
guard let sample = await locationSource.requestCurrentLocation() else { return }
// The ~10s fix may have straddled a `quiesce()`; re-check the gate before
// persisting, mirroring `ingest(_:)`. The guard and the `capturePersistTask`
// assignment are synchronous (no `await` between), so a concurrent
// `quiesce()` either sees `acceptsSamples == false` here (we skip) or sees
// the handle already set (it awaits us) — never neither.
guard acceptsSamples else { return }
Self.logger.info("Captured one-shot foreground location for today")
// Persist via `processIngestedSample` on the capture's own handle rather
// than `ingest(_:)`, so it never shares the stream loop's single
// `inFlightIngest` slot. `quiesce()` awaits this handle independently.
let work = Task { [weak self] in
guard let self else { return }
await processIngestedSample(sample)
}
capturePersistTask = work
await work.value
capturePersistTask = nil
}

/// The current location authorization status.
public func authorizationStatus() async -> LocationAuthorizationStatus {
await locationSource.currentAuthorization()
Expand Down
10 changes: 10 additions & 0 deletions Where/WhereCore/Sources/Location/LocationSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ public enum SampleSource: Sendable, Hashable, Codable {
}
}

/// Whether this sample came from GPS (passive Visits / significant-change,
/// or a one-shot foreground fix) rather than user-asserted manual or
/// evidence-derived data. Exhaustive so a new case forces a decision here.
public var isGPS: Bool {
switch self {
case .gpsVisit, .gpsSignificantChange: true
case .manual, .evidenceImplied: false
}
}

/// `.evidenceImplied` only — the originating evidence's UUID. Returns
/// `nil` for the other cases, so SwiftData can skip writing the column.
public var evidenceId: UUID? {
Expand Down
12 changes: 7 additions & 5 deletions Where/WhereCore/Sources/Location/LocationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ public protocol LocationSource: AnyObject, Sendable {
/// 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.
/// be minutes stale), this actively asks for a fresh fix. Two callers use
/// it: stamping a manual entry's audit trail with where it was made, and
/// `LocationIngestor.captureTodayIfNeeded(now:)`, which persists a fix for
/// today when the app opens on a day that has no GPS sample yet. Returns
/// `nil` rather than throwing when a fix can't be obtained (permission not
/// granted, timeout, or a location error), so an absent fix is recorded
/// honestly instead of blocking the caller.
func requestCurrentLocation() async -> LocationSample?

/// The current authorization status, read on demand.
Expand Down
8 changes: 8 additions & 0 deletions Where/WhereCore/Tests/LocationAuthorizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ struct LocationAuthorizationTests {
#expect(!LocationAuthorizationStatus.denied.allowsBackgroundTracking)
}

@Test func allowsForegroundFixForAnyGrantedStatus() {
#expect(LocationAuthorizationStatus.always.allowsForegroundFix)
#expect(LocationAuthorizationStatus.whenInUse.allowsForegroundFix)
#expect(!LocationAuthorizationStatus.notDetermined.allowsForegroundFix)
#expect(!LocationAuthorizationStatus.denied.allowsForegroundFix)
#expect(!LocationAuthorizationStatus.restricted.allowsForegroundFix)
}

@Test func isDeniedCoversDeniedAndRestricted() {
#expect(LocationAuthorizationStatus.denied.isDenied)
#expect(LocationAuthorizationStatus.restricted.isDenied)
Expand Down
155 changes: 155 additions & 0 deletions Where/WhereCore/Tests/LocationIngestorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,106 @@ struct LocationIngestorTests {
#expect(await ingestor.currentLocation() == nil)
}

@Test func captureTodayPersistsAndReportsFixWhenNoGPSSampleYet() async throws {
let store = try SwiftDataStore.inMemory()
let source = ScriptedLocationSource(authorizationStatus: .whenInUse)
let recorder = OutcomeRecorder()
let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder)
source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00"))

// No monitoring started (the When-In-Use case): the foreground fix is
// the only way this user's data lands, and it still persists + reports.
await ingestor
.captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"))

try await waitUntil { await (try? store.allSamples().count) == 1 }
#expect(await recorder.last?.liveSample != nil)
}

@Test func captureTodaySkipsWhenGPSSampleAlreadyExistsToday() async throws {
let store = try SwiftDataStore.inMemory()
let source = ScriptedLocationSource(authorizationStatus: .whenInUse)
let recorder = OutcomeRecorder()
let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder)
// A passive GPS sample already covers today.
try await store
.perform { try await store.add(sample: sample(at: "2026-03-15T02:00:00-07:00")) }
source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00"))

await ingestor
.captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"))

// The day is already covered, so no fix is taken. Give the capture task
// time to run and confirm it added nothing.
try await Task.sleep(for: .milliseconds(100))
#expect(try await store.allSamples().count == 1)
}

@Test func captureTodayStillCapturesWhenOnlyManualSampleExistsToday() async throws {
let store = try SwiftDataStore.inMemory()
let source = ScriptedLocationSource(authorizationStatus: .whenInUse)
let recorder = OutcomeRecorder()
let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder)
// A manual entry isn't a passive-tracking data point, so it must not
// suppress the GPS fix.
try await store.perform {
try await store.add(sample: LocationSample(
timestamp: WhereCoreTestSupport.iso("2026-03-15T02:00:00-07:00"),
coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
horizontalAccuracy: 0,
source: .manual,
))
}
source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00"))

await ingestor
.captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"))

try await waitUntil { await (try? store.allSamples().count) == 2 }
}

@Test func captureTodaySkipsWhenSourceHasNoFix() async throws {
let store = try SwiftDataStore.inMemory()
let source = ScriptedLocationSource(authorizationStatus: .whenInUse)
let recorder = OutcomeRecorder()
let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder)
// No fix scripted → `requestCurrentLocation()` returns nil.

await ingestor
.captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"))

try await Task.sleep(for: .milliseconds(100))
#expect(try await store.allSamples().isEmpty)
}

@Test func captureTodayIsSingleFlightWhileFixInFlight() async throws {
let store = try SwiftDataStore.inMemory()
let source = GatedLocationSource(fix: sample(at: "2026-03-15T08:05:00-07:00"))
let recorder = OutcomeRecorder()
let ingestor = LocationIngestor(
store: store,
locationSource: source,
calendar: WhereCoreTestSupport.calendar(),
onPersisted: { outcome in await recorder.record(outcome) },
)
let now = WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00")

// The first capture parks awaiting the gated fix, holding the
// single-flight slot.
await ingestor.captureTodayIfNeeded(now: now)
try await waitUntil { source.requestCount == 1 }

// A second call while the first is still in flight is dropped by the
// single-flight guard — it never requests a fix of its own.
await ingestor.captureTodayIfNeeded(now: now)

// Release the fix: the first capture persists exactly one sample, and the
// second never asked the source for one.
source.openGate()
try await waitUntil { await (try? store.allSamples().count) == 1 }
#expect(source.requestCount == 1)
}

@Test func liveSampleIsPersistedAndReported() async throws {
let store = try SwiftDataStore.inMemory()
let source = ScriptedLocationSource(authorizationStatus: .always)
Expand Down Expand Up @@ -337,6 +437,61 @@ struct LocationIngestorTests {

private struct WaitTimeout: Error {}

/// `LocationSource` whose one-shot `requestCurrentLocation()` blocks until
/// `openGate()` is called, so a test can hold a capture "in flight" and prove
/// the single-flight guard drops an overlapping second request. Counts fix
/// requests so the test can assert the dropped call never reached the source.
private final class GatedLocationSource: LocationSource, @unchecked Sendable {
let sampleStream: AsyncStream<LocationSample>
var authorizationUpdates: AsyncStream<LocationAuthorizationStatus> {
AsyncStream { _ in }
}

private let fix: LocationSample
private let lock = NSLock()
private var waiters: [CheckedContinuation<Void, Never>] = []
private var _requestCount = 0

init(fix: LocationSample) {
self.fix = fix
sampleStream = AsyncStream { _ in }
}

var requestCount: Int {
lock.withLock { _requestCount }
}

func start() async {}
func stop() async {}
func currentAuthorization() async -> LocationAuthorizationStatus {
.whenInUse
}

func requestPermission() async throws {}

func requestCurrentLocation() async -> LocationSample? {
await withCheckedContinuation { continuation in
lock.withLock {
_requestCount += 1
waiters.append(continuation)
}
}
return fix
}

/// Resume every parked fix request with the scripted fix.
func openGate() {
let resumed = lock.withLock {
let current = waiters
waiters.removeAll()
return current
}
for continuation in resumed {
continuation.resume()
}
}
}

private struct ToggleFailingStoreError: Error {}

/// `WhereStore` that lets a test toggle whether `add(sample:)` succeeds; every
Expand Down
Loading
Loading