Hosted live map of Santa Barbara BCycle stations, polling the GBFS feed every 120 seconds. Cloudflare Workers do the polling, KV holds the latest snapshot, R2 holds compacted parquet history, and a small React + MapLibre frontend renders it.
The architecture is documented in docs/superpowers/specs/2026-05-13-bcycle-map-design.md. The full implementation plan is at docs/superpowers/plans/2026-05-13-bcycle-map-v1.md.
| Layer | Tools |
|---|---|
| Frontend | React 18, TypeScript, Vite, Tailwind, MapLibre GL JS |
| Workers | Cloudflare Workers (3 scripts: poller, read API, smoke), wrangler |
| Storage | Cloudflare KV (latest snapshot + intra-hour buffer), Cloudflare R2 (parquet partitions) |
| Compaction | GitHub Actions cron, parquet-wasm + apache-arrow in Node, @aws-sdk/client-s3 (R2 is S3-compatible) |
| Testing | Vitest, happy-dom, @testing-library/react |
- Node 20+ and npm
- A Cloudflare account (free tier is enough)
- A GitHub account (free tier is enough)
ghCLI optional but recommended for creating the repo
Useful for the very first run after cloning, when you want to verify tests pass and the map can render.
git clone git@github.com:samgutentag/bcycle-map.git
cd bcycle-map
npm install
npm test # 51 tests
npm run typecheck # tsc --noEmit
npm run dev:web # Vite at http://localhost:5173 (falls back to 5174 if taken)The map will boot and render the Santa Barbara basemap, but every call to /api/... will 404 until a Worker is running.
To run the Workers locally too, see "Three-terminal local dev" below.
If you're cloning this into a fresh Cloudflare account, the resources need to be provisioned and their IDs need to land in the wrangler*.toml files.
npx wrangler login # browser OAuth
npx wrangler whoami # capture the Account IDnpx wrangler kv namespace create GBFS_KV
npx wrangler kv namespace create GBFS_KV --previewCapture both IDs (production id and preview_id). Paste them into all three TOML files, replacing every PLACEHOLDER_REPLACE_AT_DEPLOY:
wrangler.toml(poller)wrangler.read-api.toml(read API)wrangler.smoke.toml(smoke)
npx wrangler r2 bucket create bcycle-map-archiveIn the Cloudflare dashboard (https://dash.cloudflare.com → R2 → bcycle-map-archive):
- Settings → Public Development URL → Allow Access. Capture the
https://pub-<hash>.r2.devURL. - Settings → CORS Policy → Add policy with:
[ { "AllowedOrigins": ["*"], "AllowedMethods": ["GET"], "AllowedHeaders": ["*"] } ]
The wildcard origin is fine for this bucket — the data is public GBFS info and R2 has no egress fees. Tighten later if your use case ever changes.
npx wrangler deploy # poller (cron every 2 min)
npx wrangler deploy --config wrangler.read-api.toml # HTTP read API
npx wrangler deploy --config wrangler.smoke.toml # daily smoke testEach prints its *.workers.dev URL. Save the read-API URL — the frontend needs it.
Copy .env.example to .env.local:
cp .env.example .env.localEdit .env.local:
VITE_API_BASE=https://bcycle-map-read-api.<your-account>.workers.dev
Now npm run dev:web will fetch from the deployed Worker. No local Workers needed.
After the first 2-minute cron tick fires:
curl https://bcycle-map-read-api.<your-account>.workers.dev/api/systems/bcycle_santabarbara/currentExpected: a JSON blob with ~85 stations. If you get not found, the cron hasn't fired yet — wait a moment.
You can also tail Worker logs in real time:
npx wrangler tail bcycle-map-pollerThe poller writes JSON snapshots to KV during each hour. A GitHub Action seals those into parquet files in R2 at the top of every hour. This requires repository secrets to function.
In the repo's Settings → Secrets and variables → Actions, add:
| Secret | Where to find it |
|---|---|
CF_ACCOUNT_ID |
npx wrangler whoami |
CF_KV_API_TOKEN |
Cloudflare dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Workers KV Storage" template |
CF_KV_NAMESPACE_ID |
The production KV id from Step 2 above |
R2_ACCESS_KEY_ID |
Cloudflare dashboard → R2 → Manage API Tokens → Create API Token (Object Read & Write on bcycle-map-archive) |
R2_SECRET_ACCESS_KEY |
Shown once when the R2 token is created — save it |
R2_BUCKET |
bcycle-map-archive |
The workflow at .github/workflows/compact.yml runs hourly at 5 past the hour. Trigger it manually from the Actions tab to test.
For working on Worker code without pushing every change. Uses Miniflare to simulate KV and R2 locally.
# Terminal 1: read API on port 8787 (matches the Vite proxy)
npx wrangler dev --config wrangler.read-api.toml --persist-to .wrangler-state
# Terminal 2: poller on port 8788 with --test-scheduled so /__scheduled is exposed
npx wrangler dev --port 8788 --persist-to .wrangler-state --test-scheduled
# Terminal 3: frontend
npm run dev:webBoth wrangler dev commands must share the same --persist-to directory so they see each other's KV writes.
To seed data, trigger the poller's scheduled handler manually:
curl 'http://localhost:8788/__scheduled?cron=*/2+*+*+*+*'To simulate the production cron, run it in a loop:
while true; do
curl -s 'http://localhost:8788/__scheduled?cron=*/2+*+*+*+*' > /dev/null
echo "polled at $(date '+%H:%M:%S')"
sleep 120
doneWhen using the local Workers, .env.local should NOT set VITE_API_BASE (or should set it to empty / unset). The Vite dev server's built-in proxy forwards /api to localhost:8787.
src/
├── shared/ # Used by both Workers and (where typed) the Web app
│ ├── types.ts # Snapshot, KVValue, etc
│ ├── normalize.ts # GBFS v1.1 → internal shape (anti-corruption layer)
│ ├── parquet.ts # Snapshot ↔ parquet bytes (Node-only)
│ ├── systems.ts # config loader for systems.json
│ └── fixtures/ # Captured real GBFS responses for tests
├── workers/
│ ├── poller.ts # scheduled handler: fetch GBFS → KV (latest + buffer)
│ ├── read-api.ts # HTTP handler: GET /api/systems/:id/current
│ ├── smoke.ts # daily handler: file GitHub Issue on normalize failure
│ └── lib/
│ ├── gbfs-client.ts # fetch with retry
│ └── github.ts # file-issue-with-dedupe
└── web/
├── main.tsx + App.tsx # React entry + router
├── routes/
│ ├── LiveMap.tsx # MapLibre + station markers + popups
│ └── Explore.tsx # placeholder for Plan 2 (Kepler + DuckDB-WASM)
├── components/
│ └── StalenessBadge.tsx
├── hooks/
│ └── useLiveSnapshot.ts
└── lib/
├── api.ts # fetchCurrent()
└── marker-style.ts # color + size helpers
scripts/
└── compact.ts # GH Action runtime: KV buffer → parquet → R2
.github/workflows/
└── compact.yml # hourly cron that runs scripts/compact.ts
wrangler.toml # poller config
wrangler.read-api.toml # read API config
wrangler.smoke.toml # smoke worker config
systems.json # list of GBFS systems to poll (with per-system enabled flag)
Every system the app touches is one entry in systems.json. The poller, all
scripts/compute-* rollups, and the picker's systems-index.json read the
list through a single function — getSystems() in src/shared/systems.ts — so
that one file is the only place you edit.
-
Append an entry to
systems.json.enabledis optional; omit it (or settrue) to make the system active immediately:{ "system_id": "bcycle_madison", "name": "Madison BCycle", "gbfs_url": "https://gbfs.bcycle.com/bcycle_madison/gbfs.json", "version": "1.1" }system_idmust match the slug in the GBFS URL. Find systems at the BCycle GBFS index. -
Redeploy the poller so the new entry is bundled in and polling starts:
gh workflow run deploy-workers.yml -f target=poller
-
Run corridors once to add the system to the picker (it writes
gbfs/systems-index.json, the file/api/systemsserves to the frontend):gh workflow run corridors.yml
The network dropdown only appears once two or more systems are active (
NetworkPickerhides itself below that). The rest of the rollups (leaderboards, popularity, routes, travel-times, typicals) pick the new system up on their next scheduled run, or trigger them manually the same way.
Disabling is the reverse switch — useful for staying under the Workers Free KV
cap (1000 puts/day; each active system costs ~576 puts/day at the 5-min poll
interval, so two systems is already over). Flip the flag, don't delete the
entry — already-collected data stays frozen in R2/KV and getSystem(id) still
resolves it, so re-enabling is lossless.
-
Set the flag in
systems.json:{ "system_id": "bcycle_cincyredbike", "...": "...", "enabled": false }enabled: falseremoves the system fromgetSystems(); omitting the flag or settingtruekeeps it active. -
Redeploy the poller so it stops (or resumes) polling that system:
gh workflow run deploy-workers.yml -f target=poller
-
Run corridors to rewrite
systems-index.json, which drops the system from (or restores it to) the picker:gh workflow run corridors.yml
Disabled systems leave the dropdown on the next corridors run; if only one system remains active the picker disappears entirely.
| Script | What it does |
|---|---|
npm test |
Run Vitest once |
npm run test:watch |
Run Vitest in watch mode |
npm run typecheck |
tsc --noEmit |
npm run dev:web |
Vite dev server |
npm run dev:worker |
wrangler dev (poller, local Miniflare) |
npm run build:web |
Production build of the frontend → dist/ |
npm run deploy:worker |
wrangler deploy (poller only) |
- Two-minute polling keeps everything on Cloudflare Workers Free (720 invocations/day, under the 1000/day cap).
- Compaction lives in a GitHub Action, not in a Worker.
parquet-wasm+apache-arrowtogether exceed the 1 MiB Workers script size cap; pushing them out to GH Actions sidesteps that without paying for Workers Paid. - KV is the hot path, R2 is the cold path. Live reads come from KV via the read API. Historical reads will come from R2 parquet directly (Plan 2, the
/exploreKepler view). - The frontend is decoupled from the API host via
VITE_API_BASE. Dev can run against local Workers or against the deployed read API just by changing one env var.
- Plan 2 (deferred):
/exploreview backed by Kepler.gl + DuckDB-WASM. Loads R2 parquet directly in the browser and lets you slice the historical data. Will be written once enough history has accumulated to be interesting.