From bf252a9e9ba1271727a5feb03ac1344298748062 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 13 May 2026 19:13:27 +0200 Subject: [PATCH] Refactor mobile UI and centralize HAFAS parsing Introduce useColors hook to replace direct theme usage in mobile screens. Extract EventHeader, JourneyList, BikeSection, and NearbyStops components to reduce complexity in EventDetailScreen. Move parseHafasJourneys to packages/core for shared usage between web and mobile clients. Update web API route with stricter HAFAS validation and consistent client instantiation. Add comprehensive codebase function guide documenting data flow, shared packages, and service integrations. --- apps/mobile/src/components/BikeSection.tsx | 65 +++ apps/mobile/src/components/EventHeader.tsx | 76 +++ apps/mobile/src/components/JourneyList.tsx | 105 ++++ apps/mobile/src/components/NearbyStops.tsx | 71 +++ apps/mobile/src/hooks/useColors.ts | 40 ++ apps/mobile/src/hooks/useWienerLinien.ts | 2 +- apps/mobile/src/screens/AddEventScreen.tsx | 24 +- .../src/screens/CalendarImportScreen.tsx | 55 +- apps/mobile/src/screens/EventDetailScreen.tsx | 348 +++--------- apps/mobile/src/screens/EventListScreen.tsx | 20 +- apps/mobile/src/screens/SettingsScreen.tsx | 24 +- apps/mobile/src/store/eventStore.ts | 98 +--- apps/web/src/app/api/hafas/route.ts | 52 +- apps/web/src/hooks/useCalendar.ts | 4 +- apps/web/src/hooks/useDestinationStation.ts | 4 +- apps/web/src/hooks/useJourneys.ts | 7 +- apps/web/src/lib/api.ts | 3 + apps/web/src/lib/hafas-client.ts | 53 +- docs/CODEBASE_FUNCTION_GUIDE.md | 535 ++++++++++++++++++ packages/api-client/src/client.ts | 20 +- packages/core/src/hafas-parser.ts | 36 ++ packages/core/src/index.ts | 1 + 22 files changed, 1091 insertions(+), 552 deletions(-) create mode 100644 apps/mobile/src/components/BikeSection.tsx create mode 100644 apps/mobile/src/components/EventHeader.tsx create mode 100644 apps/mobile/src/components/JourneyList.tsx create mode 100644 apps/mobile/src/components/NearbyStops.tsx create mode 100644 apps/mobile/src/hooks/useColors.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 docs/CODEBASE_FUNCTION_GUIDE.md create mode 100644 packages/core/src/hafas-parser.ts diff --git a/apps/mobile/src/components/BikeSection.tsx b/apps/mobile/src/components/BikeSection.tsx new file mode 100644 index 0000000..3c1cebf --- /dev/null +++ b/apps/mobile/src/components/BikeSection.tsx @@ -0,0 +1,65 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { formatDuration, formatDistance } from '@timetoleave/core'; +import type { BikeRoute, Station } from '@timetoleave/core'; +import type { AppColors } from '../hooks/useColors'; + +interface Props { + bikeRoute: BikeRoute | null; + loading: boolean; + origin: Station | null; + colors: AppColors; +} + +export function BikeSection({ bikeRoute, loading, origin, colors }: Props) { + return ( + + Radroute + + {loading ? ( + + + Radroute wird geladen… + + ) : bikeRoute ? ( + + + ⏱ Dauer + {formatDuration(bikeRoute.duration)} + + + πŸ“ Distanz + {formatDistance(bikeRoute.distance)} + + + πŸ—Ί Karte (post-MVP) + + + ) : ( + + {origin ? 'Keine Radroute verfΓΌgbar' : 'Ursprungstation festlegen'} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 20 }, + sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 }, + centered: { alignItems: 'center', gap: 8 }, + hint: { fontSize: 15, marginTop: 12 }, + empty: { fontSize: 14 }, + card: { borderRadius: 10, padding: 14, borderWidth: 1 }, + row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 }, + label: { fontSize: 15, fontWeight: '500' }, + value: { fontSize: 15, fontWeight: '600' }, + mapPlaceholder: { + marginTop: 10, + height: 100, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + }, + mapText: { fontSize: 14 }, +}); diff --git a/apps/mobile/src/components/EventHeader.tsx b/apps/mobile/src/components/EventHeader.tsx new file mode 100644 index 0000000..e7005bb --- /dev/null +++ b/apps/mobile/src/components/EventHeader.tsx @@ -0,0 +1,76 @@ +import { StyleSheet, Text, View } from 'react-native'; +import type { Event } from '@timetoleave/core'; +import type { AppColors } from '../hooks/useColors'; + +interface Props { + event: Event; + leaveByTime: Date | null; + arrivalBufferMinutes: number; + colors: AppColors; +} + +export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) { + const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000); + + return ( + + {event.title} + {event.destination} + + {event.eventTime.toLocaleString('de-AT', { + weekday: 'long', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + Quelle: {event.source} + + + + Losgehen um + + {leaveByTime + ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) + : 'β€”'} + + + + Ankommen um + + {arriveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} + + + + Puffer + {arrivalBufferMinutes} min + + + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 20, marginBottom: 12 }, + title: { fontSize: 22, fontWeight: '700' }, + destination: { fontSize: 16, marginTop: 4 }, + time: { fontSize: 14, marginTop: 8 }, + source: { fontSize: 12, marginTop: 4 }, + infoGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + }, + infoBox: { alignItems: 'center' }, + infoLabel: { + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 1, + }, + infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 }, +}); diff --git a/apps/mobile/src/components/JourneyList.tsx b/apps/mobile/src/components/JourneyList.tsx new file mode 100644 index 0000000..8ded434 --- /dev/null +++ b/apps/mobile/src/components/JourneyList.tsx @@ -0,0 +1,105 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { formatDuration, formatDistance } from '@timetoleave/core'; +import type { Journey, Station, WalkRoute } from '@timetoleave/core'; +import type { AppColors } from '../hooks/useColors'; + +interface Props { + journeys: Journey[]; + destStationLoading: boolean; + walkRoute: WalkRoute | null; + loadingWalk: boolean; + showWalkingOption: boolean; + origin: Station | null; + colors: AppColors; +} + +export function JourneyList({ + journeys, + destStationLoading, + walkRoute, + loadingWalk, + showWalkingOption, + origin, + colors, +}: Props) { + return ( + + Zugverbindungen + + {destStationLoading && ( + + + Ziel-Station wird aufgelΓΆst… + + )} + + {journeys.length === 0 && !destStationLoading ? ( + + {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'} + + ) : ( + journeys.map((j) => ( + + + + {j.trains.length > 0 ? j.trains.join(', ') : 'β€”'} + + {j.delay > 0 && ( + +{j.delay} min + )} + {j.cancelled && ( + Storniert + )} + + + Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} + {' '}(Plattform {j.platform || 'β€”'}) + + + Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} + {' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`}) + + + )) + )} + + {showWalkingOption && walkRoute && ( + + 🚢 Finaler Fußweg + + ⏱ Dauer + {formatDuration(walkRoute.duration)} + + + πŸ“ Distanz + {formatDistance(walkRoute.distance)} + + + )} + + {showWalkingOption && loadingWalk && ( + + + Fußweg wird geladen… + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 20 }, + sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 }, + centered: { alignItems: 'center', gap: 8 }, + hint: { fontSize: 15, marginTop: 12 }, + empty: { fontSize: 14 }, + card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 }, + row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + line: { fontSize: 16, fontWeight: '600' }, + delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 }, + cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 }, + detail: { fontSize: 13, marginTop: 6 }, + walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 }, + walkLabel: { fontSize: 14, fontWeight: '500' }, + walkValue: { fontSize: 14, fontWeight: '600' }, +}); diff --git a/apps/mobile/src/components/NearbyStops.tsx b/apps/mobile/src/components/NearbyStops.tsx new file mode 100644 index 0000000..a5daf5e --- /dev/null +++ b/apps/mobile/src/components/NearbyStops.tsx @@ -0,0 +1,71 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import type { WienerLinienStop } from '@timetoleave/core'; +import type { DepartureRow } from '../hooks/useWienerLinien'; +import type { AppColors } from '../hooks/useColors'; + +interface Props { + stops: WienerLinienStop[]; + departures: DepartureRow[]; + loading: boolean; + error: string | null; + colors: AppColors; +} + +export function NearbyStops({ stops, departures, loading, error, colors }: Props) { + if (!loading && stops.length === 0 && !error) return null; + + return ( + + 🚏 Γ–PNV in der NΓ€he des Ziels + + {loading ? ( + + + Haltestellen werden geladen… + + ) : departures.length > 0 ? ( + departures.slice(0, 8).map((dep, i) => ( + + + + {dep.lineName} + + + {dep.direction} + + + {dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`} + + + + )) + ) : ( + stops.slice(0, 5).map((stop) => ( + + {stop.name} + + )) + )} + + {error && {error}} + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 20 }, + sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 }, + centered: { alignItems: 'center', gap: 8 }, + hint: { fontSize: 14 }, + depCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 }, + depRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' }, + lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' }, + direction: { flex: 1, fontSize: 13 }, + minutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' }, + stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 }, + stopName: { fontSize: 14, fontWeight: '500' }, +}); diff --git a/apps/mobile/src/hooks/useColors.ts b/apps/mobile/src/hooks/useColors.ts new file mode 100644 index 0000000..a1a2d4a --- /dev/null +++ b/apps/mobile/src/hooks/useColors.ts @@ -0,0 +1,40 @@ +import { useTheme } from './useTheme'; + +const DARK = { + background: '#090816', + card: '#17112A', + text: '#F4F1EA', + subtext: 'rgba(244,241,234,0.5)', + accent: '#8B5CF6', + border: '#38383a', + error: '#ff453a', + success: '#30d158', + warning: '#ff9f0a', + purple: '#B23CFF', + delete: '#FF3B30', + overlay: 'rgba(28,28,30,0.95)', + highlight: '#1a3a5c', +} as const; + +const LIGHT = { + background: '#f2f2f7', + card: '#ffffff', + text: '#1c1c1e', + subtext: '#8e8e93', + accent: '#B23CFF', + border: '#e5e5ea', + error: '#FF3B30', + success: '#34C759', + warning: '#FF9500', + purple: '#8B5CF6', + delete: '#FF3B30', + overlay: 'rgba(255,255,255,0.95)', + highlight: '#e8f4fd', +} as const; + +export type AppColors = Record; + +export function useColors(): AppColors { + const { dark } = useTheme(); + return dark ? DARK : LIGHT; +} diff --git a/apps/mobile/src/hooks/useWienerLinien.ts b/apps/mobile/src/hooks/useWienerLinien.ts index 60b0303..2a72f40 100644 --- a/apps/mobile/src/hooks/useWienerLinien.ts +++ b/apps/mobile/src/hooks/useWienerLinien.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core'; import { api } from '../services/api'; -interface DepartureRow { +export interface DepartureRow { stopId: string; lineName: string; direction: string; diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx index 2599c9a..134ed09 100644 --- a/apps/mobile/src/screens/AddEventScreen.tsx +++ b/apps/mobile/src/screens/AddEventScreen.tsx @@ -11,7 +11,7 @@ import type { RouteProp } from '@react-navigation/native'; import { loadEvents, addEvent, updateEvent } from '../store/eventStore'; import type { Event as CalendarEvent } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; -import { useTheme } from '../hooks/useTheme'; +import { useColors } from '../hooks/useColors'; type ScreenProps = { navigation: NativeStackNavigationProp; @@ -19,7 +19,7 @@ type ScreenProps = { }; export function AddEventScreen({ navigation, route }: ScreenProps) { - const { dark } = useTheme(); + const colors = useColors(); const [title, setTitle] = useState(''); const [destination, setDestination] = useState(''); const [dateStr, setDateStr] = useState(''); @@ -27,24 +27,6 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { const [error, setError] = useState(''); const [success, setSuccess] = useState(false); - const colors = dark ? { - background: '#090816', - card: '#17112A', - text: '#F4F1EA', - subtext: 'rgba(244,241,234,0.5)', - accent: '#8B5CF6', - border: '#38383a', - error: '#ff453a', - } : { - background: '#f2f2f7', - card: '#ffffff', - text: '#1c1c1e', - subtext: '#8e8e93', - accent: '#B23CFF', - border: '#e5e5ea', - error: '#FF3B30', - }; - // If editing an existing event, populate the form useEffect(() => { if (!route.params?.editEventId) return; @@ -162,7 +144,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { {success && ( - + βœ“ diff --git a/apps/mobile/src/screens/CalendarImportScreen.tsx b/apps/mobile/src/screens/CalendarImportScreen.tsx index ea50d8a..e668322 100644 --- a/apps/mobile/src/screens/CalendarImportScreen.tsx +++ b/apps/mobile/src/screens/CalendarImportScreen.tsx @@ -15,7 +15,7 @@ import { fetchNativeEvents } from '../services/calendar'; import { addEvent, loadEvents } from '../store/eventStore'; import type { Event as CalendarEvent } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; -import { useTheme } from '../hooks/useTheme'; +import { useColors } from '../hooks/useColors'; type ScreenProps = { navigation: NativeStackNavigationProp; @@ -23,34 +23,12 @@ type ScreenProps = { }; export function CalendarImportScreen({ navigation }: ScreenProps) { - const { dark } = useTheme(); + const colors = useColors(); const [url, setUrl] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [count, setCount] = useState(null); - const colors = dark ? { - background: '#090816', - card: '#17112A', - text: '#F4F1EA', - subtext: 'rgba(244,241,234,0.5)', - accent: '#8B5CF6', - border: '#38383a', - error: '#ff453a', - success: '#30d158', - purple: '#B23CFF', - } : { - background: '#f2f2f7', - card: '#ffffff', - text: '#1c1c1e', - subtext: '#8e8e93', - accent: '#B23CFF', - border: '#e5e5ea', - error: '#FF3B30', - success: '#34C759', - purple: '#8B5CF6', - }; - const handleImport = async () => { if (!url.trim()) { setError('Bitte ICS-URL eingeben'); @@ -62,19 +40,26 @@ export function CalendarImportScreen({ navigation }: ScreenProps) { setCount(null); try { - const events = await api.fetchCalendar(url.trim()); - // Add imported events to local store + const [events, existing] = await Promise.all([ + api.fetchCalendar(url.trim()), + loadEvents(), + ]); + const existingIds = new Set(existing.map((e) => e.id)); + let added = 0; for (const evt of events) { - const localEvent: CalendarEvent = { - id: evt.id, - title: evt.title, - destination: evt.destination, - eventTime: new Date(evt.eventTime), - source: `calendar:${url.trim().slice(0, 40)}`, - }; - await addEvent(localEvent); + if (!existingIds.has(evt.id)) { + const localEvent: CalendarEvent = { + id: evt.id, + title: evt.title, + destination: evt.destination, + eventTime: new Date(evt.eventTime), + source: `calendar:${url.trim().slice(0, 40)}`, + }; + await addEvent(localEvent); + added++; + } } - setCount(events.length); + setCount(added); } catch (err) { setError(err instanceof Error ? err.message : 'Import fehlgeschlagen'); } finally { diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx index 5078fe5..b6c6a4d 100644 --- a/apps/mobile/src/screens/EventDetailScreen.tsx +++ b/apps/mobile/src/screens/EventDetailScreen.tsx @@ -11,14 +11,17 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore'; import { api } from '../services/api'; -import { formatDuration, formatDistance } from '@timetoleave/core'; import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core'; import { useDestinationStation } from '../hooks/useDestinationStation'; import { useDepartureTime } from '../hooks/useDepartureTime'; import { useGeocode } from '../hooks/useGeocode'; import { useWalkRoute } from '../hooks/useWalkRoute'; import { useWienerLinien } from '../hooks/useWienerLinien'; -import { useTheme } from '../hooks/useTheme'; +import { useColors } from '../hooks/useColors'; +import { EventHeader } from '../components/EventHeader'; +import { JourneyList } from '../components/JourneyList'; +import { BikeSection } from '../components/BikeSection'; +import { NearbyStops } from '../components/NearbyStops'; import type { RootStack } from '../types/navigation'; type ScreenProps = { @@ -30,7 +33,7 @@ type TransportMode = 'train' | 'bike'; export function EventDetailScreen({ navigation, route }: ScreenProps) { const { eventId } = route.params; - const { dark } = useTheme(); + const colors = useColors(); const [event, setEvent] = useState(null); const [journeys, setJourneys] = useState([]); @@ -46,21 +49,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { const [showBikeOption, setShowBikeOption] = useState(true); const [showWalkingOption, setShowWalkingOption] = useState(true); - // Resolve destination text to HAFAS station ID (CRITICAL FIX) const destStation = useDestinationStation(event?.destination); - - // Geocode destination for bike/walk routes const destCoords = useGeocode(event?.destination); - - // Fetch walk route from destination station to final address const walkHook = useWalkRoute( destStation.station?.lat, destStation.station?.lng, destCoords.coords?.lat, destCoords.coords?.lng, ); - - // Fetch nearby WienerLinien stops const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); const fetchData = useCallback(async () => { @@ -78,35 +74,24 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { setShowWalkingOption(settings.showWalkingOption); const found = events.find((e) => e.id === eventId); - if (!found) { - setError('Termin nicht gefunden'); - return; - } + if (!found) { setError('Termin nicht gefunden'); return; } setEvent(found); if (originStation) { - // Use resolved destination station extId instead of raw text (CRITICAL FIX) const destExtId = destStation.station?.extId; if (destExtId) { - const results = await api.searchJourneys( - originStation.extId, - destExtId, - found.eventTime, - ); + const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime); setJourneys(results); } else if (destStation.error) { setError(`Ziel-Station nicht auflΓΆsbar: ${destStation.error}`); } - // Fetch bike route if we have coordinates try { setLoadingBike(true); if (destCoords.coords && originStation.lat && originStation.lng) { const bike = await api.getBikeRoute( - originStation.lat, - originStation.lng, - destCoords.coords.lat, - destCoords.coords.lng, + originStation.lat, originStation.lng, + destCoords.coords.lat, destCoords.coords.lng, ); setBikeRoute(bike); } @@ -125,7 +110,6 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { useEffect(() => { fetchData(); }, [fetchData]); - // Sync walk route from hook useEffect(() => { setWalkRoute(walkHook.walkRoute); setLoadingWalk(walkHook.loading); @@ -138,36 +122,17 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { fetchData(); }; - // Use the shared departure time hook instead of inline calculation const departureInfo = useDepartureTime( event?.eventTime ?? new Date(), journeys.length > 0 ? journeys : null, bikeRoute?.duration ?? null, - activeMode === 'train' && journeys.length > 0 ? 'train' : (activeMode === 'bike' && bikeRoute ? 'bike' : null), + activeMode === 'train' && journeys.length > 0 + ? 'train' + : activeMode === 'bike' && bikeRoute + ? 'bike' + : null, arrivalBufferMinutes, ); - const leaveByTime = departureInfo.departureTime; - - // Theme-based colors - const colors = dark ? { - background: '#090816', - card: '#17112A', - text: '#F4F1EA', - subtext: 'rgba(244,241,234,0.5)', - accent: '#8B5CF6', - border: '#38383a', - warning: '#ff9f0a', - error: '#ff453a', - } : { - background: '#f2f2f7', - card: '#ffffff', - text: '#1c1c1e', - subtext: '#8e8e93', - accent: '#B23CFF', - border: '#e5e5ea', - warning: '#FF9500', - error: '#FF3B30', - }; if (loading) { return ( @@ -180,75 +145,38 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { ); } - // Disable bike mode if setting is off const bikeDisabled = !showBikeOption; - const requestedMode: TransportMode = activeMode; - const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode; + const effectiveMode: TransportMode = + bikeDisabled && activeMode === 'bike' ? 'train' : activeMode; return ( - {/* Event header */} {event && ( - - {event.title} - {event.destination} - - {event.eventTime.toLocaleString('de-AT', { - weekday: 'long', - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} - - Quelle: {event.source} - - {/* Leave by / Arrive by / Buffer info */} - - - Losgehen um - - {leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : 'β€”'} - - - - Ankommen um - - {new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} - - - - Puffer - - {arrivalBufferMinutes} min - - - - + )} - {/* Error */} {error && ( - ⚠ {error} + ⚠ {error} )} - {/* Origin status */} {!origin && !error && ( - - Keine Ursprungstation festgelegt. - {' '} - navigation.navigate('Settings')}> + + Keine Ursprungstation festgelegt.{' '} + navigation.navigate('Settings')}> Einstellungen ΓΆffnen )} - {/* Transport mode selector */} {origin && ( - {bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'} + {bikeDisabled + ? 'In Einstellungen deaktiviert' + : loadingBike + ? 'Route wird berechnet...' + : 'Direktweg'} )} - {/* Journeys list (Train mode) */} {effectiveMode === 'train' && ( - - Zugverbindungen - {destStation.loading && ( - - - - Ziel-Station wird aufgelΓΆst… - - - )} - {journeys.length === 0 && !destStation.loading ? ( - - {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'} - - ) : ( - journeys.map((j) => ( - - - - {j.trains.length > 0 ? j.trains.join(', ') : 'β€”'} - - {j.delay > 0 && ( - +{j.delay} min - )} - {j.cancelled && ( - Storniert - )} - - - Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} - {' '} - (Plattform {j.platform || 'β€”'}) - - - Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} - {' '} - ({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`}) - - - )) - )} - - {/* Walk route section (when walking option enabled) */} - {showWalkingOption && walkRoute && ( - - 🚢 Finaler Fußweg - - ⏱ Dauer - {formatDuration(walkRoute.duration)} - - - πŸ“ Distanz - {formatDistance(walkRoute.distance)} - - - )} - {showWalkingOption && loadingWalk && ( - - - - Fußweg wird geladen… - - - )} - + )} - {/* Bike route section (Bike mode) */} {effectiveMode === 'bike' && ( - - Radroute - {loadingBike ? ( - - - Radroute wird geladen… - - ) : bikeRoute ? ( - - - ⏱ Dauer - {formatDuration(bikeRoute.duration)} - - - πŸ“ Distanz - {formatDistance(bikeRoute.distance)} - - - πŸ—Ί Karte (post-MVP) - - - ) : ( - - {origin ? 'Keine Radroute verfΓΌgbar' : 'Ursprungstation festlegen'} - - )} - + )} - {/* WienerLinien nearby stops + departures */} - {(wienerLinien.loading || wienerLinien.stops.length > 0) && ( - - 🚏 Γ–PNV in der NΓ€he des Ziels - {wienerLinien.loading ? ( - - - Haltestellen werden geladen… - - ) : wienerLinien.departures.length > 0 ? ( - wienerLinien.departures.slice(0, 8).map((dep, i) => ( - - - - {dep.lineName} - - - {dep.direction} - - - {dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`} - - - - )) - ) : ( - wienerLinien.stops.slice(0, 5).map((stop) => ( - - {stop.name} - - )) - )} - {wienerLinien.error && ( - {wienerLinien.error} - )} - - )} + - {/* Refresh */} - + πŸ”„ Neu laden - {/* Bottom padding for scroll */} ); @@ -451,57 +272,24 @@ const styles = StyleSheet.create({ container: { flex: 1 }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, loadingText: { marginTop: 12, fontSize: 15 }, - header: { padding: 20, marginBottom: 12 }, - eventTitle: { fontSize: 22, fontWeight: '700' }, - eventDest: { fontSize: 16, marginTop: 4 }, - eventTime: { fontSize: 14, marginTop: 8 }, - source: { fontSize: 12, marginTop: 4 }, - infoGrid: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#e5e5ea' }, - infoBox: { alignItems: 'center' }, - infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' as const, letterSpacing: 1 }, - infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 }, errorBanner: { padding: 12, marginBottom: 12 }, - errorBannerText: { color: '#fff', fontSize: 14 }, warningBanner: { padding: 12, marginBottom: 12 }, - warningBannerText: { color: '#fff', fontSize: 14 }, - warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' }, - modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 }, + bannerText: { color: '#fff', fontSize: 14 }, + bannerLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' }, + modeSelector: { + flexDirection: 'row', + padding: 12, + gap: 12, + marginBottom: 12, + borderWidth: 1, + borderRadius: 12, + }, modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' }, modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, modeLabel: { fontSize: 15, fontWeight: '600' }, modeMeta: { fontSize: 11, marginTop: 4 }, activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 }, activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' }, - journeys: { padding: 20 }, - sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 }, - emptyText: { fontSize: 14 }, - journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 }, - journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, - lineText: { fontSize: 16, fontWeight: '600' }, - delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 }, - cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 }, - departure: { fontSize: 13, marginTop: 6 }, - arrival: { fontSize: 13, marginTop: 2 }, - walkCard: { borderRadius: 10, padding: 14, marginTop: 10, borderWidth: 1 }, - walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 }, - walkRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, - walkLabel: { fontSize: 14, fontWeight: '500' }, - walkValue: { fontSize: 14, fontWeight: '600' }, - centerBike: { alignItems: 'center', gap: 8 }, - bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 }, - bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 }, - bikeLabel: { fontSize: 15, fontWeight: '500' }, - bikeValue: { fontSize: 15, fontWeight: '600' }, - mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 }, - mapPlaceholderText: { fontSize: 14 }, - stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 }, - stopName: { fontSize: 14, fontWeight: '500' }, - departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 }, - departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 }, - lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const }, - lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' }, - departureDirection: { flex: 1, fontSize: 13 }, - departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const }, refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, refreshBtnText: { fontSize: 15, fontWeight: '600' }, }); diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx index 8d0dfc1..40073f1 100644 --- a/apps/mobile/src/screens/EventListScreen.tsx +++ b/apps/mobile/src/screens/EventListScreen.tsx @@ -14,7 +14,7 @@ import { loadEvents, removeEvent } from '../store/eventStore'; import { calculateCountdown } from '@timetoleave/core'; import type { Event as CalendarEvent } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; -import { useTheme } from '../hooks/useTheme'; +import { useColors } from '../hooks/useColors'; type ScreenProps = { navigation: NativeStackNavigationProp; @@ -22,28 +22,12 @@ type ScreenProps = { }; export function EventListScreen({ navigation }: ScreenProps) { - const { dark } = useTheme(); + const colors = useColors(); const [events, setEvents] = useState([]); const [refreshing, setRefreshing] = useState(false); // Force countdown recalculation periodically const [, setTick] = useState(0); - const colors = dark ? { - background: '#090816', - card: '#17112A', - text: '#F4F1EA', - subtext: 'rgba(244,241,234,0.5)', - accent: '#8B5CF6', - delete: '#FF3B30', - } : { - background: '#f2f2f7', - card: '#ffffff', - text: '#1c1c1e', - subtext: '#8e8e93', - accent: '#B23CFF', - delete: '#FF3B30', - }; - const reload = useCallback(async () => { const list = await loadEvents(); setEvents(list); diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 780eeb9..66affc6 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -16,6 +16,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot import { api } from '../services/api'; import type { Station, ReminderSettings } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; +import { useColors } from '../hooks/useColors'; import { useTheme } from '../hooks/useTheme'; type ScreenProps = { @@ -25,6 +26,7 @@ type ScreenProps = { export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const { dark, toggle: toggleTheme } = useTheme(); + const colors = useColors(); const [origin, setOrigin] = useState(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); @@ -41,24 +43,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const searchTimerRef = useRef | null>(null); - const colors = dark ? { - background: '#090816', - card: '#17112A', - text: '#F4F1EA', - subtext: 'rgba(244,241,234,0.5)', - accent: '#8B5CF6', - border: '#38383a', - success: '#30d158', - } : { - background: '#f2f2f7', - card: '#ffffff', - text: '#1c1c1e', - subtext: '#8e8e93', - accent: '#B23CFF', - border: '#e5e5ea', - success: '#34C759', - }; - // Load persisted data on mount useEffect(() => { loadOriginStation().then(setOrigin); @@ -266,7 +250,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { ))} - + πŸ“ Aktuelle Position verwenden @@ -298,7 +282,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert. - + {showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'} diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts index 06d0a42..4749e00 100644 --- a/apps/mobile/src/store/eventStore.ts +++ b/apps/mobile/src/store/eventStore.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import type { Event, Station, ReminderSettings } from '@timetoleave/core'; import * as Notifications from '../services/expoNotifications'; +import { SchedulableTriggerInputTypes } from 'expo-notifications'; // ── Keys ─────────────────────────────────────────────────────── @@ -47,38 +48,14 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); } -async function scheduleEventNotification(event: Event): Promise { - const settings = await getNotificationSettings(); - if (!settings.enabled) { - return; - } +async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise { + const REMINDERS_MIN = [30, 10, 0]; + const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000); + const now = new Date(); - const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); - const existing = await Notifications.getAllScheduledNotificationsAsync(); - const toCancel = existing.filter(n => n.content.data?.eventId === event.id); - for (const notif of toCancel) { - await Notifications.cancelScheduledNotificationAsync(notif.identifier); - } - - // Default reminders: 30min, 10min, and at leave-by time - const defaultReminders = [30, 10, 0]; - - // Schedule notifications - for (const minutesBefore of defaultReminders) { + for (const minutesBefore of REMINDERS_MIN) { const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000); - - // Skip if trigger time is in the past - if (triggerTime <= new Date()) { - continue; - } - - // Skip if this would be before the event actually starts (add some safety margin) - if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) { - continue; - } - - // Use timestamp (seconds) as trigger β€” more reliable than Date object across versions - const timestampSeconds = Math.floor(triggerTime.getTime() / 1000); + if (triggerTime <= now || triggerTime < twoHoursBefore) continue; await Notifications.scheduleNotificationAsync({ content: { @@ -88,12 +65,25 @@ async function scheduleEventNotification(event: Event): Promise { : `${minutesBefore} Minuten bis du losmusst`, data: { eventId: event.id }, }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - trigger: timestampSeconds as any, + trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime }, }); } } +async function scheduleEventNotification(event: Event): Promise { + const settings = await getNotificationSettings(); + if (!settings.enabled) return; + + const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); + + const existing = await Notifications.getAllScheduledNotificationsAsync(); + for (const notif of existing.filter(n => n.content.data?.eventId === event.id)) { + await Notifications.cancelScheduledNotificationAsync(notif.identifier); + } + + await fireNotificationsForEvent(event, leaveByTime); +} + // ─────────────────────────────────────────────────────────────── // Events // ─────────────────────────────────────────────────────────────── @@ -176,48 +166,12 @@ export async function saveNotificationSettings( // ─────────────────────────────────────────────────────────────── export async function rescheduleAllNotifications(): Promise { - const events = await loadEvents(); - const settings = await loadNotificationSettings(); - - // Cancel ALL existing notifications first + const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]); await Notifications.cancelAllScheduledNotificationsAsync(); + if (!settings.enabled) return; - // Schedule new notifications for each event for (const event of events) { - if (settings.enabled) { - const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); - - // Default reminders: 30min, 10min, and at leave-by time - const defaultReminders = [30, 10, 0]; - - for (const minutesBefore of defaultReminders) { - const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000); - - // Skip if trigger time is in the past - if (triggerTime <= new Date()) { - continue; - } - - // Skip if this would be before the event actually starts (add some safety margin) - if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) { - continue; - } - - // Use timestamp (seconds) as trigger β€” more reliable than Date object - const timestampSeconds = Math.floor(triggerTime.getTime() / 1000); - - await Notifications.scheduleNotificationAsync({ - content: { - title: `πŸš† ${event.title}`, - body: minutesBefore === 0 - ? 'Zeit zu gehen!' - : `${minutesBefore} Minuten bis du losmusst`, - data: { eventId: event.id }, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - trigger: timestampSeconds as any, - }); - } - } + const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); + await fireNotificationsForEvent(event, leaveByTime); } } diff --git a/apps/web/src/app/api/hafas/route.ts b/apps/web/src/app/api/hafas/route.ts index 3aa26ff..955d771 100644 --- a/apps/web/src/app/api/hafas/route.ts +++ b/apps/web/src/app/api/hafas/route.ts @@ -11,12 +11,20 @@ const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB"; const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700"; const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp"; -/** - * Maximum number of characters allowed in the JSON body of a HAFAS POST. - * Keeps the relay surface small β€” a TripSearch + LocMatch request is ~1 KB. - */ const HAFAS_BODY_MAX = 4 * 1024; // 4 KB +const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const; +type HafasMethod = (typeof ALLOWED_METHODS)[number]; + +interface HafasServiceRequest { + meth: string; + req?: Record; +} + +interface HafasBody extends Record { + svcReqL: HafasServiceRequest[]; +} + function injectHafasAuth( body: Record | null | undefined, ): Record { @@ -118,52 +126,30 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - // Validate body shape - if ( - !body || - typeof body !== "object" || - "svcReqL" in body - ? !Array.isArray((body as Record).svcReqL) - : false - ) { - // If svcReqL is missing, the injectHafasAuth will add an empty object β€” - // so we need to check if the enriched body has it - } - // Inject required HAFAS protocol fields - const hafasBody = injectHafasAuth(body as Record); + const hafasBody = injectHafasAuth(body as Record) as HafasBody; // Validate enriched body if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) { return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 }); } - const svcReq = hafasBody.svcReqL[0]; - const allowedMethods = ["TripSearch", "LocMatch"]; + const svcReq = hafasBody.svcReqL[0] as HafasServiceRequest; if ( !svcReq || - typeof svcReq !== "object" || - typeof (svcReq as Record).meth !== "string" || - !allowedMethods.includes((svcReq as Record).meth as string) + typeof svcReq.meth !== "string" || + !(ALLOWED_METHODS as readonly string[]).includes(svcReq.meth) ) { return NextResponse.json( - { error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` }, + { error: `Invalid HAFAS method. Allowed: ${ALLOWED_METHODS.join(", ")}` }, { status: 400 }, ); } // Cap TripSearch results at 10 - if ( - (svcReq as Record).meth === "TripSearch" && - (svcReq as Record).req && - typeof (svcReq as Record).req === "object" && - ((svcReq as Record).req as Record).numF - ) { - ((svcReq as Record).req as Record).numF = Math.min( - Number(((svcReq as Record).req as Record).numF), - 10, - ); + if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") { + svcReq.req.numF = Math.min(Number(svcReq.req.numF), 10); } const controller = new AbortController(); diff --git a/apps/web/src/hooks/useCalendar.ts b/apps/web/src/hooks/useCalendar.ts index 2efdccf..58cc803 100644 --- a/apps/web/src/hooks/useCalendar.ts +++ b/apps/web/src/hooks/useCalendar.ts @@ -1,8 +1,6 @@ import { useState, useCallback } from "react"; import type { CalendarEvent } from "@timetoleave/core"; -import { ApiClient } from "@timetoleave/api-client"; - -const client = new ApiClient(); +import { api as client } from "@/lib/api"; export function useCalendar(days: number = 14) { const [events, setEvents] = useState([]); diff --git a/apps/web/src/hooks/useDestinationStation.ts b/apps/web/src/hooks/useDestinationStation.ts index b708e72..0875178 100644 --- a/apps/web/src/hooks/useDestinationStation.ts +++ b/apps/web/src/hooks/useDestinationStation.ts @@ -1,8 +1,6 @@ import { useState, useEffect } from "react"; import type { Station } from "@timetoleave/core"; -import { ApiClient } from "@timetoleave/api-client"; - -const client = new ApiClient(); +import { api as client } from "@/lib/api"; interface HafasLocation { type: string; diff --git a/apps/web/src/hooks/useJourneys.ts b/apps/web/src/hooks/useJourneys.ts index a9677c8..9fa251e 100644 --- a/apps/web/src/hooks/useJourneys.ts +++ b/apps/web/src/hooks/useJourneys.ts @@ -1,10 +1,7 @@ import { useState, useEffect } from "react"; import type { Journey } from "@timetoleave/core"; -import { ApiClient } from "@timetoleave/api-client"; -import { parseHafasJourneys } from "@/lib/hafas-client"; -import { hafasDateTime } from "@timetoleave/core"; - -const client = new ApiClient(); +import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core"; +import { api as client } from "@/lib/api"; export function useJourneys( fromStationExtId: string | null, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..671aada --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,3 @@ +import { ApiClient } from "@timetoleave/api-client"; + +export const api = new ApiClient(); diff --git a/apps/web/src/lib/hafas-client.ts b/apps/web/src/lib/hafas-client.ts index f79678f..e035912 100644 --- a/apps/web/src/lib/hafas-client.ts +++ b/apps/web/src/lib/hafas-client.ts @@ -1,6 +1,6 @@ import type { Journey, Station } from "@timetoleave/core"; +import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core"; import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants"; -import { parseHafasTime, hafasDateTime } from "@timetoleave/core"; import { ApiClient } from "./api-service"; // ------------------------------------------------------------- @@ -46,57 +46,6 @@ interface HafasTripResponse { }>; } -// ------------------------------------------------------------- -// Shared HAFAS journey parser -// ------------------------------------------------------------- - -/** - * Parse a raw HAFAS response into Journey[]. - * Works with both typed and untyped (raw JSON) responses. - */ -export function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested - const data = json as any; - const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? []; - - return outConL.map((con, i): Journey => { - const first = con.secL?.[0]; - const last = con.secL?.[con.secL.length - 1]; - const dep = first?.dep; - const arr = last?.arr; - - const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate; - const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD; - const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD; - const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA; - - const delayMs = rD.getTime() - sD.getTime(); - const delay = Math.max(0, Math.round(delayMs / 60000)); - - const trains = (con.secL ?? []) - .filter((s) => s.jny) - .map((s) => s.jny?.stopL?.[0]?.name ?? "") - .filter(Boolean); - - const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true); - const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1); - const platform = first?.dep?.dPlatfS ?? ""; - - return { - id: con.ctxRecon ?? `journey-${i}`, - sD, - rD, - sA, - rA, - delay, - platform, - changes, - trains, - cancelled, - }; - }); -} - // ------------------------------------------------------------- // HafasClient β€” Wraps the Γ–BB HAFAS journey-planning API // ------------------------------------------------------------- diff --git a/docs/CODEBASE_FUNCTION_GUIDE.md b/docs/CODEBASE_FUNCTION_GUIDE.md new file mode 100644 index 0000000..69abc21 --- /dev/null +++ b/docs/CODEBASE_FUNCTION_GUIDE.md @@ -0,0 +1,535 @@ +# TimeToLeave Codebase Function Guide + +This guide is for a developer opening the project for the first time. It explains the program structure, how data flows through the app, and which source files contain which functions, classes, hooks, and React components. + +It documents the first-party source code under `apps/` and `packages/`. It intentionally does not document generated files, tests, static assets, or third-party dependencies. + +## Program Structure + +TimeToLeave is an npm workspaces monorepo with four main parts: + +| Path | Purpose | +| --- | --- | +| `packages/core` | Shared TypeScript domain types and pure utility functions used by both apps. This package contains the canonical `Event`, `Journey`, `Station`, route, countdown, and Wiener Linien types. | +| `packages/api-client` | Shared browser/mobile client for calling the web app's `/api/*` backend proxy routes. Mobile uses this package heavily. Web hooks also use it for client-side calls. | +| `apps/web` | Next.js App Router application. It is both the web UI and the backend proxy for HAFAS, geocoding, OSRM route calculation, calendar parsing, Google Calendar, and Wiener Linien. | +| `apps/mobile` | Expo/React Native app. It stores events and settings locally, uses native calendar/location/notification APIs, and calls the web backend through `@timetoleave/api-client`. | + +The web app uses Next.js App Router. Pages live under `apps/web/src/app`, and route handlers live under `apps/web/src/app/api/**/route.ts`. The repo's `AGENTS.md` notes that this Next.js version may differ from older conventions; before changing routing or framework code, read the relevant files in `node_modules/next/dist/docs/`. + +## Runtime Data Flow + +1. Calendar events enter the system through manual entry, `.ics` URL import, `.ics` file parsing, Google Calendar, or mobile native calendar sync. +2. Events are normalized into `Event` or `CalendarEvent` objects from `packages/core/src/types.ts`. +3. The origin station comes from browser/mobile geolocation, a saved station, or defaults. +4. The destination text is geocoded, then mapped to a nearby HAFAS station with `LocMatch`. +5. Journey searches use HAFAS `TripSearch`, with time conversion handled by `packages/core/src/hafas-time.ts`. +6. Optional bike and walk routes are fetched through OSRM proxy routes. +7. Optional Wiener Linien nearby stop and monitor data is fetched near the destination. +8. Countdown and leave-by calculations combine event time, journey arrival time, route duration, and reminder settings. + +## Shared Core Package + +### `packages/core/src/types.ts` + +Contains shared TypeScript types only. + +| Type | What it represents | +| --- | --- | +| `Event` | Local app event with `Date` event time. Used by UI state and mobile storage. | +| `CalendarEvent` | API-safe calendar event with ISO string event time. Used by calendar import endpoints and client responses. | +| `Station` | HAFAS station identity plus optional coordinates. | +| `Journey` | Parsed public transport journey, including scheduled/real departure and arrival, delay, platform, changes, train labels, and cancellation state. | +| `BikeRoute`, `BikeStep` | OSRM bicycle route summary and per-step instructions. | +| `WalkRoute`, `WalkStep` | OSRM walking route summary and per-step instructions. | +| `CountdownInfo` | UI-facing countdown label, color key, and urgency flag. | +| `ReminderSettings` | User settings for reminder timing and optional route sections. | +| `GeocodeResult` | Nominatim-style latitude, longitude, and display name. | +| `WienerLinien*`, `NearbyStop` | Vienna transit stop, line, departure, and monitor response shapes. | + +### `packages/core/src/countdown-utils.ts` + +| Function | What it does | +| --- | --- | +| `calculateCountdown(targetDate)` | Compares a target date with the current time and returns a `CountdownInfo`. Past or current targets become `Now` and urgent red. Near future targets return minute labels with orange/yellow/green urgency colors. Targets more than one hour away return an hour/minute label with blue styling. | + +### `packages/core/src/formatting.ts` + +| Function | What it does | +| --- | --- | +| `formatTime(date)` | Formats a date as Austrian local time with two-digit hour and minute. | +| `formatDate(date)` | Formats a date as Austrian local date with weekday, day, month, and year. | +| `formatDateTime(date)` | Formats date and time together for UI labels. | +| `formatDuration(seconds)` | Converts seconds to `Xh Ymin` or `Ymin`. | +| `formatDistance(meters)` | Converts meters to meters under 1 km and one-decimal kilometers above that. | + +### `packages/core/src/hafas-time.ts` + +HAFAS timestamps are Vienna-local strings, not UTC timestamps. Use these helpers whenever converting to or from HAFAS. + +| Function | What it does | +| --- | --- | +| `getTimezoneOffsetMinutes(instant, tz)` | Internal helper that determines whole-hour UTC offset for a timezone at an instant. It is designed for `Europe/Vienna`, including CET/CEST, and should not be generalized to fractional-offset timezones. | +| `parseHafasTime(dateStr, timeStr)` | Converts HAFAS `YYYYMMDD` plus `HHMMSS` strings into a UTC `Date`. It tries Vienna CET and CEST offsets and verifies the offset at the resulting instant, including DST transition handling. | +| `getDateTimeParts(instant, tz)` | Internal helper that extracts year, month, day, hour, minute, and second in a target timezone with `Intl.DateTimeFormat`. | +| `hafasDateTime(date)` | Converts a JavaScript `Date` into HAFAS `date` and `time` strings in `Europe/Vienna`. This is the inverse helper for `parseHafasTime`. | + +### `packages/core/src/status-utils.ts` + +| Function or class | What it does | +| --- | --- | +| `StatusUtils.checkServerStatus(url)` | Performs a `HEAD` request with a 5 second timeout and returns `true` if the server responds with an OK status. | +| `getLeaveStatus(event, journeys)` | Picks the earliest non-cancelled journey and returns a human-readable status such as `No journey data`, `All journeys cancelled`, `Departure missed`, `Delayed +N min`, `Leave now`, or `On time`. | + +### `packages/core/src/hafas-parser.ts` + +| Function | What it does | +| --- | --- | +| `parseHafasJourneys(json, hafasDate, queryDate)` | Shared HAFAS trip parser that converts raw `outConL` connections into `Journey[]`. It parses scheduled and realtime departure/arrival strings with `parseHafasTime`, computes delay, platform, train labels, change count, and cancellation state. | + +### `packages/core/src/index.ts` + +Barrel file that re-exports the core types, countdown utilities, formatting utilities, status utilities, HAFAS time helpers, and the shared HAFAS parser. + +## Shared API Client Package + +### `packages/api-client/src/client.ts` + +This client talks to the web app's backend proxy routes. `baseUrl` defaults to an empty string, which means same-origin in the web app. Mobile usually sets it through `EXPO_PUBLIC_API_BASE_URL`. + +| 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. | +| `ApiClient.geocode(name, countrycodes?)` | Calls `/api/geocode`, wraps the single returned `GeocodeResult` in an array, and throws if the route fails. | +| `ApiClient.getBikeRoute(fromLat, fromLng, toLat, toLng)` | Calls `/api/bike-route` and returns a `BikeRoute`. | +| `ApiClient.getWalkRoute(fromLat, fromLng, toLat, toLng)` | Calls `/api/walk-route` and returns a `WalkRoute`. | +| `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.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. | +| `ApiClient.findNearestStationByCoords(lat, lng)` | Uses HAFAS coordinate `LocMatch` to find the closest station to GPS coordinates. | +| `ApiClient.monitorStops(stopIds)` | Calls `/api/wienerlinien/monitor` and returns flattened departure rows. | + +### `packages/api-client/src/index.ts` + +Barrel file that re-exports the API client. + +## Web App Backend and Libraries + +### `apps/web/src/lib/constants.ts` + +Defines environment-backed service URLs and defaults: + +| Constant | Purpose | +| --- | --- | +| `HAFAS_URL`, `HAFAS_TIMEOUT_MS` | HAFAS endpoint and timeout. | +| `NOMINATIM_URL`, `NOMINATIM_USER_AGENT` | Geocoding endpoint and required user agent. | +| `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. | +| `APP_VERSION` | Health endpoint version string. | + +### `apps/web/src/lib/api-service.ts` + +Generic HTTP helper layer used by service clients. + +| Function, class, or method | What it does | +| --- | --- | +| `ApiError` | Error class carrying optional HTTP status, response body, rate-limit flag, and retryability flag. | +| `MemoryCache.constructor(options?)` | Creates an in-memory TTL cache with default TTL and max size. | +| `MemoryCache.get(key)` | Returns cached data if present and unexpired; updates hit/miss counters. | +| `MemoryCache.set(key, data, ttl?)` | Stores data and evicts the oldest entry when max size is reached. | +| `MemoryCache.invalidate(key)` | Removes one cache entry. | +| `MemoryCache.clear()` | Clears all cache entries. | +| `MemoryCache.stats()` | Returns cache size, hits, misses, and hit rate. | +| `calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter)` | Computes exponential retry delay plus random jitter. | +| `isRetryableError(error, retryableStatuses)` | Internal helper that decides whether an error should be retried. | +| `sleep(ms)` | Promise-based timeout helper. | +| `fetchWithRetry(url, init?, options?)` | Fetch wrapper with retry support for 429 and server errors. It respects `Retry-After` for 429 and throws `ApiError` when retries are exhausted. | +| `cachedFetch(cache, url, init?, options?)` | Adds TTL cache behavior around `fetchWithRetry`, unless `skipCache` is set. | +| `ApiClient.constructor(options)` | Configures base URL, default timeout, cache TTL, retry count, user agent, and headers. | +| `ApiClient.buildHeaders(extra?)` | Protected helper that merges default headers with request-specific headers. | +| `ApiClient.buildTimeoutSignal(timeoutMs?)` | Protected helper that creates an `AbortSignal` for request timeout. | +| `ApiClient.get(path, search?, options?)` | Performs a cached GET with retries, timeout, headers, and query params. | +| `ApiClient.post(path, body, options?)` | Performs an uncached JSON POST with retries and timeout. | +| `ApiClient.cacheStats()` | Exposes underlying cache stats. | +| `ApiClient.clearCache()` | Clears the underlying cache. | + +### `apps/web/src/lib/api-guards.ts` + +| Function | What it does | +| --- | --- | +| `readBodyWithLimit(request, maxBytes?)` | Reads a request body stream as text and returns `null` if it exceeds the configured size. | +| `validateCoordinate(value, min, max)` | Strictly parses a numeric coordinate and verifies it is in range. | +| `rateLimitExceededResponse(remaining, limit, resetAtMs)` | Builds a 429 JSON `NextResponse` with rate-limit headers. | +| `applyRateLimitHeaders(response, remaining, limit)` | Adds rate-limit headers to a successful `NextResponse`. | + +### `apps/web/src/lib/rate-limiter.ts` + +| Function or method | What it does | +| --- | --- | +| `RateLimiter.constructor(options?)` | Creates an in-memory sliding-window limiter and starts cleanup interval. | +| `RateLimiter.check(key)` | Records/checks a request for a key and returns allowed, remaining, reset time, and limit metadata. | +| `RateLimiter.forget(key)` | Removes one key from the limiter. | +| `RateLimiter.clear()` | Clears all rate-limit state. | +| `RateLimiter.destroy()` | Stops the cleanup timer. Useful in tests. | +| `RateLimiter._cleanup()` | Private timer callback that removes expired timestamps and empty keys. | + +### `apps/web/src/lib/calendar-utils.ts` + +| Function | What it does | +| --- | --- | +| `extractEvents(content, days?, now?)` | Parses ICS text with `node-ical`, finds `VEVENT` entries, keeps future events within the requested day horizon, requires a location, normalizes destination with `cleanLocation`, sorts by event time, and returns `CalendarEvent[]`. | +| `cleanLocation(location)` | Normalizes common Austrian Hauptbahnhof names to short HAFAS names, otherwise returns the first comma-separated location segment. | + +### `apps/web/src/lib/url-validation.ts` + +| Function | What it does | +| --- | --- | +| `isPrivateOrReservedHost(hostname)` | Internal SSRF-protection helper. Blocks localhost, private/internal suffixes, raw IP literals, IPv6 literals, and suspicious resolver-style names. | +| `isCalendarUrlAllowed(url)` | Validates remote calendar URLs. Only HTTP/HTTPS URLs from known calendar provider domains are allowed, and private/reserved hosts are blocked. | + +### `apps/web/src/lib/hafas-client.ts` + +| 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.cacheStats()` | Exposes cache stats from the internal client, mostly for debugging. | + +### `apps/web/src/lib/geocoding-client.ts` + +| Method | What it does | +| --- | --- | +| `GeocodingClient.constructor(baseUrl?, userAgent?, ttlMs?)` | Configures a Nominatim client with timeout, retries, user agent, and cache TTL. | +| `GeocodingClient.geocode(query, countrycodes?)` | Calls Nominatim `/search`, caches results, and maps strings to numeric `GeocodeResult`s. | +| `GeocodingClient.reverseGeocode(lat, lng)` | Calls Nominatim `/reverse`, caches the result, and returns `null` if the request fails. | +| `GeocodingClient.cacheStats()` | Exposes cache stats. | +| `GeocodingClient.clearCache()` | Clears cached geocoding data. | + +### `apps/web/src/lib/bike-routing-client.ts` + +| Function or method | What it does | +| --- | --- | +| `stepInstruction(step)` | Internal OSRM helper that prefers the step's provided instruction, otherwise builds one from maneuver type, modifier, and street name. | +| `BikeRoutingClient.constructor(baseUrl?, ttlMs?)` | Configures an OSRM bicycle client with a 5 minute default cache TTL. | +| `BikeRoutingClient.getBikeRoute(fromLat, fromLng, toLat, toLng)` | Calls OSRM `/route/v1/bicycle`, maps route distance/duration and step instructions into `BikeRoute`, or returns `null` if no route exists. | +| `BikeRoutingClient.cacheStats()` | Exposes route cache stats. | +| `BikeRoutingClient.clearCache()` | Clears route cache. | + +### `apps/web/src/lib/walk-routing-client.ts` + +| Function or method | What it does | +| --- | --- | +| `stepInstruction(step)` | Same OSRM instruction helper as the bike client. | +| `WalkRoutingClient.constructor(baseUrl?, ttlMs?)` | Configures an OSRM foot-routing client with a 5 minute default cache TTL. | +| `WalkRoutingClient.getWalkRoute(fromLat, fromLng, toLat, toLng)` | Calls OSRM `/route/v1/foot`, maps route distance/duration and step instructions into `WalkRoute`, or returns `null` if no route exists. | +| `WalkRoutingClient.cacheStats()` | Exposes route cache stats. | +| `WalkRoutingClient.clearCache()` | Clears route cache. | + +### `apps/web/src/lib/wienerlinien-client.ts` + +| Function or method | What it does | +| --- | --- | +| `WienerLinienClient.constructor(baseUrl?)` | Creates a client for the configured Wiener Linien API base URL. | +| `WienerLinienClient.findNearbyStops(lat, lng, radius)` | Calls `/nearbyStops`, caches results for 5 minutes, validates each stop with `parseNearbyStop`, and returns valid stops only. | +| `WienerLinienClient.getMonitor(stopIds)` | Calls `/monitor` for stop IDs, caches results for 1 minute, and parses departures into `WienerLinienMonitorResponse`. | +| `parseNearbyStop(item)` | Internal validator/mapper for a raw nearby stop item. | +| `parseMonitorResponse(raw)` | Internal mapper from raw monitor response object to grouped stop departures. | +| `parseDeparture(item, stopId)` | Internal validator/mapper for one raw departure row. | +| `parseLine(item)` | Internal validator/mapper for a raw line object. | + +### `apps/web/src/lib/demo.ts` + +| Function or constant | What it does | +| --- | --- | +| `DEMO_STATIONS` | Static station list used for demo/default data. | +| `createDemoJourney(id, station)` | Creates a future no-delay demo `Journey` with randomized train label. The station parameter is currently unused. | + +### `apps/web/src/lib/index.ts` + +Barrel file for web library exports. + +## Web API Routes + +All route functions are Next.js App Router route handlers. + +### `apps/web/src/proxy.ts` + +| Function | What it does | +| --- | --- | +| `getClientIp(request)` | Internal helper that extracts the client IP from `x-forwarded-for`, `x-real-ip`, or Next request metadata. | +| `buildCorsHeaders(origin)` | Internal helper that returns CORS headers only when the origin is in `CORS_ALLOWED_ORIGINS`. | +| `proxy(request)` | Middleware for `/api/*`. Handles CORS preflight, applies allowed-origin CORS headers, and enforces per-IP rate limiting with `RateLimiter`. | + +### `apps/web/src/app/api/health/route.ts` + +| Function | What it does | +| --- | --- | +| `GET()` | Returns JSON health metadata: `ok`, timestamp, and app version. | + +### `apps/web/src/app/api/hafas/route.ts` + +| Function | What it does | +| --- | --- | +| `injectHafasAuth(body)` | Internal helper that adds HAFAS protocol fields: version, language, auth AID, and client identity. | +| `GET(request)` | Convenience journey search route. Validates `from`, `to`, and `date`, builds a HAFAS `TripSearch`, forwards it to HAFAS, and returns raw HAFAS JSON. | +| `POST(request)` | Generic allowed HAFAS relay. Reads body with a 4 KB cap, parses JSON, injects auth, allows only `TripSearch` and `LocMatch`, caps `TripSearch.numF` at 10, forwards to HAFAS, and returns raw HAFAS JSON. | + +### `apps/web/src/app/api/geocode/route.ts` + +| Function | What it does | +| --- | --- | +| `GET(request)` | Validates `name` and optional `countrycodes`, calls `GeocodingClient.geocode`, returns the first result, and maps no-result or validation failures to appropriate HTTP statuses. | + +### `apps/web/src/app/api/bike-route/route.ts` + +| Function | What it does | +| --- | --- | +| `GET(request)` | Validates four coordinate query params, rejects unrealistically distant points, calls `BikeRoutingClient.getBikeRoute`, and returns route JSON or 404 when OSRM finds no route. | + +### `apps/web/src/app/api/walk-route/route.ts` + +| Function | What it does | +| --- | --- | +| `GET(request)` | Same shape as the bike route endpoint, but uses `WalkRoutingClient.getWalkRoute`. | + +### `apps/web/src/app/api/calendar/route.ts` + +| Function | What it does | +| --- | --- | +| `hasAcceptableContentType(contentType)` | Internal helper that allows `text/calendar`, `text/plain`, and `application/octet-stream` calendar responses. | +| `GET(request)` | Validates a remote ICS URL with SSRF protections, rejects redirects, checks content type and size, reads up to 5 MB, parses events with `extractEvents`, and returns `CalendarEvent[]`. | + +### `apps/web/src/app/api/calendar/parse/route.ts` + +| Function | What it does | +| --- | --- | +| `POST(request)` | Reads raw ICS content with a body limit, applies an additional 10 KB parse guard, parses with `extractEvents`, and returns `CalendarEvent[]`. | + +### `apps/web/src/app/api/calendar/google/route.ts` + +| Function | What it does | +| --- | --- | +| `refreshAccessToken(tokens)` | Internal helper that refreshes Google OAuth access tokens using the stored refresh token and client credentials. | +| `GET(request)` | Reads Google OAuth tokens from HTTP-only cookies, refreshes if close to expiry, calls Google Calendar primary events for the requested day horizon, filters events with locations and `dateTime` starts, cleans destinations, and returns `CalendarEvent[]`. | + +### `apps/web/src/app/api/auth/google/route.ts` + +| Function | What it does | +| --- | --- | +| `getBaseUrl()` | Internal helper returning `DEPLOYMENT_URL` or local development URL. | +| `GET()` | Starts Google OAuth by creating a state cookie and redirecting to Google's consent URL for readonly calendar access. | + +### `apps/web/src/app/api/auth/google/callback/route.ts` + +| Function | What it does | +| --- | --- | +| `getBaseUrl()` | Internal helper returning `DEPLOYMENT_URL` or local development URL. | +| `calendarRedirect(params)` | Internal helper that redirects back to `/calendar` with status/error query params. | +| `GET(request)` | Handles Google OAuth callback. Validates state, exchanges code for tokens, stores tokens in an HTTP-only cookie, and redirects to the calendar page. | + +### `apps/web/src/app/api/auth/google/status/route.ts` + +| Function | What it does | +| --- | --- | +| `GET()` | Returns whether Google Calendar is configured and whether the user has a token cookie. | + +### `apps/web/src/app/api/auth/google/disconnect/route.ts` + +| Function | What it does | +| --- | --- | +| `POST()` | Deletes the Google token cookie and returns success. | + +### `apps/web/src/app/api/wienerlinien/stops/route.ts` + +| Function | What it does | +| --- | --- | +| `GET(request)` | Validates `lat`, `lng`, and optional radius, caps radius at 5000 meters, calls `WienerLinienClient.findNearbyStops`, and returns `{ stops }`. | + +### `apps/web/src/app/api/wienerlinien/monitor/route.ts` + +| Function | What it does | +| --- | --- | +| `GET(request)` | Validates up to 10 stop IDs, calls `WienerLinienClient.getMonitor`, flattens grouped monitor data into a `departures` array, and returns it. | + +## Web React Hooks + +### `apps/web/src/hooks/useEventsStore.tsx` + +| Function | What it does | +| --- | --- | +| `loadFromStorage()` | Internal helper that reads `ttl_events` from `localStorage`, converts event time strings back to `Date`, and returns an empty list on SSR or parse errors. | +| `EventsProvider({ children })` | React context provider for local events. Persists events to `localStorage` and exposes add, update, remove, clear, set, and merge operations. | +| `useEventsStore()` | Reads the event context and throws if used outside `EventsProvider`. | + +### `apps/web/src/hooks/useReminderSettings.tsx` + +| Function | What it does | +| --- | --- | +| `loadFromStorage()` | Internal helper that reads reminder settings from `localStorage` and merges them with defaults. | +| `ReminderSettingsProvider({ children })` | React context provider for reminder settings. It persists settings and exposes bounded setters for buffer, arrival buffer, notification enabled state, and visibility toggles for walking/bike sections. | +| `useReminderSettings()` | Reads the settings context and throws if used outside `ReminderSettingsProvider`. | + +### Other web hooks + +| File | Function | What it does | +| --- | --- | --- | +| `useBikeRoute.ts` | `useBikeRoute(fromLat, fromLng, toLat, toLng)` | Fetches a bike route when all coordinates are present and returns `{ bikeRoute, loading, error }`. | +| `useWalkRoute.ts` | `useWalkRoute(fromLat, fromLng, toLat, toLng)` | Fetches a walking route when all coordinates are present and returns `{ walkRoute, loading, error }`. | +| `useCalendar.ts` | `useCalendar(days?)` | Manages imported calendar events and exposes URL import, file parse, merge, and state setters. | +| `useClock.ts` | `useClock(targetDate, departureTime?)` | Recomputes countdown every 10 seconds against departure time when available, otherwise event time. | +| `useDepartureTime.ts` | `useDepartureTime(eventTime, journeys, bikeRoute, activeMode)` | Computes the leave-by and arrival time for either train or bike mode. For train mode, it picks the latest non-cancelled journey that still arrives before the event minus arrival buffer. | +| `useDestinationStation.ts` | `useDestinationStation(destination)` | Debounces destination lookup, geocodes the destination, then uses HAFAS `LocMatch` to choose the nearest station. | +| `useGeocode.ts` | `useGeocode(destination)` | Debounces destination geocoding and returns first coordinate result. | +| `useGeolocation.ts` | `getGeolocation()` | Internal SSR-safe wrapper around `navigator.geolocation`. | +| `useGeolocation.ts` | `useGeolocation()` | Watches browser geolocation and returns position, geolocation error, and permission-like state. | +| `useJourneys.ts` | `useJourneys(fromStationExtId, toStationExtId, date, refreshKey?)` | Builds and sends a HAFAS `TripSearch`, parses journeys, and returns loading/error state. | +| `useOriginStation.ts` | `useOriginStation()` | Uses browser geolocation to find the nearest station through HAFAS; falls back to configured default station. | +| `useReminder.ts` | `useReminder()` | Polls upcoming events and fires browser notifications when an event enters the reminder window. | +| `useServerHealth.ts` | `useServerHealth()` | Checks `/api/health` every 30 seconds and returns status plus last check time. | +| `useTheme.ts` | `getServerTheme()` | Internal server fallback theme, currently light. | +| `useTheme.ts` | `getClientTheme()` | Internal helper that reads local theme or `prefers-color-scheme`. | +| `useTheme.ts` | `useTheme()` | Manages dark/light state, toggles the `dark` class on `documentElement`, and persists the choice. | +| `useWienerLinien.ts` | `transformDeparture(dep)` | Internal helper that maps API departure timestamps to minutes-from-now UI rows. | +| `useWienerLinien.ts` | `useWienerLinien(lat, lng, radius?)` | Debounces nearby-stop lookup, fetches monitor departures, refreshes departures every minute, and returns stops/departures/loading/error. | + +## Web Pages and Components + +### App shell + +| File | Function/component | What it does | +| --- | --- | --- | +| `apps/web/src/app/layout.tsx` | `RootLayout({ children })` | Defines global HTML/body shell, font, metadata, and wraps the app in reminder settings and events providers. Renders `ReminderEngine`, `Header`, page content, and `Navbar`. | +| `apps/web/src/app/page.tsx` | `Home()` | Main dashboard page. Reads events and origin station, filters upcoming events, shows summary counts, and renders `EventCard` for each upcoming event. | +| `apps/web/src/app/layout/Header.tsx` | `Header()` | Top app header with branding and shared navigation/actions. | +| `apps/web/src/app/layout/Navbar.tsx` | `Navbar()` | Bottom navigation between main app views. | +| `apps/web/src/app/layout/ReminderEngine.tsx` | `ReminderEngine()` | Client component that activates `useReminder` without rendering visible UI. | + +### Event components + +| File | Function/component | What it does | +| --- | --- | --- | +| `EventCard.tsx` | `EventCard({ event, originStation })` | Main event detail card on the web dashboard. It wires together destination station lookup, journey search, bike route, walk route, geocoding, Wiener Linien data, reminder settings, transport mode selection, countdown, edit modal, and remove action. | +| `TrainSection.tsx` | `TrainSection(props)` | Displays journey loading/error states, train journey list, event target time, arrival buffer, and optional final walking route. | +| `JourneyList.tsx` | `JourneyList(props)` | Renders each journey with departure, platform, delay, train names, cancellation/late styling, arrival time, change count, and leave-by badge. | +| `LeaveByBadge.tsx` | `LeaveByBadge({ countdown, className? })` | Maps a `CountdownInfo` color to chip styles and pulses urgent countdowns. | +| `BikeSection.tsx` | `BikeSection(props)` | Displays bike route distance, duration, steps, loading/error states, and optional refresh action. | +| `WalkingOption.tsx` | `WalkingOption(props)` | Displays final walking route from arrival station to destination with distance, duration, steps, loading/error states, and optional refresh action. | +| `WienerLinienSection.tsx` | `WienerLinienSection(props)` | Displays nearby Wiener Linien stops and upcoming departures, with loading, empty, and error states. | + +### Calendar components + +| File | Function/component | What it does | +| --- | --- | --- | +| `apps/web/src/app/calendar/page.tsx` | `CalendarPage()` | Page entry for the calendar import/management view. | +| `CalendarView.tsx` | `CalendarView()` | Top-level calendar screen component that coordinates imported events and calendar UI panels. | +| `CalendarPanel.tsx` | `CalendarPanel()` | Provides tabs/panels for Google, URL, and file calendar import. | +| `GoogleTab.tsx` | `GoogleTab()` | Handles Google Calendar connection status, OAuth start/disconnect actions, fetching Google events, and displaying OAuth status/errors. | +| `GoogleTab.tsx` | `GoogleIcon()` | Renders the small Google logo used in the tab. | +| `GoogleTab.tsx` | `friendlyOAuthError(error)` | Maps OAuth error query values to human-readable messages. | +| `UrlTab.tsx` | `UrlTab()` | UI for importing calendar events from an ICS URL. | +| `FileTab.tsx` | `FileTab()` | UI for parsing an uploaded ICS file. | +| `DayEvents.tsx` | `DayEvents()` | Groups and displays events for a day in the calendar view. | +| `BatchEditPanel.tsx` | `sourceLabel(source)` | Internal helper that converts event source strings into user-facing labels. | +| `BatchEditPanel.tsx` | `BatchEditPanel()` | Lets users batch-review or update imported event destinations before/after merging into stored events. | + +### Add-event and UI components + +| File | Function/component | What it does | +| --- | --- | --- | +| `apps/web/src/app/add-event/AddEventModal.tsx` | `AddEventModal(props)` | Modal form for creating or editing an event in the web app. It validates basic fields and writes through `useEventsStore`. | +| `apps/web/src/app/ui/Button.tsx` | `Button(props)` | Shared styled button with variant and size props. | +| `apps/web/src/app/ui/Chip.tsx` | `Chip(props)` | Shared pill/chip wrapper used for badges. | +| `apps/web/src/app/ui/CountdownBadge.tsx` | `CountdownBadge(props)` | Countdown chip used on event cards. It maps countdown colors to styles and pulses urgent/current statuses. | +| `apps/web/src/app/ui/LoadingSpinner.tsx` | `LoadingSpinner(props)` | Shared CSS spinner with size variants. | +| `apps/web/src/app/ui/LogoHorizontal.tsx` | `LogoHorizontal(props)` | Inline SVG horizontal TimeToLeave logo. | +| `apps/web/src/app/ui/LogoIcon.tsx` | `LogoIcon(props)` | Inline SVG icon logo. Uses `useId` so gradient/filter IDs do not collide. | +| `apps/web/src/app/ui/ReminderSettingsPanel.tsx` | `ReminderSettingsPanel(props)` | UI controls for browser reminders, reminder lead time, arrival buffer, walking option, bike option, and notification permission status. | +| `apps/web/src/app/ui/logos.ts` | constants | Static logo-related exports. | + +## Mobile App + +### Mobile entry and navigation + +| File | Function/component | What it does | +| --- | --- | --- | +| `apps/mobile/App.tsx` | `App()` | Requests notification permission once, installs the Expo notification handler once, and renders `AppNavigator`. | +| `apps/mobile/index.ts` | registration | Expo entrypoint that imports polyfills and registers the app. | +| `apps/mobile/src/navigation/AppNavigator.tsx` | `AppNavigator()` | Creates the native stack navigator inside safe-area and navigation providers. Defines screens: event list, add/edit event, event detail, settings, and calendar import. | +| `apps/mobile/src/types/navigation.ts` | `RootStack` | Type-only route parameter map for React Navigation. | + +### Mobile hooks + +| File | Function | What it does | +| --- | --- | --- | +| `useDepartureTime.ts` | `useDepartureTime(eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes)` | Mobile version of leave-by calculation. It chooses the latest train that arrives in time or subtracts bike duration from target arrival. | +| `useDestinationStation.ts` | `useDestinationStation(destination)` | Debounces destination lookup, geocodes it, and resolves nearest HAFAS station through the shared API client. | +| `useGeocode.ts` | `useGeocode(destination)` | Debounces geocoding and returns the first `GeocodeResult`. | +| `useColors.ts` | `useColors()` | Returns the current mobile color palette by reading `useTheme`; exports `AppColors` for palette typing. | +| `useTheme.ts` | `getDefaultTheme()` | Internal helper. Mobile defaults to dark to match the app style. | +| `useTheme.ts` | `useTheme()` | Loads theme from AsyncStorage, toggles dark/light mode, and persists changes. | +| `useWalkRoute.ts` | `useWalkRoute(fromLat, fromLng, toLat, toLng)` | Fetches walking route data via API client and returns route/loading/error state. | +| `useWienerLinien.ts` | `transformDeparture(dep)` | Internal helper mapping raw departures to UI rows with minutes from now. | +| `useWienerLinien.ts` | `useWienerLinien(lat, lng, radius?)` | Fetches nearby stops and periodically refreshes departures through the API client. | + +### Mobile services and storage + +| File | Function | What it does | +| --- | --- | --- | +| `apps/mobile/src/services/api.ts` | `api` | Shared `ApiClient` instance using `EXPO_PUBLIC_API_BASE_URL` or empty base URL. | +| `apps/mobile/src/services/calendar.ts` | `ensureCalendarPermission()` | Requests Expo calendar permission and returns whether calendar access is available. | +| `apps/mobile/src/services/calendar.ts` | `fetchNativeEvents(startDate, endDate)` | Reads native device calendars, filters events with locations, and maps them to shared `Event` objects. | +| `apps/mobile/src/services/expoNotifications.ts` | re-exports | Re-exports public Expo notification functions and types from stable package paths. | +| `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` | `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` | `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/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 screens + +| File | Function/component | What it does | +| --- | --- | --- | +| `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/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 + +Use `packages/core` for logic that must behave the same on web and mobile. Time parsing, countdowns, formatting, shared route types, and leave-status logic belong there. + +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/*` 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. + +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`. + +Security-sensitive areas are the calendar URL route, HAFAS relay, CORS middleware, rate limiter, request body limits, and coordinate validators. Extend those conservatively when adding new inputs. diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index b492646..3192098 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -8,7 +8,7 @@ import type { Station, WienerLinienDeparture, } from "@timetoleave/core"; -import { hafasDateTime } from "@timetoleave/core"; +import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core"; const DEFAULT_BASE_URL = ""; @@ -115,7 +115,6 @@ export class ApiClient { toStationExtId: string, date: Date, ): Promise { - // Build a proper HAFAS TripSearch body β€” the /api/hafas endpoint expects svcReqL. const { date: hafasDate, time: hafasTime } = hafasDateTime(date); const body = { @@ -140,7 +139,8 @@ export class ApiClient { }); if (!res.ok) throw new Error(`Journey search failed: ${res.status}`); - return res.json(); + const data = await res.json(); + return parseHafasJourneys(data, hafasDate, date); } async reverseGeocode(lat: number, lng: number): Promise { @@ -166,18 +166,20 @@ export class ApiClient { } 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[] } }>; + svcResL?: Array<{ res?: { match?: { locL?: Array<{ type: string; name: string; extId: string }> } } }>; }>({ svcReqL: [ { meth: "LocMatch", - req: { searchTxt: query, maxMatches: 5 }, + req: { input: { loc: { name: query, type: "S" }, maxLoc: 5, field: "S" } }, }, ], }); - return result?.svcReqL?.[0]?.res?.locL ?? []; + const locL = result?.svcResL?.[0]?.res?.match?.locL ?? []; + return locL + .filter((l) => l.type === "S") + .map((l) => ({ name: l.name, extId: l.extId })); } /** @@ -192,7 +194,7 @@ export class ApiClient { } const result = await this.hafasRequest<{ - svcReqL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>; + svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>; }>({ svcReqL: [ { @@ -214,7 +216,7 @@ export class ApiClient { ], }); - const stations: HafasLocation[] = result?.svcReqL?.[0]?.res?.match?.locL ?? []; + const stations: HafasLocation[] = result?.svcResL?.[0]?.res?.match?.locL ?? []; if (stations.length === 0) return null; // Filter to only "S" (station) type results, then pick the closest diff --git a/packages/core/src/hafas-parser.ts b/packages/core/src/hafas-parser.ts new file mode 100644 index 0000000..dd78c44 --- /dev/null +++ b/packages/core/src/hafas-parser.ts @@ -0,0 +1,36 @@ +import type { Journey } from "./types"; +import { parseHafasTime } from "./hafas-time"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RawJson = any; + +export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: Date): Journey[] { + const outConL: RawJson[] = json?.svcResL?.[0]?.res?.outConL ?? []; + + return outConL.map((con: RawJson, i: number): Journey => { + const first = con.secL?.[0]; + const last = con.secL?.[con.secL.length - 1]; + const dep = first?.dep; + const arr = last?.arr; + + const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate; + const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD; + const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD; + const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA; + + const delay = Math.max(0, Math.round((rD.getTime() - sD.getTime()) / 60000)); + const trains: string[] = (con.secL ?? []) + .filter((s: RawJson) => s.jny) + .map((s: RawJson) => s.jny?.stopL?.[0]?.name ?? "") + .filter(Boolean); + + return { + id: con.ctxRecon ?? `journey-${i}`, + sD, rD, sA, rA, delay, + platform: dep?.dPlatfS ?? "", + changes: Math.max(0, (con.secL ?? []).filter((s: RawJson) => s.jny).length - 1), + trains, + cancelled: (con.secL ?? []).some((s: RawJson) => s.jny?.isCncl === true), + }; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7a4593..e02d2f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,4 @@ export * from './countdown-utils'; export * from './formatting'; export * from './status-utils'; export * from './hafas-time'; +export * from './hafas-parser';