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/src/format.ts ADDED
@@ -0,0 +1,230 @@
1
+ import chalk from "chalk";
2
+ import type {
3
+ Alert,
4
+ BusLocation,
5
+ GeoResult,
6
+ NextStop,
7
+ Stop,
8
+ StopDetail,
9
+ TripItinerary,
10
+ } from "./api";
11
+
12
+ const routeColor = chalk.bold.yellow;
13
+ const stopColor = chalk.cyan;
14
+ const dimText = chalk.dim;
15
+ const heading = chalk.bold.underline;
16
+
17
+ export function currentDate(): string {
18
+ const now = new Date();
19
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
20
+ }
21
+
22
+ export function currentTime(): string {
23
+ const now = new Date();
24
+ return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
25
+ }
26
+
27
+ function stripHtml(html: string): string {
28
+ return html
29
+ .replace(/<br\s*\/?>/gi, "\n")
30
+ .replace(/<[^>]+>/g, "")
31
+ .trim();
32
+ }
33
+
34
+ function formatTime(iso: string): string {
35
+ return new Date(iso).toLocaleTimeString("is-IS", {
36
+ hour: "2-digit",
37
+ minute: "2-digit",
38
+ });
39
+ }
40
+
41
+ function formatDuration(seconds: number): string {
42
+ const mins = Math.round(seconds / 60);
43
+ if (mins < 60) return `${mins} mín`;
44
+ const hours = Math.floor(mins / 60);
45
+ return `${hours}h ${mins % 60}m`;
46
+ }
47
+
48
+ function busLetter(busId: string): string {
49
+ return busId.split("-").pop() ?? busId;
50
+ }
51
+
52
+ function nearestStopLabel(nextStops: NextStop[]): string {
53
+ const valid = nextStops.filter((nextStop) => nextStop.stop != null);
54
+ if (valid.length === 0) return dimText("—");
55
+ const first = valid[0];
56
+ if (!first) return dimText("—");
57
+ const mins = first.waitingTime;
58
+ const name = first.stop?.name ?? "?";
59
+ if (mins <= 1) return `at ${stopColor(name)}`;
60
+ return `near ${stopColor(name)} ${dimText(`(${mins} mín)`)}`;
61
+ }
62
+
63
+ export function formatBusOverview(route: string, buses: BusLocation[], lastUpdate: string): string {
64
+ if (buses.length === 0) return dimText("No buses currently running on this route.");
65
+
66
+ // Find the longest headsign for alignment
67
+ const maxHeadsign = Math.max(...buses.map((bus) => bus.headsign.length));
68
+
69
+ const lines = [
70
+ `${routeColor(`Route ${route}`)} ${dimText(`— ${buses.length} bus${buses.length !== 1 ? "es" : ""}`)}${" ".repeat(Math.max(0, maxHeadsign + 20 - route.length))}${dimText(formatTime(lastUpdate))}`,
71
+ "",
72
+ ];
73
+
74
+ for (const bus of buses) {
75
+ const letter = chalk.bold(busLetter(bus.busId));
76
+ const headsign = bus.headsign.padEnd(maxHeadsign);
77
+ const location = nearestStopLabel(bus.nextStops);
78
+ lines.push(` ${letter} ${dimText("→")} ${stopColor(headsign)} ${location}`);
79
+ }
80
+
81
+ return lines.join("\n");
82
+ }
83
+
84
+ export function formatBusDetail(bus: BusLocation, lastUpdate: string, lastStop?: string): string {
85
+ const valid = bus.nextStops.filter((nextStop) => nextStop.stop != null);
86
+
87
+ const lines = [
88
+ `${routeColor(`Bus ${bus.busId}`)} ${dimText("→")} ${stopColor(bus.headsign)}${" ".repeat(20)}${dimText(formatTime(lastUpdate))}`,
89
+ "",
90
+ ];
91
+
92
+ if (lastStop) {
93
+ lines.push(` ${dimText("✓")} ${dimText(lastStop.padEnd(22))} ${dimText("passed")}`);
94
+ }
95
+
96
+ if (valid.length === 0) {
97
+ lines.push(` ${dimText("No upcoming stops")}`);
98
+ return lines.join("\n");
99
+ }
100
+
101
+ for (let i = 0; i < valid.length; i++) {
102
+ const nextStop = valid[i];
103
+ if (!nextStop) continue;
104
+ const stopName = (nextStop.stop?.name ?? "?").padEnd(22);
105
+ const mins = nextStop.waitingTime;
106
+ const time = mins <= 1 ? dimText("< 1 mín") : dimText(`~${mins} mín`);
107
+ const isNext = i === 0;
108
+ const marker = isNext ? chalk.cyan("▸") : dimText("○");
109
+ const name = isNext ? chalk.bold.cyan(stopName) : stopColor(stopName);
110
+ lines.push(` ${marker} ${name} ${time}`);
111
+ }
112
+
113
+ return lines.join("\n");
114
+ }
115
+
116
+ export function formatStops(stops: Stop[]): string {
117
+ const lines = [heading(`Bus stops`) + dimText(` (${stops.length} total)`), ""];
118
+
119
+ for (const stop of stops) {
120
+ const routes =
121
+ stop.routes.length > 0
122
+ ? ` ${stop.routes.map((route) => routeColor(route)).join(dimText(", "))}`
123
+ : "";
124
+ const terminal = stop.isTerminal ? chalk.magenta(" ◉") : "";
125
+ lines.push(` ${stopColor(stop.name)}${terminal}${routes} ${dimText(`#${stop.id}`)}`);
126
+ }
127
+
128
+ return lines.join("\n");
129
+ }
130
+
131
+ export function formatStop(stop: StopDetail): string {
132
+ const lines = [
133
+ heading(stop.name) + (stop.isTerminal ? chalk.magenta(" Terminal") : ""),
134
+ "",
135
+ ` ${dimText("ID:")} ${stop.id}`,
136
+ ` ${dimText("Coords:")} ${stop.lat}, ${stop.lon}`,
137
+ ` ${dimText("Routes:")} ${stop.routes.length > 0 ? stop.routes.map((route) => routeColor(route)).join(", ") : "none"}`,
138
+ ];
139
+
140
+ return lines.join("\n");
141
+ }
142
+
143
+ export function formatAlerts(alerts: Alert[]): string {
144
+ if (alerts.length === 0) return dimText("No active alerts.");
145
+
146
+ const lines = [heading(`Alerts`) + dimText(` (${alerts.length})`), ""];
147
+
148
+ for (const alert of alerts) {
149
+ const routes = alert.routes.map((route) => routeColor(route)).join(", ");
150
+ lines.push(` ${chalk.red("!")} ${chalk.bold(alert.title)}`);
151
+ lines.push(
152
+ ` ${dimText("Routes:")} ${routes} ${dimText("Cause:")} ${alert.cause.toLowerCase().replace(/_/g, " ")}`,
153
+ );
154
+ lines.push(` ${stripHtml(alert.text)}`);
155
+ lines.push("");
156
+ }
157
+
158
+ return lines.join("\n");
159
+ }
160
+
161
+ export function formatGeocode(results: GeoResult[]): string {
162
+ if (results.length === 0) return dimText("No results found.");
163
+
164
+ const lines = [heading(`Search results`), ""];
165
+
166
+ for (const result of results) {
167
+ lines.push(` ${chalk.bold(result.name)}`);
168
+ lines.push(
169
+ ` ${dimText(result.address)} ${dimText(`(${result.type})`)} ${dimText(`${result.lat}, ${result.lon}`)}`,
170
+ );
171
+ }
172
+
173
+ return lines.join("\n");
174
+ }
175
+
176
+ export function formatTrips(trips: TripItinerary[]): string {
177
+ if (trips.length === 0) return dimText("No trips found.");
178
+
179
+ // Limit to best 5 options
180
+ const shown = trips.slice(0, 5);
181
+ const lines: string[] = [];
182
+
183
+ for (let i = 0; i < shown.length; i++) {
184
+ const trip = shown[i];
185
+ if (!trip) continue;
186
+ const start = formatTime(trip.time.from);
187
+ const end = formatTime(trip.time.to);
188
+ const dur = formatDuration(trip.duration.total);
189
+ const busLegs = trip.legs.filter((leg) => leg.type === "BUS");
190
+ const transfers = Math.max(0, busLegs.length - 1);
191
+
192
+ // Header line: time range and duration
193
+ const transferLabel =
194
+ transfers === 0
195
+ ? chalk.green("Direct")
196
+ : `${transfers} transfer${transfers !== 1 ? "s" : ""}`;
197
+
198
+ lines.push(` ${chalk.bold(start)} → ${chalk.bold(end)} ${dimText(dur)} ${transferLabel}`);
199
+
200
+ // Journey visualization
201
+ for (const leg of trip.legs) {
202
+ const fromName = leg.from.stop?.name ?? "";
203
+ const toName = leg.to.stop?.name ?? "";
204
+
205
+ if (leg.type === "WALK") {
206
+ const walkMins = formatDuration(leg.duration);
207
+ if (toName) {
208
+ lines.push(` ${dimText("│")} ${dimText(`Walk ${walkMins} to ${toName}`)}`);
209
+ } else if (walkMins !== "0 mín") {
210
+ lines.push(` ${dimText("│")} ${dimText(`Walk ${walkMins}`)}`);
211
+ }
212
+ } else {
213
+ const routeNr = leg.trip?.routeNr ?? "?";
214
+ const headsign = leg.trip?.headsign ?? toName;
215
+ const depTime = formatTime(leg.time.from);
216
+ const arrTime = formatTime(leg.time.to);
217
+ const stopCount = leg.stops?.length ?? 0;
218
+ const stopsLabel = stopCount > 0 ? dimText(` · ${stopCount} stops`) : "";
219
+
220
+ lines.push(` ${routeColor(routeNr)} ${stopColor(fromName)} ${depTime}`);
221
+ lines.push(` ${dimText("│")} ${dimText(`→ ${headsign}`)}${stopsLabel}`);
222
+ lines.push(` ${routeColor(routeNr)} ${stopColor(toName)} ${arrTime}`);
223
+ }
224
+ }
225
+
226
+ if (i < shown.length - 1) lines.push("");
227
+ }
228
+
229
+ return lines.join("\n");
230
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,252 @@
1
+ import chalk from "chalk";
2
+ import type { GeoResult } from "./api";
3
+ import { currentTime } from "./format";
4
+ import { ANSI, clearLines, disableRaw, enableRaw, readKey, write } from "./terminal";
5
+
6
+ const MAX_SUGGESTIONS = 6;
7
+ const DEBOUNCE_MS = 200;
8
+ const MIN_QUERY_LEN = 2;
9
+
10
+ function dedupeResults(results: GeoResult[], limit: number): GeoResult[] {
11
+ const seen = new Set<string>();
12
+ const unique: GeoResult[] = [];
13
+ for (const result of results) {
14
+ const key = `${result.name}|${result.address}`;
15
+ if (!seen.has(key)) {
16
+ seen.add(key);
17
+ unique.push(result);
18
+ }
19
+ if (unique.length >= limit) break;
20
+ }
21
+ return unique;
22
+ }
23
+
24
+ // --- Location prompt with autocomplete ---
25
+
26
+ function renderLocationFrame(
27
+ label: string,
28
+ input: string,
29
+ suggestions: GeoResult[],
30
+ selected: number,
31
+ ): string {
32
+ let buf = `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(`${label}:`)} ${input}`;
33
+ for (let i = 0; i < suggestions.length; i++) {
34
+ const suggestion = suggestions[i];
35
+ if (!suggestion) continue;
36
+ const addr = suggestion.address ? chalk.dim(` — ${suggestion.address}`) : "";
37
+ const prefix = i === selected ? chalk.cyan(" ▸ ") : " ";
38
+ const name = i === selected ? chalk.cyan.bold(suggestion.name) : suggestion.name;
39
+ buf += `\n${ANSI.clearLine}${prefix}${name}${addr}`;
40
+ }
41
+ return buf;
42
+ }
43
+
44
+ export async function promptLocation(
45
+ label: string,
46
+ geocode: (q: string) => Promise<GeoResult[]>,
47
+ ): Promise<{ lat: number; lon: number; name: string }> {
48
+ enableRaw();
49
+
50
+ let input = "";
51
+ let suggestions: GeoResult[] = [];
52
+ let selected = 0;
53
+ let prevLines = 0;
54
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
55
+ let pendingQuery = "";
56
+
57
+ const draw = async () => {
58
+ await clearLines(prevLines);
59
+ const frame = renderLocationFrame(label, input, suggestions, selected);
60
+ await write(frame);
61
+ if (suggestions.length > 0) await write(ANSI.moveUp(suggestions.length));
62
+ const col = label.length + 2 + input.length;
63
+ await write(`${ANSI.carriageReturn}${ANSI.moveToCol(col + 1)}`);
64
+ prevLines = suggestions.length;
65
+ };
66
+
67
+ const scheduleSearch = () => {
68
+ if (debounceTimer) clearTimeout(debounceTimer);
69
+ if (input.length < MIN_QUERY_LEN) {
70
+ suggestions = [];
71
+ selected = 0;
72
+ return;
73
+ }
74
+ pendingQuery = input;
75
+ debounceTimer = setTimeout(async () => {
76
+ const query = pendingQuery;
77
+ try {
78
+ const results = await geocode(query);
79
+ if (pendingQuery === query) {
80
+ suggestions = dedupeResults(results, MAX_SUGGESTIONS);
81
+ selected = 0;
82
+ await draw();
83
+ }
84
+ } catch {}
85
+ }, DEBOUNCE_MS);
86
+ };
87
+
88
+ const finish = async (result: GeoResult) => {
89
+ await clearLines(prevLines);
90
+ await write(
91
+ `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(`${label}:`)} ${chalk.cyan(result.name)}\n`,
92
+ );
93
+ disableRaw();
94
+ return { lat: result.lat, lon: result.lon, name: result.name };
95
+ };
96
+
97
+ await draw();
98
+
99
+ while (true) {
100
+ const key = await readKey();
101
+
102
+ if (key[0] === 3) {
103
+ await clearLines(prevLines);
104
+ await write("\n");
105
+ disableRaw();
106
+ process.exit(0);
107
+ }
108
+
109
+ if (key[0] === 13) {
110
+ const current = suggestions[selected];
111
+ if (suggestions.length > 0 && current) {
112
+ return finish(current);
113
+ }
114
+ if (input.length >= MIN_QUERY_LEN && suggestions.length === 0) {
115
+ try {
116
+ const results = await geocode(input);
117
+ suggestions = dedupeResults(results, MAX_SUGGESTIONS);
118
+ selected = 0;
119
+ const first = suggestions[0];
120
+ if (suggestions.length === 1 && first) return finish(first);
121
+ await draw();
122
+ } catch {}
123
+ }
124
+ continue;
125
+ }
126
+
127
+ if (key[0] === 9) {
128
+ const current = suggestions[selected];
129
+ if (suggestions.length > 0 && current) {
130
+ input = current.name;
131
+ scheduleSearch();
132
+ await draw();
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (key[0] === 127 || key[0] === 8) {
138
+ if (input.length > 0) {
139
+ input = input.slice(0, -1);
140
+ scheduleSearch();
141
+ await draw();
142
+ }
143
+ continue;
144
+ }
145
+
146
+ if (key[0] === 27 && key[1] === 91) {
147
+ if (key[2] === 65 && suggestions.length > 0) {
148
+ selected = (selected - 1 + suggestions.length) % suggestions.length;
149
+ await draw();
150
+ }
151
+ if (key[2] === 66 && suggestions.length > 0) {
152
+ selected = (selected + 1) % suggestions.length;
153
+ await draw();
154
+ }
155
+ continue;
156
+ }
157
+
158
+ if (key[0] === 27 || (key[0] !== undefined && key[0] < 32)) continue;
159
+
160
+ input += key.toString("utf-8");
161
+ scheduleSearch();
162
+ await draw();
163
+ }
164
+ }
165
+
166
+ // --- Time mode prompt ---
167
+
168
+ export interface TimeMode {
169
+ time: string;
170
+ arrivalBy: boolean;
171
+ }
172
+
173
+ export async function promptTimeMode(): Promise<TimeMode> {
174
+ enableRaw();
175
+
176
+ const options = [
177
+ { label: "Leave now", time: currentTime(), needsInput: false, arrivalBy: false },
178
+ { label: "Depart at…", time: "", needsInput: true, arrivalBy: false },
179
+ { label: "Arrive by…", time: "", needsInput: true, arrivalBy: true },
180
+ ];
181
+
182
+ let selected = 0;
183
+
184
+ const draw = async () => {
185
+ let buf = `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold("When:")}`;
186
+ for (let i = 0; i < options.length; i++) {
187
+ const opt = options[i];
188
+ if (!opt) continue;
189
+ if (i === selected) {
190
+ buf += ` ${chalk.cyan.bold(opt.label)}`;
191
+ } else {
192
+ buf += ` ${chalk.dim(opt.label)}`;
193
+ }
194
+ }
195
+ buf += chalk.dim(" ←/→ to pick, enter to confirm");
196
+ await write(buf);
197
+ };
198
+
199
+ await draw();
200
+
201
+ while (true) {
202
+ const key = await readKey();
203
+
204
+ if (key[0] === 3) {
205
+ await write("\n");
206
+ disableRaw();
207
+ process.exit(0);
208
+ }
209
+
210
+ // Enter
211
+ if (key[0] === 13) {
212
+ const opt = options[selected];
213
+ if (!opt) continue;
214
+ if (!opt.needsInput) {
215
+ await write(
216
+ `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold("When:")} ${chalk.cyan(opt.label)}\n`,
217
+ );
218
+ disableRaw();
219
+ return { time: opt.time, arrivalBy: opt.arrivalBy };
220
+ }
221
+
222
+ // Need time input
223
+ await write(
224
+ `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(opt.label.replace("…", ":"))} `,
225
+ );
226
+ disableRaw();
227
+
228
+ const timeInput = (prompt("") ?? "").trim();
229
+ if (/^\d{1,2}:\d{2}$/.test(timeInput)) {
230
+ return { time: timeInput, arrivalBy: opt.arrivalBy };
231
+ }
232
+ // Invalid, re-prompt
233
+ enableRaw();
234
+ await draw();
235
+ continue;
236
+ }
237
+
238
+ // Left/Right arrows
239
+ if (key[0] === 27 && key[1] === 91) {
240
+ if (key[2] === 68) selected = (selected - 1 + options.length) % options.length; // Left
241
+ if (key[2] === 67) selected = (selected + 1) % options.length; // Right
242
+ await draw();
243
+ continue;
244
+ }
245
+
246
+ // Tab cycles right
247
+ if (key[0] === 9) {
248
+ selected = (selected + 1) % options.length;
249
+ await draw();
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,44 @@
1
+ const ANSI = {
2
+ clearLine: "\x1B[K",
3
+ hideCursor: "\x1B[?25l",
4
+ showCursor: "\x1B[?25h",
5
+ moveUp: (n: number) => `\x1B[${n}A`,
6
+ moveToCol: (col: number) => `\x1B[${col}C`,
7
+ carriageReturn: "\r",
8
+ moveToStart: (n: number) => `\x1B[${n}A\x1B[G`,
9
+ };
10
+
11
+ export { ANSI };
12
+
13
+ export const write = (s: string) => Bun.write(Bun.stdout, s);
14
+ export const writeLn = (s: string) => Bun.write(Bun.stdout, `${s}\n`);
15
+ export const writeErr = (s: string) => Bun.write(Bun.stderr, `${s}\n`);
16
+
17
+ export async function clearLines(count: number) {
18
+ if (count > 0) {
19
+ let buf = "";
20
+ for (let i = 0; i < count; i++) buf += `\n${ANSI.clearLine}`;
21
+ buf += ANSI.moveUp(count);
22
+ await write(buf);
23
+ }
24
+ }
25
+
26
+ export function enableRaw() {
27
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
28
+ process.stdin.resume();
29
+ }
30
+
31
+ export function disableRaw() {
32
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
33
+ process.stdin.pause();
34
+ }
35
+
36
+ export function readKey(): Promise<Buffer> {
37
+ return new Promise((resolve) => {
38
+ const onData = (chunk: Buffer) => {
39
+ process.stdin.removeListener("data", onData);
40
+ resolve(chunk);
41
+ };
42
+ process.stdin.on("data", onData);
43
+ });
44
+ }