Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 52 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,61 +378,62 @@ On startup, the app checks for locally cached GTFS data. If the cache is missing
- **Real-time cache** — 15s TTL prevents redundant API calls
- **Reanimated animations** — all transitions run at 60 fps on the native thread

## API Usage

### Get All Active Trains

```typescript
import { RealtimeService } from './services/realtime';

const trains = await RealtimeService.getAllActiveTrains();
// Returns ~150-160 active trains with position, speed, bearing
```

### Get a Specific Train's Position

```typescript
const position = await RealtimeService.getPositionForTrip('543');
// Accepts train number ("543") or full trip ID ("2026-01-16_AMTK_543")
```

### Check Delay at a Stop

```typescript
const delay = await RealtimeService.getDelayForStop('543', 'NYP');
console.log(RealtimeService.formatDelay(delay));
// "On Time", "Delayed 5m", or "Early 2m"
```

### Get Full Train Details (Schedule + Real-Time)

```typescript
import { TrainAPIService } from './services/api';

const train = await TrainAPIService.getTrainDetails('543');
// Includes full itinerary, real-time position, and delay status
```

### Search Stations

```typescript
import { gtfsParser } from './utils/gtfs-parser';

const stations = await gtfsParser.searchStations('Boston');
## API Surface

Tracky mobile is wired to the backend via:

- REST base URL: `https://api.trackyapp.net` (default from [apps/mobile/constants/config.ts](apps/mobile/constants/config.ts))
- WebSocket URL: `wss://api.trackyapp.net/ws/realtime`

Endpoints are implemented in [apps/api/routes/static.go](apps/api/routes/static.go) and [routes.go](apps/api/routes/routes.go), and consumed via the typed client in [apps/mobile/services/api-client.ts](apps/mobile/services/api-client.ts) plus the WebSocket client in [apps/mobile/services/ws-client.ts](apps/mobile/services/ws-client.ts).

Date parameters are `YYYY-MM-DD`. Cacheable read endpoints set `Cache-Control: public, max-age=3600`. All `/ingest` writes require `X-Ingest-Secret`; reads are public (subject to a 30 req/s per-IP rate limit).

### REST Endpoints

| Method & Path | Required params | Optional params | Returns |
| --- | --- | --- | --- |
| `GET /health` | — | — | `ok` (text) |
| `GET /v1/search` | `q` (query) | `provider`, `types` (CSV of `stations`, `trains`, `routes`) | `{ stations, trains, routes }` |
| `GET /v1/providers/{provider}` | `provider` (path) | — | Provider metadata |
| `GET /v1/stops` | `provider` (query) | `bbox` (`minLon,minLat,maxLon,maxLat`) | Array of stops |
| `GET /v1/stops/nearby` | `lat`, `lon` (query) | `radius_m` (default 5000, max 50000), `provider` | Array of stops |
| `GET /v1/stops/{provider}/{stopCode}` | `provider`, `stopCode` (path) | — | Stop metadata |
| `GET /v1/routes` | `provider` (query) | — | Array of routes |
| `GET /v1/routes/{provider}/{routeCode}` | `provider`, `routeCode` (path) | — | Route metadata |
| `GET /v1/routes/{provider}/{routeCode}/trains` | `provider`, `routeCode` (path) | — | Array of trains on route |
| `GET /v1/trains/{trainNumber}/service` | `trainNumber` (path), `provider` (query) | `from`, `to` (date) | Service info / date range |
| `GET /v1/trips/lookup` | `provider`, `train_number`, `date` (query) | — | Array of trips |
| `GET /v1/trips/{tripId}` | `tripId` (path) | — | Trip metadata |
| `GET /v1/trips/{tripId}/stops` | `tripId` (path) | — | Scheduled stop timeline |
| `GET /v1/departures` | `stop_id`, `date` (query) | — | Departures/arrivals for the day |
| `GET /v1/connections` | `from_stop`, `to_stop`, `date` (query) | — | Station-to-station trip options |
| `GET /v1/runs/{provider}/{tripId}/{runDate}/stops` | `provider`, `tripId`, `runDate` (path) | — | Per-stop scheduled / estimated / actual times (uncached, realtime) |
| `GET /v1/realtime` | `topic` (query) | — | `{ runs: [...] }` from latest realtime snapshot for the topic |
| `POST /ingest` | `X-Ingest-Secret` header | — | Snapshot ingest from the edge collector |

