Skip to content
Open
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
3 changes: 3 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ let project = Project(
"NSLocationAlwaysAndWhenInUseUsageDescription": .string(
"Where checks your location in the background so it can log which region you're in each day.",
),
"NSPhotoLibraryUsageDescription": .string(
"Where reads photo locations and dates to build a history you review before importing.",
),
]),
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
Expand Down
3 changes: 2 additions & 1 deletion Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ The item format and the placement rule live in the root
## P1s (Should do)
- fix(WhereCore) [needs-design]: Scope initial CloudKit-import readiness to Where's expected store/container. `CloudKitImportReadiness.start()` observes `NSPersistentCloudKitContainer.eventChangedNotification` with `object: nil`, and `eventChanged(_:)` accepts any successful completed import (`WhereCore/Sources/Persistence/CloudKitImportReadiness.swift:19-49`), while discovery starts that observer before `WhereLaunch.prepareStore()` creates the intended store (`WhereUI/Sources/Launch/WhereLaunch.swift:326-333`). An unrelated CloudKit-backed store in the process could therefore release onboarding against an incomplete device list. Bind readiness to the container/store created for this launch (or return its initial-import completion directly from store preparation), ignore unrelated notifications, and cover that filtering with tests. (pr#160 review)
- refactor(WhereUI) [quick-win]: Remove `StoredContext.CodingKeys`; it lists every property under the identical synthesized key and the installation-context sidecar has no shipped compatibility shape to preserve (`WhereUI/Sources/Launch/InstallationRecordingContextStore.swift:138-158`). Let the compiler synthesize the keys and retain the existing persistence round-trip coverage as the wire-shape guard. (pr#160 review)
- feat(Where) [needs-design]: Add an optional onboarding step that backfills the current year from the GPS metadata of photos in the user's library. `OnboardingView.Phase` currently moves from region selection/customization directly to location permission (`WhereUI/Sources/Onboarding/OnboardingView.swift:30`), while `DayJournal.ingest(_:)` is the existing bulk sample path (`WhereCore/Sources/Journal/DayJournal.swift:82`). Design a PhotoKit-backed importer that requests access only after an explicit opt-in, reads location and capture time locally without uploading photo contents, previews what will be added, records photo-derived provenance rather than treating it as live GPS, deduplicates repeat imports, and makes skipping the screen frictionless. (human 2026-08-03)
- refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29)
- fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26)
- fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayDataChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. Out of scope for [`PostWriteReconcile`](Specifications/PostWriteReconcile/README.md) until routed. (audit 2026-07-26)
Expand Down Expand Up @@ -102,6 +101,8 @@ re-recording:

# Completed issues

- feat(Where): Add an optional onboarding step that backfills the current year from photo GPS metadata. (Resolved 2026-08-07: the explicit opt-in reads only local Photos dates and coordinates, applies a best-effort device-capture filter, previews an editable timeline, records deterministic `.photo` samples, and commits only the approved draft; skipping writes nothing.)

- fix(WhereCore): Push data-generation membership into SwiftData fetch predicates. (Resolved 2026-08-08: `GenerationScopedFetch` now builds generation-aware descriptors for all seven scoped entity types, composes membership with identity/range filters before materialization, and preserves legacy `nil == .initial` rows. `SwiftDataStoreTests` covers mixed-generation reads, updates, and rotation deletes.)
- fix(WhereCore): Make the raw-location retry outbox crash-safe and surface failed durable writes. (Resolved 2026-08-04: `LocationOutbox` now journals complete queue snapshots through JournalKit, recovers the newest intact snapshot after a torn tail, migrates the previous JSON format once, and stops recording with the sample retained in memory if the durable checkpoint fails. `LocationOutboxTests` and `LocationIngestorTests.failedOutboxWriteStopsRecordingWithTheSampleStillInMemory` cover recovery and failure.)
- fix(WhereCore): Remove the cross-device assignment DAG and its clock-skew/compaction liabilities. (Resolved 2026-08-03: automatic-recording consent is now installation-local; CloudKit syncs only profiles, nickname events, advisory check-ins, and append-only removal tombstones. A removal retains the intentional remote history cutoff without a mutable authority timeline.)
Expand Down
10 changes: 5 additions & 5 deletions Where/Tools/Tests/upgrade_backup_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class UpgradeBackupTest < Minitest::Test
def test_v1_adds_current_tables_without_inventing_recording_consent
upgraded = upgrade_manifest(base_manifest(1))

