diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..c69a4f58 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "worktree": { + "bgIsolation": "none" + } +} \ No newline at end of file diff --git a/.claude/skills/drive-app/SKILL.md b/.claude/skills/drive-app/SKILL.md new file mode 100644 index 00000000..60a973af --- /dev/null +++ b/.claude/skills/drive-app/SKILL.md @@ -0,0 +1,113 @@ +--- +name: drive-app +description: Build, launch, and drive the iBurn app in the iOS Simulator via XcodeBuildMCP UI automation — for verifying flows end-to-end, taking screenshots, and sanity-passing changes. Use when asked to run the app, exercise/verify a user flow, reproduce a UI bug, or validate database behavior on-device. Flow-by-flow steps live in references/flows.md. +--- + +# Driving the iBurn app in the Simulator + +## Prerequisites + +1. **XcodeBuildMCP** must be connected with the `simulator` and `ui-automation` + workflows. `.xcodebuildmcp/config.yaml` in this repo already enables + `["simulator", "device", "ui-automation"]`. If `tap` / `type_text` / `swipe` + tools are missing from ToolSearch, the server predates the config — ask the + user to run `/mcp` → reconnect XcodeBuildMCP. +2. Call `session_show_defaults` first. If workspace/scheme/simulator/bundleId are + not set, set them: + - workspacePath: `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn.xcworkspace` + - scheme: `iBurn` + - simulator: iPhone 17 Pro Max (look up the UDID with `list_sims`) + - bundleId: `com.trailbehind.iBurn2010` + +## Critical setup facts (learned the hard way) + +- **The SwiftUI/PlayaDB stack is ON by default.** The flag + `featureFlag.lists.useSwiftUI` (all builds, default true) acts as a + kill-switch: set it to NO and you get legacy UIKit/YapDatabase screens and + `PlayaDB.sqlite` is never created. To exercise the legacy stack, set before + (re)launching: + ```bash + xcrun simctl spawn defaults write com.trailbehind.iBurn2010 featureFlag.lists.useSwiftUI -bool NO + ``` +- **PlayaDB seeds lazily**, when the DependencyContainer is first built (tab + construction after onboarding) — not at app launch. Don't conclude seeding is + broken because the DB file doesn't exist yet; navigate into the main UI first. +- **`print()` output is not captured** in build_run_sim runtime logs (NSLog only). + Verify database state by querying the on-sim SQLite file directly (below) + instead of hunting for log lines. +- For **first-launch flows**, erase the simulator first: + `xcrun simctl shutdown ; xcrun simctl erase `. +- For **Nearby/location flows**, set a Black Rock City location: + `xcrun simctl location set 40.7864,-119.2065`. + +## Interaction workflow + +Observe with `snapshot_ui`, act with `tap`/`swipe`/`type_text` on elementRefs +from the latest snapshot, re-snapshot after navigation. Gotchas specific to this +app: + +- **Onboarding carousel pages advance by swiping LEFT** on the page scroll-view. + The action button only works on pages that request permissions; on info-only + pages (Search, Nearby) tapping it does nothing — swipe instead. +- **Permission alerts arrive in this order** on a fresh install: notifications + (springboard alert), then in onboarding: location → notifications + (PermissionScope buttons) → calendar full-access. See flows.md for exact steps. +- After onboarding, an **embargo alert** ("Locations Are Hidden") appears over the + map — dismiss via "Ok cool whatever". +- **Favorite hearts are not in the accessibility tree.** To verify heart state in + a list, take a `screenshot` and inspect the image; don't grep the AX snapshot. +- **SwiftUI searchable fields flicker in and out of the AX tree** (the + "Search events" field may not be listed after scrolling). Prefer validating + search at the database layer (FTS MATCH query below) unless the search UI + itself is under test. + +## Verifying database state directly + +```bash +APP_DATA=$(xcrun simctl get_app_container com.trailbehind.iBurn2010 data) +sqlite3 "file:$APP_DATA/Documents/PlayaDB.sqlite?mode=ro" " + PRAGMA journal_mode; -- expect: wal + SELECT COUNT(*) FROM art_objects; -- ~321 (2026 data) + SELECT COUNT(*) FROM camp_objects; -- ~1201 + SELECT COUNT(*) FROM event_objects; -- ~2101 + SELECT COUNT(*) FROM event_occurrences; -- ~4431 + SELECT identifier FROM grdb_migrations; -- v1-initial-schema + SELECT object_type, object_id, is_favorite FROM object_metadata;" +``` + +Invariants worth asserting after UI actions: +- Favoriting an **event** writes exactly one `object_metadata` row keyed by the + **parent event uid** (a real uid from `event_objects`, never a synthesized + `"_"` form). +- Plain browsing/fetching must **not** create `object_metadata` rows (reads are + write-free). +- FTS health: `INSERT INTO event_objects_fts(event_objects_fts) + VALUES('integrity-check')` must not error; `MATCH` is stemmed and + case-insensitive. + +These `sqlite3`/`simctl` commands need the Bash sandbox disabled (simulator +container paths are outside the sandbox allowlist). + +## Flow catalog + +Step-by-step scripts for the critical flows (onboarding, events browsing, +favoriting, search, map/embargo, detail, feature flags) live in +[references/flows.md](references/flows.md). Read it before driving a flow. + +## Physical devices + +Running on real hardware (enabling the `device` workflow, discovery, code +signing) is covered in +[references/device-deploy.md](references/device-deploy.md). + +## Keeping the flow docs current + +These docs are maintained by whoever notices drift, in the session where they +notice it: + +- If a flow in `references/flows.md` doesn't match what the app actually does + (renamed screens, reordered onboarding, moved buttons, new permission prompts), + **update the doc in the same session** and commit it with your other changes. +- If you add or materially change a user-facing flow, add/update its entry in + `references/flows.md` as part of that change. +- Record data-shape drift too (seeded row counts change every festival year). diff --git a/.claude/skills/drive-app/references/device-deploy.md b/.claude/skills/drive-app/references/device-deploy.md new file mode 100644 index 00000000..77faa31f --- /dev/null +++ b/.claude/skills/drive-app/references/device-deploy.md @@ -0,0 +1,41 @@ +# Running iBurn on a physical device + +Simulator work is the default (see [../SKILL.md](../SKILL.md)); this file covers +the extra setup needed to build, install, and launch on real hardware. + +## Enabling the device workflow + +XcodeBuildMCP only exposes device tools when the `device` workflow is enabled in +`.xcodebuildmcp/config.yaml`: + +```yaml +schemaVersion: 1 +enabledWorkflows: ["simulator", "device"] +``` + +After creating or modifying this file, restart the XcodeBuildMCP MCP server +(`/mcp` → reconnect in Claude Code). + +## Device discovery + +```bash +# List connected physical devices (USB or network) +xcrun devicectl list devices +``` + +## XcodeBuildMCP device workflow + +1. `list_devices` — List connected devices and their UDIDs +2. `session_set_defaults` — Set workspace, scheme, and `deviceId` (UDID) +3. `build_run_device` — Build, install, and launch on device (single step) +4. `launch_app_device` — Launch an already-installed app +5. `start_device_log_cap` / `stop_device_log_cap` — Capture device logs +6. `test_device` — Run tests on the physical device + +## Requirements + +- Code signing must be configured in Xcode for the target device +- Device must have Developer Mode enabled +- Device must be unlocked for app launch to succeed +- Device builds need the Bash sandbox disabled — the sandbox hides the Keychain, + so `codesign` can't find the iOS Development certificate diff --git a/.claude/skills/drive-app/references/flows.md b/.claude/skills/drive-app/references/flows.md new file mode 100644 index 00000000..03581893 --- /dev/null +++ b/.claude/skills/drive-app/references/flows.md @@ -0,0 +1,361 @@ +# iBurn critical flows — simulator driving scripts + +Companion to the `drive-app` skill. Each flow lists preconditions, steps +(as XcodeBuildMCP UI-automation actions), and what to verify. Element labels +below are the accessibility labels/identifiers observed in snapshots — match on +label text, not on elementRef numbers (refs change every snapshot). + +> **Maintenance:** if a step here doesn't match the running app, fix this file in +> the same session (see "Keeping the flow docs current" in SKILL.md). +> Last verified: 2026-07-03 against the 2026 dataset, iPhone 17 Pro Max sim. + +## 1. First-launch onboarding (fresh install) + +Preconditions: simulator erased; feature flag set if you want the SwiftUI stack +(set it BEFORE first launch so tab construction uses it). + +1. `build_run_sim` — app launches to a springboard **notifications permission + alert** → tap "Allow" (or "Don't Allow"; flows below assume Allow). +2. Onboarding page "Welcome to iBurn" → tap **"📍 Continue with Location"**. +3. PermissionScope sheet → tap **"CONTINUE WITH LOCATION"** → system location + alert → tap **"Allow While Using App"**. +4. Page "Reminders" → tap **"⏰ Continue with Notifications"** → PermissionScope + sheet → tap **"CONTINUE WITH EVENTS"** → system calendar alert → tap + **"Allow Full Access"**. +5. Pages "Search" and "Nearby" are info-only — the action button does nothing; + **swipe left** on the page scroll-view to advance. +6. Final page "Thank you!" → tap **"🔥 Ok let's burn!"**. +7. Main UI appears (Map tab) with the **embargo alert** "Locations Are Hidden" → + tap **"Ok cool whatever"**. + +Verify: tab bar shows Map / Nearby / Favorites / Events / More. + +## 2. SwiftUI + PlayaDB stack (default ON; legacy fallback) + +The flag `featureFlag.lists.useSwiftUI` (all builds, default true) gates the +Favorites/Nearby/Events/Art/Camps SwiftUI screens, More → Visit List, and PlayaDB +creation/seeding. It is ON by default; disable it to exercise the legacy +UIKit/YapDatabase stack. + +- CLI (preferred for automation): terminate app → + `xcrun simctl spawn defaults write com.trailbehind.iBurn2010 featureFlag.lists.useSwiftUI -bool NO` + → relaunch. (Use `-bool YES` or delete the key to restore the default.) +- In-app (DEBUG builds only): More tab → Feature Flags → toggle "Use SwiftUI Lists". + +Verify: after navigating to any tab post-launch, +`/Documents/PlayaDB.sqlite` exists, `PRAGMA journal_mode` = wal, +and `grdb_migrations` contains every migration through `v6-pin-sync`. Seeded +counts (2026 data, Aug 6 refresh): 330 art / 1196 camps / 2361 events / +4894 occurrences / 495 mutant vehicles / **1580 `thumbnail_colors`**; +`object_metadata` stays empty until the user favorites/views something. + +`thumbnail_colors` being populated on a *fresh* install is the signal that the +pre-baked seed restored. `iBurn/PlayaDB-.zip` is gitignored and built by +`Packages/PlayaSeed` (`swift run playa-seed --fetch-media`), so a clone that has +never run the tool has no seed: the app silently falls back to importing JSON on +device, first launch takes noticeably longer, and `thumbnail_colors` fills in +gradually via `ColorPrefetcher` instead of arriving complete. Both paths are +valid — just know which one you're looking at before calling a slow first launch +a regression. + +## 3. Events browsing + day tabs + +Preconditions: flow 2 done (SwiftUI stack on). + +1. Tap the **Events** tab. +2. Day strip shows SUN 30 → MON 7 (festival week, end-inclusive so the final + day/Exodus is browsable; scroll the strip to reach MON 7). Tap another day + (e.g. "WED, 2"). + +Verify: rows swap instantly to that day's events (day slicing is in-memory — +no spinner, no reload flash). Row content: name, type emoji, host camp, +description, "Wed 2:00pm (2h)"-style time label. + +**Max Duration filter (default 6h):** occurrences longer than 6h (all-day +"amenity listing" pseudo-events) are hidden by default. The toolbar Filter +sheet has a "Max Duration" slider (1h–12h, rightmost = "Any"; exactly-6h events +stay visible — inclusive). The HID tooling cannot drag SwiftUI sliders; for +automation, inject the preference directly (app terminated first) into the +**app container** plist — the user-level `defaults write ` domain is +NOT what the app reads: +``` +C=$(xcrun simctl get_app_container com.trailbehind.iBurn2010 data) +# "Any": {"unlimited":{}} ; N seconds: {"limited":{"_0":N}} +xcrun simctl spawn defaults write \ + "$C/Library/Preferences/com.trailbehind.iBurn2010" \ + "eventListFilter.maxDuration" -data 7b22756e6c696d69746564223a7b7d7d +``` +then relaunch; delete the key to restore the 6h default. + +**Hour scrub strip automation:** the trailing-edge hour digits are text-only AX +elements (`tap` refuses them). Use `touch {elementRef: , down: true, up: true}` +to scrub to that hour. Quirks: the "8 PM"-style scrubber bubble can stick on screen +afterwards (synthetic touches skip the DragGesture `.onEnded` reset — cosmetic only), +and a far jump (e.g. 12am → 8pm) may land on a blank viewport until the next +swipe/touch materializes rows (LazyVStack far-target estimation; short jumps land +exactly). + +## 4. Favorite an event (end-to-end) + +Preconditions: flow 3; pick any event row. + +1. Tap an event row → detail screen (title, HOSTED BY CAMP, NEXT EVENT, + host's other events). +2. Tap the **"Add Favorite"** heart button (top bar; becomes "Remove Favorite"). +3. Tap the back button ("Events") to return to the list. +4. Take a **screenshot** — the favorited row's heart is filled/red; others are + outlined. (Hearts are not in the AX tree.) +5. Tap the **Favorites** tab — the event appears with all its occurrences, + under All/Events filter tabs. + +Verify in DB: exactly one new `object_metadata` row, `object_type='event'`, +`object_id` equal to the parent event uid in `event_objects` (never +`"_"`), `is_favorite=1`. + +Also verify the Yap mirror (`FavoriteSyncService`): in +`/Library/Application Support/iBurn/iBurn-2026/iBurn-2026.sqlite`, +every per-occurrence row (`database2` table, collection `BRCEventObject`, keys +`"-"`) gets an updated metadata blob containing `isFavorite=true` and +(with calendar permission granted) an EKEvent `calendarEventIdentifier`. The +favorited blobs are larger than the ~440-byte import-stamped baseline. + +## 5. Search (FTS) + +The events/favorites lists have searchable fields ("Search events", +"Search favorites"), but SwiftUI searchable fields drop out of the AX snapshot +unpredictably. Two options: + +- **UI path (when the field is visible):** `type_text` into the field; results + filter live. Porter stemming applies ("taco" matches "Tacos", "taCO"). +- **DB path (always works, use for FTS correctness):** + ```sql + SELECT COUNT(*) FROM event_objects_fts WHERE event_objects_fts MATCH 'taco'; + INSERT INTO event_objects_fts(event_objects_fts) VALUES('integrity-check'); + ``` + 2026 data: 'taco' → 12 event matches; 'oasis' → 72 camp matches. + +## 6. Map + embargo + +- Map tab renders the MapLibre offline map immediately after onboarding. +- Locations are hidden until the embargo lifts (the "Locations Are Hidden" + alert on first run explains this). The embargo is **two-tier** per the BMorg + API ToS: camps (and camp-hosted events) unlock at 12:01 am the Sunday before + gates (`YearSettings.campLocationUnlock`), art (and art-located events) at + gates-open (`eventStart`). Location-dependent pins won't appear in pre-event + builds — this is expected, not a bug. +- The camp boundary/label style layers (`camp-boundaries`, `camp-labels-big`, + geojson shipped inside `Map.bundle`) are gated on the camp tier via + `MapLayerManager`/`CampLayerVisibility`: hidden while locked even when the + "Show Camp Boundaries (Always)" map filter is on, and they appear live on + unlock with the rest. +- To exercise location flows before placement drops, apply mock fixtures: + `node scripts/mock_locations.js apply --map-fixtures` in + `Submodules/iBurn-Data` (revert with `... revert`). Rebuild + relaunch: the + bumped update.json triggers a JSON re-import with last year's placements. + While applied, `MockDataShipGuardTests` fails and `playa-seed` refuses — by + design; revert before committing or building seeds. +- Unlocking (More → "Unlock Location Data" passcode, or entering the BRC region) + posts `BRCEmbargoDidClear`: the map's PlayaDB observations restart and the six + SwiftUI list hosting controllers rebuild their root view, so pins/playa + addresses appear immediately — **no relaunch needed**. If you have to restart + the app to see locations after unlocking, that's a regression. +- "List" button (top-left) opens "Visible Pins" — a SwiftUI/PlayaDB list of what + is currently drawn inside the map's visible bounds, sectioned Art / Camps / + Events / Map Pins, nearest-first when a location is available. Tapping a data + row pushes the PlayaDB detail screen; tapping a Map Pins row pops back to the + map, recenters on that pin and opens its callout. Legacy Yap-fed maps (the + `useSwiftUILists` kill-switch list screens) still get the old + `MapPinListViewController` — the split is in `ListButtonHelper`, keyed on + whether any visible annotation is a `DataObjectAnnotation`. +- Search field "Search" is in the map header. + +## 7. Detail screen + +From any list row (event/camp/art): +- Title + description, host section (tap navigates to host detail), + "NEXT EVENT" section, "See all N events from ". +- Top bar: Share, favorite heart, back. +- Viewing a detail writes `last_viewed`/`first_viewed` metadata (this must NOT + cause list observations to re-emit — the metadata region excludes those + columns; regression-tested in FilterObservationTests). + +### More → Visit List (PlayaDB, default) + +More tab → **Visit List** pushes the SwiftUI `VisitListHostingController` +(`useSwiftUILists` ON; OFF falls back to the Yap-fed `VisitListViewController`). + +- Segmented picker **All / Want to Visit / Visited** over sections + "⭐ Want to Visit" and "✅ Visited" (there is never an "unvisited" section); + rows are mixed art/camp/event with hearts + distance, `map` toolbar button. +- Populate it from a detail screen's VISIT STATUS cell, then back out to More → + Visit List. There is **no observation API for visit status**: the list re-fetches + on every appearance and on `didBecomeActive` (so a watch-applied status shows up + after backgrounding/foregrounding, not live while on screen). +- Verify in DB: `SELECT object_type, object_id, visit_status FROM object_metadata + WHERE visit_status != 0;` — art/camp rows keyed by uid, events by the **parent** + event uid. + +## 8. Feature Flags screen + +More tab → scroll to Feature Flags (DEBUG only) → toggles including +"Use SwiftUI Lists". Toggling takes effect on next relaunch for tab +construction. + +## 9. watchOS app (iBurnWatch) + +Companion watch app **embedded in the iOS app** (`iBurn.app/Watch/iBurnWatch.app`) +but independently runnable (`WKRunsIndependentlyOfCompanionApp`). Bundle id +`com.trailbehind.iBurn2010.watchkitapp`. Two ways to get it on a watch sim: + +- **Paired install (companion path):** build scheme `iBurn`, `simctl install` + the iOS app on a phone sim with an active watch pair (`xcrun simctl list + pairs`) — the watch app auto-installs on the paired watch within ~10 s. + Location authorization can carry over from the phone app. +- **Direct install (development):** build scheme `iBurnWatch` for + `id=` (or `generic/platform=watchOS Simulator`) and + `simctl install` the watch app directly. + +Set XcodeBuildMCP session defaults to the watch sim UDID + +`simulatorPlatform: "watchOS Simulator"` before snapshot/tap. + +**The watch seeds from the same pre-baked database as the phone.** It ships its own +copy at `iBurnWatch/PlayaDB-.zip` (also gitignored, written by the same +`playa-seed` run) and restores it in `iBurnWatchApp.init()` — *before* `createPlayaDB()`, +since the restore is a no-op once a database file exists. `WatchSeeder.seedIfNeeded` +then runs in the root `.task` and re-imports only when the bundled JSON is newer +than the seed. Verify with the same query as §2 against +`com.trailbehind.iBurn2010.watchkitapp`'s container; `thumbnail_colors` = 1573 there +too (unused on watch — no thumbnails are rendered — but it rides along in the shared +seed). `update_info.created_at` staying at the *bake* time rather than launch time is +the tell that the restore was used and no JSON import ran. + +1. Build (see above for scheme choice). +2. Set a BRC location first: `xcrun simctl location set 40.7864,-119.2065`. +3. Launch via simctl. On a fresh direct install, first launch shows the + **location permission alert** — swipe the alert scroll-view up twice to + reveal the buttons, then tap **"Allow While Using App"** (or "Allow Once"). +4. Root is a **NavigationStack with the Map fullscreen** (Canvas-rendered BRC: + dashed pentagon fence, radial street grid, plazas, user dot, The Man / + Center Camp markers, user pins). Digital Crown zooms the map, drag pans — + there is intentionally no page-swiping (gesture conflict). All four controls + are **system toolbar buttons**, one per screen corner: + top-left "Browse" (list.bullet), top-right "Favorites" (heart.circle), + bottom-left the tracking button, bottom-right "Drop a pin here" + (mappin.and.ellipse). The two bottom ones are `.bottomBar` toolbar items + (watchOS 10+) — watchOS renders them as corner circles, not a bar. +5. The tracking button cycles MapKit-style, **free → follow → follow-heading → + free**; its AX label states the *next* mode ("Follow my location" / + "Switch to compass mode" / "Stop following my location"), which is the + reliable way to assert the current mode from a snapshot. Simulators have no + compass hardware, so heading mode stays north-up and no calibration hint shows. +6. **Browse** → rows: 📍 Nearby / 📌 Pins / 🏕️ Camps / 🎨 Art / 🚌 Vehicles / 🎪 Events. + - Camps/Art/Vehicles: alphabetical searchable list (search field automation + is unreliable — the watch keyboard's AX field doesn't accept `type_text`; + verify search logic in code/DB instead), distances shown with a GPS fix. + - Events: day-chip strip ("Sun 30" …, defaults to today or first day; chips + switch the list instantly) over name + "5:00 PM (2h)" rows. `adlt` events + are excluded unless the user's location is on-playa. +7. Nearby → tap a row → Detail (favorite toggle, **visit-status button** — tap + opens a sheet: Not Visited / Visited / Want to Visit — description, + event occurrence times, **Navigate** when the object has GPS) → Navigate + shows target marker + user dot + live " · °" readout. +8. Favorites toolbar has a **Filter** button (sheet with "Show": + Favorites / Want to Visit / Visited and "Type": All/Camps/Art/Events/Vehicles; + icon fills when non-default). +9. **User map pins** (bike / home / star), synced with the phone: + - Drop: bottom-right toolbar button → sheet with tinted Bike (green) / + Home (orange) / Pin (yellow) rows → tap saves at the **current GPS fix** + and dismisses. With no fix the sheet shows "Waiting for GPS…" instead — + after a sim reboot the location resets, so re-run `simctl location set` + or you'll only see that state. + - List: Browse → 📌 Pins (distance-sorted; empty state "No pins yet"). + - Detail: Navigate (same compass view as objects) / Rename / Delete. + Delete asks for confirmation, then pops back to the list. + - Pins also render on the map as tinted circles with their SF Symbol inside. + A pin at your exact location is hidden under the user dot (the dot draws + last) — move the sim location to see it. + +Verify: city geometry renders (not a blank background); PlayaDB.sqlite exists in +the watch app container with 2026 counts +(`xcrun simctl get_app_container com.trailbehind.iBurn2010.watchkitapp data`); +favoriting writes `object_metadata` `camp||1` etc.; +dropping a pin writes `user_map_pins` +(`SELECT id,title,pin_type,is_deleted FROM user_map_pins;`). + +### Phone↔watch sync (`PeerSyncManager`) + +Favorites, visit status, **and user map pins** sync bidirectionally over +WatchConnectivity `applicationContext` (best-effort, latest-state; LWW merge via +`PlayaDB.applyFavoriteSync` / `applyUserMapPinSync`). All payloads ride in **one** +manager and one context dictionary — `updateApplicationContext` replaces the +dictionary wholesale, so a second publisher would clobber the first. +Both sims must be a booted **pair** +(`xcrun simctl list pairs` → "(active, connected)"); the phone app and watch app +each start their manager at launch (phone: `DependencyContainer` init; watch: +root `.task` after seeding). + +1. Favorite an event on the phone (flow 4) → within seconds the watch's + `object_metadata` gains `event||1` with `favorite_updated_at` + set, and the watch Favorites screen lists it (event details show occurrence + times, e.g. "Sun 5:00 – 7:00 PM"). +2. Favorite a camp on the watch (Nearby → detail → Add Favorite) → the phone's + PlayaDB gains `camp||1` AND the phone's Yap mirror updates the + `BRCCampObject` metadata blob (`isFavorite=true`; for events, all + `"-"` occurrence rows + EKEvent, same as flow 4). +3. Delivery requires the peer app to be installed at push time; the managers + re-push on `sessionWatchStateDidChange`/`sessionCompanionAppInstalledDidChange`, + on activation, and on every favorite change, so a fresh watch install + converges on first launch. + +Sync checks: `SELECT object_type, object_id, is_favorite, visit_status FROM +object_metadata WHERE favorite_updated_at IS NOT NULL OR visit_status_updated_at +IS NOT NULL;` on either DB. Un-favoriting syncs too (rows persist with +`is_favorite=0`). + +**Pins sync the same way** (LWW on `modified_date`): + +1. Drop a pin on the watch → the phone's `user_map_pins` gains the row and the + annotation appears on the phone map immediately (`FilteredMapDataSource` + observes PlayaDB; no Yap mirror is involved). +2. Drop one on the phone (map sidebar bike/home/star → name → Save) → it appears + in the watch's Browse → Pins. +3. Delete on either device → the row becomes a **tombstone** + (`is_deleted=1`, `modified_date` bumped) rather than disappearing, which is + what lets the deletion win the peer's merge. Expect the tombstone row to + persist in both DBs; only `fetchUserMapPins`/`observeUserMapPins` filter it. + A tombstone for a pin the peer never had is **not** inserted, so the two DBs + legitimately differ in tombstone rows — compare `is_deleted=0` rows when + checking convergence. + +**Visit status syncs the same way** (per-field LWW on `visit_status_updated_at`, +values 0=unvisited/1=visited/2=wantToVisit): setting "Want to Visit" on the +watch shows up in the phone's PlayaDB `visit_status` AND its Yap metadata blob; +setting a status in the phone detail's VISIT STATUS cell (below USER NOTES — +present on both the legacy and PlayaDB detail paths) appears on the watch. +The rating prompt ("Enjoying iBurn?") can block phone UI automation — it's not +in the AX tree, so there's no elementRef to tap. Appirater is configured with +`setTimeBeforeReminding:2` (`BRCAppDelegate.m`), so a plain terminate + relaunch +can bring it straight back. Suppress it at the defaults layer instead, then +relaunch: + +```bash +xcrun simctl spawn defaults write com.trailbehind.iBurn2010 kAppiraterDeclinedToRate -bool YES +xcrun simctl spawn defaults write com.trailbehind.iBurn2010 kAppiraterRatedCurrentVersion -bool YES +``` + +Pre-embargo note: the bundled data has **zero GPS rows**, so Nearby shows an +explanatory empty state and Detail hides Navigate. To exercise those flows, +inject GPS into a few `camp_objects` rows via plain `UPDATE` — the +`*_spatial_update` triggers keep `spatial_index` in sync automatically — then +uninstall the app afterward so the DB reseeds clean. + +## Known quirks / expected noise + +- Yap legacy import logs ("Marking event ... as all-day", "Duped dates for ...") + appear at every fresh launch — legacy pipeline, unrelated to PlayaDB. +- "Error fetching updates: unsupported URL" in sim logs: the updates URL secret + is empty in local builds. Expected. +- Walk/bike times show "? min" until a location is set + (`xcrun simctl location set 40.7864,-119.2065`). +- The app dual-writes favorites Yap→PlayaDB; PlayaDB object data comes from the + bundled seed only (network updates still flow through YapDatabase). diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 64fd46e1..260b5dc2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,6 +35,23 @@ jobs: submodules: recursive token: ${{ secrets.GITHUB_TOKEN }} + - name: Refuse mock placement data + run: | + # scripts/mock_locations.js (iBurn-Data) fabricates camp/art locations + # for pre-drop testing and marks the bundle with these signals. + # A release must never ship them. + DATA_DIR=$(ls -d Submodules/iBurn-Data/data/20?? | sort | tail -1) + if [ -f "$DATA_DIR/APIData/APIData.bundle/MOCK_LOCATIONS" ]; then + echo "::error::$DATA_DIR contains MOCK placement data (MOCK_LOCATIONS sentinel). Revert mock_locations.js before tagging." + exit 1 + fi + for f in camp_outlines camp_labels; do + if grep -q "${f}_2025" "$DATA_DIR/Map/Map.bundle/${f}.geojson" 2>/dev/null; then + echo "::error::${f}.geojson is last year's fixture. Revert mock_locations.js before tagging." + exit 1 + fi + done + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: diff --git a/.gitignore b/.gitignore index e947d7a6..28f0ee4d 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,10 @@ iOSInjectionProject/ iBurn/BRCSecrets.m iBurn/InfoPlistSecrets.h iBurn/GoogleService-Info.plist +# Pre-populated database seeds (regenerated each season; see Docs) +iBurn/iBurn-*.zip +iBurn/PlayaDB-*.zip +iBurnWatch/PlayaDB-*.zip .env iBurn/crashlytics.sh diff --git a/.gitmodules b/.gitmodules index cf781f01..32bbd9cc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "Submodules/iBurn-Data"] path = Submodules/iBurn-Data -# url = git@github.com:iBurnApp/iBurn-Data-Private.git - url = git@github.com:iBurnApp/iBurn-Data.git + url = git@github.com:iBurnApp/iBurn-Data-Private.git +# url = git@github.com:iBurnApp/iBurn-Data.git [submodule "Submodules/DOFavoriteButton"] path = Submodules/DOFavoriteButton url = git@github.com:chrisballinger/DOFavoriteButton.git diff --git a/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml index 2e6b5a38..3c679511 100644 --- a/.xcodebuildmcp/config.yaml +++ b/.xcodebuildmcp/config.yaml @@ -1,2 +1,2 @@ schemaVersion: 1 -enabledWorkflows: ["simulator", "device"] +enabledWorkflows: ["simulator", "device", "ui-automation"] diff --git a/CLAUDE.md b/CLAUDE.md index afc934fb..dc7b58dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,364 +29,97 @@ Each document should include: * **Related Work**: Reference previous documents and build upon them * **Completion**: Mark final outcomes and any remaining work -## Source Control - -IMPORTANT: Do not perform any operations that result in git writes unless authorized by the user. Never attempt to rewrite history, pull from remote, squash, merge or rebase unless authorized. You can use read-only operations like `git show`, `git log` etc. - -## Project Overview - -iBurn is an offline map and guide for the Burning Man art festival. It's a native iOS application built primarily with Swift and Objective-C, featuring offline map tiles, art/camp/event data management, and location tracking capabilities. - -## Project Details +## Driving the App / Flow Verification -**Key Project Information**: -- **Workspace Path**: `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn.xcworkspace` -- **Main Scheme**: `iBurn` (for building the app) -- **Test Schemes**: `iBurnTests`, `PlayaKitTests` -- **Default Destination**: iPhone 16 Pro (arm64 simulator) -- **Active Branch**: Check with `git status` as development happens on feature branches +* To run the app in the simulator and exercise user flows (sanity passes, screenshots, UI bug repro), use the **`drive-app` skill** (`.claude/skills/drive-app/SKILL.md`). It covers XcodeBuildMCP setup, the SwiftUI/PlayaDB feature flag, onboarding automation, and on-device database verification. Physical-device deployment lives in `.claude/skills/drive-app/references/device-deploy.md`. +* Critical-flow scripts live in `.claude/skills/drive-app/references/flows.md`. **Keep them current:** when a change adds or alters a user-facing flow (screens, onboarding steps, permissions, navigation), update the corresponding flow entry in the same change. When driving the app, if reality diverges from the doc, fix the doc in that session. -### Project Discovery - -Start new sessions by exploring the project structure: - -```bash -# List available schemes -xcodebuild -workspace iBurn.xcworkspace -list +## Source Control -# List available simulators -xcrun simctl list devices available - -# Check workspace structure -open iBurn.xcworkspace # Opens in Xcode for scheme inspection -``` +* **Commit after finishing a validated chunk of work.** Once a coherent unit of work is complete and verified (tests passing, plus an app build when the change could affect the app target), commit it without waiting to be asked. Keep each commit scoped to one logical change with a descriptive message. +* Before committing, check `git status` for unintended changes (e.g. xcodebuild flipping `DEVELOPMENT_TEAM` in the pbxproj — revert those rather than committing them). +* Do NOT push to remotes unless the user asks. Never rewrite history, pull from remote, squash, merge or rebase unless authorized. +* Read-only operations (`git show`, `git log`, `git diff`, etc.) are always fine. ## Development Commands -### Build/Test Output Parsing (xcsift) - -This repo uses `xcsift` to parse and format `xcodebuild` and SwiftPM `swift test` output for coding agents. - -Key rule: always redirect stderr to stdout (`2>&1`) before piping into `xcsift`. - -Examples: -```bash -xcodebuild build ... 2>&1 | xcsift -f toon -w -xcodebuild test ... 2>&1 | xcsift -f toon -w -swift test 2>&1 | xcsift -f toon -w -``` - ### Building and Dependencies - `pod install` - Install CocoaPods dependencies (required after cloning) - `git submodule update --init` - Initialize git submodules (required after cloning) - Build via Xcode: Open `iBurn.xcworkspace` (NOT the .xcodeproj file) -### Build Commands - -**Preferred Build Command (arm64 simulator, parsed via xcsift)**: -```bash -# Build for iOS Simulator (quiet xcodebuild + xcsift parsing) -xcodebuild -workspace iBurn.xcworkspace -scheme iBurn -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' -quiet 2>&1 | xcsift -f toon -w -# -# Note: if xcsift prints "Error: No input provided", xcodebuild likely produced no output (e.g. a fully -# incremental build with `-quiet`). Re-run without `-quiet`. - -# Build and show full xcodebuild output (debugging) -xcodebuild -workspace iBurn.xcworkspace -scheme iBurn -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' 2>&1 | xcsift -f toon -w -``` - -**Testing Commands**: -```bash -# Run tests on simulator with quiet output -xcodebuild test -workspace iBurn.xcworkspace -scheme iBurnTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' -quiet 2>&1 | xcsift -f toon -w - -# Run tests with full output (for debugging) -xcodebuild test -workspace iBurn.xcworkspace -scheme iBurnTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' 2>&1 | xcsift -f toon -w - -# Run PlayaKit tests -xcodebuild test -workspace iBurn.xcworkspace -scheme PlayaKitTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' -quiet 2>&1 | xcsift -f toon -w - -# Run SwiftPM tests (note: may require elevated permissions in sandboxed environments) -swift test 2>&1 | xcsift -f toon -w -``` - -**Utility Commands**: -```bash -# Clean build products -xcodebuild clean -workspace iBurn.xcworkspace -scheme iBurn 2>&1 | xcsift -f toon -w - -# Show build settings -xcodebuild -workspace iBurn.xcworkspace -scheme iBurn -showBuildSettings 2>&1 | xcsift -f toon -w -``` +### Build/Test Output Parsing (xcsift) -### Simulator Management +This repo uses `xcsift` to parse and format `xcodebuild` and SwiftPM `swift test` output for coding agents. +Key rule: always redirect stderr to stdout (`2>&1`) before piping into `xcsift`. -Basic simulator control using standard tools: +Default destination: **iPhone 17 Pro Max, iOS 26.5, arm64 simulator**. Schemes: `iBurn` (app), `iBurnTests`, `PlayaKitTests`. ```bash -# List available simulators -xcrun simctl list devices available - -# Boot a simulator -xcrun simctl boot "iPhone 17 Pro Max" +DEST='platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.5,arch=arm64' -# Open Simulator app -open -a Simulator - -# Shutdown simulator -xcrun simctl shutdown "iPhone 17 Pro Max" - -# Erase simulator content -xcrun simctl erase "iPhone 17 Pro Max" +xcodebuild -workspace iBurn.xcworkspace -scheme iBurn -destination "$DEST" -quiet 2>&1 | xcsift -f toon -w +xcodebuild test -workspace iBurn.xcworkspace -scheme iBurnTests -destination "$DEST" -quiet 2>&1 | xcsift -f toon -w +swift test 2>&1 | xcsift -f toon -w # SwiftPM targets (PlayaDB, PlayaAPI); may need elevated permissions when sandboxed ``` -### Physical Device Deployment (XcodeBuildMCP) +If xcsift prints "Error: No input provided", xcodebuild likely produced no output (e.g. a fully incremental build with `-quiet`). Re-run without `-quiet`. -To build and run on a physical device using XcodeBuildMCP, the `device` workflow must be enabled. +### Pre-baked database seed (`playa-seed`) -**Configuration** (`.xcodebuildmcp/config.yaml`): -```yaml -schemaVersion: 1 -enabledWorkflows: ["simulator", "device"] -``` +`iBurn/PlayaDB-.zip` and `iBurnWatch/PlayaDB-.zip` ship a pre-populated PlayaDB +so first launch doesn't import JSON or compute thumbnail colors on device. Both are +gitignored — regenerate them whenever the API data or media files change: -After creating or modifying this file, restart the XcodeBuildMCP MCP server (e.g. `/mcp` → reconnect in Claude Code). - -**Device Discovery**: ```bash -# List connected physical devices (USB or network) -xcrun devicectl list devices +swift run --package-path Packages/PlayaSeed playa-seed --fetch-media ``` -**XcodeBuildMCP Device Workflow**: -1. `list_devices` — List connected devices and their UDIDs -2. `session_set_defaults` — Set workspace, scheme, and `deviceId` (UDID) -3. `build_run_device` — Build, install, and launch on device (single step) -4. `launch_app_device` — Launch an already-installed app -5. `start_device_log_cap` / `stop_device_log_cap` — Capture device logs -6. `test_device` — Run tests on the physical device - -**Requirements**: -- Code signing must be configured in Xcode for the target device -- Device must have Developer Mode enabled -- Device must be unlocked for app launch to succeed +One run writes both copies (the phone and watch each restore from their own bundle). +`--fetch-media` also downloads any thumbnails the API references but +`Submodules/iBurn-Data/data//MediaFiles/MediaFiles.bundle` is missing; commit those +in the submodule. `--help` lists the rest (`--year`, `--data-root`, `--output`, +`--skip-colors`). Without a seed both apps still work — they fall back to the on-device +JSON import — so a missing zip shows up as a slow first launch, not a build failure. The +JSON stays bundled either way: `needsImport` compares it against the seed's `update_info` +and re-imports when a build ships data newer than the baked database. -### Fastlane Commands -- `fastlane ios beta` - Build and upload to TestFlight -- `fastlane ios refresh_dsyms` - Download and upload crash symbols +Colors come from `Packages/PlayaColors`, which the app also uses at runtime, so a baked +color is identical to one the device would compute. ### Testing When adding new functionality, make sure to plan for testability. When your feature is complete, add tests to validate your business logic, and then ensure they are passing. -- **Command Line**: Use xcodebuild test commands shown above for automated testing -- **Xcode GUI**: Run tests through Xcode Test Navigator or `Cmd+U` -- **Test targets**: `iBurnTests`, `PlayaKitTests`, and local Swift Package targets for `PlayaDB` and `PlayaAPI` - ## Architecture Overview ### Guidance Protocolize dependencies and use dependency injection with factory pattern. For example `protocol FooService` and `class FooServiceImpl: FooService`, where the factory builds and returns a `FooService`, obscuring the underlying Impl. -### Core Components - -**Database Layer (YapDatabase)** -- Primary data storage using YapDatabase (key-value database) -- Database manager: `BRCDatabaseManager` (Obj-C) with Swift extensions -- Data objects inherit from `BRCYapDatabaseObject` and conform to YAP protocols -- Background/UI connection separation for performance - -**Data Models** -- `BRCDataObject` - Base class for all data objects (Art, Camps, Events) -- `BRCArtObject`, `BRCCampObject`, `BRCEventObject` - Specific data types -- `BRCUpdateInfo` - Manages data updates and versioning -- Data import handled by `BRCDataImporter` (both Obj-C and Swift versions) - -**Map System (MapLibre)** -- Uses MapLibre for offline map rendering (migrated from Mapbox) -- `BaseMapViewController` - Base map functionality -- `MainMapViewController` - Primary map interface -- `MapViewAdapter` and `UserMapViewAdapter` - Map interaction handling -- Custom annotation views: `ImageAnnotationView`, `LabelAnnotationView` - -**UI Architecture** -- Mix of UIKit (programmatic and Storyboard) with some SwiftUI adoption -- `TabController` - Root tab bar controller with theme management -- Table view adapters: `YapTableViewAdapter` for database-driven lists -- Custom table cells for different data types with corresponding XIB files - -**Location Services** -- `BRCLocations` - Centralized location management -- `CLLocationManager+iBurn` - Location utilities -- User tracking with breadcrumb trail functionality - -**Data Management** -- Year-based configuration via `YearSettings` -- Embargo system for restricted data access -- Background data downloads and updates -- Offline-first approach with optional data syncing - -### Key Frameworks -- **YapDatabase** - Local database storage -- **MapLibre** - Map rendering and offline tiles -- **Mantle** - Object serialization/deserialization -- **CocoaLumberjack** - Logging -- **Firebase** - Analytics and crash reporting -- **Anchorage** - Auto Layout helpers - -### File Organization -- `/iBurn/` - Main application code - - Core data objects and managers - - View controllers and UI components - - Map-related functionality - - Utility extensions and helpers -- `/PlayaKit/` - Shared data models and protocols -- `/Submodules/` - Git submodules for custom dependencies -- `/Pods/` - CocoaPods dependencies - ### Required Setup Files -Before building, create these files: +Before building, create these files (they are gitignored, so they won't exist in a fresh clone): - `iBurn/BRCSecrets.m` - API keys and configuration constants - `iBurn/InfoPlistSecrets.h` - Preprocessor defines for sensitive data - `iBurn/crashlytics.sh` - Crashlytics build script (optional) -### Development Notes -- The app supports both light and dark themes via `Appearance` system -- Heavy use of Objective-C categories for extending system classes -- Mix of programmatic UI and Interface Builder (XIB files) -- Database views are used extensively for filtered/sorted data presentation -- Location data is embargoed by Burning Man organization until gates open each year +### Domain Notes +- Location data is embargoed by the Burning Man organization until gates open each year; year-based configuration lives in `YearSettings`. ## Submodule Dependencies -### iBurn-Data (`/Submodules/iBurn-Data/`) -Data repository containing yearly festival datasets, geospatial data, and processing scripts for offline map tiles, art/camp/event data, and Black Rock City layout geometry. - -**Key Features:** -- Year-based data structure (`data/YYYY/`) with APIData, geo/, layouts/, Map/, and MediaFiles/ -- Burning Man's unique time-based addressing system (12:00, 1:00, etc.) -- GeoJSON generation for streets, plazas, toilets, and city boundaries -- Offline MBTiles for mobile map consumption -- Data embargo system (location data restricted until gates open) - -**Common Commands:** -```bash -cd Submodules/iBurn-Data/scripts/BlackRockCityPlanner -npm install -node src/cli/generate_all.js -d ../../data/2024 -``` - -### BlackRockCityPlanner (`/Submodules/iBurn-Data/scripts/BlackRockCityPlanner/`) -Node.js geospatial tool that generates GeoJSON files for Black Rock City's unique radial layout and provides geocoding for Burning Man addresses. - -**Key Features:** -- Generates radial street grids based on clock positions (3:00 & 500') -- Geocodes user addresses to coordinates with fuzzy matching -- Creates city geometry: streets, polygons, fence, toilets -- Handles special locations (Center Camp Plaza, Man Base) -- Uses Turf.js v3.x and JSTS for geospatial operations - -**Common Commands:** -```bash -npm test # Run geocoding and geometry tests -node src/cli/api.js -l layout.json -f camp.json -k location_string -o camp-location.json -browserify src/geocoder/index.js -o bundle.js -``` - -**Address Formats Supported:** -- Time-based: "3:00 & 500'" (radial position + distance) -- Intersections: "Esplanade & 6:00" (named street + time) -- Special locations: "Center Camp Plaza", "9:00 Portal" - -## CI/CD with GitHub Actions - -The project uses GitHub Actions for continuous integration and deployment. This replaced the legacy Travis CI setup in July 2025 with modern macOS runners and enhanced security. - -### Workflow Overview - -**Three main workflows handle different aspects of CI/CD:** - -1. **`.github/workflows/ci.yml`** - Main CI pipeline for master/develop branches -2. **`.github/workflows/pr.yml`** - Lightweight validation for pull requests -3. **`.github/workflows/deploy.yml`** - Deployment to TestFlight - -### Infrastructure Details - -- **Runners:** macOS 15 ARM64 with Xcode 16.4 (latest stable) -- **Simulators:** iPhone 16 Pro ARM64 with latest iOS -- **Ruby:** Version 3.1 with bundler caching -- **Dependencies:** CocoaPods with intelligent caching -- **Parallel Execution:** Build and test schemes run concurrently - -### Security & Secrets - -All sensitive data is managed through GitHub Secrets: - -```bash -# Required Secrets for CI -MAPBOX_ACCESS_TOKEN -CRASHLYTICS_API_TOKEN -HOCKEY_BETA_IDENTIFIER -HOCKEY_LIVE_IDENTIFIER -EMBARGO_PASSCODE_SHA256 -UPDATES_URL -MAPBOX_STYLE_URL - -# Additional Secrets for Deployment -APP_STORE_CONNECT_API_KEY -APP_STORE_CONNECT_API_KEY_ID -APP_STORE_CONNECT_API_ISSUER_ID -GOOGLE_SERVICE_INFO_PLIST -BUILD_CERTIFICATE_BASE64 -P12_PASSWORD -BUILD_PROVISION_PROFILE_BASE64 -KEYCHAIN_PASSWORD -FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD -FASTLANE_SESSION -MATCH_PASSWORD -``` - -### Workflow Triggers - -**Automatic Triggers:** -- **CI:** All pushes to master/develop, all pull requests -- **PR:** Pull request open/sync/reopen (lightweight validation only) -- **Deploy:** Git tags starting with 'v' (e.g., v1.2.3) - -**Manual Triggers:** -- All workflows support manual dispatch via "Run workflow" button -- Deploy workflow allows choosing Fastlane lane (beta, refresh_dsyms) - -### Performance Optimizations - -- **Intelligent Caching:** Ruby gems and CocoaPods cached across runs -- **Parallel Execution:** Build matrix allows concurrent scheme testing -- **Artifact Storage:** Test results and build logs preserved for debugging -- **Optimized Dependencies:** Concurrent installation with retry logic - -### Monitoring & Debugging - -**Workflow Monitoring:** -- View all workflows in repository Actions tab -- Real-time logs with timestamps and step-by-step execution -- Build artifacts and test results preserved (30 days for CI, 7 days for PRs) - -**Test Analysis:** -- XCResult files uploaded as artifacts for detailed analysis -- Test failures include full logs and error context -- PR workflows automatically comment build status +`Submodules/iBurn-Data/` (festival datasets, geospatial data, offline tiles) and its +`scripts/BlackRockCityPlanner/` (GeoJSON generation + Burning Man address geocoding) each +have their own `CLAUDE.md`, which loads automatically when you work in those directories. +Read those for the current data-generation and geocoder-build pipelines. -**Common Debugging Steps:** -1. Check workflow logs in GitHub Actions tab -2. Download test result artifacts for detailed analysis -3. Verify GitHub Secrets are properly configured -4. Check for CocoaPods or dependency issues in setup steps +## CI/CD -### Migration Notes +GitHub Actions workflows live in `.github/workflows/` (`ci.yml`, `pr.yml`, `deploy.yml`, plus the +Claude review workflows). Secrets are managed through GitHub Secrets — the workflow files list the +exact names required. Deployment is triggered by git tags starting with `v`. -**Replaced:** Legacy `.travis.yml` configuration (Xcode 12.3, basic security) -**Enhanced:** Modern infrastructure (Xcode 16.4), secure secrets, parallel execution, comprehensive testing -**Added:** PR validation, automated deployment, intelligent caching, detailed reporting +For the Travis → GitHub Actions migration history, see `Docs/2025-07-23-github-actions-migration.md`. -For complete migration details, see `Docs/2025-07-23-github-actions-migration.md`. +### Fastlane +`fastlane lanes` lists the available lanes (`fastlane/Fastfile`); `beta` uploads to TestFlight. diff --git a/Docs/2026-07-03-2026-year-update-plan.md b/Docs/2026-07-03-2026-year-update-plan.md new file mode 100644 index 00000000..8c06ea2c --- /dev/null +++ b/Docs/2026-07-03-2026-year-update-plan.md @@ -0,0 +1,202 @@ +# 2026 Year Update Plan (2025 → 2026) + +**Date:** 2026-07-03 (Pacific) +**Status:** IMPLEMENTED & VERIFIED (all workstreams complete, all tests green). Nothing committed or pushed yet. + +**Final verification results (2026-07-03):** +- App builds clean (0 errors) and runs in simulator: 2026 map, data, and reverse geocoding all correct. +- PlayaAPI package: 54/54. PlayaDB package: 146 tests, 0 failures, 4 embargo skips. +- iBurnTests: full suite green after fixes (96 passed + ObjectListViewModelTests deterministic after mock-provider fix). +- `ObjectListViewModelTests.testToggleFavoriteCallsProvider` was a pre-existing broken test (April 2026 ListRow refactor): the mock provider never re-yielded rows with metadata after `toggleFavorite`, and the VM's optimistic update no-ops on nil metadata. Fixed the mock to mirror the real GRDB observation (re-yield with fresh `ObjectMetadata` on toggle) — unrelated to the year flip but fixed en route. +**Branch:** `2026-updates` + +## Implementation Log (2026-07-03) + +Workstream A (iBurn-Data, all local, uncommitted): +- A1 ✅ Removed `iBurn-2025.zip` + `.DS_Store`; `org-datasets/` now holds `2026 BRC Measurements.pdf`, `BRC_City_Plan_2026_update.pdf`, `Burning_Man_2026_Location_Data.md` (full transcription + computed radii table). +- A2 ✅ `layouts/layout.json`: new center `[-119.207871, 40.783242]`, `fence_distance` 8287, all 12 cStreets renamed/re-radiused (Ararat…Kundalini), added 2:00 + 10:00 B Plazas, 2:15/9:45 community paths now terminate at Iroko (double-wide I–K end blocks). `poi.json`/`toilet.json` carried over (address-relative, re-geocode automatically). +- A3 ✅ `APIData.bundle`: art/camp/event pretty-printed + mv minified from `/Users/chrisbal/Documents/Code/API/*-2026.json`; `update.json` timestamps refreshed; `dates_info.json` → Aug 30–Sep 7 2026, majorEvents 9 entries (burns on last three days). Verified: 321/1201/2140/499 records, all `year: 2026`. `majorEvents` is not parsed by app logic (only `BundleDataLoader.loadDatesInfo` + tests). +- A4 ✅ `generate_all.js` regenerated `geo/*.geojson`. **Validation: generated fence pentagon matches all 5 official surveyed fence points within ~10 m.** Street names + all 12 plazas + 5 portals present in polygons/streets output. +- A5 ✅ `geocoder/bundle.js` rebuilt. **Gotcha: `scripts/BlackRockCityPlanner/src/geocoder/index.js` hardcodes the year's layout path** — bumped `data/2025` → `data/2026` (uncommitted change in the BRCP submodule). Smoke tests pass: "9:00 & Ararat", "2:00 B Plaza", "3:00 & 500'" geocode; "4:30 & Kundalini" lands exactly due south of the Man (bearing check ✓). +- A6 ✅ tippecanoe → `Map.bundle/map.mbtiles` (139 KB, z4–14, 7 layers: dmz/fence/outline/points/polygons/streets/toilets). Bounds match 2026 fence. **Gotcha: tippecanoe needs `-t "$TMPDIR"` under the sandbox.** Style only references generated-layer names, so generated tiles are fully compatible. Official-GIS redo deferred (innovate-GIS-data has no 2026 yet). +- A7 ✅ Styles: `asset://iBurnData_iBurn2025Map.bundle` → `iBurn2026Map` (3 refs each in light/dark). `camp_labels.geojson`/`camp_outlines.geojson` replaced with empty FeatureCollections (2025 placement data removed, ~26 MB saved). +- A8 ✅ MediaFiles.bundle: cleared 2025 media (1355 files incl. 87 audio-tour m4a), downloaded **1574/1574 thumbnails, 0 failures** (art 316, camp 760, mv 498) keyed `.jpg` from widen.net CDN. Audio tour deferred. +- A9 ✅ Renamed `iBurn2025*` → `iBurn2026*`: wrapper sources in `data/2026/{APIData,Map,MediaFiles}/`, `Tests/iBurn2026*Tests/` dirs+files+identifiers+year literals, root `Package.swift` products/targets/paths → `data/2026`. (`swift build` inside the session sandbox fails on SwiftPM's nested sandbox-exec; validation via xcodebuild instead.) + +Workstream B (app repo, uncommitted): +- `YearSettings.plist`: 2026 / Aug 30 07:00Z / Sep 7 07:00Z / Man 40.783242, -119.207871. +- `iBurn2025` → `iBurn2026` in `Bundle+iBurn.swift`, `project.pbxproj` (3 product deps), `PlayaAPI`+`PlayaDB` `Package.swift`, `BundleDataLoader.swift`, `BundleDataIntegrationTests.swift`, `PlayaDBRealDataTests.swift`. +- `MARKETING_VERSION` 2025.5 → 2026.0; `iBurn-2026.sqlite`/folder in `BRCDatabaseManager.m`; `kBRCEntered2026EmbargoPasscodeKey` (re-arms embargo); `BRCArtObject.m` default year 2026; `NSDate+iBurn.m` mock-date fallback → `2026-09-04T11:00:00-07:00`. +- Tests: `BundleDataIntegrationTests` year literals → 2026; `PlayaDBRealDataTests` Thursday → `2026-09-03`, GPS + spatial tests get `XCTSkipIf` embargo guards (no GPS in 2026 data until gates). Fixture-only tests (EventObjectOccurrence, MockAPIData, BRCDataSorter w/ frozen `initial_data` bundle, previews) left at 2025 — self-consistent. +- Verified: 2026 event-type codes AND labels identical to 2025 (8 types) — no `BRCEventObject.swift`/`EventTypeInfo.swift` changes. Embargo logic already covers camp+event+art and unlocks via `eventStart`. `BRCSecrets.m` has no year refs; 2026 passcode hash still pending from BMorg. +- **Gotcha found at runtime: `PlayaGeocoder/PlayaGeocoder.xcodeproj/project.pbxproj` embeds `../../Submodules/iBurn-Data/data/YYYY/geocoder/bundle.js` by year-hardcoded path** — the app's reverse geocoder showed "3:31 & Farmer" (2025 street) until this was bumped to `data/2026`. Add to the annual checklist alongside `scripts/BlackRockCityPlanner/src/geocoder/index.js`. +- Data fix: 4 records (3 camps, 1 MV) had user-entered `url` values with spaces/commas ("http://a, b", page titles) that crash strict `URL` decoding in PlayaAPI — sanitized in the 2026 bundle (first URL token kept, else null). Consider adding sanitization to `fetch_and_geocode.js` for the August re-fetches. +- Runtime verification (simulator, iPhone 17 Pro Max): app launches, imports 2026 data, map renders the 2026 city (Ararat…Kundalini labels, toilets, POIs), nearby cards show 2026 camps, reverse geocode returns 2026 addresses ("2:40 & Eternal"). +- **Test-fixture consequence of the year flip: `BRCRecurringEventObject.eventObjects()` (iBurn/BRCRecurringEventObject.m:38-90) drops occurrences outside `YearSettings.eventStart/eventEnd`.** Any fixture with prior-year dates silently imports zero events. Fixed by day-mapping the fixture dates into the 2026 window (Aug 24→Aug 30 … Aug 31→Sep 6): `iBurnTests/Fixtures/initial_data.bundle/event.json`, `updated_data.bundle/event.json`, the three `now` strings in `BRCDataSorterTests.swift`, and `MockServices.eventObject`'s occurrence (was crashing DetailViewModelTests via the dateless `BRCEventObject()` fallback). Add fixture re-dating to the annual checklist. + +## High-Level Plan + +Annual rollover of the app from Burning Man 2025 to 2026. Three workstreams: + +1. **iBurn-Data submodule** — populate `data/2026/` (currently a byte-for-byte copy of `data/2025/`): new city layout geometry, fresh API data from `/Users/chrisbal/Documents/Code/API`, regenerated geo/tiles/geocoder, renamed SwiftPM targets (`iBurn2026*`). +2. **App repo** — flip year-stamped code: `YearSettings.plist`, package product names, database name, embargo defaults key, marketing version, tests. +3. **Verification** — build, run, and test against the new data. + +### Hard constraints (from Chris) + +- **NO git pushes to any remote.** Local commits only, and only when authorized. The iBurn-Data submodule `origin` is the *private* repo (`iBurnApp/iBurn-Data-Private`) with a `public` remote; `.gitmodules` in the app repo currently points at the public URL with the private one commented out. 2026 API data must stay private if it ever contains location data. +- Current 2026 API data is **fully embargoed**: every `location`/`location_string` in art and camp is `null`. No GPS anywhere. So nothing sensitive exists yet, but the no-push rule stands. + +--- + +## Key 2026 Facts (verified against official sources) + +Sources: `2026 BRC Measurements.pdf` (bm-innovate S3, dated 2.25.2026), `BRC_City_Plan_2026_update.pdf` (webassets), burningman.org 2026 city plan page, and the raw API pull at `/Users/chrisbal/Documents/Code/API/*-2026.json`. + +### Event dates +- **Sun Aug 30 – Mon Sep 7, 2026** (Labor Day = Sep 7). API occurrences span `2026-08-30T17:00:00-07:00` → `2026-09-06T23:00:00-07:00`. +- Major events (same convention as 2025's last-three-days pattern): Man Burn **Sat Sep 5**, Temple Burn **Sun Sep 6**, Exodus **Mon Sep 7**. + +### Geometry (Measurements PDF) +- **The Man (golden spike): `40.783242, -119.207871`** — moved significantly from 2025 (`40.786958, -119.202994`). +- Fence pentagon points: `40.779710,-119.237421` / P2 `40.803523,-119.221409` / P3 `40.799290,-119.186670` / P4 `40.772883,-119.181237` / P5 `40.760786,-119.212582`. Man→fence points **8287'** (2025: 8337 in layout.json). +- Center of Greeter's Gap: `40.770268, -119.225025`. Man→Haul Rd center 6435'. Man→fence @ Greeters 6705'. +- True N/S along 4:30 axis → **bearing 45 unchanged**. +- Esplanade 2500' from Man; Esplanade→A block 400' deep; A–E blocks 250'; **mid-city double blocks E–F 450'** ; I–K blocks 150'. K road diameter 11,510' → radius 5755'. +- Widths: radial avenues 40'; annular streets 30' except **E & Esplanade 40'**, **K 50'**. Community Paths (20', ped/bike) between F and K at 3:45, 4:15, 4:45, 5:15, 6:45, 7:15, 7:45, 8:15. +- Man → center of The Canopy (center camp) = 2999' (unchanged). Center Camp portal mouth at Esplanade: 210'. +- Plazas: portal plazas ring at **B = 3215'** (3:00, 4:30, 7:30, 9:00), mid-city ring at **G = 4825'** (3:00, 4:30, 6:00, 7:30, 9:00). Five portals to Esplanade: 3:00, 4:30, 6:00, 7:30, 9:00 (unchanged). +- **NEW per city-plan page: B Plazas at 2:00 and 10:00** ("along traditional sound avenues"). Also **double-wide blocks 2:00–2:30 and 9:30–10:00 between I–K** for large camps/HUBS. *Verify both against the city plan map PDF / GIS data when editing layout.json.* +- BRC Depot & Sanitation 670' from Kilgore→K center-to-center — identical wording to 2025, so the 2025 `dmz` block in layout.json can carry over. +- Walk-in camping: beyond K to fence, 2:00–5:00 (city plan map shows two walk-in areas). + +### 2026 street names (from city plan PDF map — authoritative) +Esplanade, then A–K: **Ararat, Bodhi, Chomolungma, Delphi, Eternal, Fulcrum, Great Oak, Heiau, Iroko, Jiba, Kundalini**. +(Note: burningman.org page summary suggested "Ceiba" for C; the official map PDF clearly shows **Chomolungma**. The measurements PDF still says "Bradbury/Gibson/Kilgore" — stale 2025 names used as ring references only.) + +### Computed 2026 cStreet radii (center-to-center; cross-checks: B=3215 ✓, G=4825 ✓, K=5755=11510/2 ✓) + +| ref | name | distance (ft) | width | 2025 was | +|---|---|---|---|---| +| esplanade | Esplanade | 2500 | 40 | 2500 | +| a | Ararat | 2935 | 30 | 2940 | +| b | Bodhi | 3215 | 30 | 3220 | +| c | Chomolungma | 3495 | 30 | 3500 | +| d | Delphi | 3775 | 30 | 3780 | +| e | Eternal | 4060 | 40 | 4060 | +| f | Fulcrum | 4545 | 30 | 4540 | +| g | Great Oak | 4825 | 30 | 4825 | +| h | Heiau | 5105 | 30 | 5100 | +| i | Iroko | 5385 | 30 | 5380 | +| j | Jiba | 5565 | 30 | 5560 | +| k | Kundalini | 5755 | 50 | 5755 | + +Derivation: gap = ½(inner width) + block depth + ½(outer width). E.g. A = 2500 + 20 + 400 + 15 = 2935; F = 4060 + 20 + 450 + 15 = 4545; K = 5565 + 15 + 150 + 25 = 5755. Note 2025's layout.json was ~5' off the official plaza ring (official 2025 md also said B=3215); the 2026 numbers above match the official anchors exactly. + +### API data (already fetched, at `/Users/chrisbal/Documents/Code/API/`) +- `art-2026.json` (321 records, −11 vs 2025), `camp-2026.json` (1201, −184), `event-2026.json` (2140 events / 4526 occurrences), `mv-2026.json` (499, +276). All minified, valid UTF-8, no BOM. +- Schema identical to 2025 except two NEW booleans: art `needs_volunteers`, camp `accepting_campers` (harmless to Codable; optionally surface later). +- Event type codes **identical to 2025** (adlt, arts, food, kid, othr, prty, tea, work) — no changes needed in `BRCEventObject.swift` / `EventTypeInfo.swift`. +- All locations null (embargo). `uid`/`year` fields correct. + +--- + +## Workstream A — iBurn-Data submodule (`Submodules/iBurn-Data`) + +`data/2026/` is currently an exact copy of `data/2025/` (verified with `diff -rq`). Steps: + +### A1. Clean the template +- Delete stray `data/2026/iBurn-2025.zip` (19.9 MB build artifact) and `.DS_Store` files. +- Replace `data/2026/org-datasets/` contents with 2026 source docs: save the two PDFs (measurements + city plan) and a `2026-city-plan.txt` / location-data markdown analogous to 2025's. + +### A2. Hand-edit `data/2026/layouts/layout.json` +- `center.geometry.coordinates` → `[-119.207871, 40.783242]` (lon, lat). +- `fence_distance` → `8287`. +- `cStreets`: names + distances per table above (keep `segments` structure; Esplanade keeps its split segments around center camp; widths: only esplanade=40, e=40, k=50 explicit, matching 2025 style). +- `plazas`: keep 2025 set (B ring ×4, G ring ×5 incl. 6:00, Man Plaza), **add `2:00 B Plaza` and `10:00 B Plaza`** (diameter 200, distance "b") — verify against city plan map first. +- `tStreets`: verify the :15/:45 F→K streets against the 2026 map — the new double-wide I–K blocks at 2:00–2:30 and 9:30–10:00 may terminate 2:15 and 9:45 at I instead of K. +- `dmz`, `center_camp`, `entrance_road`, `portals`: carry over (measurements match 2025 wording); sanity-check entrance road/Greeters against new gap coordinates. +- `layouts/toilet.json`, `layouts/poi.json`: carry over initially (addresses are time/distance-based and geocode against the new layout); update POIs if 2026 info differs (Temple location, airport, medical usually stable early-season). Toilets get corrected when official GIS lands. + +### A3. Populate `data/2026/APIData/APIData.bundle/` +- Copy `/Users/chrisbal/Documents/Code/API/{art,camp,event,mv}-2026.json` → `art.json`, `camp.json`, `event.json`, `mv.json`. Pretty-print art/camp/event with `jq` to match 2025 bundle formatting (mv.json stayed minified in 2025). +- `update.json`: refresh `updated` timestamps for all four entries (file mtime-style ISO timestamps like 2025's). +- `dates_info.json`: `rangeInfo` → start `2026-08-30T00:00:00-07:00`, end `2026-09-07T12:00:00-07:00`; `majorEvents` → same last-three-days convention (…, "Man Burn", "Temple Burn", "Exodus"). **Check the consumer of `majorEvents` (search code) to get array length/indexing right for a 9-day span.** +- `points.json` (empty FeatureCollection) and `credits.json` carry over. +- Locations stay null (embargo). Optional dev aid: `scripts/BlackRockCityPlanner/src/cli/mock_locations.js` can fabricate mock locations from a prior year for local testing — must never be committed/shipped. + +### A4. Regenerate geometry +```bash +cd Submodules/iBurn-Data/scripts/BlackRockCityPlanner +npm install +node src/cli/generate_all.js -d ../../data/2026 +``` +Outputs `data/2026/geo/{streets,polygons,outline,fence,dmz,toilets,points}.geojson`. Spot-check in a geojson viewer: street names, new Man position, plaza set. + +### A5. Rebuild geocoder +`browserify src/geocoder/index.js -o ../../data/2026/geocoder/bundle.js` (layout changed, so the embedded geometry must be rebuilt). Check `hardcoded_locations.js` for 2025-specific entries. + +### A6. Regenerate map tiles +`bmorg/innovate-GIS-data` has **no 2026 folder yet**, so use the generated-geo tippecanoe variant from the repo CLAUDE.md (layers: fence, outline, polygons, streets, toilets, points, dmz; `-Z 4 -z 14 -B0`) → `data/2026/Map/Map.bundle/map.mbtiles`. The `points` layer with uppercase `NAME` is required for POI sprites. Redo from official GIS when BMorg publishes 2026 (tracked in Deferred). + +### A7. Map bundle styles + camp layers +- `Map.bundle/styles/iburn-{light,dark}.json`: update `asset://iBurnData_iBurn2025Map.bundle/...` → `iBurnData_iBurn2026Map.bundle` (glyphs + camp geojson sources). +- Replace `camp_labels.geojson` (25.7 MB) and `camp_outlines.geojson` with **empty FeatureCollections** — they contain 2025 placement data; 2026 placement won't exist until ~gates. Keeps the style layers valid and drops ~26 MB. (`/Users/chrisbal/Downloads/placement_geojson` is the *2025* placement source, dated Aug 2025 — not usable for 2026.) + +### A8. MediaFiles bundle +`data/2026/MediaFiles/MediaFiles.bundle` currently holds 1358 2025 files (art/camp jpg + audio-tour m4a). For the initial 2026 build: clear 2025 media, download 2026 thumbnails from `images[].thumbnail_url` (art 316/321 have images, camp 760/1201, mv 498/499) using the same file-naming convention (verify how `Bundle.brc_mediaFileURL`/`brc_loadMediaData(fileId:)` keys files — likely by uid). Audio tour arrives later in the season. If we want a smaller first pass: ship empty media bundle and add media in a follow-up like 2025 did (commits `efbed09`, `6618625`, `7f5a3be`). + +### A9. Rename SwiftPM targets to 2026 +- Rename `data/2026/APIData/iBurn2025APIData.swift` → `iBurn2026APIData.swift` (update `year` and bundle-name strings inside); same for `Map/iBurn2025Map.swift`, `MediaFiles/iBurn2025MediaFiles.swift`. +- Root `Package.swift`: products/targets `iBurn2025*` → `iBurn2026*`, `path:` → `data/2026/...`. + +### A10. Local commit (submodule) — **no push** + +--- + +## Workstream B — App repo + +Modeled on last year's commits (`947c4f5` "Working on 2024" — the canonical rollover; `fe76662` "2025 data" — final submodule bump; `a552efd` — embargo key; `61a3b46` — repo flip): + +1. **`iBurn/YearSettings.plist`** — `PlayaYear` `2026`; `EventStart` `2026-08-30T07:00:00Z`; `EventEnd` `2026-09-07T07:00:00Z` (midnight PDT convention, matching 2025's values); `ManCenterLatitude` `40.783242`, `ManCenterLongitude` `-119.207871`. Embargo unlock date derives from `EventStart` automatically (`BRCEventObject.m:153` → `BRCEmbargo.m:47`). +2. **`iBurn/Bundle+iBurn.swift`** — `import iBurn2025APIData/Map/MediaFiles` → `iBurn2026*` (≈12 references). +3. **`iBurn.xcodeproj/project.pbxproj`** — three `XCSwiftPackageProductDependency` names → `iBurn2026*`; `MARKETING_VERSION` `2025.5` → `2026.0`. (Watch for the known DEVELOPMENT_TEAM pbxproj dirtying — revert any team flip before committing.) +4. **`Packages/PlayaAPI/Package.swift` & `Packages/PlayaDB/Package.swift`** — test dependency product → `iBurn2026APIData`. +5. **`iBurn/BRCDatabaseManager.m:30-31`** — `iBurn-2026.sqlite` / `iBurn-2026` folder (forces clean rebuild). +6. **`iBurn/NSUserDefaults+iBurn.m`** — `kBRCEntered2025EmbargoPasscodeKey` → `...2026...` (re-arms embargo). +7. **`iBurn/BRCArtObject.m:122`** — default year `2026`. +8. **`iBurn/BRCSecrets.m`** (untracked, local) — new `kBRCEmbargoPasscodeSHA256Hash` when BMorg issues the 2026 passcode (deferred; keep old hash until then). Verify `kBRCUpdatesURLString` will serve the 2026 `update.json` (no year string embedded in the file today). +9. **Embargo policy check** — 2025 removed camp/event embargo keeping art-only (`21b5794`). Confirm desired 2026 behavior; moot until location data exists, revisit in August. +10. **Tests/fixtures** — update `iBurn2025APIData` imports and 2025-hardcoded dates/years in: `Packages/PlayaAPI/Tests/**` (esp. `BundleDataIntegrationTests`), `Packages/PlayaDB/Tests/**` (esp. `PlayaDBRealDataTests` — record-count assertions must change to 2026 counts: 321/1201/2140/499), `iBurnTests/**` (`BRCDataSorterTests`, `RightNowCandidateTests`, `NearbyCardViewModelTests`, etc.), `MockServices.swift`, SwiftUI previews. +11. **`Package.resolved` + submodule pointer** — after the submodule commit. + +--- + +## Workstream C — Verification + +1. `xcodebuild build` (iPhone 17 Pro Max, OS 26.2 sim) via xcsift — clean compile with renamed packages. +2. Run app in sim: fresh DB import (expect 321 art / 1201 camps / 2140 events / 499 MVs), map centered on new Man location with 2026 streets (Ararat→Kundalini), POIs render, no camp outline layer errors, event list spans Aug 30–Sep 7, gate countdown correct, embargo screen active. +3. Test suites: `iBurnTests`, `PlayaKitTests`, PlayaAPI + PlayaDB package tests — all green after fixture updates. +4. Geometry spot-check: geocode a few addresses via BRCP tests (`npm test`), e.g. "9:00 & Ararat", "Center Camp Plaza". + +--- + +## Deferred (August 2026, as data lands) + +- Re-fetch API with `fetch_and_geocode.js -y 2026` once location data unlocks (needs `BMORG_API_KEY`); geocode camps; re-verify event types. +- Official 2026 GIS tiles when `burningmantech/innovate-GIS-data` publishes 2026 (redo tippecanoe per `Docs/2025-07-19-map-tiles-official-data.md`). +- 2026 placement geojson → regenerate `camp_labels.geojson` / `camp_outlines.geojson`. +- 2026 media files + audio tour; 2026 embargo passcode hash; `.gitmodules` private/public flip decisions; `credits.json` refresh. + +## Open questions / decisions taken + +- **Rename data targets to `iBurn2026*`** (matches every prior year's pattern) rather than keeping 2025 names — requires the coordinated app-side renames in B2–B4. +- **Empty camp label/outline layers** for launch (placement data doesn't exist yet). +- **2:00/10:00 B Plazas + double-wide I–K end blocks**: city-plan page says yes; measurements PDF is silent — verify against the map PDF while editing layout.json. +- **Media**: download 2026 thumbnails during A8 vs. ship-empty-first — either works; plan assumes download now, fallback to empty. + +## Context: how last year's update actually went (git archaeology) + +- **iBurn-Data**: `728172c` "2025 template" (copy prior year, Jun 28) → `7ab12a5` "2025" (layout.json edit + full geometry regen + tiles, Jun 28) → July: bundle restructure (`d01b186`), swiftpm (`e38bd7d`), tiles (`dfd4030`), geocoder bundle.js (`fdf039c`), org data (`d9bb5be`), location mocker (`601e629`) → August: repeated "Pull fresh API data" + geocoder fixes + media/audio commits → `6a79c73` placement geojson + camp layers (Aug 23–24) → `9dbe557` final data (Aug 24). +- **App repo**: year-flip file set per `947c4f5`; season of point releases `2025.1`→`2025.5`; `21b5794` embargo relaxation; `fe76662`/`d9d816a` data bumps; `61a3b46` "Use public repo again" post-event. +- Takeaway: the rollover is a June/July structural pass (exactly this plan), then an August cadence of data refreshes. diff --git a/Docs/2026-07-03-playadb-audit-and-improvements.md b/Docs/2026-07-03-playadb-audit-and-improvements.md new file mode 100644 index 00000000..63ffbbfc --- /dev/null +++ b/Docs/2026-07-03-playadb-audit-and-improvements.md @@ -0,0 +1,225 @@ +# PlayaDB Audit: Correctness & Performance Improvements + +**Date:** 2026-07-03 (Pacific) +**Branch:** `2026-updates` +**Related:** `2026-04-06-grdb-performance-optimization.md`, `2026-05-09-events-hour-index-and-fts.md`, `2026-05-17-event-list-day-tab-perf-round-2.md`, `2026-07-03-2026-year-update-plan.md` + +## High-Level Plan + +Full audit of `Packages/PlayaDB` (GRDB implementation, importer, query layer, observations) for correctness and performance, followed by staged fixes. Baseline: `swift test` in `Packages/PlayaDB` green (146 tests) before changes. + +### Audit Findings + +#### Correctness + +1. **FTS5 external-content triggers are wrong** (`PlayaDBImpl.setupFTS5Tables`). The + `*_ad` / `*_au` triggers use `DELETE FROM WHERE rowid = old.rowid`. For + external-content FTS5 tables (`content=`), delete/update must use the special + `INSERT INTO fts(fts, rowid, ) VALUES('delete', old.rowid, old.)` command + because FTS5 needs the *old* column values to remove index entries — with the plain + DELETE it reads the content table where the row is already gone/changed, silently + corrupting the index. Currently masked because import ends with a full `rebuild`, + but any out-of-import UPDATE/DELETE corrupts search. +2. **Occurrence favorite identity mismatch.** `EventObjectOccurrence.uid` is a synthesized + `"_"`. `setLastViewed` maps occurrence → parent event uid, but + `toggleFavorite` / `setFavorite` / `isFavorite` / `metadata(for:)` do **not**. Favoriting an + occurrence writes metadata under the synthesized uid, which: + - the JOIN path (`eventObjectOccurrencesJoined`, used by `observeEventsByDayThenHour`) + never matches (its SQL EXISTS checks `event_occurrences.event_id` only), and + - `observeListRows` metadata batch-fetch (keyed by `event.uid`) never inflates → stale hearts. + The non-JOIN path checks both uids (`PlayaDBImpl.swift:1208-1217`), so the two code paths + disagree. App-side is currently safe by accident (dual-write mirrors favorites via + `fetchEvent(uid:)` → EventObject), but the PlayaDB API itself is a footgun. +3. **`observeEvents` tracked regions omit `object_metadata`/`thumbnail_colors`** even though + its fetch reads both for ListRow inflation. Favorite toggles and color-cache writes don't + re-fire the observation → stale hearts/colors in event lists and the favorites-only map + annotation layer (`PlayaDBAnnotationDataSource` uses `observeEvents(onlyFavorites:)`). + `observeEventsByDayThenHour` deliberately includes them; art/camp/MV observations + auto-track everything. Inconsistent semantics. +4. **Retain cycle + dead API in `setupObservations`.** The event observation captures + `self` strongly while `self.observations` retains the cancellable → `PlayaDBImpl` can never + deinit. Worse, the 5 always-on full-table observations back `allArt/allCamps/allEvents/ + allMutantVehicles/favorites` which are **unused by the app** (verified via grep) — they + re-fetch entire tables on every write and fire `ensureMetadata` storms. +5. (Minor) SwiftUI previews build ad-hoc `try! createPlayaDB()` instances + (`CampListView.swift:163`, `ArtListView.swift:224`) — second connections to the on-disk + DB. Preview-only; low priority. + +#### Performance + +6. **`DatabaseQueue` instead of `DatabasePool`/WAL.** Single serialized connection: every + read blocks behind writes. First-launch seed import is one giant write transaction, so + all UI reads stall until it finishes; `ensureMetadata` write bursts serialize all list + observations. `DatabasePool` gives concurrent WAL readers + GRDB's observation fast path. +7. **Read paths perform writes.** Nearly every fetch/observe calls `ensureMetadata`, + pre-populating blank `object_metadata` rows (~10k rows on first launch). ListRow already + tolerates nil metadata (`observeEventsByDayThenHour` sets `skipEnsureMetadata: true` for + exactly this reason). Blank prepopulation should be removed everywhere; metadata should be + created lazily on actual writes only. +8. **Import inefficiencies** (`importFromData`): per-event `CampObject.fetchOne` / + `ArtObject.fetchOne` for GPS denormalization (≈2 × 8k point queries); spatial index rebuild + fetches full model objects then inserts row-by-row instead of `INSERT INTO … SELECT`; + FTS triggers do per-row indexing during bulk insert even though a full `rebuild` follows. +9. **Missing `end_time` index.** `happeningNow` / `notExpired` / `activeWindow` all filter on + `event_occurrences.end_time`; only `start_time` is indexed. +10. **No `removeDuplicates()`** on any observation — identical result sets re-emit and reload + UI (e.g. `setLastViewed` on a *camp* re-runs + re-emits the full 8k-row event JOIN because + byDayThenHour tracks the whole `object_metadata` table). +11. (Minor) `fetch*ImageURLs` load all image rows then dedupe in memory — `GROUP BY mv_id` + with `MIN(id)` would do it in SQL. +12. (Minor) Schema managed with ad-hoc `CREATE TABLE IF NOT EXISTS` + column checks instead of + `DatabaseMigrator` — works, but fragile as migrations accumulate. + +### Phased Fix Plan (task list mirrors this) + +- **Phase 1 — Correctness:** FTS triggers (#1), favorite identity normalization (#2), + observeEvents regions (#3), remove dead reactive props + retain cycle (#4). +- **Phase 2 — Concurrency core:** DatabasePool/WAL (#6), remove ensureMetadata from read + paths (#7). +- **Phase 3 — Query/index:** end_time index + EXPLAIN QUERY PLAN pass (#9), image URL + GROUP BY (#11), removeDuplicates where profitable (#10). +- **Phase 4 — Import:** batch GPS resolution, SQL-side spatial rebuild, FTS trigger churn (#8). +- Each phase: `swift test` in `Packages/PlayaDB` + new targeted tests; app build via + xcodebuild at the end. + +## Technical Details + +### Key context + +- App integration: single shared instance via `DependencyContainer` (`iBurn/DependencyContainer.swift:69-71`), + seeded once per install by `PlayaDBSeeder.seedIfNeeded()` (bundle JSON only; network updates + still go through legacy YapDatabase/`BRCDataImporter` — dual-database migration in progress). +- Favorites are dual-written Yap → PlayaDB by uid (`DetailDataService.syncFavoriteToPlayaDB`, + `BRCDataObjectTableViewCell`). +- Consumers: `*DataProvider` (SwiftUI lists), `PlayaDBAnnotationDataSource` (map), + `PlayaSearchTools`/`RightNowWorkflow` (AI), detail screens, deep links, global search. +- `Packages/PlayaDB` GRDB pin: `.upToNextMajor(from: "7.6.1")`. +- Test data: `iBurn2026APIData` bundle from `Submodules/iBurn-Data`. + +### Canonical FTS5 external-content trigger form (fix for #1) + +```sql +CREATE TRIGGER art_objects_ad AFTER DELETE ON art_objects BEGIN + INSERT INTO art_objects_fts(art_objects_fts, rowid, uid, name, description, artist, hometown, category) + VALUES('delete', old.rowid, old.uid, old.name, old.description, old.artist, old.hometown, old.category); +END; +-- _au = 'delete' with old values, then plain INSERT with new values +``` + +Existing wrong triggers must be dropped (CREATE TRIGGER IF NOT EXISTS won't replace them), +then recreated in the new form; follow with an FTS `rebuild` for any DB that may have +corrupted index state. + +## Progress Log + +- 2026-07-03: Audit complete (impl read end-to-end; app integration mapped; findings above). + Baseline `swift test` green. Starting Phase 1. +- 2026-07-03 (later): **All phases implemented.** 162 tests green (was 157 baseline; +9 new, + −4 rewritten). Suite runtime halved (~14s → ~7.5s) from the import rewrite; full 2026 + dataset import now ~0.3s inside one transaction. + +### Changes landed (all in `Packages/PlayaDB`) + +1. **FTS5 triggers** (`PlayaDBImpl.setupFTS5Tables/setupFTS5Triggers`): rewritten data-driven + (`ftsTableConfigs`) with canonical external-content `'delete'`-command triggers. Legacy + plain-DELETE triggers detected via sqlite_master and replaced + index rebuilt once on + open. New `FTSTriggerTests` (5 tests incl. corruption→migration→rebuild end-to-end). +2. **Metadata identity** (`metadataIdentity(for:)`): toggleFavorite/setFavorite/isFavorite/ + metadata(for:)/setUserNotes/setLastViewed/clearLastViewed all normalize + EventObjectOccurrence → parent event uid. `migrateOccurrenceKeyedMetadata` folds legacy + `"_"` rows into parent rows on open (OR favorite, min/max viewed dates, + coalesce notes). Non-JOIN onlyFavorites filter now matches JOIN path (event uid only). + This fixed a live bug: `EventDataProvider.toggleFavorite` passes occurrences directly. + New `MetadataIdentityTests` (4 tests). +3. **observeEvents regions**: now include `ObjectMetadata` + `ThumbnailColors` (was event + tables only → stale hearts / favorites map layer). 2 new regression tests in + `FilterObservationTests`. +4. **Removed dead reactive API**: `allArt/allCamps/allEvents/allMutantVehicles/favorites` + props + `setupObservations()` (5 always-on full-table observations, ensureMetadata storms, + and a `[self]` retain cycle) deleted from protocol + impl. No app callers existed. +5. **DatabasePool/WAL**: `dbQueue` is now `any DatabaseWriter` — DatabasePool for on-disk + paths (concurrent WAL reads; seed import no longer blocks UI), DatabaseQueue retained for + `:memory:` test databases. Test helpers updated to `any DatabaseWriter`. +6. **Read paths are write-free**: all ensureMetadata calls removed from fetch/observe paths + (blank-row prepopulation, ~10k rows first launch). Metadata created lazily only by actual + writes. Two tests rewritten to assert reads create no rows. +7. **Indexes**: added `idx_event_occurrences_end_time` (notExpired/happeningNow/activeWindow). + New `QueryPlanTests` with EXPLAIN QUERY PLAN assertions on hot queries. Image URL fetches + (`fetch*ImageURLs`) now aggregate first-thumbnail-per-object in SQL (GROUP BY + MIN(id)). +8. **Import**: per-event camp/art GPS `fetchOne`s replaced with dictionaries built during + insert (~16k point queries eliminated); spatial index rebuilt via set-based + INSERT…SELECT; occurrence R*Tree rebuild likewise; FTS/spatial sync triggers dropped for + the bulk phase and recreated before commit (rebuild was already wholesale); duplicate-UID + warnings aggregated to one line; import duration logged. +9. **removeDuplicates()** on all list observations (observeListRows) + user map pins + + update info. Added `Equatable` to ArtObject/CampObject/EventObject/EventOccurrence/ + MutantVehicleObject/ObjectMetadata/UserMapPin/UpdateInfo, manual `==` for + EventObjectOccurrence (existential host compared by concrete value), conditional + `ListRow: Equatable`. Unrelated metadata writes no longer re-emit identical 8k-row arrays. + +### Verification +- `swift test` in `Packages/PlayaDB`: 162 passed, 0 failed (4 skipped, pre-existing). +- Full app `xcodebuild` (iPhone 17 Pro Max sim): **succeeded**, 0 errors, only pre-existing + warnings (unrelated to these changes). Uncommitted on `2026-updates` pending review. + +### Follow-up round (same day, committed separately after ed0b174) + +10. **Narrowed metadata observation regions.** All list observations now track + `ObjectMetadata.select(object_type, object_id, is_favorite, user_notes)` instead of the + whole table (`listMetadataRegion` helper), and art/camp/MV observations moved from + auto-tracking to explicit regions (own table + narrowed metadata + colors; + + `event_objects` when `onlyWithEvents`; + `mv_tags` when tag-filtered). Required + switching all metadata writers to **column-limited updates** + (`metadata.update(db, columns:)`) — GRDB's full-row `update(db)` touches every column, + which made even a last_viewed write intersect the is_favorite region. Result: viewing a + detail screen (setLastViewed) no longer re-runs *any* list query, including the 8k-row + event JOIN; favorite toggles and notes edits still re-fire. Two inverted-expectation + regression tests in `FilterObservationTests`. +11. **DatabaseMigrator adoption.** `setupDatabase` now registers the full current schema as + migration `v1-initial-schema` (idempotent DDL, so pre-migrator installs adopt cleanly) + and runs `migrator.migrate()`. FTS/R*Tree virtual tables + sync triggers, the occurrence + R*Tree backfill, and the occurrence-keyed metadata fold stay as open-time maintenance + (they carry self-repair logic and are data-dependent/idempotent; imports re-invoke the + trigger setup). Future schema changes are new numbered migrations — v1 must not be + extended. New `SchemaMigrationTests` covering fresh-install recording and pre-migrator + adoption with data preservation. + +Test count after follow-ups: **166 passed, 0 failed** (4 skipped, pre-existing). + +### Preview fix + simulator sanity pass (same day, commit 728e562) + +- **Preview providers** now use a shared in-memory PlayaDB (`createInMemoryPlayaDB()` package + factory + `PreviewPlayaDB.shared` in DependencyContainer.swift) instead of + `try! createPlayaDB()` on-disk connections. `.xcodebuildmcp/config.yaml` gained the + `ui-automation` workflow. +- **Simulator sanity pass** (fresh-install, iPhone 17 Pro Max sim, XcodeBuildMCP UI driving) + verified the audit changes end-to-end: + - Seed: WAL journal mode (DatabasePool ✓), 321 art / 1201 camps / 2101 events / + 4431 occurrences, FTS populated, **0 blank object_metadata rows** (write-free reads ✓), + `grdb_migrations` = v1-initial-schema ✓. + - Day-tab switching instant (pre-bucketed observation) ✓. + - Favoriting from event detail wrote exactly one metadata row keyed by the **parent event + uid** (identity fix ✓), the list heart lit up immediately (region fix ✓), and the + Favorites tab showed the event's occurrences ✓. + - FTS on the live DB: MATCH 'taco' → 12 stemmed/case-insensitive hits, integrity-check + passes, triggers confirmed canonical 'delete'-command form ✓. +- **Two behavioral findings:** (1) the entire SwiftUI/PlayaDB stack is behind the DEBUG-only + `featureFlag.lists.useSwiftUI` flag (default false) — fresh installs never create + PlayaDB.sqlite until it's enabled; (2) seeding runs when the DependencyContainer is first + built (tab construction after onboarding), not at app launch. +- **Pending:** RenderPreview validation of the Art/Camp list previews via the `xcode` MCP — + server tool fetch timed out while Xcode was launching; retry `/mcp` reconnect with Xcode + fully loaded. + +### Remaining / follow-up candidates (not done) +- PlayaDB is still bundle-seeded only; network updates flow through legacy YapDatabase. + Unifying update ingestion is a larger project (see 2026-01-25 roadmap doc). + +## Expected Outcomes + +- Search index stays correct under row updates/deletes outside import. +- Favoriting an occurrence and favoriting its event agree across all query paths. +- Event lists/map refresh on favorite + color-cache writes. +- No always-on full-table observations; PlayaDBImpl can deinit. +- Reads never block on the seed import; first-launch UI responsive during import. +- Fewer redundant observation emissions; hot event queries fully indexed. diff --git a/Docs/2026-07-03-watchos-mvp-plan.md b/Docs/2026-07-03-watchos-mvp-plan.md new file mode 100644 index 00000000..332b60fe --- /dev/null +++ b/Docs/2026-07-03-watchos-mvp-plan.md @@ -0,0 +1,300 @@ +# watchOS MVP App — Plan + +Date: 2026-07-03 (Pacific) +Branch: `2026-updates` +Status: ALL phases complete and sim-verified. Phase 2 (WatchConnectivity +favorites sync) landed 2026-07-11 — see +`2026-07-11-swiftui-lists-default-on.md` Session 2 for design + E2E results. +Remaining: follow-ups only (complication, events UI, embargo passcode). +See "Phase 3/4 Results" for the spatial-index UPDATE-trigger gap found in +PlayaDB (fixed same day). + +## High-Level Plan + +Build a **standalone-capable watchOS 26 app** for iBurn. MVP pillars, in order: + +1. **Map + compass (P0):** show yourself on an offline map of Black Rock City with + compass rotation (heading-up mode) and a calibration hint when heading accuracy + degrades. +2. **Favorites (P1):** full favorites list on the watch, synced bidirectionally with + the phone via WatchConnectivity. +3. **Nearby (P1):** list of closest art/camps sorted by GPS distance (list-first, not + a card like the phone's map). +4. **POI navigation (P1):** from a camp/art detail, a navigation view showing the POI + and your location on the map in compass mode with distance/bearing readout. + +Development style: **preview-driven** — every view ships with `#Preview`s backed by +mock data / in-memory PlayaDB, validated via `mcp__xcode__RenderPreview` before +on-device runs. + +## Decisions (user-confirmed 2026-07-03) + +1. **Map rendering: custom SwiftUI Canvas vector map.** + MapLibre Native does not support watchOS — upstream issue + [maplibre-native#12](https://github.com/maplibre/maplibre-native/issues/12) is + marked *wontfix*, and the SPM xcframework (`maplibre-gl-native-distribution`) + ships iOS slices only. Instead we draw BRC directly from the bundled GeoJSON + (streets/fence/plazas/toilets, ~300 KB) in a SwiftUI `Canvas`. Fully offline, + trivially rotatable (affine transform we own), theme-able, preview-friendly. + Rejected alternatives: pre-rendered raster snapshot (blurry, no dynamic layers), + building MapLibre for watchOS from source (wontfix upstream, unproven + Metal/memory profile on watch, permanent fork). +2. **Data: full standalone PlayaDB seed on watch.** + Bundle the same `iBurn2026APIData` JSON (3.4 MB) and run the existing + `PlayaDBSeeder` + GRDB on-watch. Skip MediaFiles/thumbnails. The watch works 100% + without the phone; WatchConnectivity syncs only the tiny favorites/metadata + deltas. Rejected: favorites-only synced subset (watch useless until first sync, + no browsing/nearby). +3. **Favorites access: top-level in-app list for MVP.** WidgetKit complication is a + follow-up, not MVP. + +## Research Facts (2026-07-03) + +### Platform / APIs +- **MapLibre:** no watchOS support (wontfix; iOS-only xcframework slices). +- **Compass:** `CLLocationManager.startUpdatingHeading()` is available on watchOS 6+; + every watchOS 26 device (Series 6+/SE2+/Ultra) has compass hardware. + `locationManagerShouldDisplayHeadingCalibration` (system figure-8 UI) is + **iOS-only** — on watchOS we monitor `CLHeading.headingAccuracy` (negative = + uncalibrated) and show our own "wave your wrist in a figure-8" hint. +- **GRDB:** supports watchOS. **MapKit types** (`MKCoordinateRegion` used by + `FilterRegion`) exist on watchOS. + +### Repo facts (from exploration) +- `iBurn.xcodeproj` has exactly two targets (`iBurn`, `iBurnTests`). No extension + targets exist yet. CocoaPods covers the iOS app only (legacy Obj-C/UIKit pods, + iOS-only) — the **watch target must be SPM-only**. +- SPM local packages: `Packages/PlayaAPI` (pure Foundation, models + + `BundleDataLoader`), `Packages/PlayaDB` (GRDB 7.x). Both declare + `platforms: [.iOS(.v16), .macOS(.v13)]` — need `.watchOS` added. Same for + `Submodules/iBurn-Data/Package.swift` (resource bundles `iBurn2026APIData` 3.4 MB, + `iBurn2026Map`, `iBurn2026MediaFiles`). +- Seeding: `DependencyContainer` → `PlayaDBSeeder.seedIfNeeded()` → + `BundleDataLoader.load*(from: .brc_dataBundle)` → `PlayaDBImpl.importFromData` + (~0.3 s on iPhone sim). Reusable as-is on watch. +- Nearby: no explicit `fetchNearby` API — a bounding-box `FilterRegion` on + `ArtFilter`/`CampFilter`/`EventFilter` hits the R*Tree + (`spatial_index`/`event_occurrence_rtree`); distance sort is client-side + (`NearbyViewModel`, 500 m default region). +- Favorites: `object_metadata` table (`object_type`, `object_id`, `is_favorite`, + `user_notes`, timestamps incl. `updated_at`). **No sync mechanism exists anywhere** + (zero hits for WatchConnectivity/CloudKit). +- GeoJSON inventory (`Submodules/iBurn-Data/data/2026/geo/`): `streets.geojson` + 244 K, `polygons.geojson` 176 K, `toilets.geojson` 60 K, `points.geojson` 8 K, + `fence.geojson`/`dmz.geojson` ~1–2 K, `outline.geojson` 776 K (big — simplify or + skip; fence suffices for the boundary). These raw files are *not* currently + bundled at runtime (the phone uses `map.mbtiles`); the watch will bundle the geo + files it needs. +- iOS deployment target 16.6; local packages swift-tools 5.9. + +## Architecture + +### New pieces +- **`Packages/PlayaGeo`** (new local SPM package, platforms iOS/watchOS/macOS): + - GeoJSON parsing into typed geometry (polylines/polygons/points). + - Local planar projection (equirectangular around city center — fine at BRC + scale), `MapCamera` (center/zoom/rotation) math, viewport culling. + - `PlayaMapView`: SwiftUI `Canvas` renderer (streets, fence, toilets, POI dots, + user location dot + heading cone). Pure SwiftUI → previewable on any platform, + unit-testable camera/projection math via `swift test` on macOS. + - Reason it's a package: preview-driven dev + fast mac-side tests without + building the watch target. +- **`iBurnWatch` target** (watchOS app, SwiftUI lifecycle, watchOS 26, SPM deps + only: PlayaDB, PlayaAPI, PlayaGeo, iBurn2026APIData + bundled geo resources). + Embedded in the iOS app (single App Store listing), but `WKRunsIndependentlyOfCompanionApp` + so it runs standalone. + - `WatchDependencyContainer`: builds PlayaDB at watch Documents path, runs + `PlayaDBSeeder` (shared logic — may need a small move of `PlayaDBSeeder` into a + package or a watch copy; prefer moving seeding into `PlayaDB`/`PlayaAPI` so both + apps share it). + - `LocationService`: CLLocationManager wrapper exposing async streams for + location + heading, `headingAccuracy`-based calibration state. + - Root navigation: Map (launch screen) / Favorites / Nearby. +- **`WatchSync` (WatchConnectivity bridge)**, both sides: + - Payload: favorites snapshot `[(objectType, objectId, isFavorite, updatedAt)]` + (+ embargo-unlocked flag) via `updateApplicationContext` (last-state) plus + `transferUserInfo` for reliability on individual toggles. + - Merge: per-item last-writer-wins on `updated_at`. New PlayaDB API: + `applyFavoriteSync(_ items:)` that only writes rows whose incoming + `updatedAt` is newer (column-limited updates, consistent with region-narrowed + observations). + - Phone side: a `WatchSessionManager` in `DependencyContainer` observing + favorites changes and pushing; applies incoming watch toggles. + +### Embargo on watch +Camp/art locations are restricted until gates open. MVP: watch defaults to +restricted; the phone syncs its embargo-unlocked state over WatchConnectivity +(no passcode entry UI on watch). Until unlocked, map shows city geometry + toilets ++ user location but no camp/art pins; nearby rows show "Location Restricted" like +the phone lists. + +## Phases + +### Phase 0 — Foundations (build plumbing) +1. Add `.watchOS("26.0")` (or the tools-supported spelling) to `platforms` in + `Packages/PlayaAPI`, `Packages/PlayaDB`, `Submodules/iBurn-Data` Package.swift. + Verify `PlayaDB` compiles for a watchOS destination (watch-sim build of a tiny + consumer, or `xcodebuild -destination 'generic/platform=watchOS Simulator'`). +2. Create `iBurnWatch` watchOS app target. Hand-editing pbxproj is error-prone — + use a Ruby script with the `xcodeproj` gem (already present via CocoaPods) to + add the target, build settings (SDKROOT watchos, `WATCHOS_DEPLOYMENT_TARGET=26.0`, + `TARGETED_DEVICE_FAMILY=4`, bundle id `com.trailbehind.iBurn2010.watchkitapp`), + SPM product links, and the Embed Watch Content phase on `iBurn`. +3. Smoke: "Hello Playa" watch app builds + runs in watch simulator; PlayaDB seeds + on-watch (verify row counts via simctl container + sqlite3, same as drive-app + skill). + +### Phase 1 — Map + compass (P0) +1. `PlayaGeo` package: GeoJSON decode, projection, `MapCamera`, culling; unit tests + for projection/camera math. +2. Bundle trimmed geo resources (streets, fence, toilets, points; skip/simplify + outline.geojson). +3. `PlayaMapView` Canvas renderer + previews (mock geometry, fixed camera states: + north-up, rotated, zoomed). +4. Watch `MapScreen`: user dot + heading cone, Digital Crown zoom, drag pan, + north-up ⇄ heading-up toggle, recenter button, calibration hint banner when + `headingAccuracy < 0` or > threshold. +5. Validate previews via RenderPreview; then on watch simulator with simulated + location (40.7864, -119.2065). + +### Phase 2 — Favorites (P1) +1. Move/share seeding so watch reuses it; watch `FavoritesScreen` (name, type + emoji, distance; tap → detail). Previews with in-memory PlayaDB + (`createInMemoryPlayaDB()` + mock favorites). +2. `applyFavoriteSync` API + tests in PlayaDBTests (LWW merge semantics). +3. WatchConnectivity bridge both sides + embargo flag sync. Manual end-to-end: + favorite on phone sim → appears on watch sim (paired sims), and reverse. + +### Phase 3 — Nearby (P1) +1. `NearbyScreen`: reuse `FilterRegion` bounding-box observe + client distance + sort (mirror `NearbyViewModel` shape, simplified). Rows: type emoji, name, + distance, relative bearing arrow. +2. Previews with mock rows at known offsets; sim validation with simulated + location. + +### Phase 4 — POI detail + navigation (P1) +1. `DetailScreen` (camp/art): name, description, location string (embargo-aware), + favorite toggle (heart), "Navigate" button. +2. `NavigationScreen`: `PlayaMapView` fit-to-bounds(user, POI), compass mode + forced on, distance + bearing readout, straight-line path. + +### Follow-ups (explicitly NOT MVP) +- WidgetKit complication (favorites / next favorited event) — needs App Group. +- Events on watch (data is seeded already; UI deferred). +- Notifications/local reminders for favorited events; watch-side embargo passcode + entry; breadcrumb trails. + +## Phase 0 Results (2026-07-03) + +- watchOS 26.5 platform was a stub in Xcode 26.5 — downloaded via + `xcodebuild -downloadPlatform watchOS` (required before any watch destination + resolves; `-showsdks` listing the SDK is not sufficient). +- Added `.watchOS(.v10)` to `Packages/PlayaDB`, `Packages/PlayaAPI`, and + `Submodules/iBurn-Data` Package.swift (submodule commit `6bc923e`). +- Created `iBurnWatch` target via `xcodeproj` gem script (not hand-edited pbxproj): + standalone watch app (`INFOPLIST_KEY_WKApplication`/`WKWatchOnly`, generated + Info.plist, bundle id `com.trailbehind.iBurn2010.watchkitapp`, + `WATCHOS_DEPLOYMENT_TARGET=26.0`, `TARGETED_DEVICE_FAMILY=4`), SPM products + PlayaDB + PlayaAPI + iBurn2026APIData, shared scheme `iBurnWatch`. + ~~Deliberately NOT embedded in the iOS app target yet~~ **Embedded 2026-07-03** + (user decision): iBurnWatch is now a companion app inside iBurn.app — + `WKCompanionAppBundleIdentifier=com.trailbehind.iBurn2010`, + `WKRunsIndependentlyOfCompanionApp=YES` (still fully standalone-capable), + `WKWatchOnly` removed, versions aligned to the container (2026.0 / 108), + iBurn target got a dependency + "Embed Watch Content" copy phase. Verified: + installing iBurn.app on the paired iPhone 17 Pro Max sim auto-installed the + watch app on the paired Series 11, where it launched and rendered the map. + **CI consequence:** every iOS build now also builds the watch target, so CI + runners need the watchOS platform/SDK (GitHub macOS images bundle it, but + older pinned Xcode setups may need `xcodebuild -downloadPlatform watchOS`). + This unblocks Phase 2 WatchConnectivity (real pairing now exists). +- Watch sources: `iBurnWatch/iBurnWatchApp.swift` (creates PlayaDB via + `createPlayaDB()`), `iBurnWatch/ContentView.swift` (Phase 0 smoke screen: + seeds from `iBurn2026APIData.bundle` via `BundleDataLoader` + + `importFromData` when `getUpdateInfo()` is empty, then shows counts; includes + an in-memory-DB `#Preview`). +- **Verified on Apple Watch Ultra 3 (49mm) sim (watchOS 26.5, UDID + `73FEA1F2-69EB-4A7E-AAD1-3613B88D8F30`):** app launches, seeds on-watch, shows + `321 art / 1201 camps` — matching the 2026 dataset counts from the iPhone app. +- Build command: + `xcodebuild -workspace iBurn.xcworkspace -scheme iBurnWatch -destination 'generic/platform=watchOS Simulator' build 2>&1 | xcsift -f toon -w` + +## Phase 1 Results (2026-07-03) + +- **`Packages/PlayaGeo`** created (iOS 16 / macOS 13 / watchOS 10, zero deps): + - `GeoJSON.swift` — FeatureCollection decoder into `GeoFeature`/`GeoGeometry` + with its own `GeoCoordinate` (no CoreLocation dependency; null-geometry + features skipped). + - `PlayaProjection.swift` — equirectangular meters around The Man; +x east, + +y south (screen-down) so north-up needs no flip. WGS84 local scale factors; + sub-meter accurate at city scale. + - `MapCamera.swift` — center/metersPerPoint/headingDegrees + world→screen + `CGAffineTransform`, rotation-aware `centerAfterPan`, `fitting(points:)`. + - `PlayaMapView.swift` — SwiftUI Canvas renderer: plaza fills, streets with + true-meter widths, dashed fence, toilets (zoom-gated), `MapMarker`s, user + dot + heading cone; light/dark styles via colorScheme. + - 18 tests green (`swift test`), including decoding the real 2026 geo files and + camera-math directional assertions. One test expectation was initially wrong + (pan under rotation): facing east means the screen-bottom is west, so + dragging up moves the center −x; code was correct. +- Watch target wiring (2nd xcodeproj-gem script): PlayaGeo local package + + product, GeoJSON resources referenced **in place** from + `Submodules/iBurn-Data/data/2026/geo/` (points/streets/fence/toilets/polygons — + no duplication; year rollover means re-pointing these refs), + `INFOPLIST_KEY_NSLocationWhenInUseUsageDescription`. +- Watch UI: `LocationService` (async location + heading, `needsCalibration` when + headingAccuracy < 0 or > 45° — watchOS can't summon the system figure-8 UI, so + MapScreen shows a hint banner), `MapScreen` (crown zoom 50→0.8 m/pt, drag pan + honoring rotation, heading-up toggle, follow-user with recenter, The Man / + Center Camp markers), root vertical-page TabView (Map, DB-status page). +- **Verified on Ultra 3 sim:** location alert (buttons require swiping the alert + up), city renders (pentagon fence, radial grid, plazas, user dot at simulated + BRC location, markers, controls); compass toggle flips state without crash + (sim has no compass hardware). iOS app still builds after pbxproj changes. + +## Phase 3/4 Results + watch-local favorites (2026-07-03) + +- Root restructured from paging TabView to **NavigationStack**: `.verticalPage` + paging uses crown + vertical swipes, which the map already claims for zoom/pan. + Map is fullscreen root; toolbar buttons (topBarLeading "Nearby", + topBarTrailing "Favorites") navigate. Old ContentView smoke page removed; + seeding moved to `WatchSeeder.seedIfNeeded` run from an app-level `.task`. +- New screens (all in `iBurnWatch/`): `NearbyScreen` (region-filtered + `fetchArt`/`fetchCamps` around user, client distance sort, 30-row cap, + embargo-aware empty state), `FavoritesScreen` (`getFavorites()`, distance + sort), `DetailScreen` (favorite toggle via `setFavorite`, description, + Navigate link gated on `hasLocation`), `NavigationScreen` (PlayaMapView fit to + user+target, heading-up when compass exists, live distance/bearing readout, + calibration hint). +- **Sim-verified end-to-end** (test GPS injected into 3 camps via sqlite3, app + uninstalled afterward to purge): Nearby sorted 309 m / 590 m / 1.1 km → + detail → Add Favorite → `object_metadata` row `camp||1` → Navigate view + showed target marker + user dot + "309 m · 55°" → Favorites listed the camp. +- **PlayaDB finding (FIXED same day):** `spatial_index` R*Tree was only + maintained by import-time rebuild + insert/delete triggers — UPDATE of gps + columns left it stale, so region queries missed rows whose GPS changed + in-place (would have bitten when the embargo drop arrives as an update). + Fixed with `*_spatial_update` triggers (art/camp/event; delete-then- + conditionally-reinsert keyed via the mapping table, not last_insert_rowid) + plus `event_occurrence_rtree_event_update` refreshing the denormalized + occurrence R*Tree when an event's GPS changes. Trigger generation refactored + into a data-driven loop; existing DBs pick the new triggers up on next open + (CREATE TRIGGER IF NOT EXISTS). Covered by + `SpatialIndexUpdateTests` (gain/move/clear GPS, no duplicate rows, occurrence + propagation, trigger presence); full 172-test suite green. +- Embargo on watch today: no GPS in bundle → Nearby shows explanatory empty + state; Detail shows "Location hidden until gates open" instead of Navigate. + +## Verification strategy +- Preview-driven: every screen has `#Preview`s (including loading/empty/restricted + states) rendered via `mcp__xcode__RenderPreview` before simulator passes. +- `PlayaGeo` math under `swift test`; PlayaDB sync-merge under PlayaDBTests. +- Watch simulator flows get entries in `.claude/skills/drive-app/references/flows.md` + as they land (per skill maintenance rule). +- Commit after each validated phase chunk (per CLAUDE.md source-control policy). + +## Cross-references +- `Docs/2026-07-03-playadb-audit-and-improvements.md` — PlayaDB audit this branch + builds on (region-narrowed observations, column-limited metadata writes — the + sync merge must follow the same rules). +- `.claude/skills/drive-app/SKILL.md` — simulator driving + DB verification recipes. diff --git a/Docs/2026-07-04-events-empty-on-device-stale-seed.md b/Docs/2026-07-04-events-empty-on-device-stale-seed.md new file mode 100644 index 00000000..2ff0927e --- /dev/null +++ b/Docs/2026-07-04-events-empty-on-device-stale-seed.md @@ -0,0 +1,93 @@ +# 2026-07-04: Events List Empty on Device — Stale PlayaDB Seed + +## High-Level Plan + +**Problem:** After building the `2026-updates` branch to a physical phone, the Events list is empty while Art/Camps/MVs display fine. + +**Root cause:** Both `PlayaDBSeeder` (iOS) and `WatchSeeder` (watchOS) only imported bundled data when the database had *never* been seeded: + +```swift +let updateInfo = try await playaDB.getUpdateInfo() +guard updateInfo.isEmpty else { return } +``` + +A device that installed a dev build earlier in the year had PlayaDB seeded with **2025 data**. When the 2026 APIData bundle landed (`iBurn-Data` commit `3947cfb`, Jul 3 2026), the seeder saw a non-empty `update_info` table and skipped the import entirely. The result: + +- **Art/Camps/MVs:** stale 2025 rows, but they display fine (no time filtering) → "other data types work". +- **Events:** all 2025 occurrences ended Sep 2025, so the default `EventFilter(includeExpired: false)` (`EventListViewModel.swift:85`) → `notExpired()` (`endTime > now`) filters out *every* row → empty list. + +Fresh simulator installs (erased regularly) seeded 2026 data from scratch, masking the bug. + +**Solution:** Make seeding version-aware using the bundle's `update.json` per-type timestamps: + +1. `PlayaDB.importFromData` gained an `updateData: Data?` parameter. When provided, the per-type `updated` timestamps from `update.json` are stored as each type's `UpdateInfo.lastUpdated` (previously always import wall-clock time). Falls back to `Date()` when absent. +2. New protocol method `PlayaDB.needsImport(bundleUpdateData:) -> Bool` — true when the DB has never been seeded, when a bundle data type has no imported counterpart (e.g. `mv` added later), or when the bundle's timestamp for any type is newer than the stored `lastUpdated`. +3. `PlayaDBSeeder` and `WatchSeeder` call `needsImport` instead of `isEmpty`, and pass `updateData` through to the import. +4. `PlayaAPI.UpdateInfo` model gained the missing `mv: FileUpdateInfo?` field (2026 `update.json` includes an `mv` entry). +5. `DataUpdatesView.reimportPlayaDB()` also passes `updateData` now. + +Wall-clock fallback comparison stays correct: import time is always ≥ the data's publish time, so a legacy row (wall-clock `lastUpdated`) is re-imported iff the bundle ships genuinely newer data. + +## Technical Details + +### Files Modified + +- `Packages/PlayaAPI/Sources/PlayaAPI/Models/UpdateInfo.swift` — added `mv` field; included in `lastUpdated`/`hasUpdates`. +- `Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift` — protocol: `importFromData(...updateData:)` + `needsImport(bundleUpdateData:)`; convenience overload preserves the old 4-arg signature. +- `Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift` — `needsImport` implementation; import stores `update.json` timestamps as `lastUpdated`; `importFromPlayaAPI` loads `update.json` too. +- `iBurn/PlayaDBSeeder.swift` — staleness-aware seed gate. +- `iBurnWatch/WatchSeeder.swift` — same fix for the watch. +- `iBurn/DataUpdatesView.swift` — manual re-import passes `updateData`. +- `Packages/PlayaDB/Tests/PlayaDBTests/PlayaDBImportTests.swift` — 5 new tests: empty DB → needs import; same data → no re-import; newer events timestamp → re-import; new data type (mv) in bundle → re-import; `lastUpdated` stores bundle timestamp not wall-clock. + +### needsImport comparison logic (PlayaDBImpl) + +```swift +func needsImport(bundleUpdateData: Data) async throws -> Bool { + let bundleInfo = try APIParserFactory.create().parseUpdateInfo(from: bundleUpdateData) + let storedInfo = try await getUpdateInfo() + guard !storedInfo.isEmpty else { return true } + let storedByType = Dictionary(uniqueKeysWithValues: storedInfo.map { ($0.dataType, $0) }) + let bundleByType: [(DataObjectType, FileUpdateInfo?)] = [ + (.art, bundleInfo.art), (.camp, bundleInfo.camps), + (.event, bundleInfo.events), (.mutantVehicle, bundleInfo.mv) + ] + for (type, fileInfo) in bundleByType { + guard let fileInfo else { continue } + guard let stored = storedByType[type.rawValue] else { return true } + if fileInfo.updated > stored.lastUpdated { return true } + } + return false +} +``` + +Gotcha: inside PlayaDB, `PlayaAPI.FileUpdateInfo` fails to compile — `PlayaAPI` resolves to the public enum, not the module. Use the unqualified name. + +## Debugging Path (Context Preservation) + +Approaches ruled out along the way: + +- `EventFilter(includeExpired: false)` default with 2026 data — bundled occurrences span Aug 30–Sep 6 2026, all future, `notExpired` keeps them. +- Day-bucket key mismatch (`bucketByDayThenHour` vs `EventListViewModel.browseSections`) — both use `Calendar.current.startOfDay`, consistent per device. +- 2026 `event.json` format/type codes — 2,140 events / 4,526 occurrences, standard `event_type.abbr` codes, `update.json` includes an `events` entry. +- Legacy Yap path (`eventsFilteredByExpirationAndType`) — passes non-ended events; the legacy `BRCDataImporter` has its own update.json timestamp comparison so it self-heals on upgrade. The SwiftUI/PlayaDB path did not — hence this fix. +- Atomicity red herring: `importFromData` is one GRDB transaction, so "camps imported but events failed" is impossible; a *skipped* import (stale DB) explains the partial-looking symptom instead. + +## Expected Outcomes + +- Rebuilding to the phone reseeds PlayaDB with 2026 data on next launch (bundle events timestamp `2026-07-03T12:40:06-07:00` > stored 2025-era `lastUpdated`), and Events populate. +- Same auto-heal on the watch. +- Future data drops (new `update.json` timestamps) auto-import on devices with existing databases. +- Immediate manual workaround (pre-fix builds): More → Data Updates → "Reset to Bundled Data". + +## Verification + +- `swift test` in `Packages/PlayaDB` — all suites pass (incl. 5 new tests); `Packages/PlayaAPI` — all pass. +- `xcodebuild -workspace iBurn.xcworkspace -scheme iBurn` (iPhone 17 Pro Max sim) — build succeeds; watch target builds as embedded companion. +- **On-device (BigPhone 17, iPhone 17 Pro Max):** deployed via XcodeBuildMCP `build_run_device`; reseed ran on launch and the Events list populated with 2026 events. User confirmed working. +- Note: the user had "show expired events" enabled, which initially looked like it contradicted the expired-filter theory — but it doesn't: the events UI is day-scoped to the 2026 festival window, so 2025-dated events are invisible on every selectable day regardless of the expired toggle. Stale seed data explains the symptom under both settings. + +## Cross-References + +- `Docs/2026-07-03-2026-year-update-plan.md` — the 2026 data drop that exposed this. +- `Docs/2026-07-03-watchos-mvp-plan.md` — WatchSeeder origin. diff --git a/Docs/2026-07-06-watch-map-blank-off-playa.md b/Docs/2026-07-06-watch-map-blank-off-playa.md new file mode 100644 index 00000000..658f8df9 --- /dev/null +++ b/Docs/2026-07-06-watch-map-blank-off-playa.md @@ -0,0 +1,96 @@ +# 2026-07-06: Watch Map Blank When Off-Playa + Location Simulation + +## High-Level Plan + +**Problem:** On a real Apple Watch far from Black Rock City, the watch app's map screen renders blank (background only). Console shows repeated "Crown Sequencer was set up without a view property" warnings and `NSOSStatusErrorDomain Code=-536870187` errors, but neither is the cause. + +**Root cause:** `MapScreen` starts with `followUser = true`. As soon as the watch gets a real GPS fix, `displayCamera` centers on the user's projected position. `PlayaProjection` is a local equirectangular projection centered on The Man — a fix in (say) the Bay Area projects to a point hundreds of kilometers from the city in world space. The Canvas then draws only background: every street/fence/plaza is off-screen. The simulator never reproduced this because it had no (or no far-away) location fix, leaving the camera at `.zero` (city center). + +`NavigationScreen` (Detail → Navigate) had the same hazard in a different form: it fits the camera to *you + the target*, so an off-playa fix zooms out to a ~500 km span where the city is an invisible dot. + +**Solution:** + +1. New `PlayaMapData.pointOnPlaya(for:maxDistanceMeters:)` in PlayaGeo (default radius 10 km — covers the fence, deep playa, and the airport with margin). Projects a coordinate and returns `nil` when it's farther than the radius from The Man. +2. `MapScreen.userPoint` and `NavigationScreen.userWorldPoint` use it. Off-playa fixes now behave exactly like "no location": map shows the city overview, no user dot, recenter button centers the city; Navigate fits the target (or city bounds). +3. Location simulation for at-home testing: added `iBurnWatch/BlackRockCity.gpx` (The Man, 2026 coords from `YearSettings.plist`) and set it as the `LocationScenarioReference` in the shared `iBurnWatch` scheme (which already had `allowLocationSimulation = YES`). Running the watch app from Xcode now simulates standing at The Man; switch via Debug ▸ Simulate Location (choose "Don't Simulate" for real GPS). + +**Log noise (not the bug):** +- "Crown Sequencer was set up without a view property" — known watchOS/SwiftUI framework noise triggered by `.digitalCrownRotation`; harmless. +- `NSOSStatusErrorDomain Code=-536870187` (0xE00002D5) — watchOS system-framework log spam (commonly haptics/audio related), not emitted by app code. + +## Technical Details + +### Files Modified +- `Packages/PlayaGeo/Sources/PlayaGeo/PlayaMapData.swift` — added `pointOnPlaya(for:maxDistanceMeters:)`. +- `iBurnWatch/MapScreen.swift` — `userPoint` uses the clamped projection. +- `iBurnWatch/DetailScreen.swift` — `NavigationScreen.userWorldPoint` uses the clamped projection. +- `iBurnWatch/BlackRockCity.gpx` — new; The Man at 40.783242, -119.207871. +- `iBurn.xcodeproj/xcshareddata/xcschemes/iBurnWatch.xcscheme` — `LocationScenarioReference` → the GPX (referenceType 0 = project-relative path; no pbxproj change needed for GPX files). +- `Packages/PlayaGeo/Tests/PlayaGeoTests/PlayaGeoTests.swift` — `testPointOnPlayaClampsFarOffFixes`: origin → (0,0); ~3 km deep playa → non-nil; San Francisco → nil. + +### Behavior notes +- Nearby/Favorites screens intentionally unchanged: off-playa, Nearby's region query truthfully returns nothing ("Waiting for GPS…"/empty), Favorites shows large-but-true distances. Only camera math needed the clamp. +- iBurnWatch is *not* a filesystem-synchronized group in the pbxproj (unlike `iBurn`/`iBurnTests`), so new Swift files there need manual project surgery — that's why the helper lives in the PlayaGeo SPM package instead. + +## Verification +- `swift test` in `Packages/PlayaGeo` — 19/19 pass (18 existing + new clamp test). +- `xcodebuild -workspace iBurn.xcworkspace -scheme iBurn` (iPhone 17 Pro Max sim) — succeeds, watch app embedded. +- Not yet re-run on the physical watch — user to rebuild `iBurnWatch` scheme to the watch; map should show the city overview immediately, and with the GPX active the blue user dot should sit at The Man. + +## Cross-References +- `Docs/2026-07-04-events-empty-on-device-stale-seed.md` — previous on-device bug in the same 2026 update cycle. +- `Docs/2026-07-03-watchos-mvp-plan.md` — watch MVP that introduced MapScreen/NavigationScreen. + +--- + +# Session 2: Fix All Swift Concurrency Build Warnings + +## High-Level Plan +A clean build of the `iBurn` scheme (iPhone 17 Pro Max sim, includes iBurnWatch + PlayaDB/PlayaAPI/PlayaGeo packages) surfaced 10 unique warnings, all Swift-concurrency related. Goal: zero project warnings. Committed as `a2cc6fa`. + +## Warnings Found (clean build, 2026-07-06) +1. `iBurn/DataUpdatesView.swift:235` (×2) — main-actor `BRCAppDelegate.shared` / `.dataImporter` referenced from nonisolated context (inside `withCheckedContinuation` closure in a `Task`). +2. `iBurn/Detail/ViewModels/DetailViewModel.swift:518,532` — "consider using asynchronous alternative function" for `asyncReadWrite` called inside `Task.detached`. +3. `iBurn/ListView/ArtListView.swift:243,249` + `CampListView.swift:182,188` — `@MainActor` preview data-provider classes: main-actor `mockRows` used as a default argument (nonisolated context) and main-actor `rows` read from the nonisolated `observeObjects` override (Swift 6 errors). +4. `iBurn/ListView/NearbyListHostingController.swift:49` — capture of non-Sendable `self` in `@Sendable` `Timer.scheduledTimer` closure. +5. `iBurn/MainMapViewController.swift:224` — completion-handler `selectAnnotation` called in async context. + +(`appintentsmetadataprocessor` "No AppIntents.framework dependency found" is toolchain noise, not fixable in code.) + +## Fixes +- **DataUpdatesView** — hoist `let dataImporter = BRCAppDelegate.shared.dataImporter` into the synchronous method body before the `Task`; the continuation closure uses the capture. +- **DetailViewModel** — removed the `Task.detached` wrappers in `syncFavoriteToYapDB` / `syncNotesToYapDB`. `asyncReadWrite` already enqueues on YapDatabase's own queue, so the wrapper was pure overhead and its async closure context was what triggered the diagnostic. +- **MainMapViewController** — `await self.mapView.selectAnnotation(point, animated: true)` (async translation of the `completionHandler:` variant; the 2-arg sync variant is deprecated in MapLibre and is NOT what this resolves to in an async context). +- **Art/CampListView previews** — dropped `@MainActor` from `PreviewArt/CampDataProvider` (parent `ArtDataProvider`/`CampDataProvider` are nonisolated, so the `observeObjects` override was nonisolated anyway). Instead: `@MainActor init(rows: [ListRow]? = nil)` with `rows ?? Self.mockRows` resolved inside the init — `PreviewPlayaDB.shared` (main-actor) is only touched there. `rows: []` still yields the permanent-loading preview. +- **NearbyListHostingController** — switched to target/selector `Timer.scheduledTimer(timeInterval:target:selector:...)` + `@objc geocoderTimerDidFire()`. Timer retains the VC but is invalidated in `viewWillDisappear`, matching the existing lifecycle. (Same block-timer pattern exists in `SortedViewController`/`MainMapViewController` but doesn't warn there — likely different isolation inference; left untouched.) + +## Verification +- `xcodebuild clean build` of `iBurn` scheme — BUILD SUCCEEDED, zero `warning:` lines other than `appintentsmetadataprocessor` noise. +- `xcodebuild test -scheme iBurnTests` — 97/97 passed. +- Pre-existing uncommitted `project.pbxproj` (Xcode adding `lastKnownFileType` to 2026 geojson refs) and `iBurnWatch.xcscheme` changes left out of the commit. + +## Cross-References +- Session 1 above (watch map clamp) — same branch `2026-updates`. + +--- + +# Session 3: Watch Double-Tap Zoom, App Icon, Install Name + +## High-Level Plan +Three watch-app polish items requested together (commit `6bd261f`): +1. Double tap on the map zooms in one level. +2. Watch app icon (target had no asset catalog at all). +3. Installed app should read "iBurn", not "iBurnWatch". + +## Technical Details +- **Double-tap zoom** — `iBurnWatch/MapScreen.swift`: `.onTapGesture(count: 2)` bumps `zoomLevel` by +2 clamped to 12. Scale is `metersPerPoint = 50 / 2^(level/2)`, so +2 crown units = one traditional 2× map zoom level. Placed before the drag gesture; there is no single-tap handler on the map, so no recognition delay concerns. +- **App icon** — new `iBurnWatch/Assets.xcassets` with `AppIcon.appiconset` (single 1024×1024 watchOS icon, `platform: watchos`, converted from `iBurn/Images.xcassets/AppIcon.appiconset/appstore.jpg` via `sips`; no alpha, as required) and a default `AccentColor.colorset` (both were already referenced by `ASSETCATALOG_COMPILER_*` build settings). Wired into the pbxproj by hand (iBurnWatch is not a filesystem-synchronized group): new `PBXFileReference F5F3C81E…` + `PBXBuildFile 6D70904E…` in the watch Resources phase + group child. +- **Install name** — `CFBundleDisplayName` was already "iBurn" since target creation, so what the user saw on-device comes from `CFBundleName`, which `GENERATE_INFOPLIST_FILE` derives from `PRODUCT_NAME` (`$(TARGET_NAME)` = iBurnWatch). Fix: `PRODUCT_NAME = iBurn` on both watch configs; product reference renamed `iBurnWatch.app` → `iBurn.app` (pbxproj `D5A03B13…` path + comments). Xcode auto-rewrote the scheme's `BuildableName`s during the verification build. Target name stays `iBurnWatch`. No collision with the iOS `iBurn.app`: different platform build dirs, and the embed phase copies into `iBurn.app/Watch/iBurn.app`. + +## Verification +- `xcodebuild` iBurn scheme (sim) — BUILD SUCCEEDED, zero warnings. +- Built products: watch product is `Debug-watchsimulator/iBurn.app`, embedded at `iBurn.app/Watch/iBurn.app`; `Info.plist` has `CFBundleName = iBurn`, `CFBundleDisplayName = iBurn`, `CFBundleIcons → CFBundleIconName = AppIcon`; `assetutil --info Assets.car` shows `AppIcon` at 1024px. +- Not yet reinstalled on the physical watch — user should redeploy to see icon + name (double-tap testable in the watch simulator too). + +## Cross-References +- Sessions 1–2 above; `Docs/2026-07-03-watchos-mvp-plan.md` (watch MVP). diff --git a/Docs/2026-07-07-architecture-analysis-and-roadmap.md b/Docs/2026-07-07-architecture-analysis-and-roadmap.md new file mode 100644 index 00000000..0030f540 --- /dev/null +++ b/Docs/2026-07-07-architecture-analysis-and-roadmap.md @@ -0,0 +1,340 @@ +# Architecture Analysis & Roadmap (2026 → 2027) + +**Date:** 2026-07-07 (Pacific) +**Branch:** `2026-updates` +**Status:** Analysis / planning document (no code changes) +**Related:** `2026-07-03-2026-year-update-plan.md`, `2026-07-03-playadb-audit-and-improvements.md`, `2026-07-03-watchos-mvp-plan.md`, `2026-01-25-playadb-migration-next-steps.md`, `2026-05-29-ai-guide-right-now-overhaul.md` + +## High-Level Plan + +This document captures (1) a factual snapshot of the current architecture, (2) the target +architecture we are migrating toward, (3) a **short-term plan for the 2026 event season** +(now → September 2026, ship-focused), and (4) a **longer-term plan for the 2027 cycle** +(post-event fall 2026 → summer 2027, migration-completion focused). + +**The one-sentence story:** iBurn is mid-migration from a legacy +**Objective-C + YapDatabase + Mantle + UIKit** stack to a modern +**Swift + GRDB ("PlayaDB") + SwiftUI** stack; the map, detail screens, AI Guide, global +search, and the entire watch app already run on the modern stack, while the five list tabs +and the network data-update pipeline are the last major legacy holdouts. The 2026 season +plan is "ship what's proven, don't destabilize"; the 2027 plan is "finish the migration and +delete the legacy stack." + +--- + +## 1. Current Architecture (verified snapshot, 2026-07-07) + +### 1.1 Top-level layout + +- `/iBurn/` — main app target: **43 `.m` + 48 `.h` Obj-C files, 206 Swift files** +- `/iBurnWatch/` — watchOS 26 app, 7 Swift files, fully modern (SwiftUI + PlayaDB + PlayaGeo) +- `/iBurnTests/` — 15 Swift test files +- `/Packages/` — local SPM: **PlayaDB** (~5.6k LOC), **PlayaAPI** (~1.1k LOC), **PlayaGeo** (~0.6k LOC) +- `/PlayaGeocoder/` — separate `.xcodeproj` framework (JS-bundle reverse geocoder), embedded in the app +- `/Submodules/` — `iBurn-Data`, `YapDatabase`, `DOFavoriteButton`, `ASDayPicker`, `PermissionScope` +- Shared schemes: `iBurn`, `iBurn (Mock Date)`, `iBurnTests`, `iBurnWatch` +- **Stale:** no `/PlayaKit/` directory exists anymore; `PlayaKit`/`PlayaKitTests` survive only as + vestigial Pods xcconfig references in the pbxproj. The repo `CLAUDE.md` still describes both — + needs a cleanup pass. + +### 1.2 The two data stacks + +**Legacy (YapDatabase + Mantle + Obj-C):** +- `BRCDatabaseManager`, `BRCDataObject` family (`BRCArtObject`/`BRCCampObject`/`BRCEventObject`/ + `BRCRecurringEventObject`), `BRCObjectMetadata`, `BRCMapPoint`/`BRCUserMapPoint`, `BRCEmbargo`. + ~61 files still reference Yap. +- Serialization via Mantle 2.x. +- **Still load-bearing for:** the five default list tabs (via `YapTableViewAdapter`/ + `YapViewHandler`/`SortedViewController`), user map points + breadcrumbs on the map, and the + **entire network update pipeline** (`BRCDataImporter` fetching `update.json` from + `kBRCUpdatesURLString`, bundled zipped Yap DB seed). + +**Modern (PlayaDB = GRDB 7.x + Codable via PlayaAPI):** +- `PlayaDB` protocol + `PlayaDBImpl` (2.5k LOC): async fetch/observe APIs, FTS5 search, R*Tree + spatial indexes, day/hour-bucketed event observations, metadata (favorites/notes/last-viewed), + thumbnail-color cache, user map pins, `importFromData`. +- Hardened by the 2026-07-03 audit: DatabasePool/WAL, write-free read paths, canonical FTS5 + external-content triggers, occurrence→parent-event metadata identity, narrowed observation + regions, `DatabaseMigrator` (`v1-initial-schema`), 166 green tests, full 2026 import ~0.3 s. +- **Seeded from bundled JSON only** (`PlayaDBSeeder` → `BundleDataLoader` → `importFromData`); + re-seeds when bundle `update.json` timestamps advance (`needsImport`). **No network update path.** +- DI via `@MainActor DependencyContainer` (single shared PlayaDB, providers, VM factories), + reached through `BRCAppDelegate.dependencies`. + +### 1.3 Who runs on what today + +| Surface | Stack | Gating | +|---|---|---| +| Map annotations + filtering | PlayaDB (`FilteredMapDataSource`, `PlayaDBAnnotationDataSource`) | always on | +| Global search (map + lists) | PlayaDB FTS (`GlobalSearchViewModel`) | always on | +| Detail screen | PlayaDB SwiftUI (`DetailView`/`DetailViewModel`) | `useSwiftUIDetailView` default **true** all builds | +| AI Guide ("Right Now"), AI search, event summaries | PlayaDB + Apple Foundation Models | always on (hidden if no Apple Intelligence) | +| Nearby card on map | PlayaDB | always on | +| watchOS app (map/nearby/favorites/detail) | PlayaDB + PlayaGeo | always on (own DB, own seeder) | +| **Five list tabs** (Favorites, Nearby, Events, Art, Camps) | **YapDatabase UIKit by default**; PlayaDB SwiftUI alternates exist | `useSwiftUILists` — **DEBUG-only, default false** | +| User map pins / breadcrumbs | YapDatabase (`BRCUserMapPoint`) | always on | +| Network data updates | YapDatabase (`BRCDataImporter`) | always on | + +> **Correction 2026-07-25:** the "User map pins / breadcrumbs" row above was already wrong when +> written. User map pins have been 100% PlayaDB (`user_map_pins` table) since commit `99587a3` +> (2026-04-05) — all six CRUD paths verified in the 07-25 audit; `BRCUserMapPoint` survives only +> as an in-memory MapLibre annotation adapter (`BRCUserMapPoint+PlayaDB.swift`). Breadcrumbs +> have never been Yap in the shipping path: they live in a standalone GRDB database, +> `LocationHistory.sqlite` (`iBurn/Tracks/LocationStorage.swift`). See +> `2026-07-25-playadb-default-yap-audit-and-migration.md`. + +**Bridging/dual-writes:** `DetailSubject` enum bridges `.legacy(BRCDataObject)` and PlayaDB +cases; favorites are dual-written Yap ↔ PlayaDB by uid (`DetailDataService.syncFavoriteToPlayaDB`, +`BRCDataObjectTableViewCell`) so hearts agree across stacks. ~11 files import both databases. + +### 1.4 Map, watch, AI + +- **Map:** MapLibre via SPM (`maplibre-gl-native-distribution`), offline MBTiles from + `iBurn-Data` (2026 tiles generated 2026-07-03). Annotation content is PlayaDB; residual Yap + connections in `MainMapViewController` exist only for user pins/breadcrumbs. Reverse geocoding + via the `PlayaGeocoder` framework (JS bundle, year-hardcoded paths — annual checklist item). + **Correction 2026-07-25:** user pins are PlayaDB (`user_map_pins`, commit `99587a3`) and + breadcrumbs are standalone GRDB (`LocationHistory.sqlite`); the residual Yap connections in + `MainMapViewController` are dead code, not pin storage. +- **watchOS:** standalone watchOS 26 app; SwiftUI `Canvas` vector map from bundled GeoJSON via + `PlayaGeo` (MapLibre is wontfix on watchOS), own PlayaDB seeded from `iBurn2026APIData`, + watch-local favorites. **No WatchConnectivity sync yet** (Phase 2 of the watch plan, pending + an embedding decision). +- **AI:** all on-device Foundation Models (iOS 26+), gated on `canImport(FoundationModels)`. + Single "Right Now" flow on the More tab (8 old workflows + chat deleted in May), semantic + search wired into global search, AI event summaries on detail screens. All AI tools query + PlayaDB. + +### 1.5 Dependencies + +- **CocoaPods (iOS-app-only, legacy-leaning):** YapDatabase (local podspec), Mantle, + CocoaLumberjack, Anchorage, PureLayout, FormatterKit, BButton, TTTAttributedLabel, Appirater, + CupertinoYankee, TUSafariActivity, KVOController, Onboard, JTSImageViewController, + UIImageColors, LicensePlist + 4 local-podspec submodules. +- **SPM remote:** GRDB.swift 7.6.1+, MapLibre, firebase-ios-sdk, Siren, Zip. +- **SPM local:** PlayaDB, PlayaAPI, PlayaGeo, iBurn-Data (`iBurn2026APIData`/`Map`/`MediaFiles`). +- **Dual-sourced smells:** two databases in production; two geo helpers (`PlayaGeocoder` + xcodeproj vs `PlayaGeo` package); Mantle vs Codable; residual `MGL` naming from Mapbox days. + +### 1.6 Data pipeline & embargo + +- Yearly rollover to 2026 is **complete and verified** (see `2026-07-03-2026-year-update-plan.md`): + new city geometry (Man at 40.783242,-119.207871, streets Ararat…Kundalini, fence 8287'), + 2026 API data (321 art / 1201 camps / 2140 events / 499 MVs), regenerated tiles + geocoder, + `iBurn2026*` package renames, `MARKETING_VERSION` 2026.0. +- All 2026 locations are **null (embargoed)** until gates; `BRCEmbargo` gates location display in + both stacks; 2026 passcode hash still pending from BMorg. +- Event dates: **Sun Aug 30 – Mon Sep 7, 2026** (Man Burn Sep 5, Temple Burn Sep 6). + +--- + +## 2. Target (Future) Architecture + +The end-state we are converging on, most of which already exists in embryo: + +1. **Single database: PlayaDB (GRDB).** One SQLite file, one import path, one observation + layer. YapDatabase, Mantle, and the `BRC*Object` Mantle models deleted. User data + (favorites, notes, map pins, breadcrumbs, visit lists) lives in GRDB tables. +2. **Single data pipeline.** A `PlayaAPI`-based network updater (Codable models, delta-aware + `importFromData`) replaces `BRCDataImporter`; the bundled-zip Yap seed and dual seeding + disappear. The same pipeline serves iPhone and (via reseed or connectivity) watch. +3. **SwiftUI-first UI.** List tabs, detail, search, AI, onboarding, and settings in SwiftUI; + UIKit retained only where it earns its keep (MapLibre host VC, tab controller shell). + `YapTableViewAdapter`/`SortedViewController` and the legacy list VC family deleted. +4. **Protocolized services + DI everywhere** (already the house style): `PlayaDB`, + `LocationProvider`, `MediaAssetProviding`, `PreferenceService` behind protocols built by + `DependencyContainer` factories. +5. **SPM-only dependencies.** CocoaPods retired; the surviving legacy pods either dropped with + the UIKit screens that use them or replaced by SPM equivalents. `PlayaGeocoder` either + absorbed into `PlayaGeo` (Swift port of the radial geocoding math) or repackaged as SPM. +6. **Multi-target platform story:** iPhone app + standalone watch app sharing + PlayaAPI/PlayaDB/PlayaGeo, with WatchConnectivity syncing user metadata deltas; room for + widgets/complications ("gates open" countdown, next favorited event) on the same packages. +7. **AI as a first-class but optional layer:** Foundation Models tools over PlayaDB only, one + immediacy-first flow, degrading gracefully on unsupported hardware. + +Architecture rule of thumb going forward: **new persistence is GRDB/Codable, new UI is +SwiftUI, new services are protocol + Impl behind the container** — legacy code is only +touched to delete it or to bridge it out. + +--- + +## 3. Short-Term Plan — 2026 Season (now → mid-September 2026) + +Guiding principle: **the burn is in 8 weeks; ship what's proven.** No structural migration +work lands between now and the event. The season is a data-cadence + polish exercise, same +shape as 2025 (June/July structural pass → August point releases). + +### 3.1 Release engineering (July) + +- [ ] Commit/land the `2026-updates` branch work (year flip + PlayaDB audit are done and + verified; watch MVP phases 0/1/3/4 done) and get a `2026.0` TestFlight beta out early + so the season cadence has a baseline. +- [ ] Decide watch app shipping scope for 2026: **ship standalone** (map/nearby/favorites + work today, watch-local favorites) and defer WatchConnectivity sync unless it lands + comfortably by early August. A standalone watch app is a complete, honest v1. +- [ ] Verify Siren/App Store metadata, screenshots for the 2026 city, privacy manifest + currency. + +### 3.2 Feature-flag decisions (decide by ~Aug 1) + +> **DECIDED 2026-07-11 (Chris):** `useSwiftUILists` flipped to default-ON in all builds, +> ahead of the Aug 1 checkpoint. The OTA-staleness constraint was explicitly accepted +> ("the network updater can wait"); PlayaDB stays bundle-seeded for 2026. A Settings.bundle +> kill-switch was added and legacy lists remain as fallback. Yap user-data migration was +> deemed unnecessary (fresh year, no existing installs). See +> `2026-07-11-swiftui-lists-default-on.md`. + +- **`useSwiftUIDetailView` (default true):** already the shipped default — keep. +- **`useSwiftUILists` (DEBUG-only, default false):** the SwiftUI list stack now covers all + five tabs and PlayaDB is audited/fast, but it has never survived a public beta. + **Recommendation:** promote the flag from `#if DEBUG` to a hidden/internal toggle in + release builds, run it **on** in TestFlight betas through July, and make the go/no-go call + ~Aug 1. Default **off** for the App Store release unless beta telemetry/crash data is + clean — legacy lists are battle-tested and the cost of dual stacks for one more season is + already paid. +- **Known blocker to check before any flip:** PlayaDB is bundle-seeded only. The August + data refreshes reach PlayaDB **only via app updates** (bundle reseed through + `needsImport`), while Yap gets them over the air via `update.json`. If lists ship on + PlayaDB, on-playa users without app-store access would see stale data the moment we push + an OTA-only update. Either (a) keep lists on Yap for 2026 (default recommendation), or + (b) land the minimal "PlayaDB network refresh" — reuse `BRCDataImporter`'s download, then + feed the fetched JSON through `importFromData` — which is a small, testable bridge but + still new plumbing in August. Decide deliberately, not by default. + +### 3.3 August data cadence (deferred items from the rollover plan, as data lands) + +- [ ] **2026 embargo passcode hash** from BMorg → `BRCSecrets.m`; re-confirm embargo policy + (2025 relaxed camp/event embargo to art-only — decide 2026 stance). +- [ ] **API re-fetch with locations** once BMorg unlocks GPS (`fetch_and_geocode.js -y 2026`, + needs `BMORG_API_KEY`); add URL sanitization to the fetch script (the 4 malformed-URL + records found in July would crash strict `URL` decoding again on re-fetch). +- [ ] **Official GIS tiles** when `innovate-GIS-data` publishes 2026 (redo tippecanoe per + `2025-07-19-map-tiles-official-data.md`; remember `-t "$TMPDIR"` under sandbox). +- [ ] **Placement geojson** → regenerate `camp_labels.geojson`/`camp_outlines.geojson` + (currently shipped as empty FeatureCollections). +- [ ] **Media refresh + audio tour** (thumbnails already done — 1574/1574; audio arrives + late season). +- [ ] Each data drop = a point release (`2026.1`, `2026.2`, …), matching the 2025 rhythm. + Remember both bundles feed *two* databases until the migration completes: bundled-zip + Yap seed **and** JSON for PlayaDB must both be regenerated per drop. +- [ ] Annual-checklist gotchas already logged: geocoder year-hardcoded paths + (`BlackRockCityPlanner/src/geocoder/index.js`, `PlayaGeocoder.xcodeproj`), test-fixture + re-dating into the event window, pbxproj `DEVELOPMENT_TEAM` dirtying. + +### 3.4 Stability/paper-cut budget (July, small and reversible only) + +- [ ] Watch follow-ups from the 07-03/07-06 sessions (e.g. the PlayaDB spatial-index + UPDATE-trigger gap noted in the watch plan; off-playa blank-map fix landed 07-06). +- [ ] Favorites dual-write spot-check on device (Yap ↔ PlayaDB agreement after the metadata + identity fix). +- [ ] Fresh-install + upgrade-install passes via the drive-app flows before each release; + keep `references/flows.md` current. +- [ ] AI Guide sanity pass on-device (iOS 26 hardware) — it ships always-on for capable + devices. + +**Explicit non-goals for the season:** list-tab migration flip (unless beta-proven), PlayaDB +network ingestion (unless chosen in 3.2), Yap user-data migration, CocoaPods exit, any +schema migrations beyond additive ones. + +--- + +## 4. Longer-Term Plan — 2027 Cycle (Oct 2026 → Aug 2027) + +Goal: **enter the 2027 rollover with one database, one pipeline, and no Obj-C data layer.** +Sequenced so each phase ships independently behind the existing flag infrastructure and shrinks the +legacy surface monotonically. + +### Phase 1 — Unify data ingestion on PlayaDB (Oct–Dec 2026) + +The keystone: everything else is blocked on PlayaDB being update-capable. +- Build `PlayaUpdateService` (protocol + Impl in `Packages/PlayaDB` or a thin app service): + fetch `update.json` + per-type JSON via `PlayaAPI` Codable models, diff timestamps against + `UpdateInfo`, call `importFromData` per changed type. Reuse the existing + `kBRCUpdatesURLString` endpoint and background-fetch hooks. +- Import semantics to verify/extend: deletion handling (records removed upstream), partial + imports, embargo-safe location updates, preserving `ObjectMetadata` across re-imports + (already keyed by uid — add regression tests). +- Run it **in parallel with** `BRCDataImporter` for a beta cycle (both stacks stay fresh), + then make PlayaDB the source of truth and let Yap go stale behind the scenes. + +### Phase 2 — Lists to SwiftUI by default; user-data migration (Jan–Mar 2027) + +- Flip `useSwiftUILists` default **on** for all builds; keep the legacy stack one release as + a kill-switch, then delete: the five legacy list VCs, `YapTableViewAdapter`, + `YapViewHandler`, `SortedViewController`, `ListCoordinator`, the XIB cell family. +- One-time migration of Yap user data into GRDB: favorites + notes + last-viewed (mostly + already dual-written), **user map pins & breadcrumbs** (new GRDB tables; port + `BRCUserMapPoint` map layer to `UserMapPin`), visit lists. Migration runs once at launch, + idempotent, covered by tests with a fixture Yap DB. + **Correction 2026-07-25:** user map pins and breadcrumbs are already off Yap and need no + migration — pins ship in PlayaDB `user_map_pins` (commit `99587a3`, 2026-04-05) and + breadcrumbs in standalone GRDB `LocationHistory.sqlite`. Visit status is likewise already + dual-written (`object_metadata.visit_status` + `FavoriteSyncService.mirrorVisitStatus`), so + the remaining Phase 2 migration surface is legacy-only leftovers, not these. +- Remove the favorites dual-write once migration ships. + +### Phase 3 — Delete the legacy core (Apr–May 2027) + +- With no UI or pipeline consumers, delete `BRCDatabaseManager`, `BRCDataImporter`, + `BRCDataObject` family, `BRCObjectMetadata`, Mantle usage, the bundled-zip Yap seed, and + the YapDatabase pod/submodule. Port stragglers (`BRCEmbargo`, `BRCDetailViewController` + off-path, remaining categories) to Swift as encountered. +- CocoaPods exit: with the legacy UIKit screens gone, most pods lose their reason to exist. + Replace survivors with SPM (CocoaLumberjack has SPM; UIImageColors et al. as needed) and + delete the Podfile. Single-package-manager builds also simplify the watch/CI story. +- Consolidate geo: port the reverse geocoder into `PlayaGeo` (Swift radial math — the + layout.json numbers are all we need) or wrap the JS bundle as an SPM target; delete + `PlayaGeocoder.xcodeproj` and its year-hardcoded path gotcha. + +### Phase 4 — Platform expansion on the unified stack (May–Jul 2027) + +- **WatchConnectivity metadata sync** (the deferred watch Phase 2): favorites/notes deltas + keyed by `updated_at`, last-writer-wins; watch data refresh piggybacks on Phase 1's + update service. +- **Widgets/complications:** gates countdown, next favorited event, sunrise/sunset — cheap + once PlayaDB is the single source (shared app group or per-target seed). +- Evaluate: Live Activities for favorited events, Siri/App Intents ("what's happening now"), + deeper AI Guide iterations — all now single-stack features. +- 2027 rollover itself (June–July 2027) should then be *only* the data/geometry checklist — + no dual-database bookkeeping. + +### Continuous + +- Keep `CLAUDE.md`/`AGENTS.md` truthful as the migration deletes things (PlayaKit references + are already stale today). +- Test posture: PlayaDB package tests are the strongest suite (166) — every phase above adds + its regression tests there or in `iBurnTests`; keep `EXPLAIN QUERY PLAN` tests honest as + queries evolve. + +--- + +## 5. Risks & Open Questions + +1. **Dual-database drift** is the top short-term risk: two seeds, two update stories, dual-written + favorites. Every August data drop must feed both until Phase 1 lands. Mitigation: the + release checklist in §3.3, plus the on-device favorites spot-check. +2. **`useSwiftUILists` go/no-go** (§3.2) is the season's only real architecture decision — + the OTA-staleness constraint is the deciding factor, not UI polish. +3. **Watch scope creep:** WatchConnectivity is tempting but the standalone story is complete; + defer to Phase 4 unless trivially done. +4. **Foundation Models availability:** AI features are iOS 26 + Apple Intelligence hardware + only; the graceful-hiding paths (`AISearchServiceFactory` returning nil, + `makeAIGuideViewModel()` nil) must stay tested as the OS evolves through betas. +5. **Yap user-data migration fidelity** (Phase 2): breadcrumbs/visit lists have no dual-write + today; the one-time migration is the only shot — needs fixture-DB tests before shipping. + **Correction 2026-07-25:** breadcrumbs were never in Yap (standalone GRDB + `LocationHistory.sqlite`) and visit status *is* dual-written both ways + (`FavoriteSyncService.mirrorVisitStatus` ⇄ `DetailDataService.syncVisitStatusToPlayaDB`). + The real fidelity risk is narrower: Yap-only rows that predate the dual-writes. +6. **Open:** 2026 embargo policy (art-only vs full), 2026 passcode timing, whether BMorg + publishes 2026 GIS/placement in time, `.gitmodules` private/public flip decision. + +## Expected Outcomes + +- **September 2026:** a stable 2026.x release train — proven legacy lists (or beta-proven + SwiftUI lists), PlayaDB-powered map/detail/search/AI, a standalone watch app, fresh data + through the August cadence. +- **August 2027:** single GRDB database and single update pipeline, SwiftUI list tabs, no + YapDatabase/Mantle/CocoaPods, geo consolidated, watch synced — and a 2027 rollover that is + purely a data exercise. diff --git a/Docs/2026-07-11-swiftui-lists-default-on.md b/Docs/2026-07-11-swiftui-lists-default-on.md new file mode 100644 index 00000000..c1740a20 --- /dev/null +++ b/Docs/2026-07-11-swiftui-lists-default-on.md @@ -0,0 +1,236 @@ +# 2026-07-11 — SwiftUI lists default ON (YapDB list migration complete) + +**Branch:** `2026-updates` +**Related:** `2026-07-07-architecture-analysis-and-roadmap.md` (this executes its §3.2 decision +early, per Chris), `2026-01-25-playadb-migration-next-steps.md`, `2026-02-28-event-list-migration.md` + +## High-Level Plan + +Chris's direction: *"finish our yapdb migration and default on for SwiftUI lists. The network +updater can wait."* Explicitly descoped: PlayaDB network/OTA ingestion (accepted: PlayaDB stays +bundle-seeded this season) and any Yap→GRDB user-data migration (2026 is a fresh year — +`iBurn-2026.sqlite` starts empty, nobody has this year's version installed; a migrator was built +and then **removed** on that basis). + +Work executed with parallel Fable subagents (flag flip / adult gating / favorite sync / +parity audit), integrated, built, tested, and sim-verified end-to-end. + +### What shipped + +1. **`useSwiftUILists` promoted to all builds, default `true`** (`Preferences.swift`). + The five list tabs (Favorites, Nearby, Events, Art, Camps) now default to SwiftUI/PlayaDB; + the preference remains a kill-switch (legacy UIKit/Yap code retained for one season). + `#if DEBUG` wrappers removed at the 5 branch points (`BRCAppDelegate+Dependencies.swift` ×3, + `MoreViewController.swift` ×2). Debug Feature Flags screen stays DEBUG-only, but its toggle + now reads/writes through `PreferenceServiceFactory` (raw `UserDefaults.bool` would show OFF + for the unset-default-true state). +2. **Release kill-switch** via `Settings.bundle/Root.plist`: "Modern List Views" + `PSToggleSwitchSpecifier` on `featureFlag.lists.useSwiftUI` — field fallback without an + app-store update. +3. **Adult-event region gating restored** (App Store content risk). Legacy hid `adlt` events + until `BRCLocations.hasEnteredBurningManRegion` (`BRCDatabaseManager.m:945-948`, events tab + list + its search only). Replicated exactly: new `iBurn/ListView/RegionStatusService.swift` + (protocol + factory + `EventFilter.excludingAdultEvents()` via the existing `eventTypeCodes` + inclusion mechanism — no new PlayaDB API; empty-after-exclusion selections use a + match-nothing sentinel so "only adult selected outside region" stays empty like legacy). + Applied in `EventListViewModel` browse + search filters, observation-time only (never + persisted into the user's filter). Deliberately NOT gated (matching legacy): favorites, + global search, nearby, map, filter-sheet type list, AI Right Now. + Tests: `iBurnTests/EventListAdultGatingTests.swift` (8). +4. **`FavoriteSyncService` — SwiftUI list hearts mirror into Yap** (`iBurn/FavoriteSyncService.swift`). + Previously list hearts wrote PlayaDB only → VisitList/AudioTour hearts disagreed, kill-switch + fallback would "lose" favorites, and un-favoriting from a list left stale EKEvents. + - Protocol + Impl + Factory; injected into the four `*DataProvider.toggleFavorite`s + (fire-and-forget after the PlayaDB write; PlayaDB is source of truth) and reused by + `DetailViewModel` (its private sync deleted). + - **Event fan-out:** PlayaDB events are keyed by API uid; Yap splits events per-occurrence + as `"-"` (`BRCRecurringEventObject.eventObjects()`). The mirror updates + every matching `"-"` key (+ exact-uid defensively). + - **Calendar parity:** after the metadata write **commits**, a + `FavoriteSyncCalendarRefreshHook` fires per occurrence uid; the production hook opens its + own transaction and calls `BRCEventObject.refreshCalendarEntry(_:)` (EKEvent create/remove, + `calendarEventIdentifier` bookkeeping) — same path as legacy detail favoriting. Note: only + detail toggles ever scheduled calendar entries in legacy; list hearts now do too, which is + an upgrade, and un-favoriting from a list correctly removes the EKEvent. + - **Fixed latent bugs found en route:** `DetailViewModel.syncFavoriteToYapDB` and + `DetailDataService.syncFavoriteToPlayaDB`/`syncNotesToPlayaDB` looked events up by the + wrong uid form (bare API uid against suffixed Yap keys, and suffixed Yap uid against + PlayaDB respectively) — both silently no-oped for events. Suffix normalization: + `FavoriteSyncServiceImpl.apiEventUID(fromYapUID:)`. + - Tests: `iBurnTests/FavoriteSyncServiceTests.swift` (11; temp Yap DB + in-memory PlayaDB, + EventKit replaced by a spy hook that re-reads committed Yap state). +5. **Nearby "starting soon" window restored** (`NearbyViewModel.happeningEvents`): now includes + events starting within 30 min, matching legacy `BRCDataSorter`'s `isStartingSoon`. +6. **Final festival day browsable** (`YearSettings.festivalDays` now end-inclusive + `(0...numberOfDays)`): the Events day strip gains MON Sep 7 (Exodus), matching the legacy + end-inclusive ASDayPicker. Also feeds the AI Right Now day picker. +7. Cleanup: dead `favoritesViewController`/`eventsViewController` properties deleted from + `BRCAppDelegate.h`. + +### Parity audit — accepted gaps / follow-ups (not fixed this session) + +Ranked findings from the audit agent, with disposition: + +- **OTA updates never reach PlayaDB** (all five lists frozen to bundled data until an app + update) — *explicitly accepted by Chris; "network updater can wait."* This is the roadmap's + Phase 1. During August data drops, both DBs are refreshed via app updates anyway. +- Events filter feature loss: "Show All Day Events" + "only events hosted at art" toggles have + no SwiftUI equivalent; all-day events bucket by start hour instead of end-of-day. +- Art/camp rows: no event-count badge ("📅 N"); camp rows lack image-color theming. +- Event time labels: no green/orange/red status coloring; expired events not auto-dropped by + the 60s tick (labels refresh, filter doesn't re-run). +- Day/hour bucketing uses `Calendar.current` (device TZ) not fixed PDT (`playaCalendar` exists + unused in `PlayaDBImpl`) — wrong grouping when browsing from home timezones pre-event. +- Art/Camps/Favorites list search is in-memory `.contains`, not FTS (`filter.searchText` never + set) — contradicts the house FTS-at-SQL rule; Events list does use FTS. "Search selected day + only" option dropped. +- `"FavoritesFilter"` shared key: new stack writes "Vehicles", which the legacy enum parses as + All (only matters if kill-switch used). +- Filter prefs don't carry over (legacy discrete keys vs new JSON blobs) — moot, fresh year. +- `GlobalSearchView` hardcodes `isFavorite: false` (pre-existing). + +## Technical Details + +### The swiftmodule/@testable gotcha (cost ~4 build cycles — remember this) + +`iBurnTests` failed with *"type 'FavoriteSyncServiceFactory' has no member 'makeService'"* / +*"'FavoriteSyncServiceImpl' cannot be constructed because it has no accessible initializers"* +while the app target compiled clean. Root cause, isolated with a probe file compiled into the +test target: + +- App-module members whose **signatures** reference `YapDatabaseReadWriteTransaction` are + silently dropped when the test target imports the `iBurn` module (`@testable` or not). +- `YapDatabaseConnection` in signatures is fine; the read-write transaction type is not + (probe: `zzProbeYapConnection` resolved, `zzProbeYapTransaction` → "cannot find in scope"). +- Bodies are unaffected — only serialized signatures (params, property types, typealiases). + +Fix pattern: keep cross-module-visible signatures free of that type. The calendar hook became +`(_ yapUID: String, _ isFavorite: Bool) -> Void`, invoked **post-commit**, with the production +hook opening its own transaction. (Nested-typealias and default-argument variants of the same +symptom were chased first; the transaction type in the signature was the real poison.) + +### Verification + +- App build: clean (0 errors/warnings), iPhone 17 Pro Max iOS 26.2 sim. +- `iBurnTests`: full suite green (includes 19 new tests). +- **Fresh-install sim pass with NO flag override** (pure defaults): onboarding → all five + surfaces render SwiftUI (Events day strip scrolls to MON 7; Nearby shows correct pre-embargo + empty state; Camps show "Location Restricted"). +- DB assertions: PlayaDB seeded 321/1201/2101/4431, WAL, `v1-initial-schema`; favoriting one + event from detail wrote exactly one `object_metadata` row keyed by the **parent** uid, and + the Yap mirror updated **all six** `"-"` occurrence rows — decoded blob shows + `isFavorite: true` + a real EKEvent `calendarEventIdentifier` (calendar permission granted + in onboarding). +- flows.md updated (day strip end date; Yap-mirror verification steps). + +### What "finish the YapDB migration" still leaves on Yap (post-season work, roadmap §4) + +Network update pipeline (`BRCDataImporter`), user map pins/breadcrumbs on the map, Visit List, +Audio Tour, legacy list VCs (kept one season as kill-switch), embargo internals. The five list +tabs + detail + map annotations + search + AI are now all PlayaDB-first in shipping defaults. + +--- + +# Session 2: watchOS Phase 2 — WatchConnectivity favorites sync + watch polish + +Chris: *"The watchOS app needs some work. Let's fix it up. Use lower effort fable subagents +for implementation."* The one remaining documented MVP item +(`2026-07-03-watchos-mvp-plan.md`) is **Phase 2: phone↔watch favorites sync**; plus polish +gaps found in survey. + +## Plan + +1. **PlayaDB sync core** (`Packages/PlayaDB`): + - Migration `v2-favorite-sync`: `ALTER TABLE object_metadata ADD COLUMN favorite_updated_at + DATETIME` + backfill `= updated_at WHERE is_favorite = 1`. + **Why a new column:** `updated_at` is bumped by view-tracking and notes writes, so + LWW on it would let "viewed after favoriting elsewhere" propagate a stale unfavorite. + - `FavoriteSyncItem` (Codable: objectType/objectId/isFavorite/updatedAt). + - Protocol: `favoriteSyncSnapshot()` (rows with non-NULL `favorite_updated_at`), + `applyFavoriteSync(_:) -> [FavoriteSyncItem]` (per-item LWW; skip when local state + already matches — prevents observation/push loops; skip missing-row unfavorites; + returns applied items), `observeFavoriteSyncState(...)`. + - `toggleFavorite`/`setFavorite` stamp `favorite_updated_at`. + - Tests: LWW newer-wins/older-ignored/same-state-no-write, insert-favorite, + skip-missing-unfavorite, snapshot excludes viewed-only rows, stamping, backfill. +2. **`FavoritesSyncManager`** in the PlayaDB package (`#if canImport(WatchConnectivity)`), + symmetric on both platforms: WCSession activate → apply `receivedApplicationContext` → + push snapshot; observation on snapshot → `updateApplicationContext(["favoritesV1": json])`; + `didReceiveApplicationContext` → `applyFavoriteSync` → `onApplied` hook. + Lives in the package so **no pbxproj surgery** (iBurnWatch is not a synchronized group; + both targets already link PlayaDB). +3. **Phone integration**: `DependencyContainer` owns the manager; `onApplied` mirrors + incoming favorites into Yap via the existing `FavoriteSyncService` (Session 1). +4. **Watch integration**: instantiate + start in `iBurnWatchApp` (existing file edit only). +5. **Watch polish**: FavoritesScreen surfaces load errors; DetailScreen shows event + occurrence times (favorited events synced from the phone were time-less); + FavoritesScreen refreshes when synced favorites land. +6. Explicitly out of scope: embargo-flag sync (moot — bundled data has no GPS this season), + events browsing on watch, complications. + +## Results + +All shipped; executed with 3 Fable subagents (PlayaDB core / watch polish / WCSession +wiring) + integration and E2E verification in the main session. + +### What shipped + +1. **PlayaDB migration `v2-favorite-sync`** — `object_metadata.favorite_updated_at` + (backfilled from `updated_at` for existing favorites); stamped only by + `toggleFavorite`/`setFavorite`. Verified applying cleanly to an existing v1 phone DB. +2. **Merge API** — `FavoriteSyncItem`, `favoriteSyncSnapshot()`, + `applyFavoriteSync(_:) -> [applied]`, `observeFavoriteSyncState(...)`. LWW rules: + unknown type skipped; missing row + unfavorite skipped; same-state skipped with NO + write (loop prevention); otherwise applied iff incoming stamp newer (or local nil). + 12 new tests in `FavoriteSyncMergeTests`; package suite 189/189. +3. **`FavoritesSyncManager`** (in the PlayaDB package — both targets already link it, so + no pbxproj surgery; iBurnWatch is not a synchronized group): symmetric WCSession + wrapper, `applicationContext` key `favoritesV1` (JSON, `.secondsSince1970`), NSLock + around tiny state, push on observation change + on activation + + `sessionWatchStateDidChange`/`sessionCompanionAppInstalledDidChange` (see bug below), + incoming context → `applyFavoriteSync` → `onApplied` (only when non-empty; empty + payloads ignored so a fresh peer can't clobber). +4. **Phone wiring** — `DependencyContainer` owns/starts the manager; `onApplied` maps + `DataObjectType` → `FavoriteSyncObjectType` and mirrors into Yap via Session 1's + `FavoriteSyncService` (event occurrence fan-out + EKEvent refresh come free). +5. **Watch wiring** — manager started in `iBurnWatchApp.task` after seeding; `onApplied` + posts `.favoritesSyncDidApply`; FavoritesScreen bumps its `refreshToken` on receipt so + phone favorites appear live. +6. **Watch polish** — FavoritesScreen real error state (was silently showing "No + favorites yet" on DB errors); DetailScreen shows up to 5 upcoming occurrence times + for events ("Sun 5:00 – 7:00 PM"), which synced phone favorites made reachable. + +### Bug found during E2E (fixed) + +First E2E run: phone pushed while the watch app wasn't installed yet → +`WCErrorDomain 7006` → favorite never delivered (context is only re-pushed on change). +Fix: implement `sessionWatchStateDidChange` (iOS) / `sessionCompanionAppInstalledDidChange` +(watchOS) → `pushLatestSnapshot()`, so installs/pairing changes re-publish state. + +### Verification + +- PlayaDB package: 189/189. iBurnTests: full suite, 0 failures. Both app builds + 0 errors / 0 warnings; no pbxproj churn. +- Paired-sim E2E (iPhone 17 Pro Max + Watch Series 11 46mm, pair "active, connected"): + - Phone → watch: favorited "Booty Hour" event on phone → watch `object_metadata` + `event|pZKm9hfsiDbnz8QXueVW|1`; watch Favorites lists it; detail shows occurrence + times. Existing phone DB migrated v1→v2 in place; fresh watch install converged on + first launch (post-fix). + - Watch → phone: injected GPS into 2 camps on watch (flows.md trick), favorited + "Snuggles" via watch Nearby → phone PlayaDB `camp|a1XVI00000FBBVz2AP|1` AND phone + Yap `BRCCampObject` blob decoded `isFavorite=True` (non-favorited peer decodes + False). Phone Favorites tab showed the camp via live observation. + - Watch app uninstalled afterward to purge the GPS-tampered test DB. +- flows.md §9 updated with the sync flow + verification recipe. + +### Notes / accepted limitations + +- `applicationContext` is best-effort latest-state: intermediate toggles coalesce + (fine — final state is what matters) and delivery needs an eventual connection. +- LWW trusts device clocks (normal for this pattern; worst case a stale toggle wins + within clock skew). +- Embargo-flag sync deliberately skipped: bundled watch data has no GPS this season and + there's no watch network updater; revisit with roadmap Phase 1. +- Legacy-only phone surfaces that write favorites straight to Yap (Visit List, Audio + Tour) don't reach PlayaDB and therefore don't reach the watch — same pre-existing gap + as Session 1, tracked for post-season Yap retirement. diff --git a/Docs/2026-07-12-watch-browse-and-visit-status.md b/Docs/2026-07-12-watch-browse-and-visit-status.md new file mode 100644 index 00000000..4c1b67e6 --- /dev/null +++ b/Docs/2026-07-12-watch-browse-and-visit-status.md @@ -0,0 +1,142 @@ +# 2026-07-12 — Watch browse for all data types + visit status ("want to visit") + +**Branch:** `2026-updates` +**Related:** `2026-07-11-swiftui-lists-default-on.md` Session 2 (favorites sync this builds +on), `2026-07-03-watchos-mvp-plan.md` (all MVP phases complete as of yesterday). + +Chris: *"I couldn't find a way to browse all data types on the watch. Camp, art, event, +mutant vehicle. Then favorites/want-to-visit etc for the data types. We'll also need to +handle data update mechanism. I think for now we can just bundle databases with each app +but for incremental updates not sure what to do."* + +## High-Level Plan + +1. **pbxproj unblocking (done first, by hand):** converted the `iBurnWatch` group to a + `PBXFileSystemSynchronizedRootGroup` (same UUID `73700CCA…`, so the main-group child + reference is untouched): removed the 8 explicit file refs + build-file entries and the + per-file Sources/Resources lines, added `fileSystemSynchronizedGroups` to the target. + New Swift files in `iBurnWatch/` now join the target automatically — no more manual + project surgery (which blocked past sessions; see 2026-07-06 doc). The in-place + "iBurnWatch CityGeo" geojson refs (Submodules) are intentionally untouched. + `BlackRockCity.gpx` now ships in the watch bundle as a side effect (1 KB, harmless). + Verified: watch + iOS builds, Assets.car + geojson still in the bundle. +2. **Watch browse (new screens, Fable agent):** map root's top-left toolbar button + becomes **Browse** → list: Nearby / Camps / Art / Events / Vehicles. + - Camps/Art/Vehicles: alphabetical `ObjectListScreen` with `.searchable`, distance + when available, → existing `DetailScreen`. + - Events: `EventListScreen` with a festival-day picker driven by + `observeEventsByDayThenHour` (day keys come from the data — `YearSettings` is not in + the watch target). **Adult gating replicated** (App Store content risk): `adlt` + events excluded unless the user's location is on-playa + (`PlayaMapData.pointOnPlaya != nil`), mirroring the phone's + `EventFilter.excludingAdultEvents()`. +3. **Visit status → PlayaDB + sync (Fable agents):** + - Migration `v3-visit-status`: `object_metadata.visit_status` (INT, default 0, + `BRCVisitStatus` raw values: 0 unvisited / 1 visited / 2 wantToVisit) + + `visit_status_updated_at` (LWW stamp, same reasoning as `favorite_updated_at`). + - PlayaDB API: `VisitStatus` enum, `setVisitStatus(_:for:)`, + `fetchObjects(visitStatus:)`. + - `FavoriteSyncItem` reshaped (nothing shipped, no wire compat needed): + `favoriteUpdatedAt: Date?` (was `updatedAt`), `visitStatus: Int`, + `visitStatusUpdatedAt: Date?`. Snapshot = rows with either stamp non-NULL. + `applyFavoriteSync` does **per-field LWW** (favorite and visit status merge + independently; same-state skip per field, write only changed columns). + - Phone: `DetailViewModel.updateVisitStatus` + `DetailDataService` dual-write to + PlayaDB (event uid suffix normalization); incoming sync mirrors visit status into + Yap via a new `FavoriteSyncService.mirrorVisitStatus` (fan-out for events, no + calendar side effects). + - Watch: `DetailScreen` gains a visit-status control; `FavoritesScreen` gains a + filter menu — Show: Favorites / Want to Visit / Visited, Type: All / Camps / Art / + Events / Vehicles. +4. **Data updates: recommendation only** (Chris is explicitly undecided; see below). + +## Data update mechanism — assessment (no code this session) + +Current state of record: both apps are bundle-seeded; `needsImport(bundleUpdateData:)` +already reseeds either app when a shipped bundle is newer than what was imported +(2026-07-04 fix). So "bundle databases with each app" is already how it works — every +App Store/TestFlight release refreshes both DBs, preserving `object_metadata`. + +For incremental (OTA) updates, the legacy phone pipeline is already "incremental" at +file granularity: `update.json` carries per-type timestamps and the importer downloads +only changed type files (art/camp/event JSON), never per-row deltas. Recommendation for +PlayaDB (roadmap Phase 1, post-season unless the embargo drop forces it): + +- Build one shared `PlayaUpdater` in the PlayaDB/PlayaAPI package: fetch `update.json` + from `UPDATES_URL`, compare per-type timestamps against `getUpdateInfo()` (the + comparison logic already exists as `needsImport`), download changed files, call + `importFromData` (idempotent upsert; spatial/FTS triggers keep indexes consistent; + favorites/visit metadata is a separate table and survives). +- Phone and watch each run it over their own URLSession (the watch is standalone-capable + with WiFi; no phone-relay needed). A phone→watch `transferFile` relay is a power + optimization to consider later, not a prerequisite. +- The embargo unlock then "just works": the post-gates data drop is a normal update + whose camp/art rows carry GPS; `*_spatial_update` triggers (built 2026-07-03 for + exactly this) keep region queries correct. +- This season, if no updater ships: data refreshes (including the location drop) reach + users via app updates, same as the plan of record from yesterday's session. + +## Results + +All shipped. Executed with 4 Fable subagents (PlayaDB visit-status core / watch browse +screens / phone integration / watch visit UI); pbxproj conversion, a parity-gap fix, and +E2E verification done in the main session. + +### What shipped + +1. **pbxproj:** `iBurnWatch` is now a `PBXFileSystemSynchronizedRootGroup` (32 explicit + file-ref/build-file lines deleted; target gained `fileSystemSynchronizedGroups`). + Both apps verified building with Assets.car + geojson intact. +2. **Watch browse** (new files: `BrowseScreen.swift`, `ObjectListScreen.swift`, + `EventListScreen.swift`; map top-left toolbar → Browse): + Nearby / Camps / Art / Vehicles / Events. Generic alphabetical `.searchable` lists → + existing DetailScreen; Events uses one `observeEventsByDayThenHour` subscription with + day chips (defaults to today, in-memory day switching) and replicates the phone's + adult gating (all `EventType` codes minus `adlt` unless `pointOnPlaya` says on-playa). +3. **PlayaDB `v3-visit-status`:** `visit_status` (0/1/2 = BRCVisitStatus raw) + + `visit_status_updated_at`; `VisitStatus` enum; `setVisitStatus(_:for:)` (no-op on + same value), `fetchObjects(visitStatus:)`. `FavoriteSyncItem` reshaped + (`favoriteUpdatedAt: Date?`, `visitStatus: Int`, `visitStatusUpdatedAt: Date?`) and + `applyFavoriteSync` upgraded to **per-field LWW** (fields merge independently; + same-state per-field skip keeps the no-write loop-prevention property). Package + suite 206/206 (17 new merge tests). +4. **Phone:** `FavoriteSyncService.mirrorVisitStatus` (event fan-out, read-compare + skip, no calendar); `DependencyContainer.onApplied` mirrors both fields; + `DetailDataService.updateVisitStatus` dual-writes PlayaDB (event uid suffix + normalized via `apiEventUID`). 5 new mirror tests (17 total in + FavoriteSyncServiceTests; also fixed a latent test-fixture bug — helpers now store + `BRCCampMetadata` etc., since `metadataWithTransaction` type-checks the subclass). +5. **Parity gap found during E2E and fixed (main session):** the PlayaDB-backed detail + (`generatePlayaFooterCells`) had **no VISIT STATUS cell** and + `DetailViewModel.updateVisitStatus` silently no-oped for non-legacy subjects — so + want-to-visit was unreachable in the shipping default stack. Fixed: cell added after + USER NOTES, `playaVisitStatus` loaded in all metadata paths (incl. preloaded), and + `updateVisitStatus` now handles all subject cases (PlayaDB write + Yap mirror; + occurrence uses `occ.event.uid`). +6. **Watch visit UI:** DetailScreen visit-status button → sheet (SwiftUI `Menu` is + `@available(watchOS, unavailable)`); FavoritesScreen filter sheet — Show: + Favorites/Want to Visit/Visited, Type: All/Camps/Art/Events/Vehicles; per-mode + empty states; title tracks mode. + +### Verification + +- PlayaDB 206/206; iBurnTests full suite green (re-run after the DetailViewModel fix); + all builds 0 warnings; no stray pbxproj churn beyond the intended conversion. +- Paired-sim E2E: fresh watch install ran v1→v3 migrations, pulled phone favorites on + activation (reshaped payload). Watch: Browse menu, Camps list, Events day chips + (Sun 30 ⇄ Tue 1 verified), detail sheet set Snuggles → Want to Visit → phone PlayaDB + `visit_status=2` + Yap blob decoded `[2, True]`; Favorites filter "Want to Visit" + showed only Snuggles. Phone: VISIT STATUS cell on the PlayaDB camp detail set + Best Butt → Visited → watch row `camp|Best Butt|0|1` inserted via sync + phone Yap + blob `[1, False]`. +- flows.md watch section updated (Browse flow, visit-status sync checks, rating-prompt + and watch-keyboard automation quirks). + +### Known gaps / follow-ups + +- Watch list search verified in code only (the watch keyboard's AX field rejects + `type_text`; manual check recommended on device). +- Legacy Visit List screen (`VisitListViewController`, More tab) still reads Yap only — + fine, since the mirrors keep Yap in sync from every write path. +- Data updater (`PlayaUpdater`) not built — see assessment above; recommend first + post-season item unless the embargo drop should go OTA this year. diff --git a/Docs/2026-07-13-official-2026-map-tiles.md b/Docs/2026-07-13-official-2026-map-tiles.md new file mode 100644 index 00000000..4efd0fbd --- /dev/null +++ b/Docs/2026-07-13-official-2026-map-tiles.md @@ -0,0 +1,134 @@ +# Official 2026 Map Tiles from BMorg GIS Data - 2026-07-13 + +## High-Level Plan + +**Problem Statement**: BMorg published official 2026 GIS data +(`burningmantech/innovate-GIS-data` commit `3c69f43` "2026 GeoJSON (#7)", +2026-07-13). The 2026 tiles shipped so far (`Docs/2026-07-03-2026-year-update-plan.md`, +step A6) were built from *generated* geometry as a stopgap; the plan deferred an +official-data redo until BMorg published. This session completes that deferred item. + +**Solution Overview**: Update the `bmorg/innovate-GIS-data` submodule inside +iBurn-Data, regenerate `data/2026/Map/Map.bundle/map.mbtiles` with tippecanoe from +the official GeoJSON (same layer mapping as 2025), and adapt the style filter + +app `imageMap` to BMorg's renamed CPN points. Also fix a stale-cache bug that +would have prevented existing installs from ever seeing new bundled tiles. + +**Key Changes**: +1. `Submodules/iBurn-Data/bmorg/innovate-GIS-data`: `9d9892f` → `3c69f43` (adds `2026/GeoJSON/`). +2. Regenerated `data/2026/Map/Map.bundle/map.mbtiles` from official data (286 KB, 949 features, z4–14, 8 layers: blocks/dmz/fence/outline/plazas/points/streets/toilets — same set 2025 shipped). +3. `data/2026/Map/Map.bundle/styles/iburn-{light,dark}.json`: points-layer exclusion filter extended for renamed CPNs. +4. `iBurn/MapViewAdapter.swift`: `imageMap` entries for renamed/new CPNs. +5. `iBurn/Bundle+iBurn.swift`: refresh the Application Support mbtiles cache when the bundled file changes. + +## Technical Details + +### Tile generation (run from `Submodules/iBurn-Data/data/2026/`) + +2026 official data is *more complete* than 2025: `toilets.geojson` and +`dmz.geojson` are now included (2025 had to fall back to generated geo for both). +All 8 layers now come from official data. + +**Gotcha — street names**: official `street_lines.geojson` only carries letter +names (`A`, `B`, … `K`, `ESP`, plus `Rods Road`, `Route 66`). The MapLibre style +labels streets via `{name}`, so letters were rewritten to the real 2026 names +using the mapping in `layouts/layout.json` `cStreets`: + +``` +ESP→Esplanade, A→Ararat, B→Bodhi, C→Chomolungma, D→Delphi, E→Eternal, +F→Fulcrum, G→Great Oak, H→Heiau, I→Iroko, J→Jiba, K→Kundalini +``` + +(291 of 573 features renamed; radial clock streets keep their `H:MM` names.) + +```bash +# 1. rewrite letters → names into a temp copy (python json round-trip on +# bmorg/innovate-GIS-data/2026/GeoJSON/street_lines.geojson) +# 2. tippecanoe (needs -t "$TMPDIR" under the Claude sandbox): +tippecanoe -t "$TMPDIR" --output=Map/Map.bundle/map.mbtiles -f \ + -L fence:../../bmorg/innovate-GIS-data/2026/GeoJSON/trash_fence.geojson \ + -L outline:../../bmorg/innovate-GIS-data/2026/GeoJSON/street_outlines.geojson \ + -L points:../../bmorg/innovate-GIS-data/2026/GeoJSON/cpns.geojson \ + -L blocks:../../bmorg/innovate-GIS-data/2026/GeoJSON/city_blocks.geojson \ + -L plazas:../../bmorg/innovate-GIS-data/2026/GeoJSON/plazas.geojson \ + -L streets:.geojson \ + -L toilets:../../bmorg/innovate-GIS-data/2026/GeoJSON/toilets.geojson \ + -L dmz:../../bmorg/innovate-GIS-data/2026/GeoJSON/dmz.geojson \ + -z 14 -Z 4 -B0 +``` + +The same command (with placeholder path) is documented in iBurn-Data `README.md` +and `CLAUDE.md`. + +### CPN renames (points layer) + +BMorg renamed several CPNs vs 2025. The style hides some points behind a `!in +NAME` filter and the app registers runtime icons keyed by exact `NAME`, so both +needed updating: + +| 2025 NAME | 2026 NAME | Handling | +|---|---|---| +| `DMV` | `Department of Mutant Vehicles (DMV)` | added to style exclusion filter | +| `DMZ` | `Deep-Playa Music Zone (DMZ)` | added to style exclusion filter | +| `Station 6` | `ESD Station 6` | added to style exclusion filter | +| `Station 3` / `Station 9` | `ESD Station 3` / `ESD Station 9` | added `firstAid` imageMap entries | +| — (new) | `Arctica Outpost` | added `ice` imageMap entry | +| — (new) | `Recycle Camp` | added `recycle` imageMap entry (`pin_recycle` asset exists) | + +Old keys were kept in both places, so the styles/app remain compatible with +either dataset. + +### Stale tile cache fix (`iBurn/Bundle+iBurn.swift`) + +`brc_cachedMbtilesURL` copies the bundled `map.mbtiles` into +`Application Support/iBurn//Map/` **only if the file is missing** — so any +install that had already launched the app would keep the old tiles forever after +an app update. (Observed live in the simulator: the map kept rendering the old +generated tiles until the fix.) Remote tile updates are dead code +(`BRCDataImporter.m` `loadDataFromLocalURL` early-returns for +`BRCUpdateDataTypeTiles`), so the bundle is the only writer of that file and it's +safe to refresh: the getter now deletes the cached copy when +`FileManager.contentsEqual` says it differs from the bundle, then falls through +to the existing copy-if-missing path. + +### Verification + +- `tippecanoe-decode` on z12/z13 tiles: all 12 themed street names + `Rods Road` + present; CPN `NAME` properties intact. +- mbtiles metadata: 8 vector layers, bounds `-119.2408,40.7605,-119.1812,40.8035` + (matches 2026 fence; city center moved ~south-west vs 2025). +- Official fence agrees with our generated `geo/fence.geojson` within ~10 m, so + the existing layout/geocoder data needs no change. +- App built and driven in the simulator (iPhone 17 Pro Max, iOS 26.2): city + renders from official blocks/outline, street labels show themed names, toilets + / fence / DMZ / POI icons all present. Cache-refresh verified via md5 of the + Application Support copy matching the new bundle after relaunch. + +## Context Preservation + +- 2025 precedent: `Submodules/iBurn-Data/Docs/2025-07-19-map-tiles-official-data.md` + (same layer mapping; 2025 used generated toilets/dmz fallbacks that are no + longer needed). +- Style contract: layer names `fence/outline/points/blocks/plazas/streets/toilets/dmz`; + styles only reference `dmz, fence, outline, points, streets, toilets`. The + `points` layer must carry uppercase `NAME` (satisfied natively by cpns.geojson). + Street labels use lowercase `{name}`. +- `points` icons resolve via runtime images registered in + `MapViewAdapter.mapView(_:didFinishLoading:)` — the bundle sprite sheet keys + (`sprite.json`) are legacy and not what `icon-image: {NAME}` matches against. +- In-flight watchOS work (PlayaGeo `StreetLabelLayout`, `MapScreen`) was present + in the working tree during this session and deliberately left uncommitted; the + watch map draws from generated geo, not these tiles. + +## Expected Outcomes + +- Map shows official 2026 city geometry (streets, blocks, plazas, toilets, DMZ, + fence) with correct themed street names and POI icons. +- Existing installs pick up new bundled tiles on next launch after updating. +- When BMorg pushes revised 2026 GeoJSON, rerun the documented tippecanoe command + (README.md / CLAUDE.md in iBurn-Data) after bumping the submodule. + +## Remaining Work + +- 2026 placement data (camp_labels/camp_outlines geojson in Map.bundle) is still + a placeholder — arrives closer to the event (see 2025 timeline: late August). diff --git a/Docs/2026-07-18-api-data-refresh.md b/Docs/2026-07-18-api-data-refresh.md new file mode 100644 index 00000000..6aef6163 --- /dev/null +++ b/Docs/2026-07-18-api-data-refresh.md @@ -0,0 +1,386 @@ +# 2026 API Data Refresh (July 18) + +## High-Level Plan + +Pull the latest 2026 camp/art/event data from the Burning Man API using the existing sync script from last year, verify the result, and commit it in the iBurn-Data submodule. + +**Outcome:** Success. Data refreshed, validated, and committed (`iBurn-Data` `79d9748`, parent pointer bump `e56a543`, both on `2026-updates`). + +## Technical Details + +### The script (built last year, reused as-is) + +`Submodules/iBurn-Data/scripts/BlackRockCityPlanner/src/cli/fetch_and_geocode.js` — fetches camp/art/event from `api.burningman.org`, geocodes camps against the year's layout, writes `update.json`. Requires `BMORG_API_KEY` (already exported in `~/.zprofile`). + +```bash +cd Submodules/iBurn-Data/scripts/BlackRockCityPlanner +node src/cli/fetch_and_geocode.js -y 2026 \ + -l ../../data/2026/layouts/layout.json \ + -o ../../data/2026/APIData/APIData.bundle +``` + +Note: `api.burningman.org` is not in the Claude Code sandbox network allowlist — the first run failed with `getaddrinfo ENOTFOUND` and had to be re-run with sandbox disabled. Consider adding the host via `/sandbox` for future runs. A failed run still overwrites `update.json` (with all-failed timestamps), so always re-run to completion. + +### Results (old → new) + +| File | HEAD | New | Notes | +|---|---|---|---| +| camp.json | 1201 | 1201 | 4 dropped, 4 added; only 9 real content changes (urls/emails). Large diff is null-key stripping, not data churn. | +| art.json | 321 | 321 | 8 dropped, 8 added | +| event.json | 2140 | 2217 | +77 net (9 removed, 116 added) | + +- **"Geocoded 0 camps"** is expected: API returns no `location_string`/`location` for camps or art yet (embargo until gates open). HEAD data was identically location-free — no regression. +- Duplicate event uids: 9 byte-identical dupes (upstream API quirk; was 39 at HEAD, so improved). +- 2 events reference a camp uid absent from the roster (`a1XVI00000FJ1B32AL`) — pre-existing upstream inconsistency. + +### Script bug discovered and fixed: mv support + +`fetch_and_geocode.js` fully rewrote `update.json` with only art/camps/events keys, silently dropping the `mv` (mutant vehicles) entry; `mv.json` itself had been fetched manually (499 records, July 3). Fixed by making mv a first-class data source: the script now fetches `https://api.burningman.org/api/mv?year=N` (same shape as art, saved as-is) and writes an `mv` entry to `update.json` each run. Verified end-to-end: 496 vehicles fetched (3 dropped upstream since the manual pull), all uids unique. The script lives in the nested BlackRockCityPlanner submodule, so the fix is a three-level commit chain: BRCP `ec84cd3` → iBurn-Data `be53e0b` → app repo `17addfc`. + +## Context Preservation + +- First attempt delegated the whole run to a subagent; blocked by the permission classifier (prompt pre-authorized a sandbox bypass). Ran the fetch inline instead; verification/diff analysis was delegated to a Sonnet subagent (read-only, no sandbox issues). +- 2026 dir also has loose `art.json`/`camp.json`/etc. at `APIData/` level (outside `APIData.bundle/`) — these were not touched and appear to be leftovers; the app consumes `APIData.bundle`. + +## Expected Outcomes + +- App picks up refreshed 2026 rosters and +77 events on next build (bundled data). +- Locations remain null until BMorg lifts the embargo — re-run the same command then, and expect real geocode success/failure counts at that point. + +## Cross-References + +- `Docs/2026-07-13-official-2026-map-tiles.md` — map tile side of 2026 data +- `Submodules/iBurn-Data/CLAUDE.md` — full data-generation workflow (geometry, tiles, geocoder bundle) + +--- + +# Same-day session 2: 2026 release-readiness audit + event list day-switch bug fix + +## Part A — Release-readiness audit (first 2026 release prep) + +Verified locally on `2026-updates` (a8b07f3): app builds clean (Xcode 26.x, iPhone 17 Pro Max +OS 26.2 sim) and the `iBurnTests` scheme passes with 0 failures. Note the shared `iBurnTests` +scheme already includes `PlayaKitTests` + `PlayaGeocoderTests` as testables; a separate shared +`PlayaKitTests` scheme no longer exists (CLAUDE.md's separate test command is stale). +Version state: `MARKETING_VERSION 2026.0`, build 108. App-repo branch pushed (0 unpushed). + +### Blockers / action items found + +1. **GitHub Actions CI has never worked since the July 2025 migration.** All three workflows + (`ci.yml`, `pr.yml`, `deploy.yml`) use `runs-on: macos-15-arm64`, which is not a valid + GitHub-hosted runner label — every job queues for 24h with **zero steps executed**, then is + auto-cancelled. Last 100 runs: 94 cancelled, only successes are Dependabot bundler jobs. + Consequence: tagging `v*` for a release would never deploy to TestFlight. + **Fix committed** on local branch `worktree-fix-ci-runner-labels` (`20f7bd1`, based on + master, not pushed): `macos-26` runners, Xcode 26.2 (watchOS 26 target requires Xcode 26; + old pin was 16.4), Ruby 3.1→3.4 (EOL; 3.4 matches local), destination iPhone 16 Pro → + iPhone 17 Pro Max, `xcpretty` (not in Gemfile) → preinstalled `xcbeautify`, and test matrix + drops the nonexistent `PlayaKitTests` scheme. Evidence: `actions/runner-images` + macos-26-arm64 readme (Xcode 26.0.1–26.6, iPhone 17 Pro Max sims). +2. **iBurn-Data submodule `2026-updates` branch exists ONLY on this machine.** Not pushed to + `origin` (iBurn-Data-Private) nor `public` (iBurn-Data). The app repo's pushed + `2026-updates` branch points at submodule commit `be53e0b` that no remote has — fresh + clones/CI can't init the submodule, and the entire 2026 data work has no off-machine + backup. Needs a push decision (private vs public; data is currently embargo-clean — all + locations null). +3. **Remote updates endpoint has no 2026 data.** Production `UPDATES_URL` (GitHub secret) + serves from the public `iBurnApp/iBurn-Data` repo raw path + `data/YYYY/APIData.bundle/update.json`; the public repo has no `data/2026/`. Before + release: push 2026 data to the public repo and point the `UPDATES_URL` secret at the 2026 + path, else shipped apps can't receive OTA data refreshes (or worse, would fetch 2025 data + if the secret still embeds `data/2025`). +4. **2026 embargo passcode hash still pending from BMorg** — local `BRCSecrets.m` and the + `EMBARGO_PASSCODE_SHA256` GitHub secret presumably still carry the 2025 hash. +5. **Deploy signing predates the watch app.** `deploy.yml` installs a single + `BUILD_PROVISION_PROFILE_BASE64`; the new iBurnWatch target needs its own bundle-id + provisioning for archive/export (App Store Connect + secrets work required before a + TestFlight deploy can succeed). + +## Part B — Event list bug: wrong day-of-week on first row + dead tap (FIXED) + +**Symptom (user screenshot, sim iOS 26.5):** Events tab, SAT 5 selected; first row +("Drama Dump & Gift", hour-0 section) shows trailing label "Wed 12:00am (12h)" and tapping +it does nothing. Other rows correct ("Sat 12:00am …") and tappable. + +**Data:** two 2026 events titled "Drama Dump & Gift"; the relevant one (event_id 56449, uid +`EJVpjWpfy6uUjvATLNh3`) has six occurrences, midnight→noon Mon Aug 31 … Sat Sep 5. So a +Saturday occurrence exists; the row was rendering the Wednesday one. + +**Root cause** (`iBurn/ListView/EventListView.swift`): since the day-tab perf work +(`ceb0f42`, 2026-05-17), day switching does NOT rebuild the list — the same +`ScrollView`+`LazyVStack` persists and `selectedDay` just swaps the `dayBuckets` slice. +Rows have occurrence-unique `ForEach` ids (`EventObjectOccurrence.uid` = +`eventUid_occurrenceRowid`), BUT the first row of each hour section carried a bare +`.id(hour)` (0–23) as a `ScrollViewReader` anchor for the hour scrub strip +(`EventHourIndexView`). Hours repeat every day, so after a day switch the new day's anchor +row has the SAME explicit identity as the old day's — SwiftUI/LazyVStack treats it as the +same view and keeps the cached row: stale trailing label AND stale tap closure. The stale +closure sends the old day's occurrence to `EventListHostingController.showDetail` +(`EventListHostingController.swift:41`), whose +`firstIndex(where: uid && occurrence.startTime match)` guard finds no match in the current +day's `visibleRows` and silently `return`s — hence the dead tap. (The May-17 perf doc +records that `.id(selectedDay)` full-remount was tried and reverted for perf; the identity +collision this leaves behind wasn't noticed.) + +**Fix** (branch `fix-event-day-occurrence`): namespace the anchor identity by day — +`private struct HourAnchorID: Hashable { let day: Date; let hour: Int }`, applied as +`.id(HourAnchorID(day: anchorDay, hour: hour))` on first-in-section rows and matching +`proxy.scrollTo(HourAnchorID(day: anchorDay, hour: hour))` in the hour-index overlay, where +`anchorDay = Calendar.current.startOfDay(for: viewModel.selectedDay)` (same key derivation +as the viewmodel's day buckets). Anchor identity now changes on every day switch, so the +lazy stack can never resurrect the previous day's row; scrub-strip scrolling behavior is +unchanged; no extra remounts (≤24 anchor rows affected per day), preserving the May perf +work. + +**Verification:** clean build green; simulator drive (WED→SAT/THU/FRI switches, first-row +label + detail push on first and second rows) — see session log / PR. + +## Part C — Pre-populated YapDatabase seed restored for 2026 + +The bundled Yap seed (`iBurn-YYYY.zip`, shipped 2022–2024) was collateral damage of the +Oct 2025 SPM/pbxproj cleanup (`99b8cbd`): the consumption code survived +(`BRCDataImporter.copyDatabaseFromBundle()` unzips a main-bundle `iBurn-2026.zip` into +`App Support/iBurn/iBurn-2026/`), but nothing bundled the zip, so 2026 first launches were +doing the full JSON import (~3m10s on an iPhone 17 Pro Max sim). The zip artifact itself was +always local-only/gitignored, regenerated by hand each season — no script ever existed. + +**2026 mechanism (simpler than the old pbxproj wiring):** the `iBurn/` folder is now a +filesystem-synchronized group, so the seed just lives at **`iBurn/iBurn-2026.zip`** +(gitignored via the new `iBurn/iBurn-*.zip` rule) and is auto-copied into the app bundle — +no pbxproj entries needed. If the file is absent at build time the app silently falls back +to JSON import (non-fatal), so the release builder must have the zip present. + +**Regeneration procedure** (redo whenever `APIData.bundle` JSON is refreshed — especially +the final pre-release August data drop, or the seed's saved timestamps go stale and first +launch pays seed-copy + full re-import): +1. Fresh install (`xcrun simctl uninstall com.trailbehind.iBurn2010` — note bundle id) + of the current build; launch; complete onboarding; let the JSON import finish. Poll + `sqlite3 "/Library/Application Support/iBurn/iBurn-2026/iBurn-2026.sqlite" + "SELECT count(*) FROM database2;"` until stable (>6000; 2026 July data: **6456**). + Container via `xcrun simctl get_app_container com.trailbehind.iBurn2010 data`. +2. `xcrun simctl terminate com.trailbehind.iBurn2010`, then from + `/Library/Application Support/iBurn`: `zip -r -X iBurn-2026.zip iBurn-2026` + (top-level zip entry MUST be the `iBurn-2026` folder; include sqlite + -wal/-shm). +3. Drop it at `iBurn/iBurn-2026.zip`. Done (synced group bundles it automatically). + +**Verified 2026-07-18:** built app contains the 4.2 MB zip; after uninstall + fresh +install, `database2` reads 6456 rows 12 s after launch (vs 190 s import). PlayaDB (GRDB, +SwiftUI lists) intentionally has no seed — its bundle-JSON import is ~0.3 s +(`Docs/2026-07-07-architecture-analysis-and-roadmap.md`). A copy of the zip also sits at +`Submodules/iBurn-Data/data/2026/iBurn-2026.zip` (archival convention from prior years; +the live one is the `iBurn/` copy). + +## Part D — CRITICAL: July 18 data refresh silently broke PlayaDB import (fixed) + PlayaDB seed + +**Regression discovered while generating the PlayaDB seed:** the July 18 API refresh +(`79d9748`) reintroduced user-entered junk `url` values that the July 3 session had +sanitized by hand (camp.json 5, art.json 4, mv.json 1 — e.g. Hel's Diner +`"http://www.campporta.org, www.helsdiner.com"`). PlayaAPI decoded `url` strictly as +`URL`, so decoding threw, and since `PlayaDB.importFromData` runs art+camp+event+mv in ONE +GRDB transaction, the whole import rolled back: **fresh installs of `2026-updates` had a +completely empty PlayaDB** (Events tab "No events found"), while existing installs +silently kept stale July-3 data (reimport failed on every launch). The failure was +invisible because `PlayaDBSeeder` logged via `print()`, which the sim console drops. The +July 3 notes predicted exactly this ("Consider adding sanitization to fetch_and_geocode.js +for the August re-fetches"). + +**Fix (code hardening, not data patching):** new +`Packages/PlayaAPI/Sources/PlayaAPI/Models/Shared/LenientURL.swift` — user-entered URL +fields (`Camp.url`, `Art.url`/`donationLink`, `Event.url`, `MutantVehicle.url`/ +`donationLink`) now decode leniently via explicit `init(from:)`: clean single-token web +URL accepted as-is; dirty values salvage the first comma/whitespace-separated token that +parses with scheme+host (bare `www.*` gets `http://`); otherwise nil. Never throws, so one +bad upstream record can never blank the database again. Org-generated `thumbnailUrl` +fields stay strict. Encoding unchanged. `PlayaDBSeeder`'s `print`s upgraded to +`DDLogError`/`DDLogInfo`. Tests: PlayaAPI 67 passed (incl. 12 new LenientURL cases), +PlayaDB 206 passed, and the previously-failing real-bundle acceptance test +`testImportRealDataFromiBurnBundle` now passes. Data files untouched; optional follow-up: +sanitize at the source in `fetch_and_geocode.js` for hygiene. + +**PlayaDB seed (per Chris: "we need a pre seeded PlayaDB as well"):** unlike Yap, PlayaDB +had no copy-from-bundle path, so one was added: +`PlayaDBSeeder.restoreBundledSeedIfNeeded(documentsURL:seedZipURL:bundle:)` — synchronous, +called from `DependencyContainer.init` before `PlayaDB.create()`. No-op when +`Documents/PlayaDB.sqlite` exists or the seed resource is absent; otherwise unzips the +bundled `PlayaDB-.zip` to a temp dir, clears stray `-wal`/`-shm` +sidecars, and moves `PlayaDB.sqlite` into place; any failure removes partial files and +falls back to JSON import. 5 unit tests (`iBurnTests/PlayaDBSeedRestoreTests.swift`) with +runtime-built fixture zips. Existing installs are untouched (their data updates still flow +through `needsImport` timestamp checks). + +**Seed artifact:** `iBurn/PlayaDB-2026.zip` (gitignored, auto-bundled by the synced +group like the Yap zip; also add to the seasonal regeneration checklist). Generation: +same fresh-install procedure as the Yap seed (Part C) but harvest +`/Documents/PlayaDB.sqlite` after `PRAGMA wal_checkpoint(TRUNCATE)`, then +`zip -X -j PlayaDB-2026.zip PlayaDB.sqlite` (single top-level file entry, no folder). +July data: 321 art / 1201 camps / 2208 events / 4697 occurrences, 4.7 MB sqlite → +1.7 MB zip. Verified end-to-end: fresh install restores a byte-identical +(md5-matched) PlayaDB.sqlite instead of importing JSON, and both seeds coexist. +(Note: 2208 events in PlayaDB vs 2217 fetched — PlayaDB dedupes the 9 byte-identical +duplicate-uid events noted in the refresh section.) + +**Watch follow-up:** the watch app's own GRDB store still JSON-imports on first launch +(`WatchSeeder`); no seed there yet — its dataset import is small, revisit only if watch +first-launch feels slow. + +## Part E — Max-duration event filter (hide amenity-listing pseudo-events) + +Per Chris: camps list amenities as day-long "events" (e.g. a mailbox open midnight–noon +daily, 12h) that aren't real events to attend. New filter hides them by duration. + +- **PlayaDB:** `EventFilter.maxDuration: TimeInterval?` (package default nil — watch/ + Nearby/Right Now/detail consumers unchanged). Predicate in `eventOccurrenceRequest` + (so browse + search + fetch paths all get it): + `(julianday(end_time) - julianday(start_time)) * 86400.0 <= ? + 0.5` — inclusive, so + exactly-6h events stay visible; +0.5 s absorbs julianday float rounding at the boundary. +- **Events tab:** default **6h**, defined in `EventListViewModel` + (`defaultMaxDuration`). Persisted under a separate UserDefaults key + (`.maxDuration`, `StoredMaxDuration` `.limited/.unlimited`) rather than in + the EventFilter JSON blob, because synthesized Codable omits nil optionals — a nil in + the blob would be indistinguishable from a pre-field legacy blob, and an explicit "Any" + would get re-coerced to 6h. Key absent → 6h; `.unlimited` → no limit. +- **UI:** "Max Duration" section in `EventFilterSheet` — discrete slider, positions + 1–12 = hour caps, rightmost = "Any" (nil), value readout ("6h"/"Any"), footer copy + explains the amenity-listing rationale. Filter-icon active indicator deliberately does + NOT include maxDuration (the default is non-nil; it would always read active). +- **Tests:** PlayaDB `testEventOccurrenceRequestMaxDurationFilter` (5h59m/6h in, + 6h1m/12h out, nil = all; suite 207 green); iBurnTests `EventListDurationFilterTests` + ×6 (default, browse/search flow-through, legacy-blob default, 3h round-trip, explicit + Any persistence; suite 133 green). +- **Sim-verified:** SAT 5 default hides "Drama Dump & Gift" 12h and keeps the exactly-6h + "Sunset to Sunrise" (inclusive boundary) and SUN 30's 5h45m twin; default persists + across relaunch; search results exclude 12h rows. The "Any" path was verified by + injecting the persisted `.unlimited` pref into the app-container plist and relaunching + (12h rows reappear, matching the original bug screenshot lineup) — the XcodeBuildMCP + HID layer cannot drag SwiftUI sliders, so the slider gesture itself is covered by unit + tests + code review. **Gotcha for future automation:** the app reads prefs from the + app-container plist; `simctl spawn defaults write ` writes the user-level + domain the app never reads (procedure now in drive-app flows.md). +- **Pre-existing bug found during verification (follow-up):** event SEARCH results + drop one of two same-timestamp events — searching "Drama" shows "Drama Prevention + Darkwad Station" (5h45m, Sun 6pm) but not "Drama Dump & Gift" (5h45m, same exact + start/end), though browse mode shows both and both are in the FTS index. Likely a + dedup/collision keyed on occurrence timestamps rather than event uid somewhere in the + search result assembly. Unrelated to the duration filter (reproduces with it set to + Any). Not fixed in this session. +- **Test hygiene note:** `EventListDurationFilterTests` leaves its UUID-keyed + `EventListDurationFilterTests..maxDuration` entries in the simulator app's + UserDefaults plist (keys are unique per run, so no cross-test pollution — just litter). + +## Part F — Stale first row again: anchor-id collision on same-day filter changes (FIXED) + +**Symptom (user screenshot, 26.5 sim, 3:21 PM):** Events tab, THU 3 selected; first row +shows "Drama Dump & Gift — Thu 12:00am (12h)" even though the max-duration filter is 6h. +Rows 2+ correct (Midnight Tacos 30m, Midnight Ramen 2h, Jazz Jam Session 2). + +**Diagnosis — stale rendered row, not a query bug.** The sim's app-container plist held +`eventListFilter.maxDuration = {"limited":{"_0":21600}}` (6h), so the SQL query provably +excluded 12h occurrences at screenshot time; the rendered 12h row could not be in the +result set. Confirmed by the fixed build: THU hour-0's true first row is +"Sunset to Sunrise at the LandHo! Port (6h)" — in the user's screenshot the stale Drama +Dump row sat exactly where LandHo should be, with rows 2+ matching the query. + +**Root cause** (`iBurn/ListView/EventListView.swift`): the b211090 fix namespaced the +hour-scrub anchor id by day (`HourAnchorID{day, hour}`), which fixes day-switch collisions +but is still a *positional* identity. Any same-day data change that swaps which row is +first in an hour section — moving the Max Duration slider (Any ↔ 6h), toggling an event +type, etc. — re-emits the buckets while day+hour stay constant, so the NEW first row gets +the SAME anchor id as the OLD one and the persistent LazyVStack resurrects the cached old +row view (stale label + stale tap closure; same mechanism as Part B, different trigger). + +**Fix:** eliminate synthesized positional identity. First-in-section rows now use their own +occurrence uid as the anchor id (`button.id(row.object.uid)` — identity ≡ content, so +collisions are impossible for any data change), and the hour-index overlay resolves +hour → first-row uid from `viewModel.browseSections` at `scrollTo` time (with a guard for +vanished sections). `HourAnchorID` and `anchorDay` deleted; `rowButton` takes +`isScrollAnchor: Bool` instead of `scrollAnchorHour: Int?`. + +**Verification (26.5 sim, fixed build):** THU first row = LandHo 6h (inclusive boundary +still honored, no 12h rows); filter sheet type toggle 🎉 off → first row updates live to +Midnight Tacos 30m (this exact step went stale pre-fix), toggle back on → LandHo returns; +first-row tap pushes the correct occurrence detail (Thursday 9/3 12:00 AM–6:00 AM); +hour scrub still scrolls (short jumps land exactly; a cross-day-length jump, e.g. +12am → 8pm, can land on a blank viewport until the next touch materializes rows — a +pre-existing LazyVStack far-target estimation artifact, identical under the old id scheme +since scrollTo resolves the same destination row). iBurnTests suite green. + +**Automation notes:** the strip's digit labels are text-only AX elements — `tap` refuses +them, but `touch {down:true, up:true}` on the digit's elementRef drives the scrub +(the "8 PM" scrubber bubble may stick afterwards because the synthetic touch skips the +DragGesture `.onEnded` reset — harmless artifact, not app state). + +## Part G — "Didn't work" report resolved (stale binary) + filter sheet tap-to-set & Reset + +**User re-report after Part F:** first row still stale (5h45m "Drama Dump & Gift" under a +1h cap, dead tap). **Root cause of the re-report: the user was running the pre-fix +binary.** The Part F fix (3c2065b) exists only on the worktree branch +`event-duration-filter`; the main checkout (`2026-updates`, what Xcode builds) is at +dc37275. Confirmed forensically: the installed app's `iBurn.debug.dylib` (built 14:06 from +the main checkout's DerivedData `iBurn-hgliinvssaqjsefpgmponufvwfxe`) contains the old +`HourAnchorID` symbol and lacks the new `isScrollAnchor` — the user's Xcode ⌘R overwrote +the fixed build I had installed at 15:29. The reported symptom (5h45m Sun-6pm +first-in-section row surviving a 1h cap with a dead tap) is exactly the old positional- +anchor collision. **Lesson: after fixing something in a worktree, the fix must land on the +branch the user builds from (or they must run the worktree build) before they retest.** + +**New filter-sheet features (same session, per user request):** +- **Tap-to-set slider — ADDED THEN REVERTED.** First attempt: stock Slider + + `simultaneousGesture(DragGesture(minimumDistance: 0))` in a GeometryReader mapping + tap x → nearest step (+ 7 unit tests). User direction: "go back to basics" — the + gesture/GeometryReader layering was deemed too fancy, so the sheet is back to the + bare stock `Slider` with inline min/max labels (48dcf9c's slider changes undone in + a follow-up commit; tests deleted). Consequence: track taps don't set the value + (stock Slider behavior); only thumb drags do. +- **Reset button:** appears in the sheet toolbar (cancellation slot) only when any exposed + control differs from defaults (hide expired / all favorites / all types / 6h cap); + resets field-by-field so unexposed fields (dates, searchText, activeWindow) are + untouched. +- **Filter icon consistency:** toolbar icon now fills iff the same "differs from default" + predicate holds (previously `!includeExpired` made it permanently filled at defaults; + duration was ignored entirely). + +**Verified in sim (26.5, binary symbol-checked before driving):** Reset appears on +type-toggle, restores defaults, disappears; icon outline↔fill tracks default/non-default; +list live-updates behind the sheet. Track-tap can't be driven by the AX tooling (the +slider exposes no AX element via XcodeBuildMCP snapshots) — covered by the unit tests; +needs one manual tap check. Full iBurnTests suite green (140). + +## Part H — Root-cause hardening after third "still broken" report (stale binary again) + +**8:23 PM user screenshot** (SUN 30, 1h cap, stale 5h45m "Drama Dump & Gift" first cell): +installed dylib was STILL the 14:06 pre-fix build (`HourAnchorID` present, +`isScrollAnchor` absent), main checkout still dc37275 — the ff-merge from Part G was +permission-blocked for the session and never run manually. No fixed binary has ever been +user-tested up to this point. + +Per user direction the slider went back to fully stock (tap-to-set gesture + its 7 tests +removed — track taps don't set the value again; only thumb drags). Reset button and the +differs-from-default filter icon remain. + +**Hardening (belt & suspenders, closes every remaining path to a stale row):** +1. **No explicit `.id()` on rows at all.** The scrub strip's `scrollTo` targets the + ForEach identity (`\.object.uid`) directly — the conditional IDView wrapper (a + branch-switch remount source and the historical collision surface) is gone entirely. +2. **Observation generation guard** (`EventListViewModel.observationGeneration`): rapid + filter changes (slider drag ticks) cancel-and-restart the GRDB observation many times + in a burst; a superseded observation's in-flight emission could land after the newest + one and overwrite `dayBuckets` with rows the current filter excludes (renders stale + rows whose taps fail the visibleRows guard). Emissions now no-op unless their + generation is current, on both browse and search paths. + +**Verified on the fixed binary (symbol-checked), user's exact persisted state (1h cap):** +SUN 30 6pm = The Surge (15m) / Inflatable Wildlife (1h) / Radical Humanity (15m) / +Queeratorio (1h) — no Drama Dump, matching the query; first-cell tap pushes the right +detail; 12 rapid type-toggle filter changes (batch) leave zero stale/duplicate rows; +hour scrub lands on 8 PM via ForEach-identity scrollTo. iBurnTests 133 green. + +### Worktree build note (for future sessions) + +Building an app-repo worktree without re-cloning everything: symlinking `Pods/` to the main +checkout works, and non-SwiftPM submodules can stay empty (Pods' dev-pod file refs resolve +relative to the real Pods dir), but `Submodules/iBurn-Data` must be a REAL checkout — +SwiftPM's sandboxed manifest loader refuses symlinked package roots ("manifest … cannot be +accessed"). `git clone --local
/Submodules/iBurn-Data /Submodules/iBurn-Data` + +`checkout ` is fast (hardlinked objects). Also copy `BRCSecrets.m`, +`InfoPlistSecrets.h`, `GoogleService-Info.plist` into `/iBurn/`. And beware `git reset +--hard` in such a worktree: it replaces submodule-path symlinks with empty dirs. diff --git a/Docs/2026-07-25-bmorg-geojson-refresh-gate-road.md b/Docs/2026-07-25-bmorg-geojson-refresh-gate-road.md new file mode 100644 index 00000000..8145942c --- /dev/null +++ b/Docs/2026-07-25-bmorg-geojson-refresh-gate-road.md @@ -0,0 +1,166 @@ +# 2026-07-25 — BMorg GeoJSON refresh + Gate Road layer + +## High-Level Plan + +**Problem**: BMorg published an update to the official GIS dataset +(`burningmantech/innovate-GIS-data`). The 2026 map tiles were generated from the +previous commit, so the shipped tiles were stale. + +**Solution**: Bump the nested `bmorg/innovate-GIS-data` submodule, regenerate +`data/2026/Map/Map.bundle/map.mbtiles`, and add the newly-published Gate Road as +a rendered layer. + +**Key Changes**: +1. `Submodules/iBurn-Data/bmorg/innovate-GIS-data`: `3c69f43` → `e9e33e0`. +2. Regenerated `data/2026/Map/Map.bundle/map.mbtiles` — now **9 layers / 952 + features / 290,816 bytes** (was 8 / 949 / 286,720). +3. Added a `gate-road` line layer to `iburn-light.json` and `iburn-dark.json`. +4. Checked in `scripts/rename_official_streets.py` so the street-rename step is + reproducible (it was an unsaved temp script last time — see "Reproducibility"). +5. Updated the tippecanoe command in iBurn-Data `CLAUDE.md` + `README.md`. +6. `drive-app` skill: corrected the Appirater dismissal note in `flows.md`. + +## Technical Details + +### What changed upstream (`3c69f43..e9e33e0`, "2026 cpns gate road (#8)") + +Two files: + +- **`2026/GeoJSON/cpns.geojson`** — still 59 features; 4 gate-area CPNs moved + (BMorg realigned the gate complex): + + | CPN | old | new | + |---|---|---| + | Box Office | `-119.239013,40.765055` | `-119.235670,40.768227` | + | D Lot | `-119.239329,40.764309` | `-119.233825,40.768178` | + | Gate Actual | `-119.237693,40.765087` | `-119.234088,40.768725` | + | Will Call Lot | `-119.240781,40.764932` | `-119.237046,40.768406` | + + No renames, so **no style-filter or `imageMap` changes were needed** — + `Will Call Lot` and `D Lot` remain in the points-layer `!in NAME` exclusion + list, `Box Office` and `Gate Actual` still render. + +- **`2026/GeoJSON/gate_road.geojson`** — new file, 3 LineStrings (148 points + each), properties carry only `FID` (no name). This was the only file in + `2026/GeoJSON/` not being tiled. + +### Tile regeneration + +Run from `Submodules/iBurn-Data/data/2026/`. `-t "$TMPDIR"` is required under the +Claude sandbox. + +```bash +python3 ../../scripts/rename_official_streets.py \ + layouts/layout.json \ + ../../bmorg/innovate-GIS-data/2026/GeoJSON/street_lines.geojson \ + "$TMPDIR/street_lines_named_2026.geojson" + +tippecanoe -t "$TMPDIR" --output=Map/Map.bundle/map.mbtiles -f \ + -L fence:../../bmorg/innovate-GIS-data/2026/GeoJSON/trash_fence.geojson \ + -L outline:../../bmorg/innovate-GIS-data/2026/GeoJSON/street_outlines.geojson \ + -L points:../../bmorg/innovate-GIS-data/2026/GeoJSON/cpns.geojson \ + -L blocks:../../bmorg/innovate-GIS-data/2026/GeoJSON/city_blocks.geojson \ + -L plazas:../../bmorg/innovate-GIS-data/2026/GeoJSON/plazas.geojson \ + -L streets:"$TMPDIR/street_lines_named_2026.geojson" \ + -L toilets:../../bmorg/innovate-GIS-data/2026/GeoJSON/toilets.geojson \ + -L dmz:../../bmorg/innovate-GIS-data/2026/GeoJSON/dmz.geojson \ + -L gate_road:../../bmorg/innovate-GIS-data/2026/GeoJSON/gate_road.geojson \ + -z 14 -Z 4 -B0 +``` + +Result: + +``` +bounds = -119.273565,40.745943,-119.181240,40.803521 (was -119.240781,40.760545,-119.181240,40.803521) +tiles = 35 (was 28) +layers = blocks dmz fence gate_road outline plazas points streets toilets +size = 290816 bytes (was 286720) +``` + +The west edge moves twice, for two independent reasons: + +1. The 4 gate CPNs moving **east** pulled the old western bound in + (`-119.240781` → `-119.237418` on the 8-layer intermediate run). +2. Adding `gate_road` then pushed it far **west** to `-119.273565`, since the + approach road runs ~4 km southwest toward the highway. + +### Reproducibility fix + +The 2026-07-13 session generated the renamed streets file with an ad-hoc script +in a session scratchpad, which was garbage-collected — the mbtiles +`generator_options` still pointed at +`/private/tmp/.../1f727389-.../street_lines_named_2026.geojson`, a path that no +longer exists. That script had to be rewritten from the prose description in +`Docs/2026-07-13-official-2026-map-tiles.md`. + +It's now checked in at `scripts/rename_official_streets.py`. Verified the +committed script reproduces the exact input used for these tiles +(`cmp` → byte-identical), renaming 291 of 573 features: + +``` +ESP→Esplanade(17) A→Ararat(18) B→Bodhi(18) C→Chomolungma(18) D→Delphi(16) +E→Eternal(16) F→Fulcrum(32) G→Great Oak(32) H→Heiau(32) I→Iroko(32) +J→Jiba(28) K→Kundalini(32) +``` + +### Gate Road style layer + +`street_outlines` is a **fill** polygon (it carries the street bodies) and the +`streets` layer is symbol/labels only — so Gate Road, being a LineString with no +polygon counterpart, needed a real `line` layer. Inserted directly after +`outline` so it draws over the fence it crosses but under camp boundaries: + +```json +{ + "id": "gate-road", + "type": "line", + "source": "composite", + "source-layer": "gate_road", + "layout": {"visibility": "visible", "line-join": "round", "line-cap": "round"}, + "paint": { + "line-color": "#C3B8AB", + "line-width": {"base": 1.4, "stops": [[8, 0.5], [12, 1.5], [14, 3], [17, 8], [22, 20]]} + } +} +``` + +`line-color` matches each theme's `outline` fill so the road reads as the same +material as the city streets: `#C3B8AB` (light) / `#574e26` (dark). Layer order in +both styles is now `… fence → outline → gate-road → camp-boundaries → …`. + +Features carry no `name`, so the line is deliberately unlabelled. + +## Verification + +- `tippecanoe-decode` over all 35 tiles: `gate_road` present with 57 tiled + segments; tiled extent `-119.273758,40.745176 → -119.223633,40.772222` matches + the source (small overshoot is the tile buffer). +- All 4 moved CPNs confirmed at their **new** coordinates in the max-zoom tiles; + no features remaining at the old gate positions. +- All 12 themed street names present in the `streets` layer; zero leftover + single-letter names. +- Both style files parse as valid JSON; `source-layer` is `gate_road`, matching + the tippecanoe layer name exactly. +- `xcodebuild` iBurn scheme: **0 errors, 0 warnings**. +- Drove the app in the simulator (iPhone 17 Pro Max, iOS 26.2): city renders with + themed street labels; panning southwest shows the three Gate Road lines running + off toward the highway. Confirmed visually. + +No app-target code changes were required. The stale-tile-cache fix from +2026-07-13 (`iBurn/Bundle+iBurn.swift:87`, `FileManager.contentsEqual`) is still +in place, so existing installs will pick up the regenerated tiles on next launch. + +## Cross-References + +- `Docs/2026-07-13-official-2026-map-tiles.md` — original migration to official + BMorg geometry; CPN rename table and the stale-cache fix. +- `Submodules/iBurn-Data/Docs/2025-07-19-map-tiles-official-data.md` — 2025 work. +- `Submodules/iBurn-Data/CLAUDE.md` / `README.md` — canonical tippecanoe command. + +## Expected Outcomes + +- 2026 map tiles reflect BMorg's latest gate-complex geometry. +- Gate Road renders in both light and dark themes, giving arriving burners the + approach road from the highway to the gate. +- Regenerating tiles next time is a two-command process with no unsaved + intermediate steps. diff --git a/Docs/2026-07-25-geocoder-handoff.md b/Docs/2026-07-25-geocoder-handoff.md new file mode 100644 index 00000000..1bc391de --- /dev/null +++ b/Docs/2026-07-25-geocoder-handoff.md @@ -0,0 +1,162 @@ +# Handoff — org-GeoJSON geocoder (branch `reverse-geocoder`) + +**Status: complete and verified. Ready to merge.** +Full session notes and rationale: `Docs/2026-07-25-reverse-geocoder-2026-audit.md`. + +## What this delivers + +BMorg's official GeoJSON (`bmorg/innovate-GIS-data`) is now the source of truth +for geocoding in **both** directions, replacing the handcrafted `layout.json` +city synthesis. Forward matters as much as reverse because the API data pipeline +geocodes camp GPS from playa addresses. + +It also fixes two wrong 2026 street facts that had reached shipped artifacts: + +| | was | now | reached | +|---|---|---|---| +| C street | Chomolungma | **Ceiba** | layout, geocoder bundle, **map tile labels** | +| Center Camp frontage arc | (unnamed → `"6:26 & undefined"`) | unnamed, falls back to nearest real street | geocoder bundle | + +Rod's Road was removed by BMorg for 2026; the `Rods Road` features still in the +GIS drop are carryover and are listed as `retired_streets` in the config. + +## Commits to merge (3 repos, bottom-up) + +Nothing is pushed. **Push in this order** — the parent repo's gitlink is +unreachable until the submodule commits exist on their remotes. + +### 1. BlackRockCityPlanner (`git@github.com:iBurnApp/BlackRockCityPlanner.git`), branch `2026-updates` +``` +8f8a932 Geocoder over BMorg's official GeoJSON, both directions +3b84cb5 2026 corrections: C street is Ceiba; Rod's Road no longer exists +7a8ae48 2026 layout support: name the frontage arc, never emit undefined streets +``` +Base: `ec84cd3`. New code lives in `src/orggeocoder/`; `src/geocoder/` (legacy) +is retained as the fallback for years with no GIS drop. + +### 2. iBurn-Data, branch `2026-updates` +``` +84a16bb 2026 geocoder: ship the org-GeoJSON build; add per-year geocoder configs +c69e44d 2026 corrections: C street is Ceiba; drop Rod's Road (street removed) +f8911da 2026: Rod's Road on the Center Camp frontage arc; geocoder validated vs BMorg GIS +``` +Base: `c9f7bdb`. + +> **Check the remote before pushing.** This worktree's `Submodules/iBurn-Data` +> has `origin = iBurnApp/iBurn-Data.git`, but the main checkout at +> `~/Documents/Code/iBurn-iOS` has `origin = iBurnApp/iBurn-Data-Private.git`. +> Push from the main checkout, or confirm with the user which is correct. +> (Same caution for the planner: this worktree's `origin` is a **local path** +> set up for submodule syncing; the main checkout has the real GitHub URL.) + +Note `f8911da` names the frontage arc "Rod's Road" and `c69e44d` reverts that. +The intermediate state is wrong but the sequence is honest about the correction; +squash if you prefer a clean history. + +### 3. iBurn-iOS, branch `reverse-geocoder` +``` +a05ca4d Bump iBurn-Data: geocoder now reads BMorg's official GeoJSON +b48ef7b Bump iBurn-Data: C street is Ceiba, Rod's Road removed (2026 corrections) +156ae24 Bump iBurn-Data: 2026 geocoder validated vs BMorg GIS; Rod's Road fix +``` +Base: `8cbdbde`. Only two kinds of change: the `Submodules/iBurn-Data` gitlink +and two `Docs/` files. **No app source was modified.** + +## Merging into `2026-updates` + +`2026-updates` has moved 7 commits ahead of this branch's base (PlayaDB +migration work + a CLAUDE.md simulator bump). **None of them touch +`Submodules/iBurn-Data`**, and both the old and new tips record the same +submodule SHA (`c9f7bdb`), so there is no gitlink conflict. + +Verified conflict-free — `git merge-tree --write-tree 2026-updates +reverse-geocoder` exits 0: + +```bash +git rebase 2026-updates reverse-geocoder # clean +``` + +After merging, the submodule must be at `84a16bb`: +```bash +git submodule update --init --recursive +git -C Submodules/iBurn-Data rev-parse HEAD # 84a16bb… +``` + +## Loose ends the merging agent must handle + +1. **Android bundle is updated but UNCOMMITTED.** `iBurn-Android` has a modified + `iBurn/src/main/assets/js/bundle.js` (verified byte-identical to the shipped + iOS bundle). It had still been the **2025** build, so this is a real fix, but + it belongs to a repo outside this branch's scope — commit it there separately. + +2. **The full `iBurn` app target was never built.** This worktree has no `Pods/` + installed (pre-existing condition, unrelated to these changes), so + `xcodebuild -scheme iBurn` fails at the Pods xcconfig. What *was* verified: + the `PlayaGeocoder (iOS)` framework builds clean (0 errors, 0 warnings) and + embeds the correct 868KB bundle. Since no app source changed and the bundle + path in `PlayaGeocoder.xcodeproj` is unchanged, risk is low — but do a build + from the main checkout before merging. + +3. **`PlayaGeocoderTests` was not executed** — the `PlayaGeocoder (iOS)` scheme + has no test action configured. Its three assertions were instead verified + directly against the new geocoder (`"6:15 & A"` and `"A & 6:15"` return + identical valid coordinates; `"Center Camp Plaza @ 7:30"` resolves; reverse + returns non-nil). Wiring up the test action would be a small win. + +## Verification evidence + +Re-runnable from `Submodules/iBurn-Data/scripts/BlackRockCityPlanner`: + +```bash +npm install && npm test # 17/17 files pass, coverage gates met, exit 0 +``` + +| check | result | +|---|---| +| 512 official radial×ring intersections → reverse | 489 exact, 23 correctly named as the plaza on top, **0 wrong** (legacy: 44 wrong + 4 misclassified) | +| 1369 published 2025 camp addresses → forward | **all resolve** (legacy: 30 fail), median **7'** from published GPS, 98% within 150' | +| parity sweep, 2773-point city grid | 95.6% identical to legacy or within 5'; all 123 remaining differences favor org data | +| real JavaScriptCore (iOS engine) | 47ms setup, correct results | +| Android J2V8 call pattern | `window.prepare()` / `reverse` / `forward` / `forwardAsString` all correct | +| bundle | 868KB, prepares in 13ms (legacy: 1.2MB, 36ms) | +| map tiles | `tippecanoe-decode`: 211× Ceiba, 0× Chomolungma | + +The forward-accuracy number measures *agreement*, not independent truth: those +published 2025 coordinates were themselves produced by the legacy geocoder. Its +tail is portals, where the org CPN is surveyed truth and legacy's position was +computed. + +## How the pieces fit (for whoever maintains this next) + +- `src/orggeocoder/schema.js` — absorbs BMorg's year-to-year schema drift. + 2024/25 ship `{type: arc|radial, width}` with themed ring names; 2026 ships + `{source: annular|radial, kind, width_ft}` with bare letters; the plaza name + key changed case. Expect this to need extending each year. +- `data//geocoder/config.json` (~20 lines) — the only handcrafted input + left: letter→themed-name map, city bearing, `retired_streets`, and + `gap_landmark_cpn` (names the Center Camp keyhole, where Esplanade has a real + 5:45–6:15 gap). Configs exist for 2025 and 2026. +- **Bundle build is two steps** (browserify can't `require` a `.geojson`): + ```bash + node src/cli/build_geocoder_data.js --data-root ../../ --year 2026 \ + --output ../../data/2026/geocoder/geocoder-data.json + browserify src/orggeocoder/index.js -o ../../data/2026/geocoder/bundle.js + ``` + The year is now hardcoded in exactly one place (`src/orggeocoder/index.js`), + down from two. +- `src/orggeocoder/factory.js` — CLI tools keep their `--layout` argument and + infer year/checkout-root from it, so every documented pipeline command is + unchanged while running on org data. + +## Suggested next work (not blocking) + +- Source POIs from `cpns.geojson`: `poi.json` still places Greeters and the + Airport by time+distance, landing 263' and 700' from their official CPNs. +- Point `PlayaGeo` (watch renderer) at org GeoJSON so `data//geo/*` and + `layout.json` can retire for GIS-covered years. +- Native Swift/Kotlin ports of the reverse geocoder — it needs only bearing, + haversine distance, point-in-polygon and a sorted-sample lookup (no + turf/JSTS). `tests/OrgGeocoderTest.js`'s 512-intersection sweep is the shared + conformance vector. This removes the JSContext startup cost behind the three + blocking iOS call sites and would give the watch app addresses it currently + lacks. diff --git a/Docs/2026-07-25-playadb-default-yap-audit-and-migration.md b/Docs/2026-07-25-playadb-default-yap-audit-and-migration.md new file mode 100644 index 00000000..64072133 --- /dev/null +++ b/Docs/2026-07-25-playadb-default-yap-audit-and-migration.md @@ -0,0 +1,371 @@ +# 2026-07-25 — Yap→PlayaDB default audit + remaining feature migration + +**Branch:** `2026-updates` +**Related:** `2026-07-11-swiftui-lists-default-on.md`, `2026-07-07-architecture-analysis-and-roadmap.md`, +`2026-07-03-playadb-audit-and-improvements.md` + +## High-Level Plan + +Chris: *"audit the remaining migration we need to get off of yapdatabase for the 2026 release by +default. we can keep yapdb and legacy code around, but let's ensure all features use the new +playadb code via feature flag by default. then proceed to use Opus 5 subagents at high effort +level for implementation."* + +Four parallel Explore agents audited (1) map pins/breadcrumbs, (2) Visit List + Audio Tour, +(3) misc default-path Yap consumers, (4) the network update pipeline. Findings consolidated +below; scope decisions confirmed with Chris; implementation runs as Opus subagents in two waves. + +### Scope decisions (Chris, 2026-07-25) + +1. **OTA updater (PlayaUpdateService): KEEP DEFERRED.** Consistent with the 07-11 decision — + PlayaDB stays bundle-seeded for 2026; August drops reach PlayaDB via app updates. +2. **Audio Tour: add `audio_tour_url` column** to PlayaDB (migration v4) + PlayaAPI field, + matching legacy semantics (remote URL OR local file). +3. **Map "Visible Pins" list: rebuild on PlayaDB** (it is silently broken/empty today). +4. **Calendar sync (EKEvent): migrate to PlayaDB-native NOW** (Chris chose this over keeping + the Yap bridge), flag-gated with the Yap hook retained as fallback. + +## Audit Findings (2026-07-25) + +### Already fully PlayaDB — the 07-07 roadmap doc was stale on these + +- **User map pins**: 100% PlayaDB (`user_map_pins`) since commit `99587a3` (2026-04-05). All + six CRUD paths verified: display `FilteredMapDataSource.swift:30-38`, create (sidebar star + `MainMapViewController.swift:238-250`, bike/home, deep link `BRCDeepLinkRouter.swift:172-181`), + edit title/drag (`UserMapViewAdapter.swift:65-71,302-341`), delete (`:271-283`), UserGuidance. + `BRCUserMapPoint` survives only as an in-memory MapLibre annotation adapter + (`BRCUserMapPoint+PlayaDB.swift`). +- **Breadcrumbs/location history**: standalone GRDB `LocationHistory.sqlite` + (`iBurn/Tracks/LocationStorage.swift`) — never Yap in the shipping path. +- **Deep links**: `BRCDeepLinkRouter` resolves via PlayaDB, pushes PlayaDB SwiftUI detail; + the `import YapDatabase` is unused. +- **Embargo unlock**: UserDefaults-only in both directions (`EmbargoPasscodeViewModel.swift:93-101`, + `BRCEmbargo.m:38-52`). No DB touched on unlock. +- Recently Viewed, Mutant Vehicles, AI Guide, Data Updates screen UI, Credits, watch app: PlayaDB. + +### Remaining default-on Yap feature surfaces + +1. **Visit List** (More → Visit List, unconditional `MoreViewController.swift:403-407`). + Fully Yap: `allObjectsGroupedByVisitStatusViewName` grouped view (`BRCDatabaseManager.m:384-410`) + + `refreshVisitStatusGroupedView` versionTag hack, Yap FTS search via `SearchDisplayManager`, + Yap cells, `YapViewAnnotationDataSource` map button. PlayaDB already has: + `object_metadata.visit_status` (+`visit_status_updated_at`), `setVisitStatus`/`fetchObjects(visitStatus:)` + (`PlayaDBImpl.swift:1991-2059`), watch precedent (`FavoritesScreen.swift:196-207`), and the + `FavoritesViewModel`/`RecentlyViewedViewModel` multi-type section patterns to copy. No + reactive visit-status observation API (one-shot fetch; matches RecentlyViewed precedent). +2. **Audio Tour** (More → Audio Tour, unconditional `MoreViewController.swift:458-462`). + `SortedViewController` over Yap filtered view `audioTourViewName` (filter + `art.audioURL != nil`, `BRCDatabaseManager.m:411-429`). PlayaDB `ArtObject` has **no audio + column**; SwiftUI rows already resolve audio from disk (`MediaAssetProviding.localAudioURL`), + `BRCAudioPlayer` already has DB-agnostic `BRCAudioTourTrack` API (`BRCAudioPlayer.swift:13-33,154-180`). + 2026 data currently has 0 `audio_tour_url` and 0 `.m4a` (arrives late season; 2025 had 87). + `BRCMediaDownloader`'s instance/download path is dead code — never instantiated; only its + static path helpers are used. So audio arrives only via bundled MediaFiles today. +3. **Map "Visible Pins" list** (`MapPinListViewController` via `ListButtonHelper.swift:29-34`). + Yap-based (`SortedViewController`) and **functionally broken**: collects only + `DataObjectAnnotation`, but the default map emits `PlayaObjectAnnotation` + `BRCUserMapPoint` + → always empty. +4. **Data Updates screen actions**: UI is PlayaDB, but "Check for Updates"/"Reset" drive the Yap + importer and then `reimportPlayaDB()` **from the bundle** (`DataUpdatesView.swift:205-247,262-286`). + Fine while OTA-for-PlayaDB is deferred; becomes a clobber-hazard when it lands. +5. **Boot**: Yap open + bundled preload + OTA-to-Yap always run (`BRCAppDelegate.m:87-107`) — + intentionally kept (feeds the kill-switch stack). But `ColorCache.prefetchAllColors` + (`BRCAppDelegate.m:106`) does duplicate Yap-side color work every launch alongside PlayaDB's + `ColorPrefetcher` (`DependencyContainer.swift:109-113`). +6. **Calendar/EKEvent**: identity lives only in Yap metadata (`calendarEventIdentifier`), + written by `BRCEventObject.m:224-311` via the `FavoriteSyncService` hook + (`FavoriteSyncService.swift:236-241`) and legacy `DetailDataService.swift:29-42`. EKEvents + are per-occurrence (legacy Yap splits events per-occurrence as `"-"`), alarms + −90/−10 min. → migrating per decision 4. + +### Intentional bridges (keep for 2026) + +`FavoriteSyncService` PlayaDB→Yap favorites mirror (+ occurrence fan-out), notes/visit +dual-writes, Yap boot import. These keep the kill-switch stack coherent. + +### Latent bugs found (fixing in this pass) + +- `BRCDataObjectTableViewCell.swift:25-47` — legacy cell heart syncs PlayaDB with the raw Yap + uid; for events (`"-"`) the PlayaDB write silently no-ops. Reachable **by default** + today via Visit List / Audio Tour / MapPinList cells. +- `DetailViewModel.swift:577-584` `syncNotesToYapDB` — writes with raw PlayaDB uid into + `BRCEventObject.yapCollection`; never matches per-occurrence Yap keys. +- **Embargo passcode unlock never refreshes PlayaDB observations**: + `PlayaDBAnnotationDataSource.startObserving()` captures `embargoAllowed` once (`:57`); the six + list hosting controllers snapshot it too; `MoreViewController.showUnlockView` only reloads its + table → locations don't appear until relaunch. +- Pin de-dup identity: `MapViewAdapter.swift:69-72` keys `BRCMapPoint` by `yapKey`, which is a + fresh random UUID per DB rebuild (`BRCYapDatabaseObject.m:19-26`) — can never match; stable id + is `pinId`. +- `LocationStorage.setup()` never calls `start()` → breadcrumbs record only after visiting + More → Location History once per launch; `TracksViewController.setupStorage()` builds a + second `LocationStorage` on the same file instead of `.shared`. +- PlayaDB→Yap visit-status mirror doesn't call `refreshVisitStatusGroupedView` → legacy Visit + List groups stale under kill-switch. +- Zero tests for `UserMapPin` (package or app). +- Stale docs/comments: roadmap §1.3/§4 rows for pins/breadcrumbs; `FilteredMapDataSource` header; + `FavoritesFilterable.swift:13-14` claims Yap is favorites source of truth. + +### Network pipeline facts (for the deferred OTA bridge, future reference) + +- Server JSON is byte-identical in shape to bundled JSON; `importFromData` accepts it as-is; + `needsImport(bundleUpdateData:)` works on any update.json `Data` (`PlayaDBImpl.swift:2299-2317`). +- `importFromData` is full-replace, single transaction, all-types-required (only `mvData` + optional — and nil leaves stale MV rows); `object_metadata`/`thumbnail_colors`/`user_map_pins` + survive by construction; orphaned metadata rows never GC'd; no metadata-survival regression test. +- Yap importer discards `mv.json` silently (`BRCUpdateInfo.m:82-98` has no `mv` type). +- Downloaded JSON is never persisted (in-memory only, `BRCDataImporter.m:489`); no hand-off surface. +- Watch is bundle-only by design (`WatchSeeder.swift:14-15`). + +## Implementation Plan (Opus subagents) + +**Wave 1 (parallel):** +- **D — PlayaDB package schema**: migration `v4-audio-tour` (`art_objects.audio_tour_url` + + PlayaAPI `Art` field + import mapping + `ArtFilter` support) and `v5-calendar-entries` + (per-occurrence EKEvent identifier storage + CRUD API) + package tests. +- **A — Visit List SwiftUI/PlayaDB**: `VisitListView`/VM/hosting controller (segmented + All/Want to Visit/Visited, ⭐/✅ sections, multi-type rows, map button, paging detail), + `MoreViewController` branch on `useSwiftUILists`, tests. +- **B — Map pin list rebuild + map Yap cleanups**: PlayaDB-native visible-annotations list for + the main map; keep legacy class for legacy `MapListViewController` contexts; delete dead Yap + connections/imports (`MainMapViewController.swift:21-22,49-50`, `MapViewAdapter`, + `BRCDeepLinkRouter`); fix pin de-dup identity; `UserMapPin` package tests. +- **C — Cross-cutting fixes**: cell/notes uid bugs, embargo-unlock live refresh, ColorCache + prefetch gating on `useSwiftUILists`, LocationStorage start/shared fixes, visit-mirror + regroup call, doc/comment corrections. + +**Wave 2 (parallel, after D lands):** +- **E — Audio Tour SwiftUI/PlayaDB screen**: art-with-audio list (URL or local file), per-row + play, Play All + intro track + SoundCloud via `BRCAudioTourTrack`/`AudioPlayerProtocol`, + `MoreViewController` branch on `useSwiftUILists`, tests. +- **F — PlayaDB-native calendar sync**: `EventCalendarService` (protocol+Impl+factory, + EventKit protocolized for tests), per-occurrence EKEvents with −90/−10 alarms, identifiers in + PlayaDB (v5 API), new flag `Preferences.FeatureFlags.usePlayaDBCalendarSync` default **true**; + when true, `FavoriteSyncService` calendar hook + `DetailDataService` legacy path route through + the new service (single owner across stacks); when false, legacy Yap hook. Tests. + +**Integration (main session):** resolve overlaps, full build + `iBurnTests` + PlayaDB package +tests, sim sanity pass (drive-app flows), flows.md updates, commit(s). + +## Expected Outcomes + +- All More-tab features (Visit List, Audio Tour) + the map's list button run SwiftUI/PlayaDB by + default, flag-fallback to legacy. +- Calendar EKEvents owned by a PlayaDB-native service by default (flagged). +- Default-path Yap writes reduced to: boot import (intentional), favorites/notes/visit mirrors + (intentional bridges). +- Bug fixes above landed with tests. +- Explicitly out of scope: OTA→PlayaDB updater (deferred), watch data updates, Yap deletion + (post-season, roadmap §4). + +## Wave 1 — B outcome (map pin list + map Yap cleanups) + +**New PlayaDB-native "Visible Pins" screen** (`iBurn/ListView/VisiblePins{ViewModel,View,HostingController}.swift`): +sections Art / Camps / Events / Map Pins, nearest-first when a location is available +(alphabetical otherwise), `ObjectRowView` rows for data objects and a compact marker-image +row for `BRCUserMapPoint`s. Rows de-dupe by stable id (art/camp uid, event uid, pin `pinId`). + +- **No database round trip.** `PlayaObjectAnnotation` now carries the object it was built + from (`PlayaAnnotationObject` enum: art / camp / eventOccurrence / event). The list renders + straight off the annotations already on the map, so an event row shows the exact occurrence + that placed the pin instead of re-resolving one. +- **List-button split** lives in `ListButtonHelper.listButtonPressed`: if any annotation inside + the visible bounds is a `DataObjectAnnotation` (legacy Yap-fed maps behind the + `useSwiftUILists` kill-switch), push the old `MapPinListViewController`; otherwise push + `VisiblePinsHostingController`. Covers both attach sites (`MainMapViewController`'s + `FilteredMapDataSource` and `MapListViewController`'s `StaticAnnotationDataSource`). + `MapPinListViewController` is kept, not deleted. +- **Data source**: the screen reads `mapView.annotations` (what `MapViewAdapter` actually put + on the map from `FilteredMapDataSource`) filtered to `visibleCoordinateBounds`, so no new + accessor on `FilteredMapDataSource` was needed and the same code works for every map. +- **Pin tap**: pops back to the map, recenters (unanimated, so the pin is inside the viewport) + and selects the annotation, opening its callout. Data-object rows push the PlayaDB SwiftUI + detail via `DetailViewControllerFactory.create(with:playaDB:)`. +- **Pin de-dup identity fixed**: `MapViewAdapter.keyForAnnotation` now keys `BRCUserMapPoint` + by `pinId` (stable PlayaDB row id) instead of the per-instance random `yapKey`. +- **Dead Yap removed**: `MainMapViewController.uiConnection`/`writeConnection` + its + `import YapDatabase`; `MapViewAdapter`'s `import YapDatabase` and duplicate `import PlayaDB`; + `BRCDeepLinkRouter`'s `import YapDatabase`. +- **Tests**: `Packages/PlayaDB/Tests/PlayaDBTests/UserMapPinTests.swift` (10 tests: save/fetch + round trip incl. nil title, upsert-replace on same id, delete + unknown-id no-op, + `created_date` ordering, observation on insert/update/delete) and + `iBurnTests/VisiblePinsViewModelTests.swift` (sectioning, de-dup, payload-less annotations + ignored, distance vs. alphabetical ordering, untitled-pin naming). +- `flows.md` §6 (Map + embargo) updated to describe the new list screen and the split. + +## Wave 1 — C outcome (cross-cutting fixes) + +Seven fixes from "Latent bugs found". No `project.pbxproj` changes (`iBurn/` and `iBurnTests/` +are `PBXFileSystemSynchronizedRootGroup`s, so new files are picked up automatically). + +1. **Legacy cell heart dropped the PlayaDB write for events** — + `BRCDataObjectTableViewCell.swift`. New testable helper + `BRCDataObjectTableViewCell.playaDBUID(for:)` normalizes event uids via + `FavoriteSyncServiceImpl.apiEventUID(fromYapUID:)` (art/camp pass through untouched, even + when they end in `-`). Yap-write-first ordering preserved. +2. **Notes mirror used the wrong event uid** — new + `FavoriteSyncService.mirrorNotes(type:uid:notes:)` (protocol + Impl) reuses the occurrence + fan-out machinery, *without* the calendar hook, and skips equal-value writes. + `DetailViewModel.syncNotesToYapDB(uid:yapCollection:notes:)` became + `syncNotesToYapDB(type:uid:notes:)` and routes through the service (4 call sites). The + duplicated `"-"` key matching in the Impl was factored into + `FavoriteSyncServiceImpl.occurrenceKeys(from:apiUID:)` — a `[String] -> [String]` helper, so + no YapDatabase type enters a member signature (the swiftmodule/@testable gotcha). +3. **Embargo unlock now refreshes live PlayaDB UI** — no notification existed, so + `iBurn/EmbargoNotification.swift` (new) adds `Notification.Name.BRCEmbargoDidClear` plus an + `@objc(BRCEmbargoNotifier)` shim with `+postDidClear` (Notification.Name extensions are + invisible to ObjC) that hops to the main thread. Posted from + `EmbargoPasscodeViewModel.unlockButtonPressed` and from + `BRCAppDelegate.enteredBurningManRegion` — the latter also snapshots the flag *before* + calling `+allowEmbargoedData`, which itself flips the flag once the festival starts (that + early-return made the existing region-unlock branch effectively dead). + Observers: `PlayaDBAnnotationDataSource` restarts its observations (new `isObserving` gate so + a stopped data source never resurrects) and each of the six list hosting controllers rebuilds + its SwiftUI root view via an extracted `makeRootView()`. `MoreViewController.showUnlockView` + needed no change (it already dismisses + reloads; the SwiftUI unlock view auto-dismisses + 0.5s after `isDataUnlocked` flips). +4. **Duplicate color prefetch** — `BRCAppDelegate.m` now gates `[ColorCache.shared + prefetchAllColors]` behind `!BRCPreferenceService.useSwiftUILists`. The flag is exposed to + ObjC as a new `@objc public static var useSwiftUILists` on the existing `BRCPreferenceService` + bridge (`Preferences/PreferenceServiceFactory.swift`). PlayaDB's `ColorPrefetcher` serves the + default stack. +5. **Breadcrumbs recorded only after visiting Tracks** — `LocationStorage.setup()` now calls + `start()` (which still honors `UserDefaults.isLocationHistoryDisabled`) and is idempotent; + `TracksViewController.setupStorage()` reuses `LocationStorage.shared` instead of opening a + second `DatabaseQueue` + `CLLocationManager` on the same file. +6. **Legacy Visit List grouping staleness** — the visit-status mirror now fires a + `FavoriteSyncVisitStatusDidChangeHook` post-commit, wired in + `FavoriteSyncServiceFactory.shared` to + `BRCDatabaseManager.refreshVisitStatusGroupedView(completionBlock: nil)` (matching + `DetailDataService.updateVisitStatus`). Injected rather than called directly so tests stay + off the shared `BRCDatabaseManager`; the private mirrors now return `didWrite` so the hook + only fires on a real change. +7. **Stale docs/comments** — `2026-07-07-architecture-analysis-and-roadmap.md` got + "**Correction 2026-07-25:**" notes on §1.3's pins/breadcrumbs row, §1.4's map bullet, the + Phase 2 migration bullet and risk 5 (history left intact); `FilteredMapDataSource` header and + `FavoritesFilterable.swift`'s "Yap is the favorites source of truth" claim corrected. + +**Tests** — `iBurnTests/YapPlayaDBBridgeTests.swift` (new, temp Yap DB via +`BRCTestDatabaseHelper` + in-memory PlayaDB, `XCTUnwrap` only): cell uid normalization (incl. an +end-to-end `fetchEvent` miss/hit against a real PlayaDB), notes fan-out to all occurrences + +non-matching-neighbor isolation + clear-with-empty-string + equal-value skip (asserted via +`connection.snapshot`) + unknown-uid/MV no-ops, the visit-status regroup hook firing exactly +once per real change and never on no-op mirrors, and the embargo notification name/post. +`StubFavoriteSyncService` in `VisitListViewModelTests.swift` (Wave 1 — A's file) needed a +one-line `mirrorNotes` stub for the protocol addition. + +**Verification** — `xcodebuild build -scheme iBurn` (iPhone 17 Pro Max, iOS 26.2, arm64): +**BUILD SUCCEEDED**. `xcodebuild test -scheme iBurnTests -only-testing:iBurnTests/YapPlayaDBBridgeTests +-only-testing:iBurnTests/FavoriteSyncServiceTests`: **28 tests, 0 failures** (11 new + 17 existing). +`DetailViewModelTests` + `DetailServicesTests` (the notes-path consumers): **26 tests, 0 failures**. +`project.pbxproj` unchanged (no `DEVELOPMENT_TEAM` churn). Note: the build used the default +DerivedData location rather than a scratch `-derivedDataPath` — the machine was briefly out of +disk space from four concurrent agent builds, and reusing the existing incremental tree avoided a +second 4 GB copy. + +--- + +## Wave 1 — A outcome (Visit List → SwiftUI/PlayaDB) + +New `iBurn/ListView/VisitListViewModel.swift` / `VisitListView.swift` / +`VisitListHostingController.swift`; `MoreViewController.pushVisitListView()` branches on +`useSwiftUILists` (legacy `VisitListViewController` retained as kill-switch). One-shot +`fetchObjects(visitStatus:)` for `.wantToVisit` + `.visited` (no reactive visit-status +observation exists — same pattern as Recently Viewed), refreshed on `viewWillAppear` **and** +`UIApplication.didBecomeActiveNotification`. Segmented All / Want to Visit / Visited, sections +"⭐ Want to Visit" / "✅ Visited" (never an unvisited section, matching legacy), in-memory search +(matching Favorites/RecentlyViewed), hearts through the `*DataProvider`s so the Yap mirror fires, +map button + paged detail per the RecentlyViewed pattern. No visit-status mutation from the list +(legacy parity — the detail screen owns that write and its Yap mirror). Tests: +`iBurnTests/VisitListViewModelTests.swift` (13). + +Follow-up noted, not fixed: on iOS there is no notification when watch favorites/visit status +arrive (`.favoritesSyncDidApply` is watch-only; the phone applies them in `DependencyContainer`'s +`onApplied` closure). Favorites lists get it free via GRDB observation; the Visit List would need +a post from that closure for live in-foreground updates. Also observed: `RecentlyViewedViewModel` +keys event hearts by occurrence uid rather than parent uid, so its event hearts never light up +(pre-existing, untouched). + +## Wave 2 — E outcome (Audio Tour → SwiftUI/PlayaDB) + +New `iBurn/ListView/AudioTourViewModel.swift` / `AudioTourView.swift` / +`AudioTourHostingController.swift`; `MoreViewController.showAudioTour()` branches on +`useSwiftUILists` (legacy `AudioTourViewController` retained). Membership is the union the legacy +screen implied (`art.audioURL = localAudioURL ?? remoteAudioURL`): art with `audio_tour_url` in +PlayaDB (v4 column, filtered at SQL via `ArtFilter(hasAudioTour: true)`) **plus** art with a local +`MediaFiles/.m4a`. Local uids come from a single directory listing behind the +`AudioTourAssetProviding` protocol (stubbable in tests); when no local recordings exist the +observation uses the SQL filter, otherwise it observes unfiltered and applies the union in memory. +Track URLs prefer the local file over the remote URL. Toolbar: Play All, intro track (only when +`MediaFiles/intro.m4a` exists), SoundCloud. Playback goes through the DB-agnostic +`BRCAudioTourTrack` / `BRCAudioPlayer` API. Tests: `iBurnTests/AudioTourViewModelTests.swift`. + +## Wave 2 — F outcome (PlayaDB-native calendar sync) + +New `iBurn/Calendar/EventCalendarService.swift` (actor `EventCalendarServiceImpl`, coalescing +concurrent reconciles), `EventStoreProviding.swift` (EventKit behind a protocol so tests inject a +spy), `LegacyCalendarIdentifierStore.swift` (Yap takeover). New flag +`Preferences.FeatureFlags.usePlayaDBCalendarSync` (default **true**). + +**Single owner per flag state.** `EventCalendarHookRouter.makeCalendarRefreshHook` wraps the +existing `FavoriteSyncCalendarRefreshHook` rather than changing the `FavoriteSyncService` +protocol: flag on → normalize the Yap uid to its API uid and hand off to `EventCalendarService` +(EKEvents owned by it, identifiers in PlayaDB `event_calendar_entries`); flag off → the original +Yap `refreshCalendarEntry` path, untouched. `DetailDataService` gained an optional +`calendarService` and skips its in-transaction `refreshCalendarEntry` when the service owns the +entry, reconciling post-commit instead. `EventCalendarServiceFactory.shared` resolves to +`DependencyContainer.eventCalendarService`, so hook and detail path share one actor instance. +Watch-sync favorites inherit the new path for free (they already funnel through +`FavoriteSyncService`). Legacy alarm offsets preserved (−90 min, −10 min). Takeover: when +reconciling an event with no PlayaDB entries, legacy identifiers are read from Yap metadata, +their EKEvents removed and the Yap identifiers cleared, so upgrading users don't get orphaned +calendar entries. Tests: `iBurnTests/EventCalendarServiceTests.swift`. + +## Integration & Verification (main session, 2026-07-25) + +Both Wave 2 agents were terminated mid-run by a session usage limit **after** writing their +implementation and test files but **before** self-verifying; the integration pass below is +therefore the first verification those two workstreams received. + +- **App build**: `xcodebuild -scheme iBurn` (iPhone 17 Pro Max, **iOS 26.5** — 26.2 runtimes are + gone from this machine): **0 errors, 0 warnings**. +- **`iBurnTests`**: **202 passed, 0 failures**. All six new/touched suites confirmed executed and + passing: `VisitListViewModelTests`, `VisiblePinsViewModelTests`, `AudioTourViewModelTests`, + `EventCalendarServiceTests`, `YapPlayaDBBridgeTests`, `FavoriteSyncServiceTests`. +- **Packages**: PlayaDB **238 passed**, PlayaAPI **71 passed**. +- **Two Swift-6-fatal warnings fixed in new test code** (both would be hard errors under Swift 6): + `EventCalendarServiceTests.swift` held an `NSLock` across an async boundary (extracted a + synchronous `recordEnsureAccess()`); `YapPlayaDBBridgeTests.swift` called the MainActor-isolated + `playaDBUID(for:)` from a non-isolated async test (annotated the test `@MainActor`). Test suite + now compiles warning-free. +- **`project.pbxproj` unchanged** — no `DEVELOPMENT_TEAM` churn to revert. + +### Simulator pass (iPhone 17 Pro Max, iOS 26.5, existing install — upgrade path) + +- **Migrations applied in place** on a pre-existing v1–v3 database: + `v1-initial-schema, v2-favorite-sync, v3-visit-status, v4-audio-tour, v5-calendar-entries`. +- **Map → Visible Pins** (rebuilt screen): correct empty state with no pins in view; after + dropping a Home pin at "2:40 & Eternal" the pin appears in a Map Pins row with walk/bike times. + This screen was silently always-empty before this change. +- **More → Visit List**: renders "⭐ Want to Visit" (Snuggles, favorited) and "✅ Visited" + (Best Butt) with thumbnails and embargo-correct "? min" distances; segments, search field and + map button all present. +- **More → Audio Tour**: correct empty state ("Audio tour content arrives later in the season…"). + Verified this is *correct*, not a failure: the 2026 payload has **0** `audio_tour_url` values, + and the 87 `.m4a` files left on this simulator from the 2025 bundle carry 2025 art uids — + `SELECT COUNT(*) FROM art_objects WHERE uid IN ()` returns **0**. The intro button + correctly appears because `MediaFiles/intro.m4a` genuinely exists on this device. +- **Calendar sync round trip** (the riskiest item, flag default on): favoriting "Welcome Jam + Lounge" wrote `event_calendar_entries` = `(TRMHhqiTnH7viCZ9f2Le, 2026-08-31T01:00:00Z, + A0397B35-…:8E1F3577-…)` — a real EventKit identifier, and the occurrence key is Sun Aug 30 + 6:00 pm PDT expressed in UTC, i.e. correct. Unfavoriting removed both the EKEvent and the row + (`COUNT(*) = 0`, `is_favorite = 0`). A previously-favorited event from an earlier session + (`pZKm9hfsiDbnz8QXueVW`) has no PlayaDB entry, as expected — its legacy Yap-bookkept entry is + taken over on its next reconcile. + +### Deliberately still on Yap after this pass + +Boot-time Yap open + bundled preload + OTA-to-Yap (feeds the kill-switch stack); the +favorites/notes/visit-status Yap mirrors (intentional bridges); the legacy list/detail VCs behind +their flags. **OTA→PlayaDB updater remains deferred per decision 1** — PlayaDB is bundle-seeded +for 2026, so August data drops reach the shipping UI only through app updates. diff --git a/Docs/2026-07-25-reverse-geocoder-2026-audit.md b/Docs/2026-07-25-reverse-geocoder-2026-audit.md new file mode 100644 index 00000000..df2dbb03 --- /dev/null +++ b/Docs/2026-07-25-reverse-geocoder-2026-audit.md @@ -0,0 +1,238 @@ +# 2026-07-25 — Geocoder on BMorg's official GeoJSON (both directions) + +## High-Level Plan + +**Goal** (branch `reverse-geocoder`): make the org's official map GeoJSON +(`bmorg/innovate-GIS-data`) the source of truth for all geo operations, in both +directions — the API data pipeline geocodes camp GPS from playa addresses, so +forward matters as much as reverse — keeping the shared-JS-bundle shape that +lets iOS and Android run the same implementation. + +**Phase 1 — audit + verify the legacy geocoder against 2026. Done.** Found and +fixed an `undefined` street bug; corrected two wrong street facts (below); added +2026 regression coverage where the suite had been pinned to 2025 fixtures. + +**Phase 2 — org-GeoJSON geocoder, forward and reverse. Done and shipped.** +`src/orggeocoder/` now backs the data pipeline and the apps' `bundle.js`. +Native Swift/Kotlin ports remain the endgame; the conformance sweep built here +is their shared test vector. + +## Audit findings (how geocoding works today) + +- **One JS artifact serves both apps.** `BlackRockCityPlanner/src/geocoder/index.js` + hardcodes `require('../../../../data//layouts/layout.json')`; browserify + inlines the layout into `data//geocoder/bundle.js`. `prepare()` + synthesizes every polygon/street **in memory from layout.json at startup** — + the geocoder never reads the generated `geo/*.geojson`. + - iOS: `PlayaGeocoder/PlayaGeocoder.swift` (JavaScriptCore, serial queue), + bundle path pinned at `PlayaGeocoder.xcodeproj/project.pbxproj:46` → + `data/2026/geocoder/bundle.js`. Already on 2026. + - Android: `iBurn/src/main/java/com/gaiagps/iburn/js/Geocoder.kt` (J2V8) runs + `iBurn/src/main/assets/js/bundle.js` — **still the 2025 build** (embeds + `data/2025/layouts/layout.json`, none of the 2026 street names). +- iOS call sites: 9 total — sync (blocking) reverse in `BRCMapPoint.m:209`, + `TracksViewController.swift:145`, `BRCDataImporter.m:330/446`; async nav-bar + address in map/list controllers on a 5 s timer; forward geocoding only at + import (`BRCDataImporter.m:335-341`). Watch app has no geocoding at all. +- `Packages/PlayaGeo` (watch map renderer) already loads `data/2026/geo/*.geojson` + and has the equirectangular projection — natural home for a Swift port + (per `Docs/2026-07-07-architecture-analysis-and-roadmap.md` L270-272). +- `data/2026/geo/*` was freshly generated Jul 3 from the 2026 layout; its only + consumer is the watch app (tiles now come from BMorg data). +- This worktree's `Submodules/iBurn-Data` was an unpopulated stale pointer; + synced it from the sibling clone (`git fetch ` + checkout + `c9f7bdb`, nested submodules via `-c protocol.file.allow=always`). + +## 2026 layout verification (vs BMorg innovate-GIS-data @ e9e33e0) + +- `data/2026/layouts/layout.json` is **current** apart from the C-street name + corrected below: 12 themed names (Esplanade, Ararat, Bodhi, **Ceiba**, Delphi, + Eternal, Fulcrum, Great Oak, Heiau, Iroko, Jiba, Kundalini), center `[-119.207871, 40.783242]` matches + `YearSettings.plist` and org's "The Man" CPN within 4'. Ring radii match org + annular centerlines within ~3' on all 12 rings. New 2:15/9:45 F–I segments and + the two new B plazas (2:00/10:00) are modeled. +- **Validation sweep**: intersected every org radial × annular centerline + (`turf.lineIntersect`, letters mapped to themed names) → **512 ground-truth + points**, reverse geocoded each with the legacy geocoder: + - **Clock times: 512/512 within 3 minutes** (most exact). + - Street names exact except two benign classes: + 1. *Ring-edge epsilon (~25', half a road width)*: points exactly on the + Kundalini or Esplanade centerline can fall just outside the streets-area + polygon → `5:00 & 5760' Outer Playa` instead of `5:00 & Kundalini` + (correct time, distance = ring radius). Below GPS accuracy; documented + in the new test file, not "fixed". + 2. *Center Camp overlap*: org draws A/B rings schematically through Center + Camp; legacy correctly answers `Café` / `Center Camp Plaza` there. +- POI check: `poi.json` (2025 carryover) is all time/distance-relative, so + positions track the moved Golden Spike automatically; Center Camp Plaza and + The Man within 2–4' of org CPNs. + +## Bug found + fixed: `"6:26 & undefined"` near Center Camp + +2026 (and 2025) layouts have no `rod_road_distance`, so the `frontage_arc` is +the Center Camp boundary street — and it reached the reverse candidate set with +`name: undefined`. Any GPS point nearest that arc produced `"