diff --git a/apps/mobile/src/__tests__/eventStore.test.ts b/apps/mobile/src/__tests__/eventStore.test.ts index 66d1737..cc04286 100644 --- a/apps/mobile/src/__tests__/eventStore.test.ts +++ b/apps/mobile/src/__tests__/eventStore.test.ts @@ -137,11 +137,16 @@ describe('eventStore', () => { }); describe('origin station', () => { - it('should load null when no origin exists', async () => { + it('should load the default origin when no saved origin exists', async () => { (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); const station = await loadOriginStation(); - expect(station).toBeNull(); + expect(station).toEqual({ + name: 'Goethegasse 36, 2340 Moedling', + extId: '1231701', + lat: 48.0806926, + lng: 16.2908052, + }); }); it('should load origin station from AsyncStorage', async () => { diff --git a/apps/mobile/src/components/JourneyList.tsx b/apps/mobile/src/components/JourneyList.tsx index 8ded434..b094d25 100644 --- a/apps/mobile/src/components/JourneyList.tsx +++ b/apps/mobile/src/components/JourneyList.tsx @@ -9,6 +9,8 @@ interface Props { walkRoute: WalkRoute | null; loadingWalk: boolean; showWalkingOption: boolean; + eventTime: Date; + arrivalBufferMinutes: number; origin: Station | null; colors: AppColors; } @@ -19,9 +21,14 @@ export function JourneyList({ walkRoute, loadingWalk, showWalkingOption, + eventTime, + arrivalBufferMinutes, origin, colors, }: Props) { + const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0; + const targetArrivalTime = eventTime.getTime() - arrivalBufferMinutes * 60_000; + return ( Zugverbindungen @@ -38,8 +45,19 @@ export function JourneyList({ {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'} ) : ( - journeys.map((j) => ( - + journeys.map((j) => { + const finalArrival = new Date(j.rA.getTime() + walkDurationMs); + const arrivesTooLate = finalArrival.getTime() > targetArrivalTime; + + return ( + {j.trains.length > 0 ? j.trains.join(', ') : '—'} @@ -59,8 +77,15 @@ export function JourneyList({ Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} {' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`}) + {walkDurationMs > 0 && ( + + Ziel: {finalArrival.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} + {arrivesTooLate ? ' (zu spät)' : ''} + + )} - )) + ); + }) )} {showWalkingOption && walkRoute && ( @@ -94,6 +119,7 @@ const styles = StyleSheet.create({ hint: { fontSize: 15, marginTop: 12 }, empty: { fontSize: 14 }, card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 }, + lateCard: { opacity: 0.55 }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, line: { fontSize: 16, fontWeight: '600' }, delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 }, diff --git a/apps/mobile/src/hooks/useDepartureTime.ts b/apps/mobile/src/hooks/useDepartureTime.ts index c1f24ff..58673b3 100644 --- a/apps/mobile/src/hooks/useDepartureTime.ts +++ b/apps/mobile/src/hooks/useDepartureTime.ts @@ -17,6 +17,7 @@ export function useDepartureTime( bikeDurationSeconds: number | null, activeMode: 'train' | 'bike' | null, arrivalBufferMinutes: number, + trainWalkDurationSeconds = 0, ): DepartureTimeResult { return useMemo(() => { // Calculate target arrival time (event time minus buffer) @@ -32,8 +33,9 @@ export function useDepartureTime( if (activeMode === 'train' && validJourneys.length > 0) { // Find journeys that arrive by target time + const walkDurationMs = trainWalkDurationSeconds * 1000; const onTimeJourneys = validJourneys.filter( - (journey) => journey.rA.getTime() <= targetArrivalTime.getTime(), + (journey) => journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime(), ); if (onTimeJourneys.length > 0) { @@ -43,7 +45,7 @@ export function useDepartureTime( ); departureTime = new Date(bestJourney.rD); - arrivalTime = new Date(bestJourney.rA); + arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs); mode = 'train'; } } @@ -59,5 +61,5 @@ export function useDepartureTime( } return { departureTime, arrivalTime, mode }; - }, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]); + }, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]); } diff --git a/apps/mobile/src/hooks/useDestinationStation.ts b/apps/mobile/src/hooks/useDestinationStation.ts index c05ee06..7ec02fa 100644 --- a/apps/mobile/src/hooks/useDestinationStation.ts +++ b/apps/mobile/src/hooks/useDestinationStation.ts @@ -10,6 +10,11 @@ interface HafasLocation { lon: number; } +function normalizeHafasCoordinate(value: number | undefined): number | undefined { + if (value == null) return undefined; + return Math.abs(value) > 1000 ? value / 1e6 : value; +} + export function useDestinationStation(destination: string | undefined) { const [station, setStation] = useState(null); const [loading, setLoading] = useState(false); @@ -63,12 +68,16 @@ export function useDestinationStation(destination: string | undefined) { ], }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = await api.hafasRequest(body); + const data = await api.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body); const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? []; const stations = locL .filter((l) => l.type === 'S') - .map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon })); + .map((l) => ({ + name: l.name, + extId: l.extId, + lat: normalizeHafasCoordinate(l.lat), + lng: normalizeHafasCoordinate(l.lon), + })); if (!isMounted) return; setStation(stations[0] ?? null); diff --git a/apps/mobile/src/hooks/useWalkRoute.ts b/apps/mobile/src/hooks/useWalkRoute.ts index 9143444..8b97512 100644 --- a/apps/mobile/src/hooks/useWalkRoute.ts +++ b/apps/mobile/src/hooks/useWalkRoute.ts @@ -21,6 +21,9 @@ export function useWalkRoute( const fetchRoute = async () => { if (fromLat == null || fromLng == null || toLat == null || toLng == null) { + setWalkRoute(null); + setLoading(false); + setError(null); return; } diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx index 134ed09..3f67e57 100644 --- a/apps/mobile/src/screens/AddEventScreen.tsx +++ b/apps/mobile/src/screens/AddEventScreen.tsx @@ -102,7 +102,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { Ziel { fetchData(); }, [fetchData]); @@ -132,6 +154,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { ? 'bike' : null, arrivalBufferMinutes, + showWalkingOption ? (walkRoute?.duration ?? 0) : 0, ); if (loading) { @@ -227,13 +250,15 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { )} - {effectiveMode === 'train' && ( + {event && effectiveMode === 'train' && ( diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts index 4749e00..ab39561 100644 --- a/apps/mobile/src/store/eventStore.ts +++ b/apps/mobile/src/store/eventStore.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { Event, Station, ReminderSettings } from '@timetoleave/core'; +import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core'; import * as Notifications from '../services/expoNotifications'; import { SchedulableTriggerInputTypes } from 'expo-notifications'; @@ -137,7 +137,7 @@ export async function removeEvent(id: string, onDone?: () => void): Promise { const json = await AsyncStorage.getItem(ORIGIN_KEY); - return json ? JSON.parse(json) : null; + return json ? JSON.parse(json) : DEFAULT_ORIGIN_STATION; } export async function saveOriginStation(station: Station): Promise { diff --git a/apps/web/src/app/add-event/AddEventModal.tsx b/apps/web/src/app/add-event/AddEventModal.tsx index 3728bf8..26d9487 100644 --- a/apps/web/src/app/add-event/AddEventModal.tsx +++ b/apps/web/src/app/add-event/AddEventModal.tsx @@ -127,7 +127,7 @@ const AddEventModal: React.FC = ({ isOpen, onClose, editEven type="text" value={destination} onChange={(e) => setDestination(e.target.value)} - placeholder="Vienna Main Station" + placeholder="Technikum Wien or Hoechstaedtplatz 6, 1200 Wien" className="brand-input px-3 py-2" required /> diff --git a/apps/web/src/app/api/calendar/parse/route.ts b/apps/web/src/app/api/calendar/parse/route.ts index 54b524a..a763e14 100644 --- a/apps/web/src/app/api/calendar/parse/route.ts +++ b/apps/web/src/app/api/calendar/parse/route.ts @@ -12,14 +12,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 }); } - // Guard: cap at 10 000 chars to prevent OOM from node-ical parsing - if (body.length > 10_000) { - return NextResponse.json( - { error: "Request body too large (max 10 KB for ICS content)" }, - { status: 413 }, - ); - } - // Use extractEvents for consistent parsing with cleanLocation() and filtering const events = extractEvents(body, DEFAULT_DAYS); diff --git a/apps/web/src/app/event/EventCard.tsx b/apps/web/src/app/event/EventCard.tsx index 915c63b..7d786a9 100644 --- a/apps/web/src/app/event/EventCard.tsx +++ b/apps/web/src/app/event/EventCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { format } from "date-fns"; import { useJourneys } from "@/hooks/useJourneys"; import { useDestinationStation } from "@/hooks/useDestinationStation"; @@ -36,12 +36,6 @@ export default function EventCard({ event, originStation }: EventCardProps) { const destStation = useDestinationStation(event.destination); - const { - journeys, - loading: journeysLoading, - error: journeysError, - } = useJourneys(originStation?.extId ?? null, destStation.station?.extId ?? null, event.eventTime, 0); - const destCoords = useGeocode(event.destination); const { @@ -69,6 +63,30 @@ export default function EventCard({ event, originStation }: EventCardProps) { } = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings(); + const finalWalkLookupPending = + showWalkingOption && + destCoords.coords !== null && + destStation.station?.lat != null && + destStation.station?.lng != null && + !walkRoute && + !walkError; + const trainWalkDurationSeconds = showWalkingOption ? (walkRoute?.duration ?? 0) : 0; + const trainStationArrivalTarget = useMemo( + () => new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000 - trainWalkDurationSeconds * 1000), + [event.eventTime, arrivalBufferMinutes, trainWalkDurationSeconds], + ); + + const { + journeys, + loading: journeysLoading, + error: journeysError, + } = useJourneys( + finalWalkLookupPending ? null : (originStation?.extId ?? null), + finalWalkLookupPending ? null : (destStation.station?.extId ?? null), + trainStationArrivalTarget, + 0, + true, + ); type TransportMode = "train" | "bike"; const [requestedMode, setRequestedMode] = useState("train"); @@ -79,6 +97,7 @@ export default function EventCard({ event, originStation }: EventCardProps) { journeys, bikeRoute?.duration ?? null, activeMode, + trainWalkDurationSeconds, ); const { countdown, status } = useClock(event.eventTime, departureTime); @@ -190,6 +209,7 @@ export default function EventCard({ event, originStation }: EventCardProps) { loading={journeysLoading} error={journeysError} arrivalBufferMinutes={arrivalBufferMinutes} + walkDurationSeconds={trainWalkDurationSeconds} showWalkingOption={showWalkingOption} walkRoute={walkRoute} walkLoading={walkLoading} diff --git a/apps/web/src/app/event/JourneyList.tsx b/apps/web/src/app/event/JourneyList.tsx index 8d004c8..3b6d586 100644 --- a/apps/web/src/app/event/JourneyList.tsx +++ b/apps/web/src/app/event/JourneyList.tsx @@ -10,6 +10,7 @@ type JourneyListProps = { journeys: Journey[]; eventTime: Date; arrivalBufferMinutes: number; + walkDurationSeconds?: number; className?: string; }; @@ -17,9 +18,11 @@ const JourneyList: React.FC = ({ journeys, eventTime, arrivalBufferMinutes, + walkDurationSeconds = 0, className = "", }) => { const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000); + const walkDurationMs = walkDurationSeconds * 1000; if (journeys.length === 0) { return
No journeys found
; @@ -28,7 +31,8 @@ const JourneyList: React.FC = ({ return (
    {journeys.map((journey) => { - const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime(); + const finalArrival = new Date(journey.rA.getTime() + walkDurationMs); + const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime(); const departure = journey.rD ?? journey.sD; const arrival = journey.rA ?? journey.sA; @@ -60,7 +64,10 @@ const JourneyList: React.FC = ({
    {journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"} - Arrives {formatTime(arrival)} + + Arrives {formatTime(arrival)} + {walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""} + {arrivesTooLate && ( misses {arrivalBufferMinutes} min buffer diff --git a/apps/web/src/app/event/TrainSection.tsx b/apps/web/src/app/event/TrainSection.tsx index 08f261a..f2b8f1c 100644 --- a/apps/web/src/app/event/TrainSection.tsx +++ b/apps/web/src/app/event/TrainSection.tsx @@ -17,6 +17,7 @@ type TrainSectionProps = { onRefresh?: () => void; className?: string; arrivalBufferMinutes?: number; + walkDurationSeconds?: number; showWalkingOption?: boolean; walkRoute?: WalkRoute | null; walkLoading?: boolean; @@ -32,6 +33,7 @@ const TrainSection: React.FC = ({ onRefresh, className = "", arrivalBufferMinutes, + walkDurationSeconds, showWalkingOption, walkRoute, walkLoading, @@ -68,7 +70,12 @@ const TrainSection: React.FC = ({
    {error}
    ) : ( <> - + {showWalkingOption && (walkRoute || walkLoading || walkError) && (
    diff --git a/apps/web/src/hooks/__tests__/useDepartureTime.test.ts b/apps/web/src/hooks/__tests__/useDepartureTime.test.ts index a52e103..3801004 100644 --- a/apps/web/src/hooks/__tests__/useDepartureTime.test.ts +++ b/apps/web/src/hooks/__tests__/useDepartureTime.test.ts @@ -78,6 +78,24 @@ describe("useDepartureTime", () => { ); }); + it("should include final walking duration when selecting a train journey", () => { + const journeys: Journey[] = [ + createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z"), + createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"), + ]; + + const { result } = renderHook(() => + useDepartureTime(eventTime, journeys, null, "train", 8 * 60) + , { wrapper }); + + expect(result.current.departureTime?.getTime()).toBe( + new Date("2024-01-01T10:30:00Z").getTime() + ); + expect(result.current.arrivalTime?.getTime()).toBe( + new Date("2024-01-01T11:53:00Z").getTime() + ); + }); + it("should calculate correct departure time for bike mode", () => { const bikeRouteDuration = 1800; // 30 minutes in seconds diff --git a/apps/web/src/hooks/useDepartureTime.ts b/apps/web/src/hooks/useDepartureTime.ts index f147eb1..86054cb 100644 --- a/apps/web/src/hooks/useDepartureTime.ts +++ b/apps/web/src/hooks/useDepartureTime.ts @@ -14,7 +14,8 @@ export function useDepartureTime( eventTime: Date, journeys: Journey[] | null, bikeRoute: number | null, // duration in seconds - activeMode: "train" | "bike" | null + activeMode: "train" | "bike" | null, + trainWalkDurationSeconds = 0, ): DepartureTimeResult { const { arrivalBufferMinutes } = useReminderSettings(); @@ -31,9 +32,10 @@ export function useDepartureTime( let mode: "train" | "bike" | null = null; if (activeMode === "train" && validJourneys.length > 0) { - const onTimeJourneys = validJourneys.filter(journey => - journey.rA.getTime() <= targetArrivalTime.getTime() - ); + const walkDurationMs = trainWalkDurationSeconds * 1000; + const onTimeJourneys = validJourneys.filter((journey) => ( + journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime() + )); if (onTimeJourneys.length > 0) { const bestJourney = onTimeJourneys.reduce((latest, current) => @@ -41,7 +43,7 @@ export function useDepartureTime( ); departureTime = new Date(bestJourney.rD); - arrivalTime = new Date(bestJourney.rA); + arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs); mode = "train"; } } @@ -57,5 +59,5 @@ export function useDepartureTime( } return { departureTime, arrivalTime, mode }; - }, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes]); + }, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]); } diff --git a/apps/web/src/hooks/useDestinationStation.ts b/apps/web/src/hooks/useDestinationStation.ts index 0875178..c5853ae 100644 --- a/apps/web/src/hooks/useDestinationStation.ts +++ b/apps/web/src/hooks/useDestinationStation.ts @@ -10,13 +10,25 @@ interface HafasLocation { lon: number; } +function normalizeHafasCoordinate(value: number | undefined): number | undefined { + if (value == null) return undefined; + return Math.abs(value) > 1000 ? value / 1e6 : value; +} + export function useDestinationStation(destination: string) { const [station, setStation] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (!destination.trim()) return; + if (!destination.trim()) { + const resetId = setTimeout(() => { + setStation(null); + setLoading(false); + setError(null); + }, 0); + return () => clearTimeout(resetId); + } let isMounted = true; const timeoutId = setTimeout(async () => { @@ -55,12 +67,16 @@ export function useDestinationStation(destination: string) { ], }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = await client.hafasRequest(body); + const data = await client.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body); const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? []; const stations = locL .filter((l) => l.type === "S") - .map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon })); + .map((l) => ({ + name: l.name, + extId: l.extId, + lat: normalizeHafasCoordinate(l.lat), + lng: normalizeHafasCoordinate(l.lon), + })); if (!isMounted) return; setStation(stations[0] ?? null); diff --git a/apps/web/src/hooks/useEventsStore.tsx b/apps/web/src/hooks/useEventsStore.tsx index e1b0e52..55bd0fa 100644 --- a/apps/web/src/hooks/useEventsStore.tsx +++ b/apps/web/src/hooks/useEventsStore.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from "react"; import type { Event, CalendarEvent } from "@timetoleave/core"; const STORAGE_KEY = "ttl_events"; @@ -30,9 +30,18 @@ interface EventsContextType { const EventsContext = createContext(undefined); export function EventsProvider({ children }: { children: ReactNode }) { - const [events, setEventsState] = useState(loadFromStorage); + const [events, setEventsState] = useState([]); + const skipInitialWrite = useRef(true); useEffect(() => { + setEventsState(loadFromStorage()); + }, []); + + useEffect(() => { + if (skipInitialWrite.current) { + skipInitialWrite.current = false; + return; + } localStorage.setItem(STORAGE_KEY, JSON.stringify(events)); }, [events]); diff --git a/apps/web/src/hooks/useGeocode.ts b/apps/web/src/hooks/useGeocode.ts index 88ca333..9a99ae9 100644 --- a/apps/web/src/hooks/useGeocode.ts +++ b/apps/web/src/hooks/useGeocode.ts @@ -1,7 +1,5 @@ import { useState, useEffect } from "react"; -import { ApiClient } from "@timetoleave/api-client"; - -const client = new ApiClient(); +import { api as client } from "@/lib/api"; export function useGeocode(destination: string) { const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); @@ -9,7 +7,14 @@ export function useGeocode(destination: string) { const [error, setError] = useState(null); useEffect(() => { - if (!destination.trim()) return; + if (!destination.trim()) { + const resetId = setTimeout(() => { + setCoords(null); + setLoading(false); + setError(null); + }, 0); + return () => clearTimeout(resetId); + } let isMounted = true; let abortController: AbortController | null = null; @@ -22,7 +27,8 @@ export function useGeocode(destination: string) { try { const data = await client.geocode(destination, "at"); if (isMounted) { - setCoords({ lat: data[0]?.lat ?? 0, lng: data[0]?.lng ?? 0 }); + const first = data[0]; + setCoords(first ? { lat: first.lat, lng: first.lng } : null); setLoading(false); } } catch (err) { diff --git a/apps/web/src/hooks/useJourneys.ts b/apps/web/src/hooks/useJourneys.ts index 9fa251e..ab360d2 100644 --- a/apps/web/src/hooks/useJourneys.ts +++ b/apps/web/src/hooks/useJourneys.ts @@ -3,11 +3,39 @@ import type { Journey } from "@timetoleave/core"; import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core"; import { api as client } from "@/lib/api"; +const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000; + +function buildTripSearchBody( + fromStationExtId: string, + toStationExtId: string, + hafasDate: string, + hafasTime: string, + arriveBy: boolean, + numF = 5, +) { + return { + svcReqL: [ + { + meth: "TripSearch", + req: { + depLocL: [{ type: "S", extId: fromStationExtId }], + arrLocL: [{ type: "S", extId: toStationExtId }], + outDate: hafasDate, + outTime: hafasTime, + outFrwd: !arriveBy, + numF, + }, + }, + ], + }; +} + export function useJourneys( fromStationExtId: string | null, toStationExtId: string | null, date: Date, refreshKey = 0, + arriveBy = false, ) { const [journeys, setJourneys] = useState([]); const [loading, setLoading] = useState(false); @@ -18,6 +46,9 @@ export function useJourneys( const fetchJourneys = async () => { if (!fromStationExtId || !toStationExtId || !date) { + setJourneys([]); + setLoading(false); + setError(null); return; } @@ -26,24 +57,26 @@ export function useJourneys( try { const { date: hafasDate, time: hafasTime } = hafasDateTime(date); + const data = await client.hafasRequest( + buildTripSearchBody(fromStationExtId, toStationExtId, hafasDate, hafasTime, arriveBy), + ); + let result = parseHafasJourneys(data, hafasDate, date); - const body = { - svcReqL: [ - { - meth: "TripSearch", - req: { - depLocL: [{ type: "S", extId: fromStationExtId }], - arrLocL: [{ type: "S", extId: toStationExtId }], - outDate: hafasDate, - outTime: hafasTime, - numF: 5, - }, - }, - ], - }; - - const data = await client.hafasRequest(body); - const result = parseHafasJourneys(data, hafasDate, date); + if (arriveBy && result.length === 0) { + const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS); + const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate); + const fallbackData = await client.hafasRequest( + buildTripSearchBody( + fromStationExtId, + toStationExtId, + fallbackHafasDate, + fallbackHafasTime, + false, + 10, + ), + ); + result = parseHafasJourneys(fallbackData, fallbackHafasDate, fallbackDate); + } if (isMounted) { setJourneys(result); @@ -63,7 +96,7 @@ export function useJourneys( return () => { isMounted = false; }; - }, [fromStationExtId, toStationExtId, date, refreshKey]); + }, [fromStationExtId, toStationExtId, date, refreshKey, arriveBy]); return { journeys, loading, error }; } diff --git a/apps/web/src/hooks/useOriginStation.ts b/apps/web/src/hooks/useOriginStation.ts index 3148173..16b37f3 100644 --- a/apps/web/src/hooks/useOriginStation.ts +++ b/apps/web/src/hooks/useOriginStation.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import type { Station } from "@timetoleave/core"; -import { DEFAULT_STATION_NAME, DEFAULT_STATION_EXT_ID } from "@/lib/constants"; +import { DEFAULT_STATION } from "@/lib/constants"; import { useGeolocation } from "./useGeolocation"; interface HafasLocation { @@ -23,7 +23,7 @@ export function useOriginStation() { const fetchNearestStation = async () => { if (!location || state !== "granted") { if (isMounted) { - setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID }); + setStation(DEFAULT_STATION); } return; } @@ -76,14 +76,14 @@ export function useOriginStation() { if (stations.length > 0) { setStation(stations[0]); } else { - setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID }); + setStation(DEFAULT_STATION); } setLoading(false); } catch (err: unknown) { if (isMounted) { const message = err instanceof Error ? err.message : "Failed to find nearest station"; setError(message); - setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID }); + setStation(DEFAULT_STATION); setLoading(false); } } diff --git a/apps/web/src/hooks/useWalkRoute.ts b/apps/web/src/hooks/useWalkRoute.ts index 1ba8080..1e851ee 100644 --- a/apps/web/src/hooks/useWalkRoute.ts +++ b/apps/web/src/hooks/useWalkRoute.ts @@ -1,8 +1,6 @@ import { useState, useEffect } from "react"; import type { WalkRoute } from "@timetoleave/core"; -import { ApiClient } from "@timetoleave/api-client"; - -const client = new ApiClient(); +import { api as client } from "@/lib/api"; export function useWalkRoute( fromLat: number | undefined, @@ -19,6 +17,9 @@ export function useWalkRoute( const fetchRoute = async () => { if (fromLat == null || fromLng == null || toLat == null || toLng == null) { + setWalkRoute(null); + setLoading(false); + setError(null); return; } diff --git a/apps/web/src/lib/__tests__/calendar-utils.test.ts b/apps/web/src/lib/__tests__/calendar-utils.test.ts index bd33736..231aba5 100644 --- a/apps/web/src/lib/__tests__/calendar-utils.test.ts +++ b/apps/web/src/lib/__tests__/calendar-utils.test.ts @@ -16,6 +16,7 @@ describe("calendar-utils", () => { it("should return the location as-is if it's not a known station", () => { expect(cleanLocation("Some Other Station")).toBe("Some Other Station"); expect(cleanLocation("Berlin")).toBe("Berlin"); + expect(cleanLocation("Hoechstaedtplatz 6, 1200 Wien")).toBe("Hoechstaedtplatz 6, 1200 Wien"); }); it("should handle partial matches", () => { diff --git a/apps/web/src/lib/calendar-utils.ts b/apps/web/src/lib/calendar-utils.ts index 8b3ab52..822db21 100644 --- a/apps/web/src/lib/calendar-utils.ts +++ b/apps/web/src/lib/calendar-utils.ts @@ -84,5 +84,5 @@ export function cleanLocation(location: string): string { } } - return location.split(",")[0].trim(); + return location.replace(/\s+/g, " ").trim(); } diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 48df459..8ab0b68 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -1,3 +1,9 @@ +import { + DEFAULT_ORIGIN_STATION, + DEFAULT_ORIGIN_STATION_EXT_ID, + DEFAULT_ORIGIN_STATION_NAME, +} from "@timetoleave/core"; + export const HAFAS_URL = process.env.HAFAS_URL || "https://fahrplan.oebb.at/bin/mgate.exe"; export const HAFAS_TIMEOUT_MS = parseInt(process.env.HAFAS_TIMEOUT_MS ?? "10000", 10); export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org"; @@ -5,6 +11,7 @@ export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToL export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org"; export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2"; export const DEFAULT_DAYS = 14; -export const DEFAULT_STATION_NAME = "Mödling Bahnhof"; -export const DEFAULT_STATION_EXT_ID = "1231701"; +export const DEFAULT_STATION = DEFAULT_ORIGIN_STATION; +export const DEFAULT_STATION_NAME = DEFAULT_ORIGIN_STATION_NAME; +export const DEFAULT_STATION_EXT_ID = DEFAULT_ORIGIN_STATION_EXT_ID; export const APP_VERSION = process.env.APP_VERSION || "0.1.0"; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index a1318e3..d09b8f8 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -25,11 +25,14 @@ const allowedOrigins: Set = new Set( : ["http://localhost:3000"], // dev fallback ); -// Rate limiter: 30 requests / minute per IP. Tune these values for -// your deployment. +// Rate limiter: tune through env for deployment. Calendar pages legitimately +// fan out across geocode, routing, transit, and nearby-stop APIs per event. +const rateLimitMaxRequests = Number.parseInt(process.env.API_RATE_LIMIT_MAX_REQUESTS ?? "120", 10); +const rateLimitWindowMs = Number.parseInt(process.env.API_RATE_LIMIT_WINDOW_MS ?? "60000", 10); + const limiter = new RateLimiter({ - maxRequests: 30, - windowMs: 60_000, + maxRequests: Number.isFinite(rateLimitMaxRequests) ? rateLimitMaxRequests : 120, + windowMs: Number.isFinite(rateLimitWindowMs) ? rateLimitWindowMs : 60_000, }); // ---------- Helpers ---------- diff --git a/docs/CODEBASE_FUNCTION_GUIDE.md b/docs/CODEBASE_FUNCTION_GUIDE.md index 69abc21..9fb1f71 100644 --- a/docs/CODEBASE_FUNCTION_GUIDE.md +++ b/docs/CODEBASE_FUNCTION_GUIDE.md @@ -99,7 +99,6 @@ This client talks to the web app's backend proxy routes. `baseUrl` defaults to a | Function or method | What it does | | --- | --- | -| `parseHafasJourneys(json, hafasDate, queryDate)` | Internal parser that converts raw HAFAS `outConL` data into shared `Journey` objects. It parses scheduled and realtime times, computes delay, extracts train names, change count, platform, and cancellation state. | | `buildUrl(base, path, params)` | Internal helper that builds a URL and encodes query parameters. | | `ApiClient.constructor(baseUrl?)` | Stores the backend base URL. | | `ApiClient.getHealth()` | Calls `/api/health` and returns the health payload, throwing on non-OK responses. | @@ -109,7 +108,7 @@ This client talks to the web app's backend proxy routes. `baseUrl` defaults to a | `ApiClient.fetchCalendar(url, days?)` | Calls `/api/calendar` for a remote ICS URL and returns normalized `CalendarEvent[]`. | | `ApiClient.parseCalendarIcs(content)` | POSTs raw ICS content to `/api/calendar/parse` and returns normalized `CalendarEvent[]`. | | `ApiClient.hafasRequest(body)` | POSTs an arbitrary allowed HAFAS body to `/api/hafas`. Used for `TripSearch` and `LocMatch`. | -| `ApiClient.searchJourneys(fromStationExtId, toStationExtId, date)` | Builds a HAFAS `TripSearch`, sends it to `/api/hafas`, and parses the response into `Journey[]`. | +| `ApiClient.searchJourneys(fromStationExtId, toStationExtId, date)` | Builds a HAFAS `TripSearch`, sends it to `/api/hafas`, and parses the response into `Journey[]` with the shared `parseHafasJourneys` from `@timetoleave/core`. | | `ApiClient.reverseGeocode(lat, lng)` | Calls `/api/geocode/reverse`. Note: the current web app does not define this route, so callers should tolerate `null` or failures. | | `ApiClient.findNearbyStops(lat, lng, radius?)` | Calls `/api/wienerlinien/stops` and returns nearby stops. | | `ApiClient.searchStation(query)` | Uses HAFAS `LocMatch` through `/api/hafas` to search station names. | @@ -133,7 +132,7 @@ Defines environment-backed service URLs and defaults: | `OSRM_URL` | Routing endpoint for bike and walk routes. | | `WIENER_LINIEN_API_URL` | Wiener Linien API base. | | `DEFAULT_DAYS` | Default calendar import horizon. | -| `DEFAULT_STATION_NAME`, `DEFAULT_STATION_EXT_ID` | Web fallback origin station. | +| `DEFAULT_STATION`, `DEFAULT_STATION_NAME`, `DEFAULT_STATION_EXT_ID` | Web fallback origin. The default origin is Goethegasse 36, 2340 Moedling, using Mödling Bahnhof as the nearest transit station fallback. | | `APP_VERSION` | Health endpoint version string. | ### `apps/web/src/lib/api-service.ts` @@ -162,6 +161,12 @@ Generic HTTP helper layer used by service clients. | `ApiClient.cacheStats()` | Exposes underlying cache stats. | | `ApiClient.clearCache()` | Clears the underlying cache. | +### `apps/web/src/lib/api.ts` + +| Export | What it does | +| --- | --- | +| `api` | Shared browser-side `@timetoleave/api-client` instance with the default same-origin base URL. Web hooks import this singleton instead of creating their own client instances. | + ### `apps/web/src/lib/api-guards.ts` | Function | What it does | @@ -200,10 +205,9 @@ Generic HTTP helper layer used by service clients. | Function, class, or method | What it does | | --- | --- | -| `parseHafasJourneys(json, hafasDate, queryDate)` | Converts raw HAFAS trip responses into shared `Journey[]` using `parseHafasTime`. | | `HafasClient.constructor(baseUrl?, timeoutMs?)` | Creates an internal retrying `ApiClient` for live HAFAS calls. Caching is disabled because journey data changes often. | | `HafasClient.searchStation(query)` | Sends HAFAS `LocMatch` and returns matching station names and extIds. | -| `HafasClient.fetchJourneys(from, to, date)` | Sends HAFAS `TripSearch` between two stations for a date and parses the journeys. | +| `HafasClient.fetchJourneys(from, to, date)` | Sends HAFAS `TripSearch` between two stations for a date and parses the journeys with the shared core HAFAS parser. | | `HafasClient.cacheStats()` | Exposes cache stats from the internal client, mostly for debugging. | ### `apps/web/src/lib/geocoding-client.ts` @@ -493,19 +497,31 @@ All route functions are Next.js App Router route handlers. | `apps/mobile/src/store/eventStore.ts` | `reviveDates(json)` | Internal helper that parses stored events and converts event time strings back to `Date`. | | `apps/mobile/src/store/eventStore.ts` | `getNotificationSettings()` | Internal helper that loads notification settings or returns defaults. | | `apps/mobile/src/store/eventStore.ts` | `calculateLeaveByTime(event, arrivalBufferMinutes, bufferMinutes)` | Computes a fallback leave-by time from event time minus arrival buffer minus reminder buffer. It currently does not use live journey data. | -| `apps/mobile/src/store/eventStore.ts` | `scheduleEventNotification(event)` | Cancels existing notifications for an event and schedules default reminders 30 minutes, 10 minutes, and 0 minutes before leave-by time when enabled. | +| `apps/mobile/src/store/eventStore.ts` | `fireNotificationsForEvent(event, leaveByTime)` | Internal helper that schedules the standard 30 minute, 10 minute, and leave-now notifications, skipping past triggers and triggers more than two hours before the event. | +| `apps/mobile/src/store/eventStore.ts` | `scheduleEventNotification(event)` | Cancels existing notifications for one event and delegates standard reminder creation to `fireNotificationsForEvent` when notifications are enabled. | | `apps/mobile/src/store/eventStore.ts` | `loadEvents()` | Loads persisted events from AsyncStorage. | | `apps/mobile/src/store/eventStore.ts` | `saveEvents(events)` | Persists events to AsyncStorage. | | `apps/mobile/src/store/eventStore.ts` | `addEvent(event)` | Adds an event, saves the list, and schedules notifications. | | `apps/mobile/src/store/eventStore.ts` | `updateEvent(id, updates)` | Updates a stored event, saves the list, and reschedules notifications for that event. | | `apps/mobile/src/store/eventStore.ts` | `removeEvent(id, onDone?)` | Removes an event, cancels its scheduled notifications, and calls an optional completion callback. | -| `apps/mobile/src/store/eventStore.ts` | `loadOriginStation()` | Loads the saved origin station. | +| `apps/mobile/src/store/eventStore.ts` | `loadOriginStation()` | Loads the saved origin station or returns the shared default origin at Goethegasse 36, 2340 Moedling when none is saved. | | `apps/mobile/src/store/eventStore.ts` | `saveOriginStation(station)` | Persists the selected origin station. | | `apps/mobile/src/store/eventStore.ts` | `loadNotificationSettings()` | Loads notification settings or returns defaults. | | `apps/mobile/src/store/eventStore.ts` | `saveNotificationSettings(settings)` | Persists notification settings. | -| `apps/mobile/src/store/eventStore.ts` | `rescheduleAllNotifications()` | Cancels all scheduled notifications and recreates reminders for every stored event using current settings. | +| `apps/mobile/src/store/eventStore.ts` | `rescheduleAllNotifications()` | Loads events and settings together, cancels all scheduled notifications, exits early when notifications are disabled, and recreates reminders for every stored event through `fireNotificationsForEvent`. | | `apps/mobile/src/polyfills/sharedArrayBuffer.ts` | `toWellFormedString(value)` | Internal polyfill helper that replaces malformed UTF-16 surrogate pairs. The file also polyfills `String.prototype.toWellFormed`, `String.prototype.isWellFormed`, `ArrayBuffer.prototype.resizable`, and `SharedArrayBuffer` when missing. | +### Mobile components + +These components were extracted from the mobile detail screen so `EventDetailScreen` now handles orchestration while the display sections stay focused. + +| File | Function/component | What it does | +| --- | --- | --- | +| `apps/mobile/src/components/EventHeader.tsx` | `EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors })` | Displays the selected event title, destination, localized event time, source, leave-by time, arrive-by time, and arrival buffer. | +| `apps/mobile/src/components/JourneyList.tsx` | `JourneyList(props)` | Displays train connections, destination-station loading state, empty state based on whether an origin station exists, delay/cancellation badges, and optional final walking route summary. | +| `apps/mobile/src/components/BikeSection.tsx` | `BikeSection({ bikeRoute, loading, origin, colors })` | Displays bike route loading, empty, duration, distance, and a placeholder map area. | +| `apps/mobile/src/components/NearbyStops.tsx` | `NearbyStops({ stops, departures, loading, error, colors })` | Displays nearby destination public-transport stops or the first eight live departures. It hides itself when there is no loading state, no stops, and no error. | + ### Mobile screens | File | Function/component | What it does | @@ -513,7 +529,7 @@ All route functions are Next.js App Router route handlers. | `apps/mobile/src/screens/EventListScreen.tsx` | `EventListScreen({ navigation })` | Main mobile list screen. Loads events from AsyncStorage, refreshes on focus and pull-to-refresh, recalculates countdowns every 30 seconds, and lets users navigate to details, edit, delete, settings, or calendar import. Significant inner callbacks: `reload`, `onRefresh`, and `renderItem`. | | `apps/mobile/src/screens/AddEventScreen.tsx` | `AddEventScreen({ navigation, route })` | Manual add/edit form. Loads an existing event when `editEventId` is present, validates title/destination/date/time, then calls `addEvent` or `updateEvent`. Significant inner functions: `validate` and `handleSave`. | | `apps/mobile/src/screens/CalendarImportScreen.tsx` | `CalendarImportScreen({ navigation })` | Imports ICS URL events through the backend or syncs native device calendar events. Avoids duplicates by ID before calling `addEvent`. Significant inner functions: `handleImport` and `handleSyncNative`. | -| `apps/mobile/src/screens/EventDetailScreen.tsx` | `EventDetailScreen({ navigation, route })` | Mobile event detail and route screen. Loads event, origin station, settings, resolves destination station, searches journeys, loads bike/walk routes, displays leave-by data, and shows nearby Wiener Linien data. Significant inner functions: `fetchData` and `handleRefresh`. | +| `apps/mobile/src/screens/EventDetailScreen.tsx` | `EventDetailScreen({ navigation, route })` | Mobile event detail orchestrator. Loads event, origin station, settings, resolves destination station, searches journeys, loads bike/walk routes, computes leave-by data, manages train/bike mode selection, and passes display data to `EventHeader`, `JourneyList`, `BikeSection`, and `NearbyStops`. Significant inner functions: `fetchData` and `handleRefresh`. | | `apps/mobile/src/screens/SettingsScreen.tsx` | `SettingsScreen({ navigation })` | Settings screen for origin station, current-location station lookup, reminder settings, advanced transport toggles, and theme toggle. Significant inner functions: `searchStation`, `onQueryChange`, `selectStation`, `useCurrentLocation`, `toggleNotifications`, `updateBufferMinutes`, `updateArrivalBuffer`, `toggleWalking`, and `toggleBike`. | ## Development Notes for New Contributors @@ -522,12 +538,16 @@ Use `packages/core` for logic that must behave the same on web and mobile. Time Use `packages/api-client` for calls from client code to the web backend. If a new backend route is shared by web and mobile, add a method there instead of duplicating `fetch` calls in screens. +Use `apps/web/src/lib/api.ts` when a web hook needs the shared browser API client. Creating ad-hoc `new ApiClient()` instances in each hook is no longer the local pattern. + Use `apps/web/src/lib/*` for server-side integrations and wrappers around external services. These files are the right place for caching, retries, protocol parsing, and API-specific response validation. Use `apps/web/src/app/api/**/route.ts` for public backend proxy endpoints. Keep validation and size limits close to the route handler, then delegate external service work to `apps/web/src/lib`. Use hooks for UI-side asynchronous state. Most web and mobile hooks follow the same pattern: inputs, `loading`, `error`, result state, debounce or refresh logic, and cleanup guards to prevent setting state after unmount. +For mobile detail UI, keep orchestration in `EventDetailScreen` and reusable display sections in `apps/mobile/src/components`. The extracted components expect already-loaded data plus the `useColors()` palette. + Be especially careful with HAFAS time handling. HAFAS dates and times are Vienna-local strings. Use `hafasDateTime()` before building requests and `parseHafasTime()` when parsing responses. Be careful with stored dates. Web localStorage and mobile AsyncStorage serialize `Date` objects to strings, so both apps have helper functions that revive `eventTime` back into `Date`. diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 3192098..7532ded 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -12,6 +12,12 @@ import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core"; const DEFAULT_BASE_URL = ""; +type SearchJourneyOptions = { + arriveBy?: boolean; +}; + +const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000; + function buildUrl(base: string, path: string, params: Record = {}): string { const queryString = Object.entries(params) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) @@ -20,6 +26,36 @@ function buildUrl(base: string, path: string, params: Record = { return queryString ? `${full}?${queryString}` : full; } +function buildTripSearchBody( + fromStationExtId: string, + toStationExtId: string, + hafasDate: string, + hafasTime: string, + arriveBy: boolean, + numF = 5, +) { + return { + svcReqL: [ + { + meth: "TripSearch", + req: { + depLocL: [{ type: "S", extId: fromStationExtId }], + arrLocL: [{ type: "S", extId: toStationExtId }], + outDate: hafasDate, + outTime: hafasTime, + outFrwd: !arriveBy, + numF, + }, + }, + ], + }; +} + +function normalizeHafasCoordinate(value: number | undefined): number | undefined { + if (value == null) return undefined; + return Math.abs(value) > 1000 ? value / 1e6 : value; +} + export class ApiClient { private readonly baseUrl: string; @@ -114,24 +150,30 @@ export class ApiClient { fromStationExtId: string, toStationExtId: string, date: Date, + options: SearchJourneyOptions = {}, ): Promise { const { date: hafasDate, time: hafasTime } = hafasDateTime(date); + const arriveBy = options.arriveBy === true; + const journeys = await this.fetchTripSearch( + buildTripSearchBody(fromStationExtId, toStationExtId, hafasDate, hafasTime, arriveBy), + hafasDate, + date, + ); - const body = { - svcReqL: [ - { - meth: "TripSearch", - req: { - depLocL: [{ type: "S", extId: fromStationExtId }], - arrLocL: [{ type: "S", extId: toStationExtId }], - outDate: hafasDate, - outTime: hafasTime, - numF: 5, - }, - }, - ], - }; + if (!arriveBy || journeys.length > 0) { + return journeys; + } + const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS); + const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate); + return this.fetchTripSearch( + buildTripSearchBody(fromStationExtId, toStationExtId, fallbackHafasDate, fallbackHafasTime, false, 10), + fallbackHafasDate, + fallbackDate, + ); + } + + private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise { const res = await fetch(`${this.baseUrl}/api/hafas`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -140,7 +182,7 @@ export class ApiClient { if (!res.ok) throw new Error(`Journey search failed: ${res.status}`); const data = await res.json(); - return parseHafasJourneys(data, hafasDate, date); + return parseHafasJourneys(data, hafasDate, queryDate); } async reverseGeocode(lat: number, lng: number): Promise { @@ -225,12 +267,21 @@ export class ApiClient { // Find the closest station by Euclidean distance const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => { - const bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng); - const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng); + const bestLat = normalizeHafasCoordinate(best.lat) ?? lat; + const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon) ?? lng; + const candidateLat = normalizeHafasCoordinate(candidate.lat) ?? lat; + const candidateLng = normalizeHafasCoordinate(candidate.lng ?? candidate.lon) ?? lng; + const bestDist = Math.hypot(bestLat - lat, bestLng - lng); + const candDist = Math.hypot(candidateLat - lat, candidateLng - lng); return candDist < bestDist ? candidate : best; }, stationResults[0]); - return closest; + return { + name: closest.name, + extId: closest.extId, + lat: normalizeHafasCoordinate(closest.lat), + lng: normalizeHafasCoordinate(closest.lng ?? closest.lon), + }; } async monitorStops(stopIds: string[]): Promise { diff --git a/packages/core/src/defaults.ts b/packages/core/src/defaults.ts new file mode 100644 index 0000000..4195c7b --- /dev/null +++ b/packages/core/src/defaults.ts @@ -0,0 +1,14 @@ +import type { Station } from "./types"; + +export const DEFAULT_ORIGIN_ADDRESS = "Goethegasse 36, 2340 Moedling"; +export const DEFAULT_ORIGIN_LAT = 48.0806926; +export const DEFAULT_ORIGIN_LNG = 16.2908052; +export const DEFAULT_ORIGIN_STATION_NAME = "Mödling Bahnhof"; +export const DEFAULT_ORIGIN_STATION_EXT_ID = "1231701"; + +export const DEFAULT_ORIGIN_STATION: Station = { + name: DEFAULT_ORIGIN_ADDRESS, + extId: DEFAULT_ORIGIN_STATION_EXT_ID, + lat: DEFAULT_ORIGIN_LAT, + lng: DEFAULT_ORIGIN_LNG, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e02d2f4..8d79680 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,3 +5,4 @@ export * from './formatting'; export * from './status-utils'; export * from './hafas-time'; export * from './hafas-parser'; +export * from './defaults';