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
5 changes: 4 additions & 1 deletion Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ slow.
store is injected as a no-op or skipped at the call site (Spotlight indexing
in `AppDelegate`), and Settings hides the groups that would reach past it
(`SettingsDestination.isAvailableInDemoMode`). A new persisting surface needs
the same treatment.
the same treatment. The user-requested annual PDF export is the sole explicit
exception: it may create/share a temporary sample document, but its demo
filename and metadata say demo and every page carries a non-removable
`SAMPLE — DEMO DATA` watermark.
- **`WhereModel` decides when a scope routes its logs.** A scope holds its log
store from birth and routes only while active, so one that opens while
shadowed is remembered rather than attached. Guard:
Expand Down
1 change: 1 addition & 0 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The item format and the placement rule live in the root
- perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human)

## P1s (Should do)
- feat(Where) [needs-design]: Add a January 1 “Your last year at a glance” annual recap — each year, surface a dismissible in-app banner that opens a native previous-year summary, deliver one owned local notification, and offer the existing annual PDF export from that summary. `MainTabs` owns foreground activation and top-level presentation (`WhereUI/Sources/MainTabs.swift:41-83`); the existing notification schedulers demonstrate stable identifiers plus calendar triggers (`WhereCore/Sources/Reminders/DailySummaryScheduler.swift:56-66`, `WhereCore/Sources/Reminders/LoggingReminderScheduler.swift:248-263`); and the completed export path already reads one captured annual audit and renders/shares it (`WhereCore/Sources/Reporting/ReportReader.swift:48-84`, `WhereUI/Sources/Model/YearExportModel.swift:113-158`, `WhereUI/Sources/Year/YearView.swift:57-88`). Add a Core-owned annual-recap eligibility/scheduling collaborator and a UI model/view that reuse those report/export semantics instead of building a second summary pipeline; persist the last presented or acknowledged recap year through `WherePreferences` (`WhereCore/Sources/Preferences/WherePreferences.swift:102-127`) so foregrounding cannot repeat the banner. Decide the availability window (January 1 only versus first foreground afterward), dismissal/replay behavior, notification-authorization policy, and tap routing; cover Gregorian year rollover/time zones, idempotent scheduling, acknowledgement, empty/demo data, native summary rendering, and the PDF handoff with Core, UI, notification, and snapshot tests. (human 2026-08-01)
- 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.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26)
Expand Down
4 changes: 4 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ internal shape.
`WhereServices`; the separate DEBUG Inspector runtime uses
`SwiftDataStore.makeContainer`, `inspectorModelTypes`, and
`inspectorStoreURL` as its schema/storage adapter.
- **Annual audits are one captured policy snapshot.** Read through
`WhereStore.auditRecords(in:manualDays:)`, then attribute every row and total
against that value's tracked-region set; never compose an export from
separately timed UI reads.
- **Each process opens its on-disk store once and injects it** — the app's
launch opens it; the App Intents stack shares it via
`WhereServices.forIntents(sharingStoreOf:)`. A second container over the
Expand Down
23 changes: 21 additions & 2 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ one it belongs to rather than to a god-object:

