Skip to content

RegionKit: data-driven regions + per-region on-demand geometry, with store-backed tracked regions#87

Merged
kyleve merged 13 commits into
mainfrom
cursor/regionkit-dynamic-regions
Jul 15, 2026
Merged

RegionKit: data-driven regions + per-region on-demand geometry, with store-backed tracked regions#87
kyleve merged 13 commits into
mainfrom
cursor/regionkit-dynamic-regions

Conversation

@kyleve

@kyleve kyleve commented Jul 15, 2026

Copy link
Copy Markdown
Owner

What & why

Prepares RegionKit for "more locations" ahead of letting users pick their own regions. Two coupled shifts:

  1. Split the ~2.5 MB monolithic us-states.geojson into one GeoJSON file per region, loaded on demand — so we only ever parse the regions we track, not the whole US at launch.
  2. Replace the hardcoded five-case Region enum with a data-driven catalog (a bundled regions.json manifest + a Region value type), so any US state (50 + DC + PR) plus Canada/EU are available regions. The user's tracked regions live in the synced SwiftData store (WhereCore owns "which"; RegionKit stays the agnostic geometry/lookup engine).

Onboarding's region picker and user-chosen per-region styling are out of scope here (designed for, not built).

What changed

  • RegionKit — data-driven catalog + on-demand geometry
    • Region is now a Hashable/Codable value type over a stable id (us-CA, canada, …) with a well-known .other and conveniences (.california, etc.). RegionCatalog loads the bundled regions.json manifest (all/name/localizedName/geometry file, canonical order). Adding a region is now pure data — a manifest + geojson change generated by Tools/generate-regions.rb, no code.
    • RegionAttributor(for:) loads only the passed regions' files; .all covers the whole catalog, .shared the default four. RegionAttributing abstracts the engine so the app can supply a live, swappable attributor. RegionGeometryCatalog.outlines(for:attributor:) takes an explicit attributor.
  • WhereCore — store-backed, synced tracked regions + live attributor
    • Tracked regions persist as one SDTrackedRegion row per region (so concurrent cross-device edits merge instead of last-write-wins), read as a Set defaulting to the four until the user chooses.
    • RegionAttribution rebuilds the attributor from the tracked set on the store's changes() signal (local edit or remote CloudKit import). WhereServices.make(...) (async) derives it from the store; the app launch and the App Intents process (WhereServices.forIntents(), now async) both attribute against the same synced set. make(...) is the sole public assembly entry; the synchronous init is @_spi(Testing) for tests/previews.
  • WhereUI / WhereIntents — catalog-driven
    • RegionStyle is id-keyed with an isolated, disposable bespoke-override table (the seam user-chosen styling will replace). Ordering uses Region.inCanonicalOrder(_:) instead of scanning Region.allCases.
    • Siri's "pick a region" suggestions and the Spotlight index surface the tracked set; RegionEntity id resolution stays full-catalog (so "days in Texas" answers even when untracked).
  • Backup — tracked regions round-trip in the archive (additive field; v1 manifests decode to []); .replace restores the set exactly, .merge unions it.

Key decisions / tradeoffs

  • Clean id scheme (us-CA vs california): existing persisted region raw values change; data is re-exported/re-imported. The NAME → id map lives in Tools/generate-regions.rb.
  • Localization tradeoff: region names come from the manifest (with an optional localizationKey override), so region names lose static string-catalog extraction — consistent with how the App Intents RegionEntity already resolves names at runtime.
  • Attribution priority is the manifest's canonical order (regions are mutually exclusive at our resolution).

Deferred (follow-ups)

  • The onboarding region picker and user-chosen per-region color/emoji/symbol/tint.
  • Seeding semantics (the default four apply only at zero rows; the picker will seed/replace) and materializing the implicit default before the first explicit edit.
  • Untracking a region: the store currently deletes the tracked-region row, which would re-attribute that region's past GPS days to .other on re-aggregation (manual days, stored as region sets, are safe). A TODO defers the soft-delete (mark inactive + load every ever-tracked region so history stays stable) to the picker work that defines the untrack UX. Not user-reachable today — nothing calls setTrackedRegion(false) outside tests.

