straeto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ <p align="center">
2
+ <img src="icon.png" width="120" alt="Strætó logo" />
3
+ </p>
4
+
5
+ <h1 align="center">straeto</h1>
6
+
7
+ <p align="center">
8
+ A CLI for the Icelandic bus system <a href="https://www.straeto.is">Strætó</a>, built with <a href="https://bun.sh">Bun</a>.
9
+ </p>
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun install -g straeto
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ ### `straeto route <number> [bus]`
22
+
23
+ Show all active buses on a route, or drill into a specific bus by letter.
24
+
25
+ ```bash
26
+ straeto route 3 # overview of all buses on route 3
27
+ straeto route 3 A # detail view for bus 3-A (next stops, ETA)
28
+ straeto route 3 --watch # live-track with a spinner, polls every second
29
+ ```
30
+
31
+ ### `straeto stops`
32
+
33
+ List bus stops with optional filtering.
34
+
35
+ ```bash
36
+ straeto stops # first 10 stops
37
+ straeto stops -r 6 # stops on route 6
38
+ straeto stops -s Hlemmur # search by name
39
+ straeto stops --all # show all stops
40
+ straeto stops -n 20 # show 20 results
41
+ ```
42
+
43
+ ### `straeto stop <name or id>`
44
+
45
+ Look up a specific stop by name or numeric ID.
46
+
47
+ ```bash
48
+ straeto stop Hlemmur
49
+ straeto stop 10000802
50
+ ```
51
+
52
+ ### `straeto alerts`
53
+
54
+ Show active service alerts.
55
+
56
+ ```bash
57
+ straeto alerts # alerts in Icelandic (default)
58
+ straeto alerts -l EN # alerts in English
59
+ ```
60
+
61
+ ### `straeto plan`
62
+
63
+ Plan a trip between two locations. Launches interactive mode by default with autocomplete search and time selection.
64
+
65
+ ```bash
66
+ straeto plan # interactive mode
67
+ straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinate mode
68
+ straeto plan --at 08:30 # depart at specific time
69
+ straeto plan --by 17:00 # arrive by specific time
70
+ ```
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ bun install
76
+ bun run start # run the CLI
77
+ bun run lint # check with biome
78
+ bun run lint:fix # auto-fix lint issues
79
+ bun run format # format source files
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
package/icon.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "straeto",
3
+ "version": "0.1.0",
4
+ "description": "Strætó — Icelandic bus system CLI",
5
+ "author": "magtastic",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/magtastic/straeto-cli"
10
+ },
11
+ "keywords": ["straeto", "iceland", "bus", "cli", "bun", "reykjavik"],
12
+ "module": "src/cli.ts",
13
+ "type": "module",
14
+ "bin": {
15
+ "straeto": "src/cli.ts"
16
+ },
17
+ "files": ["src", "icon.png", "README.md"],
18
+ "scripts": {
19
+ "start": "bun run src/cli.ts",
20
+ "lint": "biome check src",
21
+ "lint:fix": "biome check --write src",
22
+ "format": "biome format --write src",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "bun test"
25
+ },
26
+ "devDependencies": {
27
+ "@biomejs/biome": "2.4.6",
28
+ "@types/bun": "latest"
29
+ },
30
+ "peerDependencies": {
31
+ "typescript": "^5"
32
+ },
33
+ "dependencies": {
34
+ "chalk": "5",
35
+ "commander": "13",
36
+ "zod": "^4.3.6"
37
+ }
38
+ }
@@ -0,0 +1,285 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as api from "./api";
3
+
4
+ describe("getBusLocations", () => {
5
+ test("returns valid bus locations for a known route", async () => {
6
+ const data = await api.getBusLocations(["1"]);
7
+
8
+ expect(data.lastUpdate).toBeString();
9
+ expect(Array.isArray(data.results)).toBe(true);
10
+
11
+ for (const bus of data.results) {
12
+ expect(bus.busId).toBeString();
13
+ expect(bus.routeNr).toBe("1");
14
+ expect(bus.headsign).toBeString();
15
+ expect(bus.lat).toBeNumber();
16
+ expect(bus.lng).toBeNumber();
17
+ expect(bus.direction).toBeNumber();
18
+ expect(Array.isArray(bus.nextStops)).toBe(true);
19
+
20
+ for (const nextStop of bus.nextStops) {
21
+ expect(nextStop.arrival).toBeString();
22
+ expect(nextStop.waitingTime).toBeNumber();
23
+ if (nextStop.stop !== null) {
24
+ expect(nextStop.stop.name).toBeString();
25
+ }
26
+ }
27
+ }
28
+ });
29
+
30
+ test("returns empty results for nonexistent route", async () => {
31
+ const data = await api.getBusLocations(["99999"]);
32
+
33
+ expect(data.lastUpdate).toBeString();
34
+ expect(data.results).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe("getStops", () => {
39
+ test("returns a list of stops with required fields", async () => {
40
+ const stops = await api.getStops();
41
+
42
+ expect(stops.length).toBeGreaterThan(100);
43
+
44
+ const stop = stops[0];
45
+ expect(stop).toBeDefined();
46
+ if (!stop) return;
47
+
48
+ expect(stop.id).toBeNumber();
49
+ expect(stop.name).toBeString();
50
+ expect(stop.lat).toBeNumber();
51
+ expect(stop.lon).toBeNumber();
52
+ expect(stop.type).toBeNumber();
53
+ expect(stop.code === null || typeof stop.code === "string").toBe(true);
54
+ expect(typeof stop.isTerminal).toBe("boolean");
55
+ expect(Array.isArray(stop.routes)).toBe(true);
56
+ });
57
+
58
+ test("contains known stops", async () => {
59
+ const stops = await api.getStops();
60
+ const names = stops.map((stop) => stop.name);
61
+
62
+ expect(names).toContain("Hamraborg");
63
+ expect(names).toContain("Mjódd A");
64
+ });
65
+ });
66
+
67
+ describe("getStop", () => {
68
+ test("returns details for a known stop", async () => {
69
+ const stops = await api.getStops();
70
+ const hamraborg = stops.find((stop) => stop.name === "Hamraborg");
71
+ expect(hamraborg).toBeDefined();
72
+ if (!hamraborg) return;
73
+
74
+ const stop = await api.getStop(String(hamraborg.id));
75
+ expect(stop).not.toBeNull();
76
+ if (!stop) return;
77
+
78
+ expect(stop.name).toBe("Hamraborg");
79
+ expect(stop.lat).toBeNumber();
80
+ expect(stop.lon).toBeNumber();
81
+ expect(Array.isArray(stop.routes)).toBe(true);
82
+ expect(Array.isArray(stop.routes)).toBe(true);
83
+ });
84
+
85
+ test("returns null for nonexistent stop", async () => {
86
+ const stop = await api.getStop("0");
87
+ expect(stop).toBeNull();
88
+ });
89
+ });
90
+
91
+ describe("getAlerts", () => {
92
+ test("returns alerts in Icelandic", async () => {
93
+ const alerts = await api.getAlerts("IS");
94
+
95
+ expect(Array.isArray(alerts)).toBe(true);
96
+
97
+ for (const alert of alerts) {
98
+ expect(alert.id).toBeString();
99
+ expect(alert.cause).toBeString();
100
+ expect(alert.effect).toBeString();
101
+ expect(alert.title).toBeString();
102
+ expect(alert.text).toBeString();
103
+ expect(alert.dateStart).toBeString();
104
+ expect(Array.isArray(alert.routes)).toBe(true);
105
+ }
106
+ });
107
+
108
+ test("returns alerts in English", async () => {
109
+ const alerts = await api.getAlerts("EN");
110
+ expect(Array.isArray(alerts)).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe("geocode", () => {
115
+ test("finds results for a known location", async () => {
116
+ const results = await api.geocode("Hlemmur");
117
+
118
+ expect(results.length).toBeGreaterThan(0);
119
+
120
+ const first = results[0];
121
+ expect(first).toBeDefined();
122
+ if (!first) return;
123
+
124
+ expect(first.id).toBeString();
125
+ expect(first.name).toBeString();
126
+ expect(first.lat).toBeNumber();
127
+ expect(first.lon).toBeNumber();
128
+ expect(first.address).toBeString();
129
+ expect(first.type).toBeString();
130
+ expect(first.subType).toBeString();
131
+ });
132
+
133
+ test("returns empty for gibberish query", async () => {
134
+ const results = await api.geocode("xyzxyzxyz123456");
135
+ expect(results).toEqual([]);
136
+ });
137
+ });
138
+
139
+ describe("planTrip", () => {
140
+ test("plans a trip between two known locations", async () => {
141
+ // Hlemmur → Mjódd (common route)
142
+ const trips = await api.planTrip({
143
+ from: "64.1426,-21.9009",
144
+ to: "64.1117,-21.8437",
145
+ date: new Date().toISOString().slice(0, 10),
146
+ time: "08:00",
147
+ arrivalBy: false,
148
+ });
149
+
150
+ expect(trips.length).toBeGreaterThan(0);
151
+
152
+ const trip = trips[0];
153
+ expect(trip).toBeDefined();
154
+ if (!trip) return;
155
+
156
+ expect(trip.id).toBeString();
157
+ expect(trip.duration.total).toBeNumber();
158
+ expect(trip.duration.walk).toBeNumber();
159
+ expect(trip.duration.bus).toBeNumber();
160
+ expect(trip.time.from).toBeString();
161
+ expect(trip.time.to).toBeString();
162
+ expect(trip.legs.length).toBeGreaterThan(0);
163
+
164
+ for (const leg of trip.legs) {
165
+ expect(["WALK", "BUS"]).toContain(leg.type);
166
+ expect(leg.duration).toBeNumber();
167
+ expect(leg.distance).toBeNumber();
168
+ expect(leg.time.from).toBeString();
169
+ expect(leg.time.to).toBeString();
170
+ expect(leg.from.lat).toBeNumber();
171
+ expect(leg.from.lon).toBeNumber();
172
+ expect(leg.to.lat).toBeNumber();
173
+ expect(leg.to.lon).toBeNumber();
174
+
175
+ if (leg.type === "BUS") {
176
+ expect(leg.trip).toBeDefined();
177
+ expect(leg.trip?.routeNr).toBeString();
178
+ expect(leg.trip?.headsign).toBeString();
179
+ }
180
+ }
181
+ });
182
+ });
183
+
184
+ describe("findBus", () => {
185
+ test("finds a bus by route and letter", () => {
186
+ const buses: api.BusLocation[] = [
187
+ {
188
+ busId: "1-A",
189
+ tripId: "t1",
190
+ routeNr: "3",
191
+ tag: null,
192
+ headsign: "Grandi",
193
+ lat: 64.0,
194
+ lng: -21.0,
195
+ direction: 0,
196
+ nextStops: [],
197
+ },
198
+ {
199
+ busId: "2-B",
200
+ tripId: "t2",
201
+ routeNr: "3",
202
+ tag: null,
203
+ headsign: "Sel/Fell",
204
+ lat: 64.1,
205
+ lng: -21.1,
206
+ direction: 180,
207
+ nextStops: [],
208
+ },
209
+ ];
210
+
211
+ const found = api.findBus(buses, "3", "b");
212
+ expect(found).toBeDefined();
213
+ expect(found?.busId).toBe("2-B");
214
+
215
+ expect(api.findBus(buses, "3", "c")).toBeUndefined();
216
+ expect(api.findBus(buses, "5", "a")).toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe("findLastStop", () => {
221
+ test("returns closest stop not in nextStops", () => {
222
+ const bus: api.BusLocation = {
223
+ busId: "1-A",
224
+ tripId: "t1",
225
+ routeNr: "3",
226
+ tag: null,
227
+ headsign: "Grandi",
228
+ lat: 64.15,
229
+ lng: -21.95,
230
+ direction: 0,
231
+ nextStops: [{ arrival: "", waitingTime: 2, stop: { name: "NextStop" } }],
232
+ };
233
+
234
+ const stops: api.Stop[] = [
235
+ {
236
+ id: 1,
237
+ name: "NextStop",
238
+ lat: 64.16,
239
+ lon: -21.96,
240
+ type: 1,
241
+ code: null,
242
+ isTerminal: false,
243
+ routes: ["3"],
244
+ },
245
+ {
246
+ id: 2,
247
+ name: "PreviousStop",
248
+ lat: 64.149,
249
+ lon: -21.949,
250
+ type: 1,
251
+ code: null,
252
+ isTerminal: false,
253
+ routes: ["3"],
254
+ },
255
+ {
256
+ id: 3,
257
+ name: "FarStop",
258
+ lat: 64.2,
259
+ lon: -22.0,
260
+ type: 1,
261
+ code: null,
262
+ isTerminal: false,
263
+ routes: ["3"],
264
+ },
265
+ ];
266
+
267
+ expect(api.findLastStop(bus, stops)).toBe("PreviousStop");
268
+ });
269
+
270
+ test("returns undefined when no route stops exist", () => {
271
+ const bus: api.BusLocation = {
272
+ busId: "1-A",
273
+ tripId: "t1",
274
+ routeNr: "99",
275
+ tag: null,
276
+ headsign: "Nowhere",
277
+ lat: 64.0,
278
+ lng: -21.0,
279
+ direction: 0,
280
+ nextStops: [],
281
+ };
282
+
283
+ expect(api.findLastStop(bus, [])).toBeUndefined();
284
+ });
285
+ });
package/src/api.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { z } from "zod/v4";
2
+ import { currentDate } from "./format";
3
+
4
+ const API_URL = "https://api.straeto.is/graphql";
5
+
6
+ const HEADERS = {
7
+ "Content-Type": "application/json",
8
+ Origin: "https://www.straeto.is",
9
+ };
10
+
11
+ async function query<T>(
12
+ gql: string,
13
+ variables: Record<string, unknown> | undefined,
14
+ schema: z.ZodType<T>,
15
+ ): Promise<T> {
16
+ const res = await fetch(API_URL, {
17
+ method: "POST",
18
+ headers: HEADERS,
19
+ body: JSON.stringify({ query: gql, variables }),
20
+ });
21
+
22
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
23
+
24
+ const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
25
+ if (json.errors && !json.data)
26
+ throw new Error(json.errors.map((error) => error.message).join(", "));
27
+ if (!json.data) throw new Error("No data returned from API");
28
+ return schema.parse(json.data);
29
+ }
30
+
31
+ // --- Schemas ---
32
+
33
+ const NextStopSchema = z.object({
34
+ arrival: z.string(),
35
+ waitingTime: z.number(),
36
+ stop: z.object({ name: z.string() }).nullable(),
37
+ });
38
+
39
+ const BusLocationSchema = z.object({
40
+ busId: z.string(),
41
+ tripId: z.string(),
42
+ routeNr: z.string(),
43
+ tag: z.string().nullable(),
44
+ headsign: z.string(),
45
+ lat: z.number(),
46
+ lng: z.number(),
47
+ direction: z.number(),
48
+ nextStops: z.array(NextStopSchema),
49
+ });
50
+
51
+ const StopSchema = z.object({
52
+ id: z.number(),
53
+ name: z.string(),
54
+ lat: z.number(),
55
+ lon: z.number(),
56
+ type: z.number(),
57
+ code: z.string().nullable(),
58
+ isTerminal: z.boolean(),
59
+ routes: z.array(z.string()),
60
+ });
61
+
62
+ const AlertSchema = z.object({
63
+ id: z.string(),
64
+ cause: z.string(),
65
+ effect: z.string(),
66
+ routes: z.array(z.string()),
67
+ title: z.string(),
68
+ text: z.string(),
69
+ dateStart: z.string(),
70
+ dateEnd: z.string().nullable(),
71
+ });
72
+
73
+ const GeoResultSchema = z.object({
74
+ id: z.string(),
75
+ name: z.string(),
76
+ lat: z.number(),
77
+ lon: z.number(),
78
+ address: z.string(),
79
+ type: z.string(),
80
+ subType: z.string(),
81
+ });
82
+
83
+ const TripStopSchema = z.object({
84
+ id: z.union([z.string(), z.number()]),
85
+ name: z.string(),
86
+ lat: z.number(),
87
+ lon: z.number(),
88
+ });
89
+
90
+ const TripLegSchema = z.object({
91
+ type: z.string(),
92
+ duration: z.number(),
93
+ distance: z.number(),
94
+ time: z.object({ from: z.string(), to: z.string() }),
95
+ from: z.object({
96
+ lat: z.number(),
97
+ lon: z.number(),
98
+ depature: z.string().nullable(),
99
+ stop: TripStopSchema.nullable(),
100
+ }),
101
+ to: z.object({
102
+ lat: z.number(),
103
+ lon: z.number(),
104
+ arrival: z.string().nullable(),
105
+ stop: TripStopSchema.nullable(),
106
+ }),
107
+ trip: z.object({ routeNr: z.string(), headsign: z.string() }).optional(),
108
+ stops: z.array(z.object({ id: z.union([z.string(), z.number()]), name: z.string() })).optional(),
109
+ });
110
+
111
+ const TripItinerarySchema = z.object({
112
+ id: z.string(),
113
+ duration: z.object({ walk: z.number(), bus: z.number(), total: z.number() }),
114
+ time: z.object({ from: z.string(), to: z.string() }),
115
+ legs: z.array(TripLegSchema),
116
+ });
117
+
118
+ // --- Exported types ---
119
+
120
+ export type NextStop = z.infer<typeof NextStopSchema>;
121
+ export type BusLocation = z.infer<typeof BusLocationSchema>;
122
+ export type Stop = z.infer<typeof StopSchema>;
123
+ export type Alert = z.infer<typeof AlertSchema>;
124
+ export type GeoResult = z.infer<typeof GeoResultSchema>;
125
+ export type TripStop = z.infer<typeof TripStopSchema>;
126
+ export type TripLeg = z.infer<typeof TripLegSchema>;
127
+ export type TripItinerary = z.infer<typeof TripItinerarySchema>;
128
+ export type StopDetail = z.infer<typeof StopDetailSchema>;
129
+
130
+ // --- Helpers ---
131
+
132
+ function distSq(lat1: number, lon1: number, lat2: number, lon2: number) {
133
+ return (lat1 - lat2) ** 2 + (lon1 - lon2) ** 2;
134
+ }
135
+
136
+ export function findBus(buses: BusLocation[], route: string, letter: string) {
137
+ const upper = letter.toUpperCase();
138
+ return buses.find((bus) => {
139
+ const busLetter = bus.busId.split("-").pop()?.toUpperCase();
140
+ return bus.routeNr === route && busLetter === upper;
141
+ });
142
+ }
143
+
144
+ export function findLastStop(bus: BusLocation, stops: Stop[]): string | undefined {
145
+ const nextNames = new Set(
146
+ bus.nextStops.filter((nextStop) => nextStop.stop).map((nextStop) => nextStop.stop?.name),
147
+ );
148
+ const routeStops = stops.filter(
149
+ (stop) => stop.routes.includes(bus.routeNr) && !nextNames.has(stop.name),
150
+ );
151
+ const first = routeStops[0];
152
+ if (!first) return undefined;
153
+ // Find closest to bus position (note: BusLocation uses lng, Stop uses lon)
154
+ let closest = first;
155
+ let minDist = distSq(bus.lat, bus.lng, closest.lat, closest.lon);
156
+ for (let i = 1; i < routeStops.length; i++) {
157
+ const stop = routeStops[i];
158
+ if (!stop) continue;
159
+ const dist = distSq(bus.lat, bus.lng, stop.lat, stop.lon);
160
+ if (dist < minDist) {
161
+ minDist = dist;
162
+ closest = stop;
163
+ }
164
+ }
165
+ return closest.name;
166
+ }
167
+
168
+ // --- Response schemas ---
169
+
170
+ const BusLocationResponseSchema = z.object({
171
+ BusLocationByRoute: z.object({
172
+ lastUpdate: z.string(),
173
+ results: z.array(BusLocationSchema),
174
+ }),
175
+ });
176
+
177
+ const StopsResponseSchema = z.object({
178
+ GtfsStops: z.object({ results: z.array(StopSchema) }),
179
+ });
180
+
181
+ const StopDetailSchema = StopSchema.extend({
182
+ streetView: z.object({ iframeUrl: z.string() }).nullable(),
183
+ });
184
+
185
+ const StopResponseSchema = z.object({
186
+ GtfsStop: StopDetailSchema.nullable(),
187
+ });
188
+
189
+ const AlertsResponseSchema = z.object({
190
+ Alerts: z.object({ results: z.array(AlertSchema) }),
191
+ });
192
+
193
+ const GeocodeResponseSchema = z.object({
194
+ Geocode: z.object({ results: z.array(GeoResultSchema) }),
195
+ });
196
+
197
+ const TripPlannerResponseSchema = z.object({
198
+ TripPlanner: z.object({ results: z.array(TripItinerarySchema) }),
199
+ });
200
+
201
+ // --- Queries ---
202
+
203
+ export async function getBusLocations(routes: string[]) {
204
+ const data = await query(
205
+ `query BusLocationByRoute($routes: [String!]!) {
206
+ BusLocationByRoute(routes: $routes) {
207
+ lastUpdate
208
+ results {
209
+ busId tripId routeNr tag headsign lat lng direction
210
+ nextStops { arrival waitingTime stop { name } }
211
+ }
212
+ }
213
+ }`,
214
+ { routes },
215
+ BusLocationResponseSchema,
216
+ );
217
+ return data.BusLocationByRoute;
218
+ }
219
+
220
+ export async function getStops() {
221
+ const data = await query(
222
+ `{ GtfsStops { results { id name lat lon type code isTerminal routes } } }`,
223
+ undefined,
224
+ StopsResponseSchema,
225
+ );
226
+ return data.GtfsStops.results;
227
+ }
228
+
229
+ export async function getStop(id: string) {
230
+ const today = currentDate();
231
+ const data = await query(
232
+ `query Stop($id: String!, $date: String) {
233
+ GtfsStop(id: $id, date: $date) {
234
+ id name lat lon type code isTerminal routes
235
+ streetView { iframeUrl }
236
+ }
237
+ }`,
238
+ { id, date: today },
239
+ StopResponseSchema,
240
+ );
241
+ return data.GtfsStop;
242
+ }
243
+
244
+ export async function getAlerts(language: string = "IS") {
245
+ const data = await query(
246
+ `query Alerts($language: AlertLanguage) {
247
+ Alerts(language: $language) {
248
+ results { id cause effect routes title text dateStart dateEnd }
249
+ }
250
+ }`,
251
+ { language },
252
+ AlertsResponseSchema,
253
+ );
254
+ return data.Alerts.results;
255
+ }
256
+
257
+ export async function geocode(placesQuery: string) {
258
+ const data = await query(
259
+ `query Geocode($placesQuery: String!) {
260
+ Geocode(query: $placesQuery) {
261
+ results { id name lat lon address type subType }
262
+ }
263
+ }`,
264
+ { placesQuery },
265
+ GeocodeResponseSchema,
266
+ );
267
+ return data.Geocode.results;
268
+ }
269
+
270
+ export async function planTrip(opts: {
271
+ from: string;
272
+ to: string;
273
+ date: string;
274
+ time: string;
275
+ arrivalBy?: boolean;
276
+ }) {
277
+ const data = await query(
278
+ `query TripPlanner($time: String!, $date: String!, $from: String!, $to: String!, $arrivalBy: Boolean, $language: Language) {
279
+ TripPlanner(time: $time, date: $date, from: $from, to: $to, arrivalBy: $arrivalBy, language: $language) {
280
+ id
281
+ results {
282
+ id
283
+ duration { walk bus total }
284
+ time { from to }
285
+ legs {
286
+ type duration distance
287
+ time { from to }
288
+ stops { id name }
289
+ from { lon lat depature stop { id name lat lon } }
290
+ to { lon lat arrival stop { id name lat lon } }
291
+ ... on TripPlannerLegBus {
292
+ trip { routeNr headsign }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ }`,
298
+ opts,
299
+ TripPlannerResponseSchema,
300
+ );
301
+ return data.TripPlanner.results;
302
+ }