diff --git a/README.md b/README.md index 8409e84..3b3a7b3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ const lTrain = await mta.subway.arrivals({ stopId: "L08", route: "L", }); + +const direction = await mta.subway.direction({ + route: "L", + fromStopId: "L06", + destination: "Union Sq", +}); ``` Arrival rows may include display-oriented fields. `destination` depends on @@ -34,18 +40,17 @@ generic fallback label: ```ts for (const arrival of lTrain) { - const direction = - arrival.displayDirection ?? - (arrival.destination - ? `toward ${arrival.destination}` - : arrival.headsign - ? `toward ${arrival.headsign}` - : arrival.direction); - - console.log(`${arrival.route.shortName} ${direction} from ${arrival.stop.name}`); + console.log( + `${arrival.route.shortName} ${arrival.displayDirection ?? arrival.destination ?? "unknown direction"} from ${arrival.stop.displayName ?? arrival.stop.name}`, + ); } ``` +Use `mta.subway.direction(...)` when a rider enters an intermediate destination +such as `Union Sq`; the hosted API resolves it against static GTFS route order +and returns a typed `direction`, `destinationStop`, and terminal-facing +`displayDirection` such as `toward 8 Av`. + NYC Subway realtime feeds use NYCT's `north`/`south` stop directions, even on east-west lines. For the L train, `mta-js` accepts rider-facing `east`/`west` aliases and maps them to the underlying feed directions. @@ -110,6 +115,7 @@ override them. ## Endpoints - `mta.subway.arrivals(...)` +- `mta.subway.direction(...)` - `mta.bus.arrivals(...)` - `mta.bus.vehicles(...)` - `mta.alerts.current(...)` diff --git a/hosted-integration.test.ts b/hosted-integration.test.ts index 8f8349f..7cf8f18 100644 --- a/hosted-integration.test.ts +++ b/hosted-integration.test.ts @@ -27,9 +27,16 @@ describe.skipIf(!apiKey)("hosted integration tests", () => { limit: 5, }); + const direction = await mta.subway.direction({ + route: "L", + fromStopId: "L06", + destination: "Union Sq", + }); + expect(Array.isArray(arrivals)).toBe(true); expect(Array.isArray(stops)).toBe(true); expect(Array.isArray(vehicles)).toBe(true); + expect(typeof direction.resolved).toBe("boolean"); expect(stops.length).toBeGreaterThan(0); expect(stops.every((stop) => stop.routeMatch)).toBe(true); }, 30_000); diff --git a/index.test.ts b/index.test.ts index a7e7428..a215e0b 100644 --- a/index.test.ts +++ b/index.test.ts @@ -137,6 +137,59 @@ test("hosted subway arrivals infer L route and normalize east aliases before req expect(url.searchParams.get("direction")).toBe("south"); }); +test("hosted subway direction calls resolve intermediate destinations through the API", async () => { + let request: Request | undefined; + const mta = new MTA({ + apiKey: "test-key", + apiBaseUrl: "https://api.example.com", + fetch: (async (input, init) => { + request = input instanceof Request ? new Request(input, init) : new Request(String(input), init); + return Response.json({ + route: { id: "L", shortName: "L" }, + destination: "Union Sq", + normalizedDestination: "union sq", + resolved: true, + direction: "north", + displayDirection: "toward 8 Av", + terminal: "8 Av", + fromStop: { + id: "L06", + name: "1 Av", + displayName: "1 Av", + parentId: "L06", + }, + destinationStop: { + id: "L03", + name: "14 St-Union Sq", + displayName: "14 St-Union Sq", + parentId: "L03", + }, + }); + }) as typeof fetch, + }); + + const resolution = await mta.subway.direction({ + route: "L", + fromStopId: "L06", + destination: "Union Sq", + }); + + const url = new URL(request?.url ?? ""); + expect(url.pathname).toBe("/api/v1/subway/direction"); + expect(url.searchParams.get("route")).toBe("L"); + expect(url.searchParams.get("fromStopId")).toBe("L06"); + expect(url.searchParams.get("destination")).toBe("Union Sq"); + expect(resolution).toMatchObject({ + resolved: true, + direction: "north", + displayDirection: "toward 8 Av", + fromStop: { + displayName: "1 Av", + parentId: "L06", + }, + }); +}); + test("subway arrivals decode GTFS realtime and join in-memory static metadata", async () => { const feed = encodeFeedMessage({ header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 }, diff --git a/index.ts b/index.ts index a1344a0..a01f86c 100644 --- a/index.ts +++ b/index.ts @@ -17,6 +17,8 @@ import type { Stop, StopsNearQuery, SubwayArrivalQuery, + SubwayDirectionQuery, + SubwayDirectionResolution, TransitMode, Vehicle, } from "./src/types"; @@ -133,6 +135,10 @@ class SubwayClient { .slice(0, query.limit ?? 20); } + direction(query: SubwayDirectionQuery): Promise { + return this.mta.hostedJson("/api/v1/subway/direction", query); + } + private feedForRoute(route: string) { const feed = this.mta.endpoints.subwayFeeds[route]; if (!feed) throw new UnknownRouteError(route); diff --git a/package.json b/package.json index 9793475..1a9ecbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mta-js", - "version": "2.1.1", + "version": "2.2.0", "description": "A TypeScript client for MTA realtime feeds and the hosted MTA API.", "license": "MIT", "repository": { diff --git a/src/types.ts b/src/types.ts index a8eec09..3f2b28a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,9 @@ export type BusStopId = AutocompleteString; export type TransitMode = "subway" | "bus" | "lirr" | "metro-north"; export type Direction = "north" | "south" | "east" | "west" | "unknown"; +export type SubwayResolvedDirection = "north" | "south"; + +export type DirectionHeadsigns = Record; export interface MTAOptions { apiKey?: string; @@ -131,17 +134,26 @@ export interface Route { export interface Stop { id: string; name: string; + displayName?: string; lat?: number; lon?: number; parentStation?: string; + parentId?: string; mode?: TransitMode; } +export type ServedRoute = Route & { + headsigns?: string[]; + directionHeadsigns?: DirectionHeadsigns; + directions?: number[]; +}; + export type NearbyStop = Stop & { distanceMeters?: number; - servedRoutes?: Route[]; + servedRoutes?: ServedRoute[]; routeMatch?: boolean; routeHeadsigns?: string[]; + directionHeadsigns?: DirectionHeadsigns; note?: string; }; @@ -200,6 +212,26 @@ export interface SubwayArrivalQuery { includeRaw?: boolean; } +export interface SubwayDirectionQuery { + route: SubwayRoute; + fromStopId: SubwayStopId; + destination: string; +} + +export interface SubwayDirectionResolution { + route: Route; + destination: string; + normalizedDestination: string; + resolved: boolean; + direction?: SubwayResolvedDirection; + displayDirection?: string; + terminal?: string; + fromStop?: Stop; + destinationStop?: Stop; + matches?: Stop[]; + reason?: string; +} + export interface BusArrivalQuery { stopId: BusStopId; route?: BusRoute;