Testing

  • Full Stuff-iOS-Tests scheme green; ./swiftformat --lint clean.
  • New/reworked coverage: catalog + value-type (RegionTests), subset-only-loads (RegionAttributorTests), rebuild-on-changes() + store round-trip (RegionAttributionTests, TrackedRegionStoreTests), end-to-end make() wiring (WhereServicesTests), tracked-set entity/suggestions (RegionEntityTests), and backup round-trip / legacy-manifest decode (BackupServiceTests, BackupCoordinatorTests).

Review pass

  • Self-review: log (not swallow) tracked-region read failures; deterministic (canonical-ordered) attributor build; surface unknown stored region ids; backup round-trip (above).
  • PR review feedback: consolidated the RegionKit bundled-data docs into the main README.md (removed the separate Resources/README.md); documented why Region's Codable is hand-written (bare id string vs the synthesized keyed object); made the sync WhereServices.init @_spi(Testing) so make() is the sole public entry; and TODO'd the untrack soft-delete (see Deferred).

Kept current with main

Merged #85 (CalendarDay), #86 (Periscope JournalKit), #88 (backup onImport reconcile hook), and #89 (streamlined AGENTS + WhereCore source regroup). Conflicts resolved: the backup formatVersion (kept v2 and the additive trackedRegions field); the BackupCoordinatorTests harness swap (kept both the tracked-region tests and #88's onImport hook test); and Where/AGENTS.md (took #89's streamlined Modules section, but kept the accurate manifest-backed RegionKit localization note over #89's stale "static keys" wording). My changes rode the source regroup into Backup/, Persistence/, Days/, DataResolution/, etc.

Open in Web Open in Cursor 

kyleve added 11 commits July 14, 2026 12:08
Replaces the hardcoded five-case Region enum with a value type wrapping a
stable string id (us-CA, canada, ...), backed by a bundled regions.json
catalog manifest, and splits the monolithic us-states.geojson into one
Resources/regions/<id>.geojson per region loaded on demand.

- Region is now a Hashable/Codable value type (RawRepresentable) with a
  failable init(rawValue:) that validates against the catalog, id-preserving
  Codable, and static conveniences (.california/.newYork/.canada/
  .europeanUnion/.other) so existing call sites keep compiling.
- RegionCatalog loads regions.json (all/name/localizedName/geometry file,
  canonical order); adding a region is now a pure data change.
- RegionAttributor builds for a specific region set (RegionAttributor(for:))
  loading only those files; .all covers the whole catalog; .shared keeps the
  historical four while the app's tracked set moves into the store.
- RegionGeometryCatalog sources outlines from the catalog's per-region files.
- The monolithic source geojson moves to Tools/source/ (generator inputs,
  no longer bundled); RegionStyle becomes id-keyed with a disposable bespoke
  override table (TODO: remove when user-chosen styling lands).

Split + loader switch land together because .process(Resources) flattens the
bundle namespace, so the monolith and the split canada.geojson can't coexist.

Closes plan steps: split-geometry, region-catalog
RegionGeometryCatalog.outlines(for:attributor:) now sources .attribution from
the passed attributor instead of a global, so a caller shows exactly the
regions it tracks; .source still decodes the whole catalog. The DEBUG
RegionMapView (standalone, no session) passes .all.

Adds on-demand subset tests: an attributor built for [.california] resolves SF
but returns .other for NYC, and any catalog state (e.g. us-IL) can be tracked.

