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.
This commit is contained in:
2026-05-13 19:13:27 +02:00
parent 6c7d57106c
commit bf252a9e9b
22 changed files with 1091 additions and 552 deletions
@@ -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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Radroute wird geladen</Text>
</View>
) : bikeRoute ? (
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.row}>
<Text style={[styles.label, { color: colors.text }]}> Dauer</Text>
<Text style={[styles.value, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
</View>
<View style={styles.row}>
<Text style={[styles.label, { color: colors.text }]}>📏 Distanz</Text>
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
</View>
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Text style={[styles.mapText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
</View>
</View>
) : (
<Text style={[styles.empty, { color: colors.subtext }]}>
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
</Text>
)}
</View>
);
}
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 },
});
@@ -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 (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
<Text style={[styles.time, { color: colors.accent }]}>
{event.eventTime.toLocaleString('de-AT', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
<View style={[styles.infoGrid, { borderTopColor: colors.border }]}>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{leaveByTime
? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })
: '—'}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{arriveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>{arrivalBufferMinutes} min</Text>
</View>
</View>
</View>
);
}
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 },
});
+105
View File
@@ -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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
{destStationLoading && (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Ziel-Station wird aufgelöst</Text>
</View>
)}
{journeys.length === 0 && !destStationLoading ? (
<Text style={[styles.empty, { color: colors.subtext }]}>
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
</Text>
) : (
journeys.map((j) => (
<View key={j.id} style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.row}>
<Text style={[styles.line, { color: colors.text }]}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{j.delay > 0 && (
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
)}
{j.cancelled && (
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
)}
</View>
<Text style={[styles.detail, { color: colors.text }]}>
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}(Plattform {j.platform || '—'})
</Text>
<Text style={[styles.detail, { color: colors.subtext }]}>
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
</Text>
</View>
))
)}
{showWalkingOption && walkRoute && (
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border, marginTop: 10 }]}>
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
<View style={styles.row}>
<Text style={[styles.walkLabel, { color: colors.text }]}> Dauer</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
</View>
<View style={styles.row}>
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
</View>
</View>
)}
{showWalkingOption && loadingWalk && (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Fußweg wird geladen</Text>
</View>
)}
</View>
);
}
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' },
});
@@ -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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Haltestellen werden geladen</Text>
</View>
) : departures.length > 0 ? (
departures.slice(0, 8).map((dep, i) => (
<View
key={`${dep.stopId}-${dep.lineName}-${i}`}
style={[styles.depCard, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<View style={styles.depRow}>
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
</View>
<Text style={[styles.direction, { color: colors.text }]} numberOfLines={1}>
{dep.direction}
</Text>
<Text style={[styles.minutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
</Text>
</View>
</View>
))
) : (
stops.slice(0, 5).map((stop) => (
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
</View>
))
)}
{error && <Text style={[styles.hint, { color: colors.subtext }]}>{error}</Text>}
</View>
);
}
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' },
});
+40
View File
@@ -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<keyof typeof DARK, string>;
export function useColors(): AppColors {
const { dark } = useTheme();
return dark ? DARK : LIGHT;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core'; import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
import { api } from '../services/api'; import { api } from '../services/api';
interface DepartureRow { export interface DepartureRow {
stopId: string; stopId: string;
lineName: string; lineName: string;
direction: string; direction: string;
+3 -21
View File
@@ -11,7 +11,7 @@ import type { RouteProp } from '@react-navigation/native';
import { loadEvents, addEvent, updateEvent } from '../store/eventStore'; import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core'; import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation'; import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme'; import { useColors } from '../hooks/useColors';
type ScreenProps = { type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>; navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
@@ -19,7 +19,7 @@ type ScreenProps = {
}; };
export function AddEventScreen({ navigation, route }: ScreenProps) { export function AddEventScreen({ navigation, route }: ScreenProps) {
const { dark } = useTheme(); const colors = useColors();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [destination, setDestination] = useState(''); const [destination, setDestination] = useState('');
const [dateStr, setDateStr] = useState(''); const [dateStr, setDateStr] = useState('');
@@ -27,24 +27,6 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); 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 // If editing an existing event, populate the form
useEffect(() => { useEffect(() => {
if (!route.params?.editEventId) return; if (!route.params?.editEventId) return;
@@ -162,7 +144,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
</View> </View>
{success && ( {success && (
<View style={[styles.successOverlay, { backgroundColor: dark ? 'rgba(28,28,30,0.95)' : 'rgba(255,255,255,0.95)' }]}> <View style={[styles.successOverlay, { backgroundColor: colors.overlay }]}>
<View style={styles.successContent}> <View style={styles.successContent}>
<View style={styles.successCircle}> <View style={styles.successCircle}>
<Text style={styles.checkmark}></Text> <Text style={styles.checkmark}></Text>
@@ -15,7 +15,7 @@ import { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore'; import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core'; import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation'; import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme'; import { useColors } from '../hooks/useColors';
type ScreenProps = { type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>; navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
@@ -23,34 +23,12 @@ type ScreenProps = {
}; };
export function CalendarImportScreen({ navigation }: ScreenProps) { export function CalendarImportScreen({ navigation }: ScreenProps) {
const { dark } = useTheme(); const colors = useColors();
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [count, setCount] = useState<number | null>(null); const [count, setCount] = useState<number | null>(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 () => { const handleImport = async () => {
if (!url.trim()) { if (!url.trim()) {
setError('Bitte ICS-URL eingeben'); setError('Bitte ICS-URL eingeben');
@@ -62,9 +40,14 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
setCount(null); setCount(null);
try { try {
const events = await api.fetchCalendar(url.trim()); const [events, existing] = await Promise.all([
// Add imported events to local store api.fetchCalendar(url.trim()),
loadEvents(),
]);
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of events) { for (const evt of events) {
if (!existingIds.has(evt.id)) {
const localEvent: CalendarEvent = { const localEvent: CalendarEvent = {
id: evt.id, id: evt.id,
title: evt.title, title: evt.title,
@@ -73,8 +56,10 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
source: `calendar:${url.trim().slice(0, 40)}`, source: `calendar:${url.trim().slice(0, 40)}`,
}; };
await addEvent(localEvent); await addEvent(localEvent);
added++;
} }
setCount(events.length); }
setCount(added);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen'); setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
} finally { } finally {
+68 -280
View File
@@ -11,14 +11,17 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native'; import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore'; import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
import { api } from '../services/api'; import { api } from '../services/api';
import { formatDuration, formatDistance } from '@timetoleave/core';
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core'; import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
import { useDestinationStation } from '../hooks/useDestinationStation'; import { useDestinationStation } from '../hooks/useDestinationStation';
import { useDepartureTime } from '../hooks/useDepartureTime'; import { useDepartureTime } from '../hooks/useDepartureTime';
import { useGeocode } from '../hooks/useGeocode'; import { useGeocode } from '../hooks/useGeocode';
import { useWalkRoute } from '../hooks/useWalkRoute'; import { useWalkRoute } from '../hooks/useWalkRoute';
import { useWienerLinien } from '../hooks/useWienerLinien'; 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'; import type { RootStack } from '../types/navigation';
type ScreenProps = { type ScreenProps = {
@@ -30,7 +33,7 @@ type TransportMode = 'train' | 'bike';
export function EventDetailScreen({ navigation, route }: ScreenProps) { export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params; const { eventId } = route.params;
const { dark } = useTheme(); const colors = useColors();
const [event, setEvent] = useState<CalendarEvent | null>(null); const [event, setEvent] = useState<CalendarEvent | null>(null);
const [journeys, setJourneys] = useState<Journey[]>([]); const [journeys, setJourneys] = useState<Journey[]>([]);
@@ -46,21 +49,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
const [showBikeOption, setShowBikeOption] = useState(true); const [showBikeOption, setShowBikeOption] = useState(true);
const [showWalkingOption, setShowWalkingOption] = useState(true); const [showWalkingOption, setShowWalkingOption] = useState(true);
// Resolve destination text to HAFAS station ID (CRITICAL FIX)
const destStation = useDestinationStation(event?.destination); const destStation = useDestinationStation(event?.destination);
// Geocode destination for bike/walk routes
const destCoords = useGeocode(event?.destination); const destCoords = useGeocode(event?.destination);
// Fetch walk route from destination station to final address
const walkHook = useWalkRoute( const walkHook = useWalkRoute(
destStation.station?.lat, destStation.station?.lat,
destStation.station?.lng, destStation.station?.lng,
destCoords.coords?.lat, destCoords.coords?.lat,
destCoords.coords?.lng, destCoords.coords?.lng,
); );
// Fetch nearby WienerLinien stops
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@@ -78,35 +74,24 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
setShowWalkingOption(settings.showWalkingOption); setShowWalkingOption(settings.showWalkingOption);
const found = events.find((e) => e.id === eventId); const found = events.find((e) => e.id === eventId);
if (!found) { if (!found) { setError('Termin nicht gefunden'); return; }
setError('Termin nicht gefunden');
return;
}
setEvent(found); setEvent(found);
if (originStation) { if (originStation) {
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
const destExtId = destStation.station?.extId; const destExtId = destStation.station?.extId;
if (destExtId) { if (destExtId) {
const results = await api.searchJourneys( const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime);
originStation.extId,
destExtId,
found.eventTime,
);
setJourneys(results); setJourneys(results);
} else if (destStation.error) { } else if (destStation.error) {
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`); setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
} }
// Fetch bike route if we have coordinates
try { try {
setLoadingBike(true); setLoadingBike(true);
if (destCoords.coords && originStation.lat && originStation.lng) { if (destCoords.coords && originStation.lat && originStation.lng) {
const bike = await api.getBikeRoute( const bike = await api.getBikeRoute(
originStation.lat, originStation.lat, originStation.lng,
originStation.lng, destCoords.coords.lat, destCoords.coords.lng,
destCoords.coords.lat,
destCoords.coords.lng,
); );
setBikeRoute(bike); setBikeRoute(bike);
} }
@@ -125,7 +110,6 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
// Sync walk route from hook
useEffect(() => { useEffect(() => {
setWalkRoute(walkHook.walkRoute); setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading); setLoadingWalk(walkHook.loading);
@@ -138,36 +122,17 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
fetchData(); fetchData();
}; };
// Use the shared departure time hook instead of inline calculation
const departureInfo = useDepartureTime( const departureInfo = useDepartureTime(
event?.eventTime ?? new Date(), event?.eventTime ?? new Date(),
journeys.length > 0 ? journeys : null, journeys.length > 0 ? journeys : null,
bikeRoute?.duration ?? 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, 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) { if (loading) {
return ( return (
@@ -180,75 +145,38 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
); );
} }
// Disable bike mode if setting is off
const bikeDisabled = !showBikeOption; const bikeDisabled = !showBikeOption;
const requestedMode: TransportMode = activeMode; const effectiveMode: TransportMode =
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode; bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
return ( return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}> <ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
{/* Event header */}
{event && ( {event && (
<View style={[styles.header, { backgroundColor: colors.card }]}> <EventHeader
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text> event={event}
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text> leaveByTime={departureInfo.departureTime}
<Text style={[styles.eventTime, { color: colors.accent }]}> arrivalBufferMinutes={arrivalBufferMinutes}
{event.eventTime.toLocaleString('de-AT', { colors={colors}
weekday: 'long', />
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
{/* Leave by / Arrive by / Buffer info */}
<View style={styles.infoGrid}>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : '—'}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{arrivalBufferMinutes} min
</Text>
</View>
</View>
</View>
)} )}
{/* Error */}
{error && ( {error && (
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}> <View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<Text style={styles.errorBannerText}> {error}</Text> <Text style={styles.bannerText}> {error}</Text>
</View> </View>
)} )}
{/* Origin status */}
{!origin && !error && ( {!origin && !error && (
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}> <View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
<Text style={styles.warningBannerText}> <Text style={styles.bannerText}>
Keine Ursprungstation festgelegt. Keine Ursprungstation festgelegt.{' '}
{' '} <Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
Einstellungen öffnen Einstellungen öffnen
</Text> </Text>
</Text> </Text>
</View> </View>
)} )}
{/* Transport mode selector */}
{origin && ( {origin && (
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
<TouchableOpacity <TouchableOpacity
@@ -289,159 +217,52 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
)} )}
</View> </View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}> <Text style={[styles.modeMeta, { color: colors.subtext }]}>
{bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'} {bikeDisabled
? 'In Einstellungen deaktiviert'
: loadingBike
? 'Route wird berechnet...'
: 'Direktweg'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{/* Journeys list (Train mode) */}
{effectiveMode === 'train' && ( {effectiveMode === 'train' && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}> <JourneyList
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text> journeys={journeys}
{destStation.loading && ( destStationLoading={destStation.loading}
<View style={styles.centerBike}> walkRoute={walkRoute}
<ActivityIndicator size="small" color={colors.accent} /> loadingWalk={loadingWalk}
<Text style={[styles.loadingText, { color: colors.subtext }]}> showWalkingOption={showWalkingOption}
Ziel-Station wird aufgelöst origin={origin}
</Text> colors={colors}
</View> />
)}
{journeys.length === 0 && !destStation.loading ? (
<Text style={[styles.emptyText, { color: colors.subtext }]}>
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
</Text>
) : (
journeys.map((j) => (
<View key={j.id} style={[styles.journeyCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.journeyRow}>
<Text style={[styles.lineText, { color: colors.text }]}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{j.delay > 0 && (
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
)}
{j.cancelled && (
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
)}
</View>
<Text style={[styles.departure, { color: colors.text }]}>
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
(Plattform {j.platform || '—'})
</Text>
<Text style={[styles.arrival, { color: colors.subtext }]}>
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
</Text>
</View>
))
)} )}
{/* Walk route section (when walking option enabled) */}
{showWalkingOption && walkRoute && (
<View style={[styles.walkCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
<View style={styles.walkRow}>
<Text style={[styles.walkLabel, { color: colors.text }]}> Dauer</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
</View>
<View style={styles.walkRow}>
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
</View>
</View>
)}
{showWalkingOption && loadingWalk && (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>
Fußweg wird geladen
</Text>
</View>
)}
</View>
)}
{/* Bike route section (Bike mode) */}
{effectiveMode === 'bike' && ( {effectiveMode === 'bike' && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}> <BikeSection
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text> bikeRoute={bikeRoute}
{loadingBike ? ( loading={loadingBike}
<View style={styles.centerBike}> origin={origin}
<ActivityIndicator size="small" color={colors.accent} /> colors={colors}
<Text style={[styles.loadingText, { color: colors.subtext }]}>Radroute wird geladen</Text> />
</View>
) : bikeRoute ? (
<View style={[styles.bikeCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.bikeRow}>
<Text style={[styles.bikeLabel, { color: colors.text }]}> Dauer</Text>
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
</View>
<View style={styles.bikeRow}>
<Text style={[styles.bikeLabel, { color: colors.text }]}>📏 Distanz</Text>
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
</View>
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Text style={[styles.mapPlaceholderText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
</View>
</View>
) : (
<Text style={[styles.emptyText, { color: colors.subtext }]}>
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
</Text>
)}
</View>
)} )}
{/* WienerLinien nearby stops + departures */} <NearbyStops
{(wienerLinien.loading || wienerLinien.stops.length > 0) && ( stops={wienerLinien.stops}
<View style={[styles.journeys, { backgroundColor: colors.background }]}> departures={wienerLinien.departures}
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text> loading={wienerLinien.loading}
{wienerLinien.loading ? ( error={wienerLinien.error}
<View style={styles.centerBike}> colors={colors}
<ActivityIndicator size="small" color={colors.accent} /> />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen</Text>
</View> <TouchableOpacity
) : wienerLinien.departures.length > 0 ? ( style={[styles.refreshBtn, { backgroundColor: colors.border }]}
wienerLinien.departures.slice(0, 8).map((dep, i) => ( onPress={handleRefresh}
<View
key={`${dep.stopId}-${dep.lineName}-${i}`}
style={[styles.departureCard, { backgroundColor: colors.card, borderColor: colors.border }]}
> >
<View style={styles.departureRow}>
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
</View>
<Text style={[styles.departureDirection, { color: colors.text }]} numberOfLines={1}>
{dep.direction}
</Text>
<Text style={[styles.departureMinutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
</Text>
</View>
</View>
))
) : (
wienerLinien.stops.slice(0, 5).map((stop) => (
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
</View>
))
)}
{wienerLinien.error && (
<Text style={[styles.emptyText, { color: colors.subtext }]}>{wienerLinien.error}</Text>
)}
</View>
)}
{/* Refresh */}
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text> <Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Bottom padding for scroll */}
<View style={{ height: 40 }} /> <View style={{ height: 40 }} />
</ScrollView> </ScrollView>
); );
@@ -451,57 +272,24 @@ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 12, fontSize: 15 }, 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 }, errorBanner: { padding: 12, marginBottom: 12 },
errorBannerText: { color: '#fff', fontSize: 14 },
warningBanner: { padding: 12, marginBottom: 12 }, warningBanner: { padding: 12, marginBottom: 12 },
warningBannerText: { color: '#fff', fontSize: 14 }, bannerText: { color: '#fff', fontSize: 14 },
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' }, bannerLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 }, modeSelector: {
flexDirection: 'row',
padding: 12,
gap: 12,
marginBottom: 12,
borderWidth: 1,
borderRadius: 12,
},
modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' }, modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' },
modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
modeLabel: { fontSize: 15, fontWeight: '600' }, modeLabel: { fontSize: 15, fontWeight: '600' },
modeMeta: { fontSize: 11, marginTop: 4 }, modeMeta: { fontSize: 11, marginTop: 4 },
activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 }, activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' }, 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 }, refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnText: { fontSize: 15, fontWeight: '600' }, refreshBtnText: { fontSize: 15, fontWeight: '600' },
}); });
+2 -18
View File
@@ -14,7 +14,7 @@ import { loadEvents, removeEvent } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core'; import { calculateCountdown } from '@timetoleave/core';
import type { Event as CalendarEvent } from '@timetoleave/core'; import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation'; import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme'; import { useColors } from '../hooks/useColors';
type ScreenProps = { type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventList'>; navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
@@ -22,28 +22,12 @@ type ScreenProps = {
}; };
export function EventListScreen({ navigation }: ScreenProps) { export function EventListScreen({ navigation }: ScreenProps) {
const { dark } = useTheme(); const colors = useColors();
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// Force countdown recalculation periodically // Force countdown recalculation periodically
const [, setTick] = useState(0); 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 reload = useCallback(async () => {
const list = await loadEvents(); const list = await loadEvents();
setEvents(list); setEvents(list);
+4 -20
View File
@@ -16,6 +16,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot
import { api } from '../services/api'; import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core'; import type { Station, ReminderSettings } from '@timetoleave/core';
import type { RootStack } from '../types/navigation'; import type { RootStack } from '../types/navigation';
import { useColors } from '../hooks/useColors';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
type ScreenProps = { type ScreenProps = {
@@ -25,6 +26,7 @@ type ScreenProps = {
export function SettingsScreen({ navigation: _navigation }: ScreenProps) { export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const { dark, toggle: toggleTheme } = useTheme(); const { dark, toggle: toggleTheme } = useTheme();
const colors = useColors();
const [origin, setOrigin] = useState<Station | null>(null); const [origin, setOrigin] = useState<Station | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]); const [results, setResults] = useState<Station[]>([]);
@@ -41,24 +43,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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 // Load persisted data on mount
useEffect(() => { useEffect(() => {
loadOriginStation().then(setOrigin); loadOriginStation().then(setOrigin);
@@ -266,7 +250,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
</TouchableOpacity> </TouchableOpacity>
))} ))}
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}> <TouchableOpacity style={[styles.locBtn, { backgroundColor: colors.highlight }]} onPress={useCurrentLocation}>
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text> <Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.locStatus, { color: colors.subtext }]}> <Text style={[styles.locStatus, { color: colors.subtext }]}>
@@ -298,7 +282,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert. Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text> </Text>
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}> <TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: colors.highlight }]} onPress={toggleAdvanced}>
<Text style={[styles.locBtnText, { color: colors.accent }]}> <Text style={[styles.locBtnText, { color: colors.accent }]}>
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'} {showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
</Text> </Text>
+25 -71
View File
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station, ReminderSettings } from '@timetoleave/core'; import type { Event, Station, ReminderSettings } from '@timetoleave/core';
import * as Notifications from '../services/expoNotifications'; import * as Notifications from '../services/expoNotifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
// ── Keys ─────────────────────────────────────────────────────── // ── Keys ───────────────────────────────────────────────────────
@@ -47,38 +48,14 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
} }
async function scheduleEventNotification(event: Event): Promise<void> { async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
const settings = await getNotificationSettings(); const REMINDERS_MIN = [30, 10, 0];
if (!settings.enabled) { const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
return; const now = new Date();
}
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); for (const minutesBefore of REMINDERS_MIN) {
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) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000); const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
if (triggerTime <= now || triggerTime < twoHoursBefore) continue;
// 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);
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {
@@ -88,12 +65,25 @@ async function scheduleEventNotification(event: Event): Promise<void> {
: `${minutesBefore} Minuten bis du losmusst`, : `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id }, data: { eventId: event.id },
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
trigger: timestampSeconds as any,
}); });
} }
} }
async function scheduleEventNotification(event: Event): Promise<void> {
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 // Events
// ─────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────
@@ -176,48 +166,12 @@ export async function saveNotificationSettings(
// ─────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────
export async function rescheduleAllNotifications(): Promise<void> { export async function rescheduleAllNotifications(): Promise<void> {
const events = await loadEvents(); const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
const settings = await loadNotificationSettings();
// Cancel ALL existing notifications first
await Notifications.cancelAllScheduledNotificationsAsync(); await Notifications.cancelAllScheduledNotificationsAsync();
if (!settings.enabled) return;
// Schedule new notifications for each event
for (const event of events) { for (const event of events) {
if (settings.enabled) {
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
await fireNotificationsForEvent(event, leaveByTime);
// 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,
});
}
}
} }
} }
+19 -33
View File
@@ -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_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp"; 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 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<string, unknown>;
}
interface HafasBody extends Record<string, unknown> {
svcReqL: HafasServiceRequest[];
}
function injectHafasAuth( function injectHafasAuth(
body: Record<string, unknown> | null | undefined, body: Record<string, unknown> | null | undefined,
): Record<string, unknown> { ): Record<string, unknown> {
@@ -118,52 +126,30 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); 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<string, unknown>).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 // Inject required HAFAS protocol fields
const hafasBody = injectHafasAuth(body as Record<string, unknown>); const hafasBody = injectHafasAuth(body as Record<string, unknown>) as HafasBody;
// Validate enriched body // Validate enriched body
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) { if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 }); return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
} }
const svcReq = hafasBody.svcReqL[0]; const svcReq = hafasBody.svcReqL[0] as HafasServiceRequest;
const allowedMethods = ["TripSearch", "LocMatch"];
if ( if (
!svcReq || !svcReq ||
typeof svcReq !== "object" || typeof svcReq.meth !== "string" ||
typeof (svcReq as Record<string, unknown>).meth !== "string" || !(ALLOWED_METHODS as readonly string[]).includes(svcReq.meth)
!allowedMethods.includes((svcReq as Record<string, unknown>).meth as string)
) { ) {
return NextResponse.json( return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` }, { error: `Invalid HAFAS method. Allowed: ${ALLOWED_METHODS.join(", ")}` },
{ status: 400 }, { status: 400 },
); );
} }
// Cap TripSearch results at 10 // Cap TripSearch results at 10
if ( if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") {
(svcReq as Record<string, unknown>).meth === "TripSearch" && svcReq.req.numF = Math.min(Number(svcReq.req.numF), 10);
(svcReq as Record<string, unknown>).req &&
typeof (svcReq as Record<string, unknown>).req === "object" &&
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF
) {
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF = Math.min(
Number(((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF),
10,
);
} }
const controller = new AbortController(); const controller = new AbortController();
+1 -3
View File
@@ -1,8 +1,6 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import type { CalendarEvent } from "@timetoleave/core"; import type { CalendarEvent } from "@timetoleave/core";
import { ApiClient } from "@timetoleave/api-client"; import { api as client } from "@/lib/api";
const client = new ApiClient();
export function useCalendar(days: number = 14) { export function useCalendar(days: number = 14) {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
+1 -3
View File
@@ -1,8 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Station } from "@timetoleave/core"; import type { Station } from "@timetoleave/core";
import { ApiClient } from "@timetoleave/api-client"; import { api as client } from "@/lib/api";
const client = new ApiClient();
interface HafasLocation { interface HafasLocation {
type: string; type: string;
+2 -5
View File
@@ -1,10 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Journey } from "@timetoleave/core"; import type { Journey } from "@timetoleave/core";
import { ApiClient } from "@timetoleave/api-client"; import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core";
import { parseHafasJourneys } from "@/lib/hafas-client"; import { api as client } from "@/lib/api";
import { hafasDateTime } from "@timetoleave/core";
const client = new ApiClient();
export function useJourneys( export function useJourneys(
fromStationExtId: string | null, fromStationExtId: string | null,
+3
View File
@@ -0,0 +1,3 @@
import { ApiClient } from "@timetoleave/api-client";
export const api = new ApiClient();
+1 -52
View File
@@ -1,6 +1,6 @@
import type { Journey, Station } from "@timetoleave/core"; import type { Journey, Station } from "@timetoleave/core";
import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants"; import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
import { parseHafasTime, hafasDateTime } from "@timetoleave/core";
import { ApiClient } from "./api-service"; 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 // HafasClient — Wraps the ÖBB HAFAS journey-planning API
// ------------------------------------------------------------- // -------------------------------------------------------------
+535
View File
@@ -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.
+11 -9
View File
@@ -8,7 +8,7 @@ import type {
Station, Station,
WienerLinienDeparture, WienerLinienDeparture,
} from "@timetoleave/core"; } from "@timetoleave/core";
import { hafasDateTime } from "@timetoleave/core"; import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
const DEFAULT_BASE_URL = ""; const DEFAULT_BASE_URL = "";
@@ -115,7 +115,6 @@ export class ApiClient {
toStationExtId: string, toStationExtId: string,
date: Date, date: Date,
): Promise<Journey[]> { ): Promise<Journey[]> {
// Build a proper HAFAS TripSearch body — the /api/hafas endpoint expects svcReqL.
const { date: hafasDate, time: hafasTime } = hafasDateTime(date); const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
const body = { const body = {
@@ -140,7 +139,8 @@ export class ApiClient {
}); });
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`); 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<GeocodeResult | null> { async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
@@ -166,18 +166,20 @@ export class ApiClient {
} }
async searchStation(query: string): Promise<Station[]> { async searchStation(query: string): Promise<Station[]> {
// Use HAFAS LocMatch to find real stations by name — returns proper extIds.
const result = await this.hafasRequest<{ const result = await this.hafasRequest<{
svcReqL?: Array<{ res?: { locL?: Station[] } }>; svcResL?: Array<{ res?: { match?: { locL?: Array<{ type: string; name: string; extId: string }> } } }>;
}>({ }>({
svcReqL: [ svcReqL: [
{ {
meth: "LocMatch", 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<{ const result = await this.hafasRequest<{
svcReqL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>; svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>;
}>({ }>({
svcReqL: [ 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; if (stations.length === 0) return null;
// Filter to only "S" (station) type results, then pick the closest // Filter to only "S" (station) type results, then pick the closest
+36
View File
@@ -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),
};
});
}
+1
View File
@@ -4,3 +4,4 @@ export * from './countdown-utils';
export * from './formatting'; export * from './formatting';
export * from './status-utils'; export * from './status-utils';
export * from './hafas-time'; export * from './hafas-time';
export * from './hafas-parser';