Skip to content
xucklessPublic

About

Calgary transit, routed and tracked in real time — an Expo app, five Go services and a self-hosted OpenTripPlanner graph, in one monorepo.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

233 Commits

Folders and files

Repository files navigation

Tranzit: Calgary transit, routed and tracked in real time

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.



React Native · Expo Go OpenTripPlanner 2 PostgreSQL
Nuxt Supabase Tailscale mTLS License: showcase only



What it is · Architecture · The monorepo · Each service · A journey, end to end · Security · Run it · Credits

✦ What it is

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.

✦ How it fits together

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
Loading

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.

✦ The monorepo

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/

✦ What each piece does

ComponentStackWhat it is responsible for
apps/mobileReact 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-serviceGo 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-saveGo 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-middlewareGo 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-serviceGo 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/otpOpenTripPlanner 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/statsNuxt 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-dbPostgreSQL 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.

✦ A journey, end to end

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
Loading

✦ How the services trust each other

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.

✦ Run it yourself

The demo — one command, no accounts

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 it

Then, 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.

A real deployment

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 :8080

2 — 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.sql

3 — 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 --build

4 — 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:3000

Each 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.

✦ Built with

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.

✦ Credits

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.

✦ License

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.

About

Calgary transit, routed and tracked in real time — an Expo app, five Go services and a self-hosted OpenTripPlanner graph, in one monorepo.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages