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 { api } from '../services/api';
interface DepartureRow {
export interface DepartureRow {
stopId: string;
lineName: 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 type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
import { useColors } from '../hooks/useColors';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
@@ -19,7 +19,7 @@ type ScreenProps = {
};
export function AddEventScreen({ navigation, route }: ScreenProps) {
const { dark } = useTheme();
const colors = useColors();
const [title, setTitle] = useState('');
const [destination, setDestination] = useState('');
const [dateStr, setDateStr] = useState('');
@@ -27,24 +27,6 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const colors = dark ? {
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
error: '#ff453a',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#B23CFF',
border: '#e5e5ea',
error: '#FF3B30',
};
// If editing an existing event, populate the form
useEffect(() => {
if (!route.params?.editEventId) return;
@@ -162,7 +144,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
</View>
{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.successCircle}>
<Text style={styles.checkmark}></Text>
@@ -15,7 +15,7 @@ import { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
import { useColors } from '../hooks/useColors';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
@@ -23,34 +23,12 @@ type ScreenProps = {
};
export function CalendarImportScreen({ navigation }: ScreenProps) {
const { dark } = useTheme();
const colors = useColors();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | 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 () => {
if (!url.trim()) {
setError('Bitte ICS-URL eingeben');
@@ -62,19 +40,26 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
setCount(null);
try {
const events = await api.fetchCalendar(url.trim());
// Add imported events to local store
const [events, existing] = await Promise.all([
api.fetchCalendar(url.trim()),
loadEvents(),
]);
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of events) {
const localEvent: CalendarEvent = {
id: evt.id,
title: evt.title,
destination: evt.destination,
eventTime: new Date(evt.eventTime),
source: `calendar:${url.trim().slice(0, 40)}`,
};
await addEvent(localEvent);
if (!existingIds.has(evt.id)) {
const localEvent: CalendarEvent = {
id: evt.id,
title: evt.title,
destination: evt.destination,
eventTime: new Date(evt.eventTime),
source: `calendar:${url.trim().slice(0, 40)}`,
};
await addEvent(localEvent);
added++;
}
}
setCount(events.length);
setCount(added);
} catch (err) {
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
} finally {
+68 -280
View File
@@ -11,14 +11,17 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
import { api } from '../services/api';
import { formatDuration, formatDistance } from '@timetoleave/core';
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
import { useDestinationStation } from '../hooks/useDestinationStation';
import { useDepartureTime } from '../hooks/useDepartureTime';
import { useGeocode } from '../hooks/useGeocode';
import { useWalkRoute } from '../hooks/useWalkRoute';
import { useWienerLinien } from '../hooks/useWienerLinien';
import { useTheme } from '../hooks/useTheme';
import { useColors } from '../hooks/useColors';
import { EventHeader } from '../components/EventHeader';
import { JourneyList } from '../components/JourneyList';
import { BikeSection } from '../components/BikeSection';
import { NearbyStops } from '../components/NearbyStops';
import type { RootStack } from '../types/navigation';
type ScreenProps = {
@@ -30,7 +33,7 @@ type TransportMode = 'train' | 'bike';
export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params;
const { dark } = useTheme();
const colors = useColors();
const [event, setEvent] = useState<CalendarEvent | null>(null);
const [journeys, setJourneys] = useState<Journey[]>([]);
@@ -46,21 +49,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
const [showBikeOption, setShowBikeOption] = useState(true);
const [showWalkingOption, setShowWalkingOption] = useState(true);
// Resolve destination text to HAFAS station ID (CRITICAL FIX)
const destStation = useDestinationStation(event?.destination);
// Geocode destination for bike/walk routes
const destCoords = useGeocode(event?.destination);
// Fetch walk route from destination station to final address
const walkHook = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
// Fetch nearby WienerLinien stops
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const fetchData = useCallback(async () => {
@@ -78,35 +74,24 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
setShowWalkingOption(settings.showWalkingOption);
const found = events.find((e) => e.id === eventId);
if (!found) {
setError('Termin nicht gefunden');
return;
}
if (!found) { setError('Termin nicht gefunden'); return; }
setEvent(found);
if (originStation) {
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
const destExtId = destStation.station?.extId;
if (destExtId) {
const results = await api.searchJourneys(
originStation.extId,
destExtId,
found.eventTime,
);
const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime);
setJourneys(results);
} else if (destStation.error) {
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
}
// Fetch bike route if we have coordinates
try {
setLoadingBike(true);
if (destCoords.coords && originStation.lat && originStation.lng) {
const bike = await api.getBikeRoute(
originStation.lat,
originStation.lng,
destCoords.coords.lat,
destCoords.coords.lng,
originStation.lat, originStation.lng,
destCoords.coords.lat, destCoords.coords.lng,
);
setBikeRoute(bike);
}
@@ -125,7 +110,6 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
useEffect(() => { fetchData(); }, [fetchData]);
// Sync walk route from hook
useEffect(() => {
setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading);
@@ -138,36 +122,17 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
fetchData();
};
// Use the shared departure time hook instead of inline calculation
const departureInfo = useDepartureTime(
event?.eventTime ?? new Date(),
journeys.length > 0 ? journeys : null,
bikeRoute?.duration ?? null,
activeMode === 'train' && journeys.length > 0 ? 'train' : (activeMode === 'bike' && bikeRoute ? 'bike' : null),
activeMode === 'train' && journeys.length > 0
? 'train'
: activeMode === 'bike' && bikeRoute
? 'bike'
: null,
arrivalBufferMinutes,
);
const leaveByTime = departureInfo.departureTime;
// Theme-based colors
const colors = dark ? {
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
warning: '#ff9f0a',
error: '#ff453a',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#B23CFF',
border: '#e5e5ea',
warning: '#FF9500',
error: '#FF3B30',
};
if (loading) {
return (
@@ -180,75 +145,38 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
);
}
// Disable bike mode if setting is off
const bikeDisabled = !showBikeOption;
const requestedMode: TransportMode = activeMode;
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
const effectiveMode: TransportMode =
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
{/* Event header */}
{event && (
<View style={[styles.header, { backgroundColor: colors.card }]}>
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text>
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text>
<Text style={[styles.eventTime, { 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>
{/* 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>
<EventHeader
event={event}
leaveByTime={departureInfo.departureTime}
arrivalBufferMinutes={arrivalBufferMinutes}
colors={colors}
/>
)}
{/* Error */}
{error && (
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<Text style={styles.errorBannerText}> {error}</Text>
<Text style={styles.bannerText}> {error}</Text>
</View>
)}
{/* Origin status */}
{!origin && !error && (
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
<Text style={styles.warningBannerText}>
Keine Ursprungstation festgelegt.
{' '}
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
<Text style={styles.bannerText}>
Keine Ursprungstation festgelegt.{' '}
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
Einstellungen öffnen
</Text>
</Text>
</View>
)}
{/* Transport mode selector */}
{origin && (
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
<TouchableOpacity
@@ -289,159 +217,52 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
)}
</View>
<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>
</TouchableOpacity>
</View>
)}
{/* Journeys list (Train mode) */}
{effectiveMode === 'train' && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
{destStation.loading && (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>
Ziel-Station wird aufgelöst
</Text>
</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>
<JourneyList
journeys={journeys}
destStationLoading={destStation.loading}
walkRoute={walkRoute}
loadingWalk={loadingWalk}
showWalkingOption={showWalkingOption}
origin={origin}
colors={colors}
/>
)}
{/* Bike route section (Bike mode) */}
{effectiveMode === 'bike' && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
{loadingBike ? (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color={colors.accent} />
<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>
<BikeSection
bikeRoute={bikeRoute}
loading={loadingBike}
origin={origin}
colors={colors}
/>
)}
{/* WienerLinien nearby stops + departures */}
{(wienerLinien.loading || wienerLinien.stops.length > 0) && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
{wienerLinien.loading ? (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen</Text>
</View>
) : wienerLinien.departures.length > 0 ? (
wienerLinien.departures.slice(0, 8).map((dep, i) => (
<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>
)}
<NearbyStops
stops={wienerLinien.stops}
departures={wienerLinien.departures}
loading={wienerLinien.loading}
error={wienerLinien.error}
colors={colors}
/>
{/* Refresh */}
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
<TouchableOpacity
style={[styles.refreshBtn, { backgroundColor: colors.border }]}
onPress={handleRefresh}
>
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
</TouchableOpacity>
{/* Bottom padding for scroll */}
<View style={{ height: 40 }} />
</ScrollView>
);
@@ -451,57 +272,24 @@ const styles = StyleSheet.create({
container: { flex: 1 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 12, fontSize: 15 },
header: { padding: 20, marginBottom: 12 },
eventTitle: { fontSize: 22, fontWeight: '700' },
eventDest: { fontSize: 16, marginTop: 4 },
eventTime: { fontSize: 14, marginTop: 8 },
source: { fontSize: 12, marginTop: 4 },
infoGrid: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#e5e5ea' },
infoBox: { alignItems: 'center' },
infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' as const, letterSpacing: 1 },
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
errorBanner: { padding: 12, marginBottom: 12 },
errorBannerText: { color: '#fff', fontSize: 14 },
warningBanner: { padding: 12, marginBottom: 12 },
warningBannerText: { color: '#fff', fontSize: 14 },
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 },
bannerText: { color: '#fff', fontSize: 14 },
bannerLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
modeSelector: {
flexDirection: 'row',
padding: 12,
gap: 12,
marginBottom: 12,
borderWidth: 1,
borderRadius: 12,
},
modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' },
modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
modeLabel: { fontSize: 15, fontWeight: '600' },
modeMeta: { fontSize: 11, marginTop: 4 },
activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' },
journeys: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
emptyText: { fontSize: 14 },
journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
lineText: { fontSize: 16, fontWeight: '600' },
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
departure: { fontSize: 13, marginTop: 6 },
arrival: { fontSize: 13, marginTop: 2 },
walkCard: { borderRadius: 10, padding: 14, marginTop: 10, borderWidth: 1 },
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
walkRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
walkLabel: { fontSize: 14, fontWeight: '500' },
walkValue: { fontSize: 14, fontWeight: '600' },
centerBike: { alignItems: 'center', gap: 8 },
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
bikeLabel: { fontSize: 15, fontWeight: '500' },
bikeValue: { fontSize: 15, fontWeight: '600' },
mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 },
mapPlaceholderText: { fontSize: 14 },
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
stopName: { fontSize: 14, fontWeight: '500' },
departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 },
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const },
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
departureDirection: { flex: 1, fontSize: 13 },
departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const },
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnText: { fontSize: 15, fontWeight: '600' },
});
+2 -18
View File
@@ -14,7 +14,7 @@ import { loadEvents, removeEvent } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core';
import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
import { useColors } from '../hooks/useColors';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
@@ -22,28 +22,12 @@ type ScreenProps = {
};
export function EventListScreen({ navigation }: ScreenProps) {
const { dark } = useTheme();
const colors = useColors();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [refreshing, setRefreshing] = useState(false);
// Force countdown recalculation periodically
const [, setTick] = useState(0);
const colors = dark ? {
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
delete: '#FF3B30',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#B23CFF',
delete: '#FF3B30',
};
const reload = useCallback(async () => {
const list = await loadEvents();
setEvents(list);
+4 -20
View File
@@ -16,6 +16,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot
import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useColors } from '../hooks/useColors';
import { useTheme } from '../hooks/useTheme';
type ScreenProps = {
@@ -25,6 +26,7 @@ type ScreenProps = {
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const { dark, toggle: toggleTheme } = useTheme();
const colors = useColors();
const [origin, setOrigin] = useState<Station | null>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]);
@@ -41,24 +43,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
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
useEffect(() => {
loadOriginStation().then(setOrigin);
@@ -266,7 +250,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
</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>
</TouchableOpacity>
<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.
</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 }]}>
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
</Text>
+26 -72
View File
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
import * as Notifications from '../services/expoNotifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
// ── Keys ───────────────────────────────────────────────────────
@@ -47,38 +48,14 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
}
async function scheduleEventNotification(event: Event): Promise<void> {
const settings = await getNotificationSettings();
if (!settings.enabled) {
return;
}
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
const REMINDERS_MIN = [30, 10, 0];
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
const now = new Date();
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
for (const notif of toCancel) {
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
}
// Default reminders: 30min, 10min, and at leave-by time
const defaultReminders = [30, 10, 0];
// Schedule notifications
for (const minutesBefore of defaultReminders) {
for (const minutesBefore of REMINDERS_MIN) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
// Skip if trigger time is in the past
if (triggerTime <= new Date()) {
continue;
}
// Skip if this would be before the event actually starts (add some safety margin)
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
continue;
}
// Use timestamp (seconds) as trigger — more reliable than Date object across versions
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
if (triggerTime <= now || triggerTime < twoHoursBefore) continue;
await Notifications.scheduleNotificationAsync({
content: {
@@ -88,12 +65,25 @@ async function scheduleEventNotification(event: Event): Promise<void> {
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: timestampSeconds as any,
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
});
}
}
async function scheduleEventNotification(event: Event): Promise<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
// ───────────────────────────────────────────────────────────────
@@ -176,48 +166,12 @@ export async function saveNotificationSettings(
// ───────────────────────────────────────────────────────────────
export async function rescheduleAllNotifications(): Promise<void> {
const events = await loadEvents();
const settings = await loadNotificationSettings();
// Cancel ALL existing notifications first
const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
await Notifications.cancelAllScheduledNotificationsAsync();
if (!settings.enabled) return;
// Schedule new notifications for each event
for (const event of events) {
if (settings.enabled) {
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
// Default reminders: 30min, 10min, and at leave-by time
const defaultReminders = [30, 10, 0];
for (const minutesBefore of defaultReminders) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
// Skip if trigger time is in the past
if (triggerTime <= new Date()) {
continue;
}
// Skip if this would be before the event actually starts (add some safety margin)
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
continue;
}
// Use timestamp (seconds) as trigger — more reliable than Date object
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Zeit zu gehen!'
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: timestampSeconds as any,
});
}
}
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
await fireNotificationsForEvent(event, leaveByTime);
}
}