assert_equal 4, upgraded.fetch("formatVersion")
assert_equal 5, upgraded.fetch("formatVersion")
assert_equal [], upgraded.fetch("recordingDeviceProfiles")
assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges")
assert_equal [], upgraded.fetch("recordingDeviceRemovals")
Expand Down Expand Up @@ -53,7 +53,7 @@ def test_v3_reshapes_recording_device_data

upgraded = upgrade_manifest(manifest)

assert_equal 4, upgraded.fetch("formatVersion")
assert_equal 5, upgraded.fetch("formatVersion")
assert_equal({
"kind" => { "other" => {} },
"registrationGenerationID" => "generation-id",
Expand All @@ -66,8 +66,8 @@ def test_v3_reshapes_recording_device_data
}, upgraded.fetch("recordingDeviceMetadataChanges").last)
end

def test_v4_is_idempotent
once = upgrade_manifest(base_manifest(4))
def test_v5_is_idempotent
once = upgrade_manifest(base_manifest(5))
assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once)))
end

Expand All @@ -83,7 +83,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps
end

def test_rejects_branch_only_or_future_formats
error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(5)) }
error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(6)) }
assert_equal 1, error.status
end

Expand Down
9 changes: 6 additions & 3 deletions Where/Tools/upgrade-backup.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Reshapes a legacy Where backup into the current v4 manifest. The automatic-recording feature
# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature
# was not shipped in v1 or v2, so upgrading adds the recording tables empty; it never invents an
# installation or recording consent. v4 expands device kinds and groups metadata edit payloads.
# installation or recording consent. v4 expands device kinds and groups metadata edit payloads;
# v5 adds `photo` to the valid sample-source vocabulary without reshaping older samples.

require "json"
require "tmpdir"
Expand All @@ -12,7 +13,7 @@
require "set"

MANIFEST_NAME = "manifest.json"
CURRENT_FORMAT_VERSION = 4
CURRENT_FORMAT_VERSION = 5
SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze

REGION_MAP = {
Expand Down Expand Up @@ -192,6 +193,8 @@ def upgrade_manifest(manifest)
manifest.delete("recordingDevices")
manifest.delete("recordingDeviceCheckIns")
manifest.delete("recordingPolicyChanges")
# v5 adds `photo` as a valid sample-source case. Existing source values need
# no reshape, so the final version declaration is sufficient.
manifest["formatVersion"] = CURRENT_FORMAT_VERSION
warnings.uniq.each { |message| warn "warning: #{message}" }
manifest
Expand Down
74 changes: 74 additions & 0 deletions Where/Where/Sources/PhotoKitLocationLibrary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation
import Photos
import RegionKit
import WhereCore

/// Production, metadata-only Photos adapter for onboarding history import.
/// It never asks PhotoKit for image data or thumbnails and never performs a
/// photo-library change request.
struct PhotoKitLocationLibrary: PhotoLocationLibrary {
func authorizationStatus() async -> PhotoLibraryAuthorization {
Self.map(PHPhotoLibrary.authorizationStatus(for: .readWrite))
}

func requestAuthorization() async -> PhotoLibraryAuthorization {
await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
continuation.resume(returning: Self.map(status))
}
}
}

