diff --git a/.gitignore b/.gitignore index ad93c39..3254be8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +**/node_modules # testing /coverage diff --git a/README.md b/README.md index 81c79a9..e71f467 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ This project uses a monorepo setup (npm workspaces) to manage multiple, intercon ### Web Application (`apps/web`) * **Framework:** Next.js 16.2.6 (App Router) * **UI:** React 19.2.4 with Tailwind CSS 4 -* **State Management:** Zustand (for events and station selection) -* **Routing:** Next.js built-in routing for `/add-event`, `/calendar`, and `/event` views. +* **State Management:** React Context (via `EventsProvider` and `ReminderSettingsProvider`) +* **Routing:** Next.js built-in routing for `/` (event list) and `/calendar` views. ### Mobile Application (`apps/mobile`) * **Framework:** React Native 0.81 via Expo 54 @@ -95,8 +95,16 @@ const api = new ApiClient("http://localhost:3000"); // 1. Sync your calendar const events = await api.fetchCalendar("https://example.com/calendar.ics", 7); -// 2. Search for a station by name -const stations = await api.searchStation("Wien Mitte"); +// 2. Search for a station via the HAFAS LocMatch endpoint +const stationResult = await api.hafasRequest({ + svcReqL: [ + { + meth: "LocMatch", + req: { searchTxt: "Wien Mitte", maxMatches: 5 }, + }, + ], +}); +const stations = stationResult?.svcReqL?.[0]?.res?.locL ?? []; // 3. Find journeys between stations for a specific date const journeys = await api.searchJourneys( @@ -108,7 +116,7 @@ const journeys = await api.searchJourneys( // 4. Get a bike route from your current location to the station const bikeRoute = await api.getBikeRoute( 48.2082, 16.3738, // From lat/lng - stations[0].lat, stations[0].lng // To lat/lng + 48.1850, 16.3780 // To lat/lng ); ``` diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js new file mode 100644 index 0000000..ba04dc4 --- /dev/null +++ b/apps/mobile/eslint.config.js @@ -0,0 +1,84 @@ +import js from "@eslint/js"; +import ts from "typescript-eslint"; +import reactPlugin from "eslint-plugin-react"; + +// We have no .eslintrc, so we must define everything here. + +export default ts.config( + { + ignores: ["dist/**"], + }, + { + extends: [js.configs.recommended, ts.configs.recommended], + files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + // React Native globals + __DEV__: "readonly", + alert: "readonly", + console: "readonly", + document: "readonly", + navigator: "readonly", + window: "readonly", + require: "readonly", + module: "readonly", + exports: "readonly", + process: "readonly", + jest: "readonly", + describe: "readonly", + it: "readonly", + test: "readonly", + expect: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + react: reactPlugin, + }, + rules: { + ...js.configs.recommended.rules, + ...ts.configs.recommended.rules, + // TypeScript handles type-related issues + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "off", + // Allow _-prefixed parameters and variables to signal intentionally unused + "no-unused-vars": [ + "warn", + { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + // React Native uses require() heavily + "no-var-requires": "off", + // We use React Native's StyleSheet + "react/no-unknown-property": [ + "error", + { + ignore: ["flex", "justifyContent", "alignItems", "width", "height", "margin", "padding"], + }, + ], + // Allow console.log for debugging + "no-console": "off", + }, + }, +); diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.cjs similarity index 100% rename from apps/mobile/jest.config.js rename to apps/mobile/jest.config.cjs diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2aca2ba..a7d0a4d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,7 @@ { "name": "@timetoleave/mobile", "version": "1.0.0", + "type": "module", "main": "index.ts", "scripts": { "start": "expo start", @@ -8,7 +9,7 @@ "ios": "expo start --ios", "web": "expo start --web", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "lint": "echo 'no lint yet'", + "lint": "eslint src/", "test": "jest" }, "dependencies": { @@ -28,14 +29,17 @@ "react-native-screens": "^4.24.0" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", "@types/react": "^19", + "eslint-plugin-react": "^7.37.5", "jest": "^29.7.0", "jest-expo": "~54.0.0", "react-test-renderer": "19.2.4", "ts-jest": "^29.4.9", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "typescript-eslint": "^8.59.3" }, "private": true } diff --git a/apps/mobile/src/__tests__/notifications.test.ts b/apps/mobile/src/__tests__/notifications.test.ts index 8c67ea1..6d9d776 100644 --- a/apps/mobile/src/__tests__/notifications.test.ts +++ b/apps/mobile/src/__tests__/notifications.test.ts @@ -23,7 +23,7 @@ import type { Event, Journey } from '@timetoleave/core'; describe('notifications service', () => { describe('calculateLeaveByTime', () => { - it('should calculate leave-by time from event time minus buffer', () => { + it('should calculate leave-by time from event time minus arrival buffer minus reminder buffer', () => { const event: Event = { id: 'test-1', title: 'Test Event', @@ -32,10 +32,12 @@ describe('notifications service', () => { source: 'manual', }; - const leaveByTime = calculateLeaveByTime(event, [], 5, 30); + const leaveByTime = calculateLeaveByTime(event, [], 30, 5); // Leave-by time should be 30 minutes before event time - const expectedTime = new Date('2025-01-01T09:30:00Z'); + // (arrival buffer) minus 5 minutes reminder buffer + // = event time - 35 minutes total + const expectedTime = new Date('2025-01-01T09:25:00Z'); expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); }); @@ -75,10 +77,11 @@ describe('notifications service', () => { }, ]; - const leaveByTime = calculateLeaveByTime(event, journeys, 5, 30); + const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5); - // Should use earliest non-cancelled journey (journey-2 at 07:00) minus buffer - const expectedTime = new Date('2025-01-01T06:30:00Z'); + // Should use earliest non-cancelled journey (journey-2 at 07:00) + // Leave-by time = journey departure (07:00) - reminder buffer (5 min) + const expectedTime = new Date('2025-01-01T06:55:00Z'); expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); }); @@ -118,10 +121,11 @@ describe('notifications service', () => { }, ]; - const leaveByTime = calculateLeaveByTime(event, journeys, 5, 30); + const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5); // Should use journey-2 since journey-1 is cancelled - const expectedTime = new Date('2025-01-01T06:30:00Z'); + // Leave-by time = journey departure (07:00) - reminder buffer (5 min) + const expectedTime = new Date('2025-01-01T06:55:00Z'); expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); }); @@ -149,10 +153,11 @@ describe('notifications service', () => { }, ]; - const leaveByTime = calculateLeaveByTime(event, journeys, 5, 30); + const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5); - // All journeys cancelled, fall back to event time minus buffer - const expectedTime = new Date('2025-01-01T09:30:00Z'); + // All journeys cancelled, fall back to event time minus arrival buffer minus reminder buffer + // = 10:00 - 30 min - 5 min = 09:25 + const expectedTime = new Date('2025-01-01T09:25:00Z'); expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); }); @@ -165,9 +170,12 @@ describe('notifications service', () => { source: 'manual', }; - const leaveByTime = calculateLeaveByTime(event, [], 5, 0); + const leaveByTime = calculateLeaveByTime(event, [], 30, 0); - expect(leaveByTime.getTime()).toBe(event.eventTime.getTime()); + // With zero reminder buffer, leave-by time = event time - arrival buffer + // = 10:00 AM - 30 minutes = 09:30 AM + const expectedTime = new Date('2025-01-01T09:30:00Z'); + expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); }); }); }); diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx index 5737dc2..f1a4587 100644 --- a/apps/mobile/src/screens/AddEventScreen.tsx +++ b/apps/mobile/src/screens/AddEventScreen.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { - Alert, StyleSheet, Text, TextInput, diff --git a/apps/mobile/src/screens/CalendarImportScreen.tsx b/apps/mobile/src/screens/CalendarImportScreen.tsx index b1291ab..05d0ff1 100644 --- a/apps/mobile/src/screens/CalendarImportScreen.tsx +++ b/apps/mobile/src/screens/CalendarImportScreen.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import { - Alert, ActivityIndicator, StyleSheet, Text, diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 27b9972..e41bd29 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -29,7 +29,7 @@ type ScreenProps = { route: RouteProp; }; -export function SettingsScreen({ navigation }: ScreenProps) { +export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const [origin, setOrigin] = useState(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); @@ -101,29 +101,30 @@ export function SettingsScreen({ navigation }: ScreenProps) { const userLat = loc.coords.latitude; const userLng = loc.coords.longitude; - // Search for stations near the user's actual GPS coordinates - // Use Nominatim reverse geocode via the API to find a nearby station - const geoResults = await api.reverseGeocode(userLat, userLng); - // Find the station closest to the user's actual coordinates - if (geoResults.length > 0) { - const closest = geoResults.reduce((best: { lat: number; lng: number; display_name: string } | null, candidate) => { - if (!best) return candidate; - const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng); - const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng); - return candDist < bestDist ? candidate : best; - }, null); - - if (closest) { - const station: Station = { - name: closest.display_name, - extId: String(closest.lat) + ',' + String(closest.lng), - lat: closest.lat, - lng: closest.lng, - }; - selectStation(station); - } + // Find real public-transport stops near the user's GPS coordinates + // via the WienerLinien nearby-stops proxy. + const stops = await api.findNearbyStops(userLat, userLng, 2000); + if (stops.length === 0) { + Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); + return; } - } catch (err) { + + // Pick the closest stop to the user's actual position + const closest = stops.reduce((best, candidate) => { + const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng); + const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng); + return candDist < bestDist ? candidate : best; + }, stops[0]); + + // Build a Station with the real stop id as extId — HAFAS can look this up. + const station: Station = { + name: closest.name, + extId: closest.id, + lat: closest.lat, + lng: closest.lng, + }; + selectStation(station); + } catch (_err) { Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.'); } }; diff --git a/apps/mobile/src/services/notifications.ts b/apps/mobile/src/services/notifications.ts index df45f54..1d07f7e 100644 --- a/apps/mobile/src/services/notifications.ts +++ b/apps/mobile/src/services/notifications.ts @@ -29,23 +29,22 @@ export function calculateLeaveByTime( bufferMinutes: number ): Date { // Calculate target arrival time (event time minus arrival buffer) - const targetArrivalTime = new Date(event.eventTime); - targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes); + const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000; - // If we have journeys, use the earliest non-cancelled real departure + // If we have journeys, use the earliest non-cancelled real departure minus reminder buffer if (journeys.length > 0) { const best = journeys .filter((j) => !j.cancelled) .sort((a, b) => a.rD.getTime() - b.rD.getTime())[0]; if (best) { - // Leave by time = target arrival time - buffer minutes - return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); + // Leave by time = earliest real departure time - reminder buffer + return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000); } } - // Fallback: event time minus arrival buffer minus buffer (no journey data) - return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); + // Fallback: event time minus arrival buffer minus reminder (no journey data) + return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000); } /** diff --git a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json deleted file mode 100644 index 4adb05c..0000000 --- a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4077.8255940000004,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":98.26416199999994,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":43.29501500000015,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":8.539022000000045,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2048.63117,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":56.078179999999975,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":70.03792500000009,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":10.01331099999993,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":44.705735000000004,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.068580999999995,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":27.223232000000053,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":6.374915999999985,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":211.8057530000001,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":73.42922599999997,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":24.91482499999995,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":24.864261000000056,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.64420599999994,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":215.039669,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":98.65793099999973,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":17.891982999999982,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":30.835298000000193,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":41.45621100000017,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":90.61091399999987,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":129.86504800000012,"failed":false}],[":src/hooks/__tests__/useWalkRoute.test.ts",{"duration":84.65005600000018,"failed":true}]]} \ No newline at end of file diff --git a/apps/web/src/__tests__/middleware.test.ts b/apps/web/src/__tests__/middleware.test.ts index 606fe63..17503a6 100644 --- a/apps/web/src/__tests__/middleware.test.ts +++ b/apps/web/src/__tests__/middleware.test.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { vi, describe, it, expect, beforeEach } from "vitest"; import proxy from "../proxy"; // Mock NextResponse to avoid internal routing context issues diff --git a/apps/web/src/app/api/bike-route/route.ts b/apps/web/src/app/api/bike-route/route.ts index 2238318..5d923ba 100644 --- a/apps/web/src/app/api/bike-route/route.ts +++ b/apps/web/src/app/api/bike-route/route.ts @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { if (fromLat === null || fromLng === null || toLat === null || toLng === null) { return NextResponse.json( - { error: "Missing or invalid parameters (fromLat, fromLng, toLat, toLng)" }, + { error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" }, { status: 400 }, ); } diff --git a/apps/web/src/app/api/walk-route/route.ts b/apps/web/src/app/api/walk-route/route.ts index 9fc563c..8cc9f6b 100644 --- a/apps/web/src/app/api/walk-route/route.ts +++ b/apps/web/src/app/api/walk-route/route.ts @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { if (fromLat === null || fromLng === null || toLat === null || toLng === null) { return NextResponse.json( - { error: "Missing or invalid parameters (fromLat, fromLng, toLat, toLng)" }, + { error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" }, { status: 400 }, ); } diff --git a/apps/web/src/app/api/wienerlinien/monitor/route.ts b/apps/web/src/app/api/wienerlinien/monitor/route.ts index 789389d..d8641e4 100644 --- a/apps/web/src/app/api/wienerlinien/monitor/route.ts +++ b/apps/web/src/app/api/wienerlinien/monitor/route.ts @@ -4,8 +4,8 @@ import { WienerLinienClient } from "@/lib/wienerlinien-client"; const client = new WienerLinienClient(); -/** Wiener Linien stop IDs are purely numeric. */ -const STOP_ID_RE = /^\d+$/; +/** Wiener Linien stop IDs can be numeric or in WL:format */ +const STOP_ID_RE = /^(?:\d+|WL:\d+)$/; /** Maximum stop IDs to batch-request in a single call. */ const MAX_STOP_IDS = 10; diff --git a/apps/web/src/app/api/wienerlinien/stops/route.ts b/apps/web/src/app/api/wienerlinien/stops/route.ts index 3179612..e76d185 100644 --- a/apps/web/src/app/api/wienerlinien/stops/route.ts +++ b/apps/web/src/app/api/wienerlinien/stops/route.ts @@ -11,8 +11,30 @@ export async function GET(request: NextRequest) { const lat = validateCoordinate(searchParams.get("lat"), -90, 90); const lng = validateCoordinate(searchParams.get("lng"), -180, 180); - if (lat === null || lng === null) { - return NextResponse.json({ error: "Missing or invalid parameters: lat and lng" }, { status: 400 }); + if (lat === null && lng === null) { + return NextResponse.json({ error: "Missing parameters: lat and lng" }, { status: 400 }); + } + + if (lat === null) { + const latParam = searchParams.get("lat"); + if (latParam === null || latParam === "") { + return NextResponse.json({ error: "Missing parameter: lat" }, { status: 400 }); + } else if (isNaN(parseFloat(latParam))) { + return NextResponse.json({ error: "Invalid parameter: lat (must be numbers)" }, { status: 400 }); + } else { + return NextResponse.json({ error: "Invalid parameter: lat (latitude out of bounds)" }, { status: 400 }); + } + } + + if (lng === null) { + const lngParam = searchParams.get("lng"); + if (lngParam === null || lngParam === "") { + return NextResponse.json({ error: "Missing parameter: lng" }, { status: 400 }); + } else if (isNaN(parseFloat(lngParam))) { + return NextResponse.json({ error: "Invalid parameter: lng (must be numbers)" }, { status: 400 }); + } else { + return NextResponse.json({ error: "Invalid parameter: lng (longitude out of bounds)" }, { status: 400 }); + } } let radius = 1000; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index dde70a1..02f6218 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8,7 +8,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: "Inter", "Poppins", "Montserrat", "Avenir Next", Arial, sans-serif; + --font-sans: var(--font-inter), "Poppins", "Montserrat", "Avenir Next", Arial, sans-serif; --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; /* Brand palette derived from the TimeToLeave logo */ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c0a7f6f..9005d3a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Inter } from "next/font/google"; import "./globals.css"; import { EventsProvider } from "@/hooks/useEventsStore"; import { ReminderSettingsProvider } from "@/hooks/useReminderSettings"; @@ -6,6 +7,13 @@ import Header from "@/app/layout/Header"; import Navbar from "@/app/layout/Navbar"; import ReminderEngine from "@/app/layout/ReminderEngine"; +const inter = Inter({ + display: "swap", + subsets: ["latin"], + variable: "--font-inter", + weight: ["400", "500", "600", "700", "800"], +}); + export const metadata: Metadata = { title: "TimeToLeave — Smart Departure Planner", description: @@ -22,10 +30,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - + diff --git a/apps/web/src/hooks/__tests__/useWalkRoute.test.ts b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts index dc5e4eb..57b64d3 100644 --- a/apps/web/src/hooks/__tests__/useWalkRoute.test.ts +++ b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts @@ -14,30 +14,24 @@ describe('useWalkRoute integration', () => { }); it('should handle API errors', async () => { - // Mock a failed API call - const originalFetch = global.fetch; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, + const mockFetch = vi.fn().mockResolvedValue( + new Response(null, { status: 500, - statusText: 'Internal Server Error' - }) as unknown as Promise); + statusText: 'Internal Server Error', + }) + ); + vi.stubGlobal('fetch', mockFetch); const { result } = renderHook(() => useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748) ); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.error).toBeDefined(); expect(result.current.walkRoute).toBe(null); - - // Restore original fetch - global.fetch = originalFetch; }); it('should work with valid coordinates', async () => { - // Mock a successful API call const mockRoute = { distance: 500, duration: 300, @@ -46,17 +40,18 @@ describe('useWalkRoute integration', () => { name: 'Start walking', distance: 500, duration: 300, - instruction: 'Walk straight ahead' - } - ] + instruction: 'Walk straight ahead', + }, + ], }; - const originalFetch = global.fetch; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockRoute) - }) as unknown as Promise); + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockRoute), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', mockFetch); const { result } = renderHook(() => useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748) @@ -68,8 +63,5 @@ describe('useWalkRoute integration', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.walkRoute).toEqual(mockRoute); expect(result.current.error).toBe(null); - - // Restore original fetch - global.fetch = originalFetch; }); }); diff --git a/apps/web/src/lib/__tests__/api-guards.test.ts b/apps/web/src/lib/__tests__/api-guards.test.ts index 9c9b155..287d663 100644 --- a/apps/web/src/lib/__tests__/api-guards.test.ts +++ b/apps/web/src/lib/__tests__/api-guards.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; import { validateCoordinate, rateLimitExceededResponse, applyRateLimitHeaders } from "../api-guards"; describe("validateCoordinate", () => { @@ -42,13 +43,10 @@ describe("rateLimitExceededResponse", () => { describe("applyRateLimitHeaders", () => { it("sets rate-limit headers on a response", () => { - const response = new Response(null, { - headers: { "Content-Type": "text/plain" }, - }); + const response = NextResponse.json({}); + applyRateLimitHeaders(response, 12, 30); - // We can't test the NextResponse-specific helper directly here - // because it depends on NextResponse internals. The helper is - // trivially tested in integration tests. - expect(true).toBe(true); + expect(response.headers.get("X-RateLimit-Limit")).toBe("30"); + expect(response.headers.get("X-RateLimit-Remaining")).toBe("12"); }); }); diff --git a/apps/web/src/lib/api-guards.ts b/apps/web/src/lib/api-guards.ts index cd5dd47..adddb54 100644 --- a/apps/web/src/lib/api-guards.ts +++ b/apps/web/src/lib/api-guards.ts @@ -45,6 +45,7 @@ export async function readBodyWithLimit( /** * Validate that a float parameter is a valid coordinate in the expected range. + * Returns null if the value is invalid, otherwise returns the parsed number. */ export function validateCoordinate( value: string | null, @@ -52,6 +53,10 @@ export function validateCoordinate( max: number, ): number | null { if (!value) return null; + // Strict numeric check - only accept pure numbers + if (!/^-?\d+\.?\d*$/.test(value)) { + return null; + } const n = parseFloat(value); if (isNaN(n) || n < min || n > max) return null; return n; diff --git a/apps/web/src/lib/walk-routing-client.ts b/apps/web/src/lib/walk-routing-client.ts index ef10b47..e68f74d 100644 --- a/apps/web/src/lib/walk-routing-client.ts +++ b/apps/web/src/lib/walk-routing-client.ts @@ -1,4 +1,4 @@ -import type { BikeRoute, BikeStep } from "@timetoleave/core"; +import type { WalkRoute, WalkStep } from "@timetoleave/core"; import { OSRM_URL } from "./constants"; import { ApiClient } from "./api-service"; @@ -58,37 +58,37 @@ export class WalkRoutingClient { this.defaultTtlMs = ttlMs; } - async getWalkRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise { - const coords = `${fromLng},${fromLat};${toLng},${toLat}`; - const path = `/route/v1/foot/${coords}`; + async getWalkRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise { + const coords = `${fromLng},${fromLat};${toLng},${toLat}`; + const path = `/route/v1/foot/${coords}`; - const cacheKey = `osrm:walk:${coords}`; + const cacheKey = `osrm:walk:${coords}`; - const res = await this.client.get( - path, - { overview: "false", steps: "true" }, - { - cacheKey, - ttl: this.defaultTtlMs, - }, - ); + const res = await this.client.get( + path, + { overview: "false", steps: "true" }, + { + cacheKey, + ttl: this.defaultTtlMs, + }, + ); - if (res.code !== "Ok" || !res.routes.length) return null; + if (res.code !== "Ok" || !res.routes.length) return null; - const route = res.routes[0]; - const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({ - name: s.name, - distance: s.distance, - duration: s.duration, - instruction: stepInstruction(s), - })); + const route = res.routes[0]; + const steps: WalkStep[] = (route.legs[0]?.steps ?? []).map((s) => ({ + name: s.name, + distance: s.distance, + duration: s.duration, + instruction: stepInstruction(s), + })); - return { - distance: route.distance, - duration: route.duration, - steps, - }; - } + return { + distance: route.distance, + duration: route.duration, + steps, + }; + } /** * Expose cache statistics for debugging/monitoring. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..02fa855 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,38 @@ +import js from "@eslint/js"; +import ts from "typescript-eslint"; + +const runtimeGlobals = { + AbortSignal: "readonly", + Blob: "readonly", + BodyInit: "readonly", + console: "readonly", + Error: "readonly", + fetch: "readonly", + FormData: "readonly", + Headers: "readonly", + Request: "readonly", + Response: "readonly", + URL: "readonly", +}; + +export default ts.config( + { + ignores: [ + "**/dist/**", + "**/.next/**", + "**/node_modules/**", + "**/coverage/**", + ], + }, + { + extends: [js.configs.recommended, ...ts.configs.recommended], + files: ["packages/**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + globals: runtimeGlobals, + parserOptions: { + sourceType: "module", + }, + }, + }, +); diff --git a/package-lock.json b/package-lock.json index c00fc4b..d88a96b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,14 +32,17 @@ "react-native-screens": "^4.24.0" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", "@types/react": "^19", + "eslint-plugin-react": "^7.37.5", "jest": "^29.7.0", "jest-expo": "~54.0.0", "react-test-renderer": "19.2.4", "ts-jest": "^29.4.9", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "typescript-eslint": "^8.59.3" } }, "apps/web": { @@ -6091,17 +6094,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6114,7 +6117,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6130,16 +6133,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -6155,14 +6158,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -6177,14 +6180,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6195,9 +6198,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -6212,15 +6215,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6237,9 +6240,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -6251,16 +6254,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6331,16 +6334,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6355,13 +6358,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -18436,16 +18439,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 378137b..a0fbda1 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "dev": "npm run dev -w apps/web", "build": "npm run build -w apps/web", "start": "npm run start -w apps/web", - "lint": "npm run lint -w apps/web && npm run lint -w apps/mobile", - "typecheck": "npm run typecheck -w apps/web && npm run typecheck -w apps/mobile", - "test": "npm run test -w apps/web", + "lint": "npm run lint -w apps/web && npm run lint -w apps/mobile && npm run lint -w packages/core && npm run lint -w packages/api-client", + "typecheck": "npm run typecheck -w apps/web && npm run typecheck -w apps/mobile && npm run typecheck -w packages/core && npm run typecheck -w packages/api-client", + "test": "npm run test -w apps/web && npm run test -w apps/mobile", "dev:mobile": "npm run start -w apps/mobile", "typecheck:mobile": "npm run typecheck -w apps/mobile" }, diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 3ec97ea..6e59261 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,5 +1,6 @@ import type { GeocodeResult, + NearbyStop, BikeRoute, WalkRoute, CalendarEvent, @@ -113,7 +114,7 @@ export class ApiClient { toStationExtId: string, date: Date, ): Promise { - // Use the proper HAFAS protocol body as expected by the endpoint + // Build a proper HAFAS TripSearch body — the /api/hafas endpoint expects svcReqL. const { date: hafasDate, time: hafasTime } = hafasDateTime(date); const body = { @@ -134,24 +135,47 @@ export class ApiClient { const res = await fetch(`${this.baseUrl}/api/hafas`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) + body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Journey search failed: ${res.status}`); return res.json(); } - async searchStation(query: string): Promise { - // WienerLinien client expects lat/lng parameters, not query string - // For now, use geocode to find Vienna coordinates as a fallback - const fallbackCoords = { lat: 48.2082, lng: 16.3738 }; - const url = buildUrl(this.baseUrl, "/api/wienerlinien/stops", { - lat: String(fallbackCoords.lat), - lng: String(fallbackCoords.lng) + async reverseGeocode(lat: number, lng: number): Promise { + const url = buildUrl(this.baseUrl, "/api/geocode/reverse", { + lat: String(lat), + lng: String(lng), }); const res = await fetch(url); - if (!res.ok) throw new Error(`Station search failed: ${res.status}`); - const result = await res.json(); - return result.stops || []; + if (!res.ok) return null; + return res.json(); + } + + async findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise { + const url = buildUrl(this.baseUrl, "/api/wienerlinien/stops", { + lat: String(lat), + lng: String(lng), + radius: String(radius), + }); + const res = await fetch(url); + if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`); + const data = await res.json(); + return data.stops ?? []; + } + + async searchStation(query: string): Promise { + // Use HAFAS LocMatch to find real stations by name — returns proper extIds. + const result = await this.hafasRequest<{ + svcReqL?: Array<{ res?: { locL?: Station[] } }>; + }>({ + svcReqL: [ + { + meth: "LocMatch", + req: { searchTxt: query, maxMatches: 5 }, + }, + ], + }); + return result?.svcReqL?.[0]?.res?.locL ?? []; } }