Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3b0f4ce
Add region-picking onboarding + per-region customization
kyleve Jul 16, 2026
571ddd9
Put region picker/customize in nav stacks with toolbar controls
kyleve Jul 16, 2026
83c71df
Commit region picks in one atomic transaction
kyleve Jul 16, 2026
e82e1ce
Document US-only convergence when editing the default region set
kyleve Jul 16, 2026
1462152
Resolve region styles via the view environment, not a global
kyleve Jul 16, 2026
f7363b2
Restore .other's original catch-all symbol
kyleve Jul 16, 2026
790e862
Signal a capped tap on the region picker map
kyleve Jul 16, 2026
8c51629
Default the region picker to List and load map geometry lazily
kyleve Jul 16, 2026
9c5108d
Clamp the region customize step index
kyleve Jul 16, 2026
82cf179
Fix WhereCoreTests build: rename primary() helper to pick()
kyleve Jul 16, 2026
97412b0
Round-trip region appearance through backups
kyleve Jul 16, 2026
f2228a9
Add restore-from-backup to onboarding
kyleve Jul 16, 2026
24d3da9
AGENTS: require keeping the PR description current
kyleve Jul 16, 2026
a060012
Group the Log-a-day region toggles into tracked / used / everything else
kyleve Jul 16, 2026
a19c611
Log-a-day middle section: non-tracked regions used this year
kyleve Jul 16, 2026
038a123
Merge remote-tracking branch 'origin/main' into cursor/region-onboarding
kyleve Jul 16, 2026
d2fc2ae
Merge remote-tracking branch 'origin/main' into cursor/region-onboarding
kyleve Jul 17, 2026
81ea280
Group the Settings region picker list into three sections
kyleve Jul 17, 2026
6ce4a04
Unify region-selection grouping across the two surfaces
kyleve Jul 17, 2026
cf9771e
Harden + test the review findings (backup merge, restore-no-wipe)
kyleve Jul 17, 2026
868ef42
Use the shared app-icon loading view for onboarding restore
kyleve Jul 17, 2026
5c1ebb8
Merge remote-tracking branch 'origin/main' into cursor/region-onboarding
kyleve Jul 17, 2026
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 @@ -385,6 +385,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
13 changes: 10 additions & 3 deletions Where/Tools/upgrade-backup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
# - Dismissals: converts `{ "key": "borderDrift:2026-04-01", ... }` to
# `{ "id": "store://issues/borderDrift?day=2026-04-01", ... }`, parsing the
# old joined key and recovering any legacy epoch value to a calendar day.
# - Top level: ensures `dismissedIssues` / `trackedRegions` exist and sets
# `formatVersion` to 1 (the reset current version).
# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes
# `primaryRegions` from the tracked ids (null appearance, listed order) when
# absent, and sets `formatVersion` to 2 (the current version).
#
# Idempotent: re-running on an already-upgraded archive is a no-op (it only
# touches legacy `date` / `key` fields and unmapped region ids).
Expand All @@ -39,7 +40,7 @@
require "set"

MANIFEST_NAME = "manifest.json"
CURRENT_FORMAT_VERSION = 1
CURRENT_FORMAT_VERSION = 2

# Former enum-case region ids -> current catalog ids. `canada` / `other` are
# unchanged but listed so an already-current id passes through untouched.
Expand Down Expand Up @@ -171,6 +172,12 @@ def upgrade_manifest(manifest)
end
manifest["dismissedIssues"] ||= []
manifest["trackedRegions"] ||= []
# v2 adds `primaryRegions` (each tracked region's picked look + order).
# A pre-v2 archive has no picked looks, so synthesize entries from the
# tracked ids with a null appearance, in their listed order.
manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index|
{ "region" => id, "appearance" => nil, "order" => index }
end
manifest["formatVersion"] = CURRENT_FORMAT_VERSION
warnings.uniq.each { |message| warn "warning: #{message}" }
manifest
Expand Down
20 changes: 20 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ internal shape.
`WhereServices.forIntents(sharingStoreOf:)` — rather than a second caller
opening another container over the same file (concurrent first-launch
creation is how the launch once failed).
- **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`, 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). The archive is **strict synthesized `Codable`** — no in-code
legacy decode. A shape change **bumps `BackupArchive.currentFormatVersion`**
(`readArchive` rejects any other version) and is handled out of band by
extending [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb), per the
no-migration-on-read rule below. Example: v2 added `primaryRegions` (per-region
picked appearance + pick order), the tool synthesizes it from the legacy
`trackedRegions` ids, and import restores looks from it.
- **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 @@ -30,9 +30,12 @@ one it belongs to rather than to a god-object:
in the app, the launch's `open-store` step opens it and the App Intents
stack shares it via `WhereServices.forIntents(sharingStoreOf:)` — so two
subsystems never race to create/open the same store file. 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()` / `setPrimaryRegions(_:)`
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.
- **`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
20 changes: 17 additions & 3 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// readers can't understand, so an importer can refuse a file it doesn't
/// know how to read instead of silently dropping data (see
/// `BackupService.readArchive`, which rejects any other version).
public static let currentFormatVersion = 1
///
/// v2 adds `primaryRegions` (each tracked region's picked appearance + pick
/// order). There's no in-app decode fallback for a pre-v2 archive — it's
/// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes
/// `primaryRegions` from `trackedRegions`), matching the module's
/// no-migration-on-read rule (see `AGENTS.md`).
public static let currentFormatVersion = 2

public let formatVersion: Int
public let exportedAt: Date
Expand All @@ -25,9 +31,15 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// Data-resolution dismissals (issue id + when dismissed), so a restore
/// keeps issues the user already dismissed dismissed.
public let dismissedIssues: [DismissedIssue]
/// The user's tracked regions at export time, so a restore carries the
/// region selection like any other data.
/// The user's tracked regions at export time (region ids only), so a restore
/// carries the region selection. Retained alongside ``primaryRegions`` as the
/// bare-id list `upgrade-backup.rb` and the import summary count read.
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. Import restores from
/// this; `trackedRegions` is the derived id list.
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 @@ -40,6 +52,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
manualDays: [DayPresence],
dismissedIssues: [DismissedIssue],
trackedRegions: [Region],
primaryRegions: [PrimaryRegion],
assets: [BackupAssetEntry],
) {
self.formatVersion = formatVersion
Expand All @@ -49,6 +62,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
self.manualDays = manualDays
self.dismissedIssues = dismissedIssues
self.trackedRegions = trackedRegions
self.primaryRegions = primaryRegions
self.assets = 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.primaryRegions
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.primaryRegions.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