From 94986f64d50d4f773019de84615ed8445a1d575d Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Wed, 27 May 2026 00:35:20 -0400 Subject: [PATCH 1/2] Add display fields to arrivals --- README.md | 21 ++++++++ index.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.ts | 51 +++++++++++++++---- package.json | 2 +- src/types.ts | 2 + 5 files changed, 198 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4c5237d..85339b5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ const lTrain = await mta.subway.arrivals({ }); ``` +Arrival rows include display-oriented fields when destination metadata is +available: + +```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}`); +} +``` + +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. + When `apiKey` is present, `mta-js` sends requests to the hosted API at `https://www.mtaapi.dev` by default. Override `apiBaseUrl` for tests or private deployments. diff --git a/index.test.ts b/index.test.ts index 54d9b63..a7e7428 100644 --- a/index.test.ts +++ b/index.test.ts @@ -25,6 +25,26 @@ const staticData = { stop_lat: 40.682, stop_lon: -73.978, }, + { + stop_id: "L06", + stop_name: "1 Av", + stop_lat: 40.730953, + stop_lon: -73.981628, + }, + { + stop_id: "L06S", + stop_name: "1 Av", + stop_lat: 40.730953, + stop_lon: -73.981628, + parent_station: "L06", + }, + { + stop_id: "L06N", + stop_name: "1 Av", + stop_lat: 40.730953, + stop_lon: -73.981628, + parent_station: "L06", + }, ], routes: [ { @@ -40,6 +60,13 @@ const staticData = { route_long_name: "Bay Ridge - Cobble Hill", route_type: 3, }, + { + route_id: "L", + route_short_name: "L", + route_long_name: "14 St-Canarsie Local", + route_type: 1, + route_color: "7C858C", + }, ], trips: [ { @@ -49,6 +76,20 @@ const staticData = { trip_headsign: "Inwood-207 St", direction_id: 0, }, + { + route_id: "L", + service_id: "weekday", + trip_id: "L-trip-1", + trip_headsign: "Canarsie-Rockaway Pkwy", + direction_id: 1, + }, + { + route_id: "L", + service_id: "weekday", + trip_id: "L-trip-2", + trip_headsign: "8 Av", + direction_id: 0, + }, ], }; @@ -77,6 +118,25 @@ test("hosted API calls include bearer and x-api-key auth headers", async () => { expect(request?.headers.get("x-api-key")).toBe("test-key"); }); +test("hosted subway arrivals infer L route and normalize east aliases before requesting 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([]); + }) as typeof fetch, + }); + + await mta.subway.arrivals({ stopId: "L06", direction: "east" }); + + const url = new URL(request?.url ?? ""); + expect(url.pathname).toBe("/api/v1/subway/arrivals"); + expect(url.searchParams.get("route")).toBe("L"); + expect(url.searchParams.get("direction")).toBe("south"); +}); + 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 }, @@ -120,12 +180,81 @@ test("subway arrivals decode GTFS realtime and join in-memory static metadata", name: "Jay St-MetroTech", }, direction: "north", + destination: "Inwood-207 St", + displayDirection: "toward Inwood-207 St", headsign: "Inwood-207 St", tripId: "A-trip-1", source: "mta-gtfs-rt", }); }); +test("subway arrivals map L east and west aliases to NYCT north and south directions", async () => { + const feed = encodeFeedMessage({ + header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 }, + entity: [ + { + id: "arrival-1", + tripUpdate: { + trip: { tripId: "L-trip-1", routeId: "L" }, + stopTimeUpdate: [ + { + stopId: "L06S", + arrival: { time: 1_700_000_300 }, + }, + ], + }, + }, + { + id: "arrival-2", + tripUpdate: { + trip: { tripId: "L-trip-2", routeId: "L" }, + stopTimeUpdate: [ + { + stopId: "L06N", + arrival: { time: 1_700_000_360 }, + }, + ], + }, + }, + ], + }); + + const mta = new MTA({ + staticData, + fetch: (async () => new Response(feed)) as unknown as typeof fetch, + endpoints: { + subwayFeeds: { L: "feed://l" }, + }, + }); + + const eastboundArrivals = await mta.subway.arrivals({ stopId: "L06", direction: "east" }); + const westboundArrivals = await mta.subway.arrivals({ stopId: "L06", direction: "west" }); + + expect(eastboundArrivals).toHaveLength(1); + expect(eastboundArrivals[0]).toMatchObject({ + route: { + id: "L", + shortName: "L", + longName: "14 St-Canarsie Local", + }, + stop: { + id: "L06", + name: "1 Av", + }, + direction: "south", + destination: "Canarsie-Rockaway Pkwy", + displayDirection: "toward Canarsie-Rockaway Pkwy", + headsign: "Canarsie-Rockaway Pkwy", + }); + expect(westboundArrivals).toHaveLength(1); + expect(westboundArrivals[0]).toMatchObject({ + direction: "north", + destination: "8 Av", + displayDirection: "toward 8 Av", + headsign: "8 Av", + }); +}); + test("bus arrivals normalize BusTime responses with in-memory static metadata", async () => { let requestedUrl = ""; const mta = new MTA({ @@ -168,6 +297,9 @@ test("bus arrivals normalize BusTime responses with in-memory static metadata", mode: "bus", route: { id: "B63", shortName: "B63" }, stop: { id: "308214", name: "5 Av/Atlantic Av" }, + destination: "Cobble Hill", + displayDirection: "toward Cobble Hill", + headsign: "Cobble Hill", source: "mta-bustime", }); }); diff --git a/index.ts b/index.ts index 5822a50..a1344a0 100644 --- a/index.ts +++ b/index.ts @@ -109,22 +109,23 @@ class SubwayClient { constructor(private readonly mta: MTA) {} async arrivals(query: SubwayArrivalQuery): Promise { + const normalizedQuery = normalizeSubwayArrivalQuery(query); if (this.mta.hostedApiEnabled()) { - return this.mta.hostedJson("/api/v1/subway/arrivals", query); + return this.mta.hostedJson("/api/v1/subway/arrivals", normalizedQuery); } await this.mta.ready(); - const routeIds = query.route ? [normalizeRouteId(query.route)] : Object.keys(this.mta.endpoints.subwayFeeds); + const routeIds = normalizedQuery.route ? [normalizeRouteId(normalizedQuery.route)] : Object.keys(this.mta.endpoints.subwayFeeds); const feeds = [...new Set(routeIds.map((route) => this.feedForRoute(route)))]; - const stopIds = this.mta.static.getStopIdsForQuery(query.stopId); - if (this.mta.static.hasStaticData("subway") && !this.mta.static.getStopOrParent(query.stopId)) { - throw new UnknownStopError(query.stopId); + const stopIds = this.mta.static.getStopIdsForQuery(normalizedQuery.stopId); + if (this.mta.static.hasStaticData("subway") && !this.mta.static.getStopOrParent(normalizedQuery.stopId)) { + throw new UnknownStopError(normalizedQuery.stopId); } const arrivals: Arrival[] = []; for (const feedUrl of feeds) { const feed = await this.mta.realtimeFeed(feedUrl); - arrivals.push(...this.arrivalsFromFeed(feed, stopIds, query)); + arrivals.push(...this.arrivalsFromFeed(feed, stopIds, normalizedQuery)); } return arrivals @@ -170,12 +171,15 @@ class SubwayClient { if (!event?.time) continue; const stop = this.mta.static.getStopOrParent(stopId) ?? fallbackStop(query.stopId); + const headsign = staticTrip?.headsign ?? undefined; arrivals.push({ mode: "subway", route, stop, direction, - headsign: staticTrip?.headsign ?? undefined, + destination: headsign, + displayDirection: displayDirection(headsign, direction), + headsign, arrivalTime: new Date(event.time * 1000).toISOString(), departureTime: update.departure?.time ? new Date(update.departure.time * 1000).toISOString() : undefined, minutes: Math.max(0, Math.round((event.time * 1000 - now) / 60_000)), @@ -223,12 +227,15 @@ class BusClient { const expected = call.ExpectedArrivalTime ?? call.AimedArrivalTime; if (!expected) return undefined; const stop = this.mta.static.getStop(String(call.StopPointRef ?? query.stopId)) ?? fallbackStop(query.stopId); + const headsign = stringOrUndefined(mvj.DestinationName); return { mode: "bus", route: routeWithFallback(this.mta.static.getRoute(routeId), routeId), stop, direction: "unknown", - headsign: stringOrUndefined(mvj.DestinationName), + destination: headsign, + displayDirection: displayDirection(headsign, "unknown"), + headsign, arrivalTime: new Date(expected).toISOString(), minutes: Math.max(0, Math.round((Date.parse(expected) - now) / 60_000)), tripId: stringOrUndefined(mvj.FramedVehicleJourneyRef?.DatedVehicleJourneyRef), @@ -374,13 +381,39 @@ function normalizeRouteId(route: string) { return route.toUpperCase().trim(); } -function normalizeDirection(direction: Direction | "uptown" | "downtown" | undefined): Direction | undefined { +function normalizeSubwayArrivalQuery(query: SubwayArrivalQuery): SubwayArrivalQuery { + const route = query.route ?? routeFromLStopId(query.stopId); + return { + ...query, + route, + direction: normalizeDirection(query.direction, route), + }; +} + +function normalizeDirection( + direction: Direction | "uptown" | "downtown" | undefined, + route?: string, +): Direction | undefined { if (!direction) return undefined; if (direction === "uptown") return "north"; if (direction === "downtown") return "south"; + if (route && normalizeRouteId(route) === "L") { + if (direction === "east") return "south"; + if (direction === "west") return "north"; + } return direction; } +function routeFromLStopId(stopId: string) { + return /^L\d{2}[NS]?$/.test(stopId.toUpperCase().trim()) ? "L" : undefined; +} + +function displayDirection(headsign: string | undefined, direction: Direction) { + if (headsign) return `toward ${headsign}`; + if (direction === "unknown") return undefined; + return `${direction}bound`; +} + function routeWithFallback(route: Route | undefined, routeId: string): Route { return ( route ?? { diff --git a/package.json b/package.json index 3269ad5..9793475 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mta-js", - "version": "2.1.0", + "version": "2.1.1", "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 0db264b..a8eec09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,6 +150,8 @@ export interface Arrival { route: Route; stop: Stop; direction: Direction; + destination?: string; + displayDirection?: string; headsign?: string; arrivalTime: string; departureTime?: string; From 9c1e014317cf289c250cccdb67517806fc527979 Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Wed, 27 May 2026 00:45:41 -0400 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85339b5..8409e84 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ const lTrain = await mta.subway.arrivals({ }); ``` -Arrival rows include display-oriented fields when destination metadata is -available: +Arrival rows may include display-oriented fields. `destination` depends on +destination metadata, while `displayDirection` may still be present as a +generic fallback label: ```ts for (const arrival of lTrain) {