← back to Roo '26

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.

0origin servers
2edge Workers
4app routes
154sets tracked
100%on the edge

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

LayerChoiceWhy
FrameworkAstro 6Static-first, zero client JS by default; on-demand routes for the API.
Runtime@astrojs/cloudflare → WorkersStatic Assets + a Worker, one deploy, global edge, generous free tier.
Client logicVanilla JS, one bundleNo hydration, instant, offline-friendly on bad festival signal.
MapLeaflet (lazy-loaded)Light, offline-tolerant, raster tiles cache well.
Shared stateKV · D1 · Analytics Engine · R2KV: push/news. D1: telemetry log. AE: live event stream. R2: optional archive.
PushHand-rolled Web PushVAPID (RFC 8292) + aes128gcm (RFC 8291) via Web Crypto — zero deps.
Cronroo-push WorkerScheduled dispatch of reminders + weather alerts.
ExternalNWS · RainViewer · Tradable Bits · Esri/OSMWeather/alerts, rain radar, official schedule, map tiles.
DeployWorkers BuildsPush to main → Cloudflare builds & ships. Config-as-code in wrangler.jsonc.

03 Architecture

graph TB subgraph client["📱 Client"] APP["Installed PWA<br/>Astro build · _app.js · localStorage"] end subgraph edge["⚡ Cloudflare edge"] W["roo26 Worker<br/>Static Assets + on-demand routes"] CRON["roo-push cron<br/>every 5 min"] end subgraph store["🗄️ Storage"] KV[("PUSH_KV<br/>push · news")] D1[("D1<br/>telemetry log")] AE[("Analytics Engine<br/>live stream")] R2[("R2<br/>archive · opt")] end subgraph ext["🌐 External"] PUSH["Web Push<br/>FCM · APNs"] APIS["NWS · RainViewer<br/>Tradable Bits"] end APP -- static assets --> W APP -- "/roo26-api/* · fetch + beacon" --> W W --> KV & D1 & AE & R2 CRON -- reads --> KV CRON --> PUSH W -.-> APIS CRON -.-> APIS

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

FileHolds
schedule.json154 sets, compact {a,s,d,t,e} (artist, stage, day, start, end).
pois.json63 map POIs across stages, food & drinks, water, medical, restrooms, camping, landmarks.
artists.json115 artists — photo, bio, genre, socials, news.
food.json71 official vendors grouped into 10 cuisines (directory).
erDiagram SET ||--o| ARTIST : "slug(artist)" SET ||--|| STAGE : "plays on" SET { string id "day-stage-slug" string day string start string end } ARTIST { string slug PK string bio string genre string socials } POI { string cat "stage·food·water·…" float lat float lon } FOOD { string cuisine string vendor }

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.

flowchart LR T["set clock time<br/>CDT · no offset"] --> R{"before 8 AM?"} R -- yes --> P["belongs to the<br/>previous festival day"] R -- no --> S["belongs to today"]

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.

mindmap root((Roo '26)) Plan Star sets Overlaps + leave-by Share link / QR Calendar export Map Live distance Food + bars Crew sharing Trail + compass Rain radar Live Push reminders Weather alerts News + overrides Insight Anonymous analytics Live dashboard

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.
sequenceDiagram autonumber participant C as Client (PWA) participant W as roo26 Worker participant K as PUSH_KV participant R as roo-push cron participant P as Push service C->>C: subscribe — PushManager(VAPID public) C->>W: POST /push {sub, reminders, stars} W->>K: store record Note over R,K: every 5 minutes R->>K: list + read records R->>R: VAPID JWT (ES256) + aes128gcm payload R->>P: POST encrypted push P-->>C: deliver notification

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.

flowchart LR A["🤖 Research agents<br/>official · press · social"] --> B["🔎 Cross-reference<br/>≥2 sources"] B --> C{"🎚️ Confidence?"} C -- high --> H["✅ Auto-publish<br/>POST /news + targeted push"] C -- medium --> M["📰 Publish to feed<br/>no push"] C -- low --> L["⏸️ Hold for review"] H --> O["⚡ Live schedule overlay<br/>badge · reminders · push"]

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:

flowchart LR E["👆 Event<br/>tap · view · geo"] --> Q["📥 localStorage queue<br/>offline-safe"] Q --> F["📡 Flush<br/>sendBeacon"] F --> I["⚡ /roo26-api/t<br/>enrich request.cf"] I --> AE["📈 Analytics Engine"] I --> D1["🗄️ D1 log + snapshots"] I --> R2["📦 R2 archive · opt"]

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
flowchart LR APK["📦 Festiverse APK"] --> HB["🔣 hermes-dec<br/>disassemble bundle"] HB --> KEY["🔑 recover client key + host"] KEY --> TB["🛰️ Tradable Bits<br/>/idols/events"] TB --> DIFF["🔀 diff vs schedule.json"] DIFF --> OV["⚡ override system<br/>auto-apply changes"]

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 Date timezone parsing.
localStorage keys
Stable + migrated, never silently broken.
Free-tier discipline
Cloudflare free limits respected — e.g. /news is 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:

Vibe-ship to main. Everything commits/deploys straight to main; no branches, no PRs.
Spotify removed. A warm-up-playlist feature hit Spotify's Development-Mode 403 — playlist writes are blocked for hobby apps and Extended Quota is enterprise-only. No fix exists, so the feature was pulled rather than left broken.
Maximal telemetry, incl. GPS. By request, analytics is comprehensive and uploads the location trail too; the "never uploaded" promise was dropped and a fine-print privacy note added.
Auto-publish if verified. The news pipeline auto-publishes high-confidence changes (official source or ≥2 agreeing outlets) and pushes affected fans — no human gate, with confidence/dedupe/link-check guardrails.
Alerts: concise, sourced. Tight summaries, ≤6 bullets ordered When/Where/Affected/Sets/Cause/Caveat, always cite real (link-checked) sources.
Onboarding: plain & value-first. The welcome sheet leads with the free action (star sets), then primes each permission before the OS prompt; utility-first copy, no slogans.
Food: directory + clusters, no fake pins. Bonnaroo doesn't publish per-vendor GPS, so all 71 vendors are a searchable list, and the map shows honest food cluster zones with directions instead of invented coordinates.
Fewer map filters. Merged food + drinks, cut toggles 9 → 5, and made Stages/Landmarks/Entrances always-on orientation layers.
Reverse-engineer the official schedule. With no public API, the Festiverse app was torn down from public artifacts to find the Tradable Bits feed and keep our data in sync.
Stay in the free tier. When KV usage spiked, the fix was architectural — edge-cache the feed and throttle the cron — not a paid upgrade.

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.

flowchart LR G["git push → main"] --> WB["Workers Builds"] WB --> AB["astro build"] AB --> DEP["deploy roo26 Worker"] RP["roo-push"] -. "manual: wrangler deploy" .-> DEP2["deploy cron Worker"]

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