Roo '26 — Architecture & Colophon
How the Bonnaroo 2026 PWA works — stack, edge architecture, constraints, and the decisions behind it.
Roo '26 is a standalone progressive web app for Bonnaroo 2026 at roo26.alkem.dev: full schedule + personal planner, an interactive map, live weather, closed-app push notifications, a live news/alerts pipeline fed by autonomous research, passive trip tracking, anonymous analytics, and offline support — running entirely on Cloudflare's edge with no origin server and no client framework.
01 Overview
One Astro project produces a static PWA plus a single Cloudflare Worker. The Worker serves the static assets and a handful of on-demand /roo26-api/* routes (telemetry, news, push, crew). A second tiny cron Worker (roo-push) dispatches Web Push. Everything the user saves lives in their browser; everything shared lives in Cloudflare KV/D1. No accounts, no passwords, no server to babysit.
🎸 Plan
Full schedule, star sets, overlap + leave-by walk times, share links/QR, calendar export.
🗺️ Navigate
Leaflet map: live distance to stages, food/bars, crew sharing, your trail, compass-to-camp.
📣 Stay current
Push reminders, weather alerts, and a live news feed that overlays day-of schedule changes.
📊 Understand
Anonymous, offline-resilient analytics into a live real-time dashboard.
02 Tech stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Astro 6 | Static-first, zero client JS by default; on-demand routes for the API. |
| Runtime | @astrojs/cloudflare → Workers | Static Assets + a Worker, one deploy, global edge, generous free tier. |
| Client logic | Vanilla JS, one bundle | No hydration, instant, offline-friendly on bad festival signal. |
| Map | Leaflet (lazy-loaded) | Light, offline-tolerant, raster tiles cache well. |
| Shared state | KV · D1 · Analytics Engine · R2 | KV: push/news. D1: telemetry log. AE: live event stream. R2: optional archive. |
| Push | Hand-rolled Web Push | VAPID (RFC 8292) + aes128gcm (RFC 8291) via Web Crypto — zero deps. |
| Cron | roo-push Worker | Scheduled dispatch of reminders + weather alerts. |
| External | NWS · RainViewer · Tradable Bits · Esri/OSM | Weather/alerts, rain radar, official schedule, map tiles. |
| Deploy | Workers Builds | Push to main → Cloudflare builds & ships. Config-as-code in wrangler.jsonc. |
03 Architecture
One roo26 Worker is both the web server (Static Assets) and the API. It writes to KV (push subscriptions, the news doc), D1 (the durable telemetry log), and Analytics Engine (the live event stream), and enriches requests with Cloudflare's request.cf geo. The roo-push cron Worker shares the same KV namespace and fans out Web Push. There is no other backend.
04 The app
Four clean, no-slash routes — /, /map, /plan, /info — are thin wrappers (src-roo26/pages/*.astro) around one component:
_App.astro— all markup + CSS (the app shell, every sheet/modal)._app.js— all client logic in a single bundle (router, schedule, map, planner, push, news, telemetry, onboarding…)._data/*.json— schedule, POIs, artists, food.
No client framework, no hydration. State persists in localStorage; the router is a tiny tab-switch over the four views. The bet: festival phones on weak signal want an app that loads instantly, works offline, and never spins. A service worker pre-caches the shell + official maps.
05 Data model
| File | Holds |
|---|---|
schedule.json | 154 sets, compact {a,s,d,t,e} (artist, stage, day, start, end). |
pois.json | 63 map POIs across stages, food & drinks, water, medical, restrooms, camping, landmarks. |
artists.json | 115 artists — photo, bio, genre, socials, news. |
food.json | 71 official vendors grouped into 10 cuisines (directory). |
Stable IDs & share links
Each set has a deterministic id {day}-{stage}-{slug(artist)}, used by the planner, share links, and live overrides. Share links carry the whole plan in the URL fragment: #p=3!<name>!<srcIdx…>, indexing stable source order (v3) so appending sets never shifts anyone's saved links; the older 2! form is still decoded.
Festival time
All times are local CDT without offset; the "festival day" rolls over at 8 AM (a 1 AM set still belongs to the night before). No timezone parsing — epoch math appends -05:00.
06 Features
Schedule & planner
Live NOW badges, search, star to plan, overlap warnings, 🚶 leave-by walk times, ICS export.
Map
5 intent filters + always-on orientation layers, live distance, food clusters & bars, rain radar, route, 🐾 trail, compass-to-camp.
Crew
6-char codes; members post location every ~25s to KV with a 5-min TTL. No accounts, nothing persisted.
Share
Link + fullscreen QR carrying your plan; importing a friend's plan saves it with 🤝 overlap markers (a social graph).
Weather
NWS forecast + severe-alert banner, RainViewer radar overlay.
Play
Roo Quest, the Lil Roo pet, and a few easter eggs — passive, location-aware.
07 Notifications
Closed-app Web Push, crypto written by hand (no library): VAPID JWT signing (ES256) and aes128gcm payload encryption (ECDH P-256 → HKDF → AES-GCM) on Web Crypto. The client subscribes via PushManager and posts its subscription + computed reminder timestamps + starred set IDs to /roo26-api/push → KV. The roo-push cron reads KV every 5 minutes and fires:
- Set reminders — default 20-min lead, fired within an 8-min window.
- Severe weather — NWS active alerts for the Farm, once per alert.
- Targeted news — schedule-change pushes go only to people who starred the affected set.
The subscribe → store → dispatch handshake, all hand-rolled crypto.
08 News & live overrides
A news/alerts feed renders as a top banner + a scrollable Guide strip + a detail modal with grouped source links. The trick: a news item can carry a structured schedule change (time/stage/cancel/add) that the client overlays onto the schedule live — a ⚡ badge, re-synced reminders, a targeted push — as data, never a redeploy. Canonical schedule.json is never rewritten.
An autonomous loop (a Claude Code routine) runs the playbook on a cadence, now diffing against the official feed (§10). Publishing is gated by an ADMIN_KEY secret. See NEWS.md.
09 Telemetry & analytics
An anonymous client SDK queues events in localStorage and flushes via sendBeacon / fetch(keepalive) — so a tap in a dead zone still lands when signal returns. The ingestion route fans out to three sinks, each optional:
Server-side enrichment from request.cf (country/city/colo/device) plus a salted IP hash — never the raw IP. A password-gated live dashboard at /roo26-api/stats streams a real-time event feed, KPIs, recent users, and historical charts. See TELEMETRY.md.
10 Official schedule sync
The official Festiverse app has no public API. It was reverse-engineered from the published APK — an Expo/React Native app whose Hermes bytecode was disassembled (hermes-dec) to recover the baked config. The set-times come from Tradable Bits, fetchable with a public client key and no user auth:
GET tradablebits.com/api/v1/idols/events?api_key=…&performance_uid=… → 275 events
We diff this against our schedule and auto-apply day-of changes through the override system (it's how Wolfmother → Claire Rosinkranz landed live). Full method, ethics, and the stage map: docs/festiverse-api-reverse-engineering.md.
11 Maps & imagery
Leaflet over Esri World Imagery. Map basemaps are stale mosaics, so during the festival the field looks empty. The honest tradeoff was researched: free Sentinel-2 (10 m, ~5-day revisit) shows the buildout footprint; sub-meter "today" imagery requires paid satellite tasking (Planet/Maxar). A recent-imagery layer is feasible but currently undeployed — documented as a tradeoff rather than shipped.
12 Design constraints & invariants
- Offline-first
- One vanilla bundle, localStorage state, SW precache, official maps cached. The app must open and work with no signal.
- Share-link stability
- v3 indexes stable
srcIdx→ appending sets never breaks links; reordering breaks legacy v2 only. - Festival time
- CDT, 8 AM rollover, no
Datetimezone parsing. - localStorage keys
- Stable + migrated, never silently broken.
- Free-tier discipline
- Cloudflare free limits respected — e.g.
/newsis edge-cached and the cron runs every 5 min to stay under the 1,000 KV-list/day cap. - Secrets server-side
- No build-time secrets in client JS; VAPID private / admin / stats keys are Worker secrets.
- Ship to main
- Small commits straight to
main; Workers Builds deploys. No feature branches.
13 Decisions & directions
The notable calls made along the way — what was asked for, and what was decided:
main; no branches, no PRs.14 Deploy & ops
roo26 deploys via Workers Builds on every push to main. roo-push is deployed manually (wrangler deploy). Bindings: PUSH_KV, DB (D1), ROO26_AE (Analytics Engine), optional R2; VAPID public key as a var. Secrets: VAPID_PRIVATE, ADMIN_KEY, STATS_KEY.
Companion docs in the repo:
README.md — file layout TELEMETRY.md — analytics + queries NEWS.md — alerts + autonomous loop docs/festiverse-api-reverse-engineering.md docs/project-overview.md — build journal