RegionAttributor.shared remains the last global user (WhereServices' default)
and is removed in the store-backed tracked-regions step.

Closes plan step: ondemand-attributor
Tracked regions (the set the app loads geometry for and attributes against)
now live in the SwiftData store as one SDTrackedRegion row per region, so
concurrent cross-device edits merge instead of last-write-wins. They're read
as a Set, defaulting to the historical four (CA/NY/Canada/EU) until the user
chooses; clearAll() (reset) wipes them back to that default.

- WhereStore gains trackedRegions() / setTrackedRegion(_:id:) with
  protocol-extension defaults, so only SwiftDataStore persists them.
- RegionAttributing protocol abstracts the lookup engine; RegionAttribution is
  a live provider that rebuilds RegionAttributor from the tracked set on the
  store's changes() signal (a local edit or a remote CloudKit import), only
  when the set actually changes. Collaborators hold an existential
  RegionAttributing.
- WhereServices.make(...) (async) derives the attributor from the store and is
  used by the app launch and the App Intents process (WhereServices.forIntents
  is now async), so both processes attribute against the same synced set. The
  synchronous init keeps RegionAttributor.shared (the default four) for
  tests/previews.
- declarationOrder now follows the catalog's canonical order.

Border-drift/data-issue detection is already scoped to the tracked set: the
attributor only loads tracked-region geometry, so distanceToBoundary returns
nil for anything untracked. (Fully removing the UI's primaryRegions parameter
to close the TODO is left for the picker work.)

Closes plan step: wherecore-chosen
Replaces the remaining Region.allCases enumeration in presentation code with
catalog-driven ordering. Adds Region.inCanonicalOrder(_:) (canonical/catalog
order without a day-count metric) and uses it to order the regions present on a
day: PresenceCalendar dots, RegionDaysView / LoggedDaysView captions, and the
widget's orderedDayRegions no longer scan every region.

RegionSelectionState now takes an explicit regions list (defaulting to the
whole catalog plus .other) instead of Region.allCases, giving a seam for a
future onboarding-aware form to offer only the tracked subset. RegionStyle was
already catalog-driven (data-driven default + disposable bespoke table).

Closes plan step: whereui-catalog
RegionEntity.tracked(from:) sources the 'pick a region' menu
(RegionEntityQuery.suggestedEntities) and the Spotlight index
(RegionSpotlightIndexer) from the user's tracked regions (via
WhereServices.trackedRegions), in canonical order, instead of every available
region. RegionEntityQuery.entities(for:) still resolves any available region
by id, so a spoken 'days in Texas' answers even when Texas isn't tracked.

Region ordering helpers (orderedRegions, IntentStrings.regionList) use
Region.inCanonicalOrder rather than scanning Region.allCases. Refreshes the
stale 'fixed five-case set' comments. Reworks RegionEntityTests to cover
id round-trip, full-catalog resolution, and tracked-set suggestions.

Closes plan step: whereintents-align
Updates the module docs to the new model: RegionKit's data-driven Region +
RegionCatalog (adding a region is now pure data — a manifest + geojson change,
no code), per-region on-demand attribution and the RegionAttributing seam, the
manifest-backed region-name localization trade-off, WhereCore's store-backed
synced tracked regions + WhereServices.make/RegionAttribution rebuild, and
WhereIntents surfacing the tracked set for suggestions/Spotlight while
resolving any available region by id. Ran ./sync-agents.

Closes plan step: docs-tests
…r order

Review follow-ups:
- RegionAttribution.reconcile no longer swallows a trackedRegions() read
  failure via try?; it logs a warning on a new WhereLog .regionAttribution
  channel and keeps the last-good attributor, so a persistent read error is
  observable instead of silently freezing on a stale/empty set.
- WhereServices.make and RegionAttribution.reconcile build the attributor from
  Region.inCanonicalOrder(tracked) rather than Array(Set), so the first-match
  attribution priority is deterministic and catalog-ordered (correct even if
  regions ever overlap) instead of riding on Set iteration order.
Tracked regions are real, backed-up data: BackupArchive gains an additive
trackedRegions field (old manifests decode to []), exportBackup captures the
resolved set (canonical order), and import restores it — .replace writes the
archive's set exactly (after clearAll), .merge unions it into the current set
(reading the resolved current set first so an implicit-default device doesn't
collapse to just the imported regions). ImportSummary + the import confirmation
message/log now include the tracked-region count.

This closes the review gap where a replace-import wiped the user's region
selection (and, on CloudKit, propagated that deletion cross-device) because the
archive didn't carry it.

Tests: archive round-trip + legacy-manifest-absent decode (BackupServiceTests),
replace-restore + merge-union (BackupCoordinatorTests), updated StringsTests.
Review follow-ups (low):
- SwiftDataStore.trackedRegions() now logs a warning listing any stored
  region ids it can't resolve against the catalog, instead of silently
  dropping them (if every id drops, the empty set makes everything attribute
  to .other, which must not be invisible).
- Adds an end-to-end test that WhereServices.make(...) derives the attributor
  from the store's tracked set and threads it through the read path: with only
  California tracked, an NYC sample reports .other (not .newYork).

(Region.allCases already documents that it includes .other and points callers
at RegionCatalog.all for the available-only list.)
…ions

# Conflicts:
#	Where/WhereCore/Sources/Backup/BackupArchive.swift
…mic-regions

