2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder - #251
Draft
chrisballinger wants to merge 60 commits into
Draft
2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder#251chrisballinger wants to merge 60 commits into
chrisballinger wants to merge 60 commits into
Conversation
- YearSettings.plist: 2026, Aug 30 - Sep 7, new Man center (40.783242, -119.207871) - Rename data package products iBurn2025* -> iBurn2026* (Bundle+iBurn, pbxproj product deps, PlayaAPI/PlayaDB test deps, BundleDataLoader) - BRCDatabaseManager: iBurn-2026.sqlite (forces clean rebuild); re-arm embargo defaults key for 2026; BRCArtObject default year 2026 - PlayaGeocoder: embed data/2026 geocoder bundle.js (was still 2025, causing 2025 street names in reverse geocoding) - MARKETING_VERSION 2026.0 - Tests: fixture event dates day-mapped into the 2026 festival window (BRCRecurringEventObject drops occurrences outside YearSettings.eventStart/eventEnd at import), MockServices event moved to 2026, PlayaDB GPS/region tests skip while location data is embargoed, real-data year assertions -> 2026, ObjectListViewModel mock provider now re-yields metadata after toggleFavorite (fixes broken-since-April toggle test) - Docs: 2026 update plan + implementation log Bump iBurn-Data submodule to 2026 data (2026-updates branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Correctness: - Fix FTS5 external-content sync triggers: delete/update now use the canonical 'delete' command with OLD values (plain DELETE corrupted the index on any out-of-import row change). Legacy triggers are detected and replaced with a one-time index rebuild on open. - Normalize event-occurrence metadata identity: favorites/notes/viewed on an EventObjectOccurrence now target the parent event uid, with a startup migration folding legacy "<uid>_<occ>" rows into the parent. Fixes hearts not lighting up from the SwiftUI event list. - observeEvents now tracks object_metadata + thumbnail_colors so favorite toggles and color-cache writes refresh lists and the favorites map layer. Performance: - DatabasePool (WAL) for on-disk databases: reads no longer block behind the seed import; DatabaseQueue retained for :memory: tests. - Read paths are write-free: removed ensureMetadata blank-row prepopulation (~10k rows on first launch) from all fetch/observe paths; metadata is created lazily on actual writes. - Removed unused allArt/allCamps/allEvents/allMutantVehicles/favorites reactive props and their 5 always-on full-table observations (incl. a retain cycle). - Import rewrite: dictionary-based GPS resolution (~16k point queries eliminated), set-based spatial/R*Tree rebuilds, sync triggers dropped during bulk load; full-dataset import now ~0.3s (test suite 14s→7.5s). - Added event_occurrences(end_time) index + EXPLAIN QUERY PLAN tests; image URL fetches aggregate in SQL. - removeDuplicates() on all observations, backed by new Equatable conformances across models and ListRow. 162 tests passing (+9 new across FTSTriggerTests, MetadataIdentityTests, QueryPlanTests, FilterObservationTests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
- List observations now track only the metadata columns lists render (object_type, object_id, is_favorite, user_notes) via a shared listMetadataRegion; art/camp/MV observations move from auto-tracking to explicit regions (event_objects only for onlyWithEvents, mv_tags only when tag-filtered). - Metadata writers switched to column-limited updates — GRDB's full-row update(db) touches every column, which made last-viewed writes look like favorite changes to the region tracker. Together these mean setLastViewed (detail-screen views) no longer re-runs any list query, including the 8k-row event JOIN; favorite/notes writes still re-fire. - Schema setup now runs through DatabaseMigrator: current schema registered as v1-initial-schema (idempotent DDL so pre-migrator installs adopt cleanly); FTS/R*Tree setup, occurrence backfill, and the metadata fold remain open-time maintenance. Future schema changes become numbered migrations. 166 tests passing (+4: inverted-expectation region tests in FilterObservationTests, new SchemaMigrationTests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Replaces the ask-first git-write policy: agents should commit once a coherent unit of work is verified (tests + app build as applicable). Pushing, history rewrites, pulls, squashes, merges, and rebases still require explicit authorization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Adds createInMemoryPlayaDB() to the PlayaDB package and a shared PreviewPlayaDB.shared helper; PreviewArtDataProvider and PreviewCampDataProvider no longer open extra connections to the real Documents/PlayaDB.sqlite from SwiftUI previews. Also enables the ui-automation workflow in .xcodebuildmcp/config.yaml for simulator UI driving from coding agents. Verified via simulator sanity pass of the audit changes on a fresh install: WAL journal mode, full seed (321 art / 1201 camps / 2101 events / 4431 occurrences), zero blank metadata rows, v1 migration recorded, day-tab switching, favorite → parent-event-uid metadata row + live heart refresh + favorites tab, FTS matches + integrity-check on the live database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
New project skill (.claude/skills/drive-app) capturing how to build, launch, and drive iBurn in the iOS Simulator via XcodeBuildMCP UI automation: session setup, the DEBUG-only SwiftUI/PlayaDB feature flag, lazy seeding behavior, onboarding/permission-alert sequence, AX-tree gotchas (hearts, searchable fields), and direct on-sim SQLite verification with invariants. references/flows.md catalogs the critical flows (onboarding, feature flag, events browsing, favoriting end-to-end, search, map/embargo, detail, feature flags screen) as step-by-step scripts, verified today against the 2026 dataset. CLAUDE.md points to the skill and establishes the maintenance rule: flow changes update the flow doc in the same change, and drift noticed while driving the app gets fixed in that session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
SwiftUI preview snapshots race the observation stream's first async emission, so list previews always captured the loading frame. Two changes make both states deterministic: - ObjectListViewModel gains an initialItems parameter (default []) so rows are present on the first rendered frame; app call sites are unchanged. - Preview data providers take rows (default mockRows, first row favorited to exercise the heart); passing [] yields a stream that never emits, pinning the loading state for its own preview. Adds "Art List - Loading" and "Camp List - Loading" previews. All four states verified via Xcode RenderPreview: cells previews show 3 rows with a filled heart on the favorited row; loading previews show the spinner. App build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
- Add .watchOS(.v10) platform to PlayaDB, PlayaAPI, iBurn-Data packages - New iBurnWatch app target (watchOS 26, SPM-only, not embedded in iOS target yet so iOS/CI builds don't require the watch platform) - Smoke screen seeds PlayaDB from bundled APIData and shows counts; verified on Apple Watch Ultra 3 sim: 321 art / 1201 camps - Plan doc: Docs/2026-07-03-watchos-mvp-plan.md (MapLibre has no watchOS support -> custom SwiftUI Canvas map from bundled GeoJSON) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- New Packages/PlayaGeo (zero-dep): GeoJSON decoder, equirectangular projection around The Man, MapCamera (pan/zoom/rotation math), PlayaMapView Canvas renderer (fence/streets/plazas/toilets/markers/ user dot + heading cone). 18 unit tests incl. real 2026 city data. - Watch MapScreen: crown zoom, drag pan (rotation-aware), heading-up toggle with figure-8 calibration hint (watchOS has no system calibration API), follow-user + recenter; geo resources referenced in place from the iBurn-Data submodule. - Verified on Apple Watch Ultra 3 sim: city renders, location permission flow, compass toggle. iOS app build unaffected. - drive-app flows.md: new watchOS flow entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- Root restructured to NavigationStack (verticalPage TabView fought the map's crown-zoom/drag-pan); Nearby + Favorites via toolbar buttons - NearbyScreen: region-filtered art+camps sorted by GPS distance - FavoritesScreen: watch-local favorites with distances - DetailScreen: favorite toggle + Navigate (gated on hasLocation); NavigationScreen fits user+POI with live distance/bearing readout - ContentView smoke page replaced by WatchSeeder app-level task - Sim-verified end-to-end with injected GPS (pre-embargo bundle has none): 309m/590m/1.1km sort, favorite -> object_metadata camp|uid|1, navigate readout '309 m . 55(deg)' - Documented PlayaDB gap: spatial_index R*Tree not maintained on UPDATE of gps columns (only import rebuild + insert/delete triggers) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
Region queries went through spatial_index, which was only maintained by import-time rebuild plus insert/delete triggers - an in-place UPDATE of gps columns (the likely shape of the embargo location drop) left the index stale and region queries silently missed those rows. - New *_spatial_update triggers for art/camp/event: delete stale rows, conditionally reinsert keyed via the spatial_objects mapping table - New event_occurrence_rtree_event_update trigger propagates event GPS changes into the denormalized occurrence R*Tree - Trigger generation refactored into a data-driven loop (was 3x copy-paste); dropIndexSyncTriggers covers the new names - Existing DBs adopt the triggers on next open (IF NOT EXISTS) - SpatialIndexUpdateTests: gain/move/clear GPS via UPDATE, no duplicate rows, occurrence propagation, trigger presence. Full suite 172 green; iOS + watch app builds verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- iBurn target: dependency + 'Embed Watch Content' copy phase - Watch target: WKCompanionAppBundleIdentifier=com.trailbehind.iBurn2010, WKRunsIndependentlyOfCompanionApp=YES (still standalone-capable), WKWatchOnly removed, version aligned to container (2026.0 / 108) - Verified: iOS install on paired iPhone 17 Pro Max sim auto-installed the watch app on the paired Series 11; map renders there - Note: iOS builds (incl. CI) now require the watchOS platform/SDK Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
The iOS and watch seeders only imported bundled APIData when the database had never been seeded, so devices seeded with 2025 data never picked up the 2026 bundle. Events then all failed the default includeExpired:false filter, leaving the Events list empty while Art/Camps silently showed stale rows. importFromData now records update.json's per-type timestamps as UpdateInfo.lastUpdated, and a new PlayaDB.needsImport(bundleUpdateData:) compares them so seeders re-import whenever the bundle ships newer data (or a new data type like mv). PlayaAPI's UpdateInfo model gains the missing mv field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
A real GPS fix far from BRC made the follow-camera center hundreds of km from the city, rendering a blank map. Clamp user projection via new PlayaMapData.pointOnPlaya (10 km radius) in MapScreen and NavigationScreen, so off-playa fixes behave like "no location". Also add BlackRockCity.gpx wired into the iBurnWatch scheme so Xcode runs simulate standing at The Man for at-home testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- DataUpdatesView: hoist main-actor BRCAppDelegate.shared.dataImporter access out of the nonisolated continuation closure - DetailViewModel: drop redundant Task.detached around asyncReadWrite, which already runs on YapDatabase's own queue - MainMapViewController: use async selectAnnotation(_:animated:) - Art/CampListView previews: nonisolated provider class with @mainactor init instead of @mainactor class, fixing default-arg and override isolation mismatches - NearbyListHostingController: target/selector timer instead of @sendable closure capturing the view controller Clean build of the iBurn scheme now emits zero warnings; all 97 iBurnTests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- DEVELOPMENT_TEAM on iBurnWatch configs (matches the team already committed for the iBurn target; needed for watch device signing) - lastKnownFileType on 2026 geojson refs, package ref comment paths, drop redundant group name, watch scheme format bump Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MapScreen: double tap zooms in one level (+2 crown units = 2x, clamped to max zoom) - New iBurnWatch/Assets.xcassets with 1024px AppIcon (from the iOS marketing icon) and AccentColor, wired into the watch target - PRODUCT_NAME = iBurn so the installed watch app reads "iBurn" (CFBundleName previously fell back to the iBurnWatch target name); product/scheme references updated to iBurn.app Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useSwiftUILists promoted to all builds, default true (kill-switch kept; Settings.bundle toggle added for Release fallback) - Adult-event region gating restored in the SwiftUI events list + search (RegionStatusService + EventFilter.excludingAdultEvents, legacy parity) - FavoriteSyncService: list hearts mirror favorites into Yap with per- occurrence event fan-out and EKEvent calendar refresh; fixes latent event-uid suffix bugs in DetailViewModel/DetailDataService sync paths - Nearby includes events starting within 30 min (legacy parity) - festivalDays end-inclusive: final festival day (Exodus) browsable - Tests: EventListAdultGatingTests (8), FavoriteSyncServiceTests (11); full iBurnTests suite green; fresh-install sim pass verified both DBs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PlayaDB gains a v2 migration (object_metadata.favorite_updated_at — LWW can't use updated_at since view-tracking bumps it), FavoriteSyncItem, favoriteSyncSnapshot/applyFavoriteSync/observeFavoriteSyncState, and a symmetric FavoritesSyncManager (WCSession applicationContext; lives in the package so the watch target needs no pbxproj surgery). Phone wiring in DependencyContainer mirrors incoming watch favorites into Yap (occurrence fan-out + EKEvent refresh); watch wiring starts the manager after seeding and live-refreshes FavoritesScreen. Same-state applies skip writes so peers can't ping-pong; sessionWatchStateDidChange / sessionCompanionAppInstalledDidChange re-push so a fresh install converges (found in paired-sim E2E: pushes fail with WCError 7006 until the peer app exists). Watch polish: FavoritesScreen surfaces load errors instead of showing "No favorites yet"; DetailScreen lists upcoming occurrence times for favorited events. Verified: PlayaDB suite 189/189 (12 new merge tests), iBurnTests green, both apps build 0 warnings, paired-sim E2E both directions (phone event -> watch list + times; watch camp -> phone PlayaDB + decoded Yap blob). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
…where Watch gains a Browse menu (map top-left): Nearby / Camps / Art / Vehicles / Events — searchable alphabetical lists and a day-chip event browser (single observeEventsByDayThenHour subscription, in-memory day switching, adult events excluded off-playa like the phone). Enabled by converting the iBurnWatch group to a filesystem-synchronized root group, so new watch files no longer need pbxproj surgery. Visit status joins PlayaDB (v3 migration: visit_status + visit_status_updated_at) with setVisitStatus/fetchObjects(visitStatus:) and per-field LWW in the favorites sync payload, so favorites and visit status merge independently between phone and watch. Phone mirrors both fields into Yap on incoming sync (new mirrorVisitStatus, event fan-out); DetailDataService dual-writes with event-uid normalization. Parity gap found during E2E and fixed: the PlayaDB-backed detail had no VISIT STATUS cell and updateVisitStatus no-oped for non-legacy subjects — want-to-visit was unreachable in the shipping default stack. Cell added after USER NOTES; all subject cases now write PlayaDB + Yap. Watch detail gains a visit-status sheet (Menu is unavailable on watchOS); watch Favorites gains a filter sheet (Show: Favorites/Want to Visit/Visited; Type: All/Camps/Art/Events/Vehicles). Verified: PlayaDB suite 206/206 (17 new merge tests), iBurnTests green (5 new mirror tests), zero-warning builds, paired-sim E2E both directions (watch "Want to Visit" -> phone PlayaDB + Yap blob; phone "Visited" -> watch row inserted via sync; fresh watch install migrates v1->v3 and pulls state on activation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
Bump iBurn-Data for tiles regenerated from burningmantech/innovate-GIS-data 2026 GeoJSON (published today). Add imageMap entries for BMorg's renamed CPNs (ESD Station 3/9 -> firstAid) and new ones (Arctica Outpost -> ice, Recycle Camp -> recycle). Fix Bundle.brc_cachedMbtilesURL to refresh the Application Support copy when the bundled map.mbtiles changes - previously the cache was copied once and existing installs never saw new tiles shipped in app updates (remote tile updates are dead code in BRCDataImporter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015MWYhmRLk3VDcpjZH1qhZT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Street names render along their polylines (majors at <8 m/pt, minors at <4), spaced by StreetLabelLayout, a pure screen-space placement helper with unit tests. Watch MapScreen's two buttons collapse into one tracking toggle cycling free -> follow -> follow-heading; panning bakes the compass rotation before dropping to free so the map doesn't snap under the finger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hour-scrub scroll anchors used a bare .id(hour) on the first row of each hour section. Hours repeat every day and the LazyVStack persists across day-tab switches (since the ceb0f42 perf work), so the new day's anchor row kept the previous day's cached view: stale occurrence label (e.g. "Wed 12:00am (12h)" under the Saturday tab) and a stale tap closure whose occurrence fails showDetail's visibleRows guard, silently swallowing the tap. Namespace the anchor identity by selected day (HourAnchorID{day, hour}) in both the row .id and the hour-index scrollTo, so anchor identity can never collide across days. Scrub-strip scrolling is unchanged and no extra remounts occur. Verified in sim: WED/THU/FRI/SAT first-row labels track the selected day; detail view pushes on first- and second-row taps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
The pre-populated seed zip mechanism survived in code (copyDatabaseFromBundle) but its bundling was lost in the Oct 2025 pbxproj cleanup, so 2026 first launches ran the full ~3min JSON import. The iBurn/ synced folder group now auto-bundles iBurn/iBurn-2026.zip (local gitignored artifact, regenerated per data refresh — procedure in Docs). Verified: fresh install reads 6456 Yap rows 12s after launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
The July 18 data refresh reintroduced junk url values (multi-URL comma strings, prose) that strict URL decoding throws on. Because PlayaDB.importFromData runs in a single transaction, one bad record rolled back the entire import: fresh installs got a completely empty PlayaDB and existing installs silently kept stale data. Camp/Art/Event/MutantVehicle url and donationLink now decode via LenientURL.parse: clean single-token web URLs pass through, dirty values salvage the first token with scheme+host (www.* gets http://), otherwise nil - never throws. Org-generated thumbnail URLs stay strict. Encoding unchanged. PlayaAPI: 67 tests green (12 new). PlayaDB: 206 green, including the previously-failing real-bundle acceptance test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
PlayaDBSeeder.restoreBundledSeedIfNeeded runs synchronously in DependencyContainer.init before PlayaDB.create(): fresh installs unzip the bundled year-stamped seed into Documents instead of JSON-importing; existing installs and seedless builds are untouched (JSON path remains the fallback, gated by needsImport timestamps). Seeder logging upgraded from print to CocoaLumberjack so import failures are visible. The artifact iBurn/PlayaDB-2026.zip is a gitignored local build input regenerated each data refresh (procedure in Docs). Verified end-to-end: fresh install restores a byte-identical (md5) PlayaDB.sqlite with 321/1201/2208/4697 rows, alongside the Yap seed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
Camps list amenities as day-long 'events' (mailbox open midnight-noon daily, etc). New Max Duration slider in the event filter sheet (1h-12h steps + Any) hides occurrences longer than the cap; default 6h, inclusive so exactly-6h events stay visible. EventFilter.maxDuration stays nil by default in PlayaDB so watch/ Nearby/Right Now are unaffected; the 6h default and persistence live in EventListViewModel. Stored under a separate UserDefaults key because synthesized Codable omits nil optionals, keeping 'never chosen' (-> 6h) distinct from an explicit Any. SQL predicate uses julianday() with an inclusive boundary and float-rounding tolerance. PlayaDB suite 207 green (new request-builder test), iBurnTests 133 green (6 new default/persistence tests). Sim-verified default hiding, 6h boundary, persistence, and the Any path (via injected container pref; HID tooling can't drag SwiftUI sliders - automation procedure documented in flows.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
The b211090 fix namespaced the hour-scrub anchor by day (HourAnchorID{day, hour}), but that identity is still positional: any same-day data change that swaps which row is first in an hour section (Max Duration slider Any<->6h, event-type toggles) re-emits the buckets while day+hour stay constant, so the new first row inherits the old one's anchor id and the persistent LazyVStack resurrects the cached row - stale label (a 12h event under the 6h cap) + stale tap closure. Use content-bound identity instead: first-in-section rows anchor on their own occurrence uid, and the hour index resolves hour -> first-row uid from browseSections at scrollTo time. Collisions are now impossible for any data change; HourAnchorID/anchorDay deleted. Sim-verified on iOS 26.5: type-toggle filter change updates the first row live in both directions (the exact step that went stale before), first-row tap pushes the correct occurrence detail, hour scrub still lands correctly (short jumps exact; far-jump blank-until-touch is a pre-existing LazyVStack estimation artifact, id-scheme independent). iBurnTests 133 green. Scrub automation notes added to flows.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
- Slider track now responds to taps (native Slider only accepts thumb drags): zero-distance simultaneousGesture maps tap x -> nearest of the 13 discrete positions, with min/max labels moved outside the GeometryReader-measured track. Mapping is unit-tested (EventFilterSheetSliderTests, 7 tests). - Reset button appears in the sheet toolbar whenever any exposed control differs from defaults (hide expired / all favorites / all types / 6h cap) and restores them field-by-field. - Toolbar filter icon now fills iff that same differs-from-default predicate holds. Previously `!includeExpired` kept it filled at defaults and duration was ignored. Note: the prior "still broken" report was the pre-fix binary - the main checkout's Xcode build overwrote the worktree-built app; symbol check of the installed iBurn.debug.dylib confirmed old HourAnchorID code. Docs Part G records the forensics. Sim-verified (binary symbol-checked first): Reset appears/works/ disappears, icon tracks default state, list live-updates behind the sheet. iBurnTests 140 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
…sions Root-cause hardening of the recurring stale-first-cell bug. Rows now carry ONLY their ForEach identity (occurrence uid); the hour scrub's scrollTo targets that identity directly, so the conditional IDView wrapper - the collision surface behind every prior incarnation (.id(hour), .id(day+hour), .id(uid)) - no longer exists. Also fixes a real race: rapid filter changes (each slider tick) cancel-and-restart the DB observation in bursts, and a superseded observation's in-flight emission could land after the newest one, overwriting dayBuckets with rows the current filter excludes (stale rows whose taps fail showDetail's visibleRows guard = dead tap). An observation generation token now drops emissions from superseded observations on both browse and search paths. Filter sheet slider reverted to bare stock per user direction (no GeometryReader/tap gesture; tap-to-set removed along with its tests). Reset button and differs-from-default filter icon remain. Note: the third "still broken" report was again the pre-fix 14:06 binary from the unmerged main checkout (symbol-checked). Verified on the fixed binary with the user's exact persisted state (1h cap): SUN 30 6pm shows only <=1h rows, first-cell tap pushes the correct detail, 12 rapid batched filter toggles leave no stale rows, hour scrub lands via ForEach-identity scrollTo. iBurnTests 133 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW5UdkHgYvLkWHUbRxVArt
Picks up the regenerated data/2026 map.mbtiles (9 layers / 952 features) built from bmorg/innovate-GIS-data e9e33e0: 4 realigned gate-area CPNs, plus the newly published gate_road.geojson rendered as a new line layer in both map styles. No app-target changes needed — the CPN names are unchanged, and the stale tile-cache refresh in Bundle+iBurn.swift already ensures existing installs replace their cached copy when the bundled mbtiles differs. Also correct the drive-app Appirater note: the rating prompt isn't in the AX tree and Appirater's setTimeBeforeReminding:2 means terminate+relaunch can bring it right back, so document suppressing it via simctl defaults instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
Reverse geocoding near Center Camp returned "<time> & undefined" — the 2026 layout's unnamed frontage arc was a street candidate. BMorg's 2026 GIS data shows that arc is Rod's Road; it is now named in the layout and the rebuilt bundle.js returns "1:30 & Rod's Road" style addresses. Legacy geocoder validated against all 512 street-centerline intersections derived from bmorg/innovate-GIS-data 2026: clock times all within 3 minutes, street names exact outside a ~25' boundary epsilon. New 2026 test fixture + 39 assertions in BlackRockCityPlanner. Docs: audit of the current geocoder architecture (shared JS bundle on iOS JSContext / Android J2V8), 2026 verification results, and the feasibility assessment + recommended architecture for an org-GeoJSON- driven native reverse geocoder (Phase 2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X77QJHvfT8eUZevdYLEG9c
…ons) BMorg's official sources name C "Ceiba" — the layout (and the shipped map tile labels) said "Chomolungma". Rod's Road was removed for 2026, so the Center Camp frontage arc is unnamed again and reverse geocoding falls back to the nearest real named street there. Regenerated streets.geojson, geocoder bundle.js, and map.mbtiles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X77QJHvfT8eUZevdYLEG9c
Pins ("where's my bike") are the top watch ask and were the last MVP
follow-up with no watch surface at all. PlayaDB already owned user_map_pins
and the phone already rendered them from an observation, so the work was
tombstones, a merge, and watch UI.
PlayaDB
- v4-pin-sync migration adds is_deleted; deleteUserMapPin is now a soft
delete. Sync exchanges whole snapshots with LWW on modified_date, and
without a tombstone a row missing from a peer's snapshot is ambiguous
("never created there" vs "deleted there") — deleted pins would resurrect
on the next push.
- userMapPinSyncSnapshot / applyUserMapPinSync / observeUserMapPinSyncState,
mirroring the favorites trio. Ties lose, no-op merges never write (the
invariant that stops the two devices ping-ponging pushes), and tombstones
for pins this device never had are skipped so the table only grows with
pins we actually saw.
- fetchUserMapPins / observeUserMapPins filter tombstones, so every existing
caller is unaffected.
- UserMapPinType gives both apps one definition of the pin_type strings the
iOS BRCMapPointType mapping already writes.
Sync transport
- FavoritesSyncManager -> PeerSyncManager: it no longer only does favorites.
Pins ride in the SAME manager and the same push because
updateApplicationContext replaces the dictionary wholesale — two
independent publishers would silently clobber each other.
- Both call sites now label onFavoritesApplied: explicitly. Adding a second
optional closure parameter had re-bound the unlabeled trailing closures to
onPinsApplied, which would have killed favorites-applied notifications.
Watch UI
- Drop-pin sheet (Bike/Home/Star at the current GPS fix, explanatory state
when there's no fix), Pins list under Browse, detail with
navigate/rename/delete, pins drawn on the map as tinted SF Symbols.
- NavigationScreen generalized from `any DataObject` to name + coordinate so
pins reuse the compass view.
- MapMarker gains an optional symbolName, drawn via Text(Image(systemName:))
— GraphicsContext.draw(Image) ignores foreground style, Text carries its own.
Map controls
- The tracking control was a hand-rolled black circle at bottomTrailing: a
~30pt hit target pushed into the display's corner curvature, matching
nothing else in the app. It's now a real .bottomBar toolbar item
(watchOS 10+) beside the new drop-pin button, so both get system styling,
system hit targets, and correct corner insets like the top-bar buttons.
- Tracking now cycles MapKit's order: free -> follow -> heading -> free.
Previously heading fell back to follow, leaving free-look unreachable
except by panning.
Verified on paired Ultra 3 / iPhone 17 sims: pin created on either device
appears on the other (phone geocodes it onto the map), delete propagates and
stays gone, orphan tombstone correctly not inserted. 222 PlayaDB tests green
including 15 new merge tests; both apps build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123WFHX47m44Myw4AV2MRfX
Two additive migrations plus the API surface the app-side Audio Tour and calendar-sync work needs. v4-audio-tour: art_objects.audio_tour_url, decoded from the BMorg API field via LenientURL (absent/null/garbage -> nil, never throws, so a bad record can't roll back the single-transaction import). ArtFilter.hasAudioTour filters at SQL and flows through both fetch and observe. v5-calendar-entries: event_calendar_entries(event_id, occurrence_key, ek_event_identifier). occurrence_key is the occurrence start rendered as fixed ISO-8601 UTC rather than the occurrence rowid, because importFromData deletes and reinserts all occurrences -- rowids never survive a refresh, start instants do. importFromData never touches the table, so entries survive re-import (regression test included). Tests: AudioTourTests (9), EventCalendarEntryTests (10), SchemaMigrationTests extended, UserMapPinTests (10, first coverage for user_map_pins). PlayaDB 238 passed, PlayaAPI 71 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
Six defects, all reachable in the default shipping config:
- Legacy list cell hearts synced PlayaDB with the raw Yap uid; for events
("<apiUID>-<index>") the PlayaDB write silently no-oped. Normalizes via
the same suffix-stripping every other bridge uses.
- DetailViewModel.syncNotesToYapDB wrote the bare PlayaDB uid into
BRCEventObject's collection, matching no per-occurrence Yap key. Notes now
go through a new FavoriteSyncService.mirrorNotes with the occurrence
fan-out (no calendar involvement, equal-value writes skipped).
- Embargo unlock never refreshed PlayaDB observations: the annotation data
source captured embargoAllowed once and the six list hosting controllers
snapshotted it, so unlocked locations only appeared after a relaunch. Adds
BRCEmbargoDidClear, posted from both the passcode and region-unlock paths.
The region path was also effectively dead -- allowEmbargoedData flips the
flag itself and returns YES, so the branch never ran; now snapshots first.
- ColorCache.prefetchAllColors ran every launch duplicating PlayaDB's
ColorPrefetcher; now gated to the legacy stack.
- LocationStorage.setup() never called start(), so breadcrumbs only recorded
after visiting More > Location History once per launch; TracksViewController
also opened a second DatabaseQueue + CLLocationManager on the same file
instead of reusing .shared.
- The PlayaDB->Yap visit-status mirror didn't bump the grouped view's
versionTag, leaving the legacy Visit List stale under the kill-switch.
Also corrects the roadmap doc: user map pins have been PlayaDB since 99587a3
and breadcrumbs were never Yap in the shipping path.
Tests: iBurnTests/YapPlayaDBBridgeTests.swift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
The map's list button opened a Yap-backed screen that collected only DataObjectAnnotation, but the default map has emitted PlayaObjectAnnotation and BRCUserMapPoint since the annotation migration -- so the list was always empty in the shipping config. New SwiftUI VisiblePins screen sections the map's current annotations into Art / Camps / Events / Map Pins, filtered to the visible bounds (without that filter a zoomed-out map lists ~1500 rows). PlayaObjectAnnotation now carries the object it was built from, so event rows show the exact occurrence that placed the pin rather than re-fetching by uid and guessing. The list button picks its screen by content, not by call site: any DataObjectAnnotation in view -> legacy MapPinListViewController (kept intact for the kill-switch list screens' maps), otherwise the new one. Also fixes user-pin de-dup, which keyed on yapKey -- a fresh random UUID on every rebuild from the database, so it could never match. Keys on the stable pinId now. Drops three dead YapDatabase imports and the two unused Yap connections MainMapViewController still held. Tests: iBurnTests/VisiblePinsViewModelTests.swift (10). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
The last two always-on Yap screens. Both branch on useSwiftUILists with the legacy controllers retained as the kill-switch, matching the other five tabs. Visit List: one-shot fetchObjects(visitStatus:) for wantToVisit + visited (there is no reactive visit-status observation, same as Recently Viewed), refreshed on viewWillAppear and didBecomeActive. Keeps legacy semantics -- segmented All / Want to Visit / Visited, the two emoji sections, no unvisited section, and no visit-status mutation from the list itself (the detail screen owns that write and its Yap mirror). Audio Tour: membership is the union the legacy screen implied via audioURL = localAudioURL ?? remoteAudioURL -- art with audio_tour_url in PlayaDB (filtered at SQL) plus art with a local MediaFiles/<uid>.m4a. Local uids come from one directory listing behind a protocol so tests can stub it; with no local recordings the observation uses the SQL filter, otherwise it observes unfiltered and unions in memory. Track URLs prefer the local file. Play All / intro / SoundCloud go through the DB-agnostic BRCAudioTourTrack API. The 2026 payload ships no audio yet, so the screen shows its empty state until a data drop lands. Tests: VisitListViewModelTests (13), AudioTourViewModelTests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
EKEvent identifiers for favorited events were bookkept only in Yap object metadata, so the last default-path calendar write still went through the legacy stack. New EventCalendarService (an actor, coalescing the repeated reconciles the per-occurrence favorite hook produces) creates one EKEvent per occurrence with the legacy alarm offsets and stores identifiers in PlayaDB's event_calendar_entries. EventKit sits behind a protocol so tests inject a spy instead of touching a real store. Single owner per flag state: EventCalendarHookRouter wraps the existing FavoriteSyncCalendarRefreshHook rather than widening the FavoriteSyncService protocol, so with usePlayaDBCalendarSync on (default) the uid is normalized and handed to the service, and with it off the original Yap path runs untouched. DetailDataService skips its in-transaction refreshCalendarEntry when the service owns the entry and reconciles post-commit instead. The hook and the detail path share one instance via DependencyContainer. Watch-sync favorites inherit the new path since they already funnel through FavoriteSyncService. Upgrading users are handled: reconciling an event with no PlayaDB entries reads the legacy identifiers out of Yap, removes those EKEvents and clears the identifiers, so no orphaned calendar entries are left behind. Verified in the simulator: favoriting wrote a real EventKit identifier keyed by the occurrence start in UTC, unfavoriting removed both the event and the row. Tests: iBurnTests/EventCalendarServiceTests.swift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
Full audit of what still ran on YapDatabase in the default shipping config, the scope decisions, per-workstream outcomes, and the integration/simulator verification. Also records what stays on Yap deliberately for 2026 (boot import, the favorites/notes/visit mirrors, the flagged legacy screens) and that the OTA->PlayaDB updater remains deferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
The documented destination pinned OS=26.2, which no longer resolves on this machine -- xcodebuild fails device resolution before building. Only 26.5 runtimes are installed now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
The apps' geocoder bundle is rebuilt from the new org-GeoJSON implementation, so BMorg's published geometry is the source of truth in both directions — including the camp placements the API pipeline geocodes from playa addresses. Also corrects two 2026 street facts: C is Ceiba (not Chomolungma, which had reached the shipped map tile labels) and Rod's Road no longer exists. Validated against org data itself: all 512 official radial x ring centerline intersections reverse to exactly that address (legacy got 44 wrong), and all 1369 published 2025 camp addresses forward-resolve with a 7' median offset (legacy failed 30). Verified through real JavaScriptCore and Android's J2V8 call pattern; PlayaGeocoder (iOS) framework builds clean and embeds the new 868KB bundle. Docs/2026-07-25-reverse-geocoder-2026-audit.md carries the full audit, the org-vs-legacy parity numbers, and the remaining work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X77QJHvfT8eUZevdYLEG9c
Merge instructions for another agent: the three commit stacks and their push order (submodules before parent), the remote discrepancy to confirm before pushing, the clean-rebase analysis against the advanced 2026-updates, verification evidence, and the two loose ends (uncommitted Android bundle, unbuilt app target in a Pods-less worktree). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X77QJHvfT8eUZevdYLEG9c
Records what another agent needs to land ce4e7fc on 2026-updates: the exact three-file conflict set (verified with a read-only merge-tree, not guessed), the v4-pin-sync -> v6-pin-sync rename forced by that branch already owning v4/v5, and the launch-crash trap the rename creates on any sim that already ran this branch (GRDB matches migrations by identifier, so the renamed one re-runs ALTER TABLE and dies on "duplicate column name"). Also captures the environment traps that cost time this session (stale Pods -> netinet6 build failure, the CLAUDE.md destination move to OS=26.5, and filling the disk via -derivedDataPath under the scratchpad) and the three sync invariants that look tidy-able but are load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123WFHX47m44Myw4AV2MRfX
Geocoder now reads BMorg's official GeoJSON in both directions (src/orggeocoder), replacing the synthesize-the-city-from-layout.json approach, plus 2026 layout corrections: the C street is Ceiba (not Chomolungma) and Rod's Road was removed by BMorg for 2026. Also fixes a '<time> & undefined' reverse-geocode near Center Camp by dropping unnamed streets from the candidate set. Brings Submodules/iBurn-Data to 84a16bb, which the main checkout was already sitting on. See Docs/2026-07-25-reverse-geocoder-2026-audit.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
User map pins on the watch, synced with the phone, plus a map control rework. Pin deletions propagate via tombstones (soft delete + LWW on modified_date), and FavoritesSyncManager becomes PeerSyncManager because updateApplicationContext replaces the whole dictionary -- favorites and pins have to ride the same push or they clobber each other. Conflict resolution (3 files, all additive, all in PlayaDB): - PlayaDBImpl/PlayaDB: kept both API blocks; calendar entries and pin sync don't overlap. - The migration collision is the one that mattered. Both branches independently claimed v4: this branch's v4-pin-sync vs 2026-updates' v4-audio-tour and v5-calendar-entries. GRDB matches migrations by identifier string, so pin sync is renamed to v6-pin-sync and registered last. Renaming rather than making the ALTER idempotent is deliberate -- it keeps a clean install as the honest test. - SchemaMigrationTests: took the allMigrations refactor and appended v6-pin-sync. Note for anyone re-testing on a sim that already ran this branch: delete the app first. A database that recorded the old v4-pin-sync sees v6-pin-sync as unapplied, re-runs the ALTER, and fails with "duplicate column name: is_deleted" -- which throws out of PlayaDBImpl init and crashes the watch app at launch. Never shipped, so no real users are affected. Verified after merge: PlayaDB 253 tests, iBurnTests 202 tests, iBurn and iBurnWatch both build clean (0 errors, 0 warnings). See Docs/2026-07-25-watch-pins-and-map-controls.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
CLAUDE.md loads into every session, and ~70% of it was content a session can rediscover on its own. Cut the directory tour, framework list, class-by-class architecture overview, standard xcodebuild/simctl/fastlane invocations, and the 92-line CI/CD walkthrough of files that live in .github/workflows/. The Submodule Dependencies section was also stale — it referenced data/2024 and the retired src/geocoder path, while Submodules/iBurn-Data/CLAUDE.md and scripts/BlackRockCityPlanner/CLAUDE.md already document the current 2026 pipeline and load automatically when working in those directories. Both it and CI/CD are now pointers. Moved physical-device deployment into the drive-app skill as references/device-deploy.md so it loads only when deploying to hardware. Kept everything not derivable from the code: the Docs/ workflow, source control rules, the xcsift 2>&1 rule and default destination, the .xcworkspace gotcha, DI/factory guidance, gitignored setup files, and the embargo note. 16,796 -> 6,366 chars (~2.6k fewer tokens of context per session). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
Two regressions from the 2026 project restructuring: Archive builds failed with "Cycle inside iBurn" because the Crashlytics run-script phase ran before Embed Watch Content. Xcode gates each phase on the previous one, so the watch-app copy waited on Crashlytics, which waits on the dSYM, which comes from the installed binary whose Info.plist write is ordered after the watch embed. Swapping the two phases breaks the loop and puts symbol upload last, where it belongs. The onboarding background video stopped playing because Media/onboarding_loop_final.mp4 sat outside the synchronized iBurn/ folder group and lost target membership in 99b8cbd. Moved it into iBurn/ (same treatment the database zips already got) and made the lookup fail loudly instead of handing AVPlayer an empty file URL. Verified with a simulator build and a full xcodebuild archive; the archived app contains the video, the embedded watch app, and all dSYMs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
iBurn/PlayaDB-<year>.zip used to be made by hand — run the app in the simulator,
wait for the import, pull PlayaDB.sqlite out of the container, zip it. The zip in
the tree also had thumbnail_colors empty, so every new user still paid for colour
extraction across ~1570 thumbnails on first launch.
Packages/PlayaSeed replaces that with one command that imports the year's API
JSON, bakes thumbnail colours, compacts the database and zips it in ~2.3s:
swift run --package-path Packages/PlayaSeed playa-seed --fetch-media
--fetch-media also downloads thumbnails the API references but the media bundle
lacks. iBurn-Data's art_image_download.js can't: it's art-only and uses Node's
http module, so it fails on the org's https image host.
Baking colours offline needed an extractor SwiftPM can reach, so UIImageColors
(a CocoaPod, UIKit-only) was ported to CoreGraphics as Packages/PlayaColors and
the app moved onto it too — one algorithm, so a baked colour is identical to one
the device would compute. The port is bug-compatible with the pod's scoring
(98.7% exact agreement on backgrounds when both are fed identical pixels) but
fixes three things: the resize no longer varies with device scale, ordering is a
total order instead of NSCountedSet hash order, and ties among the pod's
zero-count candidates break on real image prominence rather than arbitrarily.
thumbnail_colors starts empty for everyone this season, so no user sees a colour
change relative to a cached value.
PlayaDB gains createPlayaDB(atPath:) and compactForDistribution() to support
building a database outside an app container.
Verified on an erased simulator through full onboarding: the restored database
has 321/1201/2208/4697/496 rows, all six migrations, no user metadata, and 1573
thumbnail_colors — and art rows render themed immediately with no colour pop-in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
The watch was still parsing 3.4 MB of bundled JSON on first launch — slow on watch hardware, and exactly what the seed exists to avoid. It ships its own copy at iBurnWatch/PlayaDB-<year>.zip (same playa-seed run writes both; --output is now repeatable) and restores it at the top of iBurnWatchApp.init(), before createPlayaDB() — once the database file exists the restore is a no-op. The restore rules moved out of the iOS app target into PlayaDBSeedRestore so both apps share one implementation rather than a duplicated copy of the failure handling. PlayaDB doesn't take a compression dependency for it: the unzip step is injected, and the returned Outcome lets each app log through its own facility. The JSON stays bundled on both targets. The seed carries update_info baked from update.json, so needsImport still re-imports when a build ships data newer than the baked database — dropping the JSON would have saved 3.4 MB and broken that. The zip is picked up by the watch's synchronized folder group rather than an explicit file reference to the iOS copy: seeds are gitignored, and an explicit reference would fail the build on a clone that hasn't run playa-seed instead of falling back to the JSON import. Verified on an erased Apple Watch Ultra 3 sim: 321/1201/2208/4697/496 rows, 1573 thumbnail_colors, no user metadata, and update_info.created_at still reading the bake timestamp after two launches — so the restore was used and the import never ran. 262 PlayaDB tests (9 new), 20 seed-tool tests, 202 app tests pass; both the iOS and watchOS targets build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 2026 season branch: year rollover, the Yap→PlayaDB migration reaching the default path, a standalone watch app with phone sync, and the geocoder moving onto BMorg's official GeoJSON.
77 commits, 251 files. Opening as a draft for review — this is the integration branch the season ships from, not a single feature.
What's in it
2026 rollover. New city geometry (Man at 40.783242,-119.207871, Ararat…Kundalini, 8287' fence), 2026 API data, regenerated tiles from BMorg's official GIS drop,
iBurn2026*package renames,MARKETING_VERSION2026.0.Yap→PlayaDB is now the default everywhere. The five list tabs, detail, map annotations, global search and AI already ran on PlayaDB; this branch finishes the job — Visit List, Audio Tour and the map's visible-pins list are SwiftUI/PlayaDB behind
useSwiftUILists, and EKEvent calendar identifiers moved out of Yap metadata intoevent_calendar_entriesbehindusePlayaDBCalendarSync. Both flags default on, legacy retained as a kill-switch.What deliberately stays on Yap for 2026: the boot-time import (it feeds the kill-switch stack) and the favorites/notes/visit-status mirrors. The OTA→PlayaDB updater is deferred — PlayaDB stays bundle-seeded, so August data drops reach the shipping UI via app updates.
watchOS. Standalone app (map, browse, favorites, detail, compass nav) with phone↔watch sync of favorites, visit status and user map pins. Pin deletions propagate via tombstones — a snapshot exchange can't distinguish "never created here" from "deleted there" without them.
Geocoder on official data. BMorg's GeoJSON is now the source of truth in both directions. Against 512 official radial×ring intersections: 489 exact, 23 correctly naming the plaza on top, 0 wrong (the legacy geocoder got 44 wrong). Also fixes two street facts that had reached shipped artifacts — the C street is Ceiba, not Chomolungma (211× in the tile labels now), and Rod's Road was removed by BMorg for 2026.
Bug fixes worth calling out, all reachable in the default config before this branch: legacy list hearts silently dropped the PlayaDB write for events (wrong uid form); notes never mirrored to any Yap key; entering the embargo passcode didn't refresh PlayaDB observations, so unlocked locations only appeared after a relaunch; breadcrumbs only recorded after visiting Location History once per launch.
Verification
iBurnTestsSimulator pass on an existing install (the upgrade path): all six migrations apply in place; Visit List, Audio Tour and Visible Pins render correctly; favoriting an event writes a real EventKit identifier and unfavoriting removes both the event and the row.
Reviewer notes
Migration numbering looks odd on purpose.
watchos-updatesand the PlayaDB work both claimedv4in parallel. GRDB matches migrations by identifier string, so pin sync was renamed tov6-pin-syncand registered last rather than making itsALTERidempotent — a clean install stays the honest test. Anyone re-testing on a simulator that ran the pre-mergewatchos-updatesbuild must delete the app first, or the recordedv4-pin-syncmakesv6-pin-synclook unapplied, theALTERre-runs, and the duplicate-column error throws out ofPlayaDBImplinit and crashes the watch app at launch. Never shipped, so no real users are affected.Submodules are pushed:
BlackRockCityPlanner@8f8a932andiBurn-Data@84a16bb(private remote).Known follow-ups
iBurn-Android'sassets/js/bundle.jsis updated but uncommitted — it had still been the 2025 build, so that's a real fix awaiting a commit in that repopoi.jsonstill places Greeters/Airport by time+distance, landing 263'/700' from their official CPNsSession docs:
Docs/2026-07-25-playadb-default-yap-audit-and-migration.md,Docs/2026-07-25-watch-pins-and-map-controls.md,Docs/2026-07-25-reverse-geocoder-2026-audit.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw