SMART trip is a community-built schedule app for Sonoma-Marin Area Rail Transit (SMART). It runs on the web and ships as Capacitor-based iOS and Android apps.
This project is not affiliated with Sonoma-Marin Area Rail Transit.
- Static SMART train schedules with weekday and weekend service views
- Live trip updates from SMART GTFS-Realtime data, including delays, cancellations, and skipped stops when available
- Live SMART service alerts with unread state stored locally on-device
- Ferry connections for Larkspur trips
- Closest-station selection using device location
- Trip detail sheets with GPS-assisted distance-to-stop messaging and better in-motion progress inference
- English and Spanish UI
- Light, dark, and system theme support
- Fare lookup for supported rider categories
- Shared web, iOS, and Android codebase
- React 18 + TypeScript
- Vite
- Tailwind CSS + Radix UI primitives
- TanStack Query
- React Router
- Capacitor for iOS and Android
- Cloudflare Workers — GTFS-Realtime API, SPA hosting, and the Live Activity push backend (Durable Objects)
- Node.js 18+
- npm
- A 511.org API key for live GTFS data
git clone <repository-url>
cd smart-trip
npm installCreate .env.local with:
TRANSIT_511_API_KEY=your_key_herenpm run devnpm run dev proxies /api/* to the production Worker (https://smarttraintrip.com) by default, so live data works out of the box. To change that:
- set
USE_SAMPLE_DATA=truein.env.localto serve local fixtures instead, or - set
DEV_API_PROXY_TARGETin.env.localto point/apiat another backend (e.g. a localwrangler devor a feature-branch deployment).
Default local URL:
http://localhost:3210
| Variable | Where | Purpose |
|---|---|---|
TRANSIT_511_API_KEY |
.env.local (dev) / Cloudflare Worker secret (prod) |
511.org API key for static and realtime feeds |
USE_SAMPLE_DATA |
.env.local |
Serve fixtures from sample/ instead of the live API |
DEV_API_PROXY_TARGET |
.env.local |
Dev-only /api proxy target for npm run dev; defaults to https://smarttraintrip.com (production) |
VITE_API_BASE_URL |
.env.native or .env.native.local |
Absolute API base URL for native builds (production: https://smarttraintrip.com) |
In production the GTFS-RT cache uses Cloudflare's edge Cache API (caches.default) — no binding, no env vars — and TRANSIT_511_API_KEY is a Cloudflare Worker secret, server-side only.
npm run dev- Start the Vite dev server (proxies/apito production by default)npm run build- Type-check and build the web appnpm run build-native- Type-check and build the native web bundlenpm run preview- Preview the production buildnpm run lint- Run ESLintnpm run typecheck- Run TypeScript in build mode without emitting filesnpm run test:unit- Run unit tests with Vitestnpm run update-transit- Refresh GTFS schedule source data from 511.orgnpm run update-build- Refresh transit data, then buildnpm run sync- Build native bundle and sync Capacitor projectsnpm run sync-live- Sync Capacitor projects for live reload developmentnpm run assets- Generate app icons and splash screensnpm run open-ios- Open the iOS project in Xcodenpm run open-android- Open the Android project in Android Studio
Static schedule data is generated into src/data/generated/ and published to public/data/schedules.json during prebuild.
Realtime data flows through the Cloudflare Worker, fronted by the edge Cache API so 511 is polled at most once per freshness window per colo (see below):
511.org GTFS-Realtime
-> edge Cache API (poll-on-read, workers/web/src/lib/gtfsrt.ts)
-> /api/gtfsrt/{alerts,tripupdates,vehiclepositions} (Cloudflare Worker)
-> React Query hooks
-> trip cards, trip detail sheets, map, and service alerts UI
Trip updates are currently matched against static schedule entries by scheduled origin departure time. TODO (clean up soon): this should move to the canonical GTFS-RT approach — direct trip_id match, then route_id + direction_id + start_date + start_time as fallback. A contemporaneous capture (scripts/transit/captureRealtime.ts) confirms SMART's realtime trip_update.trip.trip_id matches the static trip_id, so the older assumption that "SMART trip IDs are regenerated per service date" does not hold for this feed. See the TODO(trip-matching) note in src/hooks/useTripUpdates.ts.
511's Open Data API allows ~370 requests/hour for our token, and explicitly
expects a single central backend to fetch once and fan out to all clients —
the upstream rate must not scale with users. workers/web/src/lib/gtfsrt.ts
implements that: each feed is fetched from 511 at most once per freshness
window (vehicles 15s, trip updates 40s, alerts 5min ≈ 342 calls/hr total) and
cached in the edge Cache API, then served to every user from that one snapshot.
The Cache API is per-colo rather than global, but our traffic sits on ~one Bay
Area colo, so 511 still sees ~one fetch per feed per window — well within budget
— and there's no per-day write limit (the free Workers KV cap that previously
took the feeds down).
Native builds use Capacitor and read VITE_API_BASE_URL from .env.native so app requests go to a deployed API base instead of relative web paths.
Use .env.native.local for developer- or branch-specific overrides such as a PR deployment URL. That file is already ignored by Git.
Typical flow:
npm run sync
npm run open-ios
# or
npm run open-androidFor live reload on device or simulator:
npm run dev
npm run sync-livesync-live uses your Mac's Wi-Fi IP address. The device and development machine need to be on the same network.
Releases are tagged vX.Y.Z and shipped to the App Store and Google Play. The web app deploys continuously from main via Cloudflare Workers Builds and is not gated by this process.
| Bump | When |
|---|---|
major |
Breaking changes to persisted state, removed features, or otherwise-incompatible behavior. |
minor |
New user-visible features (e.g. departure reminders, station-sheet redesign). |
patch |
Bug fixes, schedule data refreshes, and behind-the-scenes improvements only. |
From a clean checkout of main:
git checkout main
git pull --tags
npm install
npm run test:unit
npm run typecheck
npm run prebuild # should produce zero diff against committed generated filesIf prebuild leaves a diff, land a chore: refresh transit feeds commit before tagging.
npm version runs scripts/version.sh, which updates the native version metadata in lockstep so the three platforms can't drift:
package.json/package-lock.json— semver stringandroid/app/build.gradle—versionNameandversionCode(incremented by 1)ios/App/App.xcodeproj/project.pbxproj—MARKETING_VERSIONandCURRENT_PROJECT_VERSION(build number, incremented by 1)
npm version minor # or `patch` / `major`
git push --follow-tagsThat creates a commit titled e.g. 1.5.0 and pushes the matching v1.5.0 tag.
npm run build-native # prebuild + tsc --noEmit + vite build --mode native
npm run sync # npx cap sync — copies the web bundle into ios/ and android/npm run open-iosIn Xcode:
- Select Any iOS Device (arm64) as the run destination.
- Product → Archive.
- When Organizer opens, Distribute App → App Store Connect → Upload.
- In App Store Connect, create a new version, paste the iOS release notes (see below), and submit for review.
npm run open-androidIn Android Studio:
- Build → Generate Signed App Bundle / APK → Android App Bundle.
- Choose the upload keystore.
- Upload the resulting
.aabin Google Play Console → Production → Create new release, paste the Android release notes, and roll out.
Keep store notes short and user-visible. Skip refactors, build-only changes, web-only SEO work, and platform-specific fixes that don't apply to the store you're publishing to (e.g. don't list an Android-only fix in the App Store notes).
To gather candidates:
git log v<previous>..HEAD --oneline --no-mergesGroup feat(*) first, then user-visible fix(*). Trim anything users won't notice.
- The app can request location permission for closest-station selection and GPS-assisted trip detail messaging.
- Location stays on-device and is not sent as part of schedule or realtime API requests.
- The app stores a small amount of local state such as theme, language, fare preference, and dismissed/read alert state.
- The web app uses Cloudflare Web Analytics (cookieless); native builds do not include that analytics client.
workers/web/ Cloudflare Worker — GTFS-RT API, SPA host, Live Activity push (Durable Object)
api/ Shared transit + Live Activity logic imported by the Worker
public/ Static assets and hosted support/privacy pages
sample/ Local GTFS-Realtime fixtures
scripts/ Feed update and build helper scripts
src/components/ UI components
src/data/ Static and generated schedule data
src/hooks/ App hooks
src/lib/ Utilities and app logic
src/pages/ Route-level pages
src/types/ Shared TypeScript types
ios/ Capacitor iOS project
android/ Capacitor Android project
npm run test:unitnpm run build
npm run lint currently fails in this repo because of pre-existing issues in generated Android build output and unrelated source files.
The code in this repository is available under the MIT License. See LICENSE.
The SMART trip name, logo, and other project branding are not granted under the code license.
Built with ❤️ for the SMART Train community