Plan a Calgary transit trip, start it, and get told when to leave — on data that is seconds old.
A React Native app, five Go services, and a self-hosted OpenTripPlanner graph. Journey planning, live vehicle positions over SSE, a WebSocket trip session that reroutes when you miss the bus, and a day planner that works out when to walk out the door.
What it is · Architecture · The monorepo · Each service · A journey, end to end · Security · Run it · Credits
Calgary Transit publishes a static GTFS schedule and three GTFS-Realtime feeds. Google Maps will route you across them. Neither will tell you "leave in four minutes or you miss the 3".
Tranzit is the rest of that: a transit app that holds a trip session on the server while you ride it, watches the realtime feed under your route, and pushes you a notification when the arithmetic between where you are and when the bus comes stops working.
It is built as seven deployable pieces, each with its own build, its own container and its own history — now in one repository.
- 🗺️ Plan a journey against a self-hosted OpenTripPlanner graph, with Google Routes as the fallback.
- 🚍 Watch the fleet move — live vehicle positions streamed over SSE, diffed incrementally so the map never rebuilds.
- 🎒 Start a journey and hand it to a server-side session: GPS tracking, boarding confirmation, automatic rerouting, iOS Live Activities and Android sticky notifications.
- 📅 Plan a whole day on a 24-hour timeline. Each event gets a pre-computed arrive-by route, a faded travel-time lead-in, and a leave now notification timed off real transit data.
- 📴 Work offline — a SQLite mirror with a pending-write outbox fronts every user table, so saved places and planned trips survive a dead tunnel.
flowchart TB
subgraph client["📱 Client"]
APP["<b>apps/mobile</b><br/>React Native · Expo<br/>Map · Routes · Commute · Planner · Saved"]
SQL[("SQLite mirror<br/>+ pending outbox")]
APP <--> SQL
end
subgraph edge["🌐 Public edge"]
RS["<b>route-service</b><br/>journey planning<br/>places · autocomplete"]
SAVE["<b>route-save</b><br/>trip sessions<br/>WebSocket + REST"]
MW["<b>db-middleware</b><br/>read API + SSE streams"]
end
subgraph private["🔒 Private network"]
OTP["<b>otp</b><br/>OpenTripPlanner 2<br/>graph.obj"]
RT["<b>rt-service</b><br/>GTFS-RT ingest<br/>upsert loop"]
DB[("PostgreSQL<br/>GTFS static + realtime")]
UDB[("<b>infra/user-db</b><br/>user data<br/>12 tables")]
end
subgraph ops["📊 Ops"]
STATS["<b>apps/stats</b><br/>Nuxt dashboard<br/>health · docker · restart"]
end
FEED["City of Calgary<br/>GTFS + 3 GTFS-RT feeds"]
GM["Google Routes<br/>+ Places"]
SB["Supabase<br/>auth · user data"]
APP -->|"JWT"| RS & SAVE & MW
APP -->|"auth"| SB
RS -->|"mTLS"| MW
RS --> OTP
RS --> GM
SAVE -->|"mTLS"| MW & RS
MW --> DB & UDB
RT --> DB
FEED --> RT
FEED --> OTP
STATS -.->|"probe"| RS & SAVE & MW & OTP
style APP fill:#00b352,stroke:#008a40,color:#fff
style OTP fill:#e6f7ee,stroke:#00b352
style DB fill:#f0f4ff,stroke:#336791
style UDB fill:#f0f4ff,stroke:#336791
style FEED fill:#fffbeb,stroke:#fde68a
style GM fill:#fffbeb,stroke:#fde68a
style SB fill:#fffbeb,stroke:#fde68a
Only three services are reachable from the internet. Everything else — the routing graph, the realtime ingest loop, both databases — sits on a private mesh and is never published.
Seven projects that used to be seven repositories. Nothing was merged into a monolith: each directory keeps its own go.mod or package.json, its own Dockerfile, its own docker-compose.yml, and all of its original commits.
tranzit/
├── apps/
│ ├── mobile/ React Native (Expo) — the app
│ └── stats/ Nuxt — service health + docker dashboard
├── services/
│ ├── route-service/ Go — journey planning, places, autocomplete
│ ├── route-save/ Go — active trip sessions (WebSocket + REST)
│ ├── db-middleware/ Go — the only public door to the databases
│ ├── rt-service/ Go — GTFS-Realtime ingest and upsert loop
│ └── otp/ OpenTripPlanner 2 — the routing graph
├── infra/
│ └── user-db/ PostgreSQL — user data schema + compose stack
├── demo/ the local demo stack — compose file, rt-feed, seed, GPS tracks
├── demo.sh one command to run all of it on your machine
└── docs/
| Component | Stack | What it is responsible for |
|---|---|---|
| apps/mobile | React Native Expo · TypeScript |
Five tabs — Map, Routes, Commute, Planner, Saved — plus a hidden community Report screen. The Commute screen is a state machine driven by WebSocket messages ( STATE, REROUTE, NOTIFY). Themes are whole struct presets in styles/themes/ — colours, type scale, MapLibre paint, polylines and stop circles all switch together, and adding one means dropping in a file that implements AppTheme.
|
| services/route-service | Go 1.23 |
Journey planning. Prefers the self-hosted OTP graph and falls back to Google Routes. Also fronts place search: a fast /search for businesses and a separate /autocomplete that covers street addresses. The app debounces the two differently — 150 ms for the instant one, 600 ms for the quota-expensive one — so typing a destination does not burn the Places budget.
|
| services/route-save | Go 1.26 |
Owns a journey while it is being ridden. Creates a trip session, tracks GPS against the itinerary, confirms boarding, reroutes on a missed connection, and streams state over GET /trips/{id}/ws. The owner is bound at creation from the verified JWT sub — never from a client field — and non-owners get a 404, not a 403.
|
| services/db-middleware | Go 1.23 |
The only public door to the data. A whitelisted read API over the transit tables (limit/offset/order_by plus exact-match column filters), an SSE route stream, a user-data CRUD surface, and resolve_trip — which resolves the one trip a rider actually boards for a leg, server-side, instead of making the client pull every candidate trip and filter it.
|
| services/rt-service | Go 1.23 | Polls Calgary's three GTFS-Realtime feeds and upserts trip updates, vehicle positions and alerts into PostgreSQL, with a 2-hour retention window on the realtime tables and a sanitize pass that drops malformed rows before they reach a query. |
| services/otp | OpenTripPlanner 2.6 Java 21 |
Builds a routing graph from Calgary's static GTFS plus an OpenStreetMap extract clipped to the city bbox, then applies the live GTFS-RT feeds through OTP's realtime updaters — so delays and cancellations change trip plans without a graph rebuild. The demo builds the graph on first run; ./demo.sh rebuild-graph refreshes it when Calgary publishes a new schedule.
|
| apps/stats | Nuxt 4 Tailwind · Chart.js |
Operator dashboard. Probes each service server-side to confirm it is actually serving rather than merely Up — which catches the failure mode where a network sidecar restarts and leaves the app listening in an orphaned namespace, so docker ps is happy and every request still 502s.
|
| infra/user-db | PostgreSQL 17 | A 12-table user-data schema — saved places, planned trips, calendar mirror, cached pre-routes — in a container with no route to the internet. Postgres speaks raw TCP, which does not fit an HTTPS tunnel, so this node stays private and db-middleware is the only thing that can reach it. |
sequenceDiagram
autonumber
actor U as Rider
participant A as apps/mobile
participant RS as route-service
participant O as otp
participant SV as route-save
participant MW as db-middleware
U->>A: types a destination
A->>RS: /search (fast) then /autocomplete (debounced)
RS-->>A: suggestions, coordinates attached
U->>A: picks a route, taps Start Journey
A->>RS: /route/otp
RS->>O: GraphQL trip plan
O-->>RS: itineraries, realtime-adjusted
RS-->>A: route
A->>SV: POST /trips (JWT)
SV->>MW: resolve_trip (mTLS)
MW-->>SV: the one trip + windowed stops
SV-->>A: trip session
A->>SV: WS /trips/{id}/ws
loop while riding
A->>SV: GPS position
MW-->>A: SSE vehicle positions
SV-->>A: STATE
SV-->>A: NOTIFY "board here"
end
Note over SV: rider missed the connection
SV-->>A: REROUTE
A->>U: new plan, no tap required
Two layers, because the threat is different in each direction.
| Direction | Mechanism | Enforcement |
|---|---|---|
| Rider → any public service | Supabase JWT (ES256), verified against the project JWKS — iss, aud=authenticated, exp |
Hard-enforced on route-save. Enforced by default on route-service and db-middleware (AUTH_ENFORCE=false is the old log-only rollout mode) |
| Service → service | Mutual TLS with leaf certificates from a shared private CA, over the private mesh | Per-sibling: mTLS when that sibling's *_MTLS_ADDR is set, public URL otherwise |
| Anything → the databases | No route. Both databases are unpublished; db-middleware is the only reachable surface | Topological — there is no port to attack |
The honest caveat, which is written down in each service's AUTH.md rather than discovered later: the public-URL fallback for service-to-service calls carries no credential at all. It exists so a service can deploy before its siblings' mTLS listeners do. Flipping AUTH_ENFORCE=true without first setting the matching *_MTLS_ADDR breaks those calls by design.
Everything runs on your machine: PostgreSQL, the four Go services, OpenTripPlanner, and live Calgary transit data. There is no sign-in; the app runs as a seeded demo rider with saved places and a week of planned trips.
You need Docker (with Compose v2), Node 20+ or Bun, ~6 GB of free RAM, and either the Android SDK with an emulator and JDK 17 or 21 (any OS; demo.sh finds one if your default Java is newer) or Xcode (macOS, for the iOS Simulator).
./demo.sh up # first run: builds the images and the OTP graph (~10–15 min), then ~30 s
./demo.sh app android # or: ./demo.sh app ios — builds the app once, then launches itThen, in another terminal, start a journey in the app and let the emulator ride it:
./demo.sh drive android # plays demo/gps/kensington-to-downtown.csv (walk → Route 1 → walk)
SPEED=4 ./demo.sh drive android # 4× faster| Command | What it does |
|---|---|
./demo.sh up [--stats] |
Start the stack; --stats also starts the ops dashboard on localhost:3000 |
./demo.sh app [android|ios] |
Build (first time) and launch the app in demo mode against localhost |
./demo.sh drive [android|ios] [track.csv] |
Feed a GPS track into the emulator / simulator |
./demo.sh status · logs [service] |
What's running, and whether live data is coming from Calgary or the recording |
./demo.sh record [minutes] |
Record the live GTFS-RT feeds into demo/recordings for the offline fallback |
./demo.sh rebuild-graph |
Rebuild the OTP graph from the latest Calgary GTFS |
./demo.sh down · reset |
Stop (data kept) · stop and delete the demo database (graph kept) |
Live data. rt-feed proxies Calgary's three GTFS-Realtime feeds for rt-service and OTP. If they can't be reached, it replays demo/recordings, shifting every timestamp so the data reads as current. FEED_MODE=replay forces the recording. Late at night Calgary runs very few buses, so the map is quiet; a daytime recording makes for a livelier offline demo.
No keys needed. Place search uses Photon (OpenStreetMap), and the map uses OpenFreeMap tiles. Put a GMAPS_API_KEY or EXPO_PUBLIC_MAPTILER_KEY in demo/.env (see demo/.env.example) to use Google Places or MapTiler instead.
How demo mode works. The services start with DEMO_USER_ID, which makes every request that one user and skips Supabase entirely; the app is built with EXPO_PUBLIC_DEMO_MODE=true, which skips the sign-in screens. Neither flag is set outside the demo.
This needs your own Supabase project, and roughly 4 GB of RAM free for the OTP graph build. Every service reads its configuration from the environment; no deployment detail is committed anywhere in this repository. See LICENSE for what you may do with a local build.
1 — The routing graph (slowest step; do it first)
cd services/otp
cp .env.example .env
./build.sh # fetch OTP, Calgary GTFS and the Alberta OSM extract, clip, build graph.obj
./run.sh # serve on :80802 — The databases
cd infra/user-db
cp .env.example .env # set POSTGRES_PASSWORD
docker compose up -d
docker exec -i tranzit-user-db psql -U tranzit-user -d tranzit-user \
-v ON_ERROR_STOP=1 < schema.sql3 — The services — each one is independent
for s in db-middleware rt-service route-service route-save; do
(cd "services/$s" && cp .env.example .env) # then fill each in
done
# optional, for service-to-service mTLS:
./services/db-middleware/scripts/gen-mtls-certs.sh
# then, per service:
cd services/db-middleware && docker compose up -d --build4 — The app
cd apps/mobile
bun install
cp .env.example .env.local # Supabase URL + anon key, and the three service URLs
bunx expo run:android # or run:ios — a development build (MapLibre needs native code)The app fails fast and names the variable if one is missing, rather than silently fetching from an empty host.
5 — The dashboard (optional)
cd apps/stats && npm install && npm run dev # localhost:3000Each component also carries its own docs: AUTH.md for the auth model, DOCS.md for the API surface, and a README.md where there is more to say.
OpenTripPlanner 2 · Expo · MapLibre + OpenFreeMap / MapTiler · Photon · Supabase · PostgreSQL · Tailscale · Nuxt · Chart.js · Go · TypeScript
Transit data © City of Calgary under its Open Data Licence. Street data © OpenStreetMap contributors, ODbL. Neither is redistributed here — both are fetched at build time.
Built by @xuckless and @Wyden13 (Wendy Nguyen).
The mobile app is a joint effort; the services and infrastructure are @xuckless'. Every commit in this repository keeps its original author — git log --author will tell you who wrote what.
Source-available, showcase only — see LICENSE. You may read it, study it, and build it locally to evaluate it. You may not redistribute it, operate it for anyone but yourself, or use it as training data.