Skip to content

2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder - #251

Draft
chrisballinger wants to merge 60 commits into
masterfrom
2026-updates
Draft

2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder#251
chrisballinger wants to merge 60 commits into
masterfrom
2026-updates

Conversation

@chrisballinger

Copy link
Copy Markdown
Member

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_VERSION 2026.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 into event_calendar_entries behind usePlayaDBCalendarSync. 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

iBurn (iOS) builds clean, 0 errors / 0 warnings
iBurnWatch builds clean, 0 errors / 0 warnings
iBurnTests 202 passing
PlayaDB package 253 passing
PlayaAPI package 71 passing

Simulator 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-updates and the PlayaDB work both claimed v4 in parallel. GRDB matches migrations by identifier string, so pin sync was renamed to v6-pin-sync and registered last rather than making its ALTER idempotent — a clean install stays the honest test. Anyone re-testing on a simulator that ran the pre-merge watchos-updates build must delete the app first, or the recorded v4-pin-sync makes v6-pin-sync look unapplied, the ALTER re-runs, and the duplicate-column error throws out of PlayaDBImpl init and crashes the watch app at launch. Never shipped, so no real users are affected.

Submodules are pushed: BlackRockCityPlanner@8f8a932 and iBurn-Data@84a16bb (private remote).

Known follow-ups

  • OTA→PlayaDB updater (deferred by decision, not oversight)
  • iBurn-Android's assets/js/bundle.js is updated but uncommitted — it had still been the 2025 build, so that's a real fix awaiting a commit in that repo
  • poi.json still places Greeters/Airport by time+distance, landing 263'/700' from their official CPNs
  • Native Swift/Kotlin ports of the reverse geocoder (the 512-intersection sweep is the shared conformance vector)

Session 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

chrisballinger and others added 30 commits July 3, 2026 13:35
- 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
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>
chrisballinger and others added 30 commits July 18, 2026 09:10
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
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