From ce1fa4972c91ffa1b9cd8315799c4a44cd8418e7 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 18 May 2026 11:56:49 +0200 Subject: [PATCH] Account for origin walk in departure time Add useOriginStationWalk and integrate it into EventDetail/EventList to resolve walking time from origin to station. Introduce calculateLeaveByTime for notification scheduling and use exported Expo trigger types. Support HAFAS 'crd' coordinates in client and destination hooks, update tests, jest mappings, and Expo run scripts. --- apps/mobile/jest.config.cjs | 6 +- apps/mobile/package.json | 4 +- apps/mobile/src/__tests__/screens.test.tsx | 106 ++++++++++- apps/mobile/src/hooks/useDepartureTime.ts | 14 +- .../mobile/src/hooks/useDestinationStation.ts | 12 +- apps/mobile/src/hooks/useOriginStationWalk.ts | 51 +++++ apps/mobile/src/screens/EventDetailScreen.tsx | 3 + apps/mobile/src/screens/EventListScreen.tsx | 175 +++++++++++++++--- apps/mobile/src/screens/SettingsScreen.tsx | 10 +- apps/mobile/src/services/expoNotifications.ts | 1 + apps/mobile/src/services/notifications.ts | 28 +++ apps/mobile/src/store/eventStore.ts | 3 +- apps/web/src/hooks/useDestinationStation.ts | 12 +- packages/api-client/src/client.ts | 18 +- 14 files changed, 381 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/hooks/useOriginStationWalk.ts create mode 100644 apps/mobile/src/services/notifications.ts diff --git a/apps/mobile/jest.config.cjs b/apps/mobile/jest.config.cjs index 2f7ae1e..592817f 100644 --- a/apps/mobile/jest.config.cjs +++ b/apps/mobile/jest.config.cjs @@ -2,11 +2,7 @@ module.exports = { preset: 'jest-expo', testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'], moduleNameMapper: { - '^react$': '/node_modules/react', - '^react-test-renderer$': '/node_modules/react-test-renderer', - '^react-native-safe-area-context$': '/node_modules/react-native-safe-area-context', - '^react-native-screens$': '/node_modules/react-native-screens', '^@react-native-async-storage/async-storage$': - '/node_modules/@react-native-async-storage/async-storage', + '@react-native-async-storage/async-storage/jest/async-storage-mock', }, }; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4a56225..20f8ae1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,8 +5,8 @@ "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "lint": "eslint src/", diff --git a/apps/mobile/src/__tests__/screens.test.tsx b/apps/mobile/src/__tests__/screens.test.tsx index 2ac9af9..f0fa4f4 100644 --- a/apps/mobile/src/__tests__/screens.test.tsx +++ b/apps/mobile/src/__tests__/screens.test.tsx @@ -4,12 +4,14 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; import { EventListScreen } from '../screens/EventListScreen'; import { AddEventScreen } from '../screens/AddEventScreen'; -import { loadEvents } from '../store/eventStore'; +import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore'; import { calculateCountdown } from '@timetoleave/core'; // Mock the store and utilities jest.mock('../store/eventStore', () => ({ loadEvents: jest.fn(), + loadOriginStation: jest.fn(), + loadNotificationSettings: jest.fn(), removeEvent: jest.fn(), })); @@ -18,12 +20,80 @@ jest.mock('@timetoleave/core', () => ({ calculateCountdown: jest.fn(), })); +jest.mock('../hooks/useColors', () => ({ + useColors: () => ({ + background: '#000', + card: '#111', + text: '#fff', + subtext: '#aaa', + accent: '#8B5CF6', + border: '#333', + delete: '#ff3b30', + error: '#ff3b30', + overlay: '#111', + }), +})); + +jest.mock('../hooks/useDestinationStation', () => ({ + useDestinationStation: () => ({ + station: { name: 'Ziel Bahnhof', extId: '8103000', lat: 48.2, lng: 16.3 }, + loading: false, + error: null, + }), +})); + +jest.mock('../hooks/useGeocode', () => ({ + useGeocode: () => ({ + coords: { lat: 48.21, lng: 16.31, display_name: 'Test Destination' }, + loading: false, + error: null, + }), +})); + +jest.mock('../hooks/useWalkRoute', () => ({ + useWalkRoute: () => ({ + walkRoute: null, + loading: false, + error: null, + }), +})); + +jest.mock('../hooks/useOriginStationWalk', () => ({ + useOriginStationWalk: () => ({ + station: { name: 'Mödling Bahnhof', extId: '1231701', lat: 48.085, lng: 16.296 }, + walkRoute: { distance: 700, duration: 600, steps: [] }, + loading: false, + error: null, + }), +})); + +jest.mock('../services/api', () => ({ + api: { + searchJourneys: jest.fn().mockResolvedValue([ + { + id: 'journey-1', + sD: new Date('2099-01-01T08:00:00Z'), + rD: new Date('2099-01-01T08:00:00Z'), + sA: new Date('2099-01-01T09:00:00Z'), + rA: new Date('2099-01-01T09:00:00Z'), + delay: 0, + platform: '1', + changes: 0, + trains: ['S1 -> Wien'], + cancelled: false, + }, + ]), + }, +})); + // Mock useFocusEffect so EventListScreen can render without NavigationContainer jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useFocusEffect: (callback: () => void) => { - // Execute the callback immediately so the component loads data - callback(); + const React = jest.requireActual('react'); + React.useEffect(() => { + callback(); + }, [callback]); }, })); @@ -67,6 +137,19 @@ const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as un describe('EventListScreen', () => { beforeEach(() => { jest.clearAllMocks(); + (loadOriginStation as jest.Mock).mockResolvedValue({ + name: 'Mödling Bahnhof', + extId: '1231701', + lat: 48.08, + lng: 16.29, + }); + (loadNotificationSettings as jest.Mock).mockResolvedValue({ + bufferMinutes: 30, + enabled: true, + arrivalBufferMinutes: 5, + showWalkingOption: true, + showBikeOption: true, + }); }); it('should render empty state when no events', async () => { @@ -77,7 +160,7 @@ describe('EventListScreen', () => { ); await waitFor(() => { - expect(getByText('Keine Termine')).toBeTruthy(); + expect(getByText('Keine kommenden Termine')).toBeTruthy(); }); }); @@ -87,7 +170,7 @@ describe('EventListScreen', () => { id: 'test-1', title: 'Test Event', destination: 'Test Destination', - eventTime: new Date('2025-01-01T10:00:00Z'), + eventTime: new Date('2099-01-01T10:00:00Z'), source: 'manual', } ]; @@ -107,6 +190,11 @@ describe('EventListScreen', () => { expect(getByText('Test Event')).toBeTruthy(); expect(getByText('Test Destination')).toBeTruthy(); }); + await waitFor(() => { + expect(getByText('Losgehen')).toBeTruthy(); + expect(getByText('S1 -> Wien')).toBeTruthy(); + expect(getByText('Inkl. Fußweg zur Station: 10 min')).toBeTruthy(); + }); }); it('should handle refresh correctly', async () => { @@ -115,7 +203,7 @@ describe('EventListScreen', () => { id: 'test-1', title: 'Test Event', destination: 'Test Destination', - eventTime: new Date('2025-01-01T10:00:00Z'), + eventTime: new Date('2099-01-01T10:00:00Z'), source: 'manual', } ]; @@ -148,7 +236,7 @@ describe('AddEventScreen', () => { ); expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy(); - expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy(); + expect(getByPlaceholderText('z.B. Technikum Wien')).toBeTruthy(); expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy(); expect(getByPlaceholderText('SS:MM')).toBeTruthy(); expect(getByText('Speichern')).toBeTruthy(); @@ -175,7 +263,7 @@ describe('AddEventScreen', () => { // Fill in all required fields except date format is invalid fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting'); - fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien'); + fireEvent.changeText(getByPlaceholderText('z.B. Technikum Wien'), 'Wien'); fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date'); fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00'); @@ -192,7 +280,7 @@ describe('AddEventScreen', () => { // Fill in all required fields with a past date fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting'); - fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien'); + fireEvent.changeText(getByPlaceholderText('z.B. Technikum Wien'), 'Wien'); fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01'); fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00'); diff --git a/apps/mobile/src/hooks/useDepartureTime.ts b/apps/mobile/src/hooks/useDepartureTime.ts index e34a3b8..431f574 100644 --- a/apps/mobile/src/hooks/useDepartureTime.ts +++ b/apps/mobile/src/hooks/useDepartureTime.ts @@ -22,6 +22,7 @@ interface DepartureTimeResult { * @param activeMode - The currently selected transport mode. * @param arrivalBufferMinutes - Minutes to arrive before the event starts. * @param trainWalkDurationSeconds - Walking time from destination station to event (seconds). + * @param originWalkDurationSeconds - Walking time from start point to origin station (seconds). */ export function useDepartureTime( eventTime: Date, @@ -30,6 +31,7 @@ export function useDepartureTime( activeMode: 'train' | 'bike' | null, arrivalBufferMinutes: number, trainWalkDurationSeconds = 0, + originWalkDurationSeconds = 0, ): DepartureTimeResult { return useMemo(() => { // Calculate target arrival time (event time minus buffer) @@ -56,7 +58,7 @@ export function useDepartureTime( current.rD.getTime() > latest.rD.getTime() ? current : latest, ); - departureTime = new Date(bestJourney.rD); + departureTime = new Date(bestJourney.rD.getTime() - originWalkDurationSeconds * 1000); arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs); mode = 'train'; } @@ -73,5 +75,13 @@ export function useDepartureTime( } return { departureTime, arrivalTime, mode }; - }, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]); + }, [ + eventTime, + journeys, + bikeDurationSeconds, + activeMode, + arrivalBufferMinutes, + trainWalkDurationSeconds, + originWalkDurationSeconds, + ]); } diff --git a/apps/mobile/src/hooks/useDestinationStation.ts b/apps/mobile/src/hooks/useDestinationStation.ts index 318e270..c795bdc 100644 --- a/apps/mobile/src/hooks/useDestinationStation.ts +++ b/apps/mobile/src/hooks/useDestinationStation.ts @@ -14,8 +14,12 @@ interface HafasLocation { type: string; name: string; extId: string; - lat: number; - lon: number; + lat?: number; + lon?: number; + crd?: { + x?: number; + y?: number; + }; } /** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */ @@ -84,8 +88,8 @@ export function useDestinationStation(destination: string | undefined) { .map((l) => ({ name: l.name, extId: l.extId, - lat: normalizeHafasCoordinate(l.lat), - lng: normalizeHafasCoordinate(l.lon), + lat: normalizeHafasCoordinate(l.lat ?? l.crd?.y), + lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x), })); if (!isMounted) return; diff --git a/apps/mobile/src/hooks/useOriginStationWalk.ts b/apps/mobile/src/hooks/useOriginStationWalk.ts new file mode 100644 index 0000000..7be87d8 --- /dev/null +++ b/apps/mobile/src/hooks/useOriginStationWalk.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import type { Station } from '@timetoleave/core'; +import { api } from '../services/api'; +import { useWalkRoute } from './useWalkRoute'; + +/** + * Resolve the walking leg from the saved origin point to the departure station. + * + * `origin.lat/lng` may represent the user's real start point while `origin.extId` + * identifies the station used for train search. When coordinates are missing or + * station resolution fails, callers can safely fall back to zero duration. + */ +export function useOriginStationWalk(origin: Station | null) { + const [station, setStation] = useState(null); + const [lookupError, setLookupError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const resolveStation = async () => { + setStation(null); + setLookupError(null); + + if (origin?.lat == null || origin.lng == null) return; + + try { + const nearest = await api.findNearestStationByCoords(origin.lat, origin.lng); + if (!isMounted) return; + setStation(nearest); + } catch (err) { + if (!isMounted) return; + setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed'); + } + }; + + resolveStation(); + + return () => { + isMounted = false; + }; + }, [origin?.lat, origin?.lng]); + + const walk = useWalkRoute(origin?.lat, origin?.lng, station?.lat, station?.lng); + + return { + station, + walkRoute: walk.walkRoute, + loading: walk.loading, + error: lookupError ?? walk.error, + }; +} diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx index 69d87c0..b285aa1 100644 --- a/apps/mobile/src/screens/EventDetailScreen.tsx +++ b/apps/mobile/src/screens/EventDetailScreen.tsx @@ -15,6 +15,7 @@ import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } f import { useDestinationStation } from '../hooks/useDestinationStation'; import { useDepartureTime } from '../hooks/useDepartureTime'; import { useGeocode } from '../hooks/useGeocode'; +import { useOriginStationWalk } from '../hooks/useOriginStationWalk'; import { useWalkRoute } from '../hooks/useWalkRoute'; import { useWienerLinien } from '../hooks/useWienerLinien'; import { useColors } from '../hooks/useColors'; @@ -70,6 +71,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { destCoords.coords?.lat, destCoords.coords?.lng, ); + const originWalk = useOriginStationWalk(origin); const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); const fetchData = useCallback(async () => { @@ -160,6 +162,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { : null, arrivalBufferMinutes, showWalkingOption ? (walkRoute?.duration ?? 0) : 0, + originWalk.walkRoute?.duration ?? 0, ); if (loading) { diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx index 71f3f20..88ed91c 100644 --- a/apps/mobile/src/screens/EventListScreen.tsx +++ b/apps/mobile/src/screens/EventListScreen.tsx @@ -10,11 +10,17 @@ import { import { useFocusEffect } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; -import { loadEvents, removeEvent } from '../store/eventStore'; -import { calculateCountdown } from '@timetoleave/core'; -import type { Event as CalendarEvent } from '@timetoleave/core'; +import { loadEvents, loadNotificationSettings, loadOriginStation, removeEvent } from '../store/eventStore'; +import { calculateCountdown, formatTime } from '@timetoleave/core'; +import type { Event as CalendarEvent, Journey, Station } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; import { useColors } from '../hooks/useColors'; +import { useDepartureTime } from '../hooks/useDepartureTime'; +import { useDestinationStation } from '../hooks/useDestinationStation'; +import { useGeocode } from '../hooks/useGeocode'; +import { useOriginStationWalk } from '../hooks/useOriginStationWalk'; +import { useWalkRoute } from '../hooks/useWalkRoute'; +import { api } from '../services/api'; type ScreenProps = { navigation: NativeStackNavigationProp; @@ -29,13 +35,26 @@ type ScreenProps = { export function EventListScreen({ navigation }: ScreenProps) { const colors = useColors(); const [events, setEvents] = useState([]); + const [origin, setOrigin] = useState(null); + const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5); + const [showWalkingOption, setShowWalkingOption] = useState(true); + const [journeys, setJourneys] = useState([]); + const [journeysLoading, setJourneysLoading] = useState(false); + const [journeysError, setJourneysError] = useState(null); const [refreshing, setRefreshing] = useState(false); // Force countdown recalculation periodically const [, setTick] = useState(0); const reload = useCallback(async () => { - const list = await loadEvents(); + const [list, originStation, settings] = await Promise.all([ + loadEvents(), + loadOriginStation(), + loadNotificationSettings(), + ]); setEvents(list); + setOrigin(originStation); + setArrivalBufferMinutes(settings.arrivalBufferMinutes); + setShowWalkingOption(settings.showWalkingOption); }, []); useEffect(() => { reload(); }, [reload]); @@ -63,8 +82,100 @@ export function EventListScreen({ navigation }: ScreenProps) { .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime())[0] ?? null; }, [events]); + const destStation = useDestinationStation(upcomingEvent?.destination); + const destCoords = useGeocode(upcomingEvent?.destination); + const walkHook = useWalkRoute( + destStation.station?.lat, + destStation.station?.lng, + destCoords.coords?.lat, + destCoords.coords?.lng, + ); + const destinationStationExtId = destStation.station?.extId; + const destinationStationLat = destStation.station?.lat; + const destinationStationLng = destStation.station?.lng; + const destinationLat = destCoords.coords?.lat; + const destinationLng = destCoords.coords?.lng; + const originExtId = origin?.extId; + const originWalk = useOriginStationWalk(origin); + const originWalkDurationSeconds = originWalk.walkRoute?.duration ?? 0; + const walkDurationSeconds = showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0; + const departureInfo = useDepartureTime( + upcomingEvent?.eventTime ?? new Date(), + journeys.length > 0 ? journeys : null, + null, + journeys.length > 0 ? 'train' : null, + arrivalBufferMinutes, + walkDurationSeconds, + originWalkDurationSeconds, + ); + const selectedJourney = useMemo(() => { + if (!departureInfo.departureTime) return null; + const trainDepartureTime = departureInfo.departureTime.getTime() + originWalkDurationSeconds * 1000; + return journeys.find((journey) => journey.rD.getTime() === trainDepartureTime) ?? null; + }, [departureInfo.departureTime, journeys, originWalkDurationSeconds]); + + useEffect(() => { + let isMounted = true; + + const fetchJourneys = async () => { + if (!upcomingEvent || !originExtId || !destinationStationExtId) return; + + const finalWalkPending = + showWalkingOption && + destinationLat != null && + destinationLng != null && + destinationStationLat != null && + destinationStationLng != null && + walkHook.loading && + !walkHook.walkRoute && + !walkHook.error; + if (finalWalkPending) return; + + setJourneys((current) => (current.length > 0 ? [] : current)); + setJourneysError((current) => (current === null ? current : null)); + setJourneysLoading(true); + try { + const target = new Date( + upcomingEvent.eventTime.getTime() - + arrivalBufferMinutes * 60_000 - + walkDurationSeconds * 1000, + ); + const results = await api.searchJourneys(originExtId, destinationStationExtId, target, { arriveBy: true }); + if (!isMounted) return; + setJourneys(results); + } catch (err) { + if (!isMounted) return; + setJourneysError(err instanceof Error ? err.message : 'Verbindungen konnten nicht geladen werden'); + } finally { + if (isMounted) setJourneysLoading(false); + } + }; + + fetchJourneys(); + + return () => { + isMounted = false; + }; + }, [ + upcomingEvent, + originExtId, + destinationStationExtId, + destinationStationLat, + destinationStationLng, + destinationLat, + destinationLng, + walkHook.loading, + walkHook.walkRoute, + walkHook.error, + showWalkingOption, + arrivalBufferMinutes, + walkDurationSeconds, + ]); + const renderItem = ({ item }: { item: CalendarEvent }) => { const countdown = calculateCountdown(item.eventTime); + const leaveBy = departureInfo.departureTime; + const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Zugverbindung wird gesucht'; // Derive a simple status — journeys aren't loaded on the list screen for MVP // so we show countdown-based status instead @@ -86,8 +197,36 @@ export function EventListScreen({ navigation }: ScreenProps) { {item.destination} - - {item.eventTime.toLocaleString('de-AT', { + Losgehen + + {leaveBy ? formatTime(leaveBy) : journeysLoading || destStation.loading ? '--:--' : 'Keine Verbindung'} + + + + {journeysError ? 'Zugverbindung nicht erreichbar' : trainLabel} + + {selectedJourney ? ( + + Abfahrt {formatTime(selectedJourney.rD)} + {selectedJourney.platform ? ` · Gleis ${selectedJourney.platform}` : ''} + {' · '} + Ankunft {formatTime(selectedJourney.rA)} + {' · '} + {selectedJourney.changes === 0 ? 'Direkt' : `${selectedJourney.changes} Umstiege`} + + ) : ( + + {journeysError ?? (origin ? 'Beste Verbindung für den nächsten Termin' : 'Ursprungstation festlegen')} + + )} + {originWalk.walkRoute && originWalk.walkRoute.duration > 30 && ( + + Inkl. Fußweg zur Station: {Math.ceil(originWalk.walkRoute.duration / 60)} min + + )} + + + Termin: {item.eventTime.toLocaleString('de-AT', { day: '2-digit', month: '2-digit', hour: '2-digit', @@ -103,7 +242,7 @@ export function EventListScreen({ navigation }: ScreenProps) { onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })} style={styles.editBtn} > - ✏️ Bearbeiten + Bearbeiten {/* Delete button */} @@ -130,20 +269,6 @@ export function EventListScreen({ navigation }: ScreenProps) { return ( - - navigation.navigate('CalendarImport')} - style={styles.topBtn} - > - 📅 Kalender - - navigation.navigate('Settings')} - style={styles.topBtn} - > - ⚙️ Einstellungen - - item.id} @@ -165,9 +290,6 @@ export function EventListScreen({ navigation }: ScreenProps) { const styles = StyleSheet.create({ container: { flex: 1 }, - topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 }, - topBtn: { paddingHorizontal: 12, paddingVertical: 6 }, - topBtnText: { fontSize: 15 }, list: { padding: 12 }, cardWrapper: { marginBottom: 12 }, card: { @@ -185,6 +307,11 @@ const styles = StyleSheet.create({ badge: { fontSize: 12, fontWeight: '600' }, subtitle: { fontSize: 14, marginBottom: 4 }, time: { fontSize: 13 }, + leaveLabel: { fontSize: 12, fontWeight: '600', marginTop: 18, textTransform: 'uppercase' }, + leaveTime: { fontSize: 52, lineHeight: 58, fontWeight: '800', marginTop: 2, marginBottom: 12 }, + trainBox: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 12 }, + trainTitle: { fontSize: 16, fontWeight: '700' }, + trainMeta: { fontSize: 13, marginTop: 6, lineHeight: 18 }, status: { fontSize: 13, marginTop: 2, fontWeight: '500' }, editBtn: { alignSelf: 'flex-start', marginTop: 4 }, editText: { fontSize: 13, fontWeight: '500' }, diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 1952bdc..390b47e 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -138,8 +138,8 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const station: Station = { name: closest.name, extId: closest.id, - lat: closest.lat, - lng: closest.lng, + lat: userLat, + lng: userLng, }; await selectStation(station); return; @@ -152,7 +152,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); return; } - await selectStation(nearestStation); + await selectStation({ + ...nearestStation, + lat: userLat, + lng: userLng, + }); return; } catch { // API not reachable diff --git a/apps/mobile/src/services/expoNotifications.ts b/apps/mobile/src/services/expoNotifications.ts index 5d4b827..38f13af 100644 --- a/apps/mobile/src/services/expoNotifications.ts +++ b/apps/mobile/src/services/expoNotifications.ts @@ -17,6 +17,7 @@ export { cancelScheduledNotificationAsync, cancelAllScheduledNotificationsAsync, scheduleNotificationAsync, + SchedulableTriggerInputTypes, } from 'expo-notifications'; // Re-export types from the public package diff --git a/apps/mobile/src/services/notifications.ts b/apps/mobile/src/services/notifications.ts new file mode 100644 index 0000000..8982e23 --- /dev/null +++ b/apps/mobile/src/services/notifications.ts @@ -0,0 +1,28 @@ +import type { Event, Journey } from '@timetoleave/core'; + +/** + * Calculate the time at which a leave reminder should fire. + * + * If journey data is available, use the earliest non-cancelled departure and + * subtract the reminder buffer. Otherwise, fall back to event time minus the + * requested arrival buffer and reminder buffer. + */ +export function calculateLeaveByTime( + event: Event, + journeys: Journey[], + arrivalBufferMinutes: number, + reminderBufferMinutes: number, +): Date { + const validJourneys = journeys.filter((journey) => !journey.cancelled); + + if (validJourneys.length > 0) { + const earliestDeparture = validJourneys.reduce((earliest, journey) => + journey.rD.getTime() < earliest.rD.getTime() ? journey : earliest, + ); + + return new Date(earliestDeparture.rD.getTime() - reminderBufferMinutes * 60_000); + } + + const totalBufferMs = (arrivalBufferMinutes + reminderBufferMinutes) * 60_000; + return new Date(event.eventTime.getTime() - totalBufferMs); +} diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts index e0f95a6..474dfbe 100644 --- a/apps/mobile/src/store/eventStore.ts +++ b/apps/mobile/src/store/eventStore.ts @@ -9,7 +9,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core'; import * as Notifications from '../services/expoNotifications'; -import { SchedulableTriggerInputTypes } from 'expo-notifications'; // ── Keys ─────────────────────────────────────────────────────── @@ -89,7 +88,7 @@ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promi : `${minutesBefore} Minuten bis du losmusst`, data: { eventId: event.id }, }, - trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: triggerTime }, }); } } diff --git a/apps/web/src/hooks/useDestinationStation.ts b/apps/web/src/hooks/useDestinationStation.ts index dbe570a..31d2c33 100644 --- a/apps/web/src/hooks/useDestinationStation.ts +++ b/apps/web/src/hooks/useDestinationStation.ts @@ -6,8 +6,12 @@ interface HafasLocation { type: string; name: string; extId: string; - lat: number; - lon: number; + lat?: number; + lon?: number; + crd?: { + x?: number; + y?: number; + }; } function normalizeHafasCoordinate(value: number | undefined): number | undefined { @@ -81,8 +85,8 @@ export function useDestinationStation(destination: string) { .map((l) => ({ name: l.name, extId: l.extId, - lat: normalizeHafasCoordinate(l.lat), - lng: normalizeHafasCoordinate(l.lon), + lat: normalizeHafasCoordinate(l.lat ?? l.crd?.y), + lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x), })); if (!isMounted) return; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 0cc5d51..c0328fb 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -249,7 +249,11 @@ export class ApiClient { async findNearestStationByCoords(lat: number, lng: number): Promise { interface HafasLocation extends Station { type: string; - lon: number; + lon?: number; + crd?: { + x?: number; + y?: number; + }; } const result = await this.hafasRequest<{ @@ -284,10 +288,10 @@ export class ApiClient { // Find the closest station by Euclidean distance const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => { - const bestLat = normalizeHafasCoordinate(best.lat) ?? lat; - const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon) ?? lng; - const candidateLat = normalizeHafasCoordinate(candidate.lat) ?? lat; - const candidateLng = normalizeHafasCoordinate(candidate.lng ?? candidate.lon) ?? lng; + const bestLat = normalizeHafasCoordinate(best.lat ?? best.crd?.y) ?? lat; + const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon ?? best.crd?.x) ?? lng; + const candidateLat = normalizeHafasCoordinate(candidate.lat ?? candidate.crd?.y) ?? lat; + const candidateLng = normalizeHafasCoordinate(candidate.lng ?? candidate.lon ?? candidate.crd?.x) ?? lng; const bestDist = Math.hypot(bestLat - lat, bestLng - lng); const candDist = Math.hypot(candidateLat - lat, candidateLng - lng); return candDist < bestDist ? candidate : best; @@ -296,8 +300,8 @@ export class ApiClient { return { name: closest.name, extId: closest.extId, - lat: normalizeHafasCoordinate(closest.lat), - lng: normalizeHafasCoordinate(closest.lng ?? closest.lon), + lat: normalizeHafasCoordinate(closest.lat ?? closest.crd?.y), + lng: normalizeHafasCoordinate(closest.lng ?? closest.lon ?? closest.crd?.x), }; }