A mobile-first PWA that finds your nearest Edmonton LRT station and shows upcoming departures.
Live at headway.andy.ws (also at next-departures.fly.dev).
Project notes record current design decisions, release verification, and outstanding follow-ups.
Typography documents the Geist / Geist Mono pairing, the Swiss public-information style, and how npm, Vite, caching, and the PWA deliver the two self-hosted variable webfonts.
I wanted a faster way to answer "when's my next train?" than opening a transit app and tapping through menus. Headway uses your location to pick the closest LRT station, then shows the upcoming departures in both directions, computed from the official ETS GTFS schedule data.
It works best installed as a PWA on a phone (on iOS: Safari → Share → Add to Home Screen), but it's a normal web app and runs fine in any browser. If geolocation fails or is denied, it falls back to a default location, and you can always pick a station manually.
- Client: React 19 + Vite 6, Tailwind CSS 4, with shadcn components built on Base UI, including mobile drawers.
vite-plugin-pwahandles the service worker, manifest, and offline fallback. - Type: Geist for interface text and destinations, Geist Mono for timings; two Latin variable WOFF2 files supplied by Fontsource and bundled by Vite (about 52.5 KB total).
- Server: A small Hono server (Node 20) that serves the built client and exposes a JSON API for stations and departures.
- Data: ETS GTFS schedule data, imported into SQLite with node-gtfs and then slimmed down to an LRT-only database (~a few MB) that's queried directly with
better-sqlite3. The server opens the database once at startup and reuses the connection. - Hosting: Fly.io, with Sentry for error reporting and Umami for privacy-friendly analytics (proxied through
/stats.js).
The slim LRT-only database is checked into the repo at data/gtfs_lrt_only.db, so the app runs out of the box without importing anything.
npm install
npm run devThat starts the Hono API server (port 3000) and the Vite dev server (port 5173) together; open http://localhost:5173.
Other useful scripts:
npm run build # build client and server into dist/
npm run preview # build, then run the compiled server locally
npm run deploy # build locally, then fly deployA GitHub Actions workflow refreshes the schedule data every Monday: it rebuilds the slim database, commits it if it changed, and deploys. To refresh manually instead:
npm run db:updateThat downloads the latest ETS GTFS feed and imports it into db/gtfs.db (db:import), then runs a SQL script to produce the slim LRT-only database at data/gtfs_lrt_only.db (db:slim). It takes a minute or two. Both steps read their config from import-config.json and app-config.json, which are checked in and already point at the ETS feed.
To fake your location in Firefox, set these in about:config:
geo.provider.testing → true
geo.provider.network.url → data:application/json,{"location": {"lat": 53.50584, "lng": -113.52845}, "accuracy": 27000.0}
Vitest with jsdom, covering the time/stop utility logic, the API route handlers, the server bootstrap, the app controller hook, and the React components.
npm run test # watch mode
npm run test:coverage # single run with coverageSee project notes for recorded release validation. Vitest's automatic UI/browser opening is disabled. The typography guide includes a fixed-time mixed-line preview for visual checks without changing the device clock.
Deployed to Fly.io. Pushes to main deploy automatically via GitHub Actions (the workflow builds the client and server, then runs flyctl deploy). To deploy from a local checkout instead:
npm run db:update # optional: refresh schedule data
npm run deploy # npm run build && fly deployThe custom domain (headway.andy.ws) is a CNAME to Fly.io with an auto-renewing Let's Encrypt certificate.
Keep released changes on both develop and main: the weekly GTFS workflow starts from develop and fast-forwards main, while the deployment workflow builds main.
See the release workflow for preflight checks, branch synchronisation, and production verification.