- **`ReportReader`** — the pure read path: `yearReport(for:)`, the year's raw
manual entries `manualDays(inYear:)`, per-region `locations(in:year:)`, and
`representativeCoordinates(for:)`.
`representativeCoordinates(for:)`. `auditReport(for:)` returns a
`YearAuditReport`: one consistent annual snapshot containing the finalized
report, attributed samples, manual/evidence metadata, reporting timezone,
tracked-region set, and boundary provenance. Production obtains its
`YearAuditRecords` through one `SwiftDataStore` actor turn/read context and
freezes the region set into an immutable attributor before computing rows and
totals.
- **`YearReport` / `DayPresence` / `RegionDayLocations`** — the aggregated,
snapshot-stable value types the UI renders, each keyed by a
timezone-independent **`CalendarDay`** (`DayPresence.day`). A day counts for a
Expand Down Expand Up @@ -160,9 +166,22 @@ let services = try await WhereServices.make(
// Read a year, aggregated with the injected calendar + region attribution.
let report = try await services.reports.yearReport(for: 2026)

// Read a transparent audit snapshot whose source rows and totals share one
// captured attribution policy.
let annualAuditReport = try await services.reports.auditReport(for: 2026)

// Write a manual day (the caller supplies the ManualEntryAudit); the journal
// commits, then reconciles reminders + widgets.
try await services.journal.addManualDay(date: day, regions: [.california], audit: audit)
let manualEntryAudit = ManualEntryAudit(
recordedAt: Date(),
note: "Backfilled from travel records.",
location: nil,
)
try await services.journal.addManualDay(
date: day,
regions: [.california],
audit: manualEntryAudit,
)

// Refresh whenever anything changes — local edits, live GPS, or a synced import.
for await _ in services.dataChangeUpdates() {
Expand Down
2 changes: 2 additions & 0 deletions Where/WhereCore/Sources/Logging/ReportReaderLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct ReportReaderLog: LogEvent {
enum SpanName: Hashable {
/// The workhorse: a year's samples + manual days, aggregated.
case yearReport
/// One annual audit snapshot plus attribution against its captured set.
case auditReport
/// The single-read bundle a data-issue scan needs.
case dataIssueReads
/// A year of coordinates inside one region, grouped by day.
Expand Down
84 changes: 82 additions & 2 deletions Where/WhereCore/Sources/Persistence/SwiftDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,82 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}

/// Fetch every annual-audit table through the same actor-confined context
/// without an `await` between fetches. That makes the returned value a
/// coherent SwiftData snapshot even if another task is waiting to commit.
public func auditRecords(
in interval: DateInterval,
manualDays dayRange: ClosedRange<CalendarDay>,
) async throws -> YearAuditRecords {
let context = readContext()
let start = interval.start
let end = interval.end
let low = dayRange.lowerBound.description
let high = dayRange.upperBound.description

var sampleDescriptor = FetchDescriptor<SDLocationSample>(
predicate: #Predicate {
if let timestamp = $0.timestamp {
timestamp >= start && timestamp < end
} else {
false
}
},
sortBy: [SortDescriptor(\.timestamp)],
)
sampleDescriptor.includePendingChanges = true
let samples = try context.fetch(sampleDescriptor).compactMap { record in
let value = record.toValue()
if value == nil { Self.logFault(forCorrupt: record) }
return value
}

var manualDescriptor = FetchDescriptor<SDManualDay>(
predicate: #Predicate {
if let dayKey = $0.dayKey {
dayKey >= low && dayKey <= high
} else {
false
}
},
sortBy: [SortDescriptor(\.dayKey)],
)
manualDescriptor.includePendingChanges = true
let manualDays = try context.fetch(manualDescriptor).compactMap { record in
let value = record.toValue()
if value == nil { Self.logFault(forCorrupt: record) }
return value
}

var evidenceDescriptor = FetchDescriptor<SDEvidence>(
predicate: #Predicate {
if let capturedAt = $0.capturedAt {
capturedAt >= start && capturedAt < end
} else {
false
}
},
sortBy: [SortDescriptor(\.capturedAt)],
)
evidenceDescriptor.includePendingChanges = true
let evidence = try context.fetch(evidenceDescriptor).compactMap { record in
let value = record.toValue()
if value == nil { Self.logFault(forCorrupt: record) }
return value
}

var trackedDescriptor = FetchDescriptor<SDTrackedRegion>()
trackedDescriptor.includePendingChanges = true
let trackedIDs = try context.fetch(trackedDescriptor).compactMap(\.regionID)

return YearAuditRecords(
samples: samples,
manualDays: manualDays,
evidence: evidence,
trackedRegions: Self.resolvedTrackedRegions(ids: trackedIDs),
)
}

public func write(evidence: Evidence, blob: Data?) async throws {
let context = mutationContext()
let id = evidence.id
Expand Down Expand Up @@ -751,10 +827,14 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
var descriptor = FetchDescriptor<SDTrackedRegion>()
descriptor.includePendingChanges = true
let ids = try context.fetch(descriptor).compactMap(\.regionID)
return Self.resolvedTrackedRegions(ids: ids)
}