# Conflicts:
#	Where/WhereCore/Tests/BackupCoordinatorTests.swift

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better to just put this in the main RegionKit readme, not hidden down here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on Kyle's behalf.

Done — folded the bundled-region-data docs (manifest shape, id scheme, regenerating steps, and source-data provenance) into the main RegionKit/README.md under a "Bundled data" section, deleted Sources/Resources/README.md, and repointed the one doc reference in RegionCatalog. (a2f509c)

}
}

extension Region: Codable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth defining this ourselves? What do we get from doing it ourselves vs. using the compiler provided version?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on Kyle's behalf.

Kept it, but documented the rationale inline. The compiler-synthesized Codable for a single-field struct emits a keyed object ({"rawValue":"us-CA"}), whereas we want the bare id string ("us-CA") so Set<Region> / [Region: Int] and the persisted regionRaws: [String] round-trip as plain strings — matching the format the former String-backed enum produced, which keeps stored SwiftData, widget snapshots, and backup archives compatible. Without the custom impl every stored region would be wrapped in an object. Added a type-level doc note explaining this. (a2f509c)

context.insert(SDTrackedRegion(regionID: id))
} else {
for record in existing {
context.delete(record)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we ever be deleting a region? Could that break old years' data? Perhaps maybe we should just mark regions as if they're actively tracking or not?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on Kyle's behalf.

Good catch. Deleting the SDTrackedRegion row doesn't touch any stored samples / manual days / evidence (they keep their region ids) — but reports re-aggregate GPS samples through the current attributor, which only loads the tracked set, so untracking a region would re-attribute its past GPS days to .other on the next read. (Manual days are stored as region sets and are unaffected.)

Per your suggestion, the fix is a soft-delete (mark the row inactive, never delete) plus having the attributor load every ever-tracked region so past reports stay stable, while "active" drives the UI/primary set going forward. That's a model change entangled with the untrack UX the onboarding/region picker will define, so I've added a TODO and deferred it to that work. It's not user-reachable today — nothing calls setTrackedRegion(false) outside tests. (a2f509c)

/// rows resolve to the default four, matching `RegionAttributor.shared`),
/// while production wiring — the app launch and the App Intents process —
/// goes through here so both attribute against the *same* stored set.
public static func make(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels potentially duplicative with the public init; should only one be public?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on Kyle's behalf.

Agreed. Made the synchronous init @_spi(Testing) so make(...) is the single public assembly entry: production (app launch + App Intents) derives the attributor from the store's tracked regions via make(...), and tests/previews use the sync init via @_spi(Testing) import WhereCore. (make() has to stay public since the app launch needs it and it builds the internal RegionAttribution.) Updated the 13 direct callers that weren't already importing with @_spi(Testing). (a2f509c)

kyleve added 2 commits July 15, 2026 15:12
- README (#1): fold the bundled-region-data docs (manifest shape, id scheme,
  regenerating, source provenance) into the main RegionKit/README.md and delete
  the separate Sources/Resources/README.md; repoint the RegionCatalog doc.
- Region.Codable (#2): document why it's hand-written rather than synthesized —
  the synthesized Codable would emit a keyed object ({"rawValue":"us-CA"}), but
  we want the bare id string so Set<Region>/[Region:Int] and the persisted
  regionRaws round-trip as plain strings (matching the old enum's format).
- Tracked-region delete (#3): add a TODO — untracking currently deletes the row,
  which drops the region from the attributor's load set (past GPS days would
  re-attribute to .other on re-aggregation; manual days are unaffected). Defer
  the soft-delete + load-ever-tracked-regions model to the onboarding/region
  picker work, which defines the untrack UX. Not user-reachable yet.
- WhereServices constructors (#4): make the synchronous init @_spi(Testing) so
  make(...) is the single public assembly entry (production reads the tracked set
  from the store; tests/previews use the sync init via @_spi(Testing) import).
@kyleve kyleve enabled auto-merge (squash) July 15, 2026 22:29
@kyleve kyleve merged commit 5b617ad into main Jul 15, 2026
2 checks passed
kyleve added a commit that referenced this pull request Jul 15, 2026
Second main merge brought PR #87, which turned RegionAttributor into the
'any RegionAttributing' protocol across the detection/aggregation APIs.
Point the two flight-feature params added by this branch
(DayAggregator.pointsByRegion, FlightDayDetector.flightIssue) at the
protocol so they accept the store-backed attributor callers now pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant