RegionKit: data-driven regions + per-region on-demand geometry, with store-backed tracked regions#87
Conversation
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
There was a problem hiding this comment.
I think it'd be better to just put this in the main RegionKit readme, not hidden down here.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Is it worth defining this ourselves? What do we get from doing it ourselves vs. using the compiler provided version?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
This feels potentially duplicative with the public init; should only one be public?
There was a problem hiding this comment.
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)
- 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).
…mic-regions # Conflicts: # Where/AGENTS.md
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.
What & why
Prepares RegionKit for "more locations" ahead of letting users pick their own regions. Two coupled shifts:
us-states.geojsoninto one GeoJSON file per region, loaded on demand — so we only ever parse the regions we track, not the whole US at launch.Regionenum with a data-driven catalog (a bundledregions.jsonmanifest + aRegionvalue 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
Regionis now aHashable/Codablevalue type over a stable id (us-CA,canada, …) with a well-known.otherand conveniences (.california, etc.).RegionCatalogloads the bundledregions.jsonmanifest (all/name/localizedName/geometry file, canonical order). Adding a region is now pure data — a manifest + geojson change generated byTools/generate-regions.rb, no code.RegionAttributor(for:)loads only the passed regions' files;.allcovers the whole catalog,.sharedthe default four.RegionAttributingabstracts the engine so the app can supply a live, swappable attributor.RegionGeometryCatalog.outlines(for:attributor:)takes an explicit attributor.SDTrackedRegionrow per region (so concurrent cross-device edits merge instead of last-write-wins), read as aSetdefaulting to the four until the user chooses.RegionAttributionrebuilds the attributor from the tracked set on the store'schanges()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 synchronousinitis@_spi(Testing)for tests/previews.RegionStyleis id-keyed with an isolated, disposable bespoke-override table (the seam user-chosen styling will replace). Ordering usesRegion.inCanonicalOrder(_:)instead of scanningRegion.allCases.RegionEntityid resolution stays full-catalog (so "days in Texas" answers even when untracked).[]);.replacerestores the set exactly,.mergeunions it.Key decisions / tradeoffs
us-CAvscalifornia): existing persisted region raw values change; data is re-exported/re-imported. TheNAME → idmap lives inTools/generate-regions.rb.localizationKeyoverride), so region names lose static string-catalog extraction — consistent with how the App IntentsRegionEntityalready resolves names at runtime.Deferred (follow-ups)
.otheron re-aggregation (manual days, stored as region sets, are safe). ATODOdefers 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 callssetTrackedRegion(false)outside tests.Testing
Stuff-iOS-Testsscheme green;./swiftformat --lintclean.RegionTests), subset-only-loads (RegionAttributorTests), rebuild-on-changes()+ store round-trip (RegionAttributionTests,TrackedRegionStoreTests), end-to-endmake()wiring (WhereServicesTests), tracked-set entity/suggestions (RegionEntityTests), and backup round-trip / legacy-manifest decode (BackupServiceTests,BackupCoordinatorTests).Review pass
README.md(removed the separateResources/README.md); documented whyRegion'sCodableis hand-written (bare id string vs the synthesized keyed object); made the syncWhereServices.init@_spi(Testing)somake()is the sole public entry; and TODO'd the untrack soft-delete (see Deferred).Kept current with
mainMerged
#85(CalendarDay),#86(Periscope JournalKit),#88(backuponImportreconcile hook), and#89(streamlined AGENTS + WhereCore source regroup). Conflicts resolved: the backupformatVersion(kept v2 and the additivetrackedRegionsfield); theBackupCoordinatorTestsharness swap (kept both the tracked-region tests and#88'sonImporthook test); andWhere/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 intoBackup/,Persistence/,Days/,DataResolution/, etc.