### WebSocket

`WS /ws/realtime` — clients send subscribe/unsubscribe frames and receive `realtime_update` snapshots:

```jsonc
// Client → server
{ "action": "subscribe", "providers": ["amtrak"] }
{ "action": "unsubscribe", "providers": ["amtrak"] }

// Server → client
{ "type": "realtime_update", "provider": "amtrak", "positions": [...], "stopTimes": [...] }
```

### Find Trips Between Two Stations
Wire format defined in [apps/api/ws/poller.go](apps/api/ws/poller.go) and [apps/api/ws/handler.go](apps/api/ws/handler.go).

```typescript
const trips = await gtfsParser.findTripsWithStops('BOS', 'NYP');
```
### Where This Is Consumed in the App

### Refresh Real-Time Data for a Saved Train

```typescript
const updated = await TrainAPIService.refreshRealtimeData(existingSavedTrain);
// updated.realtime now has the latest position and delay
```
- Search and trip planning: `apps/mobile/components/TwoStationSearch.tsx`
- Departure boards: `apps/mobile/components/ui/DepartureBoardModal.tsx`
- Train detail per-stop enrichment: `apps/mobile/hooks/useTripDetail.ts`
- Saved train reconstruction and refresh: `apps/mobile/services/storage.ts`, `apps/mobile/services/api.ts`
- Live map and realtime fanout: `apps/mobile/context/RealtimeContext.tsx`, `apps/mobile/services/ws-client.ts`, `apps/mobile/hooks/useLiveTrains.ts`

## Tech Stack

Expand Down
12 changes: 9 additions & 3 deletions apps/api/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
-- covered by these idempotent forms (e.g. adding a column), drop in a real
-- migration runner; until then this stays simple.
--
-- Identifiers (route_id, stop_id, trip_id) are namespaced upstream as
-- "<provider_id>:<native_id>" so they're globally unique. service_id is
-- NOT namespaced; queries must always pair it with provider_id.
-- Identifiers (route_id, stop_id, trip_id) are typed global ids upstream:
-- route_id: r-<provider_id>-<native_id> e.g. 'r-amtrak-40751'
-- stop_id: s-<provider_id>-<native_id> e.g. 's-amtrak-CHI'
-- trip_id: t-<provider_id>-<native_id> e.g. 't-amtrak-251208'
-- '-' is the structural separator. '~' is permitted inside provider or native
-- as a word-break (e.g. 'metra~electric'). See apps/api/ids for the parser.
-- The provider_id column is the bare provider name ('amtrak') — kept as a
-- denormalized facet for indexed filtering and FK cleanliness. service_id is
-- NOT a global id; queries must always pair it with provider_id.

CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Expand Down
44 changes: 19 additions & 25 deletions apps/api/db/static_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,12 @@ func (d *DB) GetProvider(ctx context.Context, providerID string) (*spec.Agency,
return &a, nil
}

