Skip to content
Merged
8 changes: 7 additions & 1 deletion Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,14 @@ literals in SwiftUI `Text` or `errorDescription`.

## Dates & presentation

- **A logical day is a `CalendarDay` (Y-M-D), not a `Date`.** It is the
timezone-independent identity every stored day-record and day comparison keys
on (see [`WhereCore/AGENTS.md`](WhereCore/AGENTS.md)); a `Date` is only for
instants (GPS bucketing, grid geometry, display), derived via
`CalendarDay.startOfDay(in:)`. Never persist a day as an absolute instant.
- **Year bounds are half-open** (`[Jan 1 year, Jan 1 year+1)`); **day ranges
are inclusive** (`Date.calendarDays(through:in:)`).
are inclusive** (`Date.calendarDays(through:in:)` for instants,
`CalendarDay.days(through:)` for logical days).
- **The app is Gregorian-only.** All presence data is aggregated in a Gregorian
calendar (`DayAggregator()` defaults to Gregorian + current time zone), so any
day/year math must use a Gregorian calendar — **never `Calendar.current`**,
Expand Down
5 changes: 5 additions & 0 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a
- What’s with all the `.accessibilityIdentifier(…)` modifiers, do we need them?
- Remove get/set closure-based bindings
- Add a UI that represents where you currently are? Maybe a border on the current location card?
- refactor: Per-entity schema versioning + lazy upcasting for CloudKit sync drift. There is intentionally **no** boot-time data migration (removed pre-release as over-built for a single dev's data). Today a data-shape change relies on: (a) `SD….toValue()` recovering old-shaped rows on read — e.g. `SDManualDay` with no `dayKey` recovers its `CalendarDay` from `dateKey` — and (b) a one-time manual backup **export → replace-import** to rewrite rows into the current shape. Gaps this leaves, which a general mechanism should close: an old-build device can sync in an old-shaped entity at any time (not just at launch), a `dayKey`-less `SDManualDay` is currently **invisible** to the `dayKey`-range queries (`manualDays(in:)` / `clear(...)`) until re-imported (only `allManualDays()` / export sees it via recovery), and legacy **dismissal** keys (epoch, not ISO `CalendarDay`) aren't recovered on read at all, so a pre-`CalendarDay` dismissal reappears until re-dismissed. Replace with:
- Make record→value conversion (`SD….toValue()`) a version-aware **upcaster**: each `@Model` carries its written schema version, and `toValue()` applies an ordered, pure, idempotent `vN → vN+1` chain, so every read is correct regardless of stored version — no import hook or scan needed (there is no per-record CloudKit import callback anyway). This is the "lazy migration / event-sourcing upcaster" pattern; today's `toValue()` recovery is its `v0 → v1` seed. Make the *filtered* reads (`manualDays(in:)`) upcast-aware too, so a not-yet-rewritten row isn't dropped by a column predicate.
- Persist a **minReaderVersion** per entity, not just a version. Additive (expand/contract) changes leave it low so old builds keep reading via the retained old field (tolerant reader); only a genuinely forward-incompatible change bumps it. Readers exclude entities whose `minReaderVersion > appVersion` and surface a "some data needs a newer app" warning — the only case that actually needs exclusion.
- Durable write-back is **read-repair**, decoupled from read correctness: opportunistically (batched, on `.NSPersistentStoreRemoteChange` + launch) rewrite stale records to the current version and stamp it, so old builds can honor exclusion. Transforms must be deterministic + commutative so two devices healing the same record via CloudKit converge (LWW-safe).
- Open question: the exclusion UX — an older device progressively hiding days a newer device has touched — needs a deliberate warning surface, not a silent drop.

## 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.
Expand Down
32 changes: 32 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,38 @@ internal shape.
production `SwiftDataStore` traps otherwise), and each committed transaction
pings `changes()` — the single signal readers refresh from. The live
`ModelContainer` is surfaced only for the read-only debug inspector.
- **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (year-month-
day) is the timezone-independent identity of a day, and it is what every
*stored user record* and *day comparison* keys on: `DayPresence.day`,
`SDManualDay.dayKey`, `RegionDayLocations.day`, `MissingDayRange`, the
missing-day / detector present-sets, and `DataIssueID.storageKey` (hence
persisted dismissals). A `Date` is an absolute instant, so persisting a day as
one makes it drift onto a *different* day when the device changes time zones —
the residency bug this exists to prevent. Reach for a `Date` only where you
genuinely need an instant — bucketing a GPS `sample.timestamp` into a day
(`CalendarDay(from:in:)` with the working calendar), calendar-grid geometry,
sorting, or display — and derive it via `CalendarDay.startOfDay(in:)` /
`DayPresence.startOfDay(in:)`; never store an instant as the key.
`SDManualDay.dateKey` is kept only as informational history and as
`toValue()`'s recovery source for a legacy `dayKey`-less row, not as a lookup
key. **Scope boundary:** this pins *stored user records* (manual days,
dismissals) to a fixed day, but a GPS `sample.timestamp` is still bucketed into
a `CalendarDay` by the *current* calendar at read time, so a GPS-derived day
can still shift by one across a time-zone change — and with it a dismissed
*GPS-only* border-drift / abrupt-change issue (whose `storageKey` is that
re-bucketed day) can reappear. Only user-asserted records and their keys are
travel-proof; travel-proofing GPS-derived detections would mean bucketing GPS
by a fixed home zone, which we intentionally don't do ("where was I on this
*local* day?").
- **Pre-`CalendarDay` rows are read, not migrated.** There is no boot-time data
migration: a legacy `SDManualDay` (no `dayKey`) reads correctly via
`toValue()`'s recovery from `dateKey` (in UTC, so timezone-stable), and a
one-time backup **export → replace-import** rewrites such rows with a canonical
`dayKey`. Legacy *dismissal* keys (epoch, not ISO) are **not** recovered on
read, so a pre-`CalendarDay` dismissal can reappear until re-dismissed or fixed
in that export round-trip. This is deliberate for pre-release; the durable,
general successor (per-entity schema versioning) is tracked in
[`../TODOs.md`](../TODOs.md).
- **Writes await their side effects.** `DayJournal` commits, then awaits the
reminder reconcile + widget publish in sequence, so a reader on the next
`changes()` ping never observes a half-applied write. `DataIssueScanner` drops
Expand Down
17 changes: 13 additions & 4 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@ one it belongs to rather than to a god-object:
manual entries `manualDays(inYear:)`, per-region `locations(in:year:)`, and
`representativeCoordinates(for:)`.
- **`YearReport` / `DayPresence` / `RegionDayLocations`** — the aggregated,
snapshot-stable value types the UI renders. A day counts for a region if *any*
sample that calendar day fell inside it, so a single day can belong to several.
snapshot-stable value types the UI renders, each keyed by a
timezone-independent **`CalendarDay`** (`DayPresence.day`). A day counts for a
region if *any* sample that calendar day fell inside it, so a single day can
belong to several.
- **`CalendarDay`** — a Y-M-D value that is the stable identity of a logical day.
Stored user records and day comparisons key on it so they don't drift onto a
different day across a time-zone change; project to a concrete `Date` (grid
layout, display) only via `startOfDay(in:)`.
- **`DayAggregator`** — turns samples + manual overlays into those reports,
carrying the injected `Calendar`.
carrying the injected `Calendar` (which decides how a `sample.timestamp`
buckets into a `CalendarDay`).

