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 +84 -0
- package/icon.png +0 -0
- package/package.json +38 -0
- package/src/api.test.ts +285 -0
- package/src/api.ts +302 -0
- package/src/cli.test.ts +189 -0
- package/src/cli.ts +94 -0
- package/src/commands/alerts.ts +10 -0
- package/src/commands/plan.ts +36 -0
- package/src/commands/route.ts +88 -0
- package/src/commands/schemas.ts +26 -0
- package/src/commands/stop.ts +37 -0
- package/src/commands/stops.ts +30 -0
- package/src/format.ts +230 -0
- package/src/prompt.ts +252 -0
- package/src/terminal.ts +44 -0
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
|
+
}
|
package/src/api.test.ts
ADDED
|
@@ -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
|
+
}
|