private static func resolvedTrackedRegions(ids: [String]) -> Set<Region> {
// No rows means the user hasn't chosen yet — fall back to the default
// set (applied identically in every process). Once any row exists, the
// tracked set is exactly the persisted rows.
guard !ids.isEmpty else { return Self.defaultTrackedRegions }
guard !ids.isEmpty else { return defaultTrackedRegions }
// Unknown ids (e.g. a region dropped from the catalog) are filtered out
// rather than crashing. Surface it: a stored id we can't resolve is a
// degraded state — and if *every* id drops, the resulting empty set makes
Expand All @@ -769,7 +849,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}
if !unknown.isEmpty {
Self.logger {
logger {
.ignoredUnknownTrackedRegions(ids: unknown.sorted())
}
}
Expand Down
24 changes: 24 additions & 0 deletions Where/WhereCore/Sources/Persistence/WhereStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public protocol WhereStore: Sendable {
func samples(in interval: DateInterval) async throws -> [LocationSample]
func allSamples() async throws -> [LocationSample]

/// Every table needed by an annual audit, captured as one logical read.
/// `SwiftDataStore` overrides this to use one actor turn/read context; the
/// protocol default composes ordinary reads for lightweight stores.
func auditRecords(
in interval: DateInterval,
manualDays dayRange: ClosedRange<CalendarDay>,
) async throws -> YearAuditRecords

func write(evidence: Evidence, blob: Data?) async throws
func evidence(in interval: DateInterval) async throws -> [Evidence]
/// Every evidence record in the store, regardless of `capturedAt`. Used
Expand Down Expand Up @@ -135,6 +143,22 @@ extension WhereStore {
[.california, .newYork, .canada, .europeanUnion]
}

public func auditRecords(
in interval: DateInterval,
manualDays dayRange: ClosedRange<CalendarDay>,
) async throws -> YearAuditRecords {
let samples = try await samples(in: interval)
let manualDays = try await manualDays(in: dayRange)
let evidence = try await evidence(in: interval)
let trackedRegions = try await trackedRegions()
return YearAuditRecords(
samples: samples,
manualDays: manualDays,
evidence: evidence,
trackedRegions: trackedRegions,
)
}

/// Default: the out-of-the-box set. `SwiftDataStore` overrides this with the
/// persisted rows; in-memory test fakes inherit the default.
public func trackedRegions() async throws -> Set<Region> {
Expand Down
39 changes: 39 additions & 0 deletions Where/WhereCore/Sources/Reporting/ReportReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@ public struct ReportReader: Sendable {
}
}

/// Capture every record needed for an audit in one store read, then freeze
/// the tracked-region set into an immutable attributor so rows, daily
/// presence, and totals cannot disagree if settings change concurrently.
public func auditReport(for year: Int) async throws -> YearAuditReport {
try await Self.logger.measure(.auditReport, budget: .seconds(2)) {
let records = try await store.auditRecords(
in: aggregator.yearInterval(year: year),
manualDays: dayRange(for: year),
)
let trackedRegions = Region.inCanonicalOrder(records.trackedRegions)
let capturedAttributor = RegionAttributor(for: trackedRegions)
let attributedSamples = records.samples.map {
YearAuditAttributedSample(
sample: $0,
region: capturedAttributor.region(at: $0.coordinate),
)
}
let report = aggregator.report(
for: year,
samples: records.samples,
manualDays: records.manualDays,
attributor: capturedAttributor,
)
let trackedSet = records.trackedRegions
let sources = RegionDataSource.all.filter { source in
!trackedSet.isDisjoint(with: source.regions)
}
return YearAuditReport(
report: report,
samples: attributedSamples,
manualDays: records.manualDays,
evidence: records.evidence,
trackedRegions: trackedRegions,
timeZone: aggregator.timeZone,
regionDataSources: sources,
)
}
}

/// Everything a data-issue scan needs from a **single** year-samples read:
/// the aggregated `report`, the `.other` day coordinates border-drift checks
/// use, and the raw GPS fixes (lazily grouped in `DaySamples`) the
Expand Down
Loading
Loading