// GetStopByCode returns a stop by (provider_id, code).
func (d *DB) GetStopByCode(ctx context.Context, providerID, stopCode string) (*spec.Stop, error) {
// GetStop returns a stop by its typed global id (e.g. 's-amtrak-CHI').
func (d *DB) GetStop(ctx context.Context, stopID string) (*spec.Stop, error) {
row := d.pool.QueryRow(ctx, `
SELECT stop_id, provider_id, code, name, lat, lon, timezone, wheelchair_boarding
FROM stops
WHERE provider_id = $1 AND code = $2
LIMIT 1`, providerID, stopCode)
var s spec.Stop
FROM stops WHERE stop_id = $1`, stopID)
s := spec.Stop{Type: spec.StopTypeStop}
if err := row.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
Expand All @@ -120,19 +118,13 @@ func (d *DB) GetStopByCode(ctx context.Context, providerID, stopCode string) (*s
return &s, nil
}

// GetStopByID returns a stop by its namespaced stop_id.
func (d *DB) GetStopByID(ctx context.Context, stopID string) (*spec.Stop, error) {
row := d.pool.QueryRow(ctx, `
SELECT stop_id, provider_id, code, name, lat, lon, timezone, wheelchair_boarding
FROM stops WHERE stop_id = $1`, stopID)
var s spec.Stop
if err := row.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
// GetHub returns a meta-station (hub) by its typed global id (e.g. 'h-amtrak-CHI').
//
// Stubbed: the hubs table doesn't exist yet, so this always returns ErrNotFound.
// The signature is in place so the polymorphic /v1/stops/{id} handler can wire
// to it once dedup is implemented.
func (d *DB) GetHub(_ context.Context, _ string) (*spec.Hub, error) {
return nil, ErrNotFound
}

// GetRoute returns a route by its full namespaced route_id.
Expand Down Expand Up @@ -220,7 +212,7 @@ func (d *DB) ListStopsNearby(ctx context.Context, lat, lon, radiusM float64, pro

var out []spec.Stop
for rows.Next() {
var s spec.Stop
s := spec.Stop{Type: spec.StopTypeStop}
if err := rows.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil {
return nil, err
}
Expand Down Expand Up @@ -320,7 +312,7 @@ func (d *DB) ListStops(ctx context.Context, providerID string, bbox BBox) ([]spe

var out []spec.Stop
for rows.Next() {
var s spec.Stop
s := spec.Stop{Type: spec.StopTypeStop}
if err := rows.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil {
return nil, err
}
Expand Down Expand Up @@ -511,11 +503,11 @@ func (d *DB) GetConnections(ctx context.Context, fromStopID, toStopID, date stri
// Hydrate stop names for from/to and load intermediate stops per trip.
out := make([]ConnectionItem, 0, len(pairs))
for _, p := range pairs {
fromStop, err := d.GetStopByID(ctx, fromStopID)
fromStop, err := d.GetStop(ctx, fromStopID)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
toStop, err := d.GetStopByID(ctx, toStopID)
toStop, err := d.GetStop(ctx, toStopID)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
Expand Down Expand Up @@ -567,8 +559,10 @@ func (d *DB) intermediateStops(ctx context.Context, tripID string, fromSeq, toSe
return out, rows.Err()
}

// GetTrainsForRoute returns unique train numbers operating on a route.
func (d *DB) GetTrainsForRoute(ctx context.Context, routeID string) ([]TrainItem, error) {
// GetTripsForRoute returns unique train numbers operating on a route. The name
// reflects the new endpoint (/v1/routes/{r}/trips) — the return shape is still
// the aggregated train-number view that powers the mobile route detail screen.
func (d *DB) GetTripsForRoute(ctx context.Context, routeID string) ([]TrainItem, error) {
rows, err := d.pool.Query(ctx, `
SELECT provider_id, short_name, MIN(headsign) AS sample_headsign, COUNT(*) AS trip_count
FROM trips
Expand Down
25 changes: 19 additions & 6 deletions apps/api/gtfs/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
gtfsrt "github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs"
"google.golang.org/protobuf/proto"

"github.com/RailForLess/tracky/api/ids"
"github.com/RailForLess/tracky/api/spec"
)

Expand Down Expand Up @@ -47,8 +48,13 @@ func FetchAndParsePositions(
// emitting a position with a zero RunDate.
continue
}
pos.TripID = providerID + ":" + trip.GetTripId()
pos.RouteID = providerID + ":" + trip.GetRouteId()
if trip.GetTripId() == "" {
continue
}
pos.TripID = ids.MustEncode(ids.KindTrip, providerID, trip.GetTripId())
if rid := trip.GetRouteId(); rid != "" {
pos.RouteID = ids.MustEncode(ids.KindRoute, providerID, rid)
}
pos.RunDate = runDate
}

Expand All @@ -75,8 +81,8 @@ func FetchAndParsePositions(
}
}