### Location

Expand All @@ -57,7 +64,9 @@ one it belongs to rather than to a god-object:

- **`DataIssueScanner`** + the `DataIssue` family (missing days, border drift,
abrupt change) — the "Resolve" tab's detections and their `IssueResolution`
fixes; dismissals persist under a stable, device-independent `storageKey`.
fixes; dismissals persist under a stable, device- and timezone-independent
`storageKey` (a `CalendarDay` ISO string), so a dismissal doesn't reappear
after travel.
- **Reconcilers** — `ReminderReconciler` (daily logging reminder + app-icon
badge), `DailySummaryReconciler` (year-to-date recap),
`DataIssueAlertReconciler` ("issues to resolve").
Expand Down
8 changes: 7 additions & 1 deletion Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// was added without a bump because it is additive: older readers ignore
/// the unknown key, and newer readers tolerate its absence (see
/// `init(from:)`).
public static let currentFormatVersion = 1
///
/// v2 keys `manualDays` by a timezone-independent `CalendarDay` (`day`)
/// rather than an absolute `date` instant. This reader still imports v1
/// archives — `DayPresence` decodes the legacy `date` and recovers its
/// calendar day — but a pre-v2 build refuses a v2 file with a clear
/// "newer version" message instead of failing to find `date`.
public static let currentFormatVersion = 2

