diff --git a/README.md b/README.md index 2b8e605..a7c847a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/api/db/schema.sql b/apps/api/db/schema.sql index 188536f..f544a55 100644 --- a/apps/api/db/schema.sql +++ b/apps/api/db/schema.sql @@ -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 --- ":" 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-- e.g. 'r-amtrak-40751' +-- stop_id: s-- e.g. 's-amtrak-CHI' +-- trip_id: t-- 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; diff --git a/apps/api/db/static_read.go b/apps/api/db/static_read.go index c004ade..f5eb002 100644 --- a/apps/api/db/static_read.go +++ b/apps/api/db/static_read.go @@ -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 @@ -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. @@ -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 } @@ -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 } @@ -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 } @@ -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 diff --git a/apps/api/gtfs/realtime.go b/apps/api/gtfs/realtime.go index 96e2a94..ad522ba 100644 --- a/apps/api/gtfs/realtime.go +++ b/apps/api/gtfs/realtime.go @@ -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" ) @@ -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 } @@ -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 } @@ -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; diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go index 28b9eb6..1f77705 100644 --- a/apps/api/gtfs/static.go +++ b/apps/api/gtfs/static.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/spec" ) @@ -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"]), ShortName: r["route_short_name"], LongName: r["route_long_name"], Color: r["route_color"], @@ -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, @@ -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"], @@ -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"), diff --git a/apps/api/ids/ids.go b/apps/api/ids/ids.go new file mode 100644 index 0000000..6354a56 --- /dev/null +++ b/apps/api/ids/ids.go @@ -0,0 +1,155 @@ +// Package ids encodes and decodes Tracky's typed global identifiers. +// +// Every addressable resource is referred to by a self-describing ID of the form +// +// [kind]-[provider]-[native] +// +// where kind is a single-character entity tag (s, r, t, h) and native is the +// provider's own GTFS identifier. Operators are a degenerate case with no +// native id: o-[provider]. +// +// The `-` is the structural separator. Within a single segment (provider or +// native), the `~` character is permitted as a word-break — useful for +// multi-word provider names that don't fit a single token. +// +// Examples: +// - s-amtrak-CHI stop +// - r-amtrak-40751 route +// - t-amtrak-251208 trip +// - h-amtrak-NYC hub (meta-station) +// - o-amtrak operator / provider +// - s-metra~electric-FOO multi-word provider +// - t-brightline-service~A~v2 tildes inside the native id +package ids + +import ( + "errors" + "fmt" + "strings" +) + +type Kind string + +const ( + KindStop Kind = "s" + KindRoute Kind = "r" + KindTrip Kind = "t" + KindHub Kind = "h" + KindOperator Kind = "o" +) + +var knownKinds = map[Kind]bool{ + KindStop: true, KindRoute: true, KindTrip: true, KindHub: true, KindOperator: true, +} + +// ID is the decoded form of a typed global identifier. +// Native is empty when Kind == KindOperator. +type ID struct { + Kind Kind + Provider string + Native string +} + +var ( + ErrEmpty = errors.New("ids: empty input") + ErrMissingDash = errors.New("ids: missing '-' separator") + ErrUnknownKind = errors.New("ids: unknown kind") + ErrEmptyProvider = errors.New("ids: empty provider") + ErrEmptyNative = errors.New("ids: empty native id") + ErrOperatorNative = errors.New("ids: operator id must not have a native segment") + ErrProviderDash = errors.New("ids: provider must not contain '-' (use '~' for multi-word providers)") +) + +// Encode builds a global ID from its parts. Returns an error if any part +// violates the format (empty provider, '-' in provider, etc.). +func Encode(kind Kind, provider, native string) (string, error) { + if !knownKinds[kind] { + return "", fmt.Errorf("%w: %q", ErrUnknownKind, kind) + } + if provider == "" { + return "", ErrEmptyProvider + } + if strings.ContainsRune(provider, '-') { + return "", fmt.Errorf("%w: %q", ErrProviderDash, provider) + } + if kind == KindOperator { + if native != "" { + return "", ErrOperatorNative + } + return string(kind) + "-" + provider, nil + } + if native == "" { + return "", ErrEmptyNative + } + return string(kind) + "-" + provider + "-" + native, nil +} + +// MustEncode is Encode that panics on error. For tests and constants only. +func MustEncode(kind Kind, provider, native string) string { + s, err := Encode(kind, provider, native) + if err != nil { + panic(err) + } + return s +} + +// Decode parses a global ID. Operator IDs (o-foo) are accepted with an empty +// Native field; all other kinds require a non-empty native after the second '-'. +func Decode(s string) (ID, error) { + if s == "" { + return ID{}, ErrEmpty + } + // First dash splits kind from the rest. + kindStr, rest, ok := strings.Cut(s, "-") + if !ok || kindStr == "" { + return ID{}, ErrMissingDash + } + kind := Kind(kindStr) + if !knownKinds[kind] { + return ID{}, fmt.Errorf("%w: %q", ErrUnknownKind, kind) + } + if kind == KindOperator { + if strings.ContainsRune(rest, '-') { + return ID{}, ErrOperatorNative + } + if rest == "" { + return ID{}, ErrEmptyProvider + } + return ID{Kind: kind, Provider: rest}, nil + } + // Second dash splits provider from native. Native may itself contain '-' + // (we cut on the first one only) so e.g. native='NY-PENN' is fine. + provider, native, ok := strings.Cut(rest, "-") + if !ok { + return ID{}, ErrMissingDash + } + if provider == "" { + return ID{}, ErrEmptyProvider + } + if native == "" { + return ID{}, ErrEmptyNative + } + return ID{Kind: kind, Provider: provider, Native: native}, nil +} + +// DecodeKind parses s and asserts its kind matches want. Convenience for +// handlers that already know which kind they expect from the route. +func DecodeKind(s string, want Kind) (ID, error) { + id, err := Decode(s) + if err != nil { + return ID{}, err + } + if id.Kind != want { + return ID{}, fmt.Errorf("ids: expected kind %q, got %q", want, id.Kind) + } + return id, nil +} + +// String returns the encoded form of id, or "" if id is invalid. +func (id ID) String() string { + s, err := Encode(id.Kind, id.Provider, id.Native) + if err != nil { + return "" + } + return s +} diff --git a/apps/api/ids/ids_test.go b/apps/api/ids/ids_test.go new file mode 100644 index 0000000..2295150 --- /dev/null +++ b/apps/api/ids/ids_test.go @@ -0,0 +1,132 @@ +package ids + +import ( + "errors" + "testing" +) + +func TestEncode(t *testing.T) { + cases := []struct { + name string + kind Kind + provider string + native string + want string + wantErr error + }{ + {"stop", KindStop, "amtrak", "CHI", "s-amtrak-CHI", nil}, + {"route", KindRoute, "amtrak", "40751", "r-amtrak-40751", nil}, + {"trip", KindTrip, "amtrak", "251208", "t-amtrak-251208", nil}, + {"hub", KindHub, "amtrak", "NYC", "h-amtrak-NYC", nil}, + {"operator", KindOperator, "amtrak", "", "o-amtrak", nil}, + {"multi-word provider with tilde", KindStop, "metra~electric", "FOO", "s-metra~electric-FOO", nil}, + {"native with dash", KindStop, "amtrak", "NY-PENN", "s-amtrak-NY-PENN", nil}, + {"native with tilde", KindTrip, "brightline", "service~A~v2", "t-brightline-service~A~v2", nil}, + + {"unknown kind", Kind("x"), "amtrak", "CHI", "", ErrUnknownKind}, + {"empty provider", KindStop, "", "CHI", "", ErrEmptyProvider}, + {"provider has dash", KindStop, "metra-electric", "FOO", "", ErrProviderDash}, + {"empty native, non-operator", KindStop, "amtrak", "", "", ErrEmptyNative}, + {"operator with native", KindOperator, "amtrak", "X", "", ErrOperatorNative}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode(tc.kind, tc.provider, tc.native) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestDecode(t *testing.T) { + cases := []struct { + in string + kind Kind + provider string + native string + wantErr error + }{ + {"s-amtrak-CHI", KindStop, "amtrak", "CHI", nil}, + {"r-amtrak-40751", KindRoute, "amtrak", "40751", nil}, + {"t-amtrak-251208", KindTrip, "amtrak", "251208", nil}, + {"h-amtrak-NYC", KindHub, "amtrak", "NYC", nil}, + {"o-amtrak", KindOperator, "amtrak", "", nil}, + {"s-metra~electric-FOO", KindStop, "metra~electric", "FOO", nil}, + // Native containing '-' lands entirely after the second '-'. + {"s-amtrak-NY-PENN", KindStop, "amtrak", "NY-PENN", nil}, + {"t-brightline-service~A~v2", KindTrip, "brightline", "service~A~v2", nil}, + + {"", "", "", "", ErrEmpty}, + {"amtrak", "", "", "", ErrMissingDash}, + {"-amtrak-CHI", "", "", "", ErrMissingDash}, + {"x-amtrak-CHI", "", "", "", ErrUnknownKind}, + {"s--CHI", "", "", "", ErrEmptyProvider}, + {"s-amtrak-", "", "", "", ErrEmptyNative}, + {"s-amtrak", "", "", "", ErrMissingDash}, + {"o-amtrak-X", "", "", "", ErrOperatorNative}, + {"o-", "", "", "", ErrEmptyProvider}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got, err := Decode(tc.in) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tc.kind || got.Provider != tc.provider || got.Native != tc.native { + t.Fatalf("got %+v, want {%s %s %s}", got, tc.kind, tc.provider, tc.native) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + cases := []string{ + "s-amtrak-CHI", + "r-amtrak-40751", + "t-amtrak-251208", + "h-amtrak-NYC", + "o-amtrak", + "s-metra~electric-123", + "t-brightline-service~A~v2", + "s-amtrak-NY-PENN", + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + id, err := Decode(in) + if err != nil { + t.Fatalf("decode: %v", err) + } + if id.String() != in { + t.Fatalf("round-trip: got %q, want %q", id.String(), in) + } + }) + } +} + +func TestDecodeKind(t *testing.T) { + if _, err := DecodeKind("s-amtrak-CHI", KindStop); err != nil { + t.Fatalf("expected match: %v", err) + } + if _, err := DecodeKind("s-amtrak-CHI", KindRoute); err == nil { + t.Fatal("expected kind mismatch error") + } + if _, err := DecodeKind("garbage", KindStop); err == nil { + t.Fatal("expected decode error") + } +} diff --git a/apps/api/realtime/process.go b/apps/api/realtime/process.go index 9fa648d..aac0958 100644 --- a/apps/api/realtime/process.go +++ b/apps/api/realtime/process.go @@ -11,6 +11,7 @@ import ( "github.com/RailForLess/tracky/api/collector" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/ws" ) @@ -31,8 +32,9 @@ func (p *Processor) Process(ctx context.Context, snap *collector.Snapshot) error return fmt.Errorf("realtime: nil snapshot or feed") } - // Wire format matches the existing ws.RealtimeUpdate so iOS clients - // see no change vs. the legacy in-process poller. + // Topic is the operator's typed global id (o-) so that future + // versions can also publish to route/trip/vehicle topics without renaming. + topic := ids.MustEncode(ids.KindOperator, snap.ProviderID, "") payload, err := json.Marshal(ws.RealtimeUpdate{ Type: "realtime_update", Provider: snap.ProviderID, @@ -41,7 +43,7 @@ func (p *Processor) Process(ctx context.Context, snap *collector.Snapshot) error if err != nil { return fmt.Errorf("realtime: marshal: %w", err) } - p.Hub.Publish(snap.ProviderID, payload) + p.Hub.Publish(topic, payload) if p.DB != nil && len(snap.Feed.StopTimes) > 0 { if err := p.DB.UpsertTrainStopTimes(ctx, snap.Feed.StopTimes); err != nil { diff --git a/apps/api/realtime/process_test.go b/apps/api/realtime/process_test.go index 0657c21..f31ba96 100644 --- a/apps/api/realtime/process_test.go +++ b/apps/api/realtime/process_test.go @@ -33,7 +33,7 @@ func TestProcessor_PublishesToHubInLegacyShape(t *testing.T) { // Snapshot is async — wait briefly for hub Run loop to land it. deadline := time.After(time.Second) for { - if payload, ok := hub.Snapshot("amtrak"); ok { + if payload, ok := hub.Snapshot("o-amtrak"); ok { var u ws.RealtimeUpdate if err := json.Unmarshal(payload, &u); err != nil { t.Fatalf("unmarshal: %v", err) diff --git a/apps/api/routes/ingest_test.go b/apps/api/routes/ingest_test.go index ce67e8a..2e1788c 100644 --- a/apps/api/routes/ingest_test.go +++ b/apps/api/routes/ingest_test.go @@ -57,7 +57,7 @@ func TestIngest_Accepts204(t *testing.T) { } // Hub publish is async — wait briefly. for range 50 { - if _, ok := hub.Snapshot("amtrak"); ok { + if _, ok := hub.Snapshot("o-amtrak"); ok { return } time.Sleep(10 * time.Millisecond) diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 21f3108..30bac87 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/realtime" "github.com/RailForLess/tracky/api/ws" ) @@ -13,8 +14,11 @@ import ( // /v1/* read endpoints are not registered. func Setup(mux *http.ServeMux, hub *ws.Hub, processor *realtime.Processor, database *db.DB, ingestSecret string) { mux.HandleFunc("POST /ingest", HandleIngest(processor, ingestSecret)) - mux.HandleFunc("GET /debug/providers/{id}/realtime", handleSyncRealtime(hub)) - mux.HandleFunc("GET /v1/active", handleActiveTrains(hub)) + + // Currently-tracked runs, sourced from the hub snapshot. Future history + // (past-day runs) will live at `/v1/trips/{trip_id}/runs?from=&to=` backed + // by Timescale — this endpoint stays scoped to "live now". + mux.HandleFunc("GET /v1/realtime", handleRealtimeRuns(hub)) if database != nil { registerStatic(mux, database) @@ -27,53 +31,41 @@ func writeJSON(w http.ResponseWriter, status int, v any) { json.NewEncoder(w).Encode(v) } -// handleSyncRealtime returns the cached realtime snapshot for a provider. -func handleSyncRealtime(hub *ws.Hub) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - snapshot, ok := hub.Snapshot(id) - if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "no realtime data yet for provider " + id, - }) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(snapshot) - } -} - -// ActiveTrain identifies a single in-progress run, derived from the latest -// hub snapshot. Used by clients (e.g. the "live only" filter in the mobile -// trip search) to know which runs are currently being tracked without -// needing a full WebSocket subscription. -type ActiveTrain struct { - Provider string `json:"provider"` - TripID string `json:"tripId"` +// Run identifies a single in-progress run, derived from the latest hub +// snapshot. A run is a trip × run_date instance — distinct from the scheduled +// trip template returned by /v1/trips. +type Run struct { + ProviderID string `json:"providerId"` // bare provider for ergonomics + TripID string `json:"tripId"` // t-amtrak-... RunDate string `json:"runDate"` TrainNumber string `json:"trainNumber"` - RouteID string `json:"routeId"` + RouteID string `json:"routeId"` // r-amtrak-... } -// handleActiveTrains returns the set of currently-tracked runs for a provider, -// sourced from the most-recent realtime snapshot the hub has published. +// handleRealtimeRuns serves GET /v1/realtime?topic= — currently-tracked runs +// from the hub's most-recent snapshot for the given topic. // -// GET /v1/active?provider=amtrak → { activeTrains: [...] } -func handleActiveTrains(hub *ws.Hub) http.HandlerFunc { +// The topic param accepts any well-formed global id (operator, route, trip, +// etc.), mirroring the WebSocket subscribe protocol. Today only operator +// topics ('o-') are published by the realtime processor; route/trip +// topics return empty until finer-grained fan-out lands. +func handleRealtimeRuns(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") - if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + topic := r.URL.Query().Get("topic") + if topic == "" { + writeError(w, http.StatusBadRequest, "topic query param required (typed global id)") + return + } + if _, err := ids.Decode(topic); err != nil { + writeError(w, http.StatusBadRequest, "topic must be a well-formed global id") return } out := struct { - ActiveTrains []ActiveTrain `json:"activeTrains"` - }{ActiveTrains: []ActiveTrain{}} + Runs []Run `json:"runs"` + }{Runs: []Run{}} - snapshot, ok := hub.Snapshot(provider) + snapshot, ok := hub.Snapshot(topic) if !ok { // No realtime data yet — return empty list (not an error). writeJSON(w, http.StatusOK, out) @@ -87,8 +79,8 @@ func handleActiveTrains(hub *ws.Hub) http.HandlerFunc { } for _, p := range update.Positions { - out.ActiveTrains = append(out.ActiveTrains, ActiveTrain{ - Provider: p.Provider, + out.Runs = append(out.Runs, Run{ + ProviderID: p.Provider, TripID: p.TripID, RunDate: p.RunDate.Format("2006-01-02"), TrainNumber: p.TrainNumber, diff --git a/apps/api/routes/static.go b/apps/api/routes/static.go index 9f38063..39653e0 100644 --- a/apps/api/routes/static.go +++ b/apps/api/routes/static.go @@ -9,31 +9,37 @@ import ( "time" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" ) // registerStatic wires every read endpoint exposed under /v1/. +// +// Resources are addressed by typed global ids — see apps/api/ids/. The provider +// segment is no longer in the URL path; it lives inside the id. func registerStatic(mux *http.ServeMux, d *db.DB) { - mux.HandleFunc("GET /v1/providers/{provider}", handleGetProvider(d)) + mux.HandleFunc("GET /v1/providers/{providerID}", handleGetProvider(d)) - mux.HandleFunc("GET /v1/stops/nearby", handleNearbyStops(d)) - mux.HandleFunc("GET /v1/stops/{provider}/{stopCode}", handleGetStop(d)) + // Polymorphic: 's-' returns a Stop, 'h-' (when implemented) returns a Hub. + mux.HandleFunc("GET /v1/stops/{stopID}", handleGetStop(d)) + // Spatial / list. Accepts either ?bbox=... or ?lat=&lon=&radius_m=. mux.HandleFunc("GET /v1/stops", handleListStops(d)) + mux.HandleFunc("GET /v1/stops/{stopID}/departures", handleDepartures(d)) - mux.HandleFunc("GET /v1/routes/{provider}/{routeCode}", handleGetRoute(d)) + mux.HandleFunc("GET /v1/routes/{routeID}", handleGetRoute(d)) mux.HandleFunc("GET /v1/routes", handleListRoutes(d)) - mux.HandleFunc("GET /v1/routes/{provider}/{routeCode}/trains", handleTrainsForRoute(d)) + mux.HandleFunc("GET /v1/routes/{routeID}/trips", handleTripsForRoute(d)) - mux.HandleFunc("GET /v1/trips/lookup", handleLookupTrips(d)) - mux.HandleFunc("GET /v1/trips/{tripId}/stops", handleTripStops(d)) - mux.HandleFunc("GET /v1/trips/{tripId}", handleGetTrip(d)) + // Scheduled-trip lookup by train number on a service date. Realtime "what + // is currently running" lives at /v1/realtime (wired in routes.go). + mux.HandleFunc("GET /v1/trips", handleListTripsByLookup(d)) + mux.HandleFunc("GET /v1/trips/service", handleTrainService(d)) + mux.HandleFunc("GET /v1/trips/{tripID}/stops", handleTripStops(d)) + mux.HandleFunc("GET /v1/trips/{tripID}", handleGetTrip(d)) - mux.HandleFunc("GET /v1/runs/{provider}/{tripId}/{runDate}/stops", handleRunStops(d)) + mux.HandleFunc("GET /v1/trips/{tripID}/runs/{runDate}/stops", handleRunStops(d)) - mux.HandleFunc("GET /v1/departures", handleDepartures(d)) mux.HandleFunc("GET /v1/connections", handleConnections(d)) - mux.HandleFunc("GET /v1/trains/{trainNumber}/service", handleTrainService(d)) - mux.HandleFunc("GET /v1/search", handleSearch(d)) } @@ -103,12 +109,46 @@ func parseBBox(s string) (db.BBox, bool) { }, true } +// decodePath parses a path-segment global id and writes a 400 if malformed +// or 400 if the kind doesn't match `want`. Returns (parsed, false) on error. +func decodePath(w http.ResponseWriter, raw string, want ids.Kind, label string) (ids.ID, bool) { + id, err := ids.Decode(raw) + if err != nil { + writeError(w, http.StatusBadRequest, label+": invalid id format") + return ids.ID{}, false + } + if id.Kind != want { + writeError(w, http.StatusBadRequest, label+": expected "+string(want)+"- prefix, got "+string(id.Kind)+"-") + return ids.ID{}, false + } + return id, true +} + +// providerFromQuery returns the bare provider id from a query param holding a +// typed operator id ('o-amtrak'). Empty input yields ("", true) — callers +// that require the filter must reject it themselves. Any non-empty value that +// isn't a well-formed operator id is rejected. +func providerFromQuery(raw string) (string, bool) { + if raw == "" { + return "", true + } + id, err := ids.Decode(raw) + if err != nil || id.Kind != ids.KindOperator { + return "", false + } + return id.Provider, true +} + // ── Handlers ──────────────────────────────────────────────────────────── func handleGetProvider(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - a, err := d.GetProvider(r.Context(), provider) + raw := r.PathValue("providerID") + id, ok := decodePath(w, raw, ids.KindOperator, "provider id") + if !ok { + return + } + a, err := d.GetProvider(r.Context(), id.Provider) if errors.Is(err, db.ErrNotFound) { notFound(w, "provider not found") return @@ -122,33 +162,99 @@ func handleGetProvider(d *db.DB) http.HandlerFunc { } } +// handleGetStop is polymorphic: returns a Stop for 's-' ids and a Hub for 'h-'. +// The JSON response carries a `type` discriminator so clients can use a +// discriminated union. func handleGetStop(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("stopCode") - s, err := d.GetStopByCode(r.Context(), provider, code) - if errors.Is(err, db.ErrNotFound) { - notFound(w, "stop not found") - return - } + raw := r.PathValue("stopID") + id, err := ids.Decode(raw) if err != nil { - serverError(w, err) + writeError(w, http.StatusBadRequest, "stop id: invalid id format") return } - setCacheable(w) - writeJSON(w, http.StatusOK, s) + switch id.Kind { + case ids.KindStop: + s, err := d.GetStop(r.Context(), raw) + if errors.Is(err, db.ErrNotFound) { + notFound(w, "stop not found") + return + } + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, s) + case ids.KindHub: + h, err := d.GetHub(r.Context(), raw) + if errors.Is(err, db.ErrNotFound) { + // Until hubs are ingested this is the steady-state response. + writeError(w, http.StatusNotImplemented, "hubs are not yet supported") + return + } + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, h) + default: + writeError(w, http.StatusBadRequest, "stop id: expected s- or h- prefix, got "+string(id.Kind)+"-") + } } } +// handleListStops serves both the bbox query (?bbox=) and the nearby query +// (?lat=&lon=&radius_m=). At least one shape is required. func handleListStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") + q := r.URL.Query() + provider, ok := providerFromQuery(q.Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } + + // Nearby: lat/lon required, radius optional. + latRaw := q.Get("lat") + lonRaw := q.Get("lon") + if latRaw != "" || lonRaw != "" { + lat, latOK := parseFloat(latRaw) + lon, lonOK := parseFloat(lonRaw) + if !latOK || !lonOK || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + writeError(w, http.StatusBadRequest, "lat and lon are required (lat in [-90,90], lon in [-180,180])") + return + } + radius := 5000.0 + if raw := q.Get("radius_m"); raw != "" { + v, ok := parseFloat(raw) + if !ok || v <= 0 { + writeError(w, http.StatusBadRequest, "radius_m must be a positive number") + return + } + radius = v + } + if radius > 50000 { + radius = 50000 + } + stops, err := d.ListStopsNearby(r.Context(), lat, lon, radius, provider) + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, stops) + return + } + + // Bbox / list mode: provider required. if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + writeError(w, http.StatusBadRequest, "provider_id required when not using lat/lon") return } - bbox, ok := parseBBox(r.URL.Query().Get("bbox")) - if !ok { + bbox, bboxOK := parseBBox(q.Get("bbox")) + if !bboxOK { writeError(w, http.StatusBadRequest, "bbox must be minLon,minLat,maxLon,maxLat") return } @@ -164,9 +270,11 @@ func handleListStops(d *db.DB) http.HandlerFunc { func handleGetRoute(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("routeCode") - route, err := d.GetRoute(r.Context(), provider+":"+code) + raw := r.PathValue("routeID") + if _, ok := decodePath(w, raw, ids.KindRoute, "route id"); !ok { + return + } + route, err := d.GetRoute(r.Context(), raw) if errors.Is(err, db.ErrNotFound) { notFound(w, "route not found") return @@ -182,9 +290,13 @@ func handleGetRoute(d *db.DB) http.HandlerFunc { func handleListRoutes(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") + provider, ok := providerFromQuery(r.URL.Query().Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + writeError(w, http.StatusBadRequest, "provider_id query param required") return } routes, err := d.ListRoutes(r.Context(), provider) @@ -197,24 +309,29 @@ func handleListRoutes(d *db.DB) http.HandlerFunc { } } -func handleTrainsForRoute(d *db.DB) http.HandlerFunc { +func handleTripsForRoute(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("routeCode") - trains, err := d.GetTrainsForRoute(r.Context(), provider+":"+code) + raw := r.PathValue("routeID") + if _, ok := decodePath(w, raw, ids.KindRoute, "route id"); !ok { + return + } + trips, err := d.GetTripsForRoute(r.Context(), raw) if err != nil { serverError(w, err) return } setCacheable(w) - writeJSON(w, http.StatusOK, trains) + writeJSON(w, http.StatusOK, trips) } } func handleGetTrip(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tripID := r.PathValue("tripId") - trip, err := d.GetTrip(r.Context(), tripID) + raw := r.PathValue("tripID") + if _, ok := decodePath(w, raw, ids.KindTrip, "trip id"); !ok { + return + } + trip, err := d.GetTrip(r.Context(), raw) if errors.Is(err, db.ErrNotFound) { notFound(w, "trip not found") return @@ -230,8 +347,11 @@ func handleGetTrip(d *db.DB) http.HandlerFunc { func handleTripStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tripID := r.PathValue("tripId") - stops, err := d.GetTripStops(r.Context(), tripID) + raw := r.PathValue("tripID") + if _, ok := decodePath(w, raw, ids.KindTrip, "trip id"); !ok { + return + } + stops, err := d.GetTripStops(r.Context(), raw) if err != nil { serverError(w, err) return @@ -241,14 +361,16 @@ func handleTripStops(d *db.DB) http.HandlerFunc { } } -func handleLookupTrips(d *db.DB) http.HandlerFunc { +// handleListTripsByLookup serves GET /v1/trips?train_number=&date= — scheduled +// trips for a service date. Currently-running trips are at /v1/realtime. +func handleListTripsByLookup(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - provider := q.Get("provider") + provider, providerOK := providerFromQuery(q.Get("provider_id")) train := q.Get("train_number") date, ok := parseDate(q.Get("date")) - if provider == "" || train == "" || !ok { - writeError(w, http.StatusBadRequest, "provider, train_number, and date (YYYY-MM-DD) required") + if !providerOK || provider == "" || train == "" || !ok { + writeError(w, http.StatusBadRequest, "provider_id, train_number, and date (YYYY-MM-DD) required") return } trips, err := d.LookupTripsByTrainNumber(r.Context(), provider, train, date) @@ -263,14 +385,26 @@ func handleLookupTrips(d *db.DB) http.HandlerFunc { func handleDepartures(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - stopID := q.Get("stop_id") - date, ok := parseDate(q.Get("date")) - if stopID == "" || !ok { - writeError(w, http.StatusBadRequest, "stop_id and date (YYYY-MM-DD) required") + raw := r.PathValue("stopID") + id, err := ids.Decode(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "stop id: invalid id format") + return + } + if id.Kind != ids.KindStop && id.Kind != ids.KindHub { + writeError(w, http.StatusBadRequest, "stop id: expected s- or h- prefix") + return + } + if id.Kind == ids.KindHub { + writeError(w, http.StatusNotImplemented, "hub departures are not yet supported") + return + } + date, ok := parseDate(r.URL.Query().Get("date")) + if !ok { + writeError(w, http.StatusBadRequest, "date (YYYY-MM-DD) required") return } - departures, err := d.GetDepartures(r.Context(), stopID, date) + departures, err := d.GetDepartures(r.Context(), raw, date) if err != nil { serverError(w, err) return @@ -290,6 +424,14 @@ func handleConnections(d *db.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "from_stop, to_stop, and date (YYYY-MM-DD) required") return } + if fid, err := ids.Decode(from); err != nil || fid.Kind != ids.KindStop { + writeError(w, http.StatusBadRequest, "from_stop must be an s- prefixed stop id") + return + } + if tid, err := ids.Decode(to); err != nil || tid.Kind != ids.KindStop { + writeError(w, http.StatusBadRequest, "to_stop must be an s- prefixed stop id") + return + } conns, err := d.GetConnections(r.Context(), from, to, date) if err != nil { serverError(w, err) @@ -302,13 +444,13 @@ func handleConnections(d *db.DB) http.HandlerFunc { func handleTrainService(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - train := r.PathValue("trainNumber") q := r.URL.Query() - provider := q.Get("provider") + provider, providerOK := providerFromQuery(q.Get("provider_id")) + train := q.Get("train_number") from := q.Get("from") to := q.Get("to") - if provider == "" || train == "" { - writeError(w, http.StatusBadRequest, "provider query param and trainNumber required") + if !providerOK || provider == "" || train == "" { + writeError(w, http.StatusBadRequest, "provider_id and train_number required") return } if from != "" { @@ -339,14 +481,17 @@ func handleTrainService(d *db.DB) http.HandlerFunc { func handleRunStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - tripID := r.PathValue("tripId") + raw := r.PathValue("tripID") + id, ok := decodePath(w, raw, ids.KindTrip, "trip id") + if !ok { + return + } runDate, ok := parseDate(r.PathValue("runDate")) - if provider == "" || tripID == "" || !ok { - writeError(w, http.StatusBadRequest, "provider, tripId, and runDate (YYYY-MM-DD) required") + if !ok { + writeError(w, http.StatusBadRequest, "runDate must be YYYY-MM-DD") return } - stops, err := d.GetRunStops(r.Context(), provider, tripID, runDate) + stops, err := d.GetRunStops(r.Context(), id.Provider, raw, runDate) if errors.Is(err, db.ErrNotFound) { notFound(w, "run not found") return @@ -360,37 +505,6 @@ func handleRunStops(d *db.DB) http.HandlerFunc { } } -func handleNearbyStops(d *db.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - lat, latOK := parseFloat(q.Get("lat")) - lon, lonOK := parseFloat(q.Get("lon")) - if !latOK || !lonOK || lat < -90 || lat > 90 || lon < -180 || lon > 180 { - writeError(w, http.StatusBadRequest, "lat and lon are required (lat in [-90,90], lon in [-180,180])") - return - } - radius := 5000.0 - if raw := q.Get("radius_m"); raw != "" { - v, ok := parseFloat(raw) - if !ok || v <= 0 { - writeError(w, http.StatusBadRequest, "radius_m must be a positive number") - return - } - radius = v - } - if radius > 50000 { - radius = 50000 - } - stops, err := d.ListStopsNearby(r.Context(), lat, lon, radius, q.Get("provider")) - if err != nil { - serverError(w, err) - return - } - setCacheable(w) - writeJSON(w, http.StatusOK, stops) - } -} - func handleSearch(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -399,7 +513,11 @@ func handleSearch(d *db.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "q is required") return } - provider := q.Get("provider") + provider, ok := providerFromQuery(q.Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } types := q.Get("types") incStations, incTrains, incRoutes := true, true, true if types != "" { diff --git a/apps/api/spec/static.go b/apps/api/spec/static.go index e85e60c..9111014 100644 --- a/apps/api/spec/static.go +++ b/apps/api/spec/static.go @@ -20,7 +20,7 @@ type Agency struct { // Maps to GTFS routes.txt. type Route struct { ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - RouteID string `db:"route_id" json:"routeId"` // namespaced: 'amtrak:coast-starlight' + RouteID string `db:"route_id" json:"routeId"` // typed global id: 'r-amtrak-coast-starlight' ShortName string `db:"short_name" json:"shortName"` // '14' LongName string `db:"long_name" json:"longName"` // 'Coast Starlight' Color string `db:"color" json:"color"` // hex without #, e.g. '1D2E6E' @@ -28,17 +28,31 @@ type Route struct { ShapeID *string `db:"shape_id" json:"shapeId"` // reference into tile layer, not a DB table } +// StopType is the JSON discriminator emitted on /v1/stops/{id} responses so +// TypeScript clients can use a discriminated union over Stop | Hub. +type StopType string + +const ( + StopTypeStop StopType = "stop" + StopTypeHub StopType = "hub" +) + // Stop represents a physical station or stop. // Maps to GTFS stops.txt. +// +// Type is the polymorphic discriminator for /v1/stops/{id} responses; it is +// always StopTypeStop on this struct. The Hub variant of the union lives in +// spec.Hub and emits StopTypeHub. type Stop struct { - ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - StopID string `db:"stop_id" json:"stopId"` // namespaced: 'amtrak:LAX' - Code string `db:"code" json:"code"` // native code: 'LAX' - Name string `db:"name" json:"name"` // 'Los Angeles' - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - Timezone *string `db:"timezone" json:"timezone"` // stop-local tz if different from agency - WheelchairBoarding *bool `db:"wheelchair_boarding" json:"wheelchairBoarding"` + Type StopType `db:"-" json:"type"` // always "stop" + ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' + StopID string `db:"stop_id" json:"stopId"` // typed global id: 's-amtrak-LAX' + Code string `db:"code" json:"code"` // native code: 'LAX' + Name string `db:"name" json:"name"` // 'Los Angeles' + Lat float64 `db:"lat" json:"lat"` + Lon float64 `db:"lon" json:"lon"` + Timezone *string `db:"timezone" json:"timezone"` // stop-local tz if different from agency + WheelchairBoarding *bool `db:"wheelchair_boarding" json:"wheelchairBoarding"` } // Trip represents a scheduled service pattern. @@ -46,8 +60,8 @@ type Stop struct { // Note: a Trip is the template; a run is Trip + RunDate. type Trip struct { ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - TripID string `db:"trip_id" json:"tripId"` // namespaced: 'amtrak:5' - RouteID string `db:"route_id" json:"routeId"` // 'amtrak:coast-starlight' + TripID string `db:"trip_id" json:"tripId"` // typed global id: 't-amtrak-5' + RouteID string `db:"route_id" json:"routeId"` // 'r-amtrak-coast-starlight' ServiceID string `db:"service_id" json:"serviceId"` // links to ServiceCalendar ShortName string `db:"short_name" json:"shortName"` // GTFS trip_short_name — train number, e.g. '5' Headsign string `db:"headsign" json:"headsign"` // 'Chicago' @@ -55,6 +69,25 @@ type Trip struct { DirectionID *int `db:"direction_id" json:"directionId"` // 0=outbound, 1=inbound } +// Hub is the future "meta-station" type — a deduplicated grouping of stops +// across providers at a single physical location (e.g. CHI Union Station +// served by both Amtrak and Metra). Polymorphic /v1/stops/{id} returns this +// for ids with the 'h-' prefix. +// +// Stubbed: hubs are not yet ingested, so handlers return 501 for hub ids. +// The struct lives here so the JSON contract is stable when the table lands. +type Hub struct { + Type StopType `db:"-" json:"type"` // always "hub" + HubID string `db:"hub_id" json:"hubId"` // typed global id: 'h-amtrak-CHI~UNION' + Name string `db:"name" json:"name"` // 'Chicago Union Station' + Lat float64 `db:"lat" json:"lat"` + Lon float64 `db:"lon" json:"lon"` + Timezone *string `db:"timezone" json:"timezone"` + // Members are the stop ids that belong to this hub. Populated by the + // hub-resolution layer at read time. + Members []string `db:"-" json:"members"` +} + // ScheduledStopTime represents a trip's scheduled arrival/departure at a stop. // Maps to GTFS stop_times.txt. Static timetable only — never updated. // Actual and estimated times live in TrainStopTime (realtime model). diff --git a/apps/api/ws/handler.go b/apps/api/ws/handler.go index 5b4f15f..a6b777d 100644 --- a/apps/api/ws/handler.go +++ b/apps/api/ws/handler.go @@ -25,8 +25,8 @@ var upgrader = websocket.Upgrader{ } type clientMsg struct { - Action string `json:"action"` // "subscribe" | "unsubscribe" - Providers []string `json:"providers"` // e.g. ["cta", "amtrak"] + Action string `json:"action"` // "subscribe" | "unsubscribe" + Topics []string `json:"topics"` // typed global ids, e.g. ["o-amtrak", "o-cta"] } // Handler returns an http.HandlerFunc that upgrades connections to WebSocket. @@ -107,9 +107,9 @@ func readPump(hub *Hub, c *Client) { // Additive: add to the existing subscription set instead of replacing it. // Send cached snapshots only for newly added topics so a client that // re-subscribes to something it already had doesn't get a duplicate. - added := c.addTopics(msg.Providers) - for _, p := range added { - if snapshot, ok := hub.Snapshot(p); ok { + added := c.addTopics(msg.Topics) + for _, t := range added { + if snapshot, ok := hub.Snapshot(t); ok { // Route through the hub goroutine so c.send is only // written by the hub, avoiding a race with close on // unregister/backpressure. @@ -117,13 +117,13 @@ func readPump(hub *Hub, c *Client) { } } case "unsubscribe": - // Targeted: remove only the listed providers. An empty/missing list + // Targeted: remove only the listed topics. An empty/missing list // clears all subscriptions (preserves the original "unsubscribe = stop // everything" shortcut). - if len(msg.Providers) == 0 { + if len(msg.Topics) == 0 { c.clearTopics() } else { - c.removeTopics(msg.Providers) + c.removeTopics(msg.Topics) } } } diff --git a/apps/api/ws/hub.go b/apps/api/ws/hub.go index dd3cf5d..0488d39 100644 --- a/apps/api/ws/hub.go +++ b/apps/api/ws/hub.go @@ -22,29 +22,29 @@ func (c *Client) subscribedTo(topic string) bool { return ok } -// addTopics unions providers into the client's subscription set and returns +// addTopics unions topics into the client's subscription set and returns // the topics that were not already subscribed (caller uses this to send the // cached snapshot only for newly added topics). -func (c *Client) addTopics(providers []string) []string { +func (c *Client) addTopics(topics []string) []string { c.mu.Lock() defer c.mu.Unlock() var added []string - for _, p := range providers { - if _, ok := c.topics[p]; !ok { - c.topics[p] = struct{}{} - added = append(added, p) + for _, t := range topics { + if _, ok := c.topics[t]; !ok { + c.topics[t] = struct{}{} + added = append(added, t) } } return added } -// removeTopics drops the given providers from the subscription set. Topics +// removeTopics drops the given topics from the subscription set. Topics // that aren't currently subscribed are silently ignored. -func (c *Client) removeTopics(providers []string) { +func (c *Client) removeTopics(topics []string) { c.mu.Lock() defer c.mu.Unlock() - for _, p := range providers { - delete(c.topics, p) + for _, t := range topics { + delete(c.topics, t) } } @@ -65,7 +65,7 @@ type clientDelivery struct { payload []byte } -// Hub manages WebSocket clients and routes messages by topic (provider ID). +// Hub manages WebSocket clients and routes messages by topic (typed global id). type Hub struct { mu sync.RWMutex clients map[*Client]struct{} diff --git a/apps/mobile/__tests__/utils/train-helpers.test.ts b/apps/mobile/__tests__/utils/train-helpers.test.ts index c04a451..cb7cff0 100644 --- a/apps/mobile/__tests__/utils/train-helpers.test.ts +++ b/apps/mobile/__tests__/utils/train-helpers.test.ts @@ -1,16 +1,13 @@ import { extractTrainNumber, isLikelyTrainNumber } from '../../utils/train-helpers'; -// Mock the gtfsParser -jest.mock('../../utils/gtfs-parser', () => ({ - gtfsParser: { - getTrainNumber: jest.fn((tripId: string) => { - // Simulate GTFS parser behavior — returns trip_short_name or null - if (tripId === 'Amtrak-43-20240104') return '43'; - if (tripId === '2151') return '2151'; - // Simulate GTFS lookup miss — returns null - return null; - }), - }, +// Mock the API-client trip cache used by train-helpers. +jest.mock('../../services/api-client', () => ({ + getCachedTrip: jest.fn((tripId: string) => { + if (tripId === 'Amtrak-43-20240104') return { shortName: '43' }; + if (tripId === '2151') return { shortName: '2151' }; + return undefined; + }), + prefetchTrip: jest.fn(), })); describe('train-helpers utilities', () => { diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 72c078b..db7f2d4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,6 +1,8 @@ import "dotenv/config"; import type { ExpoConfig } from "expo/config"; +declare const process: { env: Record }; + const config: ExpoConfig = { name: "Tracky", slug: "tracky", @@ -124,6 +126,9 @@ const config: ExpoConfig = { eas: { "projectId": "f1a6b072-9cd4-4965-956c-8b60bdfba2e1" }, + apiUrl: process.env.EXPO_PUBLIC_API_URL ?? "https://api.trackyapp.net", + wsUrl: process.env.EXPO_PUBLIC_WS_URL ?? "wss://api.trackyapp.net/ws/realtime", + tilesUrl: process.env.EXPO_PUBLIC_TILES_URL ?? "https://tiles.trackyapp.net", }, owner: "railforless", }; diff --git a/apps/mobile/assets/apple-dark-style.json b/apps/mobile/assets/apple-dark-style.json new file mode 100644 index 0000000..191d42c --- /dev/null +++ b/apps/mobile/assets/apple-dark-style.json @@ -0,0 +1 @@ +{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#4e4e56","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4e4e56","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/assets/apple-light-style.json b/apps/mobile/assets/apple-light-style.json new file mode 100644 index 0000000..8a0496b --- /dev/null +++ b/apps/mobile/assets/apple-light-style.json @@ -0,0 +1 @@ +{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d8dc","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d8dc","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/components/TrainCardContent.tsx b/apps/mobile/components/TrainCardContent.tsx index de2d83c..64a5829 100644 --- a/apps/mobile/components/TrainCardContent.tsx +++ b/apps/mobile/components/TrainCardContent.tsx @@ -3,10 +3,11 @@ import { Image, StyleSheet, Text, View } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import { type ColorPalette, FontSizes, Spacing, withTextShadow } from '../constants/theme'; import { useColors } from '../context/ThemeContext'; +import { useApiCacheVersion } from '../hooks/useApiCache'; import { createStyles } from '../screens/styles'; import { getDelayColorKey, parseTimeToMinutes } from '../utils/time-formatting'; import { pluralCount } from '../utils/train-display'; -import { gtfsParser } from '../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../utils/api-stop-cache'; import { getCurrentSecondsInTimezone, getTimezoneForStop } from '../utils/timezone'; import AnimatedRollingText from './ui/AnimatedRollingText'; import MarqueeText from './ui/MarqueeText'; @@ -66,6 +67,7 @@ export default function TrainCardContent({ const colors = useColors(); const styles = useMemo(() => createStyles(colors), [colors]); const localStyles = useMemo(() => createLocalStyles(colors), [colors]); + const cacheVersion = useApiCacheVersion(); const DELAY_COLORS = { delayed: colors.delayed, @@ -76,14 +78,14 @@ export default function TrainCardContent({ const isArrived = useMemo(() => { if (!isPast || !arriveTime) return false; - const toStop = gtfsParser.getStop(toCode); - const arriveTz = toStop ? getTimezoneForStop(toStop) : gtfsParser.agencyTimezone; + const toStop = lookupStop(toCode); + const arriveTz = toStop ? getTimezoneForStop(toStop) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(arriveTz); const arriveSec = parseTimeToMinutes(arriveTime) * 60 + (arriveDayOffset ?? 0) * 24 * 3600; const delaySec = (arriveDelayMinutes ?? 0) * 60; return nowSec >= arriveSec + delaySec + (daysAway ?? 0) * 86400; - }, [isPast, arriveTime, arriveDayOffset, arriveDelayMinutes, toCode, daysAway]); + }, [isPast, arriveTime, arriveDayOffset, arriveDelayMinutes, toCode, daysAway, cacheVersion]); const shouldFadeTitle = fadeOnlyOnArrival ? isArrived : isPast; const pastColor = isPast ? { color: colors.secondary } : undefined; diff --git a/apps/mobile/components/TwoStationSearch.tsx b/apps/mobile/components/TwoStationSearch.tsx index 9b32222..94f95e2 100644 --- a/apps/mobile/components/TwoStationSearch.tsx +++ b/apps/mobile/components/TwoStationSearch.tsx @@ -3,18 +3,155 @@ import { Dimensions, Keyboard, Platform, StyleSheet, TextInput } from 'react-nat import { type ColorPalette, BorderRadius, FontSizes, Spacing, withTextShadow } from '../constants/theme'; import { useTheme } from '../context/ThemeContext'; import { light as hapticLight, selection as hapticSelection, success as hapticSuccess } from '../utils/haptics'; -import { RealtimeService } from '../services/realtime'; +import { + getActiveTrains, + getConnections, + getRoute, + getRoutes, + getRunStops, + getTrainService, + getTrainsForRoute, + getTripStops, + lookupTrips, + search as apiSearch, +} from '../services/api-client'; import { TrainStorageService } from '../services/storage'; -import type { EnrichedStopTime, Route, SearchResult, Stop } from '../types/train'; +import type { + ApiConnectionItem, + ApiEnrichedStopTime, + ApiRoute, + ApiSearchHit, + ApiTrainItem, +} from '../types/api'; +import type { EnrichedStopTime, Route, SearchResult, Stop, Trip } from '../types/train'; import { useTrainContext } from '../context/TrainContext'; import { SlideUpModalContext } from './ui/SlideUpModal'; -import { gtfsParser } from '../utils/gtfs-parser'; +import { useApiCacheVersion } from '../hooks/useApiCache'; +import { lookupAgencyTimezone, lookupStop } from '../utils/api-stop-cache'; import { logger } from '../utils/logger'; import { LocationSuggestionsService } from '../services/location-suggestions'; import { pluralCount } from '../utils/train-display'; import { formatDateForDisplay } from '../utils/date-helpers'; import type { DateData } from 'react-native-calendars'; +const PROVIDER_ID = 'amtrak'; + +function bareCode(namespacedId: string): string { + const i = namespacedId.indexOf(':'); + return i > 0 ? namespacedId.slice(i + 1) : namespacedId; +} + +function ymd(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function computeDelayMinutes( + scheduledIso: string | null | undefined, + liveIso: string | null | undefined, +): number | null { + if (!scheduledIso || !liveIso) return null; + const a = Date.parse(scheduledIso); + const b = Date.parse(liveIso); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + return Math.round((b - a) / 60000); +} + +function apiRouteToLegacy(r: ApiRoute): Route { + return { + route_id: r.routeId, + route_long_name: r.longName, + route_short_name: r.shortName, + route_color: r.color, + route_text_color: r.textColor, + }; +} + +function apiEnrichedStopTimeToLegacy(s: ApiEnrichedStopTime): EnrichedStopTime { + return { + trip_id: s.tripId, + arrival_time: s.arrivalTime ?? '', + departure_time: s.departureTime ?? '', + stop_id: s.stopId, + stop_sequence: s.stopSequence, + stop_name: s.stopName, + stop_code: s.stopCode, + pickup_type: s.pickupType ?? undefined, + drop_off_type: s.dropOffType ?? undefined, + timepoint: s.timepoint == null ? undefined : (s.timepoint ? 1 : 0), + }; +} + +function apiConnectionToTripResult(c: ApiConnectionItem): TripResult { + return { + tripId: c.tripId, + fromStop: apiEnrichedStopTimeToLegacy(c.from), + toStop: apiEnrichedStopTimeToLegacy(c.to), + intermediateStops: c.intermediate.map(apiEnrichedStopTimeToLegacy), + }; +} + +function apiTrainItemToRouteTrainItem(t: ApiTrainItem): RouteTrainItem { + return { + trainNumber: t.trainNumber, + displayName: t.trainNumber, + headsign: t.sampleHeadsign, + endpointLabel: '', + }; +} + +function stationHitToStop(hit: ApiSearchHit): Stop { + const code = bareCode(hit.id); + const cached = lookupStop(code); + if (cached) return cached; + // Synthetic placeholder; downstream code only reads stop_id/stop_name, and + // lookupStop will surface the real coords once the cache fills. + return { + stop_id: code, + stop_name: hit.name, + stop_lat: 0, + stop_lon: 0, + }; +} + +function searchHitToResult(hit: ApiSearchHit): SearchResult { + if (hit.type === 'station') { + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'station', + data: stationHitToStop(hit), + }; + } + if (hit.type === 'route') { + const code = bareCode(hit.id); + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'route', + data: { + route_id: hit.id, + route_long_name: hit.name, + route_short_name: code, + } as Route, + }; + } + // train + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'train', + data: { + trip_id: hit.id, + trip_short_name: bareCode(hit.id), + route_id: '', + service_id: '', + } as Trip, + }; +} + import { SearchResultsList } from './search/SearchResultsList'; import { TrainFlowView } from './search/TrainFlowView'; import { StationFlowView } from './search/StationFlowView'; @@ -70,7 +207,7 @@ export function getCountdownFromDeparture(departureTime: string, travelDate: Dat const departSecOfDay = h * 3600 + m * 60 // handles GTFS h>=24 naturally + (delayMinutes && delayMinutes > 0 ? delayMinutes * 60 : 0); - const tz = gtfsParser.agencyTimezone; + const tz = lookupAgencyTimezone(); const now = new Date(); const nowSec = getCurrentSecondsInTimezone(tz); @@ -145,8 +282,10 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp const [searchQuery, setSearchQuery] = useState(''); const [selectedDate, setSelectedDate] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); - const [isDataLoaded, setIsDataLoaded] = useState(gtfsParser.isLoaded); const searchInputRef = useRef(null); + // Subscribe to api-client cache so synthetic Stop placeholders re-resolve to + // real coords once a fetch lands. + useApiCacheVersion(); // --- Station flow state (Path 2a) --- const [fromStation, setFromStation] = useState(null); @@ -188,24 +327,25 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp }, [savedTrains]); useEffect(() => { - if (!isDataLoaded) return; + let cancelled = false; // Popular (always shown) - const allRoutes = gtfsParser.getAllRoutes(); - const popular: SuggestionItem[] = []; - const nerRoute = allRoutes.find(r => r.route_long_name.toLowerCase().includes('northeast regional')); - if (nerRoute) { - popular.push({ type: 'route', label: 'Northeast Regional', subtitle: 'Route', routeId: nerRoute.route_id }); - } - const acelaRoute = allRoutes.find(r => r.route_long_name.toLowerCase().includes('acela')); - if (acelaRoute) { - popular.push({ type: 'route', label: 'Acela', subtitle: 'Route', routeId: acelaRoute.route_id }); - } - const nyp = gtfsParser.getStop('NYP'); - if (nyp) { - popular.push({ type: 'station', label: nyp.stop_name, subtitle: 'NYP \u00B7 Station', stop: nyp }); - } - setPopularSuggestions(popular); + (async () => { + try { + const allRoutes = await getRoutes(PROVIDER_ID); + if (cancelled) return; + const popular: SuggestionItem[] = []; + const ner = allRoutes.find(r => r.longName.toLowerCase().includes('northeast regional')); + if (ner) popular.push({ type: 'route', label: 'Northeast Regional', subtitle: 'Route', routeId: ner.routeId }); + const acela = allRoutes.find(r => r.longName.toLowerCase().includes('acela')); + if (acela) popular.push({ type: 'route', label: 'Acela', subtitle: 'Route', routeId: acela.routeId }); + const nyp = lookupStop('NYP'); + if (nyp) popular.push({ type: 'station', label: nyp.stop_name, subtitle: 'NYP \u00B7 Station', stop: nyp }); + setPopularSuggestions(popular); + } catch (e) { + logger.warn('[Search] failed to load popular routes', e); + } + })(); // Nearby (from location service) const locationSuggestions = LocationSuggestionsService.getCachedSuggestions(); @@ -215,95 +355,129 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp // History TrainStorageService.getTripHistory().then(history => { - if (history.length > 0) { - const routeCounts = new Map(); - for (const trip of history) { - const key = `${trip.fromCode}-${trip.toCode}`; - const existing = routeCounts.get(key); - if (existing) { - existing.count++; - } else { - routeCounts.set(key, { count: 1, routeName: trip.routeName, fromCode: trip.fromCode, toCode: trip.toCode }); - } + if (cancelled || history.length === 0) return; + const routeCounts = new Map(); + for (const trip of history) { + const key = `${trip.fromCode}-${trip.toCode}`; + const existing = routeCounts.get(key); + if (existing) { + existing.count++; + } else { + routeCounts.set(key, { count: 1, routeName: trip.routeName, fromCode: trip.fromCode, toCode: trip.toCode }); } - const sorted = [...routeCounts.values()].sort((a, b) => b.count - a.count).slice(0, 1); - setHistorySuggestions(sorted.map(r => ({ - type: 'station' as const, - label: `${r.fromCode} \u2192 ${r.toCode}`, - subtitle: `${r.routeName} \u00B7 ${pluralCount(r.count, 'trip')}`, - stop: gtfsParser.getStop(r.fromCode) || undefined, - toStop: gtfsParser.getStop(r.toCode) || undefined, - }))); } + const sorted = [...routeCounts.values()].sort((a, b) => b.count - a.count).slice(0, 1); + setHistorySuggestions(sorted.map(r => ({ + type: 'station' as const, + label: `${r.fromCode} \u2192 ${r.toCode}`, + subtitle: `${r.routeName} \u00B7 ${pluralCount(r.count, 'trip')}`, + stop: lookupStop(r.fromCode) || undefined, + toStop: lookupStop(r.toCode) || undefined, + }))); }); - }, [isDataLoaded]); - // --- Train service date range (for constraining date picker in train flow) --- - const trainServiceInfo = useMemo(() => { - if (!selectedTrainNumber || !isDataLoaded) return null; - return gtfsParser.getServiceInfoForTrain(selectedTrainNumber); - }, [selectedTrainNumber, isDataLoaded]); + return () => { cancelled = true; }; + }, []); - // Check if GTFS data is loaded + // --- Train service date range (for constraining date picker in train flow) --- + const [trainServiceInfo, setTrainServiceInfo] = useState<{ minDate: Date; maxDate: Date } | null>(null); useEffect(() => { - const checkLoaded = () => { - if (gtfsParser.isLoaded && !isDataLoaded) { - setIsDataLoaded(true); - } - }; - checkLoaded(); - const interval = setInterval(checkLoaded, 500); - return () => clearInterval(interval); - }, [isDataLoaded]); + if (!selectedTrainNumber) { + setTrainServiceInfo(null); + return; + } + let cancelled = false; + getTrainService(selectedTrainNumber, { provider: PROVIDER_ID }) + .then(info => { + if (cancelled) return; + // ApiServiceInfo has YYYY-MM-DD strings; convert to Date. + const [yMin, mMin, dMin] = info.minDate.split('-').map(Number); + const [yMax, mMax, dMax] = info.maxDate.split('-').map(Number); + setTrainServiceInfo({ + minDate: new Date(yMin, mMin - 1, dMin), + maxDate: new Date(yMax, mMax - 1, dMax), + }); + }) + .catch(e => { + logger.warn('[Search] failed to load train service', e); + if (!cancelled) setTrainServiceInfo(null); + }); + return () => { cancelled = true; }; + }, [selectedTrainNumber]); // Search logic -- branches based on current state useEffect(() => { - if (!isDataLoaded || searchQuery.length === 0) { + if (searchQuery.length === 0) { setUnifiedResults({ trains: [], routes: [], stations: [] }); setStationResults([]); return; } + let cancelled = false; if (fromStation && !toStation) { // Station flow: picking arrival station - const results = gtfsParser.searchStations(searchQuery); - setStationResults(results); + apiSearch({ q: searchQuery, provider: PROVIDER_ID, types: ['station'] }) + .then(res => { + if (cancelled) return; + setStationResults(res.stations.map(stationHitToStop)); + }) + .catch(e => logger.warn('[Search] station search failed', e)); } else if (!fromStation && !selectedTrainNumber && !expandedRouteTrains) { // Initial view: unified search (skip when filtering within a route) - const results = gtfsParser.searchUnified(searchQuery); - setUnifiedResults(results); + apiSearch({ q: searchQuery, provider: PROVIDER_ID }) + .then(res => { + if (cancelled) return; + setUnifiedResults({ + trains: res.trains.map(searchHitToResult), + routes: res.routes.map(searchHitToResult), + stations: res.stations.map(searchHitToResult), + }); + }) + .catch(e => logger.warn('[Search] unified search failed', e)); } - }, [searchQuery, isDataLoaded, fromStation, toStation, selectedTrainNumber, expandedRouteTrains]); + return () => { cancelled = true; }; + }, [searchQuery, fromStation, toStation, selectedTrainNumber, expandedRouteTrains]); // Find trips when both stations AND date are selected (station flow) useEffect(() => { - if (fromStation && toStation && selectedDate) { - setLoadingTrips(true); - setTripResults([]); - // Defer heavy work so skeleton paints first - const timeout = setTimeout(() => { - logger.info( - `[Search] Finding trips: ${fromStation.stop_name} \u2192 ${toStation.stop_name} on ${selectedDate.toLocaleDateString()}` - ); - const trips = gtfsParser.findTripsWithStops(fromStation.stop_id, toStation.stop_id, selectedDate); - logger.info(`[Search] Found ${trips.length} trips`); - setTripResults(trips); - setLoadingTrips(false); - }, 50); - return () => clearTimeout(timeout); - } else { + if (!fromStation || !toStation || !selectedDate) { setTripResults([]); setLoadingTrips(false); + return; } + setLoadingTrips(true); + setTripResults([]); + let cancelled = false; + logger.info( + `[Search] Finding trips: ${fromStation.stop_name} \u2192 ${toStation.stop_name} on ${selectedDate.toLocaleDateString()}` + ); + getConnections({ + fromStop: fromStation.stop_id, + toStop: toStation.stop_id, + date: ymd(selectedDate), + }) + .then(conns => { + if (cancelled) return; + logger.info(`[Search] Found ${conns.length} trips`); + setTripResults(conns.map(apiConnectionToTripResult)); + setLoadingTrips(false); + }) + .catch(e => { + if (!cancelled) { + logger.warn('[Search] connection lookup failed', e); + setTripResults([]); + setLoadingTrips(false); + } + }); + return () => { cancelled = true; }; }, [fromStation, toStation, selectedDate]); - // Fetch delays for today's search results + // Fetch delays for today's search results \u2014 derived from /v1/runs/.../stops useEffect(() => { if (tripResults.length === 0 || !selectedDate) { setTripDelays(new Map()); return; } - // Only fetch delays if the selected date is today const now = new Date(); const isToday = selectedDate.getFullYear() === now.getFullYear() && @@ -315,19 +489,32 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp } let cancelled = false; + const runDate = ymd(selectedDate); + const fetchDelays = async () => { const delays = new Map(); await Promise.all( tripResults.map(async (trip) => { - const [depDelay, arrDelay] = await Promise.all([ - RealtimeService.getDelayForStop(trip.tripId, trip.fromStop.stop_id), - RealtimeService.getArrivalDelayForStop(trip.tripId, trip.toStop.stop_id), - ]); - if (depDelay != null || arrDelay != null) { - delays.set(trip.tripId, { - departDelay: depDelay ?? undefined, - arriveDelay: arrDelay ?? undefined, + try { + const stops = await getRunStops({ + provider: PROVIDER_ID, + tripId: trip.tripId, + runDate, }); + const fromCode = trip.fromStop.stop_code || trip.fromStop.stop_id; + const toCode = trip.toStop.stop_code || trip.toStop.stop_id; + const fromRow = stops.find(s => s.stopCode === fromCode); + const toRow = stops.find(s => s.stopCode === toCode); + const departDelay = computeDelayMinutes(fromRow?.scheduledDep, fromRow?.estimatedDep ?? fromRow?.actualDep); + const arriveDelay = computeDelayMinutes(toRow?.scheduledArr, toRow?.estimatedArr ?? toRow?.actualArr); + if (departDelay != null || arriveDelay != null) { + delays.set(trip.tripId, { + departDelay: departDelay ?? undefined, + arriveDelay: arriveDelay ?? undefined, + }); + } + } catch { + // No live data for this run yet \u2014 leave delays unset. } }) ); @@ -354,25 +541,36 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setTrainNotRunning(false); return; } - const trip = gtfsParser.getTripForTrainOnDate(selectedTrainNumber, selectedDate); - if (!trip) { - setTrainNotRunning(true); - setResolvedTripId(null); - setTrainStops([]); - return; - } - // Check if this trip's service is actually active on the date - const isActive = gtfsParser.isServiceActiveOnDate(trip.service_id, selectedDate); - if (!isActive) { - setTrainNotRunning(true); - setResolvedTripId(null); - setTrainStops([]); - return; - } - setTrainNotRunning(false); - setResolvedTripId(trip.trip_id); - const stops = gtfsParser.getStopTimesForTrip(trip.trip_id); - setTrainStops(stops); + let cancelled = false; + (async () => { + try { + const trips = await lookupTrips({ + provider: PROVIDER_ID, + trainNumber: selectedTrainNumber, + date: ymd(selectedDate), + }); + if (cancelled) return; + const trip = trips[0]; + if (!trip) { + setTrainNotRunning(true); + setResolvedTripId(null); + setTrainStops([]); + return; + } + setTrainNotRunning(false); + setResolvedTripId(trip.tripId); + const stops = await getTripStops(trip.tripId); + if (cancelled) return; + setTrainStops(stops.map(apiEnrichedStopTimeToLegacy)); + } catch (e) { + if (cancelled) return; + logger.warn('[Search] failed to resolve trip', e); + setTrainNotRunning(true); + setResolvedTripId(null); + setTrainStops([]); + } + })(); + return () => { cancelled = true; }; }, [selectedTrainNumber, selectedDate]); // --- Handlers --- @@ -449,37 +647,20 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp return undefined; }, [trainServiceInfo]); + // The picker is bounded by trainServiceInfo.{min,max}Date. We no longer + // mark individual non-service days as disabled (the API doesn't expose a + // per-day calendar) — selecting a non-service day surfaces the existing + // "train not running" empty state. const calendarMarkedDates = useMemo(() => { - const marks: Record = {}; - - if (selectedTrainNumber && trainServiceInfo && isDataLoaded) { - const trips = gtfsParser.getTripsByNumber(selectedTrainNumber); - if (trips.length > 0) { - const current = new Date(trainServiceInfo.minDate); - const end = trainServiceInfo.maxDate; - while (current <= end) { - const active = trips.some(trip => gtfsParser.isServiceActiveOnDate(trip.service_id, current)); - if (!active) { - marks[toDateString(current)] = { disabled: true, disabledColor: '#555555' }; - } - current.setDate(current.getDate() + 1); - } - } - } - + const marks: Record = {}; if (selectedDate) { const key = toDateString(selectedDate); - marks[key] = { ...marks[key], selected: true, selectedColor: '#FFFFFF' }; + marks[key] = { selected: true, selectedColor: '#FFFFFF' }; } - return marks; - }, [selectedTrainNumber, trainServiceInfo, isDataLoaded, selectedDate]); + }, [selectedDate]); const handleDayPress = (day: DateData) => { - // Don't allow selecting disabled (greyed-out) dates - const mark = calendarMarkedDates[day.dateString]; - if (mark?.disabled) return; - hapticSuccess(); const [y, m, d] = day.dateString.split('-').map(Number); setSelectedDate(new Date(y, m - 1, d)); @@ -497,21 +678,24 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setExpandedRouteName(''); }; - const handleSelectRoute = (route: Route) => { + const handleSelectRoute = async (route: Route) => { hapticSelection(); - const trains = gtfsParser.getTrainNumbersForRoute(route.route_id); - if (trains.length === 1) { - // Single train on route -- go directly to train flow - handleSelectTrain(trains[0].trainNumber, trains[0].displayName); - } else { + try { + const code = bareCode(route.route_id); + const trains = (await getTrainsForRoute(PROVIDER_ID, code)).map(apiTrainItemToRouteTrainItem); + if (trains.length === 1) { + handleSelectTrain(trains[0].trainNumber, trains[0].displayName); + return; + } setExpandedRouteTrains(trains); setExpandedRouteName(route.route_long_name); setSearchQuery(''); // Fetch live train numbers for status indicators - RealtimeService.getAllActiveTrains().then(active => { - const nums = new Set(active.map(t => t.trainNumber)); - setLiveTrainNumbers(nums); - }).catch(e => logger.warn('Failed to fetch active trains', e)); + getActiveTrains(PROVIDER_ID) + .then(active => setLiveTrainNumbers(new Set(active.map(t => t.trainNumber)))) + .catch(e => logger.warn('Failed to fetch active trains', e)); + } catch (e) { + logger.warn('[Search] failed to load route trains', e); } }; @@ -557,7 +741,7 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setExpandedRouteName(''); }} searchInputRef={searchInputRef} - isDataLoaded={isDataLoaded} + isDataLoaded={true} showDatePicker={showDatePicker} unifiedResults={unifiedResults} hasUnifiedResults={hasUnifiedResults} @@ -572,8 +756,8 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp onTodayTripPress={() => { if (!todayTrain) return; hapticSelection(); - const from = gtfsParser.getStop(todayTrain.fromCode); - const to = gtfsParser.getStop(todayTrain.toCode); + const from = lookupStop(todayTrain.fromCode); + const to = lookupStop(todayTrain.toCode); if (from && to) { setFromStation(from); setToStation(to); @@ -585,8 +769,10 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp if (suggestion.type === 'train' && suggestion.trainNumber) { handleSelectTrain(suggestion.trainNumber, suggestion.displayName || suggestion.label); } else if (suggestion.type === 'route' && suggestion.routeId) { - const route = gtfsParser.getRoute(suggestion.routeId); - if (route) handleSelectRoute(route); + const code = bareCode(suggestion.routeId); + getRoute(PROVIDER_ID, code) + .then(r => handleSelectRoute(apiRouteToLegacy(r))) + .catch(e => logger.warn('Failed to load route', e)); } else if (suggestion.stop && suggestion.toStop) { hapticSelection(); setFromStation(suggestion.stop); @@ -664,7 +850,7 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp searchQuery={searchQuery} setSearchQuery={setSearchQuery} searchInputRef={searchInputRef} - isDataLoaded={isDataLoaded} + isDataLoaded={true} showDatePicker={showDatePicker} stationResults={stationResults} tripResults={tripResults} diff --git a/apps/mobile/components/map/ProviderTiles.tsx b/apps/mobile/components/map/ProviderTiles.tsx new file mode 100644 index 0000000..a79bfd9 --- /dev/null +++ b/apps/mobile/components/map/ProviderTiles.tsx @@ -0,0 +1,156 @@ +import React, { useCallback } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; +import { Layer, VectorSource } from '@maplibre/maplibre-react-native'; +import type { Provider } from '../../constants/providers'; +import { config } from '../../constants/config'; + +export interface StationTapPayload { + providerId: string; + stopCode: string; + stopId: string; + name: string; + lat: number; + lon: number; +} + +export interface RouteTapPayload { + providerId: string; + routeId: string; + shortName?: string; + longName?: string; + color?: string; +} + +interface ProviderTilesProps { + provider: Provider; + stationColor: string; + stationStrokeColor: string; + labelColor: string; + labelHaloColor: string; + onStationPress?: (payload: StationTapPayload) => void; + onRoutePress?: (payload: RouteTapPayload) => void; +} + +interface PressEventLike { + features?: GeoJSON.Feature[]; + coordinates?: { latitude: number; longitude: number }; +} + +export const ProviderTiles = React.memo(function ProviderTiles({ + provider, + stationColor, + stationStrokeColor, + labelColor, + labelHaloColor, + onStationPress, + onRoutePress, +}: ProviderTilesProps) { + const url = `pmtiles://${config.tilesUrl}/${provider.id}.pmtiles`; + const sourceId = `tiles-${provider.id}`; + const routeLayerId = `routes-${provider.id}`; + const stationCircleLayerId = `stations-circle-${provider.id}`; + const stationLabelLayerId = `stations-label-${provider.id}`; + + const handlePress = useCallback( + (event: NativeSyntheticEvent) => { + const features = event.nativeEvent?.features ?? []; + const top = features[0]; + if (!top) return; + const props = (top.properties ?? {}) as Record; + + if (typeof props.code === 'string') { + if (!onStationPress) return; + const geom = top.geometry as GeoJSON.Point | undefined; + const [lon, lat] = geom?.type === 'Point' ? geom.coordinates : [0, 0]; + onStationPress({ + providerId: typeof props.provider_id === 'string' ? props.provider_id : provider.id, + stopCode: props.code, + stopId: typeof props.stop_id === 'string' ? props.stop_id : `${provider.id}:${props.code}`, + name: typeof props.name === 'string' ? props.name : '', + lat, + lon, + }); + return; + } + + if (typeof props.route_id === 'string') { + if (!onRoutePress) return; + onRoutePress({ + providerId: typeof props.provider_id === 'string' ? props.provider_id : provider.id, + routeId: props.route_id, + shortName: typeof props.short_name === 'string' ? props.short_name : undefined, + longName: typeof props.long_name === 'string' ? props.long_name : undefined, + color: typeof props.color === 'string' ? props.color : undefined, + }); + } + }, + [provider.id, onStationPress, onRoutePress], + ); + + return ( + + + + + + ); +}); diff --git a/apps/mobile/components/ui/DepartureBoardModal.tsx b/apps/mobile/components/ui/DepartureBoardModal.tsx index 0943083..788af99 100644 --- a/apps/mobile/components/ui/DepartureBoardModal.tsx +++ b/apps/mobile/components/ui/DepartureBoardModal.tsx @@ -28,7 +28,8 @@ import TrainCardContent from '../TrainCardContent'; import MarqueeText from './MarqueeText'; import { SkeletonBox } from './SkeletonBox'; import { getCurrentMinutesInTimezone, getCurrentSecondsInTimezone, getTimezoneForStop } from '../../utils/timezone'; -import { gtfsParser } from '../../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../../utils/api-stop-cache'; +import { useApiCacheVersion } from '../../hooks/useApiCache'; import { formatTemp, weatherApiTempUnit } from '../../utils/units'; import { getWeatherCondition } from '../../utils/weather'; import { SlideUpModalContext } from './SlideUpModal'; @@ -105,7 +106,7 @@ function isTrainUpcoming( } // Times are now in the station's local timezone, so compare "now" in that timezone - const currentMinutes = getCurrentMinutesInTimezone(stationTimezone ?? gtfsParser.agencyTimezone); + const currentMinutes = getCurrentMinutesInTimezone(stationTimezone ?? lookupAgencyTimezone()); let relevantTime: string; if (filterMode === 'arriving' || train.toCode === stationId) { @@ -190,13 +191,14 @@ const DepartureItem = React.memo(function DepartureItem({ train, stationTime, st const { colors } = useTheme(); const trainCardStyles = useMemo(() => createTrainCardStyles(colors), [colors]); const departStyles = useMemo(() => createDepartureStyles(colors), [colors]); + const cacheVersion = useApiCacheVersion(); const depDelay = train.realtime?.delay; const countdown = useMemo(() => { // Times are in the station's local timezone; compare "now" in same tz - const stopData = gtfsParser.getStop(stationId); - const tz = stopData ? getTimezoneForStop(stopData) : gtfsParser.agencyTimezone; + const stopData = lookupStop(stationId); + const tz = stopData ? getTimezoneForStop(stopData) : lookupAgencyTimezone(); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -224,7 +226,7 @@ const DepartureItem = React.memo(function DepartureItem({ train, stationTime, st const seconds = Math.round(absSec); if (seconds >= 60) return { value: 1, unit: 'MINUTE', past }; return { value: seconds, unit: seconds === 1 ? 'SECOND' : 'SECONDS', past }; - }, [stationTime, selectedDate, stationId, depDelay]); + }, [stationTime, selectedDate, stationId, depDelay, cacheVersion]); const countdownLabel = countdown.unit; const arrDelay = train.realtime?.arrivalDelay; diff --git a/apps/mobile/components/ui/TrainDetailModal.tsx b/apps/mobile/components/ui/TrainDetailModal.tsx index 5328a06..da6bfdb 100644 --- a/apps/mobile/components/ui/TrainDetailModal.tsx +++ b/apps/mobile/components/ui/TrainDetailModal.tsx @@ -10,7 +10,6 @@ import { useTheme } from '../../context/ThemeContext'; import { light as hapticLight } from '../../utils/haptics'; import { isThruwayName, TrainIcon } from '../TrainIcon'; import { addDelayToTime, formatDelayStatus, formatTimeWithDayOffset, getDelayColorKey, parseTimeToMinutes, timeToMinutes } from '../../utils/time-formatting'; -import { RealtimeService } from '../../services/realtime'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout'; import { useTrainContext } from '../../context/TrainContext'; @@ -18,7 +17,9 @@ import { useUnits } from '../../context/UnitsContext'; import { TrainStorageService } from '../../services/storage'; import type { Train } from '../../types/train'; import { haversineDistance } from '../../utils/distance'; -import { gtfsParser } from '../../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../../utils/api-stop-cache'; +import { useApiCacheVersion } from '../../hooks/useApiCache'; +import { useTripDetail } from '../../hooks/useTripDetail'; import { logger, openReportBadDataEmail } from '../../utils/logger'; import { convertGtfsTimeToLocal, getCurrentMinutesInTimezone, getCurrentSecondsInTimezone, getTimezoneForStop } from '../../utils/timezone'; import { calculateDuration, getCountdownForTrain, pluralize, pluralCount } from '../../utils/train-display'; @@ -122,7 +123,6 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const { tempUnit, distanceUnit } = useUnits(); const trainData = train || selectedTrain; - const [allStops, setAllStops] = React.useState([]); const [isWhereIsMyTrainExpanded, setIsWhereIsMyTrainExpanded] = React.useState(true); const [weatherData, setWeatherData] = React.useState(null); @@ -131,56 +131,55 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const [error, setError] = React.useState(null); const [stopWeather, setStopWeather] = React.useState>({}); const stopWeatherKeyRef = React.useRef(null); - const [stopDelays, setStopDelays] = React.useState>(new Map()); const isLiveTrain = trainData?.realtime?.position !== undefined; - // Load stops from GTFS — only re-run when the trip actually changes - const tripId = trainData?.tripId; - React.useEffect(() => { - if (!tripId) return; + // Subscribe to api-client cache updates so lookupStop / lookupAgencyTimezone + // re-render this component when fetches land. + useApiCacheVersion(); - try { - const stops = gtfsParser.getStopTimesForTrip(tripId); - if (stops && stops.length > 0) { - const agencyTzVal = gtfsParser.agencyTimezone; - const formattedStops = stops.map(stop => { - const stopData = gtfsParser.getStop(stop.stop_id); - const stopTz = stopData ? getTimezoneForStop(stopData) : agencyTzVal; - const formatted = stop.departure_time - ? convertGtfsTimeToLocal(stop.departure_time, agencyTzVal, stopTz) - : { time: '', dayOffset: 0 }; - return { - time: formatted.time, - dayOffset: formatted.dayOffset, - name: stop.stop_name, - code: stop.stop_id, - timezone: stopTz || agencyTzVal, - }; - }); - setAllStops(formattedStops); - } - } catch (e) { - logger.error('Failed to load stops:', e); - } - }, [tripId]); + const tripId = trainData?.tripId; + const runDate = React.useMemo( + () => (trainData?.travelDate ? new Date(trainData.travelDate) : new Date()), + [trainData?.travelDate], + ); + const tripDetail = useTripDetail(tripId, runDate); + const agencyTz = lookupAgencyTimezone(); + + const allStops = React.useMemo(() => { + if (tripDetail.stops.length === 0) return []; + return tripDetail.stops.map(s => { + const stopTz = s.timezone || agencyTz; + const gtfsTime = s.scheduledDeparture || s.scheduledArrival || ''; + const formatted = gtfsTime + ? convertGtfsTimeToLocal(gtfsTime, agencyTz, stopTz) + : { time: '', dayOffset: 0 }; + return { + time: formatted.time, + dayOffset: formatted.dayOffset, + name: s.name, + code: s.code, + timezone: stopTz, + }; + }); + }, [tripDetail.stops, agencyTz]); - // Fetch per-stop delays for the timeline — re-run when realtime delay changes + // Per-stop delays come from /v1/runs/{provider}/{tripId}/{runDate}/stops via + // useTripDetail. Until that endpoint lands the map is empty (the timeline + // still renders scheduled times — just without "+5 min" badges). const daysAway = trainData?.daysAway; - const currentDelay = trainData?.realtime?.delay; - React.useEffect(() => { - if (!tripId || (daysAway != null && daysAway > 0)) { - setStopDelays(new Map()); - return; + const stopDelays = React.useMemo(() => { + const m = new Map(); + if (daysAway != null && daysAway > 0) return m; + for (const s of tripDetail.stops) { + if (s.arrivalDelayMin == null && s.departureDelayMin == null) continue; + m.set(s.code, { + departureDelay: s.departureDelayMin ?? undefined, + arrivalDelay: s.arrivalDelayMin ?? undefined, + }); } - let cancelled = false; - const fetchDelays = async () => { - const delays = await RealtimeService.getDelaysForAllStops(tripId); - if (!cancelled) setStopDelays(delays); - }; - fetchDelays(); - return () => { cancelled = true; }; - }, [tripId, daysAway, currentDelay]); + return m; + }, [tripDetail.stops, daysAway]); // Fetch weather data for destination — only when destination or unit changes const toCode = trainData?.toCode; @@ -191,7 +190,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const fetchWeather = async () => { try { setIsLoadingWeather(true); - const destStop = gtfsParser.getStop(toCode); + const destStop = lookupStop(toCode); if (!destStop) return; const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${destStop.stop_lat}&longitude=${destStop.stop_lon}¤t=temperature_2m,weather_code&temperature_unit=${weatherApiTempUnit(tempUnit)}&timezone=auto`; @@ -236,7 +235,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const promises = allStops.map(async (stop) => { try { - const stopData = gtfsParser.getStop(stop.code); + const stopData = lookupStop(stop.code); if (!stopData) return; const targetDate = new Date(baseDate); @@ -306,8 +305,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!trainData || allStops.length === 0) return null; try { - const originStop = gtfsParser.getStop(trainData.fromCode); - const destStop = gtfsParser.getStop(trainData.toCode); + const originStop = lookupStop(trainData.fromCode); + const destStop = lookupStop(trainData.toCode); logger.debug('Timezone: origin stop lookup', { fromCode: trainData.fromCode, @@ -409,8 +408,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr let distanceMiles: number | null = null; if (trainData) { try { - const fromStop = gtfsParser.getStop(trainData.fromCode); - const toStop = gtfsParser.getStop(trainData.toCode); + const fromStop = lookupStop(trainData.fromCode); + const toStop = lookupStop(trainData.toCode); if (fromStop && toStop) { distanceMiles = haversineDistance(fromStop.stop_lat, fromStop.stop_lon, toStop.stop_lat, toStop.stop_lon); } @@ -424,7 +423,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!onStationSelect) return; hapticLight(); try { - const stop = gtfsParser.getStop(stationCode); + const stop = lookupStop(stationCode); if (stop) { onStationSelect(stationCode, stop.stop_lat, stop.stop_lon); } @@ -435,7 +434,6 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr // Find next stop for live trains // Each stop's time is now in that stop's local timezone - const agencyTz = gtfsParser.agencyTimezone; const nextStopIndex = React.useMemo(() => { if (!isLiveTrain || allStops.length === 0) return -1; @@ -463,7 +461,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!isLiveTrain || nextStopIndex < 0 || nextStopIndex >= allStops.length) return null; const stop = allStops[nextStopIndex]; if (!stop) return null; - const stopData = gtfsParser.getStop(stop.code); + const stopData = lookupStop(stop.code); return stopData ? getTimezoneForStop(stopData) : null; }, [isLiveTrain, nextStopIndex, allStops]); React.useEffect(() => { @@ -579,8 +577,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr : (liveDelayKey === 'onTime' || isLiveTrain) ? colors.success : colors.primary; // Check if the train has completed (arrival time has passed) - const destStop = gtfsParser.getStop(trainData.toCode); - const destTz = destStop ? getTimezoneForStop(destStop) : gtfsParser.agencyTimezone; + const destStop = lookupStop(trainData.toCode); + const destTz = destStop ? getTimezoneForStop(destStop) : agencyTz; const nowSec = getCurrentSecondsInTimezone(destTz); const arrivalDelay = trainData.realtime?.arrivalDelay; const arriveSec = parseTimeToMinutes(trainData.arriveTime) * 60 diff --git a/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx b/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx index 4131238..8afeeb9 100644 --- a/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx +++ b/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import { type ColorPalette, Spacing } from '../../../constants/theme'; +import { useApiCacheVersion } from '../../../hooks/useApiCache'; +import { lookupAgencyTimezone, lookupStop } from '../../../utils/api-stop-cache'; import { addDelayToTime, formatDelayStatus, getDelayColorKey, parseTimeToMinutes } from '../../../utils/time-formatting'; import { getCurrentSecondsInTimezone } from '../../../utils/timezone'; import { pluralCount } from '../../../utils/train-display'; import { convertDistance, distanceSuffix } from '../../../utils/units'; -import { gtfsParser } from '../../../utils/gtfs-parser'; import { getTimezoneForStop } from '../../../utils/timezone'; import { pluralize } from '../../../utils/train-display'; import AnimatedRollingText from '../AnimatedRollingText'; @@ -24,6 +25,10 @@ export default function DepartureArrivalBoard({ colors, handleStationPress, }: DepartureArrivalBoardProps) { + // Subscribe to api-client cache so the destination-stop tz lookup re-renders + // once the stop fetch lands. + useApiCacheVersion(); + return ( {/* Departure Info */} @@ -120,8 +125,8 @@ export default function DepartureArrivalBoard({ // Compute arrival countdown const arriveTime = aDelayed?.time || trainData.arriveTime; const arriveDayOffset = aDelayed?.dayOffset ?? (trainData.arriveDayOffset || 0); - const destStopData = gtfsParser.getStop(trainData.toCode); - const destTimezone = destStopData ? getTimezoneForStop(destStopData) : gtfsParser.agencyTimezone; + const destStopData = lookupStop(trainData.toCode); + const destTimezone = destStopData ? getTimezoneForStop(destStopData) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(destTimezone); const arriveSec = parseTimeToMinutes(arriveTime) * 60 + arriveDayOffset * 24 * 3600; diff --git a/apps/mobile/constants/config.ts b/apps/mobile/constants/config.ts new file mode 100644 index 0000000..6eac59d --- /dev/null +++ b/apps/mobile/constants/config.ts @@ -0,0 +1,15 @@ +import Constants from 'expo-constants'; + +interface AppExtra { + apiUrl?: string; + wsUrl?: string; + tilesUrl?: string; +} + +const extra = (Constants.expoConfig?.extra ?? {}) as AppExtra; + +export const config = { + apiUrl: extra.apiUrl ?? 'https://api.trackyapp.net', + wsUrl: extra.wsUrl ?? 'wss://api.trackyapp.net/ws/realtime', + tilesUrl: extra.tilesUrl ?? 'https://tiles.trackyapp.net', +}; diff --git a/apps/mobile/constants/map-styles.ts b/apps/mobile/constants/map-styles.ts index 2b26caa..f11fdcf 100644 --- a/apps/mobile/constants/map-styles.ts +++ b/apps/mobile/constants/map-styles.ts @@ -1,6 +1,9 @@ -/** MapLibre vector tile style URLs (CARTO - free, no API key required) */ -export const MAP_STYLE = { - standard: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', - dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', - satellite: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', +import type { StyleSpecification } from '@maplibre/maplibre-react-native'; +import appleLight from '../assets/apple-light-style.json'; +import appleDark from '../assets/apple-dark-style.json'; + +/** MapLibre vector tile styles (OpenFreeMap - free, no API key required) */ +export const MAP_STYLE: Record = { + standard: appleLight as StyleSpecification, + dark: appleDark as StyleSpecification, }; diff --git a/apps/mobile/constants/providers.ts b/apps/mobile/constants/providers.ts new file mode 100644 index 0000000..5c5a52e --- /dev/null +++ b/apps/mobile/constants/providers.ts @@ -0,0 +1,60 @@ +export type ProviderId = + | 'amtrak' + | 'brightline' + | 'cta' + | 'metra' + | 'metrotransit' + | 'trirail'; + +export interface Provider { + id: ProviderId; + displayName: string; + routeMinZoom: number; + stationMinZoom: number; + stationLabelMinZoom: number; +} + +export const PROVIDERS: readonly Provider[] = [ + { + id: 'amtrak', + displayName: 'Amtrak', + routeMinZoom: 3, + stationMinZoom: 5, + stationLabelMinZoom: 8, + }, + { + id: 'brightline', + displayName: 'Brightline', + routeMinZoom: 6, + stationMinZoom: 7, + stationLabelMinZoom: 9, + }, + { + id: 'cta', + displayName: 'CTA', + routeMinZoom: 9, + stationMinZoom: 10, + stationLabelMinZoom: 12, + }, + { + id: 'metra', + displayName: 'Metra', + routeMinZoom: 8, + stationMinZoom: 9, + stationLabelMinZoom: 11, + }, + { + id: 'metrotransit', + displayName: 'Metro Transit', + routeMinZoom: 9, + stationMinZoom: 10, + stationLabelMinZoom: 12, + }, + { + id: 'trirail', + displayName: 'Tri-Rail', + routeMinZoom: 7, + stationMinZoom: 8, + stationLabelMinZoom: 10, + }, +]; diff --git a/apps/mobile/context/GTFSRefreshContext.tsx b/apps/mobile/context/GTFSRefreshContext.tsx index 276c16c..7da55d3 100644 --- a/apps/mobile/context/GTFSRefreshContext.tsx +++ b/apps/mobile/context/GTFSRefreshContext.tsx @@ -1,30 +1,47 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +/** + * Vestigial GTFS-refresh context. + * + * Originally drove the local GTFS download/cache lifecycle on app start. + * The app now uses the Go API for static data, so there's nothing to + * refresh — but several callers (RefreshBubble, SettingsModal, MapScreen, + * ModalContent) still depend on the hook's shape. This stub keeps that + * shape stable while the call sites are cleaned up in a follow-up. + * + * Behaviors retained: + * - LocationSuggestionsService.initialize runs once on mount (it is + * independent of GTFS and the suggestions UI relies on it). + * - Native splash is hidden immediately on mount (no cache to load). + */ + import * as SplashScreen from 'expo-splash-screen'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert } from 'react-native'; -import { ensureFreshGTFS, isCacheStale, loadCachedGTFS, loadDeferredShapes } from '../services/gtfs-sync'; +import React, { createContext, useContext, useEffect, useMemo } from 'react'; import { LocationSuggestionsService } from '../services/location-suggestions'; -import { gtfsParser } from '../utils/gtfs-parser'; import { logger } from '../utils/logger'; -interface GTFSRefreshState { +interface GTFSRefreshContextType { isRefreshing: boolean; isLoadingCache: boolean; isStreamingData: boolean; refreshProgress: number; refreshStep: string; refreshFailed: boolean; -} - -interface GTFSRefreshContextType extends GTFSRefreshState { - /** Manual refresh triggered from settings */ triggerRefresh: () => void; - /** Dismiss the persistent failure indicator */ dismissRefreshFailure: () => void; - /** Debug: show loading screen for 5 seconds */ debugShowLoadingScreen: () => void; } +const NOOP_VALUE: GTFSRefreshContextType = { + isRefreshing: false, + isLoadingCache: false, + isStreamingData: false, + refreshProgress: 0, + refreshStep: '', + refreshFailed: false, + triggerRefresh: () => {}, + dismissRefreshFailure: () => {}, + debugShowLoadingScreen: () => {}, +}; + const GTFSRefreshContext = createContext(undefined); export const useGTFSRefresh = () => { @@ -37,140 +54,15 @@ export const GTFSRefreshProvider: React.FC<{ children: React.ReactNode; onRefres children, onRefreshComplete, }) => { - const [isRefreshing, setIsRefreshing] = useState(false); - const [isLoadingCache, setIsLoadingCache] = useState(true); - const [refreshProgress, setRefreshProgress] = useState(0); - const [refreshStep, setRefreshStep] = useState(''); - const [refreshFailed, setRefreshFailed] = useState(false); - const [isStreamingData, setIsStreamingData] = useState(false); - const hasInitialized = useRef(false); - const onRefreshCompleteRef = useRef(onRefreshComplete); - onRefreshCompleteRef.current = onRefreshComplete; - - const runRefresh = useCallback(async (force: boolean) => { - logger.info(`[GTFS] Starting refresh (force=${force})`); - setIsRefreshing(true); - setRefreshProgress(0.05); - setRefreshStep(force ? 'Forcing refresh' : 'Checking schedule'); - try { - if (force) { - await AsyncStorage.removeItem('GTFS_LAST_FETCH'); - } - const result = await ensureFreshGTFS(update => { - setRefreshProgress(update.progress); - setRefreshStep(update.step + (update.detail ? ` · ${update.detail}` : '')); - }); - if (result.usedCache && !force) { - // Cache was still valid — no download needed - } - setRefreshProgress(1); - setRefreshStep('Refresh complete'); - setRefreshFailed(false); - logger.info(`[GTFS] Refresh complete (usedCache=${result.usedCache})`); - LocationSuggestionsService.initialize(gtfsParser).catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); - onRefreshCompleteRef.current?.(); - // Brief display of completion then clear - setTimeout(() => { - setIsRefreshing(false); - setRefreshProgress(0); - setRefreshStep(''); - }, 1200); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`GTFS refresh failed: ${msg}`, error); - setRefreshStep(`Schedule update failed: ${msg}`); - setRefreshFailed(true); - setTimeout(() => { - setIsRefreshing(false); - setRefreshProgress(0); - }, 2000); - } - }, []); - - // Auto-initialize GTFS on provider mount (runs once) useEffect(() => { - if (hasInitialized.current) return; - hasInitialized.current = true; - - const initialize = async () => { - setIsLoadingCache(true); - setRefreshStep('Loading cached data...'); - setRefreshProgress(0.1); - - // Hide native splash immediately — the app's own LoadingOverlay handles the visual loading state - SplashScreen.hideAsync(); - - try { - const loaded = await loadCachedGTFS(); - if (loaded) { - // Cache loaded — app is usable now - setIsLoadingCache(false); - setRefreshProgress(0); - setRefreshStep(''); - - // Load shapes in background after splash is hidden - setIsStreamingData(true); - loadDeferredShapes().finally(() => setIsStreamingData(false)); - - // Pre-compute location-based suggestions in background - LocationSuggestionsService.initialize(gtfsParser).catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); - - // Check staleness in background - const stale = await isCacheStale(); - if (stale) { - runRefresh(false); - } - } else { - // No cache at all — show refresh progress UI - setIsLoadingCache(false); - runRefresh(false); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`GTFS initialization failed: ${msg}`, error); - setIsLoadingCache(false); - setRefreshStep(''); - Alert.alert( - 'Schedule Data Needs Refresh', - 'Cached schedule data could not be loaded. A fresh download is required.', - [{ text: 'Refresh Now', onPress: () => runRefresh(true) }] - ); - } - }; - - initialize(); - }, [runRefresh]); - - const triggerRefresh = useCallback(() => { - if (isRefreshing) return; - setRefreshFailed(false); - runRefresh(false); - }, [isRefreshing, runRefresh]); - - const dismissRefreshFailure = useCallback(() => { - setRefreshFailed(false); - setRefreshStep(''); - }, []); - - const debugShowLoadingScreen = useCallback(() => { - setIsLoadingCache(true); - setTimeout(() => setIsLoadingCache(false), 5000); - }, []); - - const value = useMemo( - () => ({ - isRefreshing, - isLoadingCache, - isStreamingData, - refreshProgress, - refreshStep, - refreshFailed, - triggerRefresh, - dismissRefreshFailure, - debugShowLoadingScreen, - }), - [isRefreshing, isLoadingCache, isStreamingData, refreshProgress, refreshStep, refreshFailed, triggerRefresh, dismissRefreshFailure, debugShowLoadingScreen] - ); + SplashScreen.hideAsync(); + LocationSuggestionsService.initialize().catch(e => + logger.warn('LocationSuggestionsService.initialize failed', e), + ); + onRefreshComplete?.(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const value = useMemo(() => NOOP_VALUE, []); return ( diff --git a/apps/mobile/context/RealtimeContext.tsx b/apps/mobile/context/RealtimeContext.tsx new file mode 100644 index 0000000..feeec18 --- /dev/null +++ b/apps/mobile/context/RealtimeContext.tsx @@ -0,0 +1,102 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { PROVIDERS } from '../constants/providers'; +import { prefetchRoute } from '../services/api-client'; +import { wsClient } from '../services/ws-client'; +import type { ApiTrainPosition, RealtimeUpdate } from '../types/api'; + +type PositionsByProvider = Record; + +interface RealtimeContextValue { + positionsByProvider: PositionsByProvider; +} + +const EMPTY_POSITIONS: PositionsByProvider = {}; + +const RealtimeContext = createContext({ + positionsByProvider: EMPTY_POSITIONS, +}); + +interface RealtimeProviderProps { + /** Defaults to all six known providers. */ + providers?: readonly string[]; + children: React.ReactNode; +} + +/** + * Subscribes to the realtime WebSocket and exposes the latest positions + * per provider via context. Updates are coalesced into a single state + * update per WS message. + */ +export function RealtimeProvider({ providers, children }: RealtimeProviderProps) { + const providerIds = useMemo( + () => providers ?? PROVIDERS.map(p => p.id), + [providers], + ); + + const [positionsByProvider, setPositionsByProvider] = + useState(EMPTY_POSITIONS); + + // Hold the most recent positions in a ref so we can produce a stable + // updater function below. + const latestRef = useRef(EMPTY_POSITIONS); + + useEffect(() => { + const ids = [...providerIds]; + // Build initial state with only current provider ids + const next: PositionsByProvider = {}; + for (const id of ids) { + next[id] = latestRef.current[id] || []; + } + latestRef.current = next; + setPositionsByProvider(next); + + const onUpdate = (msg: RealtimeUpdate) => { + // Warm the route cache so display sites can resolve routeId → name + // synchronously without a flicker. + for (const p of msg.positions) { + if (p.routeId) prefetchRoute(p.routeId); + } + // Only keep current provider ids when merging updates + const updated: PositionsByProvider = {}; + for (const id of ids) { + updated[id] = id === msg.provider ? msg.positions : (latestRef.current[id] || []); + } + latestRef.current = updated; + setPositionsByProvider(updated); + }; + const unsubscribe = wsClient.subscribe(ids, onUpdate); + return unsubscribe; + }, [providerIds]); + + const value = useMemo( + () => ({ positionsByProvider }), + [positionsByProvider], + ); + + return {children}; +} + +/** + * All known live train positions, optionally filtered to a single provider. + * Returns a stable empty array when no data has arrived yet for the + * requested provider. + */ +export function useRealtimePositions(provider?: string): ApiTrainPosition[] { + const { positionsByProvider } = useContext(RealtimeContext); + return useMemo(() => { + if (provider) return positionsByProvider[provider] ?? []; + const all: ApiTrainPosition[] = []; + for (const list of Object.values(positionsByProvider)) { + for (const p of list) all.push(p); + } + return all; + }, [positionsByProvider, provider]); +} diff --git a/apps/mobile/hooks/useApiCache.ts b/apps/mobile/hooks/useApiCache.ts new file mode 100644 index 0000000..d3c46b5 --- /dev/null +++ b/apps/mobile/hooks/useApiCache.ts @@ -0,0 +1,64 @@ +/** + * Hooks that wrap synchronous reads of the api-client cache. Each one + * subscribes to cache-change notifications so a render fires when a fetch + * completes, while keeping the component code feeling synchronous. + * + * Use these instead of `await getStop(...)` etc. inside components — the + * hook fires a background fetch on first call and re-renders when data + * lands. This mirrors the ergonomic of the old gtfsParser.getStop() but + * is API-backed. + */ + +import { useEffect, useSyncExternalStore } from 'react'; +import { + getApiCacheVersion, + getCachedAgency, + getCachedRoute, + getCachedStop, + prefetchAgency, + prefetchRoute, + prefetchStop, + subscribeApiCache, +} from '../services/api-client'; +import type { ApiAgency, ApiRoute, ApiStop } from '../types/api'; + +/** + * Subscribe to api-client cache invalidations. Components that read via + * `lookupStop`/`lookupAgencyTimezone` (sync, api-stop-cache) should call + * this once so they re-render when new entries land. + */ +export function useApiCacheVersion(): number { + return useSyncExternalStore(subscribeApiCache, getApiCacheVersion, getApiCacheVersion); +} + +function useCacheVersion(): number { + return useApiCacheVersion(); +} + +const DEFAULT_PROVIDER = 'amtrak'; + +export function useStop(stopCode: string | null | undefined, providerId: string = DEFAULT_PROVIDER): ApiStop | undefined { + useCacheVersion(); + useEffect(() => { + if (stopCode) prefetchStop(providerId, stopCode); + }, [providerId, stopCode]); + if (!stopCode) return undefined; + return getCachedStop(providerId, stopCode); +} + +export function useRoute(routeId: string | null | undefined): ApiRoute | undefined { + useCacheVersion(); + useEffect(() => { + if (routeId) prefetchRoute(routeId); + }, [routeId]); + if (!routeId) return undefined; + return getCachedRoute(routeId); +} + +export function useAgency(providerId: string = DEFAULT_PROVIDER): ApiAgency | undefined { + useCacheVersion(); + useEffect(() => { + prefetchAgency(providerId); + }, [providerId]); + return getCachedAgency(providerId); +} diff --git a/apps/mobile/hooks/useFrequentlyUsed.ts b/apps/mobile/hooks/useFrequentlyUsed.ts index 61220f9..6156030 100644 --- a/apps/mobile/hooks/useFrequentlyUsed.ts +++ b/apps/mobile/hooks/useFrequentlyUsed.ts @@ -1,6 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; -import { TrainAPIService } from '../services/api'; -import { debug, error as logError } from '../utils/logger'; +import { useState } from 'react'; export interface FrequentlyUsedItem { id: string; @@ -10,39 +8,20 @@ export interface FrequentlyUsedItem { type: 'train' | 'station'; } -export function useFrequentlyUsed() { - const [items, setItems] = useState([]); - - const refresh = useCallback(async () => { - try { - const routes = await TrainAPIService.getRoutes(); - const stops = await TrainAPIService.getStops(); - const loaded = [ - ...routes.slice(0, 3).map((route, index) => ({ - id: `freq-route-${index}`, - name: route.route_long_name, - code: route.route_short_name || route.route_id.substring(0, 3), - subtitle: `AMT${route.route_id}`, - type: 'train' as const, - })), - ...stops.slice(0, 2).map((stop, index) => ({ - id: `freq-stop-${index}`, - name: stop.stop_name, - code: stop.stop_id, - subtitle: stop.stop_id, - type: 'station' as const, - })), - ]; - debug(`[useFrequentlyUsed] Loaded ${loaded.length} items`); - setItems(loaded); - } catch (err) { - logError('[useFrequentlyUsed] Failed to load frequently used items:', err); - } - }, []); +const DEFAULTS: FrequentlyUsedItem[] = [ + { id: 'freq-train-acela', type: 'train', name: 'Acela', code: '2151', subtitle: 'Northeast Corridor' }, + { id: 'freq-train-ner', type: 'train', name: 'Northeast Regional', code: '171', subtitle: 'Northeast Corridor' }, + { id: 'freq-train-cz', type: 'train', name: 'California Zephyr', code: '5', subtitle: 'Chicago → Emeryville' }, + { id: 'freq-stop-nyp', type: 'station', name: 'New York Penn', code: 'NYP', subtitle: 'NYP' }, + { id: 'freq-stop-chi', type: 'station', name: 'Chicago Union', code: 'CHI', subtitle: 'CHI' }, +]; - useEffect(() => { - refresh(); - }, [refresh]); - - return { items, refresh }; +/** + * Frequently-used items shown in the empty search state. Backed by a static + * default set for now — the previous bulk-load of all routes/stops is gone + * with the GTFS parser; a real "popular" list will come from the backend. + */ +export function useFrequentlyUsed() { + const [items] = useState(DEFAULTS); + return { items, refresh: () => Promise.resolve() }; } diff --git a/apps/mobile/hooks/useLiveTrains.ts b/apps/mobile/hooks/useLiveTrains.ts index 9d231b7..57cbddf 100644 --- a/apps/mobile/hooks/useLiveTrains.ts +++ b/apps/mobile/hooks/useLiveTrains.ts @@ -1,12 +1,12 @@ /** - * Hook for fetching all live trains from GTFS-RT feed - * Returns an array of all currently active trains with their positions + * Live trains feed for the map. Consumes the realtime WebSocket via + * RealtimeContext and adapts ApiTrainPosition into the LiveTrain shape + * that LiveTrainMarker expects. */ -import { useCallback, useEffect, useState } from 'react'; -import { RealtimeService } from '../services/realtime'; +import { useMemo } from 'react'; +import { useRealtimePositions } from '../context/RealtimeContext'; import { getTrainDisplayName } from '../services/api'; -import { logger } from '../utils/logger'; export interface LiveTrain { trainNumber: string; @@ -22,68 +22,52 @@ export interface LiveTrain { } /** - * Fetch all live trains from the GTFS-RT feed - * @param intervalMs - Refresh interval in milliseconds (default: 15000ms) - * @param enabled - Whether to enable polling (default: true) + * @param _intervalMs - kept for backwards compat; no-op now (WS-driven). + * @param enabled - when false, hide trains without unsubscribing. */ -export function useLiveTrains(intervalMs: number = 15000, enabled: boolean = true) { - const [liveTrains, setLiveTrains] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); +export function useLiveTrains(_intervalMs: number = 15000, enabled: boolean = true) { + const positions = useRealtimePositions(); - const fetchLiveTrains = useCallback(async () => { - try { - const activeTrains = await RealtimeService.getAllActiveTrains(); - - const trains: LiveTrain[] = activeTrains.map(({ trainNumber, position }) => { - const { routeName } = getTrainDisplayName(position.trip_id, trainNumber); - return { - trainNumber, - tripId: position.trip_id, - position: { - lat: position.latitude, - lon: position.longitude, - bearing: position.bearing, - speed: position.speed, - }, - routeName, - timestamp: position.timestamp, - }; + const liveTrains = useMemo(() => { + if (!enabled) return []; + const out: LiveTrain[] = []; + for (const p of positions) { + if (p.lat == null || p.lon == null) continue; + const { routeName } = getTrainDisplayName(p.tripId, p.trainNumber, p.routeId); + const ts = Date.parse(p.lastUpdated); + out.push({ + trainNumber: p.trainNumber, + tripId: p.tripId, + position: { + lat: p.lat, + lon: p.lon, + bearing: p.heading ?? undefined, + speed: p.speedMph ?? undefined, + }, + routeName, + timestamp: Number.isFinite(ts) ? ts : Date.now(), }); - - setLiveTrains(trains); - setLastUpdated(Date.now()); - setError(null); - logger.debug(`[LiveTrains] Updated ${trains.length} active trains`); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch live trains')); - logger.error('Error fetching live trains:', err); - } finally { - setLoading(false); } - }, []); + return out; + }, [positions, enabled]); - // Initial fetch + periodic refresh (single effect) - useEffect(() => { - if (!enabled) return; - fetchLiveTrains(); - const interval = setInterval(fetchLiveTrains, intervalMs); - return () => clearInterval(interval); - }, [fetchLiveTrains, intervalMs, enabled]); - - // Manual refresh function - const refresh = useCallback(() => { - RealtimeService.clearCache(); - return fetchLiveTrains(); - }, [fetchLiveTrains]); + const lastUpdated = useMemo(() => { + if (liveTrains.length === 0) return null; + let maxTimestamp: number | null = null; + for (const train of liveTrains) { + if (train.timestamp != null && (maxTimestamp === null || train.timestamp > maxTimestamp)) { + maxTimestamp = train.timestamp; + } + } + return maxTimestamp; + }, [liveTrains]); return { liveTrains, - loading, - error, + loading: liveTrains.length === 0, + error: null as Error | null, lastUpdated, - refresh, + refresh: () => Promise.resolve(), trainCount: liveTrains.length, }; } diff --git a/apps/mobile/hooks/useRealtime.ts b/apps/mobile/hooks/useRealtime.ts index b53351e..e2ba95c 100644 --- a/apps/mobile/hooks/useRealtime.ts +++ b/apps/mobile/hooks/useRealtime.ts @@ -1,10 +1,16 @@ +/** + * Realtime enrichment for saved trains. Subscribes to the WebSocket via + * RealtimeContext and re-attaches latest position data to each saved train + * whenever the feed updates. No more polling. + */ + import { useEffect, useRef } from 'react'; +import { useRealtimePositions } from '../context/RealtimeContext'; import { TrainAPIService } from '../services/api'; import { TrainActivityManager } from '../services/train-activity-manager'; import type { Train } from '../types/train'; import { logger } from '../utils/logger'; -/** Compare realtime-changing fields to detect if a train actually changed */ function hasRealtimeChanged(a: Train, b: Train): boolean { const ar = a.realtime; const br = b.realtime; @@ -20,36 +26,48 @@ function hasRealtimeChanged(a: Train, b: Train): boolean { ); } -export function useRealtime(trains: Train[], setTrains: (t: Train[]) => void, intervalMs: number = 20000) { - // Use ref to avoid resetting interval when trains change +/** + * @param trains - current saved trains + * @param setTrains - state setter for the saved trains list + * @param _intervalMs - retained for backwards compat; no-op now (WS-driven). + */ +export function useRealtime( + trains: Train[], + setTrains: (t: Train[]) => void, + _intervalMs: number = 20000, +) { + const positions = useRealtimePositions(); const trainsRef = useRef(trains); trainsRef.current = trains; - const setTrainsRef = useRef(setTrains); setTrainsRef.current = setTrains; useEffect(() => { - let mounted = true; + const current = trainsRef.current; + if (current.length === 0) return; + let cancelled = false; + let timeoutId: ReturnType | null = null; - const refresh = async () => { - if (trainsRef.current.length === 0) return; - logger.debug(`[Realtime] Refreshing ${trainsRef.current.length} saved trains`); - const oldTrains = trainsRef.current; - const updated = await Promise.all(trainsRef.current.map(t => TrainAPIService.refreshRealtimeData(t))); - if (mounted) { - // Only trigger re-render if any train's realtime data actually changed - const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, oldTrains[i])); + // Debounce rapid position updates + timeoutId = setTimeout(() => { + (async () => { + const updated = await Promise.all(current.map(t => TrainAPIService.refreshRealtimeData(t))); + if (cancelled) return; + // Stale-write guard: verify snapshot still matches + if (trainsRef.current !== current) return; + const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, current[i])); if (anyChanged) { setTrainsRef.current(updated); } - TrainActivityManager.onRealtimeUpdate(oldTrains, updated).catch(e => logger.warn('TrainActivityManager.onRealtimeUpdate failed', e)); - } - }; + TrainActivityManager.onRealtimeUpdate(current, updated).catch(e => + logger.warn('TrainActivityManager.onRealtimeUpdate failed', e), + ); + })(); + }, 300); - const timer = setInterval(refresh, intervalMs); return () => { - mounted = false; - clearInterval(timer); + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); }; - }, [intervalMs]); // Only depend on intervalMs, not trains + }, [positions]); } diff --git a/apps/mobile/hooks/useShapes.ts b/apps/mobile/hooks/useShapes.ts deleted file mode 100644 index da9be5a..0000000 --- a/apps/mobile/hooks/useShapes.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { shapeLoader, type VisibleShape } from '../services/shape-loader'; -import type { ViewportBounds } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { debug } from '../utils/logger'; - -export function useShapes(bounds?: ViewportBounds) { - const [gtfsLoaded, setGtfsLoaded] = useState(gtfsParser.isLoaded); - const [shapesVersion, setShapesVersion] = useState(0); - - // Subscribe to GTFS loaded event — no polling - useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => setGtfsLoaded(true)); - }, [gtfsLoaded]); - - // Subscribe to deferred shapes update - useEffect(() => { - return gtfsParser.onShapesUpdated(() => setShapesVersion(v => v + 1)); - }, []); - - // Compute visible shapes for the current bounds - const visibleShapes = useMemo(() => { - if (!gtfsLoaded) return []; - let shapes: VisibleShape[]; - if (bounds) { - shapes = shapeLoader.getVisibleShapes(bounds); - } else { - shapes = shapeLoader.getAllShapes(); - } - debug(`[useShapes] ${shapes.length} shapes visible in viewport`); - return shapes; - }, [gtfsLoaded, shapesVersion, bounds?.minLat, bounds?.maxLat, bounds?.minLon, bounds?.maxLon]); - - return { visibleShapes }; -} diff --git a/apps/mobile/hooks/useStations.ts b/apps/mobile/hooks/useStations.ts deleted file mode 100644 index 32657b8..0000000 --- a/apps/mobile/hooks/useStations.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { VisibleStation } from '../services/station-loader'; -import { stationLoader } from '../services/station-loader'; -import type { ViewportBounds } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { debug } from '../utils/logger'; - -export function useStations(bounds?: ViewportBounds) { - const [gtfsLoaded, setGtfsLoaded] = useState(gtfsParser.isLoaded); - const [initialized, setInitialized] = useState(false); - - // Subscribe to GTFS loaded event — no polling - useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => setGtfsLoaded(true)); - }, [gtfsLoaded]); - - // Initialize station loader once GTFS is loaded - useEffect(() => { - if (!gtfsLoaded || initialized) return; - - const stops = gtfsParser.getAllStops(); - stationLoader.initialize(stops); - debug(`[useStations] Initialized station loader with ${stops.length} stops`); - setInitialized(true); - }, [gtfsLoaded, initialized]); - - // Get visible stations based on bounds - const stations = useMemo(() => { - if (!initialized) return []; - - if (bounds) { - // Use padding for smoother panning - return stationLoader.getVisibleStations(bounds); - } - - // No bounds - return all stations (fallback) - const stops = gtfsParser.getAllStops(); - return stops.map(s => ({ - id: s.stop_id, - name: s.stop_name, - lat: s.stop_lat, - lon: s.stop_lon, - })); - }, [initialized, bounds?.minLat, bounds?.maxLat, bounds?.minLon, bounds?.maxLon]); - - return stations; -} diff --git a/apps/mobile/hooks/useTravelOverlay.ts b/apps/mobile/hooks/useTravelOverlay.ts index f910c63..7e1365d 100644 --- a/apps/mobile/hooks/useTravelOverlay.ts +++ b/apps/mobile/hooks/useTravelOverlay.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { getStop } from '../services/api-client'; import { TrainStorageService } from '../services/storage'; -import { gtfsParser } from '../utils/gtfs-parser'; + +const DEFAULT_PROVIDER = 'amtrak'; interface TravelLine { key: string; @@ -14,6 +16,22 @@ interface TravelStation { id: string; } +function splitNamespaced(stopId: string): { provider: string; code: string } { + const i = stopId.indexOf(':'); + if (i <= 0) return { provider: DEFAULT_PROVIDER, code: stopId }; + return { provider: stopId.slice(0, i), code: stopId.slice(i + 1) }; +} + +async function resolveCoord(stopId: string): Promise<{ latitude: number; longitude: number } | null> { + try { + const { provider, code } = splitNamespaced(stopId); + const s = await getStop(provider, code); + return { latitude: s.lat, longitude: s.lon }; + } catch { + return null; + } +} + /** * Manages travel overlay data for profile/settings views. * Loads trip history and resolves coordinates for travel lines/stations. @@ -23,27 +41,41 @@ export function useTravelOverlay(isOverlayMode: boolean) { const [travelStations, setTravelStations] = useState([]); const [profileYear, setProfileYear] = useState(null); const tripHistoryRef = useRef>>([]); + const resolveTokenRef = useRef(0); - const resolveTravelOverlay = useCallback((history: typeof tripHistoryRef.current, year: number | null) => { - const lines: TravelLine[] = []; - const stationMap = new Map(); + const resolveTravelOverlay = useCallback(async ( + history: typeof tripHistoryRef.current, + year: number | null, + ) => { + const token = ++resolveTokenRef.current; + const filtered = year ? history.filter(t => new Date(t.travelDate).getFullYear() === year) : history; - for (const trip of history) { - if (year && new Date(trip.travelDate).getFullYear() !== year) continue; - - const fromStop = gtfsParser.getStop(trip.fromCode); - const toStop = gtfsParser.getStop(trip.toCode); - if (!fromStop || !toStop) continue; + const codes = new Set(); + for (const trip of filtered) { + codes.add(trip.fromCode); + codes.add(trip.toCode); + } + const codeList = [...codes]; + const coords = await Promise.all(codeList.map(resolveCoord)); + if (token !== resolveTokenRef.current) return; + const coordByCode = new Map(); + for (let i = 0; i < codeList.length; i++) { + const c = coords[i]; + if (c) coordByCode.set(codeList[i], c); + } - const fromCoord = { latitude: fromStop.stop_lat, longitude: fromStop.stop_lon }; - const toCoord = { latitude: toStop.stop_lat, longitude: toStop.stop_lon }; + const lines: TravelLine[] = []; + const stationMap = new Map(); + for (const trip of filtered) { + const fromCoord = coordByCode.get(trip.fromCode); + const toCoord = coordByCode.get(trip.toCode); + if (!fromCoord || !toCoord) continue; lines.push({ key: `${trip.tripId}-${trip.fromCode}-${trip.toCode}-${trip.travelDate}`, from: fromCoord, to: toCoord, }); - if (!stationMap.has(trip.fromCode)) stationMap.set(trip.fromCode, fromCoord); if (!stationMap.has(trip.toCode)) stationMap.set(trip.toCode, toCoord); } @@ -55,6 +87,7 @@ export function useTravelOverlay(isOverlayMode: boolean) { // Load trip history when overlay mode activates useEffect(() => { if (!isOverlayMode) { + resolveTokenRef.current++; setTravelLines([]); setTravelStations([]); setProfileYear(null); @@ -65,13 +98,13 @@ export function useTravelOverlay(isOverlayMode: boolean) { (async () => { const history = await TrainStorageService.getTripHistory(); tripHistoryRef.current = history; - resolveTravelOverlay(history, profileYear); + await resolveTravelOverlay(history, profileYear); })(); }, [isOverlayMode]); // eslint-disable-line react-hooks/exhaustive-deps const handleProfileYearChange = useCallback((year: number | null) => { setProfileYear(year); - resolveTravelOverlay(tripHistoryRef.current, year); + void resolveTravelOverlay(tripHistoryRef.current, year); }, [resolveTravelOverlay]); return { diff --git a/apps/mobile/hooks/useTripDetail.ts b/apps/mobile/hooks/useTripDetail.ts new file mode 100644 index 0000000..4140ec7 --- /dev/null +++ b/apps/mobile/hooks/useTripDetail.ts @@ -0,0 +1,183 @@ +/** + * Loads everything TrainDetailModal needs about a trip: scheduled stop + * times, per-stop coordinates/timezone, and (when available) live + * estimated/actual times per stop. + * + * Replaces several gtfsParser/RealtimeService calls with one async hook. + * Stop coordinates fan out as parallel /v1/stops/{provider}/{stopCode} + * requests; the 1h cache makes repeat opens of the same trip cheap. + * + * Per-stop ETAs come from /v1/runs/{provider}/{tripId}/{runDate}/stops + * which is currently a stub on apps/api — the hook returns scheduled-only + * times until the backend lands that endpoint. + */ + +import { useEffect, useState } from 'react'; +import { ApiError, getRunStops, getStop, getTripStops } from '../services/api-client'; +import type { ApiTrainStopTime } from '../types/api'; +import { logger } from '../utils/logger'; + +export interface TripDetailStop { + /** Namespaced provider:code, e.g. "amtrak:NYP". */ + stopId: string; + /** Raw GTFS stop_code, e.g. "NYP". */ + code: string; + name: string; + sequence: number; + /** GTFS-format scheduled times (HH:MM:SS, may be > 24:00 for overnight). */ + scheduledArrival: string | null; + scheduledDeparture: string | null; + /** Coordinates and timezone — populated by the per-stop fetch. */ + lat: number | null; + lon: number | null; + timezone: string | null; + /** Live data — only set when /v1/runs/.../stops returns rows for the run. */ + estimatedArrival: Date | null; + estimatedDeparture: Date | null; + actualArrival: Date | null; + actualDeparture: Date | null; + /** Minutes late (positive) or early (negative); null if not computable. */ + arrivalDelayMin: number | null; + departureDelayMin: number | null; +} + +export interface TripDetail { + stops: TripDetailStop[]; + loading: boolean; + /** True iff the per-stop ETA endpoint is unavailable for this run. */ + delaysUnavailable: boolean; +} + +const EMPTY: TripDetail = { stops: [], loading: false, delaysUnavailable: true }; + +function parseIso(s: string | null): Date | null { + if (!s) return null; + const d = new Date(s); + return Number.isFinite(d.getTime()) ? d : null; +} + +export function useTripDetail( + tripId: string | null | undefined, + runDate?: Date, +): TripDetail { + const [state, setState] = useState(EMPTY); + + useEffect(() => { + if (!tripId) { + setState(EMPTY); + return; + } + + let cancelled = false; + setState(s => ({ ...s, loading: true })); + + (async () => { + const provider = tripId.includes(':') ? tripId.split(':', 1)[0] : 'amtrak'; + + // Phase 1: scheduled stop times — single endpoint. + let scheduled: TripDetailStop[]; + try { + const apiStops = await getTripStops(tripId); + scheduled = apiStops.map(et => ({ + stopId: `${provider}:${et.stopCode}`, + code: et.stopCode, + name: et.stopName, + sequence: et.stopSequence, + scheduledArrival: et.arrivalTime, + scheduledDeparture: et.departureTime, + lat: null, + lon: null, + timezone: null, + estimatedArrival: null, + estimatedDeparture: null, + actualArrival: null, + actualDeparture: null, + arrivalDelayMin: null, + departureDelayMin: null, + })); + } catch (err) { + logger.warn('[useTripDetail] /v1/trips/.../stops failed', err); + if (!cancelled) setState({ stops: [], loading: false, delaysUnavailable: true }); + return; + } + if (cancelled) return; + + // Surface scheduled times immediately so the timeline can render. + setState({ stops: scheduled, loading: true, delaysUnavailable: true }); + + // Phase 2: per-stop coordinates / tz, fanned out in parallel. + const enrichedPromise = Promise.all( + scheduled.map(async (stop) => { + try { + const apiStop = await getStop(provider, stop.code); + return { + ...stop, + lat: apiStop.lat, + lon: apiStop.lon, + timezone: apiStop.timezone ?? null, + }; + } catch { + return stop; + } + }), + ); + + // Phase 3: per-stop estimated/actual times (optional — stub today). + const date = runDate ?? new Date(); + const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const delaysPromise = (async (): Promise<{ rows: ApiTrainStopTime[]; available: boolean }> => { + try { + const rows = await getRunStops({ provider, tripId, runDate: ymd }); + return { rows, available: true }; + } catch (err) { + if (!(err instanceof ApiError && err.status === 404)) { + logger.debug('[useTripDetail] run stops unavailable', err); + } + return { rows: [], available: false }; + } + })(); + + const [enriched, delays] = await Promise.all([enrichedPromise, delaysPromise]); + if (cancelled) return; + + const byCode = new Map(); + for (const r of delays.rows) byCode.set(r.stopCode, r); + + const merged: TripDetailStop[] = enriched.map(stop => { + const live = byCode.get(stop.code); + if (!live) return stop; + const scheduledArr = parseIso(live.scheduledArr); + const scheduledDep = parseIso(live.scheduledDep); + const estimatedArr = parseIso(live.estimatedArr); + const estimatedDep = parseIso(live.estimatedDep); + const actualArr = parseIso(live.actualArr); + const actualDep = parseIso(live.actualDep); + const refArr = actualArr ?? estimatedArr; + const refDep = actualDep ?? estimatedDep; + return { + ...stop, + estimatedArrival: estimatedArr, + estimatedDeparture: estimatedDep, + actualArrival: actualArr, + actualDeparture: actualDep, + arrivalDelayMin: + scheduledArr && refArr + ? Math.round((refArr.getTime() - scheduledArr.getTime()) / 60_000) + : null, + departureDelayMin: + scheduledDep && refDep + ? Math.round((refDep.getTime() - scheduledDep.getTime()) / 60_000) + : null, + }; + }); + + setState({ stops: merged, loading: false, delaysUnavailable: !delays.available }); + })(); + + return () => { + cancelled = true; + }; + }, [tripId, runDate?.getTime()]); + + return state; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8351a3f..04e1a91 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -42,8 +42,6 @@ "expo-task-manager": "~55.0.9", "expo-web-browser": "~55.0.9", "expo-widgets": "55.0.4", - "fflate": "^0.8.2", - "gtfs-realtime-bindings": "^1.1.1", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", @@ -62,6 +60,7 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "29.5.14", "@types/react": "~19.2.10", + "@types/react-native-vector-icons": "^6.4.18", "dotenv": "^17.3.1", "eslint": "^9.25.0", "eslint-config-expo": "~55.0.0", diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index caeb4e5..173a886 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -6,9 +6,8 @@ import { Camera, CameraRef, GeoJSONSource, Layer, Map, Marker, UserLocation, Vie import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Ionicons from 'react-native-vector-icons/Ionicons'; import { ErrorBoundary } from '../components/ErrorBoundary'; -import { AnimatedRoute } from '../components/map/AnimatedRoute'; -import { AnimatedStationMarker } from '../components/map/AnimatedStationMarker'; import { LiveTrainMarker } from '../components/map/LiveTrainMarker'; +import { ProviderTiles, type RouteTapPayload, type StationTapPayload } from '../components/map/ProviderTiles'; import MapSettingsPill, { MapType, RouteMode, StationMode, TrainMode } from '../components/map/MapSettingsPill'; import DepartureBoardModal from '../components/ui/DepartureBoardModal'; import ProfileModal from '../components/ui/ProfileModal'; @@ -33,24 +32,20 @@ import { type ColorPalette, withTextShadow } from '../constants/theme'; import { useColors, useTheme } from '../context/ThemeContext'; import { GTFSRefreshProvider, useGTFSRefresh } from '../context/GTFSRefreshContext'; import { ModalProvider, useModalActions, useModalState } from '../context/ModalContext'; +import { RealtimeProvider } from '../context/RealtimeContext'; import { TrainProvider, useTrainContext } from '../context/TrainContext'; import { UnitsProvider } from '../context/UnitsContext'; import { useLiveTrains } from '../hooks/useLiveTrains'; import { useMapLocation } from '../hooks/useMapLocation'; import { useRealtime } from '../hooks/useRealtime'; -import { useShapes } from '../hooks/useShapes'; -import { useStations } from '../hooks/useStations'; import { useTravelOverlay } from '../hooks/useTravelOverlay'; +import { PROVIDERS } from '../constants/providers'; import { TrainAPIService } from '../services/api'; import { requestPermissions as requestNotificationPermissions } from '../services/notifications'; import { TrainStorageService } from '../services/storage'; import type { SavedTrainRef, Stop, Train, ViewportBounds } from '../types/train'; -import { ClusteringConfig } from '../utils/clustering-config'; -import { gtfsParser } from '../utils/gtfs-parser'; import { light as hapticLight } from '../utils/haptics'; import { logger } from '../utils/logger'; -import { getRouteColor, getStrokeWidthForZoom } from '../utils/route-colors'; -import { clusterStations, getStationAbbreviation } from '../utils/station-clustering'; import { clusterTrains, type TrainCluster } from '../utils/train-clustering'; import { ModalContent, ModalContentHandle } from './ModalContent'; import { createStyles } from './styles'; @@ -243,10 +238,6 @@ function MapScreenInner() { return () => clearTimeout(timer); }, [isOverlayMode, travelStations]); - // Use lazy-loaded stations and shapes based on viewport - const stations = useStations(viewportBounds ?? undefined); - const { visibleShapes } = useShapes(viewportBounds ?? undefined); - // Fetch all live trains from GTFS-RT (only when trainMode is 'all') const { liveTrains } = useLiveTrains(15000, trainMode === 'all'); @@ -450,17 +441,6 @@ function MapScreenInner() { [navigateToStation, fitMapToCoordinates] ); - // Stable callback for station marker presses — receives cluster from child - const handleStationMarkerPress = useCallback((cluster: { - id: string; - lat: number; - lon: number; - isCluster: boolean; - stations: Array<{ id: string; name: string; lat: number; lon: number }>; - }) => { - handleStationPress(cluster); - }, [handleStationPress]); - // Stable callback for saved train cluster presses const handleSavedTrainClusterPress = useCallback((cluster: TrainCluster) => { if (cluster.isCluster) { @@ -567,10 +547,11 @@ function MapScreenInner() { duration: MAP_ANIMATION_DURATION, }); - // Create a Stop object and navigate + // Synthesize a Stop and navigate. The DepartureBoardModal will fetch + // full detail from the API via the stationCode/lat/lon we already have. const stop: Stop = { stop_id: stationCode, - stop_name: gtfsParser.getStopName(stationCode), + stop_name: stationCode, stop_lat: lat, stop_lon: lon, }; @@ -584,28 +565,15 @@ function MapScreenInner() { requestNotificationPermissions(); }, []); - // Track when GTFS data is loaded — event-based, no polling - const [gtfsLoaded, setGtfsLoaded] = React.useState(gtfsParser.isLoaded); - + // Load saved trains on mount (was gated on GTFS cache load — now API-backed) React.useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => { - logger.info('[MapScreen] GTFS data ready'); - setGtfsLoaded(true); - }); - }, [gtfsLoaded]); - - // Load saved trains after GTFS is ready - React.useEffect(() => { - if (!gtfsLoaded) return; - (async () => { const trains = await TrainStorageService.getSavedTrains(); logger.debug(`[MapScreen] Loading ${trains.length} saved trains with realtime data`); const trainsWithRealtime = await Promise.all(trains.map(train => TrainAPIService.refreshRealtimeData(train))); setSavedTrains(trainsWithRealtime); })(); - }, [setSavedTrains, gtfsLoaded]); + }, [setSavedTrains]); useRealtime(savedTrains, setSavedTrains, 20000); @@ -658,15 +626,23 @@ function MapScreenInner() { }, VIEWPORT_DEBOUNCE_MS); }, []); - // Initialize viewport bounds when map first becomes ready + // When location resolves AFTER the map mounts, the Camera's initialViewState + // (read once at mount) leaves the camera at world view. Jump it into place. + const initialCameraSet = useRef(false); React.useEffect(() => { - if (mapReady && regionRef.current && !viewportBounds) { + if (mapReady && regionRef.current && !initialCameraSet.current) { + initialCameraSet.current = true; + const r = regionRef.current; + cameraRef.current?.jumpTo({ + center: [r.longitude, r.latitude], + zoom: latDeltaToZoom(r.latitudeDelta), + }); setViewportState({ - bounds: regionToViewportBounds(regionRef.current), - latDelta: regionRef.current.latitudeDelta, + bounds: regionToViewportBounds(r), + latDelta: r.latitudeDelta, }); } - }, [mapReady, viewportBounds]); + }, [mapReady]); // Cleanup timers on unmount React.useEffect(() => { @@ -700,30 +676,37 @@ function MapScreenInner() { } }, [getCurrentSnap]); - // Calculate dynamic stroke width based on zoom level - const baseStrokeWidth = useMemo(() => { - return getStrokeWidthForZoom(debouncedLatDelta); - }, [debouncedLatDelta]); - - // Routes are always visible (no zoom-based fading) const shouldRenderRoutes = routeMode !== 'hidden'; - - // Cluster stations based on zoom level and station mode - const stationClusters = useMemo(() => { - if (stationMode === 'hidden') return []; - if (stationMode === 'all') { - // Return all stations without clustering - return stations.map(s => ({ - id: s.id, - lat: s.lat, - lon: s.lon, + const shouldRenderStations = stationMode !== 'hidden'; + + // Adapter: convert PMTiles station tap → existing handleStationPress contract + const handleProviderTileStationPress = useCallback( + (payload: StationTapPayload) => { + handleStationPress({ + id: payload.stopId, + lat: payload.lat, + lon: payload.lon, isCluster: false, - stations: [s], - })); - } - // 'auto' mode - use clustering - return clusterStations(stations, debouncedLatDelta); - }, [stations, debouncedLatDelta, stationMode]); + stations: [{ id: payload.stopId, name: payload.name, lat: payload.lat, lon: payload.lon }], + }); + }, + [handleStationPress], + ); + + // PMTiles route tap. No dedicated route-detail UI yet — show a brief + // confirmation so the user sees the tap was registered, with the route + // info already carried by the tile feature. + const handleProviderTileRoutePress = useCallback( + (payload: RouteTapPayload) => { + hapticLight(); + const title = payload.longName || payload.shortName || 'Route'; + const subtitle = payload.shortName && payload.longName + ? `${payload.shortName} — ${payload.providerId}` + : payload.providerId; + Alert.alert(title, subtitle); + }, + [] + ); return ( @@ -744,40 +727,19 @@ function MapScreenInner() { {showNormalMapContent && - shouldRenderRoutes && - visibleShapes.map(shape => { - const colorScheme = getRouteColor(shape.id, colors.accentBlue); - return ( - - ); - })} - - {showNormalMapContent && - stationClusters.map(cluster => { - // Show full name when zoomed in enough - const showFullName = !cluster.isCluster && debouncedLatDelta < ClusteringConfig.fullNameThreshold; - const displayName = cluster.isCluster - ? `${cluster.stations.length}+` - : showFullName - ? cluster.stations[0].name - : getStationAbbreviation(cluster.stations[0].id, cluster.stations[0].name); - return ( - - ); - })} + (shouldRenderRoutes || shouldRenderStations) && + PROVIDERS.map(provider => ( + + ))} {/* Render saved trains when mode is 'saved' */} {showNormalMapContent && @@ -983,7 +945,7 @@ function MapScreenInner() { )} - {/* Full-page loading overlay while GTFS cache loads */} + {/* Full-page loading overlay while local GTFS cache loads (fallback path) */} ); @@ -992,15 +954,19 @@ function MapScreenInner() { export default function MapScreen() { return ( - - - - - - - - - + + + + + + + + + + + ); } + + diff --git a/apps/mobile/services/api-client.ts b/apps/mobile/services/api-client.ts new file mode 100644 index 0000000..14911dc --- /dev/null +++ b/apps/mobile/services/api-client.ts @@ -0,0 +1,362 @@ +/** + * Typed client for the Tracky backend REST API at `apiUrl` (config.ts). + * + * Path structure mirrors apps/api routes — see /v1/* in apps/api/routes/static.go + * for endpoint definitions and apps/api/db/static_read.go for response shapes. + * + * Static lookups are cached in-memory for 1 hour (matches server Cache-Control). + * Time-sensitive endpoints (departures, lookups, runs) skip the cache. + */ + +import { config } from '../constants/config'; +import { fetchWithTimeout } from '../utils/fetch-with-timeout'; +import type { + ApiActiveTrain, + ApiAgency, + ApiConnectionItem, + ApiDepartureItem, + ApiEnrichedStopTime, + ApiRoute, + ApiSearchHitType, + ApiSearchResult, + ApiServiceInfo, + ApiStop, + ApiTrainItem, + ApiTrainStopTime, + ApiTrip, +} from '../types/api'; + +const STATIC_TTL_MS = 60 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 12_000; + +interface CacheEntry { + value: T; + expiresAt: number; +} +const cache = new Map>(); + +function getCached(key: string): T | undefined { + const entry = cache.get(key); + if (!entry) return undefined; + if (entry.expiresAt < Date.now()) { + cache.delete(key); + return undefined; + } + return entry.value as T; +} + +function setCached(key: string, value: T, ttlMs: number): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly path: string, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +function buildQuery(params: Record): string { + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); + if (entries.length === 0) return ''; + const search = new URLSearchParams(); + for (const [k, v] of entries) search.set(k, String(v)); + return `?${search.toString()}`; +} + +async function request( + path: string, + opts: { cacheKey?: string; ttlMs?: number; timeoutMs?: number } = {}, +): Promise { + if (opts.cacheKey) { + const hit = getCached(opts.cacheKey); + if (hit !== undefined) return hit; + } + + const url = `${config.apiUrl}${path}`; + const res = await fetchWithTimeout(url, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS }); + + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) message = body.error; + } catch { + // body was not JSON; keep status-only message + } + throw new ApiError(res.status, path, message); + } + + const value = (await res.json()) as T; + if (opts.cacheKey && opts.ttlMs) { + setCached(opts.cacheKey, value, opts.ttlMs); + } + return value; +} + +// ── Providers ────────────────────────────────────────────────────────────── + +export function getProvider(providerId: string): Promise { + return request(`/v1/providers/${encodeURIComponent(providerId)}`, { + cacheKey: `provider:${providerId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +// ── Stops ────────────────────────────────────────────────────────────────── + +export function getStop(providerId: string, stopCode: string): Promise { + return request( + `/v1/stops/${encodeURIComponent(providerId)}/${encodeURIComponent(stopCode)}`, + { cacheKey: `stop:${providerId}:${stopCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +// ── Routes ───────────────────────────────────────────────────────────────── + +export function getRoute(providerId: string, routeCode: string): Promise { + return request( + `/v1/routes/${encodeURIComponent(providerId)}/${encodeURIComponent(routeCode)}`, + { cacheKey: `route:${providerId}:${routeCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +export function getRoutes(providerId: string): Promise { + const qs = buildQuery({ provider: providerId }); + return request(`/v1/routes${qs}`, { + cacheKey: `routes:${providerId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +export function getTrainsForRoute( + providerId: string, + routeCode: string, +): Promise { + return request( + `/v1/routes/${encodeURIComponent(providerId)}/${encodeURIComponent(routeCode)}/trains`, + { cacheKey: `routeTrains:${providerId}:${routeCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +// ── Trips ────────────────────────────────────────────────────────────────── + +export function getTrip(tripId: string): Promise { + return request(`/v1/trips/${encodeURIComponent(tripId)}`, { + cacheKey: `trip:${tripId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +export function getTripStops(tripId: string): Promise { + return request(`/v1/trips/${encodeURIComponent(tripId)}/stops`, { + cacheKey: `tripStops:${tripId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +export function lookupTrips(params: { + provider: string; + trainNumber: string; + date: string; +}): Promise { + const qs = buildQuery({ + provider: params.provider, + train_number: params.trainNumber, + date: params.date, + }); + return request(`/v1/trips/lookup${qs}`); +} + +// ── Departures & connections ─────────────────────────────────────────────── + +export function getDepartures(params: { + stopId: string; + date: string; +}): Promise { + const qs = buildQuery({ stop_id: params.stopId, date: params.date }); + return request(`/v1/departures${qs}`); +} + +export function getConnections(params: { + fromStop: string; + toStop: string; + date: string; +}): Promise { + const qs = buildQuery({ + from_stop: params.fromStop, + to_stop: params.toStop, + date: params.date, + }); + return request(`/v1/connections${qs}`); +} + +// ── Train service ────────────────────────────────────────────────────────── + +export function getTrainService( + trainNumber: string, + params: { provider: string; from?: string; to?: string }, +): Promise { + const qs = buildQuery({ provider: params.provider, from: params.from, to: params.to }); + return request( + `/v1/trains/${encodeURIComponent(trainNumber)}/service${qs}`, + ); +} + +// ── Search ───────────────────────────────────────────────────────────────── + +export function search(params: { + q: string; + provider?: string; + types?: ApiSearchHitType[]; +}): Promise { + const qs = buildQuery({ + q: params.q, + provider: params.provider, + types: params.types?.join(','), + }); + return request(`/v1/search${qs}`); +} + +// ── Realtime ─────────────────────────────────────────────────────────────── + +/** + * Per-stop scheduled + estimated + actual times for a specific run of a trip. + * Powers TrainDetailModal's per-stop delay timeline. Uncached — realtime data. + */ +export function getRunStops(params: { + provider: string; + tripId: string; + runDate: string; +}): Promise { + const { provider, tripId, runDate } = params; + return request( + `/v1/runs/${encodeURIComponent(provider)}/${encodeURIComponent(tripId)}/${encodeURIComponent(runDate)}/stops`, + ); +} + +/** + * Currently-tracked runs for a provider, sourced from the latest realtime + * snapshot the backend has published. Used by the "live only" filter in the + * trip search; cheap enough to call on demand without caching. + */ +export function getActiveTrains(provider: string): Promise { + const qs = buildQuery({ provider }); + return request<{ activeTrains: ApiActiveTrain[] }>(`/v1/active${qs}`).then( + r => r.activeTrains, + ); +} + +/** + * Stops near a location across all providers, for "nearest station" suggestions. + */ +export function getNearbyStops(params: { + lat: number; + lon: number; + radiusMeters?: number; + provider?: string; +}): Promise { + const qs = buildQuery({ + lat: params.lat, + lon: params.lon, + radius_m: params.radiusMeters, + provider: params.provider, + }); + return request(`/v1/stops/nearby${qs}`, { + cacheKey: `nearby:${params.lat}:${params.lon}:${params.radiusMeters ?? ''}:${params.provider ?? ''}`, + ttlMs: STATIC_TTL_MS, + }); +} + +// ── Cache utilities (mostly for tests / sign-out) ────────────────────────── + +export function clearApiCache(): void { + cache.clear(); +} + +/** + * Synchronously read a previously-fetched route from the in-memory cache. + * Returns undefined if the route hasn't been requested via getRoute() yet + * or its TTL has expired. Useful for render-time lookups where firing an + * async fetch would cause a flicker. + */ +export function getCachedRoute(routeId: string): ApiRoute | undefined { + return getCached(`route:${routeId}`); +} + +/** + * Fire-and-forget prefetch of route metadata. Safe to call repeatedly; the + * result lands in the same cache that getCachedRoute reads from. + */ +export function prefetchRoute(routeId: string): void { + if (getCached(`route:${routeId}`) !== undefined) return; + const sep = routeId.indexOf(':'); + if (sep <= 0) return; + const provider = routeId.slice(0, sep); + const code = routeId.slice(sep + 1); + notifyAfter(getRoute(provider, code)); +} + +export function getCachedStop(providerId: string, stopCode: string): ApiStop | undefined { + return getCached(`stop:${providerId}:${stopCode}`); +} + +/** + * Synchronously read a previously-fetched trip from the in-memory cache. + */ +export function getCachedTrip(tripId: string): ApiTrip | undefined { + return getCached(`trip:${tripId}`); +} + +/** + * Fire-and-forget prefetch of trip metadata. + */ +export function prefetchTrip(tripId: string): void { + if (getCached(`trip:${tripId}`) !== undefined) return; + notifyAfter(getTrip(tripId)); +} + +export function prefetchStop(providerId: string, stopCode: string): void { + if (getCached(`stop:${providerId}:${stopCode}`) !== undefined) return; + notifyAfter(getStop(providerId, stopCode)); +} + +export function getCachedAgency(providerId: string): ApiAgency | undefined { + return getCached(`provider:${providerId}`); +} + +export function prefetchAgency(providerId: string): void { + if (getCached(`provider:${providerId}`) !== undefined) return; + notifyAfter(getProvider(providerId)); +} + +// ── Cache change notifications ───────────────────────────────────────────── +// +// Components that read from the sync getters above can subscribe here to be +// notified when a new value lands. Internal — used by the useApiCacheVersion +// hook (hooks/useApiCache.ts). + +const cacheListeners = new Set<() => void>(); +let cacheVersion = 0; + +export function subscribeApiCache(listener: () => void): () => void { + cacheListeners.add(listener); + return () => cacheListeners.delete(listener); +} + +export function getApiCacheVersion(): number { + return cacheVersion; +} + +function notifyAfter(p: Promise): void { + p.then(() => { + cacheVersion += 1; + for (const l of cacheListeners) l(); + }).catch(() => { + // leave un-cached so a later attempt can retry + }); +} diff --git a/apps/mobile/services/api.ts b/apps/mobile/services/api.ts index cbbbdc8..a9acbeb 100644 --- a/apps/mobile/services/api.ts +++ b/apps/mobile/services/api.ts @@ -1,11 +1,31 @@ /** - * API service for fetching train data - * Provides abstraction layer for GTFS data access and future real-time API integration + * Train data access layer. Backend-API-backed; thin adapter from the + * spec.* response shapes (types/api.ts) to the consumer-facing Train / + * Stop / EnrichedStopTime shapes (types/train.ts). + * + * Realtime data is read non-reactively from the WebSocket client's cached + * snapshot — the source of truth for the WS feed lives in services/ws-client. */ -import type { EnrichedStopTime, Route, SearchResult, Stop, Train } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { RealtimeService } from './realtime'; +import type { EnrichedStopTime, Stop, Train } from '../types/train'; +import type { + ApiDepartureItem, + ApiEnrichedStopTime, + ApiStop, + ApiTrainPosition, + ApiTrip, +} from '../types/api'; +import { + ApiError, + getCachedRoute, + getDepartures, + getStop, + getTrip, + getTripStops, + lookupTrips, + prefetchRoute, +} from './api-client'; +import { wsClient } from './ws-client'; import { formatTime, formatTimeWithDayOffset, type FormattedTime } from '../utils/time-formatting'; import { convertGtfsTimeForStop } from '../utils/timezone'; import { extractDateFromTripId, extractTrainNumber, isLikelyTrainNumber } from '../utils/train-helpers'; @@ -16,7 +36,10 @@ import { logger } from '../utils/logger'; export { formatTime, formatTimeWithDayOffset, extractTrainNumber, isLikelyTrainNumber }; export type { FormattedTime }; -/** Deterministic numeric hash from a string (avoids Date.now() collisions) */ +// Until multi-provider support lands, all legacy call sites assume Amtrak. +const DEFAULT_PROVIDER = 'amtrak'; + +/** Deterministic numeric hash from a string (avoids Date.now() collisions). */ function simpleHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -25,466 +48,284 @@ function simpleHash(str: string): number { return Math.abs(hash); } -/** - * Amtrak train number to route name mapping - * Common named trains and their number ranges - */ -const AMTRAK_ROUTE_NAMES: Record = { - // Acela is intentionally omitted — resolved dynamically via GTFS route_long_name - // Long-distance trains - '1': 'Sunset Limited', - '2': 'Sunset Limited', - '3': 'Southwest Chief', - '4': 'Southwest Chief', - '5': 'California Zephyr', - '6': 'California Zephyr', - '7': 'Empire Builder', - '8': 'Empire Builder', - '27': 'Empire Builder', - '28': 'Empire Builder', - '11': 'Coast Starlight', - '14': 'Coast Starlight', - '19': 'Crescent', - '20': 'Crescent', - '21': 'Texas Eagle', - '22': 'Texas Eagle', - '421': 'Texas Eagle', - '422': 'Texas Eagle', - '29': 'Capitol Limited', - '30': 'Capitol Limited', - '48': 'Lake Shore Limited', - '49': 'Lake Shore Limited', - '448': 'Lake Shore Limited', - '449': 'Lake Shore Limited', - '50': 'Cardinal', - '51': 'Cardinal', - '52': 'Auto Train', - '53': 'Auto Train', - '58': 'City of New Orleans', - '59': 'City of New Orleans', - '66': 'Palmetto', - '67': 'Northeast Regional', - '79': 'Carolinian', - '80': 'Carolinian', - '89': 'Palmetto', - '90': 'Palmetto', - '91': 'Silver Star', - '92': 'Silver Star', - '97': 'Silver Meteor', - '98': 'Silver Meteor', - // Keystone/Pennsylvanian - '42': 'Pennsylvanian', - '43': 'Pennsylvanian', - '600': 'Keystone', - '601': 'Keystone', - '602': 'Keystone', - '603': 'Keystone', - '604': 'Keystone', - '605': 'Keystone', - '606': 'Keystone', - '607': 'Keystone', - '608': 'Keystone', - '609': 'Keystone', - '610': 'Keystone', - '611': 'Keystone', - '612': 'Keystone', - '613': 'Keystone', - '614': 'Keystone', - '615': 'Keystone', - '616': 'Keystone', - '617': 'Keystone', - '618': 'Keystone', - '619': 'Keystone', - '620': 'Keystone', - '621': 'Keystone', - '622': 'Keystone', - '623': 'Keystone', - '624': 'Keystone', - '625': 'Keystone', - '626': 'Keystone', - '627': 'Keystone', - '628': 'Keystone', - '629': 'Keystone', - '630': 'Keystone', - '631': 'Keystone', - '640': 'Keystone', - '641': 'Keystone', - '642': 'Keystone', - '643': 'Keystone', - '644': 'Keystone', - '645': 'Keystone', - '646': 'Keystone', - '647': 'Keystone', - '648': 'Keystone', - '649': 'Keystone', - '650': 'Keystone', - '651': 'Keystone', - '660': 'Keystone', - '661': 'Keystone', - '662': 'Keystone', - '663': 'Keystone', - // Pacific Surfliner - '761': 'Pacific Surfliner', - '762': 'Pacific Surfliner', - '763': 'Pacific Surfliner', - '764': 'Pacific Surfliner', - '765': 'Pacific Surfliner', - '766': 'Pacific Surfliner', - '767': 'Pacific Surfliner', - '768': 'Pacific Surfliner', - '769': 'Pacific Surfliner', - '770': 'Pacific Surfliner', - '771': 'Pacific Surfliner', - '772': 'Pacific Surfliner', - '773': 'Pacific Surfliner', - '774': 'Pacific Surfliner', - '775': 'Pacific Surfliner', - '776': 'Pacific Surfliner', - '777': 'Pacific Surfliner', - '778': 'Pacific Surfliner', - '779': 'Pacific Surfliner', - '780': 'Pacific Surfliner', - '781': 'Pacific Surfliner', - '782': 'Pacific Surfliner', - '783': 'Pacific Surfliner', - '784': 'Pacific Surfliner', - '785': 'Pacific Surfliner', - '786': 'Pacific Surfliner', - '787': 'Pacific Surfliner', - '788': 'Pacific Surfliner', - '789': 'Pacific Surfliner', - '790': 'Pacific Surfliner', - '791': 'Pacific Surfliner', - '792': 'Pacific Surfliner', - '793': 'Pacific Surfliner', - '794': 'Pacific Surfliner', - '795': 'Pacific Surfliner', - '796': 'Pacific Surfliner', - // Cascades - '500': 'Cascades', - '501': 'Cascades', - '502': 'Cascades', - '503': 'Cascades', - '504': 'Cascades', - '505': 'Cascades', - '506': 'Cascades', - '507': 'Cascades', - '508': 'Cascades', - '509': 'Cascades', - '510': 'Cascades', - '511': 'Cascades', - '512': 'Cascades', - '513': 'Cascades', - '514': 'Cascades', - '515': 'Cascades', - '516': 'Cascades', - '517': 'Cascades', - '518': 'Cascades', - '519': 'Cascades', - // Hiawatha - '329': 'Hiawatha', - '330': 'Hiawatha', - '331': 'Hiawatha', - '332': 'Hiawatha', - '333': 'Hiawatha', - '334': 'Hiawatha', - '335': 'Hiawatha', - '336': 'Hiawatha', - '337': 'Hiawatha', - '338': 'Hiawatha', - '339': 'Hiawatha', - '340': 'Hiawatha', - '341': 'Hiawatha', - '342': 'Hiawatha', - '343': 'Hiawatha', - '344': 'Hiawatha', - // San Joaquins - '701': 'San Joaquins', - '702': 'San Joaquins', - '703': 'San Joaquins', - '704': 'San Joaquins', - '705': 'San Joaquins', - '706': 'San Joaquins', - '707': 'San Joaquins', - '708': 'San Joaquins', - '709': 'San Joaquins', - '710': 'San Joaquins', - '711': 'San Joaquins', - '712': 'San Joaquins', - '713': 'San Joaquins', - '714': 'San Joaquins', - '715': 'San Joaquins', - '716': 'San Joaquins', - '717': 'San Joaquins', - '718': 'San Joaquins', - '719': 'San Joaquins', - '720': 'San Joaquins', - // Capitol Corridor - '521': 'Capitol Corridor', - '522': 'Capitol Corridor', - '523': 'Capitol Corridor', - '524': 'Capitol Corridor', - '525': 'Capitol Corridor', - '526': 'Capitol Corridor', - '527': 'Capitol Corridor', - '528': 'Capitol Corridor', - '529': 'Capitol Corridor', - '530': 'Capitol Corridor', - '531': 'Capitol Corridor', - '532': 'Capitol Corridor', - '533': 'Capitol Corridor', - '534': 'Capitol Corridor', - '535': 'Capitol Corridor', - '536': 'Capitol Corridor', - '537': 'Capitol Corridor', - '538': 'Capitol Corridor', - '539': 'Capitol Corridor', - '540': 'Capitol Corridor', - '541': 'Capitol Corridor', - '542': 'Capitol Corridor', - '543': 'Capitol Corridor', - '544': 'Capitol Corridor', - '545': 'Capitol Corridor', - '546': 'Capitol Corridor', - '547': 'Capitol Corridor', - '548': 'Capitol Corridor', - '549': 'Capitol Corridor', - '550': 'Capitol Corridor', - '551': 'Capitol Corridor', - '552': 'Capitol Corridor', - // Vermonter - '54': 'Vermonter', - '55': 'Vermonter', - '56': 'Vermonter', - '57': 'Vermonter', - // Ethan Allen Express - '290': 'Ethan Allen Express', - '291': 'Ethan Allen Express', - '292': 'Ethan Allen Express', - '293': 'Ethan Allen Express', - // Downeaster - '680': 'Downeaster', - '681': 'Downeaster', - '682': 'Downeaster', - '683': 'Downeaster', - '684': 'Downeaster', - '685': 'Downeaster', - '686': 'Downeaster', - '687': 'Downeaster', - '688': 'Downeaster', - '689': 'Downeaster', - '690': 'Downeaster', - '691': 'Downeaster', - '692': 'Downeaster', - '693': 'Downeaster', - '694': 'Downeaster', - '695': 'Downeaster', - // Adirondack - '68': 'Adirondack', - '69': 'Adirondack', - // Maple Leaf - '63': 'Maple Leaf', - '64': 'Maple Leaf', - // Wolverines - '350': 'Wolverine', - '351': 'Wolverine', - '352': 'Wolverine', - '353': 'Wolverine', - '354': 'Wolverine', - '355': 'Wolverine', - // 364/365 are shared between Wolverine and Blue Water depending on the day - '364': 'Wolverine / Blue Water', - '365': 'Wolverine / Blue Water', - // Pere Marquette - '370': 'Pere Marquette', - '371': 'Pere Marquette', - // Illini/Saluki - '390': 'Saluki', - '391': 'Saluki', - '392': 'Illini', - '393': 'Illini', - // Lincoln Service - '300': 'Lincoln Service', - '301': 'Lincoln Service', - '302': 'Lincoln Service', - '303': 'Lincoln Service', - '304': 'Lincoln Service', - '305': 'Lincoln Service', - '306': 'Lincoln Service', - '307': 'Lincoln Service', - '308': 'Lincoln Service', - '309': 'Lincoln Service', - '310': 'Lincoln Service', - // 311/313/314 are shared between Lincoln Service and Missouri River Runner - '311': 'Lincoln Service / Missouri River Runner', - '312': 'Lincoln Service', - '313': 'Lincoln Service / Missouri River Runner', - '314': 'Lincoln Service / Missouri River Runner', - '315': 'Lincoln Service', - '316': 'Missouri River Runner', - // Heartland Flyer - '821': 'Heartland Flyer', - '822': 'Heartland Flyer', -}; +function isNamespaced(id: string): boolean { + return id.includes(':'); +} -/** - * Get the route name for a train number - * Returns the named route (e.g., "Pennsylvanian") or null if not a named train - */ -function getRouteNameForTrainNumber(trainNumber: string): string | null { - return AMTRAK_ROUTE_NAMES[trainNumber] || null; +function toYMD(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; } +function isoToEpochMs(s: string | null | undefined): number | undefined { + if (!s) return undefined; + const t = Date.parse(s); + return Number.isFinite(t) ? t : undefined; +} + +function safeAwait(p: Promise, fallback: T): Promise { + return p.catch((err: unknown) => { + if (!(err instanceof ApiError && err.status === 404)) { + logger.warn(`[api] request failed`, err); + } + return fallback; + }); +} + +// ── Adapters: api types → existing types ────────────────────────────────── + +function adaptStop(s: ApiStop): Stop { + return { + stop_id: s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +function adaptStopTime(et: ApiEnrichedStopTime): EnrichedStopTime { + return { + trip_id: et.tripId, + arrival_time: et.arrivalTime ?? '', + departure_time: et.departureTime ?? et.arrivalTime ?? '', + stop_id: et.stopCode, + stop_sequence: et.stopSequence, + pickup_type: et.pickupType ?? undefined, + drop_off_type: et.dropOffType ?? undefined, + timepoint: et.timepoint == null ? undefined : et.timepoint ? 1 : 0, + stop_name: et.stopName, + stop_code: et.stopCode, + }; +} + +// ── Display name helpers ─────────────────────────────────────────────────── + /** - * Get display info for a train (route name and number formatted for display) - * Examples: "Pennsylvanian 43", "Acela 2151", "Amtrak 171" - * @param knownTrainNumber - Optional pre-resolved train number (e.g. from vehicle.id) + * Display info for a train. Reads from the in-memory route cache; if the + * routeId hasn't been fetched yet, kicks off a background fetch and falls + * back to a short label so the UI doesn't show empty space. */ export function getTrainDisplayName( - tripId: string, + tripIdOrTrainNumber: string, knownTrainNumber?: string, + knownRouteId?: string, ): { routeName: string | null; trainNumber: string; displayName: string; } { - const trainNumber = knownTrainNumber || extractTrainNumber(tripId) || ''; - - // First try the hardcoded mapping (covers named trains with friendly names) - let routeName = trainNumber ? getRouteNameForTrainNumber(trainNumber) : null; - - // If not in mapping, try to get from GTFS route data - if (!routeName) { - const routeId = gtfsParser.getRouteIdForTrip(tripId); - if (routeId) { - const route = gtfsParser.getRoute(routeId); - if (route?.route_long_name && route.route_long_name !== 'Unknown Route') { - routeName = route.route_long_name; - } - } + const trainNumber = + knownTrainNumber || extractTrainNumber(tripIdOrTrainNumber) || ''; + + let routeName: string | null = null; + if (knownRouteId) { + prefetchRoute(knownRouteId); + routeName = getCachedRoute(knownRouteId)?.longName ?? null; } const displayName = routeName ? `${routeName}${trainNumber ? ' ' + trainNumber : ''}` - : trainNumber ? `Amtrak ${trainNumber}` : 'Amtrak'; + : trainNumber + ? `Amtrak ${trainNumber}` + : 'Amtrak'; return { routeName, trainNumber, displayName }; } -export class TrainAPIService { - /** - * Search for trains, routes, and stations - */ - static async search(query: string): Promise { - try { - // In a real app, this would be an API call - // For now, use the local GTFS parser - return gtfsParser.search(query); - } catch (error) { - logger.error('Error searching:', error); - return []; - } +// ── Realtime helpers (read-only snapshot from ws-client) ─────────────────── + +function findPositionForTrain(args: { + tripId?: string; + trainNumber?: string; +}): ApiTrainPosition | undefined { + return wsClient.findPosition({ + provider: DEFAULT_PROVIDER, + tripId: args.tripId, + trainNumber: args.trainNumber, + }); +} + +function attachRealtime(train: Train): Train { + const pos = findPositionForTrain({ + tripId: train.tripId, + trainNumber: train.trainNumber, + }); + if (!pos) { + return { ...train, realtime: undefined }; } + return { + ...train, + realtime: { + position: + pos.lat != null && pos.lon != null ? { lat: pos.lat, lon: pos.lon } : undefined, + // delay/arrivalDelay are populated by /v1/runs/{...}/stops which isn't + // wired yet. Until then we leave them undefined; status text is computed + // from delay so it's also blank. + lastUpdated: isoToEpochMs(pos.lastUpdated), + }, + }; +} - /** - * Get all available routes - */ - static async getRoutes(): Promise { - try { - return gtfsParser.getAllRoutes(); - } catch (error) { - logger.error('Error fetching routes:', error); - return []; - } +// ── Trip → Train conversion ──────────────────────────────────────────────── + +async function buildTrainFromTrip( + trip: ApiTrip, + stops: EnrichedStopTime[], + effectiveDate?: Date, +): Promise { + if (stops.length === 0) { + logger.debug(`[api] buildTrainFromTrip(${trip.tripId}): no stop times`); + return null; } - /** - * Get all available stops/stations - */ - static async getStops(): Promise { - try { - return gtfsParser.getAllStops(); - } catch (error) { - logger.error('Error fetching stops:', error); - return []; - } + const firstStop = stops[0]; + const lastStop = stops[stops.length - 1]; + + // Warm route cache so display name resolves once it lands + prefetchRoute(trip.routeId); + const route = getCachedRoute(trip.routeId); + const routeName = route?.longName || route?.shortName || ''; + + const provider = trip.providerId || DEFAULT_PROVIDER; + const departFormatted = convertGtfsTimeForStop(firstStop.departure_time, firstStop.stop_id); + const arriveFormatted = convertGtfsTimeForStop(lastStop.arrival_time, lastStop.stop_id); + + const train: Train = { + id: simpleHash(trip.tripId), + operator: 'Amtrak', + trainNumber: trip.shortName || extractTrainNumber(trip.tripId) || '', + from: firstStop.stop_name, + to: lastStop.stop_name, + fromCode: `${provider}:${firstStop.stop_id}`, + toCode: `${provider}:${lastStop.stop_id}`, + departTime: departFormatted.time, + arriveTime: arriveFormatted.time, + departDayOffset: departFormatted.dayOffset, + arriveDayOffset: arriveFormatted.dayOffset, + date: effectiveDate ? getDaysAwayLabel(calculateDaysAway(effectiveDate)) : 'Today', + daysAway: effectiveDate ? calculateDaysAway(effectiveDate) : 0, + travelDate: effectiveDate ? effectiveDate.getTime() : undefined, + routeName, + tripId: trip.tripId, + intermediateStops: stops.slice(1, -1).map(stop => { + const formatted = convertGtfsTimeForStop(stop.departure_time, stop.stop_id); + return { + time: formatted.time, + name: stop.stop_name, + code: `${provider}:${stop.stop_id}`, + }; + }), + }; + + if (train.daysAway <= 0) { + return attachRealtime(train); } + return train; +} + +/** + * Fallback when /v1/trips/{tripId}/stops is unavailable: build a Train + * carrying just the user's stop time (from the DepartureItem row). + * + * The DepartureBoardModal looks up the time at this station via + * intermediateStops[].code; we synthesize a single intermediate so that + * lookup still succeeds. from/to are blank since we don't know the trip's + * actual origin or destination without /v1/trips/{tripId}/stops. + */ +function buildMinimalTrainFromDeparture( + d: ApiDepartureItem, + userStopCode: string, + effectiveDate: Date, +): Train { + prefetchRoute(d.routeId); + const route = getCachedRoute(d.routeId); + const routeName = route?.longName || route?.shortName || ''; + + const provider = d.providerId || DEFAULT_PROVIDER; + const userTime = + d.departureTime != null + ? convertGtfsTimeForStop(d.departureTime, userStopCode) + : d.arrivalTime != null + ? convertGtfsTimeForStop(d.arrivalTime, userStopCode) + : { time: '', dayOffset: 0 }; + const train: Train = { + id: simpleHash(d.tripId), + operator: 'Amtrak', + trainNumber: d.shortName || extractTrainNumber(d.tripId) || '', + from: '', + to: d.headsign || '', + fromCode: `${provider}:${userStopCode}`, + toCode: '', + departTime: userTime.time, + arriveTime: userTime.time, + departDayOffset: userTime.dayOffset, + arriveDayOffset: userTime.dayOffset, + date: getDaysAwayLabel(calculateDaysAway(effectiveDate)), + daysAway: calculateDaysAway(effectiveDate), + travelDate: effectiveDate.getTime(), + routeName, + tripId: d.tripId, + intermediateStops: [ + { time: userTime.time, name: userStopCode, code: `${provider}:${userStopCode}` }, + ], + }; + + if (train.daysAway <= 0) return attachRealtime(train); + return train; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +export class TrainAPIService { /** - * Get train details for a specific trip + * Get train details for a specific trip. tripId may be: + * - the namespaced API id (e.g. "amtrak:5_2026-05-08") + * - a bare train number (e.g. "5") — resolved via lookupTrips */ - static async getTrainDetails(tripId: string, date?: Date, knownTrainNumber?: string): Promise { + static async getTrainDetails( + tripId: string, + date?: Date, + knownTrainNumber?: string, + ): Promise { try { - let stopTimes = gtfsParser.getStopTimesForTrip(tripId); - let resolvedTripId = tripId; + let trip: ApiTrip | null = null; + + if (isNamespaced(tripId)) { + trip = await safeAwait(getTrip(tripId), null); + } - // If direct lookup failed, resolve via train number + date - // This handles GTFS-RT trip_ids that differ from static GTFS trip_ids - if (stopTimes.length === 0) { + if (!trip) { const trainNumber = knownTrainNumber || extractTrainNumber(tripId); if (!trainNumber) { - logger.debug(`[API] getTrainDetails(${tripId}): cannot extract train number`); + logger.debug(`[api] getTrainDetails(${tripId}): cannot extract train number`); return null; } const inferredDate = date ?? extractDateFromTripId(tripId) ?? new Date(); - const trip = gtfsParser.getTripForTrainOnDate(trainNumber, inferredDate); - if (trip) { - resolvedTripId = trip.trip_id; - stopTimes = gtfsParser.getStopTimesForTrip(resolvedTripId); - } + const trips = await safeAwait( + lookupTrips({ + provider: DEFAULT_PROVIDER, + trainNumber, + date: toYMD(inferredDate), + }), + [], + ); + trip = trips[0] ?? null; } - if (stopTimes.length === 0) { - logger.debug(`[API] getTrainDetails(${tripId}): no stop times found`); + if (!trip) { + logger.debug(`[api] getTrainDetails(${tripId}): no matching trip`); return null; } - const firstStop = stopTimes[0]; - const lastStop = stopTimes[stopTimes.length - 1]; - - // Get proper train number and route name - const { routeName, trainNumber } = getTrainDisplayName(resolvedTripId, knownTrainNumber || undefined); - - // Format times with day offset info, converting to each stop's local timezone - const departFormatted = convertGtfsTimeForStop(firstStop.departure_time, firstStop.stop_id); - const arriveFormatted = convertGtfsTimeForStop(lastStop.arrival_time, lastStop.stop_id); - - // Infer departure date from trip ID when no explicit date provided - const effectiveDate = date ?? extractDateFromTripId(resolvedTripId) ?? undefined; - - const train: Train = { - id: simpleHash(resolvedTripId), - operator: 'Amtrak', - trainNumber: trainNumber, - from: firstStop.stop_name, - to: lastStop.stop_name, - fromCode: firstStop.stop_id, - toCode: lastStop.stop_id, - departTime: departFormatted.time, - arriveTime: arriveFormatted.time, - departDayOffset: departFormatted.dayOffset, - arriveDayOffset: arriveFormatted.dayOffset, - date: effectiveDate ? getDaysAwayLabel(calculateDaysAway(effectiveDate)) : 'Today', - daysAway: effectiveDate ? calculateDaysAway(effectiveDate) : 0, - travelDate: effectiveDate ? effectiveDate.getTime() : undefined, - routeName: routeName || '', - tripId: resolvedTripId, - intermediateStops: stopTimes.slice(1, -1).map(stop => { - const formatted = convertGtfsTimeForStop(stop.departure_time, stop.stop_id); - return { - time: formatted.time, - name: stop.stop_name, - code: stop.stop_id, - }; - }), - }; - - // Fetch real-time data only for today's trains - if (train.daysAway <= 0) { - await this.enrichWithRealtimeData(train); - } + const apiStops = await safeAwait(getTripStops(trip.tripId), []); + const stops = apiStops.map(adaptStopTime); - return train; + const effectiveDate = date ?? extractDateFromTripId(trip.tripId) ?? undefined; + return buildTrainFromTrip(trip, stops, effectiveDate); } catch (error) { logger.error('Error fetching train details:', error); return null; @@ -492,72 +333,108 @@ export class TrainAPIService { } /** - * Enrich a train object with real-time position and delay data - */ - private static async enrichWithRealtimeData(train: Train): Promise { - try { - const tripKey = train.trainNumber || train.tripId || ''; - const [position, delay, arrivalDelay] = await Promise.all([ - RealtimeService.getPositionForTrip(tripKey), - RealtimeService.getDelayForStop(tripKey, train.fromCode), - RealtimeService.getArrivalDelayForStop(tripKey, train.toCode), - ]); - - train.realtime = { - position: position ? { lat: position.latitude, lon: position.longitude } : undefined, - delay: delay ?? undefined, - arrivalDelay: arrivalDelay ?? undefined, - status: RealtimeService.formatDelay(delay), - lastUpdated: position?.timestamp, - }; - } catch (realtimeError) { - logger.warn('Could not fetch real-time data:', realtimeError); - } - } - - /** - * Get trains for a specific station + * All trains arriving/departing at a stop on a given date. Issues a + * fan-out fetch of /v1/trips/{tripId}/stops per departure to populate + * origin/destination/intermediate stops; if that fan-out fails for a + * particular trip, we still return a minimal Train (with the user's + * stop populated from the departure row) so the list never silently + * empties on a partial backend. + * + * The trip-stops endpoint is cached for 1h, so repeated views of busy + * stations are cheap. */ static async getTrainsForStation(stopId: string, date?: Date): Promise { try { - const tripIds = gtfsParser.getTripsForStop(stopId, date); - logger.debug(`[API] getTrainsForStation(${stopId}): ${tripIds.length} trip IDs`); - const trains = await Promise.all(tripIds.map(tripId => this.getTrainDetails(tripId, date))); - return trains.filter((train): train is Train => train !== null); + const effectiveDate = date ?? new Date(); + const provider = stopId.includes(':') ? stopId.split(':', 1)[0] : DEFAULT_PROVIDER; + const namespaced = stopId.includes(':') ? stopId : `${provider}:${stopId}`; + const userStopCode = stopId.includes(':') ? stopId.split(':')[1] : stopId; + + const departures = await safeAwait( + getDepartures({ stopId: namespaced, date: toYMD(effectiveDate) }), + [], + ); + logger.debug(`[api] getTrainsForStation(${stopId}): ${departures.length} departures`); + + const trains = await Promise.all( + departures.map(async (d): Promise => { + const apiStops = await safeAwait( + getTripStops(d.tripId), + [], + ); + const stops = apiStops.map(adaptStopTime); + + const trip: ApiTrip = { + providerId: d.providerId, + tripId: d.tripId, + routeId: d.routeId, + serviceId: d.serviceId, + shortName: d.shortName, + headsign: d.headsign, + shapeId: d.shapeId, + directionId: d.directionId, + }; + + if (stops.length > 0) { + const train = await buildTrainFromTrip(trip, stops, effectiveDate); + if (train) return train; + } + + return buildMinimalTrainFromDeparture(d, userStopCode, effectiveDate); + }), + ); + return trains; } catch (error) { logger.error('Error fetching trains for station:', error); return []; } } - /** - * Get stop times for a specific trip - */ + /** Stop times for a trip — used by storage rehydration of segmented trips. */ static async getStopTimesForTrip(tripId: string): Promise { try { - return gtfsParser.getStopTimesForTrip(tripId); + const apiStops = await getTripStops(tripId); + return apiStops.map(adaptStopTime); } catch (error) { logger.error('Error fetching stop times:', error); return []; } } + /** Look up a stop by its raw code (assumes Amtrak until multi-provider). */ + static async getStop(stopId: string): Promise { + try { + const code = stopId.includes(':') ? stopId.split(':')[1] : stopId; + const provider = stopId.includes(':') ? stopId.split(':')[0] : DEFAULT_PROVIDER; + const apiStop = await getStop(provider, code); + return adaptStop(apiStop); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + logger.error('Error fetching stop:', error); + return null; + } + } + /** - * Refresh real-time data for a train - * Skips realtime enrichment for future trains (daysAway > 0) to avoid - * matching a saved future train to today's live train with the same number + * Re-attach realtime snapshot data to a saved train. The new flow doesn't + * poll — the WebSocket pushes — so this is essentially a sync read. + * Future trains skip realtime to avoid matching today's same-numbered run. */ static async refreshRealtimeData(train: Train): Promise { if (!train.tripId && !train.trainNumber) return train; - - // Don't fetch realtime data for trains not running today - if (train.daysAway > 0) { - return { ...train, realtime: undefined }; - } - - const updatedTrain = { ...train }; - await this.enrichWithRealtimeData(updatedTrain); - return updatedTrain; + if (train.daysAway > 0) return { ...train, realtime: undefined }; + return attachRealtime(train); } - } + +// Backwards-compat shim — getRoutes/getStops aren't exposed by the API. +// useFrequentlyUsed should be reworked to use search; until then, this file's +// callers will see an empty list and a warning. +export const _legacyAPIWarning = () => { + logger.warn( + '[api] TrainAPIService.getRoutes/getStops are removed; use api-client search/lookups instead', + ); +}; + +// formatDateForDisplay is re-exported for callers that imported it transitively. +export { formatDateForDisplay }; diff --git a/apps/mobile/services/calendar-sync.ts b/apps/mobile/services/calendar-sync.ts index c25d80d..b4434e0 100644 --- a/apps/mobile/services/calendar-sync.ts +++ b/apps/mobile/services/calendar-sync.ts @@ -1,19 +1,20 @@ /** - * Calendar sync service for importing trips from device calendars. - * Scans for events like "Train to Philadelphia" and matches them against GTFS data. + * Calendar sync — DISABLED during the GTFS → API migration. + * + * The original matching loop relied on local stop_times/calendar joins + * (gtfsParser.findTripsWithStops, getTripsForStop, getStopTimesForTrip, + * searchStations, etc.). The API equivalents (/v1/connections, + * /v1/departures, /v1/search) cover most of it but the port hasn't been + * done yet. Re-enable in a follow-up — see git history for the original + * implementation. + * + * Permission/listing helpers stay functional so the Settings UI still + * renders without errors; the two `sync*` entrypoints return an empty + * SyncResult. */ import * as Calendar from 'expo-calendar'; import { Platform } from 'react-native'; -import type { CompletedTrip, SavedTrainRef } from '../types/train'; -import { formatDateForDisplay } from '../utils/date-helpers'; -import { gtfsParser } from '../utils/gtfs-parser'; import { logger } from '../utils/logger'; -import { haversineDistance } from '../utils/distance'; -import { formatTime, parseTimeToMinutes } from '../utils/time-formatting'; -import { getMinutesInTimezone } from '../utils/timezone'; -import { stationLoader } from './station-loader'; -import { TrainStorageService } from './storage'; - export interface DeviceCalendar { id: string; @@ -34,48 +35,20 @@ export interface SyncResult { added: number; skipped: number; addedTrips: AddedTripInfo[]; - /** Total events returned by the calendar API (before pattern filtering) */ totalCalendarEvents?: number; - /** Short reason if parsed is 0 */ failReason?: 'gtfs_not_loaded' | 'no_calendar_events' | 'no_pattern_match'; } -interface MatchedTrip { - tripId: string; - fromStopId: string; - fromStopName: string; - toStopId: string; - toStopName: string; - departTime: string; - arriveTime: string; - trainNumber: string; - routeName: string; - eventDate: Date; -} - -const TRAIN_EVENT_PATTERN = /train\s+to\s+(.+)/i; -const TIME_TOLERANCE_MINUTES = 15; - -/** - * Request calendar read permission from the user. - * Returns true if granted. - */ export async function requestCalendarPermission(): Promise { const { status } = await Calendar.requestCalendarPermissionsAsync(); return status === 'granted'; } -/** - * Check if calendar permission is already granted. - */ export async function hasCalendarPermission(): Promise { const { status } = await Calendar.getCalendarPermissionsAsync(); return status === 'granted'; } -/** - * Get list of device calendars for the user to pick from. - */ export async function getDeviceCalendars(): Promise { const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); return calendars.map(cal => ({ @@ -83,510 +56,35 @@ export async function getDeviceCalendars(): Promise { title: cal.title, color: cal.color ?? '#999999', source: - Platform.OS === 'ios' ? (cal.source?.name ?? 'Unknown') : (cal.source?.name ?? cal.accessLevel ?? 'Unknown'), + Platform.OS === 'ios' + ? (cal.source?.name ?? 'Unknown') + : (cal.source?.name ?? cal.accessLevel ?? 'Unknown'), })); } -/** - * Parse GTFS 24h time string (e.g. "14:30:00") to minutes since midnight. - */ -function gtfsTimeToMinutes(gtfsTime: string): number { - const parts = gtfsTime.split(':'); - const h = parseInt(parts[0], 10); - const m = parseInt(parts[1], 10); - return h * 60 + m; -} - -/** - * Search for a station, trying full name first then stripping trailing state abbreviation. - */ -function resolveStation(name: string) { - let stations = gtfsParser.searchStations(name); - if (stations.length === 0) { - const withoutState = name.replace(/\s+[A-Za-z]{2}$/, '').trim(); - if (withoutState !== name && withoutState.length > 0) { - stations = gtfsParser.searchStations(withoutState); - } - } - return stations.length > 0 ? stations[0] : null; -} - -/** - * Format a Date into "h:mm AM/PM" display string. - */ -function formatDateToAmPm(date: Date): string { - let h = date.getHours(); - const m = String(date.getMinutes()).padStart(2, '0'); - const ampm = h >= 12 ? 'PM' : 'AM'; - h = h % 12 || 12; - return `${h}:${m} ${ampm}`; -} - -/** - * Extract a CompletedTrip directly from a calendar event without GTFS validation. - * Used when matchGtfs is false — blindly trusts calendar data. - */ -function extractTripFromEvent(event: Calendar.Event): CompletedTrip | null { - const match = event.title.match(TRAIN_EVENT_PATTERN); - if (!match) return null; - - const destination = match[1].trim(); - const startDate = new Date(event.startDate); - const endDate = event.endDate ? new Date(event.endDate) : null; - const eventDate = new Date(startDate); - eventDate.setHours(0, 0, 0, 0); - - const originLocation = event.location?.trim() || ''; - - // Best-effort station name/code resolution (won't fail if GTFS not loaded) - const destStation = gtfsParser.isLoaded ? resolveStation(destination) : null; - const originStation = originLocation && gtfsParser.isLoaded ? resolveStation(originLocation) : null; - - let duration: number | undefined; - if (endDate) { - duration = Math.round((endDate.getTime() - startDate.getTime()) / 60000); - if (duration <= 0) duration = undefined; - } - - let distance: number | undefined; - if (originStation && destStation) { - try { - const fromStn = stationLoader.getStationByCode(originStation.stop_id); - const toStn = stationLoader.getStationByCode(destStation.stop_id); - if (fromStn && toStn) { - distance = haversineDistance(fromStn.lat, fromStn.lon, toStn.lat, toStn.lon); - } - } catch { /* best effort */ } - } - - // Synthetic deterministic trip ID based on event content - const tripId = `cal-${eventDate.getTime()}-${destination.toLowerCase().replace(/[^a-z0-9]/g, '')}`; - - return { - tripId, - trainNumber: '', - routeName: '', - from: originStation?.stop_name ?? originLocation, - to: destStation?.stop_name ?? destination, - fromCode: originStation?.stop_id ?? '', - toCode: destStation?.stop_id ?? '', - departTime: formatDateToAmPm(startDate), - arriveTime: endDate ? formatDateToAmPm(endDate) : '', - date: formatDateForDisplay(eventDate), - travelDate: eventDate.getTime(), - completedAt: Date.now(), - duration, - distance, - syncedFromCalendar: true, - }; -} - -/** - * Normalize a past date to the same day-of-week in the current week. - * This allows GTFS lookups to succeed for older rides, since the current - * GTFS cache only covers current/near-future dates but train schedules - * are consistent week-to-week. - */ -function normalizeToCurrentWeek(date: Date): Date { - const now = new Date(); - const today = new Date(now); - today.setHours(0, 0, 0, 0); - const currentDayOfWeek = today.getDay(); - const targetDayOfWeek = date.getDay(); - const diff = targetDayOfWeek - currentDayOfWeek; - const normalized = new Date(today); - normalized.setDate(today.getDate() + diff); - return normalized; -} - -/** - * Match a single calendar event against GTFS data. - * Uses event location as origin station and title destination. - * Returns the matched trip info or null if no match found. - */ -function matchEventToTrip(eventTitle: string, eventStartDate: Date, eventLocation?: string): MatchedTrip | null { - const match = eventTitle.match(TRAIN_EVENT_PATTERN); - if (!match) return null; - - const destination = match[1].trim(); - const eventDate = new Date(eventStartDate); - eventDate.setHours(0, 0, 0, 0); - const gtfsLookupDate = normalizeToCurrentWeek(eventDate); - - const destStation = resolveStation(destination); - if (!destStation) { - logger.info(`Calendar sync: no station found for destination "${destination}"`); - return null; - } - logger.info(`Calendar sync: destination "${destination}" → "${destStation.stop_name}" (${destStation.stop_id})`); - - // If event has a location, use it as the origin station - const originLocation = eventLocation?.trim(); - if (originLocation) { - const originStation = resolveStation(originLocation); - if (originStation) { - logger.info( - `Calendar sync: origin "${originLocation}" → "${originStation.stop_name}" (${originStation.stop_id})` - ); - - // GTFS times are in the agency timezone — convert event time to that timezone - const eventMinutesAtOrigin = getMinutesInTimezone(eventStartDate, gtfsParser.agencyTimezone); - - // Use findTripsWithStops for precise origin→destination matching - const trips = gtfsParser.findTripsWithStops(originStation.stop_id, destStation.stop_id, gtfsLookupDate); - logger.info( - `Calendar sync: ${trips.length} trips from ${originStation.stop_id} to ${destStation.stop_id} on ${eventDate.toLocaleDateString()}` - ); - - for (const trip of trips) { - const departMinutes = gtfsTimeToMinutes(trip.fromStop.departure_time); - if (Math.abs(departMinutes - eventMinutesAtOrigin) <= TIME_TOLERANCE_MINUTES) { - const trainNumber = gtfsParser.getTrainNumber(trip.tripId) || ''; - const routeId = gtfsParser.getRouteIdForTrip(trip.tripId); - const routeName = routeId ? gtfsParser.getRouteName(routeId) : 'Unknown Route'; - - return { - tripId: trip.tripId, - fromStopId: trip.fromStop.stop_id, - fromStopName: trip.fromStop.stop_name, - toStopId: trip.toStop.stop_id, - toStopName: trip.toStop.stop_name, - departTime: formatTime(trip.fromStop.departure_time), - arriveTime: formatTime(trip.toStop.arrival_time), - trainNumber, - routeName, - eventDate, - }; - } - } - } else { - logger.info(`Calendar sync: no station found for origin "${originLocation}", falling back to time matching`); - } - } - - // Fallback: no location or origin not found — infer origin by matching departure time at any stop - const tripIds = gtfsParser.getTripsForStop(destStation.stop_id, gtfsLookupDate); - logger.info( - `Calendar sync: fallback — ${tripIds.length} trips at ${destStation.stop_id}` - ); - - for (const tripId of tripIds) { - const stopTimes = gtfsParser.getStopTimesForTrip(tripId); - if (stopTimes.length < 2) continue; - - for (const stop of stopTimes) { - // GTFS times are in the agency timezone — convert event time to that timezone - const eventMinutesAtStop = getMinutesInTimezone(eventStartDate, gtfsParser.agencyTimezone); - const stopMinutes = gtfsTimeToMinutes(stop.departure_time); - if (Math.abs(stopMinutes - eventMinutesAtStop) <= TIME_TOLERANCE_MINUTES) { - const destStopTime = stopTimes.find(s => s.stop_id === destStation.stop_id); - if (!destStopTime) continue; - if (stop.stop_sequence >= destStopTime.stop_sequence) continue; - - const trainNumber = gtfsParser.getTrainNumber(tripId) || ''; - const routeId = gtfsParser.getRouteIdForTrip(tripId); - const routeName = routeId ? gtfsParser.getRouteName(routeId) : 'Unknown Route'; - - return { - tripId, - fromStopId: stop.stop_id, - fromStopName: stop.stop_name, - toStopId: destStopTime.stop_id, - toStopName: destStopTime.stop_name, - departTime: formatTime(stop.departure_time), - arriveTime: formatTime(destStopTime.arrival_time), - trainNumber, - routeName, - eventDate, - }; - } - } - } - - return null; -} - -/** - * Fetch events from the Calendar API, chunking into 6-month intervals - * to avoid iOS EventKit silently truncating results on large date ranges. - */ -async function fetchCalendarEventsChunked( - calendarIds: string[], - startDate: Date, - endDate: Date -): Promise { - const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000; - const rangeMs = endDate.getTime() - startDate.getTime(); - - // Small range — single fetch is fine - if (rangeMs <= SIX_MONTHS_MS) { - return Calendar.getEventsAsync(calendarIds, startDate, endDate); - } - - // Large range — chunk into 6-month windows - const allEvents: Calendar.Event[] = []; - const seenIds = new Set(); - let chunkStart = new Date(startDate); - - while (chunkStart.getTime() < endDate.getTime()) { - const chunkEnd = new Date(Math.min(chunkStart.getTime() + SIX_MONTHS_MS, endDate.getTime())); - const chunk = await Calendar.getEventsAsync(calendarIds, chunkStart, chunkEnd); - for (const event of chunk) { - const eid = event.id ?? `${event.title}-${event.startDate}`; - if (!seenIds.has(eid)) { - seenIds.add(eid); - allEvents.push(event); - } - } - logger.info(`Calendar sync: chunk ${chunkStart.toLocaleDateString()}–${chunkEnd.toLocaleDateString()}: ${chunk.length} events`); - chunkStart = chunkEnd; - } - - return allEvents; -} - -/** - * Fetch train events from calendars within a date range. - */ -async function fetchTrainEvents( - calendarIds: string[], - startDate: Date, - endDate: Date -): Promise<{ matched: Calendar.Event[]; totalEvents: number }> { - logger.info( - `Calendar sync: fetching from ${calendarIds.length} calendar(s), ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}` - ); - const events = await fetchCalendarEventsChunked(calendarIds, startDate, endDate); - logger.info(`Calendar sync: ${events.length} total events found`); - - // Log first few event titles for debugging when no matches - if (events.length > 0 && events.length <= 20) { - for (const e of events) { - logger.info(`Calendar sync: event: "${e.title}"`); - } - } else if (events.length > 20) { - for (let i = 0; i < 10; i++) { - logger.info(`Calendar sync: event: "${events[i].title}"`); - } - logger.info(`Calendar sync: ... and ${events.length - 10} more`); - } - - const matched: Calendar.Event[] = []; - for (const e of events) { - if (TRAIN_EVENT_PATTERN.test(e.title)) { - logger.info(`Calendar sync: matched "${e.title}"`); - matched.push(e); - } - } - logger.info(`Calendar sync: ${matched.length}/${events.length} matched train pattern`); - return { matched, totalEvents: events.length }; -} - -/** - * Sync past trips — scans selected calendars for past train events - * and adds matched trips to history. - */ -export async function syncPastTrips(calendarIds: string[], scanDays: number, matchGtfs: boolean = false): Promise { - const result: SyncResult = { parsed: 0, matched: 0, added: 0, skipped: 0, addedTrips: [] }; - - // When matchGtfs is enabled, require GTFS data for precise trip matching - if (matchGtfs && !gtfsParser.isLoaded) { - logger.error('Calendar sync: GTFS data not loaded — cannot sync'); - result.failReason = 'gtfs_not_loaded'; - return result; - } - - const now = new Date(); - const endDate = new Date(now); - if (scanDays === -1) { - // "All" — include through today - endDate.setHours(23, 59, 59, 999); - } else { - endDate.setDate(endDate.getDate() - 1); - endDate.setHours(23, 59, 59, 999); - } - - const startDate = new Date(now); - if (scanDays === -1) { - // "All" option - scan as far back as possible - startDate.setFullYear(startDate.getFullYear() - 10); - } else { - startDate.setDate(startDate.getDate() - scanDays); - } - startDate.setHours(0, 0, 0, 0); - - const { matched: trainEvents, totalEvents } = await fetchTrainEvents(calendarIds, startDate, endDate); - result.parsed = trainEvents.length; - result.totalCalendarEvents = totalEvents; - if (trainEvents.length === 0) { - result.failReason = totalEvents === 0 ? 'no_calendar_events' : 'no_pattern_match'; - return result; - } - - const existingHistory = await TrainStorageService.getTripHistory(); - const existingKeys = new Set(existingHistory.map(h => `${h.tripId}|${h.fromCode}|${h.toCode}|${h.travelDate}`)); - - for (const event of trainEvents) { - let entry: CompletedTrip | null; - - if (!matchGtfs) { - // Trust calendar data directly — no GTFS cross-reference - entry = extractTripFromEvent(event); - } else { - // Full GTFS matching path - const matched = matchEventToTrip(event.title, new Date(event.startDate), event.location ?? undefined); - if (!matched) { continue; } - - // Calculate duration from times - let duration: number | undefined; - try { - const departMinutes = parseTimeToMinutes(matched.departTime); - const arriveMinutes = parseTimeToMinutes(matched.arriveTime); - duration = arriveMinutes - departMinutes; - if (duration < 0) { - duration += 24 * 60; - } - } catch (error) { - logger.error('Calendar sync: Error calculating duration:', error); - } - - // Calculate distance as the crow flies using station coordinates - let distance: number | undefined; - try { - const fromStation = stationLoader.getStationByCode(matched.fromStopId); - const toStation = stationLoader.getStationByCode(matched.toStopId); - if (fromStation && toStation) { - distance = haversineDistance(fromStation.lat, fromStation.lon, toStation.lat, toStation.lon); - } - } catch (error) { - logger.error('Calendar sync: Error calculating distance:', error); - } - - // Use a stable trip ID derived from matched train info rather than the - // volatile GTFS trip ID, which changes when timetable data is refreshed. - // This prevents duplicate history entries when re-syncing after a GTFS update. - const stableTripId = `cal-${matched.eventDate.getTime()}-${matched.trainNumber || matched.fromStopId}-${matched.toStopId}`; - - entry = { - tripId: stableTripId, - trainNumber: matched.trainNumber, - routeName: matched.routeName, - from: matched.fromStopName, - to: matched.toStopName, - fromCode: matched.fromStopId, - toCode: matched.toStopId, - departTime: matched.departTime, - arriveTime: matched.arriveTime, - date: formatDateForDisplay(matched.eventDate), - travelDate: matched.eventDate.getTime(), - completedAt: Date.now(), - duration, - distance, - syncedFromCalendar: true, - }; - } - - if (!entry) continue; - - const key = `${entry.tripId}|${entry.fromCode}|${entry.toCode}|${entry.travelDate}`; - result.matched++; - - if (existingKeys.has(key)) { - result.skipped++; - } else { - const added = await TrainStorageService.addToHistory(entry); - if (added) { - result.added++; - existingKeys.add(key); - result.addedTrips.push({ - from: entry.from, - to: entry.to, - date: entry.date, - }); - } else { - result.skipped++; - } - } - } - - return result; -} - -/** - * Sync future trips — scans calendars for upcoming train events - * and adds matched trips to saved trains (My Trains). - * Called automatically on app load. - */ -export async function syncFutureTrips(calendarIds: string[], matchGtfs: boolean = false): Promise { - const result: SyncResult = { parsed: 0, matched: 0, added: 0, skipped: 0, addedTrips: [] }; - - // Future trips are stored as SavedTrainRef which requires a valid GTFS trip ID - // for reconstruction. Skip when GTFS matching is disabled. - if (!matchGtfs) { - logger.info('Calendar sync (future): skipped — GTFS matching disabled'); - return result; - } - - if (!gtfsParser.isLoaded) { - logger.error('Calendar sync (future): GTFS data not loaded — cannot sync'); - result.failReason = 'gtfs_not_loaded'; - return result; - } - - const now = new Date(); - const startDate = new Date(now); - startDate.setHours(0, 0, 0, 0); - - const endDate = new Date(now); - endDate.setDate(endDate.getDate() + 90); - endDate.setHours(23, 59, 59, 999); - - const { matched: trainEvents, totalEvents } = await fetchTrainEvents(calendarIds, startDate, endDate); - result.parsed = trainEvents.length; - result.totalCalendarEvents = totalEvents; - if (trainEvents.length === 0) { - result.failReason = totalEvents === 0 ? 'no_calendar_events' : 'no_pattern_match'; - return result; - } - - // Load existing saved trains for dedup - const existingRefs = await TrainStorageService.getSavedTrainRefs(); - const existingKeys = new Set( - existingRefs.map(r => `${r.tripId}|${r.fromCode ?? ''}|${r.toCode ?? ''}|${r.travelDate ?? 0}`) - ); - - for (const event of trainEvents) { - const matched = matchEventToTrip(event.title, new Date(event.startDate), event.location ?? undefined); - if (!matched) continue; - - result.matched++; - - const ref: SavedTrainRef = { - tripId: matched.tripId, - fromCode: matched.fromStopId, - toCode: matched.toStopId, - travelDate: matched.eventDate.getTime(), - savedAt: Date.now(), - }; - - const key = `${ref.tripId}|${ref.fromCode ?? ''}|${ref.toCode ?? ''}|${ref.travelDate ?? 0}`; - if (existingKeys.has(key)) { - result.skipped++; - } else { - const saved = await TrainStorageService.saveTrainRef(ref); - if (saved) { - result.added++; - existingKeys.add(key); - result.addedTrips.push({ - from: matched.fromStopName, - to: matched.toStopName, - date: formatDateForDisplay(matched.eventDate), - }); - } else { - result.skipped++; - } - } - } - - return result; +const DISABLED_RESULT: SyncResult = { + parsed: 0, + matched: 0, + added: 0, + skipped: 0, + addedTrips: [], + totalCalendarEvents: 0, + failReason: 'gtfs_not_loaded', +}; + +export async function syncPastTrips( + _calendarIds: string[], + _scanDays: number, + _matchGtfs: boolean = false, +): Promise { + logger.info('Calendar sync (past): disabled during GTFS → API migration'); + return DISABLED_RESULT; +} + +export async function syncFutureTrips( + _calendarIds: string[], + _matchGtfs: boolean = false, +): Promise { + logger.info('Calendar sync (future): disabled during GTFS → API migration'); + return DISABLED_RESULT; } diff --git a/apps/mobile/services/gtfs-sync.ts b/apps/mobile/services/gtfs-sync.ts deleted file mode 100644 index 7705c4a..0000000 --- a/apps/mobile/services/gtfs-sync.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * GTFS weekly sync service - * - Checks freshness (3 days) - * - Fetches GTFS.zip from Amtrak - * - Unzips in memory (fflate) and parses CSVs - * - Caches parsed JSON as compressed files on the filesystem - * - Applies cached data to the GTFS parser - */ - -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { File, Directory, Paths } from 'expo-file-system'; -import { strFromU8, strToU8, unzipSync, zlibSync, unzlibSync } from 'fflate'; -import type { CalendarDateException, CalendarEntry, Route, Shape, Stop, StopTime, Trip } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { shapeLoader } from './shape-loader'; -import { logger } from '../utils/logger'; -import { fetchWithTimeout } from '../utils/fetch-with-timeout'; - -const GTFS_URL = 'https://content.amtrak.com/content/gtfs/GTFS.zip'; - -const cacheDir = new Directory(Paths.document, 'gtfs-cache'); - -const CACHE_FILES = { - routes: new File(cacheDir, 'routes.json.z'), - stops: new File(cacheDir, 'stops.json.z'), - stopTimes: new File(cacheDir, 'stop_times.json.z'), - shapes: new File(cacheDir, 'shapes.json.z'), - trips: new File(cacheDir, 'trips.json.z'), - calendar: new File(cacheDir, 'calendar.json.z'), - calendarDates: new File(cacheDir, 'calendar_dates.json.z'), - agencyTimezone: new File(cacheDir, 'agency_timezone.txt'), -}; - -const STORAGE_KEYS = { - LAST_FETCH: 'GTFS_LAST_FETCH', -}; - -// Old AsyncStorage keys to clean up during migration -const LEGACY_STORAGE_KEYS = [ - 'GTFS_ROUTES_JSON', 'GTFS_STOPS_JSON', 'GTFS_STOP_TIMES_JSON', - 'GTFS_SHAPES_JSON', 'GTFS_TRIPS_JSON', 'GTFS_CALENDAR_JSON', - 'GTFS_CALENDAR_DATES_JSON', 'GTFS_AGENCY_TIMEZONE', -]; - -function isOlderThanDays(dateMs: number, days: number): boolean { - const now = Date.now(); - const ms = days * 24 * 60 * 60 * 1000; - return now - dateMs > ms; -} - -function ensureCacheDirSync() { - if (!cacheDir.exists) { - cacheDir.create({ intermediates: true }); - } -} - -async function ensureCacheDir() { - ensureCacheDirSync(); - // One-time migration: remove old AsyncStorage GTFS keys to free space - try { - await AsyncStorage.multiRemove(LEGACY_STORAGE_KEYS); - } catch { - // Ignore — keys may already be gone - } -} - -/** Write JSON data as zlib-compressed file */ -function writeCompressedJSON(file: File, data: unknown): void { - const json = JSON.stringify(data); - const compressed = zlibSync(strToU8(json)); - if (file.exists) { file.delete(); } - file.create(); - file.write(compressed); -} - -/** Read zlib-compressed file and parse as JSON (sync I/O — faster than async bridge roundtrip) */ -function readCompressedJSON(file: File): T | null { - try { - if (!file.exists) return null; - const compressed = file.bytesSync(); - const json = strFromU8(unzlibSync(compressed)); - return JSON.parse(json) as T; - } catch { - return null; - } -} - -/** Convert full Shape objects to compact [lat, lon] tuples for smaller cache */ -function compactifyShapes(shapes: Record): Record { - const compact: Record = {}; - for (const [id, points] of Object.entries(shapes)) { - compact[id] = points.map(p => [p.shape_pt_lat, p.shape_pt_lon]); - } - return compact; -} - -/** Expand compact [lat, lon] tuples back to full Shape objects */ -function expandShapes(compact: Record): Record { - const shapes: Record = {}; - for (const [id, points] of Object.entries(compact)) { - shapes[id] = points.map(([lat, lon], i) => ({ - shape_id: id, - shape_pt_lat: lat, - shape_pt_lon: lon, - shape_pt_sequence: i, - })); - } - return shapes; -} - -// Basic CSV parser that respects quoted fields -function parseCSV(text: string): Array> { - const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); - if (lines.length === 0) return []; - const header = splitCSVLine(lines[0]); - const rows: Array> = []; - for (let i = 1; i < lines.length; i++) { - const cols = splitCSVLine(lines[i]); - const row: Record = {}; - for (let j = 0; j < header.length; j++) { - row[header[j]] = cols[j] ?? ''; - } - rows.push(row); - } - return rows; -} - -function splitCSVLine(line: string): string[] { - const result: string[] = []; - let cur = ''; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (ch === '"') { - if (inQuotes && line[i + 1] === '"') { - // escaped quote - cur += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - } else if (ch === ',' && !inQuotes) { - result.push(cur); - cur = ''; - } else { - cur += ch; - } - } - result.push(cur); - // Trim outer quotes - return result.map(v => (v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v)); -} - -async function fetchZipBytes(): Promise { - const res = await fetchWithTimeout(GTFS_URL, { timeoutMs: 30000 }); - if (!res.ok) throw new Error(`GTFS fetch failed: ${res.status}`); - const buf = await res.arrayBuffer(); - return new Uint8Array(buf); -} - -function buildRoutes(rows: Array>): Route[] { - return rows - .map(r => ({ - route_id: r['route_id'], - agency_id: r['agency_id'] || undefined, - route_short_name: r['route_short_name'] || undefined, - route_long_name: r['route_long_name'] || r['route_short_name'] || r['route_id'], - route_type: r['route_type'] || undefined, - route_url: r['route_url'] || undefined, - route_color: r['route_color'] || undefined, - route_text_color: r['route_text_color'] || undefined, - })) - .filter(r => !!r.route_id); -} - -function buildStops(rows: Array>): Stop[] { - return rows - .map(r => ({ - stop_id: r['stop_id'], - stop_name: r['stop_name'], - stop_url: r['stop_url'] || undefined, - stop_timezone: r['stop_timezone'] || undefined, - stop_lat: parseFloat(r['stop_lat']), - stop_lon: parseFloat(r['stop_lon']), - })) - .filter(s => !!s.stop_id && !!s.stop_name); -} - -function buildStopTimes(rows: Array>): Record { - const grouped: Record = {}; - for (const r of rows) { - const trip_id = r['trip_id']; - if (!trip_id) continue; - const st: StopTime = { - trip_id, - arrival_time: r['arrival_time'], - departure_time: r['departure_time'], - stop_id: r['stop_id'], - stop_sequence: parseInt(r['stop_sequence'] || '0', 10), - pickup_type: r['pickup_type'] ? parseInt(r['pickup_type'], 10) : undefined, - drop_off_type: r['drop_off_type'] ? parseInt(r['drop_off_type'], 10) : undefined, - timepoint: r['timepoint'] ? parseInt(r['timepoint'], 10) : undefined, - }; - if (!grouped[trip_id]) grouped[trip_id] = []; - grouped[trip_id].push(st); - } - // sort sequences per trip - Object.values(grouped).forEach(arr => arr.sort((a, b) => a.stop_sequence - b.stop_sequence)); - return grouped; -} - -function buildShapes(rows: Array>): Record { - const grouped: Record = {}; - for (const r of rows) { - const shape_id = r['shape_id']; - if (!shape_id) continue; - const shape: Shape = { - shape_id, - shape_pt_lat: parseFloat(r['shape_pt_lat']), - shape_pt_lon: parseFloat(r['shape_pt_lon']), - shape_pt_sequence: parseInt(r['shape_pt_sequence'] || '0', 10), - }; - if (!grouped[shape_id]) grouped[shape_id] = []; - grouped[shape_id].push(shape); - } - // sort by sequence - Object.values(grouped).forEach(arr => arr.sort((a, b) => a.shape_pt_sequence - b.shape_pt_sequence)); - return grouped; -} - -function buildTrips(rows: Array>): Trip[] { - return rows - .map(r => ({ - route_id: r['route_id'], - trip_id: r['trip_id'], - trip_short_name: r['trip_short_name'] || undefined, - trip_headsign: r['trip_headsign'] || undefined, - service_id: r['service_id'] || '', - })) - .filter(t => !!t.trip_id); -} - -function buildCalendar(rows: Array>): CalendarEntry[] { - return rows - .map(r => ({ - service_id: r['service_id'], - monday: r['monday'] === '1', - tuesday: r['tuesday'] === '1', - wednesday: r['wednesday'] === '1', - thursday: r['thursday'] === '1', - friday: r['friday'] === '1', - saturday: r['saturday'] === '1', - sunday: r['sunday'] === '1', - start_date: parseInt(r['start_date'] || '0', 10), - end_date: parseInt(r['end_date'] || '0', 10), - })) - .filter(c => !!c.service_id); -} - -function parseAgencyTimezone(rows: Array>): string | null { - for (const r of rows) { - const tz = r['agency_timezone']; - if (tz) return tz; - } - return null; -} - -function buildCalendarDates(rows: Array>): CalendarDateException[] { - return rows - .map(r => ({ - service_id: r['service_id'], - date: parseInt(r['date'] || '0', 10), - exception_type: parseInt(r['exception_type'] || '0', 10), - })) - .filter(c => !!c.service_id && c.date > 0); -} - -type ProgressUpdate = { step: string; progress: number; detail?: string }; - -export async function ensureFreshGTFS(onProgress?: (update: ProgressUpdate) => void): Promise<{ usedCache: boolean }> { - try { - const report = async (step: string, progress: number, detail?: string) => { - onProgress?.({ step, progress: Math.min(1, Math.max(0, progress)), detail }); - // Progressive console logging - if (detail) { - logger.info(`[GTFS Refresh] ${step} (${Math.round(progress * 100)}%): ${detail}`); - } else { - logger.info(`[GTFS Refresh] ${step} (${Math.round(progress * 100)}%)`); - } - // Yield to the event loop so React can flush state updates and re-render - await new Promise(resolve => setTimeout(resolve, 0)); - }; - - await report('Checking GTFS cache', 0.05); - - await ensureCacheDir(); - - const lastFetchStr = await AsyncStorage.getItem(STORAGE_KEYS.LAST_FETCH); - const lastFetchMs = lastFetchStr ? parseInt(lastFetchStr, 10) : 0; - - // If cache is fresh, apply and return - if (lastFetchMs && !isOlderThanDays(lastFetchMs, 3)) { - const routes = readCompressedJSON(CACHE_FILES.routes); - const stops = readCompressedJSON(CACHE_FILES.stops); - const stopTimes = readCompressedJSON>(CACHE_FILES.stopTimes); - const compactShapes = readCompressedJSON>(CACHE_FILES.shapes); - const trips = readCompressedJSON(CACHE_FILES.trips); - const calendar = readCompressedJSON(CACHE_FILES.calendar); - const calendarDates = readCompressedJSON(CACHE_FILES.calendarDates); - const agencyTimezone = CACHE_FILES.agencyTimezone.exists ? CACHE_FILES.agencyTimezone.textSync() || null : null; - if (routes && stops && stopTimes) { - const shapes = compactShapes ? expandShapes(compactShapes) : {}; - gtfsParser.overrideData(routes, stops, stopTimes, shapes, trips || [], calendar || [], calendarDates || [], agencyTimezone); - shapeLoader.initialize(shapes); - await report('Using cached GTFS', 1, 'Cache age < 3 days'); - return { usedCache: true }; - } - } - - await report('GTFS.zip', 0.1, 'Fetching latest schedule'); - // Fetch and rebuild cache - const zipBytes = await fetchZipBytes(); - await report('Download complete', 0.2); - const files = unzipSync(zipBytes); - await report('Unzipping archive', 0.3); - - const routesTxt = files['routes.txt'] ? strFromU8(files['routes.txt']) : ''; - const stopsTxt = files['stops.txt'] ? strFromU8(files['stops.txt']) : ''; - const stopTimesTxt = files['stop_times.txt'] ? strFromU8(files['stop_times.txt']) : ''; - const shapesTxt = files['shapes.txt'] ? strFromU8(files['shapes.txt']) : ''; - const tripsTxt = files['trips.txt'] ? strFromU8(files['trips.txt']) : ''; - const calendarTxt = files['calendar.txt'] ? strFromU8(files['calendar.txt']) : ''; - const calendarDatesTxt = files['calendar_dates.txt'] ? strFromU8(files['calendar_dates.txt']) : ''; - const agencyTxt = files['agency.txt'] ? strFromU8(files['agency.txt']) : ''; - - if (!routesTxt || !stopsTxt || !stopTimesTxt) { - logger.error('[GTFS Refresh] Missing expected GTFS files (routes/stops/stop_times)'); - throw new Error('Missing expected GTFS files (routes/stops/stop_times)'); - } - - await report('Parsing routes', 0.35); - const routes = buildRoutes(parseCSV(routesTxt)); - await report('Parsing stops', 0.45); - const stops = buildStops(parseCSV(stopsTxt)); - await report('Parsing trips', 0.55); - const trips = tripsTxt ? buildTrips(parseCSV(tripsTxt)) : []; - await report('Parsing stop times', 0.7); - const stopTimes = buildStopTimes(parseCSV(stopTimesTxt)); - await report('Parsing shapes', 0.75); - const shapes = shapesTxt ? buildShapes(parseCSV(shapesTxt)) : {}; - await report('Parsing calendar', 0.8); - const calendar = calendarTxt ? buildCalendar(parseCSV(calendarTxt)) : []; - const calendarDates = calendarDatesTxt ? buildCalendarDates(parseCSV(calendarDatesTxt)) : []; - const agencyTimezone = agencyTxt ? parseAgencyTimezone(parseCSV(agencyTxt)) : null; - - await report('Persisting cache', 0.9, 'Writing compressed data to device storage'); - - // Write compressed JSON files to filesystem (no size limits) - writeCompressedJSON(CACHE_FILES.routes, routes); - writeCompressedJSON(CACHE_FILES.stops, stops); - writeCompressedJSON(CACHE_FILES.stopTimes, stopTimes); - writeCompressedJSON(CACHE_FILES.shapes, compactifyShapes(shapes)); - writeCompressedJSON(CACHE_FILES.trips, trips); - writeCompressedJSON(CACHE_FILES.calendar, calendar); - writeCompressedJSON(CACHE_FILES.calendarDates, calendarDates); - const tzFile = CACHE_FILES.agencyTimezone; - if (tzFile.exists) { tzFile.delete(); } - tzFile.create(); - tzFile.write(agencyTimezone || ''); - // Store only the timestamp in AsyncStorage (tiny metadata) - await AsyncStorage.setItem(STORAGE_KEYS.LAST_FETCH, String(Date.now())); - - gtfsParser.overrideData(routes, stops, stopTimes, shapes, trips, calendar, calendarDates, agencyTimezone); - - // Initialize shape loader for map rendering - shapeLoader.initialize(shapes); - - await report('Refresh complete', 1, 'Applied latest GTFS'); - return { usedCache: false }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error(`[GTFS Refresh] GTFS sync failed: ${msg}`, err); - onProgress?.({ step: 'GTFS refresh failed', progress: 1, detail: msg || 'Check network connection' }); - return { usedCache: true }; - } -} - -export async function hasCachedGTFS(): Promise { - return CACHE_FILES.routes.exists && CACHE_FILES.stops.exists && CACHE_FILES.stopTimes.exists; -} - -export async function isCacheStale(): Promise { - const lastFetchStr = await AsyncStorage.getItem(STORAGE_KEYS.LAST_FETCH); - const lastFetchMs = lastFetchStr ? parseInt(lastFetchStr, 10) : 0; - return !lastFetchMs || isOlderThanDays(lastFetchMs, 3); -} - -// Yield to the event loop so the JS thread can handle pending UI work -const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0)); - -/** - * Load cached GTFS data into the parser (called on app startup) - * This doesn't check staleness - just loads whatever is cached. - * Reads compressed files from the filesystem and yields between - * large parses to avoid blocking the UI thread. - */ -export async function loadCachedGTFS(): Promise { - try { - ensureCacheDirSync(); - - // Read routes + stops (small files, needed to check if cache exists) - const routes = readCompressedJSON(CACHE_FILES.routes); - const stops = readCompressedJSON(CACHE_FILES.stops); - if (!routes || !stops) { - logger.info('[GTFS] No cached data found'); - return false; - } - - await yieldToUI(); - - // Read remaining files with yields between heavy parses to avoid blocking UI - const stopTimes = readCompressedJSON>(CACHE_FILES.stopTimes); - if (!stopTimes) { - logger.info('[GTFS] No cached data found'); - return false; - } - - await yieldToUI(); - const trips = readCompressedJSON(CACHE_FILES.trips); - - await yieldToUI(); - const calendar = readCompressedJSON(CACHE_FILES.calendar); - const calendarDates = readCompressedJSON(CACHE_FILES.calendarDates); - - const agencyTimezone = CACHE_FILES.agencyTimezone.exists ? CACHE_FILES.agencyTimezone.textSync() || null : null; - - // Load without shapes — they are deferred to after splash hides - gtfsParser.overrideData(routes, stops, stopTimes, {}, trips || [], calendar || [], calendarDates || [], agencyTimezone); - logger.info('[GTFS] Loaded core cached data on startup (shapes deferred)'); - return true; - } catch (error) { - logger.error('[GTFS] Failed to load cached data:', error); - return false; - } -} - -/** Load shapes in the background after splash screen is hidden */ -export async function loadDeferredShapes(): Promise { - try { - // Yield before heavy sync I/O so React can flush pending state updates (e.g. hide loading screen) - await yieldToUI(); - - const compactShapes = readCompressedJSON>(CACHE_FILES.shapes); - if (!compactShapes) return; - - await yieldToUI(); - const shapes = expandShapes(compactShapes); - - gtfsParser.updateShapes(shapes); - shapeLoader.initialize(shapes); - logger.info('[GTFS] Deferred shapes loaded'); - } catch (error) { - logger.error('[GTFS] Failed to load deferred shapes:', error); - } -} diff --git a/apps/mobile/services/location-suggestions.ts b/apps/mobile/services/location-suggestions.ts index 7b80337..5274f9a 100644 --- a/apps/mobile/services/location-suggestions.ts +++ b/apps/mobile/services/location-suggestions.ts @@ -1,6 +1,16 @@ +/** + * Location-based suggestions for the search screen empty state. + * + * Backed by the new API client. The nearby-stops endpoint is a stub on + * apps/api today, so this returns no suggestions until the backend lands + * GET /v1/stops/nearby. Upcoming-train and routes-at-stop derivations + * fall out of /v1/departures. + */ + import * as Location from 'expo-location'; -import type { GTFSParser } from '../utils/gtfs-parser'; -import type { Route, Stop } from '../types/train'; +import { ApiError, getDepartures, getNearbyStops } from './api-client'; +import type { Stop } from '../types/train'; +import type { ApiDepartureItem, ApiStop } from '../types/api'; import { haversineDistance } from '../utils/distance'; import { logger } from '../utils/logger'; @@ -17,7 +27,47 @@ export interface LocationSuggestion { let cachedSuggestions: LocationSuggestion[] | null = null; let initialized = false; -async function initialize(parser: GTFSParser): Promise { +function adaptStop(s: ApiStop): Stop { + return { + stop_id: s.providerId ? `${s.providerId}:${s.code}` : s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +function toYMD(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function pickClosest(stops: ApiStop[], lat: number, lon: number): { stop: ApiStop; distMi: number } | null { + if (stops.length === 0) return null; + let best: ApiStop | null = null; + let bestDist = Infinity; + for (const s of stops) { + const d = haversineDistance(lat, lon, s.lat, s.lon); + if (d < bestDist) { + bestDist = d; + best = s; + } + } + return best ? { stop: best, distMi: bestDist } : null; +} + +function formatTimeFromIso(iso?: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return '—'; + let h = d.getHours(); + const m = String(d.getMinutes()).padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + if (h === 0) h = 12; + else if (h > 12) h -= 12; + return `${h}:${m} ${ampm}`; +} + +async function initialize(): Promise { if (initialized) return; initialized = true; @@ -32,73 +82,58 @@ async function initialize(parser: GTFSParser): Promise { accuracy: Location.Accuracy.Balanced, }); const { latitude, longitude } = location.coords; - logger.info(`[LocationSuggestions] Location: ${latitude.toFixed(3)}, ${longitude.toFixed(3)}`); - - // Find nearest station - const allStops = parser.getAllStops(); - if (allStops.length === 0) return; - - let nearestStop: Stop | null = null; - let nearestDist = Infinity; - for (const stop of allStops) { - const dist = haversineDistance(latitude, longitude, stop.stop_lat, stop.stop_lon); - if (dist < nearestDist) { - nearestDist = dist; - nearestStop = stop; + logger.info('[LocationSuggestions] Location received'); + + let nearby: ApiStop[] = []; + try { + nearby = await getNearbyStops({ lat: latitude, lon: longitude, radiusMeters: 80_000 }); + } catch (err) { + // The /v1/stops/nearby endpoint isn't on the backend yet; fail + // gracefully so the search screen renders without suggestions. + if (err instanceof ApiError) { + logger.warn(`[LocationSuggestions] /v1/stops/nearby unavailable (${err.status})`); + } else { + logger.warn('[LocationSuggestions] nearby stops unavailable', err); } + return; } - if (!nearestStop) return; - logger.info(`[LocationSuggestions] Nearest: ${nearestStop.stop_name} (${nearestDist.toFixed(1)} mi)`); - - const suggestions: LocationSuggestion[] = []; - - // 1. Nearest station - const distLabel = nearestDist < 1 - ? `${(nearestDist * 5280).toFixed(0)} ft away` - : `${nearestDist.toFixed(1)} mi away`; - suggestions.push({ - type: 'station', - label: nearestStop.stop_name, - subtitle: `${nearestStop.stop_id} · ${distLabel}`, - stop: nearestStop, - }); - - // 2. Up to 2 upcoming trains from nearest station - const upcoming = parser.getUpcomingTrainsFromStop(nearestStop.stop_id, 2); - for (const train of upcoming) { - const [hStr, mStr] = train.departureTime.split(':'); - let h = parseInt(hStr, 10); - const m = mStr; - const dayOffset = Math.floor(h / 24); - h = h % 24; - const ampm = h >= 12 ? 'PM' : 'AM'; - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; - const timeStr = `${h12}:${m} ${ampm}`; - - suggestions.push({ - type: 'train', - label: `${train.routeName} ${train.trainNumber}`, - subtitle: `Departs ${nearestStop.stop_id} at ${timeStr}`, - trainNumber: train.trainNumber, - displayName: `${train.routeName} ${train.trainNumber}`, - }); - } - - // 3. Up to 2 routes serving nearest station (skip routes already shown via trains) - const shownRouteIds = new Set(upcoming.map(t => t.trip.route_id)); - const routes = parser.getRoutesServingStop(nearestStop.stop_id); - let routeCount = 0; - for (const route of routes) { - if (routeCount >= 2) break; - if (shownRouteIds.has(route.route_id)) continue; - suggestions.push({ - type: 'route', - label: route.route_long_name, - subtitle: `Serves ${nearestStop.stop_id}`, - routeId: route.route_id, + const closest = pickClosest(nearby, latitude, longitude); + if (!closest) return; + const nearestStop = adaptStop(closest.stop); + const distLabel = closest.distMi < 1 + ? `${(closest.distMi * 5280).toFixed(0)} ft away` + : `${closest.distMi.toFixed(1)} mi away`; + + const suggestions: LocationSuggestion[] = [ + { + type: 'station', + label: nearestStop.stop_name, + subtitle: `${nearestStop.stop_id} · ${distLabel}`, + stop: nearestStop, + }, + ]; + + // Upcoming trains from this stop today (best-effort). + try { + const departures = await getDepartures({ + stopId: `${closest.stop.providerId}:${closest.stop.code}`, + date: toYMD(new Date()), }); - routeCount++; + const upcoming = departures + .filter((d): d is ApiDepartureItem & { departureTime: string } => Boolean(d.departureTime)) + .slice(0, 2); + for (const d of upcoming) { + suggestions.push({ + type: 'train', + label: `${d.shortName || d.tripId} ${d.headsign}`.trim(), + subtitle: `Departs ${nearestStop.stop_id} at ${formatTimeFromIso(d.departureTime)}`, + trainNumber: d.shortName, + displayName: `${d.shortName || ''} ${d.headsign}`.trim(), + }); + } + } catch (err) { + logger.warn('[LocationSuggestions] departures unavailable', err); } cachedSuggestions = suggestions; diff --git a/apps/mobile/services/realtime.ts b/apps/mobile/services/realtime.ts deleted file mode 100644 index 9f5165c..0000000 --- a/apps/mobile/services/realtime.ts +++ /dev/null @@ -1,432 +0,0 @@ -/** - * Real-time train tracking service - * Fetches live positions and delays from Transitdocs GTFS-RT feed - */ - -import { Alert } from 'react-native'; -import GtfsRealtimeBindings from 'gtfs-realtime-bindings'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { extractTrainNumber } from '../utils/train-helpers'; -import { logger } from '../utils/logger'; -import { fetchWithTimeout } from '../utils/fetch-with-timeout'; - -// Track last error alert time to avoid spamming user -let lastErrorAlertTime = 0; -const ERROR_ALERT_COOLDOWN = 60000; // Only show alert once per minute -let consecutiveErrors = 0; -const MAX_SILENT_ERRORS = 3; // Show alert after 3 consecutive errors - -export interface RealtimePosition { - trip_id: string; - latitude: number; - longitude: number; - bearing?: number; - speed?: number; - timestamp: number; - vehicle_id?: string; - train_number?: string; // Extracted train number for matching -} - -export interface RealtimeUpdate { - trip_id: string; - stop_id?: string; - arrival_delay?: number; // seconds - departure_delay?: number; // seconds - schedule_relationship?: 'SCHEDULED' | 'SKIPPED' | 'NO_DATA'; -} - -export interface RealtimeAlert { - trip_id?: string; - route_id?: string; - header: string; - description: string; - severity?: 'INFO' | 'WARNING' | 'SEVERE'; -} - -// Transitdocs GTFS-RT endpoint (consolidates vehicle positions and trip updates) -const TRANSITDOCS_GTFS_RT_URL = 'https://asm-backend.transitdocs.com/gtfs/amtrak'; - -// Cache for real-time data (25 seconds TTL — outlasts 15s poll interval so second consumers get cache hits) -const CACHE_TTL = 25000; -let positionsCache: { data: Map; timestamp: number } | null = null; -let updatesCache: { data: Map; timestamp: number } | null = null; - -// Shared fetch to avoid fetching + decoding the same protobuf twice -let pendingFetch: Promise | null = null; - -async function fetchSharedProtobuf(): Promise { - if (!pendingFetch) { - pendingFetch = fetchProtobuf(TRANSITDOCS_GTFS_RT_URL).finally(() => { - pendingFetch = null; - }); - } - return pendingFetch; -} - -/** - * Show error alert to user (rate-limited) - */ -function showRealtimeErrorAlert(status: number): void { - consecutiveErrors++; - - // Only show alert if enough consecutive errors and cooldown has passed - const now = Date.now(); - if (consecutiveErrors >= MAX_SILENT_ERRORS && now - lastErrorAlertTime > ERROR_ALERT_COOLDOWN) { - lastErrorAlertTime = now; - - let message = 'Unable to fetch live train positions. '; - if (status === 503) { - message += - 'The Transitdocs service is temporarily unavailable. Train positions will update when service is restored.'; - } else if (status === 429) { - message += 'Too many requests. Please wait a moment.'; - } else { - message += `Server returned error ${status}. Please try again later.`; - } - - Alert.alert('Live Data Unavailable', message, [{ text: 'OK', style: 'default' }]); - } -} - -/** - * Reset error counter on successful fetch - */ -function resetErrorCounter(): void { - consecutiveErrors = 0; -} - -/** - * Fetch GTFS-RT protobuf data - */ -async function fetchProtobuf(url: string): Promise { - const response = await fetchWithTimeout(url, { timeoutMs: 15000 }); - if (!response.ok) { - showRealtimeErrorAlert(response.status); - throw new Error(`GTFS-RT fetch failed: ${response.status}`); - } - resetErrorCounter(); - const arrayBuffer = await response.arrayBuffer(); - return new Uint8Array(arrayBuffer); -} - -// extractTrainNumber is now imported from utils/train-helpers - -/** - * Parse GTFS-RT protobuf for vehicle positions. - * Returns both the positions map and a tripId→trainNumber mapping so that - * parseTripUpdates can index delays by the correct train number. - */ -function parseVehiclePositions(buffer: Uint8Array): { positions: Map; trainNumberMap: Map } { - const positions = new Map(); - const trainNumberMap = new Map(); - - try { - const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buffer); - - for (const entity of feed.entity) { - if (entity.vehicle && entity.vehicle.position && entity.vehicle.trip) { - const tripId = entity.vehicle.trip.tripId || ''; - const vehicleId = entity.vehicle.vehicle?.id ?? ''; - const vehicleIdMatch = vehicleId.match(/_(\d+)$/); - const trainNumber = vehicleIdMatch - ? vehicleIdMatch[1] - : extractTrainNumber(tripId) || tripId; - - trainNumberMap.set(tripId, trainNumber); - - positions.set(tripId, { - trip_id: tripId, - train_number: trainNumber, - latitude: entity.vehicle.position.latitude, - longitude: entity.vehicle.position.longitude, - bearing: entity.vehicle.position.bearing ?? undefined, - speed: entity.vehicle.position.speed ?? undefined, - timestamp: entity.vehicle.timestamp - ? Number(entity.vehicle.timestamp) * 1000 // Convert to milliseconds - : Date.now(), - vehicle_id: entity.vehicle.vehicle?.id ?? undefined, - }); - - // Also index by train number for easier lookup - if (trainNumber !== tripId) { - positions.set(trainNumber, positions.get(tripId)!); - } - } - } - } catch (error) { - logger.error('Error parsing vehicle positions:', error); - } - - return { positions, trainNumberMap }; -} - -/** - * Parse GTFS-RT protobuf for trip updates - * Accepts an optional tripId→trainNumber map (from vehicle positions) so that - * updates for numeric-only GTFS-RT trip IDs can also be indexed by their real - * train number (e.g. "656" instead of "248766"). - */ -function parseTripUpdates(buffer: Uint8Array, trainNumberMap?: Map): Map { - const updates = new Map(); - - try { - const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buffer); - - for (const entity of feed.entity) { - if (entity.tripUpdate && entity.tripUpdate.trip) { - const tripId = entity.tripUpdate.trip.tripId || ''; - const trainNumber = trainNumberMap?.get(tripId) || extractTrainNumber(tripId) || tripId; - const stopUpdates: RealtimeUpdate[] = []; - - for (const stopTime of entity.tripUpdate.stopTimeUpdate || []) { - stopUpdates.push({ - trip_id: tripId, - stop_id: stopTime.stopId ?? undefined, - arrival_delay: stopTime.arrival?.delay ?? undefined, - departure_delay: stopTime.departure?.delay ?? undefined, - schedule_relationship: - stopTime.scheduleRelationship === 0 - ? 'SCHEDULED' - : stopTime.scheduleRelationship === 1 - ? 'SKIPPED' - : 'NO_DATA', - }); - } - - if (stopUpdates.length > 0) { - updates.set(tripId, stopUpdates); - // Also index by train number - if (trainNumber !== tripId) { - updates.set(trainNumber, stopUpdates); - } - } - } - } - } catch (error) { - logger.error('Error parsing trip updates:', error); - } - - return updates; -} - -export class RealtimeService { - /** - * Get real-time position for a specific trip or train number - * Supports both trip_id format (e.g., "2026-01-16_AMTK_543") and train number (e.g., "543") - */ - static async getPositionForTrip(tripIdOrTrainNumber: string): Promise { - try { - const positions = await this.getAllPositions(); - - // Try direct lookup first - let position = positions.get(tripIdOrTrainNumber); - - // If not found, try extracting/matching train number - if (!position) { - const trainNumber = extractTrainNumber(tripIdOrTrainNumber); - if (trainNumber) { - position = positions.get(trainNumber); - } - } - - return position || null; - } catch (error) { - logger.error('Error fetching real-time position:', error); - return null; - } - } - - /** - * Get all current train positions from Transitdocs feed - */ - static async getAllPositions(): Promise> { - try { - // Check cache - const now = Date.now(); - if (positionsCache && now - positionsCache.timestamp < CACHE_TTL) { - return positionsCache.data; - } - - // Fetch fresh data (shared request avoids double-fetch when updates also need data) - const buffer = await fetchSharedProtobuf(); - const { positions, trainNumberMap } = parseVehiclePositions(buffer); - logger.info(`[Realtime] Fetched ${positions.size} vehicle positions`); - - // Also populate updates cache from the same buffer to avoid a second fetch - if (!updatesCache || now - updatesCache.timestamp >= CACHE_TTL) { - updatesCache = { data: parseTripUpdates(buffer, trainNumberMap), timestamp: now }; - } - - // Update cache - positionsCache = { data: positions, timestamp: now }; - return positions; - } catch (error) { - logger.error('Error fetching vehicle positions:', error); - // Return cached data if available, even if stale - return positionsCache?.data || new Map(); - } - } - - /** - * Get trip updates (delays) for a specific trip or train number - */ - static async getUpdatesForTrip(tripIdOrTrainNumber: string): Promise { - try { - const updates = await this.getAllUpdates(); - - // Try direct lookup first - let tripUpdates = updates.get(tripIdOrTrainNumber); - - // If not found, try extracting/matching train number - if (!tripUpdates) { - const trainNumber = extractTrainNumber(tripIdOrTrainNumber); - if (trainNumber) { - tripUpdates = updates.get(trainNumber); - } - } - - return tripUpdates || []; - } catch (error) { - logger.error('Error fetching trip updates:', error); - return []; - } - } - - /** - * Get all trip updates from Transitdocs feed - */ - static async getAllUpdates(): Promise> { - try { - // Check cache - const now = Date.now(); - if (updatesCache && now - updatesCache.timestamp < CACHE_TTL) { - return updatesCache.data; - } - - // Fetch fresh data (shared request avoids double-fetch when positions also need data) - const buffer = await fetchSharedProtobuf(); - const { positions, trainNumberMap } = parseVehiclePositions(buffer); - const updates = parseTripUpdates(buffer, trainNumberMap); - - // Also populate positions cache from the same buffer to avoid a second fetch - if (!positionsCache || now - positionsCache.timestamp >= CACHE_TTL) { - positionsCache = { data: positions, timestamp: now }; - } - - // Update cache - updatesCache = { data: updates, timestamp: now }; - return updates; - } catch (error) { - logger.error('Error fetching trip updates:', error); - // Return cached data if available, even if stale - return updatesCache?.data || new Map(); - } - } - - /** - * Get delay in minutes for a trip at a specific stop - */ - static async getDelayForStop(tripIdOrTrainNumber: string, stopId: string): Promise { - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - const stopUpdate = updates.find(u => u.stop_id === stopId); - - if (stopUpdate && stopUpdate.departure_delay !== undefined) { - return Math.round(stopUpdate.departure_delay / 60); // Convert seconds to minutes - } - - return null; - } catch (error) { - logger.error('Error getting delay:', error); - return null; - } - } - - /** - * Get arrival delay in minutes for a trip at a specific stop. - * Returns arrival_delay with fallback to departure_delay (last stop has no departure). - */ - static async getArrivalDelayForStop(tripIdOrTrainNumber: string, stopId: string): Promise { - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - const stopUpdate = updates.find(u => u.stop_id === stopId); - - if (stopUpdate) { - const delaySeconds = stopUpdate.arrival_delay ?? stopUpdate.departure_delay; - if (delaySeconds !== undefined) { - return Math.round(delaySeconds / 60); - } - } - - return null; - } catch (error) { - logger.error('Error getting arrival delay:', error); - return null; - } - } - - /** - * Get delays for all stops of a trip. - * Returns Map in minutes. - */ - static async getDelaysForAllStops( - tripIdOrTrainNumber: string - ): Promise> { - const result = new Map(); - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - for (const u of updates) { - if (u.stop_id) { - result.set(u.stop_id, { - departureDelay: u.departure_delay != null ? Math.round(u.departure_delay / 60) : undefined, - arrivalDelay: u.arrival_delay != null ? Math.round(u.arrival_delay / 60) : undefined, - }); - } - } - } catch (error) { - logger.error('Error getting delays for all stops:', error); - } - return result; - } - - /** - * Format delay for display - */ - static formatDelay(delayMinutes: number | null): string { - if (delayMinutes === null || delayMinutes === 0) { - return 'On Time'; - } - if (delayMinutes > 0) { - return `Delayed ${delayMinutes}m`; - } - return `${Math.abs(delayMinutes)}m early`; - } - - /** - * Clear caches (useful for manual refresh) - */ - static clearCache(): void { - positionsCache = null; - updatesCache = null; - } - - /** - * Get all active trains with their current positions - * Returns an array of {trainNumber, position} for easy consumption - */ - static async getAllActiveTrains(): Promise> { - const positions = await this.getAllPositions(); - const trains: Array<{ trainNumber: string; position: RealtimePosition }> = []; - const seen = new Set(); - - for (const [key, position] of positions.entries()) { - const trainNumber = position.train_number || extractTrainNumber(key) || key; - if (!seen.has(trainNumber)) { - trains.push({ trainNumber, position }); - seen.add(trainNumber); - } - } - - return trains; - } -} diff --git a/apps/mobile/services/shape-loader.ts b/apps/mobile/services/shape-loader.ts deleted file mode 100644 index 2e56c6c..0000000 --- a/apps/mobile/services/shape-loader.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Shape Loader Service - * Manages efficient lazy-loading of rail route shapes based on viewport - * Uses bounding box indexing for fast spatial queries - */ - -import type { Shape, ViewportBounds } from '../types/train'; -import { debug, info } from '../utils/logger'; - -export interface ShapeBounds { - id: string; - minLat: number; - maxLat: number; - minLon: number; - maxLon: number; - pointCount: number; -} - -export interface VisibleShape { - id: string; - coordinates: Array<{ latitude: number; longitude: number }>; -} - -export class ShapeLoader { - private shapeBounds: Map = new Map(); - private shapeCoordinates: Map> = new Map(); - - /** - * Initialize shape loader with all shapes data - * Pre-computes bounding boxes for fast spatial queries - */ - initialize(shapes: Record): void { - this.shapeBounds.clear(); - this.shapeCoordinates.clear(); - - Object.entries(shapes).forEach(([shapeId, points]) => { - if (points.length === 0) return; - - // Compute bounding box - let minLat = points[0].shape_pt_lat; - let maxLat = points[0].shape_pt_lat; - let minLon = points[0].shape_pt_lon; - let maxLon = points[0].shape_pt_lon; - - const coordinates: Array<{ latitude: number; longitude: number }> = []; - - for (const point of points) { - minLat = Math.min(minLat, point.shape_pt_lat); - maxLat = Math.max(maxLat, point.shape_pt_lat); - minLon = Math.min(minLon, point.shape_pt_lon); - maxLon = Math.max(maxLon, point.shape_pt_lon); - - coordinates.push({ - latitude: point.shape_pt_lat, - longitude: point.shape_pt_lon, - }); - } - - this.shapeBounds.set(shapeId, { - id: shapeId, - minLat, - maxLat, - minLon, - maxLon, - pointCount: points.length, - }); - - this.shapeCoordinates.set(shapeId, coordinates); - }); - - const stats = this.getStats(); - info(`[ShapeLoader] Initialized: ${stats.totalShapes} shapes, ${stats.totalPoints} total points`); - } - - /** - * Get shapes visible in the given viewport with padding - * Adds padding to load shapes slightly outside viewport for smoother panning - */ - getVisibleShapes(viewport: ViewportBounds, paddingFraction: number = 0.3): VisibleShape[] { - const latPad = (viewport.maxLat - viewport.minLat) * paddingFraction; - const lonPad = (viewport.maxLon - viewport.minLon) * paddingFraction; - const paddedBounds = { - minLat: viewport.minLat - latPad, - maxLat: viewport.maxLat + latPad, - minLon: viewport.minLon - lonPad, - maxLon: viewport.maxLon + lonPad, - }; - - const visible: VisibleShape[] = []; - - // Query bounding boxes for intersection - for (const [shapeId, bounds] of this.shapeBounds) { - if (this.boundsIntersect(bounds, paddedBounds)) { - const coordinates = this.shapeCoordinates.get(shapeId); - if (coordinates) { - visible.push({ - id: shapeId, - coordinates, - }); - } - } - } - - return visible; - } - - /** - * Check if two bounding boxes intersect - */ - private boundsIntersect(bounds1: ShapeBounds, bounds2: ViewportBounds): boolean { - return !( - bounds1.maxLat < bounds2.minLat || - bounds1.minLat > bounds2.maxLat || - bounds1.maxLon < bounds2.minLon || - bounds1.minLon > bounds2.maxLon - ); - } - - /** - * Get all shapes without viewport filtering - */ - getAllShapes(): VisibleShape[] { - const all: VisibleShape[] = []; - for (const [shapeId, coordinates] of this.shapeCoordinates) { - all.push({ id: shapeId, coordinates }); - } - return all; - } - - /** - * Get statistics about loaded shapes - */ - getStats() { - let totalPoints = 0; - let maxPoints = 0; - let minPoints = Infinity; - - for (const bounds of this.shapeBounds.values()) { - totalPoints += bounds.pointCount; - maxPoints = Math.max(maxPoints, bounds.pointCount); - minPoints = Math.min(minPoints, bounds.pointCount); - } - - return { - totalShapes: this.shapeBounds.size, - totalPoints, - averagePointsPerShape: Math.round(totalPoints / (this.shapeBounds.size || 1)), - maxPointsInShape: maxPoints, - minPointsInShape: minPoints, - }; - } - - /** - * Clear all data - */ - clear(): void { - this.shapeBounds.clear(); - this.shapeCoordinates.clear(); - } -} - -// Export singleton instance -export const shapeLoader = new ShapeLoader(); diff --git a/apps/mobile/services/station-loader.ts b/apps/mobile/services/station-loader.ts index b77d4ed..f7345e6 100644 --- a/apps/mobile/services/station-loader.ts +++ b/apps/mobile/services/station-loader.ts @@ -1,11 +1,16 @@ /** - * Station Loader Service - * Manages efficient lazy-loading of station markers based on viewport - * Uses spatial indexing for fast viewport-based queries + * Thin synchronous facade over the API-client stop cache. + * + * Originally a spatial index initialized from the local GTFS dataset; now a + * shim that delegates to lookupStop so existing call sites + * (services/notifications.ts, services/storage.ts) keep their familiar + * `stationLoader.getStationByCode(code)` shape. lookupStop is itself sync — + * it returns whatever's in the api-client cache and fires a background + * fetch on miss — so behavior matches the old loader except entries + * populate lazily instead of all-at-once. */ -import type { Stop, ViewportBounds } from '../types/train'; -import { info } from '../utils/logger'; +import { lookupStop } from '../utils/api-stop-cache'; export interface StationBounds { id: string; @@ -16,82 +21,15 @@ export interface StationBounds { export interface VisibleStation extends StationBounds {} -export class StationLoader { - private stations: Map = new Map(); - - /** - * Initialize station loader with all stops data - * Stores station metadata for spatial queries - */ - initialize(stops: Stop[]): void { - this.stations.clear(); - - stops.forEach(stop => { - this.stations.set(stop.stop_id, { - id: stop.stop_id, - name: stop.stop_name, - lat: stop.stop_lat, - lon: stop.stop_lon, - }); - }); - - info(`[StationLoader] Initialized: ${this.stations.size} stations`); - } - - /** - * Get stations visible in the given viewport with padding - * Adds padding to load stations slightly outside viewport - */ - getVisibleStations(viewport: ViewportBounds, paddingFraction: number = 0.3): VisibleStation[] { - const latPad = (viewport.maxLat - viewport.minLat) * paddingFraction; - const lonPad = (viewport.maxLon - viewport.minLon) * paddingFraction; - const paddedBounds = { - minLat: viewport.minLat - latPad, - maxLat: viewport.maxLat + latPad, - minLon: viewport.minLon - lonPad, - maxLon: viewport.maxLon + lonPad, - }; - - const visible: VisibleStation[] = []; - - // Query stations within padded viewport - for (const station of this.stations.values()) { - if ( - station.lat >= paddedBounds.minLat && - station.lat <= paddedBounds.maxLat && - station.lon >= paddedBounds.minLon && - station.lon <= paddedBounds.maxLon - ) { - visible.push(station); - } - } - - return visible; - } - - /** - * Get statistics about loaded stations - */ - getStats() { +export const stationLoader = { + getStationByCode(code: string): StationBounds | undefined { + const stop = lookupStop(code); + if (!stop) return undefined; return { - totalStations: this.stations.size, + id: stop.stop_id, + name: stop.stop_name, + lat: stop.stop_lat, + lon: stop.stop_lon, }; - } - - /** - * Look up a station by its stop_id / code - */ - getStationByCode(code: string): StationBounds | undefined { - return this.stations.get(code); - } - - /** - * Clear all data - */ - clear(): void { - this.stations.clear(); - } -} - -// Export singleton instance -export const stationLoader = new StationLoader(); + }, +}; diff --git a/apps/mobile/services/ws-client.ts b/apps/mobile/services/ws-client.ts new file mode 100644 index 0000000..ab7b823 --- /dev/null +++ b/apps/mobile/services/ws-client.ts @@ -0,0 +1,244 @@ +/** + * Singleton WebSocket client for the realtime feed at `wsUrl` (config.ts). + * + * Usage: + * const off = wsClient.subscribe(['amtrak'], (update) => { ... }); + * off(); // unsubscribe + drop topic if no other listeners want it + * + * Wire format: see apps/api/ws/poller.go (RealtimeUpdate envelope). + * Subscription protocol: see apps/api/ws/handler.go (clientMsg). + */ + +import { config } from '../constants/config'; +import type { ApiTrainPosition, RealtimeUpdate } from '../types/api'; +import { logger } from '../utils/logger'; + +type Listener = (update: RealtimeUpdate) => void; +type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed'; + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; + +interface ProviderSubscription { + refCount: number; + /** True once the server has acknowledged (i.e. we sent subscribe while open). */ + sentToServer: boolean; +} + +class WSClient { + private ws: WebSocket | null = null; + private state: ConnectionState = 'idle'; + private listeners = new Set(); + private subscriptions = new Map(); + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private intentionallyClosed = false; + + /** Per-provider snapshot of the last positions array we received. */ + private latest = new Map(); + + /** + * Subscribe a listener to one or more providers. Returns an unsubscribe + * function. Listeners are invoked for *every* RealtimeUpdate the socket + * delivers — filter by `update.provider` in the listener if needed. + */ + subscribe(providers: string[], listener: Listener): () => void { + this.listeners.add(listener); + + for (const p of providers) { + const existing = this.subscriptions.get(p); + if (existing) { + existing.refCount += 1; + } else { + this.subscriptions.set(p, { refCount: 1, sentToServer: false }); + } + } + + this.ensureConnected(); + this.flushSubscribeIfOpen(providers); + + return () => { + this.listeners.delete(listener); + const drop: string[] = []; + for (const p of providers) { + const s = this.subscriptions.get(p); + if (!s) continue; + s.refCount -= 1; + if (s.refCount <= 0) { + drop.push(p); + this.subscriptions.delete(p); + } + } + if (drop.length > 0 && this.ws?.readyState === WebSocket.OPEN) { + this.send({ action: 'unsubscribe', providers: drop }); + } + // No listeners → close the socket so we don't hold a connection open. + if (this.listeners.size === 0) { + this.close(); + } + }; + } + + /** + * Permanently close the socket and cancel any pending reconnect. Subscribe() + * after close() is supported and will re-open. + */ + close(): void { + this.intentionallyClosed = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch (e) { + logger.warn('[ws-client] error closing socket', e); + } + this.ws = null; + } + this.state = 'closed'; + } + + /** All known live positions across providers, in arrival order. */ + getLatestPositions(): ApiTrainPosition[] { + const out: ApiTrainPosition[] = []; + for (const list of this.latest.values()) for (const p of list) out.push(p); + return out; + } + + /** Latest live position for a specific run, or undefined if not present. */ + findPosition(opts: { provider: string; tripId?: string; trainNumber?: string }): + | ApiTrainPosition + | undefined { + const list = this.latest.get(opts.provider); + if (!list) return undefined; + return list.find( + p => + (opts.tripId !== undefined && p.tripId === opts.tripId) || + (opts.trainNumber !== undefined && p.trainNumber === opts.trainNumber), + ); + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private ensureConnected(): void { + if (this.state === 'connecting' || this.state === 'open') return; + this.intentionallyClosed = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.connect(); + } + + private connect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.state = 'connecting'; + try { + this.ws = new WebSocket(config.wsUrl); + } catch (err) { + logger.error('[ws-client] WebSocket constructor threw', err); + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.state = 'open'; + this.reconnectAttempts = 0; + // (Re-)subscribe everything we had registered. + const providers = Array.from(this.subscriptions.keys()); + if (providers.length > 0) { + this.send({ action: 'subscribe', providers }); + for (const p of providers) { + const s = this.subscriptions.get(p); + if (s) s.sentToServer = true; + } + } + logger.debug(`[ws-client] connected, subscribed to ${providers.join(',')}`); + }; + + this.ws.onmessage = event => { + let parsed: unknown; + try { + parsed = typeof event.data === 'string' ? JSON.parse(event.data) : null; + } catch { + logger.warn('[ws-client] non-JSON message', event.data); + return; + } + if (!isRealtimeUpdate(parsed)) return; + this.latest.set(parsed.provider, parsed.positions); + for (const l of this.listeners) { + try { + l(parsed); + } catch (e) { + logger.error('[ws-client] listener threw', e); + } + } + }; + + this.ws.onerror = err => { + logger.warn('[ws-client] socket error', err); + }; + + this.ws.onclose = () => { + this.state = 'closed'; + // Server-side or transport close. Mark all subscriptions as not-sent so + // the next connect re-sends them. + for (const s of this.subscriptions.values()) s.sentToServer = false; + this.ws = null; + if (!this.intentionallyClosed && this.listeners.size > 0) { + this.scheduleReconnect(); + } + }; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + const attempt = this.reconnectAttempts++; + const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS); + logger.debug(`[ws-client] reconnecting in ${delay}ms (attempt ${attempt + 1})`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private send(msg: { action: 'subscribe' | 'unsubscribe'; providers: string[] }): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(msg)); + } catch (e) { + logger.warn('[ws-client] send failed', e); + } + } + + private flushSubscribeIfOpen(providers: string[]): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + const toSend = providers.filter(p => { + const s = this.subscriptions.get(p); + return s && !s.sentToServer; + }); + if (toSend.length === 0) return; + this.send({ action: 'subscribe', providers: toSend }); + for (const p of toSend) { + const s = this.subscriptions.get(p); + if (s) s.sentToServer = true; + } + } +} + +function isRealtimeUpdate(value: unknown): value is RealtimeUpdate { + if (!value || typeof value !== 'object') return false; + const v = value as { type?: unknown; provider?: unknown; positions?: unknown }; + return ( + v.type === 'realtime_update' && + typeof v.provider === 'string' && + Array.isArray(v.positions) + ); +} + +export const wsClient = new WSClient(); diff --git a/apps/mobile/types/api.ts b/apps/mobile/types/api.ts new file mode 100644 index 0000000..7d6fbf9 --- /dev/null +++ b/apps/mobile/types/api.ts @@ -0,0 +1,159 @@ +/** + * TypeScript mirrors of the backend `spec.*` and route response types. + * Field names match the JSON tags from apps/api (camelCase). + * + * Keep this file in sync with: + * - apps/api/spec/static.go + * - apps/api/spec/realtime.go + * - apps/api/db/static_read.go (response types: EnrichedStopTime, DepartureItem, etc.) + * - apps/api/ws/poller.go (RealtimeUpdate envelope) + */ + +export interface ApiAgency { + providerId: string; + gtfsAgencyId: string; + name: string; + url: string; + timezone: string; + lang: string | null; + phone: string | null; + country: string; +} + +export interface ApiRoute { + providerId: string; + routeId: string; + shortName: string; + longName: string; + color: string; + textColor: string; + shapeId: string | null; +} + +export interface ApiStop { + providerId: string; + stopId: string; + code: string; + name: string; + lat: number; + lon: number; + timezone: string | null; + wheelchairBoarding: boolean | null; +} + +export interface ApiTrip { + providerId: string; + tripId: string; + routeId: string; + serviceId: string; + shortName: string; + headsign: string; + shapeId: string | null; + directionId: number | null; +} + +export interface ApiScheduledStopTime { + providerId: string; + tripId: string; + stopId: string; + stopSequence: number; + arrivalTime: string | null; + departureTime: string | null; + timepoint: boolean | null; + dropOffType: number | null; + pickupType: number | null; +} + +export interface ApiEnrichedStopTime extends ApiScheduledStopTime { + stopName: string; + stopCode: string; +} + +export interface ApiDepartureItem extends ApiTrip { + arrivalTime: string | null; + departureTime: string | null; + stopSequence: number; +} + +export interface ApiConnectionItem extends ApiTrip { + from: ApiEnrichedStopTime; + to: ApiEnrichedStopTime; + intermediate: ApiEnrichedStopTime[]; +} + +export interface ApiTrainItem { + providerId: string; + trainNumber: string; + sampleHeadsign: string; + tripCount: number; +} + +export interface ApiServiceInfo { + providerId: string; + trainNumber: string; + minDate: string; + maxDate: string; +} + +export type ApiSearchHitType = 'station' | 'train' | 'route'; + +export interface ApiSearchHit { + type: ApiSearchHitType; + id: string; + name: string; + subtitle: string; + provider: string; +} + +export interface ApiSearchResult { + stations: ApiSearchHit[]; + trains: ApiSearchHit[]; + routes: ApiSearchHit[]; +} + +export type VehicleStopStatus = 'INCOMING_AT' | 'STOPPED_AT' | 'IN_TRANSIT_TO'; + +export interface ApiTrainPosition { + provider: string; + tripId: string; + runDate: string; + trainNumber: string; + routeId: string; + vehicleId: string; + lat: number | null; + lon: number | null; + heading: number | null; + speedMph: number | null; + currentStopCode: string | null; + currentStatus: VehicleStopStatus | null; + lastUpdated: string; +} + +export interface ApiTrainStopTime { + provider: string; + tripId: string; + runDate: string; + stopCode: string; + stopSequence: number; + scheduledArr: string | null; + scheduledDep: string | null; + estimatedArr: string | null; + estimatedDep: string | null; + actualArr: string | null; + actualDep: string | null; + lastUpdated: string; +} + +export interface RealtimeUpdate { + type: 'realtime_update'; + provider: string; + positions: ApiTrainPosition[]; +} + +export interface ApiActiveTrain { + provider: string; + tripId: string; + runDate: string; + trainNumber: string; + routeId: string; +} diff --git a/apps/mobile/utils/api-stop-cache.ts b/apps/mobile/utils/api-stop-cache.ts new file mode 100644 index 0000000..cde6b4e --- /dev/null +++ b/apps/mobile/utils/api-stop-cache.ts @@ -0,0 +1,52 @@ +/** + * Synchronous, API-backed shim that exposes the few read methods we used + * to lean on `gtfsParser` for. Each call returns immediately from the + * api-client cache and fires a background fetch on miss; components that + * call these inside render should also subscribe to cache changes via + * `useApiCacheVersion` so they re-render when new data arrives. + * + * Multi-provider note: until the app supports per-tap provider tracking, + * all lookups assume Amtrak. Pass providerId explicitly when the caller + * already knows the namespace. + */ + +import { + getCachedAgency, + getCachedStop, + prefetchAgency, + prefetchStop, +} from '../services/api-client'; +import type { Stop } from '../types/train'; + +const DEFAULT_PROVIDER = 'amtrak'; +const DEFAULT_TIMEZONE = 'America/New_York'; + +function splitNamespaced(stopId: string): { provider: string; code: string } { + const i = stopId.indexOf(':'); + if (i <= 0) return { provider: DEFAULT_PROVIDER, code: stopId }; + return { provider: stopId.slice(0, i), code: stopId.slice(i + 1) }; +} + +export function lookupStop(stopId: string | null | undefined): Stop | undefined { + if (!stopId) return undefined; + const { provider, code } = splitNamespaced(stopId); + prefetchStop(provider, code); + const s = getCachedStop(provider, code); + if (!s) return undefined; + return { + stop_id: s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +export function lookupStopName(stopId: string | null | undefined): string { + return lookupStop(stopId)?.stop_name ?? stopId ?? ''; +} + +export function lookupAgencyTimezone(providerId: string = DEFAULT_PROVIDER): string { + prefetchAgency(providerId); + return getCachedAgency(providerId)?.timezone ?? DEFAULT_TIMEZONE; +} diff --git a/apps/mobile/utils/gtfs-parser.ts b/apps/mobile/utils/gtfs-parser.ts deleted file mode 100644 index 6ccc9e0..0000000 --- a/apps/mobile/utils/gtfs-parser.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * GTFS data parser for Amtrak trains - * Data is populated dynamically via gtfs-sync service - no bundled fallback data - */ - -import type { CalendarDateException, CalendarEntry, EnrichedStopTime, Route, SearchResult, Shape, Stop, StopTime, Trip } from '../types/train'; -import { debug, info, warn } from './logger'; -import { getCurrentMinutesInTimezone, getTimezoneForStop } from './timezone'; - -export class GTFSParser { - private routes: Map = new Map(); - private stops: Map = new Map(); - private stopTimes: Map = new Map(); - private shapes: Map = new Map(); - private trips: Map = new Map(); // keyed by trip_id - private tripsByNumber: Map = new Map(); // keyed by trip_short_name for search - private calendarEntries: Map = new Map(); // keyed by service_id - private calendarDateExceptions: Map> = new Map(); // service_id -> (date -> exception_type) - private hasCalendarData: boolean = false; - private _isLoaded: boolean = false; - private _agencyTimezone: string | null = null; - private _onLoadedListeners: Array<() => void> = []; - private _onShapesUpdatedListeners: Array<() => void> = []; - - constructor() { - // Parser starts empty - data is loaded dynamically via overrideData() - } - - get isLoaded(): boolean { - return this._isLoaded; - } - - /** Subscribe to be notified when GTFS data finishes loading. Fires immediately if already loaded. */ - onLoaded(listener: () => void): () => void { - if (this._isLoaded) { - listener(); - return () => {}; - } - this._onLoadedListeners.push(listener); - return () => { - this._onLoadedListeners = this._onLoadedListeners.filter(l => l !== listener); - }; - } - - /** Subscribe to be notified when shapes data is updated (e.g. deferred load). Fires immediately if shapes already loaded. */ - onShapesUpdated(listener: () => void): () => void { - if (this.shapes.size > 0) { - listener(); - return () => {}; - } - this._onShapesUpdatedListeners.push(listener); - return () => { - this._onShapesUpdatedListeners = this._onShapesUpdatedListeners.filter(l => l !== listener); - }; - } - - /** IANA timezone for GTFS schedule times (from agency.txt agency_timezone). - * Falls back to America/New_York (Amtrak's agency timezone) if not yet loaded. */ - get agencyTimezone(): string { - return this._agencyTimezone || 'America/New_York'; - } - - // Override parser data with dynamically fetched cache - overrideData( - routes: Route[], - stops: Stop[], - stopTimes: Record, - shapes: Record = {}, - trips: Trip[] = [], - calendar: CalendarEntry[] = [], - calendarDates: CalendarDateException[] = [], - agencyTimezone: string | null = null, - ): void { - this.routes.clear(); - this.stops.clear(); - this.stopTimes.clear(); - this.shapes.clear(); - this.trips.clear(); - this.tripsByNumber.clear(); - this.calendarEntries.clear(); - this.calendarDateExceptions.clear(); - - routes.forEach(route => { - if (route && route.route_id) this.routes.set(route.route_id, route); - }); - stops.forEach(stop => { - if (stop && stop.stop_id) this.stops.set(stop.stop_id, stop); - }); - Object.entries(stopTimes).forEach(([tripId, times]) => { - if (tripId && Array.isArray(times)) this.stopTimes.set(tripId, times); - }); - Object.entries(shapes).forEach(([shapeId, points]) => { - if (shapeId && Array.isArray(points)) this.shapes.set(shapeId, points); - }); - // Populate trips maps - trips.forEach(trip => { - if (trip && trip.trip_id) { - this.trips.set(trip.trip_id, trip); - // Also index by trip_short_name for search - if (trip.trip_short_name) { - const existing = this.tripsByNumber.get(trip.trip_short_name) || []; - existing.push(trip); - this.tripsByNumber.set(trip.trip_short_name, existing); - } - } - }); - - // Populate calendar maps - calendar.forEach(entry => { - if (entry && entry.service_id) { - this.calendarEntries.set(entry.service_id, entry); - } - }); - calendarDates.forEach(exception => { - if (exception && exception.service_id) { - let dateMap = this.calendarDateExceptions.get(exception.service_id); - if (!dateMap) { - dateMap = new Map(); - this.calendarDateExceptions.set(exception.service_id, dateMap); - } - dateMap.set(exception.date, exception.exception_type); - } - }); - this.hasCalendarData = this.calendarEntries.size > 0 || this.calendarDateExceptions.size > 0; - this._agencyTimezone = agencyTimezone || null; - - this._isLoaded = this.routes.size > 0 && this.stops.size > 0; - - debug(`[GTFSParser] Data loaded: ${this.routes.size} routes, ${this.stops.size} stops, ${this.stopTimes.size} trips, ${this.trips.size} trip records, ${this.shapes.size} shapes`); - if (this.hasCalendarData) { - debug(`[GTFSParser] Calendar: ${this.calendarEntries.size} entries, ${this.calendarDateExceptions.size} exception sets`); - } - if (!this._isLoaded) { - warn('[GTFSParser] Data loaded but parser reports not ready - routes or stops may be empty'); - } - - // Notify listeners - if (this._isLoaded) { - for (const listener of this._onLoadedListeners) { - listener(); - } - this._onLoadedListeners = []; - } - // Notify shapes listeners if shapes were provided (fresh download path) - if (this.shapes.size > 0) { - for (const listener of this._onShapesUpdatedListeners) { - listener(); - } - this._onShapesUpdatedListeners = []; - } - } - - /** Update only shapes data (used for deferred shape loading) */ - updateShapes(shapes: Record): void { - this.shapes.clear(); - Object.entries(shapes).forEach(([shapeId, points]) => { - if (shapeId && Array.isArray(points)) this.shapes.set(shapeId, points); - }); - debug(`[GTFSParser] Shapes updated: ${this.shapes.size} shapes`); - for (const listener of this._onShapesUpdatedListeners) { - listener(); - } - this._onShapesUpdatedListeners = []; - } - - getRouteName(routeId: string): string { - return this.routes.get(routeId)?.route_long_name || 'Unknown Route'; - } - - getStopName(stopId: string): string { - return this.stops.get(stopId)?.stop_name || stopId; - } - - getStopCode(stopId: string): string { - return stopId; - } - - getStop(stopId: string): Stop | undefined { - return this.stops.get(stopId); - } - - getRoute(routeId: string): Route | undefined { - return this.routes.get(routeId); - } - - getIntermediateStops(tripId: string): EnrichedStopTime[] { - const times = this.stopTimes.get(tripId) || []; - // Filter out first and last stops, return intermediate stops - return times - .slice(1, -1) - .map(time => ({ - ...time, - stop_name: this.getStopName(time.stop_id), - stop_code: time.stop_id, - })) - .sort((a, b) => a.stop_sequence - b.stop_sequence); - } - - getStopTimesForTrip(tripId: string): EnrichedStopTime[] { - const times = this.stopTimes.get(tripId) || []; - return times - .map(time => ({ - ...time, - stop_name: this.getStopName(time.stop_id), - stop_code: time.stop_id, - })) - .sort((a, b) => a.stop_sequence - b.stop_sequence); - } - - /** - * Check if a service_id is active on a given date. - * Returns true if no calendar data is loaded (backwards compatible fallback). - */ - isServiceActiveOnDate(serviceId: string, date: Date): boolean { - if (!this.hasCalendarData) return true; - if (!serviceId) return true; - - // Convert date to YYYYMMDD integer for fast comparison - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const dateInt = year * 10000 + month * 100 + day; - - // Check calendar_dates exceptions first (they override base schedule) - const exceptions = this.calendarDateExceptions.get(serviceId); - if (exceptions) { - const exceptionType = exceptions.get(dateInt); - if (exceptionType === 1) return true; // explicitly added - if (exceptionType === 2) return false; // explicitly removed - } - - // Check base calendar schedule - const entry = this.calendarEntries.get(serviceId); - if (!entry) return false; // no calendar entry and no exception = not active - - // Check date range - if (dateInt < entry.start_date || dateInt > entry.end_date) return false; - - // Check day of week (JS: 0=Sun, 1=Mon, ..., 6=Sat) - const dayOfWeek = date.getDay(); - switch (dayOfWeek) { - case 0: return entry.sunday; - case 1: return entry.monday; - case 2: return entry.tuesday; - case 3: return entry.wednesday; - case 4: return entry.thursday; - case 5: return entry.friday; - case 6: return entry.saturday; - default: return false; - } - } - - getTripsForStop(stopId: string, date?: Date): string[] { - const trips: string[] = []; - const seen = new Set(); - this.stopTimes.forEach((times, tripId) => { - if (times.some(t => t.stop_id === stopId) && !seen.has(tripId)) { - // If date provided, filter by service calendar - if (date) { - const trip = this.trips.get(tripId); - if (trip && !this.isServiceActiveOnDate(trip.service_id, date)) return; - } - trips.push(tripId); - seen.add(tripId); - } - }); - return trips; - } - - getAllRoutes(): Route[] { - return Array.from(this.routes.values()); - } - - getAllStops(): Stop[] { - return Array.from(this.stops.values()); - } - - getAllTripIds(): string[] { - return Array.from(this.stopTimes.keys()); - } - - /** - * Get trip by trip_id - */ - getTrip(tripId: string): Trip | undefined { - return this.trips.get(tripId); - } - - /** - * Get the actual train number (trip_short_name) for a trip_id - * Falls back to extracting from trip_id if not found - * Returns null if no valid train number can be determined - */ - getTrainNumber(tripId: string): string | null { - const trip = this.trips.get(tripId); - if (trip?.trip_short_name) return trip.trip_short_name; - const match = tripId.match(/_(\d{1,4})$/); - if (match) return match[1]; - return null; - } - - /** - * Get route_id for a trip_id - */ - getRouteIdForTrip(tripId: string): string | undefined { - return this.trips.get(tripId)?.route_id; - } - - /** - * Search trips by train number (trip_short_name) - */ - getTripsByNumber(trainNumber: string): Trip[] { - return this.tripsByNumber.get(trainNumber) || []; - } - - /** - * Get all trips - */ - getAllTrips(): Trip[] { - return Array.from(this.trips.values()); - } - - getRawShapesData(): Record { - const result: Record = {}; - this.shapes.forEach((points, shapeId) => { - result[shapeId] = points; - }); - return result; - } - - search(query: string): SearchResult[] { - const results: SearchResult[] = []; - const queryLower = query.toLowerCase(); - // Support searching by AMT{train}, {name}{train}, or just {train} - let trainNumberQuery = ''; - // If query starts with 'amt', treat as AMT{train} - if (queryLower.startsWith('amt')) { - trainNumberQuery = queryLower.substring(3); - } else if (/^\d+$/.test(query)) { - // Pure number query - search by actual train number - trainNumberQuery = query; - } else { - // Try to match {name}{train} pattern (e.g., acela2150, crescent19) - const nameTrainMatch = queryLower.match(/^([a-z]+)(\d{1,4})$/); - if (nameTrainMatch) { - trainNumberQuery = nameTrainMatch[2]; - } - } - - // Search stops (stations) - this.stops.forEach(stop => { - if (stop.stop_name.toLowerCase().includes(queryLower)) { - results.push({ - id: `stop-name-${stop.stop_id}`, - name: stop.stop_name, - subtitle: `Name contains "${query}"`, - type: 'station', - data: stop, - }); - } - // Match by stop ID (abbreviation) - else if (stop.stop_id.toLowerCase().includes(queryLower)) { - results.push({ - id: `stop-id-${stop.stop_id}`, - name: stop.stop_name, - subtitle: `Station matches "${stop.stop_id}"`, - type: 'station', - data: stop, - }); - } - }); - - // Search routes - this.routes.forEach(route => { - if ( - route.route_long_name.toLowerCase().includes(queryLower) || - route.route_short_name?.toLowerCase().includes(queryLower) || - route.route_id.toLowerCase().includes(queryLower) - ) { - results.push({ - id: `route-${route.route_id}`, - name: route.route_long_name, - subtitle: `AMT${route.route_id}`, - type: 'route', - data: route, - }); - } - }); - - // Search by actual train number using trips data - if (trainNumberQuery) { - const matchingTrips = this.tripsByNumber.get(trainNumberQuery) || []; - for (const trip of matchingTrips.slice(0, 5)) { - const routeName = this.getRouteName(trip.route_id); - const displayName = - routeName !== 'Unknown Route' ? `${routeName} ${trip.trip_short_name}` : `Train ${trip.trip_short_name}`; - results.push({ - id: `train-${trip.trip_id}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: { trip_id: trip.trip_id }, - }); - } - } - - // Search trips (trains) by their stops - this.stopTimes.forEach((times, tripId) => { - const trainNumber = this.getTrainNumber(tripId) || tripId; - const trip = this.trips.get(tripId); - const routeName = trip?.route_id ? this.getRouteName(trip.route_id) : ''; - const displayName = - routeName && routeName !== 'Unknown Route' ? `${routeName} ${trainNumber}` : `Train ${trainNumber}`; - - // Check for AMT{train} or {name}{train} match (legacy support) - const tripIdLower = tripId.toLowerCase(); - if ( - trainNumberQuery && - tripIdLower.endsWith(trainNumberQuery) && - !results.find(r => r.id === `train-${tripId}`) - ) { - results.push({ - id: `tripid-${tripId}`, - name: displayName, - subtitle: trip?.trip_headsign || '', - type: 'train', - data: { trip_id: tripId }, - }); - } - const uniqueStops = new Set(times.map(t => t.stop_id)); - uniqueStops.forEach(stopId => { - const stop = this.stops.get(stopId); - if (stop && stop.stop_name.toLowerCase().includes(queryLower)) { - results.push({ - id: `trip-stop-${tripId}-${stopId}`, - name: displayName, - subtitle: `Stops at "${stop.stop_name}"`, - type: 'train', - data: { trip_id: tripId, stop_id: stopId, stop_name: stop.stop_name }, - }); - } - }); - }); - - // Remove duplicates and limit results - const seen = new Set(); - const filtered = results - .filter(result => { - if (seen.has(result.id)) return false; - seen.add(result.id); - return true; - }) - .slice(0, 20); // Limit to 20 results - debug(`[GTFSParser] search("${query}"): ${filtered.length} results`); - return filtered; - } - - getShape(shapeId: string): Shape[] | undefined { - return this.shapes.get(shapeId); - } - - getAllShapeIds(): string[] { - return Array.from(this.shapes.keys()); - } - - // Get all shapes as polyline coordinates for map rendering - getShapesForMap(): Array<{ id: string; coordinates: Array<{ latitude: number; longitude: number }> }> { - const result: Array<{ id: string; coordinates: Array<{ latitude: number; longitude: number }> }> = []; - this.shapes.forEach((points, shapeId) => { - result.push({ - id: shapeId, - coordinates: points.map(p => ({ - latitude: p.shape_pt_lat, - longitude: p.shape_pt_lon, - })), - }); - }); - return result; - } - - /** - * Unified search returning categorized results for the initial search bar. - * Returns trains, routes, and stations in separate arrays. - */ - searchUnified(query: string): { trains: SearchResult[]; routes: SearchResult[]; stations: SearchResult[] } { - const queryLower = query.toLowerCase().trim(); - if (!queryLower) return { trains: [], routes: [], stations: [] }; - - const trains: SearchResult[] = []; - const routes: SearchResult[] = []; - const stations: SearchResult[] = []; - - // --- Train number matching --- - // Extract a numeric portion for train number prefix matching - let trainNumberQuery = ''; - if (queryLower.startsWith('amt')) { - trainNumberQuery = queryLower.substring(3); - } else if (/^\d+$/.test(queryLower)) { - trainNumberQuery = queryLower; - } else { - const nameTrainMatch = queryLower.match(/^([a-z]+)(\d{1,4})$/); - if (nameTrainMatch) { - trainNumberQuery = nameTrainMatch[2]; - } - } - - if (trainNumberQuery) { - // Prefix match: "21" matches 21, 210, 2150, etc. - const seenNumbers = new Set(); - this.tripsByNumber.forEach((trips, trainNum) => { - if (trainNum.startsWith(trainNumberQuery) && !seenNumbers.has(trainNum)) { - seenNumbers.add(trainNum); - const trip = trips[0]; - if (trip) { - const routeName = this.getRouteName(trip.route_id); - const displayName = routeName !== 'Unknown Route' - ? `${routeName} ${trip.trip_short_name}` - : `Train ${trip.trip_short_name}`; - trains.push({ - id: `train-num-${trainNum}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: trip, - }); - } - } - }); - } - - // Also match train numbers where the route name matches the alpha part - if (queryLower.match(/^[a-z]/)) { - const seenNumbers = new Set(trains.map(t => { - const trip = t.data as Trip; - return trip.trip_short_name || ''; - })); - this.tripsByNumber.forEach((trips, trainNum) => { - if (seenNumbers.has(trainNum)) return; - const trip = trips[0]; - if (!trip) return; - const routeName = this.getRouteName(trip.route_id).toLowerCase(); - const displayName = routeName !== 'unknown route' - ? `${this.getRouteName(trip.route_id)} ${trip.trip_short_name}` - : `Train ${trip.trip_short_name}`; - // Match if route name starts with query or query matches "routename + number" - if (routeName.startsWith(queryLower) || displayName.toLowerCase().includes(queryLower)) { - seenNumbers.add(trainNum); - trains.push({ - id: `train-num-${trainNum}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: trip, - }); - } - }); - } - - // --- Route matching --- - this.routes.forEach(route => { - if ( - route.route_long_name.toLowerCase().includes(queryLower) || - route.route_short_name?.toLowerCase().includes(queryLower) - ) { - routes.push({ - id: `route-${route.route_id}`, - name: route.route_long_name, - subtitle: route.route_short_name || '', - type: 'route', - data: route, - }); - } - }); - - // --- Station matching --- - this.stops.forEach(stop => { - if ( - stop.stop_name.toLowerCase().includes(queryLower) || - stop.stop_id.toLowerCase().includes(queryLower) - ) { - stations.push({ - id: `stop-${stop.stop_id}`, - name: stop.stop_name, - subtitle: stop.stop_id, - type: 'station', - data: stop, - }); - } - }); - - return { - trains: trains.slice(0, 5), - routes: routes.slice(0, 5), - stations: stations.slice(0, 8), - }; - } - - /** - * Given a train number and date, find the specific Trip active on that date. - * Falls back to the first trip for the train number if no calendar match. - */ - getTripForTrainOnDate(trainNumber: string, date: Date): Trip | undefined { - const trips = this.tripsByNumber.get(trainNumber); - if (!trips || trips.length === 0) return undefined; - - // Try to find a trip whose service is active on the given date - const activeTrip = trips.find(trip => this.isServiceActiveOnDate(trip.service_id, date)); - return activeTrip || trips[0]; - } - - /** - * Get the GTFS calendar date range and service-day check for a given train number. - * Used to constrain date pickers and show live warnings for non-service days. - */ - getServiceInfoForTrain(trainNumber: string): { minDate: Date; maxDate: Date } | null { - const trips = this.tripsByNumber.get(trainNumber); - if (!trips || trips.length === 0 || !this.hasCalendarData) return null; - - let earliest = Infinity; - let latest = -Infinity; - - for (const trip of trips) { - const entry = this.calendarEntries.get(trip.service_id); - if (entry) { - if (entry.start_date < earliest) earliest = entry.start_date; - if (entry.end_date > latest) latest = entry.end_date; - } - } - - if (earliest === Infinity || latest === -Infinity) return null; - - // Convert YYYYMMDD integers to Date objects - const toDate = (d: number) => { - const year = Math.floor(d / 10000); - const month = Math.floor((d % 10000) / 100) - 1; - const day = d % 100; - return new Date(year, month, day); - }; - - return { minDate: toDate(earliest), maxDate: toDate(latest) }; - } - - /** - * Get all unique train numbers (trip_short_name) that belong to a given route_id. - * Returns array of { trainNumber, displayName, headsign }. - */ - getTrainNumbersForRoute(routeId: string): Array<{ trainNumber: string; displayName: string; headsign: string; endpointLabel: string }> { - const results: Array<{ trainNumber: string; displayName: string; headsign: string; endpointLabel: string }> = []; - const seen = new Set(); - const routeName = this.getRouteName(routeId); - - this.tripsByNumber.forEach((trips, trainNum) => { - if (seen.has(trainNum)) return; - const trip = trips.find(t => t.route_id === routeId); - if (trip) { - seen.add(trainNum); - const displayName = routeName !== 'Unknown Route' - ? `${routeName} ${trainNum}` - : `Train ${trainNum}`; - // Get first/last stop abbreviations - let endpointLabel = ''; - const stopTimes = this.stopTimes.get(trip.trip_id); - if (stopTimes && stopTimes.length >= 2) { - const sorted = [...stopTimes].sort((a, b) => a.stop_sequence - b.stop_sequence); - endpointLabel = `${sorted[0].stop_id} → ${sorted[sorted.length - 1].stop_id}`; - } - results.push({ - trainNumber: trainNum, - displayName, - headsign: trip.trip_headsign || '', - endpointLabel, - }); - } - }); - - // Sort by train number numerically - results.sort((a, b) => { - const numA = parseInt(a.trainNumber, 10); - const numB = parseInt(b.trainNumber, 10); - if (!isNaN(numA) && !isNaN(numB)) return numA - numB; - return a.trainNumber.localeCompare(b.trainNumber); - }); - - return results; - } - - /** - * Get unique routes that serve a given stop (by scanning stop_times). - */ - getRoutesServingStop(stopId: string): Route[] { - const routeIds = new Set(); - this.stopTimes.forEach((times, tripId) => { - if (routeIds.size >= 10) return; // enough - if (times.some(t => t.stop_id === stopId)) { - const trip = this.trips.get(tripId); - if (trip) routeIds.add(trip.route_id); - } - }); - const routes: Route[] = []; - for (const rid of routeIds) { - const route = this.routes.get(rid); - if (route) routes.push(route); - } - return routes; - } - - /** - * Get upcoming trains departing from a stop today. - * Returns trips sorted by departure time, filtered to active services and future departures. - */ - getUpcomingTrainsFromStop(stopId: string, limit = 2): Array<{ trip: Trip; departureTime: string; trainNumber: string; routeName: string }> { - const now = new Date(); - // GTFS stop times are in the agency timezone, so compare "now" in that timezone - const nowMinutes = getCurrentMinutesInTimezone(this._agencyTimezone); - const results: Array<{ trip: Trip; departureTime: string; trainNumber: string; routeName: string; depMinutes: number }> = []; - const seenTrainNumbers = new Set(); - - this.stopTimes.forEach((times, tripId) => { - const stopTime = times.find(t => t.stop_id === stopId); - if (!stopTime) return; - - const trip = this.trips.get(tripId); - if (!trip) return; - if (!this.isServiceActiveOnDate(trip.service_id, now)) return; - - const [hStr, mStr] = stopTime.departure_time.split(':'); - const depMinutes = parseInt(hStr, 10) * 60 + parseInt(mStr, 10); - if (depMinutes <= nowMinutes) return; // already departed - - const trainNumber = trip.trip_short_name || tripId; - if (seenTrainNumbers.has(trainNumber)) return; - seenTrainNumbers.add(trainNumber); - - results.push({ - trip, - departureTime: stopTime.departure_time, - trainNumber, - routeName: this.getRouteName(trip.route_id), - depMinutes, - }); - }); - - results.sort((a, b) => a.depMinutes - b.depMinutes); - return results.slice(0, limit).map(({ trip, departureTime, trainNumber, routeName }) => ({ - trip, departureTime, trainNumber, routeName, - })); - } - - /** - * Search for stations only (for the two-station search flow) - */ - searchStations(query: string): Stop[] { - const queryLower = query.toLowerCase(); - const results: Stop[] = []; - - this.stops.forEach(stop => { - if (stop.stop_name.toLowerCase().includes(queryLower) || stop.stop_id.toLowerCase().includes(queryLower)) { - results.push(stop); - } - }); - - return results.slice(0, 10); - } - - /** - * Find all trips that stop at both stations in sequence (fromStop before toStop) - */ - findTripsWithStops( - fromStopId: string, - toStopId: string, - date?: Date - ): Array<{ - tripId: string; - fromStop: EnrichedStopTime; - toStop: EnrichedStopTime; - intermediateStops: EnrichedStopTime[]; - }> { - const results: Array<{ - tripId: string; - fromStop: EnrichedStopTime; - toStop: EnrichedStopTime; - intermediateStops: EnrichedStopTime[]; - }> = []; - - this.stopTimes.forEach((times, tripId) => { - // If date provided, filter by service calendar - if (date) { - const trip = this.trips.get(tripId); - if (trip && !this.isServiceActiveOnDate(trip.service_id, date)) return; - } - - const fromIdx = times.findIndex(t => t.stop_id === fromStopId); - const toIdx = times.findIndex(t => t.stop_id === toStopId); - - // Both stops must exist and fromStop must come before toStop - if (fromIdx !== -1 && toIdx !== -1 && fromIdx < toIdx) { - const fromStop = times[fromIdx]; - const toStop = times[toIdx]; - const intermediateStops = times.slice(fromIdx + 1, toIdx); - - results.push({ - tripId, - fromStop: { - ...fromStop, - stop_name: this.getStopName(fromStop.stop_id), - stop_code: fromStop.stop_id, - }, - toStop: { - ...toStop, - stop_name: this.getStopName(toStop.stop_id), - stop_code: toStop.stop_id, - }, - intermediateStops: intermediateStops.map(s => ({ - ...s, - stop_name: this.getStopName(s.stop_id), - stop_code: s.stop_id, - })), - }); - } - }); - - // Sort by departure time - results.sort((a, b) => a.fromStop.departure_time.localeCompare(b.fromStop.departure_time)); - - // Deduplicate by train number + departure time (same train on different days has different trip_ids) - const seen = new Set(); - const deduped = results.filter(result => { - const trip = this.trips.get(result.tripId); - const trainNumber = trip?.trip_short_name || result.tripId; - const key = `${trainNumber}-${result.fromStop.departure_time}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - debug(`[GTFSParser] findTripsWithStops(${fromStopId} → ${toStopId}): ${deduped.length} trips found`); - return deduped; - } -} - -// Export singleton instance -export const gtfsParser = new GTFSParser(); diff --git a/apps/mobile/utils/timezone.ts b/apps/mobile/utils/timezone.ts index d2c6a95..860da1f 100644 --- a/apps/mobile/utils/timezone.ts +++ b/apps/mobile/utils/timezone.ts @@ -1,5 +1,6 @@ import tzlookup from '@photostructure/tz-lookup'; import type { Stop } from '../types/train'; +import { lookupAgencyTimezone, lookupStop } from './api-stop-cache'; import { formatTimeWithDayOffset, type FormattedTime } from './time-formatting'; import { logger } from './logger'; @@ -149,16 +150,14 @@ export function convertGtfsTimeToLocal( /** * Convenience: convert a GTFS time to local timezone for a given stop code. - * Looks up the stop via gtfsParser and derives its timezone. - * Falls back to formatTimeWithDayOffset if stop not found. + * Falls back to formatTimeWithDayOffset if the stop isn't yet in the API + * cache (a fetch is fired in the background by lookupStop). */ export function convertGtfsTimeForStop(gtfsTime24: string, stopCode: string): FormattedTime { - // Lazy import to avoid circular dependency - const { gtfsParser } = require('./gtfs-parser'); - const stop = gtfsParser.getStop(stopCode); + const stop = lookupStop(stopCode); if (!stop) { return formatTimeWithDayOffset(gtfsTime24); } const stopTz = getTimezoneForStop(stop); - return convertGtfsTimeToLocal(gtfsTime24, gtfsParser.agencyTimezone, stopTz); + return convertGtfsTimeToLocal(gtfsTime24, lookupAgencyTimezone(), stopTz); } diff --git a/apps/mobile/utils/train-display.ts b/apps/mobile/utils/train-display.ts index 5454222..05b5882 100644 --- a/apps/mobile/utils/train-display.ts +++ b/apps/mobile/utils/train-display.ts @@ -2,8 +2,8 @@ * Shared display/formatting utilities for train UI components. */ import type { Train } from '../types/train'; +import { lookupAgencyTimezone, lookupStop } from './api-stop-cache'; import { parseTimeToMinutes, timeToMinutes } from './time-formatting'; -import { gtfsParser } from './gtfs-parser'; import { getCurrentSecondsInTimezone, getTimezoneForStop } from './timezone'; /** @@ -19,8 +19,8 @@ export function getCountdownForTrain(train: Train): { const days = Math.round(train.daysAway); return { value: days, unit: days === 1 ? 'DAY' : 'DAYS', past: false }; } - const fromStop = gtfsParser.getStop(train.fromCode); - const fromTz = fromStop ? getTimezoneForStop(fromStop) : gtfsParser.agencyTimezone; + const fromStop = lookupStop(train.fromCode); + const fromTz = fromStop ? getTimezoneForStop(fromStop) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(fromTz); const departSec = parseTimeToMinutes(train.departTime) * 60 + (train.realtime?.delay && train.realtime.delay > 0 ? train.realtime.delay * 60 : 0); diff --git a/apps/mobile/utils/train-helpers.ts b/apps/mobile/utils/train-helpers.ts index f146eb6..face00d 100644 --- a/apps/mobile/utils/train-helpers.ts +++ b/apps/mobile/utils/train-helpers.ts @@ -3,7 +3,7 @@ * Consolidated from services/api.ts and services/realtime.ts */ -import { gtfsParser } from './gtfs-parser'; +import { getCachedTrip, prefetchTrip } from '../services/api-client'; import { parseTimeToDate } from './time-formatting'; import type { Train } from '../types/train'; @@ -41,11 +41,13 @@ export function isLikelyTrainNumber(s: string): boolean { * extractTrainNumber("248766") // null (opaque database ID) */ export function extractTrainNumber(tripId: string): string | null { - // 1. GTFS static data (source of truth) - const fromGtfs = gtfsParser.getTrainNumber(tripId); - if (fromGtfs && fromGtfs !== tripId && isLikelyTrainNumber(fromGtfs)) { - return fromGtfs; + // 1. API cache (source of truth when available); fire a fetch on miss so a + // later call sees it. + const cached = getCachedTrip(tripId); + if (cached?.shortName && isLikelyTrainNumber(cached.shortName)) { + return cached.shortName; } + if (!cached) prefetchTrip(tripId); // 2. Structured: trailing number after underscore (YYYY-MM-DD_AMTK_543) const underscoreMatch = tripId.match(/_(\d{1,4})$/); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd2e8f6..fd23fca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - expo-widgets@55.0.4: - hash: 919eee81369cbc6e5054e203ff6e8892a0c5e504fd1106481f9af102927d792f - path: patches/expo-widgets@55.0.4.patch + expo-widgets@55.0.4: 919eee81369cbc6e5054e203ff6e8892a0c5e504fd1106481f9af102927d792f importers: @@ -167,6 +165,9 @@ importers: '@types/react': specifier: ~19.2.10 version: 19.2.14 + '@types/react-native-vector-icons': + specifier: ^6.4.18 + version: 6.4.18 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -2546,6 +2547,12 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-native-vector-icons@6.4.18': + resolution: {integrity: sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==} + + '@types/react-native@0.70.19': + resolution: {integrity: sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -9941,6 +9948,15 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-native-vector-icons@6.4.18': + dependencies: + '@types/react': 19.2.14 + '@types/react-native': 0.70.19 + + '@types/react-native@0.70.19': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3