@concurrent
func assets(in interval: DateInterval) async throws -> [PhotoLocationAsset] {
let options = PHFetchOptions()
options.includeAssetSourceTypes = [.typeUserLibrary]
options.includeHiddenAssets = false
options.predicate = NSPredicate(
format: "mediaType == %d AND creationDate >= %@ AND creationDate < %@",
PHAssetMediaType.image.rawValue,
interval.start as NSDate,
interval.end as NSDate,
)
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]

let fetched = PHAsset.fetchAssets(with: options)
var values: [PhotoLocationAsset] = []
values.reserveCapacity(fetched.count)
for index in 0 ..< fetched.count {
try Task.checkCancellation()
let asset = fetched.object(at: index)
values.append(PhotoLocationAsset(
capturedAt: asset.creationDate,
addedAt: asset.addedDate,
coordinate: asset.location.map {
Coordinate(
latitude: $0.coordinate.latitude,
longitude: $0.coordinate.longitude,
)
},
horizontalAccuracy: asset.location?.horizontalAccuracy,
source: Self.map(asset.sourceType),
isHidden: asset.isHidden,
))
}
return values
}

private static func map(_ status: PHAuthorizationStatus) -> PhotoLibraryAuthorization {
switch status {
case .notDetermined: .notDetermined
case .restricted: .restricted
case .denied: .denied
case .authorized: .authorized
case .limited: .limited
@unknown default: .denied
}
}

private static func map(_ source: PHAssetSourceType) -> PhotoAssetLibrarySource {
if source.contains(.typeCloudShared) { return .cloudShared }
if source.contains(.typeiTunesSynced) { return .synced }
return .userLibrary
}
}
1 change: 1 addition & 0 deletions Where/Where/Sources/RegularApplicationRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ final class RegularApplicationRuntime: WhereApplicationRuntime {
)
},
logSystem: .shared,
photoLibrary: PhotoKitLocationLibrary(),
)
}

Expand Down
10 changes: 8 additions & 2 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ internal shape.
- **Primary regions *are* the tracked-region set.** `primaryRegions()` /
`setPrimaryRegions(_:)` read/write the same `SDTrackedRegion` rows as
`trackedRegions()` — picking scopes GPS attribution *and* carries each
region's `RegionAppearance` + pick order. `RegionAppearance` is data
(WhereCore); the token→`Color` mapping is presentation (WhereUI).
region's `RegionAppearance` + pick order. Await the live-attributor reconcile
before `setPrimaryRegions(_:)` returns. `RegionAppearance` is data (WhereCore);
the token→`Color` mapping is presentation (WhereUI).
- **Export backups from one `readSnapshot` and keep restorable user data
lossless.** Add persisted user-data shapes end-to-end and cover both import
strategies, but export no target-owned recording check-ins and ignore any in
Expand Down Expand Up @@ -108,6 +109,11 @@ internal shape.
`ScriptedLocationSource` in tests/previews; `requestCurrentLocation()`
returns `nil`, never throws, and backs
`LocationIngestor.captureTodayIfNeeded(now:)`.
- **Photo import is metadata-only and provisional.** `PhotoLocationLibrary`
may expose only capture/added dates, coordinate/accuracy, asset source, and
hidden state; `PhotoHistoryPlanner` keeps its draft in memory and
`DayJournal.importPhotoHistory(_:)` commits approved `.photo` samples plus
audited corrections in one transaction.
- **`DeviceRecordingController` owns this installation's local recording choice and physical GPS
state.** Serialize mutations across awaits, fail closed when the current identity is removed,
stamp every ingested GPS sample with the current installation id, and apply
Expand Down
16 changes: 14 additions & 2 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ one it belongs to rather than to a god-object:
which surface and persist each region's picked `RegionAppearance` — color
token, emoji, SF Symbol — and pick order alongside the synced rows) — one row
per region, defaulting to the four until the user chooses in the onboarding /
Settings region picker. `setPrimaryRegions(_:)` also waits for the live
attributor to rebuild before returning, so a following write cannot publish
against the previous region set.
Settings region picker. Recording identity and synced status are split into
immutable profiles, append-only nickname events and removal tombstones, and target-owned
advisory check-ins rather than one mutable device row. Recording consent stays local.
Expand All @@ -63,8 +66,9 @@ one it belongs to rather than to a god-object:
- **`DayJournal`** — the user-sourced writes: manual-day overlays
(`addManualDay` / `overrideDay` / `addManualDays`), clears
(`clearManualDay` / `clearYear` / `eraseAllData`), evidence, and issue
dismissals. Each write commits, then awaits its reminder reconcile + widget
publish so the next reader sees a fully-applied change.
dismissals, and approved photo-history imports. Each write commits, then
awaits its reminder reconcile + widget publish so the next reader sees a
fully-applied change.