public let formatVersion: Int
public let exportedAt: Date
Expand Down
144 changes: 144 additions & 0 deletions Where/WhereCore/Sources/CalendarDay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Foundation

/// A timezone-independent calendar day (year / month / day) — the stable
/// identity of a logical day in the Where domain.
///
/// A `Date` is an absolute instant, and *which* calendar day it names depends on
/// the time zone you read it in: midnight April 1 in New York is 9pm March 31 in
/// San Francisco. Persisting a user-asserted day (a manual override or backfill)
/// or a dismissal key as an instant therefore makes it silently drift onto a
/// different day when the device changes time zones — the bug this type exists
/// to prevent. `CalendarDay` pins the day to its `year`/`month`/`day` so it means
/// the same thing everywhere; resolve a concrete `Date` only when you actually
/// need an instant (calendar-grid geometry, store range queries) via
/// `startOfDay(in:)`.
///
/// Day arithmetic (`adding(days:)`, `days(through:)`, adjacency) is pure
/// Gregorian and does not depend on any caller's time zone, so two `CalendarDay`s
/// compare and step identically regardless of where the device is.
public struct CalendarDay: Hashable, Sendable, Codable, Comparable, CustomStringConvertible {
public let year: Int
public let month: Int
public let day: Int

public init(year: Int, month: Int, day: Int) {
self.year = year
self.month = month
self.day = day
}

/// The calendar day `date` falls on when read in `calendar` (whose time zone
/// decides the day boundaries).
public init(from date: Date, in calendar: Calendar) {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
// A calendar always resolves year/month/day for a concrete date; a `nil`
// is an impossible state, so trap rather than fabricate a bogus day.
guard let year = parts.year, let month = parts.month, let day = parts.day else {
preconditionFailure("Calendar returned no year/month/day for \(date)")
}
self.init(year: year, month: month, day: day)
}

/// Recover the calendar day a *legacy* start-of-day instant was meant to
/// represent, robust to the time zone it was written in. Legacy day keys were
/// midnight in the writer's zone; reading such an instant directly in a zone
/// west of the writer lands on the previous day, so we nudge ~12h toward local
/// noon before reading the components in `calendar`. Correct for every
/// realistic zone offset (best-effort only at the ±12h extremes). Used only by
/// the `CalendarDay` data migration.
public init(recoveringLegacyStartOfDay instant: Date, in calendar: Calendar) {
self.init(from: instant.addingTimeInterval(12 * 60 * 60), in: calendar)
}

/// Parse the `YYYY-MM-DD` form produced by `description`. Returns `nil` for
/// anything that isn't three correctly-padded fields *and* a real Gregorian
/// date, so a corrupt persisted key (`2026-13-01`, `2026-02-31`) can't decode
/// as a bogus day.
public init?(iso: String) {
let parts = iso.split(separator: "-", omittingEmptySubsequences: false)
guard parts.count == 3,
parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]),
(1 ... 12).contains(month), (1 ... 31).contains(day)
else { return nil }
// Reject impossible dates: the components must survive a round-trip
// through the Gregorian calendar unchanged (Feb 31 would roll to March).
let components = DateComponents(year: year, month: month, day: day)
let calendar = Self.arithmeticCalendar
guard let resolved = calendar.date(from: components) else { return nil }
let check = calendar.dateComponents([.year, .month, .day], from: resolved)
guard check.year == year, check.month == month, check.day == day else { return nil }
self.init(year: year, month: month, day: day)
}

/// The first instant of this day in `calendar` — the start-of-day `Date` for
/// grid layout and store range queries. Falls back to the epoch only for a
/// calendar that can't resolve the components (an impossible state we assert
/// on in debug rather than paper over).
public func startOfDay(in calendar: Calendar) -> Date {
guard let date = calendar.date(
from: DateComponents(year: year, month: month, day: day),
) else {
assertionFailure("Calendar could not resolve \(self)")
return Date(timeIntervalSince1970: 0)
}
return calendar.startOfDay(for: date)
}

