Skip to content
Draft
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
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,25 @@ work end to end without re-asking per comment.
not addressed, record it somewhere durable (the module's `TODOs.md` or the
review-tracking file) and reply linking where it's tracked.

## Keeping the PR description current

A PR's title and description are the durable record of what it does — keep them
matching the branch, not just the first commit.

- **When the PR changes beyond a small bug fix, update the description in the
same turn you push.** New behavior, a new/changed public API or data model, a
migration, a scope change, a follow-up feature, or review fixes that alter the
approach all warrant refreshing the body (and the title if the scope shifted).
A trivial fix — a typo, a one-line bug fix, a test tweak that doesn't change
what the PR is — doesn't.
- **Reflect the end state, not a changelog of the conversation.** Describe what
the PR now does; note notable decisions/trade-offs and testing. Don't leave a
stale body that only describes the initial commit.
- **Preserve human edits.** If someone edited the title/description in the
GitHub UI, fold your update into theirs rather than overwriting — only correct
what's now inaccurate.
- Use the PR tooling when it works; otherwise `gh pr edit <n> --body-file`.

## Debugging build/test/CI failures

**Check `git status` and recent history first — before analyzing the error.** A
Expand Down
17 changes: 17 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ 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.
- **Primary regions *are* the tracked-region set.** The picked primary regions
(`primaryRegions()` / `setPrimaryRegions(_:)`) are the same `SDTrackedRegion`
rows `trackedRegions()` reads — picking scopes GPS attribution *and* carries
each region's `RegionAppearance` (color token / emoji / SF Symbol) + pick
order. `RegionAppearance` is data (WhereCore); the token→`Color` mapping and
option catalogs are presentation (`WhereUI`).
- **Backups mirror the persisted model — keep them lossless.** Any change to
persisted data (a new/changed `SD*` field, or a value type that crosses
`WhereStore`) must be reflected end-to-end in the backup so export/restore
never silently drops it: add it to `BackupArchive` (with a back-compatible
`init(from:)`), write it in `BackupService.makeArchiveFile`, read it back in
`BackupCoordinator.importBackup` for **both** `.replace` and `.merge`, and add
a round-trip test (`BackupServiceTests` for the archive, `BackupCoordinatorTests`
for the store round-trip). New archive fields are **additive**
(`decodeIfPresent` → default) and older keys stay put so a cross-version
restore still recovers what it can — see `primaryRegions` carried alongside the
legacy `trackedRegions` ids.
- **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`,
Expand Down
9 changes: 6 additions & 3 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ one it belongs to rather than to a god-object:
atomic transaction) and `changes()` emits once per commit and on a CloudKit
remote import. `SwiftDataStore.make()` is the production, CloudKit-backed
implementation; `SwiftDataStore.inMemory()` backs tests and previews. It also
holds the user's **tracked regions** (`trackedRegions()` /
`setTrackedRegion(_:id:)`) — one synced row per region, defaulting to the four
until the user chooses.
holds the user's **tracked / primary regions** (`trackedRegions()` /
`setTrackedRegion(_:id:)`, plus `primaryRegions()` / `setPrimaryRegion(_:id:order:)`
which surface and persist each region's picked `RegionAppearance` — color
token, emoji, SF Symbol — and pick order alongside the synced row) — one row
per region, defaulting to the four until the user chooses in the onboarding /
Settings region picker.
- **`RegionAttribution`** — a live `RegionAttributing` built from the tracked
regions that rebuilds on `changes()` (a local edit or a remote import), so the
app + App Intents process attribute against the same synced set. Assemble
Expand Down
34 changes: 29 additions & 5 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// keeps issues the user already dismissed dismissed. Absent in manifests
/// written before this field existed; those decode to `[]`.
public let dismissedIssues: [DismissedIssue]
/// The user's tracked regions at export time, so a restore carries the
/// region selection like any other data. Absent in manifests written before
/// this field existed; those decode to `[]`.
/// The user's tracked regions at export time (region ids only), so a restore
/// carries the region selection like any other data. Absent in manifests
/// written before this field existed; those decode to `[]`. Retained
/// alongside ``primaryRegions`` for cross-version compatibility — an older
/// reader that predates `primaryRegions` still recovers the region set from
/// here (without the picked looks).
public let trackedRegions: [Region]
/// The user's primary regions at export time, each with its picked
/// ``RegionAppearance`` (color / emoji / icon) and pick order, so a restore
/// brings back the *look*, not just the region set. Additive: absent in
/// manifests written before it existed (those decode to `[]`, and the
/// importer falls back to ``trackedRegions``).
public let primaryRegions: [PrimaryRegion]
/// One entry per evidence record that has blob bytes in the archive.
/// Evidence without bytes simply has no entry here.
public let assets: [BackupAssetEntry]
Expand All @@ -50,6 +59,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
manualDays: [DayPresence],
dismissedIssues: [DismissedIssue] = [],
trackedRegions: [Region] = [],
primaryRegions: [PrimaryRegion] = [],
assets: [BackupAssetEntry],
) {
self.formatVersion = formatVersion
Expand All @@ -59,9 +69,20 @@ public struct BackupArchive: Codable, Sendable, Hashable {
self.manualDays = manualDays
self.dismissedIssues = dismissedIssues
self.trackedRegions = trackedRegions
self.primaryRegions = primaryRegions
self.assets = assets
}

/// The primary regions to restore: ``primaryRegions`` when present, else a
/// fallback built from ``trackedRegions`` (older archives) with no picked
/// appearance and their listed order.
public var resolvedPrimaryRegions: [PrimaryRegion] {
if !primaryRegions.isEmpty { return primaryRegions }
return trackedRegions.enumerated().map { index, region in
PrimaryRegion(region: region, appearance: nil, order: index)
}
}

private enum CodingKeys: String, CodingKey {
case formatVersion
case exportedAt
Expand All @@ -70,12 +91,13 @@ public struct BackupArchive: Codable, Sendable, Hashable {
case manualDays
case dismissedIssues
case trackedRegions
case primaryRegions
case assets
}

/// Custom decode (encode stays synthesized) so manifests written before
/// `dismissedIssues` / `trackedRegions` existed still import: the missing
/// keys decode to `[]` rather than throwing.
/// `dismissedIssues` / `trackedRegions` / `primaryRegions` existed still
/// import: the missing keys decode to `[]` rather than throwing.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
formatVersion = try container.decode(Int.self, forKey: .formatVersion)
Expand All @@ -87,6 +109,8 @@ public struct BackupArchive: Codable, Sendable, Hashable {
.decodeIfPresent([DismissedIssue].self, forKey: .dismissedIssues) ?? []
trackedRegions = try container
.decodeIfPresent([Region].self, forKey: .trackedRegions) ?? []
primaryRegions = try container
.decodeIfPresent([PrimaryRegion].self, forKey: .primaryRegions) ?? []
assets = try container.decode([BackupAssetEntry].self, forKey: .assets)
}
}
Expand Down
64 changes: 48 additions & 16 deletions Where/WhereCore/Sources/Backup/BackupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@ public actor BackupCoordinator {
let evidence = try await store.allEvidence()
let manualDays = try await store.allManualDays()
let dismissedIssues = try await store.allDismissedIssues()
// The resolved tracked set (the four when the user hasn't chosen yet),
// in canonical order so the archive is stable.
let trackedRegions = try await Region.inCanonicalOrder(store.trackedRegions())
// The user's primary regions with their picked looks + order (the
// resolved default set when they haven't chosen yet). `trackedRegions`
// carries the bare ids alongside it for older readers.
let primaryRegions = try await store.primaryRegions()
let trackedRegions = primaryRegions.map(\.region)
var blobs: [UUID: Data] = [:]
var lastPercent = -1
for (index, item) in evidence.enumerated() {
Expand All @@ -125,6 +127,7 @@ public actor BackupCoordinator {
manualDays: manualDays,
dismissedIssues: dismissedIssues,
trackedRegions: trackedRegions,
primaryRegions: primaryRegions,
blobs: blobs,
)
}.value
Expand Down Expand Up @@ -221,20 +224,21 @@ public actor BackupCoordinator {
try await store.restoreDismissedIssue(dismissal)
report()
}
// Tracked regions round-trip like any other data. On `.replace` the
// store was cleared above, so write the archive's set exactly; on
// `.merge` union it into the current set (reading the *resolved*
// current set first so a device on the implicit default four doesn't
// collapse to just the imported regions). A handful of rows, so
// they're not folded into the progress total.
let regionsToWrite: Set<Region> = if strategy == .merge {
try await store.trackedRegions().union(archive.trackedRegions)
// Primary regions (with their picked looks) round-trip like any
// other data. On `.replace` the store was cleared above, so write
// the archive's set exactly; on `.merge` union it into the current
// set (reading the *resolved* current set first so a device on the
// implicit default four doesn't collapse to just the imported ones),
// with the archive's appearance winning on overlap. `setPrimaryRegions`
// is a whole-set replace, so a merge builds the full merged list. A
// handful of rows, so they're not folded into the progress total.
let archivePrimary = archive.resolvedPrimaryRegions
let regionsToWrite: [PrimaryRegion] = if strategy == .merge {
try await Self.merge(archivePrimary, into: store.primaryRegions())
} else {
Set(archive.trackedRegions)
}
for region in regionsToWrite {
try await store.setTrackedRegion(true, id: region.rawValue)
archivePrimary
}
try await store.setPrimaryRegions(regionsToWrite)
}
// An import rewrites day data, so the badge / notification / widget
// reconcile a day change runs has to follow it — these headless
Expand All @@ -248,7 +252,35 @@ public actor BackupCoordinator {
evidenceCount: archive.evidence.count,
manualDayCount: archive.manualDays.count,
dismissedIssueCount: archive.dismissedIssues.count,
trackedRegionCount: archive.trackedRegions.count,
trackedRegionCount: archive.resolvedPrimaryRegions.count,
)
}

/// Union `archive` primary regions into `current` for a `.merge` import:
/// current regions keep their order and come first, archive-only regions are
/// appended, and the archive's picked appearance wins on overlap (a `nil`
/// archive look never clobbers an existing customized one). Reindexed densely
/// for `setPrimaryRegions`.
private static func merge(
_ archive: [PrimaryRegion],
into current: [PrimaryRegion],
) -> [PrimaryRegion] {
var appearances: [Region: RegionAppearance] = [:]
var order: [Region] = []
var seen: Set<Region> = []
func add(_ region: Region) {
if seen.insert(region).inserted { order.append(region) }
}
for entry in current {
add(entry.region)
if let appearance = entry.appearance { appearances[entry.region] = appearance }
}
for entry in archive {
add(entry.region)
if let appearance = entry.appearance { appearances[entry.region] = appearance }
}
return order.enumerated().map { index, region in
PrimaryRegion(region: region, appearance: appearances[region], order: index)
}
}
}
2 changes: 2 additions & 0 deletions Where/WhereCore/Sources/Backup/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public struct BackupService: Sendable {
manualDays: [DayPresence],
dismissedIssues: [DismissedIssue] = [],
trackedRegions: [Region] = [],
primaryRegions: [PrimaryRegion] = [],
blobs: [UUID: Data],
exportedAt: Date = Date(),
archiveName: String? = nil,
Expand Down Expand Up @@ -124,6 +125,7 @@ public struct BackupService: Sendable {
manualDays: manualDays,
dismissedIssues: dismissedIssues,
trackedRegions: trackedRegions,
primaryRegions: primaryRegions,
assets: assetEntries,
)
let manifestData = try Self.makeEncoder().encode(archive)
Expand Down
Loading
Loading