Subway Quest

Subway Quest

Event-driven mobile application powering a first-party analytics pipeline

Overview

Riding the subway becomes a running list of things left to discover: stations you haven't stood on yet, lines you haven't finished end to end, neighborhoods you haven't set foot in. Subway Quest logs your rides automatically-ish (you log a leg, it tracks the rest), checks things off a real map of the system, and turns "I've never been to that part of Brooklyn" into a quest with a name.

It's a React Native app, but it isn't just a client app with some data behind it — it's built end-to-end as a full data product. There are two real, separate data systems running underneath the game: a static reference pipeline that transforms public MTA/GTFS data into the offline map the app runs on, and a live analytics pipeline that takes real user-generated ride events all the way to a public, BigQuery-connected Power BI dashboard. The mobile app is the front door; the data engineering is the point.

App Walkthrough

A quick look at the app itself before getting into how it's built — the map, logging a ride, a station's detail page, and progress toward its quests.

Map tab showing the full subway system colored by visit status
Map — every station colored by status: not yet visited, saved for later, or checked off.
Log Trip screen with a grid of subway line icons to pick from
Log Trip — pick the line and stations for each leg; on a transfer, it shows exactly which lines you can switch to and sets the right platform for you.
Jay St-MetroTech station detail page showing routes, transfers, and quests
Station detail — routes at this platform, transfer options, visit history, and every quest tied to this station.
Achievements screen listing completed and ongoing quests
Achievements — completed quests and live progress on every ongoing one.

Stack

React NativeReact Native
TypeScriptTypeScript
SQLiteSQLite
SupabaseSupabase
PostgreSQLPostgreSQL
PythonPython
GitHub ActionsGitHub Actions
BigQueryBigQuery
dbtdbt
Power BIPower BI
  • Client: React Native, Expo, TypeScript, on-device SQLite
  • Auth / sync: Supabase (Postgres, Auth, Row-Level Security)
  • Data pipeline: Python, GitHub Actions (scheduled + manual triggers)
  • Warehouse: BigQuery, dbt (staging → intermediate → mart)
  • Dashboard: Power BI (Publish to Web)
  • Source data: MTA GTFS feed, MTA Stations/Complexes reference data, NYC DCP neighborhood boundaries

The Data Story

Two data systems live side by side in this project, and they barely talk to each other. One answers "how does the app know the subway?" — public transit data, transformed once, compiled directly into the app, working fully offline. The other answers "how does the app know you?" — every ride you log, flowing continuously through a scheduled pipeline into a warehouse and a public dashboard, whether anyone is watching or not.

How the app knows the subway

Public MTA reference data and the GTFS schedule feed are transformed once by build_static_data.py — cross- validating routes against an independent source and collapsing raw trip patterns down into real branches — into a set of JSON files. Those get copied into the mobile app and compiled directly into the binary. Nothing about browsing the map or checking a station's status ever touches the network; the whole system works the same in airplane mode as it does anywhere else.

