Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ properties only when the values are genuinely independent, and let that be the
exception you can justify, not the reflex. A good type makes the illegal states
impossible to spell and the legal ones obvious.

The Where app does this with `WhereSession.LoadState` (`idle` / `loading` /
`loaded` / `failed(String)`) rather than juggling `isLoading` + `error` +
The Where app does this with `YearReportModel.LoadState` (`idle` / `loading` /
`loaded` / `failed(LoadError)`) rather than juggling `isLoading` + `error` +
`data`, and `CalendarView` keeps one `Result<[CalendarMonth], Error>?` instead
of separate `months` and `layoutError` properties — success and failure can't
both be set, and "not loaded yet" is the `nil`.
Expand Down
39 changes: 24 additions & 15 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ Where/
- **`WhereCore`** is the domain layer: pure Swift + Foundation + SwiftData +
CoreLocation; it must **not** import SwiftUI or UIKit. Bundled region
polygons (`Resources/*.geojson`) ship here.
- **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models
(`WhereModel` app-level, `WhereSession` logged-in over `WhereServices`). It
is **not** the domain model — see [Layering](#layering).
- **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models —
the app-level `WhereModel`, the always-on `WhereSession` coordinator (no
presentation state), and its scope-tiered children (scene-scoped
`YearReportModel`, view-scoped `ResolveModel` / `BackupModel` /
`RemindersSettingsModel`). It is **not** the domain model — see
[Layering](#layering).

## Layering

Expand All @@ -42,12 +45,13 @@ must not grow business logic just because SwiftUI makes it easy.
| Layer | Where | Owns |
|-------|-------|------|
| **Domain / services** | `WhereCore` (`WhereServices` collaborators) | Rules, detection, aggregation, persistence, side effects (reminders, widgets, backup). Unit-test here. |
| **View model** | `WhereUI` (`WhereSession`, `WhereModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. Orchestrates `WhereServices`; does not reimplement Core rules. |
| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to session/model. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. |
| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator + scope-tiered `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. Orchestrates `WhereServices`; does not reimplement Core rules. |
| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings to the coordinator / scoped models. Calls view-model methods; does not talk to the store, run detection, or own cache/throttle policy. |

When in doubt: if the behavior would still be correct without SwiftUI, it
belongs in `WhereCore` (or, for logged-in orchestration that exists only to
serve the UI, on `WhereSession` — still not in a `View`).
belongs in `WhereCore` (or, for orchestration that exists only to serve the
UI, on the `WhereSession` coordinator or a scoped model — still not in a
`View`).

Rules the code enforces and agents must preserve:

Expand All @@ -59,7 +63,8 @@ Rules the code enforces and agents must preserve:
- **One read path.** Every committed write (manual edit, live GPS, CloudKit
remote import) pings the single store-change signal (`WhereStore.changes()`),
and readers refresh purely off it — write intents just commit, they don't
refresh inline. Launch is driven by
refresh inline. The scene's `YearReportModel` subscribes while it's active;
`DataIssueScanner` drops its cache on the same signal. Launch is driven by
[`LifecycleKit`](../Shared/LifecycleKit) (see `WhereLaunch` in WhereUI).
- **All logging goes through `WhereLog.channel(_:)`** with a typed
`WhereLog.Category` case, never a raw string. Messages log as `.public`, so
Expand Down Expand Up @@ -91,9 +96,11 @@ literals in SwiftUI `Text` or `errorDescription`.

- **Year bounds are half-open** (`[Jan 1 year, Jan 1 year+1)`); **day ranges
are inclusive** (`Date.calendarDays(through:in:)`).
- **Inject `Calendar`, don't reach for globals** — logged-in UI reads
`WhereSession.calendar`; layout types carry the calendar they were built
with. Prefer calendar APIs over hardcoding day/weekday counts.
- **Inject `Calendar`, don't reach for globals** — the scene's
`YearReportModel` owns the calendar (Gregorian, current time zone) its
missing-day math uses; layout types carry the calendar they were built with.
Prefer calendar APIs over hardcoding day/weekday counts (`Calendar.dayCount`
derives 365/366 rather than assuming a length).
- **Core layout APIs throw on failure**; views surface
`ContentUnavailableView` + log, never `!`.
- Layout constants live in `UIConstants`, shared date-range copy in
Expand All @@ -108,10 +115,12 @@ Every previewable component in `WhereUI` (any `View`, `Widget`, or
wrapped in `#if DEBUG` at the bottom. Don't construct services, stores, or
location sources inline — pull fixtures from
[`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) (synchronous,
in-memory, never touch disk/CloudKit/CoreLocation) and inject what the view
reads from the environment (`WhereSession` for logged-in views, `WhereModel`
for the app shell). Cover the states that matter — empty, loaded, and distinct
edge states — not just the happy path.
in-memory, never touch disk/CloudKit/CoreLocation). Pass scoped models
explicitly (a `YearReportModel` via `report:`, a seeded `ResolveModel`) and
inject ambient app state through the environment (`WhereModel` for the app
shell, the `WhereSession` coordinator for logged-in views). Cover the states
that matter — empty, loaded, and distinct edge states — not just the happy
path.

## Adding things

Expand Down
2 changes: 2 additions & 0 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a
- Add a UI that represents where you currently are? Maybe a border on the current location card?

## P2s (Nice to have)
- refactor: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split.
- refactor: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split.
- The `guard let controller else { return }` in the WhereModel in WhereUI is weird
- refactor: Move `RegionDays` / `RegionRanking` down from `WhereUI` into `WhereCore` so `DataIssueScanner` can derive primary regions itself instead of `WhereSession` passing `primaryRegions` in. Reverses the current "ranking is a presentation concept" placement; check the widget/UI call sites still compile.
- Raw data browser (similar to SD browser)
Expand Down
37 changes: 34 additions & 3 deletions Where/WhereCore/Sources/BackupCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import LogKit

/// Owns backup export/import over the `BackupService` and the store, publishing
/// the widget snapshot after an import lands new data.
Expand Down Expand Up @@ -41,16 +42,28 @@ public actor BackupCoordinator {
private let store: any WhereStore
private let backupService = BackupService()
private let widgets: WidgetSnapshotPublisher
private static let logger = WhereLog.channel(.backupService)

/// Staging directory of the most recent export. Each archive lands in its
/// own temporary directory; the share sheet copies the file it needs out of
/// ours and gives no dismissal hook to clean up after, so we purge the
/// previous export lazily when the next one starts (bounding us to one stale
/// archive on disk). Actor-isolated, so it survives the UI that triggered
/// the export being torn down.
private var previousExportDirectory: URL?

init(store: any WhereStore, widgets: WidgetSnapshotPublisher) {
self.store = store
self.widgets = widgets
}

/// Serialize the entire store (all four tables plus evidence blobs) to a
/// `.zip` in the temporary directory and return its URL. The caller owns the
/// file: share it, then delete it (or its parent directory).
/// `.zip` in a fresh temporary directory and return its URL, first purging
/// the previous export's directory. The caller shares the file; the next
/// export (or process exit) reclaims the disk.
public func exportBackup() async throws -> URL {
purgePreviousExport()

let samples = try await store.allSamples()
let evidence = try await store.allEvidence()
let manualDays = try await store.allManualDays()
Expand All @@ -62,7 +75,7 @@ public actor BackupCoordinator {
}
}
let backupService = backupService
return try await Task.detached(priority: .utility) {
let url = try await Task.detached(priority: .utility) {
try backupService.makeArchiveFile(
samples: samples,
evidence: evidence,
Expand All @@ -71,6 +84,24 @@ public actor BackupCoordinator {
blobs: blobs,
)
}.value
previousExportDirectory = url.deletingLastPathComponent()
return url
}

/// Delete the previous export's staging directory if we still have one. A
/// failure here is non-fatal — a leftover temp directory only wastes a
/// little disk — so it's logged rather than thrown, and never blocks the new
/// export.
private func purgePreviousExport() {
guard let previous = previousExportDirectory else { return }
previousExportDirectory = nil
do {
try FileManager.default.removeItem(at: previous)
} catch {
Self.logger.warning(
"Failed to remove previous backup export directory: \(error.localizedDescription)",
)
}
}

/// Read a backup `.zip` and write its contents back into the store inside a
Expand Down
22 changes: 22 additions & 0 deletions Where/WhereCore/Sources/Calendar+DayCount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

extension Calendar {
/// The number of days in `year` (365, or 366 in a leap year), evaluated in
/// this calendar. Derived from the calendar's own day-of-year range rather
/// than assuming a fixed length, so leap years come out right.
///
/// The `366`/`365` split falls out of `range(of:.day, in:.year, for:)`; the
/// literal fallback is an unreachable release safety net for a calendar that
/// can't even resolve a mid-year date (an impossible state we assert on in
/// debug rather than paper over).
public func dayCount(ofYear year: Int) -> Int {
guard
let midYear = date(from: DateComponents(year: year, month: 6, day: 15)),
let range = range(of: .day, in: .year, for: midYear)
else {
assertionFailure("Calendar could not resolve the day range for year \(year)")
return 365
}
return range.count
}
}
46 changes: 46 additions & 0 deletions Where/WhereCore/Sources/YearReport+MissingDays.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

/// Missing-day derivation for a loaded `YearReport`. These adapt the pure
/// `MissingDays` rules to a specific report + reference date, so callers (the
/// scene's year model, the calendar) read a one-liner instead of re-deriving
/// the present-day set and backlog cutoff themselves.
///
/// "Missing" is only meaningful for the *current* year: a past year can't gain
/// today's coverage, so these return empty for it. Today itself is excluded
/// (it's still loggable) via `MissingDays.backlogCutoff`.
extension YearReport {
/// Whether this report's `year` is the calendar year containing `now`.
public func isCurrentYear(asOf now: Date, calendar: Calendar) -> Bool {
year == calendar.component(.year, from: now)
}

/// Start-of-day keys for days in this report's year that still need logging
/// as of `now` (Jan 1 through yesterday). Empty for a past year.
public func missingDayKeys(asOf now: Date, calendar: Calendar) -> Set<Date> {
guard isCurrentYear(asOf: now, calendar: calendar) else { return [] }
return Set(MissingDays.missingDayKeys(
year: year,
through: MissingDays.backlogCutoff(asOf: now, calendar: calendar),
present: Set(days.map(\.date)),
calendar: calendar,
))
}

/// Unlogged days in this report's year (as of `now`), collapsed into
/// consecutive ranges for the warning banner and backfill flow. Empty for a
/// past year.
public func missingDayRanges(asOf now: Date, calendar: Calendar) -> [MissingDayRange] {
guard isCurrentYear(asOf: now, calendar: calendar) else { return [] }
return MissingDays.missingRanges(
year: year,
through: MissingDays.backlogCutoff(asOf: now, calendar: calendar),
present: Set(days.map(\.date)),
calendar: calendar,
)
}

/// Total number of unlogged days behind `missingDayRanges(asOf:calendar:)`.
public func missingDayCount(asOf now: Date, calendar: Calendar) -> Int {
missingDayRanges(asOf: now, calendar: calendar).reduce(0) { $0 + $1.dayCount }
}
}
17 changes: 17 additions & 0 deletions Where/WhereCore/Tests/BackupCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ struct BackupCoordinatorTests {
#expect(try await destination.store.allDismissedIssues() == [Self.dismissal])
}

/// The coordinator owns the export staging directory's lifecycle: starting a
/// new export purges the previous one, so at most one archive sits on disk.
@Test func exportPurgesThePreviousExportDirectory() async throws {
let harness = try Self.makeHarness()
try await Self.seed(harness.store)

let first = try await harness.coordinator.exportBackup()
let firstDirectory = first.deletingLastPathComponent()
#expect(FileManager.default.fileExists(atPath: first.path))

let second = try await harness.coordinator.exportBackup()
defer { try? FileManager.default.removeItem(at: second.deletingLastPathComponent()) }

#expect(!FileManager.default.fileExists(atPath: firstDirectory.path))
#expect(FileManager.default.fileExists(atPath: second.path))
}

@Test func importReportsProgressUpToCompletion() async throws {
let source = try Self.makeHarness()
try await Self.seed(source.store)
Expand Down
23 changes: 23 additions & 0 deletions Where/WhereCore/Tests/Calendar+DayCountTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation
import Testing
import WhereCore

struct CalendarDayCountTests {
private let calendar = WhereCoreTestSupport.calendar()

@Test func commonYearHas365Days() {
#expect(calendar.dayCount(ofYear: 2025) == 365)
#expect(calendar.dayCount(ofYear: 2023) == 365)
}

@Test func leapYearHas366Days() {
#expect(calendar.dayCount(ofYear: 2024) == 366)
#expect(calendar.dayCount(ofYear: 2000) == 366)
}

/// 1900 is divisible by 100 but not 400, so the Gregorian calendar skips its
/// leap day — the derived count must reflect that, not a naive `year % 4`.
@Test func gregorianCentennialSkipsLeapDay() {
#expect(calendar.dayCount(ofYear: 1900) == 365)
}
}
54 changes: 54 additions & 0 deletions Where/WhereCore/Tests/YearReport+MissingDaysTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation
import Testing
import WhereCore

struct YearReportMissingDaysTests {
private let calendar = WhereCoreTestSupport.calendar()

private func day(_ year: Int, _ month: Int, _ day: Int) -> Date {
calendar.startOfDay(for: calendar.date(from: DateComponents(
year: year,
month: month,
day: day,
))!)
}

/// A current-year report surfaces the past gaps (Jan 1 through yesterday) and
/// excludes today, which is still loggable.
@Test func currentYearSurfacesPastGapsExcludingToday() {
let now = day(2026, 1, 5)
let report = YearReport(
year: 2026,
days: [
DayPresence(date: day(2026, 1, 2), regions: [.california]),
DayPresence(date: day(2026, 1, 4), regions: [.california]),
],
totals: [.california: 2],
)

// Present: Jan 2 & Jan 4. Missing through Jan 4 (yesterday): Jan 1 & Jan 3.
#expect(report.missingDayRanges(asOf: now, calendar: calendar).map(\.start) == [
day(2026, 1, 1),
day(2026, 1, 3),
])
#expect(report.missingDayCount(asOf: now, calendar: calendar) == 2)
#expect(report.missingDayKeys(asOf: now, calendar: calendar) == [
day(2026, 1, 1),
day(2026, 1, 3),
])
// Today (Jan 5) is never surfaced — it can still be logged.
#expect(!report.missingDayKeys(asOf: now, calendar: calendar).contains(day(2026, 1, 5)))
}

/// A past year can't gain today's coverage, so it has no missing days even
/// with nothing logged.
@Test func pastYearHasNoMissingDays() {
let now = day(2026, 6, 15)
let report = YearReport(year: 2025, days: [], totals: [:])

#expect(!report.isCurrentYear(asOf: now, calendar: calendar))
#expect(report.missingDayRanges(asOf: now, calendar: calendar).isEmpty)
#expect(report.missingDayCount(asOf: now, calendar: calendar) == 0)
#expect(report.missingDayKeys(asOf: now, calendar: calendar).isEmpty)
}
}
12 changes: 4 additions & 8 deletions Where/WhereUI/Sources/Launch/WhereLaunch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@
/// First-run onboarding gate. Foreground-only, so a headless background
/// relaunch skips it.
case onboarding
/// Read location authorization into the session and start observing live
/// changes — both authorization updates and the store's data-change signal
/// (live GPS ingestion / remote sync the UI mirrors).
/// Read location authorization into the coordinator and start observing live
/// authorization changes. The report + data-issue scan (and their
/// store-change subscription) load with the scene now, not here — so a
/// headless background relaunch never drives a refresh no UI consumes.
case syncAuth = "sync-auth"
/// Start or stop GPS ingestion to match the user's intent + authorization.
case reconcileTracking = "reconcile-tracking"
/// Load the selected year's report into the session.
case loadYear = "load-year"
/// Push the logging-reminder schedule + backlog badge to the reconciler.
case reminders
/// Push the daily-summary recap to the reconciler.
Expand Down Expand Up @@ -57,7 +56,7 @@
public static func lifecycleReason(
from launchOptions: [UIApplication.LaunchOptionsKey: Any]?,
) -> LifecycleReason {
launchOptions?[.location] != nil ? .background(.location) : .userForeground

Check warning on line 59 in Where/WhereUI/Sources/Launch/WhereLaunch.swift

View workflow job for this annotation

GitHub Actions / Build & Test (iOS)

'location' was deprecated in iOS 26.0: Adopt CLLocationUpdate or CLMonitor, or use CLLocationManagerDelegate from CoreLocation to handle expected location events after scene connection.
}

/// Build the runner for `model`, launching for `reason`.
Expand Down Expand Up @@ -122,13 +121,10 @@
LifecycleStep.work(LaunchStepID.syncAuth) { _ in
await model.session?.syncAuthorization()
model.session?.observeAuthorizationChanges()
model.session?.observeDataChanges()
}
LifecycleStep.work(LaunchStepID.reconcileTracking) { _ in
await model.session?.reconcileTracking()
}
LifecycleStep
.work(LaunchStepID.loadYear) { _ in await model.session?.refresh() }
LifecycleStep.work(LaunchStepID.reminders) { _ in
await model.session?.applyReminderConfiguration()
}
Expand Down
Loading
Loading