/// This day shifted by `count` days (negative to go backward), computed with
/// pure Gregorian arithmetic independent of any time zone.
public func adding(days count: Int) -> CalendarDay {
let calendar = Self.arithmeticCalendar
let start = calendar.date(
from: DateComponents(year: year, month: month, day: day),
) ?? Date(timeIntervalSince1970: 0)
let shifted = calendar.date(byAdding: .day, value: count, to: start) ?? start
return CalendarDay(from: shifted, in: calendar)
}

/// Every day in the inclusive range `self ... end`. Empty when `end` is
/// before `self`.
public func days(through end: CalendarDay) -> [CalendarDay] {
guard self <= end else { return [] }
var result: [CalendarDay] = []
var cursor = self
while cursor <= end {
result.append(cursor)
cursor = cursor.adding(days: 1)
}
return result
}

public var description: String {
String(format: "%04d-%02d-%02d", year, month, day)
}

public static func < (lhs: CalendarDay, rhs: CalendarDay) -> Bool {
(lhs.year, lhs.month, lhs.day) < (rhs.year, rhs.month, rhs.day)
}

// `Codable` is compiler-synthesized over `year`/`month`/`day` — a
// `CalendarDay` is valid by construction, so there's nothing to validate or
// round-trip through a string on decode.

/// The last day of `year`. `CalendarDay` is Gregorian, so it is always
/// December 31.
public static func lastDay(ofYear year: Int) -> CalendarDay {
CalendarDay(year: year, month: 12, day: 31)
}

/// The inclusive `Jan 1 ... Dec 31` range of `year`, for scoping a year's
/// stored days.
public static func yearRange(_ year: Int) -> ClosedRange<CalendarDay> {
CalendarDay(year: year, month: 1, day: 1) ... lastDay(ofYear: year)
}

/// A fixed UTC Gregorian calendar for day arithmetic. `CalendarDay` is
/// timezone-independent, so stepping to the next day must not depend on the
/// caller's zone — only on Gregorian month/year lengths.
private static let arithmeticCalendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt
return calendar
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ public struct AbruptChangeIssue: DataIssue, Hashable {
}

public var id: DataIssueID {
.abruptChange(earlier: earlierDay.date, later: laterDay.date)
.abruptChange(earlier: earlierDay.day, later: laterDay.day)
}

public var category: DataIssueCategory {
.abruptChange
}

public var sortKey: Date {
laterDay.date
public var sortKey: CalendarDay {
laterDay.day
}

public var isDismissible: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public struct AbruptLocationChangeDetector: DataIssueDetector {
public init() {}

public func detectIssues(in input: DataIssueInput) -> [AbruptChangeIssue] {
let sortedDays = input.report.days.sorted { $0.date < $1.date }
let sortedDays = input.report.days.sorted { $0.day < $1.day }
guard sortedDays.count >= 2 else { return [] }

var issues: [AbruptChangeIssue] = []
Expand All @@ -24,16 +24,11 @@ public struct AbruptLocationChangeDetector: DataIssueDetector {
!earlier.regions.isEmpty,
!later.regions.isEmpty,
earlier.regions.isDisjoint(with: later.regions),
isCalendarAdjacent(earlier.date, later.date, calendar: input.calendar)
earlier.day.adding(days: 1) == later.day
else { continue }

issues.append(AbruptChangeIssue(earlierDay: earlier, laterDay: later))
}
return issues
}

private func isCalendarAdjacent(_ earlier: Date, _ later: Date, calendar: Calendar) -> Bool {
guard let next = calendar.date(byAdding: .day, value: 1, to: earlier) else { return false }
return calendar.isDate(next, inSameDayAs: later)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public struct BorderDriftDetector: DataIssueDetector {

var issues: [BorderDriftIssue] = []
for day in input.report.days where day.regions.contains(.other) {
guard let coordinates = input.otherDayCoordinates[day.date],
guard let coordinates = input.otherDayCoordinates[day.day],
!coordinates.isEmpty else { continue }

var bestRegion: Region?
Expand Down
6 changes: 3 additions & 3 deletions Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ public struct BorderDriftIssue: DataIssue, Hashable {
}

public var id: DataIssueID {
.borderDrift(date: day.date)
.borderDrift(day: day.day)
}

public var category: DataIssueCategory {
.borderDrift
}

public var sortKey: Date {
day.date
public var sortKey: CalendarDay {
day.day
}

public var isDismissible: Bool {
Expand Down
Loading
Loading