- **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a
given `WhereServices`: a plausible current year of living in New York with
Expand Down Expand Up @@ -110,6 +114,14 @@ one it belongs to rather than to a god-object:
the current installation's `RecordingDeviceID`. Every durable retry entry
also carries the data generation that authorized it, so a pre-reset fix can be
discarded but never written into the replacement generation.
- **`PhotoLocationLibrary` / `PhotoHistoryPlanner`** — the metadata-only seam
and pure planner for onboarding's optional current-year Photos backfill. The
app adapter supplies capture date, added date, coordinate, accuracy, source,
and hidden state only; the planner applies the best-effort device-capture
policy, tags accepted samples as `.photo`, gives them deterministic IDs for
repeat-import deduplication, and keeps corrections provisional until one
`DayJournal.importPhotoHistory(_:)` transaction. Approved corrections carry
a `ManualEntryAudit` stamped when the user imports the draft.
- **`LocationOutbox`** — a backup-excluded, JournalKit-backed sidecar for samples
SwiftData could not commit. It appends complete bounded queue snapshots, so a
crash-torn final write falls back to the preceding intact state; Reset and
Expand Down
7 changes: 4 additions & 3 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ public struct BackupArchive: Codable, Sendable, Hashable {
///
/// v3 adds sample provenance, immutable installation profiles, nickname changes, and archive
/// tombstones. v4 expands device kinds, groups metadata edit payloads, and renames the
/// profile's registration-generation key. There's no in-app decode fallback for an older
/// archive — it is reshaped out of band by
/// profile's registration-generation key. v5 adds `.photo` to the persisted
/// `SampleSource` vocabulary. There's no in-app decode fallback for an older archive — it is
/// reshaped out of band by
/// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see
/// `AGENTS.md`).
public static let currentFormatVersion = 4
public static let currentFormatVersion = 5

public let formatVersion: Int
public let exportedAt: Date
Expand Down
2 changes: 1 addition & 1 deletion Where/WhereCore/Sources/DataResolution/DaySamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public final class DaySamples: Sendable {
case .gpsVisit, .gpsSignificantChange:
byDay[CalendarDay(from: sample.timestamp, in: calendar), default: []]
.append(sample)
case .manual, .evidenceImplied:
case .photo, .manual, .evidenceImplied:
continue
}
}
Expand Down
18 changes: 18 additions & 0 deletions Where/WhereCore/Sources/Journal/DayJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ public actor DayJournal {
await reconcileAfterDayDataChange()
}

/// Persist an approved photo-history draft as one transaction, including
/// any authoritative day corrections made in the preview. Re-importing is
/// idempotent because the planner gives every sample a deterministic id.
public func importPhotoHistory(_ history: PhotoHistoryImport) async throws {
guard !history.samples.isEmpty || !history.corrections.isEmpty else { return }
try await Self.logger.measure(.importPhotoHistory, budget: .seconds(5)) {
try await store.performInCurrentGeneration {
for sample in history.samples {
try await store.add(sample: sample)
}
for correction in history.corrections {
try await store.setManualDay(correction)
}
}
}
await reconcileAfterDayDataChange()
}

// MARK: - Retroactive entry

public func addManualSample(_ sample: LocationSample) async throws {
Expand Down
Loading
Loading