if vp.StopId != nil {
stopID := providerID + ":" + vp.GetStopId()
if sid := vp.GetStopId(); sid != "" {
stopID := ids.MustEncode(ids.KindStop, providerID, sid)
pos.CurrentStopCode = &stopID
}

Expand Down Expand Up @@ -129,14 +135,21 @@ func FetchAndParseTripUpdates(
// emitting stop times with a zero RunDate.
continue
}
tripID := providerID + ":" + trip.GetTripId()
if trip.GetTripId() == "" {
continue
}
tripID := ids.MustEncode(ids.KindTrip, providerID, trip.GetTripId())

for _, stu := range tu.StopTimeUpdate {
var stopCode string
if sid := stu.GetStopId(); sid != "" {
stopCode = ids.MustEncode(ids.KindStop, providerID, sid)
}
st := spec.TrainStopTime{
Provider: providerID,
TripID: tripID,
RunDate: runDate,
StopCode: providerID + ":" + stu.GetStopId(),
StopCode: stopCode,
LastUpdated: now,
}
// Only set StopSequence when explicitly provided by the feed;
Expand Down
14 changes: 8 additions & 6 deletions apps/api/gtfs/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"time"

"github.com/RailForLess/tracky/api/ids"
"github.com/RailForLess/tracky/api/spec"
)

Expand Down Expand Up @@ -396,7 +397,7 @@ func parseRoutes(f *zip.File, providerID string) ([]spec.Route, error) {
for _, r := range rows {
out = append(out, spec.Route{
ProviderID: providerID,
RouteID: providerID + ":" + r["route_id"],
RouteID: ids.MustEncode(ids.KindRoute, providerID, r["route_id"]),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Replace ids.MustEncode in feed parsing with error-returning ids.Encode.

Line 400, Line 435, Line 456, and Line 481 currently panic on malformed feed IDs. Since GTFS input is external, this should return contextual parse errors, not crash the process.

Suggested change pattern
- RouteID:    ids.MustEncode(ids.KindRoute, providerID, r["route_id"]),
+ routeID, err := ids.Encode(ids.KindRoute, providerID, r["route_id"])
+ if err != nil {
+   return nil, fmt.Errorf("gtfs [%s]: routes.txt: invalid route_id %q: %w", providerID, r["route_id"], err)
+ }
+ RouteID: routeID,

Apply the same pattern for stop_id, trip_id, and route_id in parseStops, parseTrips, and parseStopTimes.

Also applies to: 435-435, 456-457, 481-482

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/gtfs/static.go` at line 400, Replace the panicking ids.MustEncode
calls with the error-returning ids.Encode and propagate a contextual parse error
instead of crashing: in the route parsing assignment that sets RouteID (and
similarly in parseStops for stop_id, parseTrips for trip_id, and parseStopTimes
for route_id/stop_id), call ids.Encode(ids.Kind..., providerID, rawID), check
the returned error, and return a descriptive parse error that includes the
providerID, the raw ID value and the CSV row/context (e.g. function
parseRoutes/parseStops/parseTrips/parseStopTimes) so the caller can handle
malformed feed IDs instead of panicking.

ShortName: r["route_short_name"],
LongName: r["route_long_name"],
Color: r["route_color"],
Expand Down Expand Up @@ -429,8 +430,9 @@ func parseStops(f *zip.File, providerID string) ([]spec.Stop, error) {
code = r["stop_id"]
}
out = append(out, spec.Stop{
Type: spec.StopTypeStop,
ProviderID: providerID,
StopID: providerID + ":" + r["stop_id"],
StopID: ids.MustEncode(ids.KindStop, providerID, r["stop_id"]),
Code: code,
Name: r["stop_name"],
Lat: lat,
Expand All @@ -451,8 +453,8 @@ func parseTrips(f *zip.File, providerID string) ([]spec.Trip, error) {
for _, r := range rows {
out = append(out, spec.Trip{
ProviderID: providerID,
TripID: providerID + ":" + r["trip_id"],
RouteID: providerID + ":" + r["route_id"],
TripID: ids.MustEncode(ids.KindTrip, providerID, r["trip_id"]),
RouteID: ids.MustEncode(ids.KindRoute, providerID, r["route_id"]),
ServiceID: r["service_id"],
ShortName: r["trip_short_name"],
Headsign: r["trip_headsign"],
Expand All @@ -476,8 +478,8 @@ func parseStopTimes(f *zip.File, providerID string) ([]spec.ScheduledStopTime, e
}
out = append(out, spec.ScheduledStopTime{
ProviderID: providerID,
TripID: providerID + ":" + r["trip_id"],
StopID: providerID + ":" + r["stop_id"],
TripID: ids.MustEncode(ids.KindTrip, providerID, r["trip_id"]),
StopID: ids.MustEncode(ids.KindStop, providerID, r["stop_id"]),
StopSequence: seq,
ArrivalTime: optStr(r, "arrival_time"),
DepartureTime: optStr(r, "departure_time"),
Expand Down
Loading
Loading