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), }; }