Static subway reference data pipelineMTA station/complex data and the GTFS feed are transformed by build_static_data.py into processed JSON, then copied by sync-data.js and bundled directly into the app, where it works fully offline.SourceTransformDeliverMTA Stations & Complexesstations.csv · complexes.csvGTFS Feedroutes · trips · stop_times · shapesbuild_static_data.pycross-validates routes · dedupes branchesnetwork/processed/*.json496 stations · 445 complexes · 29 routessync-data.jscopies into mobile/dataBundled into the appworks fully offline

How the app knows you

Every ride is logged locally first, then synced to Supabase. A Python job scheduled on GitHub Actions pulls new events into BigQuery every six hours, using a watermark so it only ever loads what's new. From there, dbt cleans, deduplicates, and privacy-suppresses the data before it ever reaches the public Power BI dashboard — a continuous, always-on pipeline running whether or not anyone is watching.

Live ride-event analytics pipelineRide events logged on-device sync to Supabase, are pulled incrementally by a scheduled Python job into BigQuery, transformed and privacy-suppressed by dbt, and published to a public Power BI dashboard.DeviceCloudTransformDashboardOn-device event logSQLite · append-onlySupabaseraw_events · Row-Level SecurityEL job (GitHub Actions)watermark incremental load · every 6hdbt (staging → mart)dedupe · min-N privacy suppressionPower BIpublic dashboard · Publish to Web

The two systems almost never touch. The one narrow exception: if someone reinstalls the app, their own history gets replayed back out of Supabase into local storage, one time, on sign-in.

Engineering Decisions

Events Are the Source of Truth — Not Trips

Every ride is logged locally as an append-only sequence of raw events. The trip/leg records shown in the app aren't stored directly; they're rebuilt from that event log every time. If the projection logic ever needs to change, the history to rebuild it from is already there.

The Subway Map Is Offline By Design

Public MTA/GTFS data is transformed once into JSON and compiled directly into the app binary at build time. Nothing about browsing the map, viewing a line, or checking a station's status requires a network connection — connectivity is only needed to sync your own ride history.

Physical Stations and Platforms Are Modeled Separately, on Purpose

A "station complex" (the physical place a rider would call a station) and an individual platform (stop_id) are different grains in the data, and the app uses each deliberately: whether you've personally visited a platform is tracked at the finer grain, but which routes you can transfer to from a given spot — and whether a quest counts as complete — is evaluated at the complex level, because that's the grain that actually matches how a rider experiences the place.

Dev and Test Data Never Reach the Public Dashboard

Every event generated in a development build is stamped at creation time and filtered out before it reaches the warehouse's aggregate models — so testing the app never pollutes the real, public-facing numbers.

Live Data Loads Incrementally, Not by Full Reload

The pipeline that moves ride events into the warehouse runs as a stateless scheduled job with no persistent memory between runs — so instead of re-pulling everything each time, it asks the warehouse what the newest record it already has is, and only pulls what's newer.

Aggregate Metrics Are Privacy-Protected by Design

The public dashboard enforces a minimum-count threshold on every metric it shows, so no chart can ever be read back down to reveal a single user's individual activity.

Bugs Found & Fixed

None of these were caught because something visibly broke. Each one was caught by a check built specifically to verify an assumption — before it became a real problem.

Trains Labeled as the Wrong Line

A handful of scheduled train trips in the raw MTA data were tagged with one line's identifier but everything else about them — their route shape, their final stop, their destination name — clearly belonged to a different line. Left alone, this would have made at least one real station (4 Av-9 St) falsely appear reachable by trains that never actually stop there. Caught by cross-checking every trip against an independently-sourced list of which lines actually serve which stations, and only trusting trips both sources agree on.

One Visit, Credit for Every Line

Big transfer hubs — stations where five, six, sometimes eight different lines share a platform — were initially granting "you've ridden this line" credit for every line at the hub from a single visit via just one of them. Caught by validating quest logic against ground truth rather than trusting it worked. The fix treats "visited" as a (station, specific line) pair rather than just a station, so credit only applies to the line you actually rode.

Privacy Protection That Quietly Turned Itself Off

The dashboard suppresses any metric covering fewer than five people — applied directly to the warehouse tables by hand. But the automated pipeline rebuilds those same tables from scratch on every scheduled run, and a rebuilt table doesn't carry forward protections that were applied to the old one. Every run since launch had been silently recreating the suppressed tables without the suppression — no error, nothing visibly broken. It only surfaced because of a routine check confirming the protection was still in place after a rebuild. The fix: reapply the privacy rule automatically as a required last step of every pipeline run, and verify it took effect immediately after, every time.

One Signed-In Device, Two People's Data

The on-device database is shared by whichever account is currently signed in — it isn't wiped and rebuilt per user, only per device. Testing with a second real account on the same phone surfaced a genuine problem: without a way to tell whose data was whose, a second account signing in could see the first account's saved stations, and stale data from a previous account never triggered the reset meant to catch it. Chasing that down surfaced a second bug in the same corner: two independent parts of the app could open a database transaction against each other at the same time, which the local database doesn't support — corrupting the local data outright. The fix: wipe and rebuild local data whenever the signed-in account changes, and add a lock so only one part of the app can touch the database at a time.

A Database Rename That Quietly Broke a Different Table

Rebuilding a table mid-migration seemed straightforward — set the old one aside, build the replacement under the real name, copy the data over. But the on-device database doesn't just track the table being renamed; it silently updates every other table's stored reference to point at the new name too, including a foreign key on a completely different table that was never touched directly. Once the old table was cleaned up, that reference pointed at nothing — and it only broke on a device that had actually been through a real migration, never on a fresh install. A dedicated test simulating an already-migrated device caught it before it ever reached a real phone. The fix: never rename an existing table away — build the replacement under a temporary name and rename it into place only once, at the very end.

How I Built This

I built Subway Quest working closely with Claude throughout — not as autocomplete, but as a technical collaborator I directed the way I'd work with a strong pair. I wrote living design docs as I went (architecture, data model, a running build log broken into milestones), used them as the shared spec for every session, and reviewed and corrected the actual output at every step rather than accepting it wholesale.

I think being open about this matters. Knowing how to direct an AI collaborator well — writing down constraints clearly, breaking ambitious work into reviewable milestones, catching what it gets wrong, knowing which decisions are yours to make and holding onto them — is a real, current skill, not a shortcut around having one. The bugs in the section above, the architectural tradeoffs above that, were things I had to understand and choose, regardless of who typed the fix. I'd rather show that process than pretend it wasn't part of how this got built.

What's Next

  • Recruiting a broader tester group beyond the current TestFlight pool
  • Continuing to collect feedback from test users and implementing their suggestions
  • Official App Store submission