From 9e461aab1a29c954cb9692366a7b3b9e2b56e608 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 13 May 2026 09:45:55 +0200 Subject: [PATCH] Switch mobile app to dark theme and fix calendar filtering Switch mobile app to dark theme and fix calendar filtering - Default to dark theme in mobile app, matching web app style - Filter native calendar events by location to exclude empty ones - Add batch edit panel for destinations on web calendar - Add edit support to AddEventModal - Update notification trigger input type export --- apps/mobile/app.json | 17 +- apps/mobile/package.json | 1 + apps/mobile/src/__tests__/calendar.test.ts | 42 +++-- apps/mobile/src/hooks/useDepartureTime.ts | 1 - apps/mobile/src/hooks/useTheme.ts | 4 +- apps/mobile/src/hooks/useWienerLinien.ts | 62 +++---- apps/mobile/src/navigation/AppNavigator.tsx | 6 +- apps/mobile/src/screens/AddEventScreen.tsx | 59 ++++++- .../src/screens/CalendarImportScreen.tsx | 18 +- apps/mobile/src/screens/EventDetailScreen.tsx | 41 ++++- apps/mobile/src/screens/EventListScreen.tsx | 18 +- apps/mobile/src/screens/SettingsScreen.tsx | 47 ++++-- apps/mobile/src/services/calendar.ts | 16 +- apps/mobile/src/services/expoNotifications.ts | 2 +- apps/mobile/src/types/navigation.ts | 2 +- apps/web/src/app/add-event/AddEventModal.tsx | 120 +++++++------ apps/web/src/app/calendar/BatchEditPanel.tsx | 158 ++++++++++++++++++ apps/web/src/app/calendar/page.tsx | 4 +- apps/web/src/app/event/EventCard.tsx | 32 +++- .../app/event/__tests__/EventCard.test.tsx | 12 ++ package-lock.json | 118 +++++++++++++ packages/api-client/src/client.ts | 20 ++- 22 files changed, 632 insertions(+), 168 deletions(-) create mode 100644 apps/web/src/app/calendar/BatchEditPanel.tsx diff --git a/apps/mobile/app.json b/apps/mobile/app.json index abe63e0..6be9a10 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -5,12 +5,12 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "dark", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#007AFF" + "backgroundColor": "#090816" }, "ios": { "supportsTablet": true, @@ -23,7 +23,7 @@ "android": { "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#007AFF" + "backgroundColor": "#090816" }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, @@ -31,7 +31,8 @@ "permissions": [ "android.permission.ACCESS_FINE_LOCATION", "android.permission.POST_NOTIFICATIONS", - "android.permission.INTERNET" + "android.permission.INTERNET", + "android.permission.ACCESS_COARSE_LOCATION" ] }, "web": { @@ -41,6 +42,12 @@ "expo-location", "expo-notifications" ], - "privacyPolicyUrl": "https://timetoleave.app/privacy-policy" + "privacyPolicyUrl": "https://timetoleave.app/privacy-policy", + "extra": { + "eas": { + "projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec" + } + }, + "owner": "floegger" } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e0a4a08..0ad7b67 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "@timetoleave/core": "*", "expo": "~54.0.33", "expo-calendar": "~15.0.8", + "expo-dev-client": "~6.0.21", "expo-location": "~19.0.8", "expo-notifications": "~0.32.17", "expo-status-bar": "~3.0.9", diff --git a/apps/mobile/src/__tests__/calendar.test.ts b/apps/mobile/src/__tests__/calendar.test.ts index b83f0a1..cc82a26 100644 --- a/apps/mobile/src/__tests__/calendar.test.ts +++ b/apps/mobile/src/__tests__/calendar.test.ts @@ -91,7 +91,8 @@ describe('calendar service', () => { const result = await fetchNativeEvents(startDate, endDate); - expect(result).toHaveLength(2); + // Only the event with a location is returned; events without a location are filtered out + expect(result).toHaveLength(1); expect(result[0]).toEqual({ id: 'evt1', title: 'Team Meeting', @@ -99,13 +100,6 @@ describe('calendar service', () => { eventTime: new Date('2025-01-15T10:00:00'), source: 'native:cal1', }); - expect(result[1]).toEqual({ - id: 'evt2', - title: 'Dentist', - destination: '', - eventTime: new Date('2025-01-20T14:00:00'), - source: 'native:cal2', - }); expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith( ['cal1', 'cal2'], @@ -114,7 +108,33 @@ describe('calendar service', () => { ); }); - it('handles events with missing title or startDate', async () => { + it('filters out events with no location', async () => { + mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true }); + mockCalendar.isAvailableAsync.mockResolvedValue(true); + mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]); + mockCalendar.getEventsAsync.mockResolvedValue([ + { + id: 'evt1', + calendarId: 'cal1', + title: 'No Location Event', + location: null, + startDate: new Date('2025-01-15T10:00:00'), + }, + { + id: 'evt2', + calendarId: 'cal1', + title: 'Empty Location Event', + location: ' ', + startDate: new Date('2025-01-16T10:00:00'), + }, + ] as Calendar.Event[]); + + const result = await fetchNativeEvents(new Date(), new Date()); + + expect(result).toHaveLength(0); + }); + + it('handles events with missing title', async () => { mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true }); mockCalendar.isAvailableAsync.mockResolvedValue(true); mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]); @@ -123,7 +143,7 @@ describe('calendar service', () => { id: 'evt1', calendarId: 'cal1', title: null as unknown as string, - location: null, + location: 'Wien Hbf', startDate: null as unknown as string | Date, }, ] as Calendar.Event[]); @@ -132,7 +152,7 @@ describe('calendar service', () => { expect(result).toHaveLength(1); expect(result[0].title).toBe('Untitled Event'); - expect(result[0].destination).toBe(''); + expect(result[0].destination).toBe('Wien Hbf'); }); }); }); diff --git a/apps/mobile/src/hooks/useDepartureTime.ts b/apps/mobile/src/hooks/useDepartureTime.ts index bf3a092..c1f24ff 100644 --- a/apps/mobile/src/hooks/useDepartureTime.ts +++ b/apps/mobile/src/hooks/useDepartureTime.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import type { Journey } from '@timetoleave/core'; -import { loadNotificationSettings } from '../store/eventStore'; interface DepartureTimeResult { departureTime: Date | null; diff --git a/apps/mobile/src/hooks/useTheme.ts b/apps/mobile/src/hooks/useTheme.ts index bfd96c1..8875211 100644 --- a/apps/mobile/src/hooks/useTheme.ts +++ b/apps/mobile/src/hooks/useTheme.ts @@ -7,8 +7,8 @@ type Theme = 'dark' | 'light'; function getDefaultTheme(): Theme { // React Native doesn't have window.matchMedia, but we can use a simple default - // In practice, we'd use useColorScheme from react-native for system preference - return 'light'; + // The web app uses a dark-first theme, so we match that default + return 'dark'; } /** diff --git a/apps/mobile/src/hooks/useWienerLinien.ts b/apps/mobile/src/hooks/useWienerLinien.ts index 0d812b5..60b0303 100644 --- a/apps/mobile/src/hooks/useWienerLinien.ts +++ b/apps/mobile/src/hooks/useWienerLinien.ts @@ -22,10 +22,6 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow { }; } -/** - * Fetch nearby WienerLinien stops and their departures. - * Mirrors the web app's useWienerLinien hook, adapted for mobile API client. - */ export function useWienerLinien( lat: number | undefined, lng: number | undefined, @@ -37,37 +33,38 @@ export function useWienerLinien( const [error, setError] = useState(null); const stopIdsRef = useRef([]); - const abortRef = useRef(null); const cancelledRef = useRef(false); - // Effect for fetching stops and initial departures + const fetchMonitor = useCallback(async (stopIds: string[]): Promise => { + if (stopIds.length === 0 || cancelledRef.current) return; + try { + const rawDepartures = await api.monitorStops(stopIds); + if (!cancelledRef.current) { + setDepartures(rawDepartures.map(transformDeparture)); + } + } catch { + // Silently ignore monitor errors — stops are still shown + } + }, []); + useEffect(() => { cancelledRef.current = false; - const resetState = () => { + if (lat === undefined || lng === undefined) { setStops([]); setDepartures([]); setError(null); setLoading(false); - }; - - if (lat === undefined || lng === undefined) { - resetState(); return; } const debounceTimer = setTimeout(async () => { if (cancelledRef.current) return; - abortRef.current?.abort(); - const abortController = new AbortController(); - abortRef.current = abortController; - setLoading(true); setError(null); try { - // Fetch nearby stops const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500); if (cancelledRef.current) return; @@ -78,20 +75,10 @@ export function useWienerLinien( const ids = stopsList.map((s) => s.id); stopIdsRef.current = ids; - // Chain monitor fetch for departures - if (ids.length > 0) { - try { - // Fetch departures for each stop - note: mobile API client doesn't have - // a direct monitor endpoint, so we skip this for now - // The web app uses an internal API route for this - } catch { - // Silently ignore departure fetch errors - } - } + await fetchMonitor(ids); } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; if (cancelledRef.current) return; - setError(err instanceof Error ? err.message : 'An unexpected error occurred'); + setError(err instanceof Error ? err.message : 'Haltestellen konnten nicht geladen werden'); setLoading(false); } }, DEBOUNCE_MS); @@ -99,25 +86,22 @@ export function useWienerLinien( return () => { cancelledRef.current = true; clearTimeout(debounceTimer); - abortRef.current?.abort(); - abortRef.current = null; }; - }, [lat, lng, radius]); + }, [lat, lng, radius, fetchMonitor]); - // Effect for periodic departures refresh + // Periodic departures refresh useEffect(() => { if (stops.length === 0) return; - const intervalId = setInterval(async () => { - const currentIds = stopIdsRef.current; - if (currentIds.length === 0) return; - - // Refresh logic would go here if we had the monitor API - // For now, this is a placeholder for future implementation + const intervalId = setInterval(() => { + const ids = stopIdsRef.current; + if (ids.length > 0) { + fetchMonitor(ids); + } }, REFRESH_INTERVAL_MS); return () => clearInterval(intervalId); - }, [stops.length]); + }, [stops.length, fetchMonitor]); return { stops, departures, loading, error }; } diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx index d30aa82..8db0d22 100644 --- a/apps/mobile/src/navigation/AppNavigator.tsx +++ b/apps/mobile/src/navigation/AppNavigator.tsx @@ -16,12 +16,12 @@ const Root = createNativeStackNavigator(); export default function AppNavigator() { return ( - - + + diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx index 19e366c..2599c9a 100644 --- a/apps/mobile/src/screens/AddEventScreen.tsx +++ b/apps/mobile/src/screens/AddEventScreen.tsx @@ -25,13 +25,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { const [dateStr, setDateStr] = useState(''); const [timeStr, setTimeStr] = useState(''); const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); const colors = dark ? { - background: '#1c1c1e', - card: '#2c2c2e', - text: '#f2f2f2', - subtext: '#aeaeb2', - accent: '#0a84ff', + background: '#090816', + card: '#17112A', + text: '#F4F1EA', + subtext: 'rgba(244,241,234,0.5)', + accent: '#8B5CF6', border: '#38383a', error: '#ff453a', } : { @@ -39,7 +40,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { card: '#ffffff', text: '#1c1c1e', subtext: '#8e8e93', - accent: '#007AFF', + accent: '#B23CFF', border: '#e5e5ea', error: '#FF3B30', }; @@ -97,11 +98,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) { await addEvent(event); } - navigation.goBack(); + setSuccess(true); + setTimeout(() => { + navigation.goBack(); + }, 1500); }; return ( - + Titel Abbrechen + + {success && ( + + + + + + Erfolg! + + {route.params?.editEventId ? 'Termin aktualisiert' : 'Termin hinzugefügt'} + + + + )} ); } @@ -174,7 +192,7 @@ const styles = StyleSheet.create({ }, errorText: { fontSize: 14, marginBottom: 8 }, saveBtn: { - backgroundColor: '#007AFF', + backgroundColor: '#8B5CF6', paddingVertical: 14, borderRadius: 12, alignItems: 'center', @@ -183,4 +201,27 @@ const styles = StyleSheet.create({ saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, cancelBtn: { marginTop: 12 }, cancelText: { fontSize: 16, fontWeight: '600' }, + successOverlay: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 20, + paddingBottom: 40, + borderTopWidth: 1, + borderTopColor: '#34C759', + alignItems: 'center', + }, + successContent: { alignItems: 'center', gap: 8 }, + successCircle: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#34C759', + justifyContent: 'center', + alignItems: 'center', + }, + checkmark: { color: '#fff', fontSize: 24, fontWeight: 'bold' }, + successTitle: { fontSize: 18, fontWeight: '600' }, + successSubtitle: { fontSize: 14 }, }); diff --git a/apps/mobile/src/screens/CalendarImportScreen.tsx b/apps/mobile/src/screens/CalendarImportScreen.tsx index bbcd427..ea50d8a 100644 --- a/apps/mobile/src/screens/CalendarImportScreen.tsx +++ b/apps/mobile/src/screens/CalendarImportScreen.tsx @@ -30,25 +30,25 @@ export function CalendarImportScreen({ navigation }: ScreenProps) { const [count, setCount] = useState(null); const colors = dark ? { - background: '#1c1c1e', - card: '#2c2c2e', - text: '#f2f2f2', - subtext: '#aeaeb2', - accent: '#0a84ff', + background: '#090816', + card: '#17112A', + text: '#F4F1EA', + subtext: 'rgba(244,241,234,0.5)', + accent: '#8B5CF6', border: '#38383a', error: '#ff453a', success: '#30d158', - purple: '#bf5af2', + purple: '#B23CFF', } : { background: '#f2f2f7', card: '#ffffff', text: '#1c1c1e', subtext: '#8e8e93', - accent: '#007AFF', + accent: '#B23CFF', border: '#e5e5ea', error: '#FF3B30', success: '#34C759', - purple: '#5856D6', + purple: '#8B5CF6', }; const handleImport = async () => { @@ -209,7 +209,7 @@ const styles = StyleSheet.create({ successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 }, successText: { color: '#fff', fontSize: 14 }, importBtn: { - backgroundColor: '#007AFF', + backgroundColor: '#8B5CF6', paddingVertical: 14, borderRadius: 12, alignItems: 'center', diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx index 6cca58f..5078fe5 100644 --- a/apps/mobile/src/screens/EventDetailScreen.tsx +++ b/apps/mobile/src/screens/EventDetailScreen.tsx @@ -150,11 +150,11 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { // Theme-based colors const colors = dark ? { - background: '#1c1c1e', - card: '#2c2c2e', - text: '#f2f2f2', - subtext: '#aeaeb2', - accent: '#0a84ff', + background: '#090816', + card: '#17112A', + text: '#F4F1EA', + subtext: 'rgba(244,241,234,0.5)', + accent: '#8B5CF6', border: '#38383a', warning: '#ff9f0a', error: '#ff453a', @@ -163,7 +163,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { card: '#ffffff', text: '#1c1c1e', subtext: '#8e8e93', - accent: '#007AFF', + accent: '#B23CFF', border: '#e5e5ea', warning: '#FF9500', error: '#FF3B30', @@ -395,8 +395,8 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { )} - {/* WienerLinien nearby stops */} - {wienerLinien.stops.length > 0 && ( + {/* WienerLinien nearby stops + departures */} + {(wienerLinien.loading || wienerLinien.stops.length > 0) && ( 🚏 ÖPNV in der Nähe des Ziels {wienerLinien.loading ? ( @@ -404,6 +404,25 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { Haltestellen werden geladen… + ) : wienerLinien.departures.length > 0 ? ( + wienerLinien.departures.slice(0, 8).map((dep, i) => ( + + + + {dep.lineName} + + + {dep.direction} + + + {dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`} + + + + )) ) : ( wienerLinien.stops.slice(0, 5).map((stop) => ( @@ -477,6 +496,12 @@ const styles = StyleSheet.create({ mapPlaceholderText: { fontSize: 14 }, stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 }, stopName: { fontSize: 14, fontWeight: '500' }, + departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 }, + departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 }, + lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const }, + lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' }, + departureDirection: { flex: 1, fontSize: 13 }, + departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const }, refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, refreshBtnText: { fontSize: 15, fontWeight: '600' }, }); diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx index 395720a..8d0dfc1 100644 --- a/apps/mobile/src/screens/EventListScreen.tsx +++ b/apps/mobile/src/screens/EventListScreen.tsx @@ -29,18 +29,18 @@ export function EventListScreen({ navigation }: ScreenProps) { const [, setTick] = useState(0); const colors = dark ? { - background: '#1c1c1e', - card: '#2c2c2e', - text: '#f2f2f2', - subtext: '#aeaeb2', - accent: '#0a84ff', - delete: '#ff453a', + 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: '#007AFF', + accent: '#B23CFF', delete: '#FF3B30', }; @@ -197,7 +197,7 @@ const styles = StyleSheet.create({ deleteText: { fontSize: 13 }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, empty: { fontSize: 20, marginBottom: 16 }, - addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, + addBtn: { backgroundColor: '#8B5CF6', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, fab: { position: 'absolute', @@ -206,7 +206,7 @@ const styles = StyleSheet.create({ width: 56, height: 56, borderRadius: 28, - backgroundColor: '#007AFF', + backgroundColor: '#8B5CF6', justifyContent: 'center', alignItems: 'center', shadowColor: '#000', diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 3f4f9e7..780eeb9 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -29,6 +29,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); const [notifSettings, setNotifSettings] = useState({ bufferMinutes: 30, enabled: true, @@ -41,11 +42,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const searchTimerRef = useRef | null>(null); const colors = dark ? { - background: '#1c1c1e', - card: '#2c2c2e', - text: '#f2f2f2', - subtext: '#aeaeb2', - accent: '#0a84ff', + background: '#090816', + card: '#17112A', + text: '#F4F1EA', + subtext: 'rgba(244,241,234,0.5)', + accent: '#8B5CF6', border: '#38383a', success: '#30d158', } : { @@ -53,7 +54,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { card: '#ffffff', text: '#1c1c1e', subtext: '#8e8e93', - accent: '#007AFF', + accent: '#B23CFF', border: '#e5e5ea', success: '#34C759', }; @@ -72,14 +73,18 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const searchStation = useCallback(async (q: string) => { if (q.trim().length < 2) { setResults([]); + setSearchError(null); return; } setSearching(true); + setSearchError(null); try { const stations = await api.searchStation(q.trim()); setResults(stations); + if (stations.length === 0) setSearchError('Keine Stationen gefunden.'); } catch { setResults([]); + setSearchError('API nicht erreichbar. Läuft der Server auf deinem Gerät? Überprüfe EXPO_PUBLIC_API_BASE_URL in .env.'); } finally { setSearching(false); } @@ -87,6 +92,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const onQueryChange = (text: string) => { setQuery(text); + setSearchError(null); // Proper debounce using useRef — no `any` if (searchTimerRef.current) clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => searchStation(text), 400); @@ -121,8 +127,10 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { // Try the WienerLinien nearby-stops proxy first let stops: Awaited> | null = null; + let apiReachable = false; try { stops = await api.findNearbyStops(userLat, userLng, 2000); + apiReachable = true; } catch { // API unavailable, will fall back to HAFAS LocMatch below } @@ -146,15 +154,28 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { } // Fallback: use HAFAS LocMatch directly (same pattern as the web app) - const nearestStation = await api.findNearestStationByCoords(userLat, userLng); - if (!nearestStation) { - Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); + try { + const nearestStation = await api.findNearestStationByCoords(userLat, userLng); + if (!nearestStation) { + Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); + return; + } + await selectStation(nearestStation); return; + } catch { + // API not reachable } - await selectStation(nearestStation); + if (!apiReachable) { + Alert.alert( + 'API nicht erreichbar', + 'Der Server konnte nicht erreicht werden. Stelle sicher, dass EXPO_PUBLIC_API_BASE_URL in der .env-Datei auf die LAN-IP deines Entwicklungsrechners zeigt (z. B. http://192.168.1.x:3000) und nicht auf localhost.' + ); + } else { + Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); + } } catch (_err) { - Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.'); + Alert.alert('Fehler', 'Standortermittlung fehlgeschlagen. Bitte überprüfe die Berechtigungen in den Systemeinstellungen.'); } }; @@ -232,6 +253,9 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { accessibilityLabel="Station suchen" /> {searching && } + {searchError && ( + {searchError} + )} {origin && ( Aktuell: {origin.name} )} @@ -344,6 +368,7 @@ const styles = StyleSheet.create({ }, locBtnText: { fontSize: 15, fontWeight: '500' }, locStatus: { fontSize: 12, marginTop: 6 }, + errorText: { fontSize: 13, marginTop: 6 }, advancedToggle: { marginTop: 12, marginBottom: 12, diff --git a/apps/mobile/src/services/calendar.ts b/apps/mobile/src/services/calendar.ts index 2ae3e9f..2419504 100644 --- a/apps/mobile/src/services/calendar.ts +++ b/apps/mobile/src/services/calendar.ts @@ -31,11 +31,13 @@ export async function fetchNativeEvents( const calendarIds = calendars.map((c) => c.id); const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate); - return events.map((evt) => ({ - id: evt.id, - title: evt.title ?? 'Untitled Event', - destination: evt.location ?? '', - eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()), - source: `native:${evt.calendarId}`, - })); + return events + .filter((evt) => evt.location && evt.location.trim().length > 0) + .map((evt) => ({ + id: evt.id, + title: evt.title ?? 'Untitled Event', + destination: evt.location!.trim(), + eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()), + source: `native:${evt.calendarId}`, + })); } diff --git a/apps/mobile/src/services/expoNotifications.ts b/apps/mobile/src/services/expoNotifications.ts index 00efbe5..b144e9c 100644 --- a/apps/mobile/src/services/expoNotifications.ts +++ b/apps/mobile/src/services/expoNotifications.ts @@ -18,5 +18,5 @@ export type { NotificationBehavior, NotificationRequest, NotificationRequestInput, - SchedulableTriggerInput, + SchedulableTriggerInputTypes as SchedulableTriggerInput, } from 'expo-notifications'; diff --git a/apps/mobile/src/types/navigation.ts b/apps/mobile/src/types/navigation.ts index f95fc6c..f85f6c7 100644 --- a/apps/mobile/src/types/navigation.ts +++ b/apps/mobile/src/types/navigation.ts @@ -5,7 +5,7 @@ export type RootStack = { EventList: undefined; EventDetail: { eventId: string }; - AddEvent: { editEventId?: string }; + AddEvent: undefined | { editEventId?: string }; Settings: undefined; CalendarImport: undefined; }; diff --git a/apps/web/src/app/add-event/AddEventModal.tsx b/apps/web/src/app/add-event/AddEventModal.tsx index 1e22c39..3728bf8 100644 --- a/apps/web/src/app/add-event/AddEventModal.tsx +++ b/apps/web/src/app/add-event/AddEventModal.tsx @@ -1,92 +1,108 @@ "use client"; import React, { useState, useEffect } from "react"; +import { format } from "date-fns"; import Button from "@/app/ui/Button"; import { useEventsStore } from "@/hooks/useEventsStore"; +import type { Event } from "@timetoleave/core"; type AddEventModalProps = { isOpen: boolean; onClose: () => void; + editEvent?: Event; className?: string; }; -const AddEventModal: React.FC = ({ isOpen, onClose, className = "" }) => { +const AddEventModal: React.FC = ({ isOpen, onClose, editEvent, className = "" }) => { + const isEditing = !!editEvent; const [title, setTitle] = useState(""); const [destination, setDestination] = useState(""); const [eventTime, setEventTime] = useState(""); const [eventDate, setEventDate] = useState(""); const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); - const { addEvent } = useEventsStore(); + const { addEvent, updateEvent } = useEventsStore(); + + // Populate fields when opening in edit mode + useEffect(() => { + if (editEvent) { + setTitle(editEvent.title); + setDestination(editEvent.destination); + setEventDate(format(editEvent.eventTime, "yyyy-MM-dd")); + setEventTime(format(editEvent.eventTime, "HH:mm")); + } else { + setTitle(""); + setDestination(""); + setEventDate(""); + setEventTime(""); + } + setSuccess(false); + }, [editEvent, isOpen]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) { - return; - } + if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) return; setLoading(true); try { - const eventTimeDate = new Date(`${eventDate} ${eventTime}`); + const eventTimeDate = new Date(`${eventDate}T${eventTime}`); - await addEvent({ - id: `manual-${Date.now()}`, - title: title.trim(), - destination: destination.trim(), - eventTime: eventTimeDate, - source: "manual", - }); + if (isEditing && editEvent) { + updateEvent(editEvent.id, { + title: title.trim(), + destination: destination.trim(), + eventTime: eventTimeDate, + }); + } else { + await addEvent({ + id: `manual-${Date.now()}`, + title: title.trim(), + destination: destination.trim(), + eventTime: eventTimeDate, + source: "manual", + }); + } - // Reset form and close modal - setTitle(""); - setDestination(""); - setEventTime(""); - setEventDate(""); - onClose(); + setSuccess(true); + setTimeout(() => { + onClose(); + setSuccess(false); + }, 1200); } catch (err) { - console.error("Error adding event:", err); + console.error("Error saving event:", err); } finally { setLoading(false); } }; - // Close modal when clicking outside const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } + if (e.target === e.currentTarget) onClose(); }; - // Close modal with Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - if (isOpen) { - document.addEventListener("keydown", handleEscape); - } - - return () => { - document.removeEventListener("keydown", handleEscape); + if (e.key === "Escape") onClose(); }; + if (isOpen) document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); }, [isOpen, onClose]); - if (!isOpen) { - return null; - } + if (!isOpen) return null; return (
-
+
-

Manual event

-

Add a departure target

+

+ {isEditing ? "Edit event" : "Manual event"} +

+

+ {isEditing ? "Edit departure target" : "Add a departure target"} +

-
+ + {success && ( +
+
+
+ ✓ +
+

+ {isEditing ? "Changes saved" : "Event added"} +

+
+
+ )}
); diff --git a/apps/web/src/app/calendar/BatchEditPanel.tsx b/apps/web/src/app/calendar/BatchEditPanel.tsx new file mode 100644 index 0000000..67b0b4f --- /dev/null +++ b/apps/web/src/app/calendar/BatchEditPanel.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { format } from "date-fns"; +import { useEventsStore } from "@/hooks/useEventsStore"; +import Button from "@/app/ui/Button"; + +function sourceLabel(source: string): string { + if (source === "manual") return "Manually added"; + if (source === "google_calendar") return "Google Calendar"; + if (source.startsWith("calendar:")) { + const url = source.slice("calendar:".length); + try { + return new URL(url).hostname; + } catch { + return url.slice(0, 40); + } + } + if (source.startsWith("native:")) return `Device calendar (${source.slice(7)})`; + return source; +} + +export default function BatchEditPanel() { + const { events, updateEvent } = useEventsStore(); + + const [sourceFilter, setSourceFilter] = useState("__all__"); + const [destContains, setDestContains] = useState(""); + const [newDestination, setNewDestination] = useState(""); + const [applied, setApplied] = useState(null); + + const sources = useMemo(() => { + const seen = new Set(); + for (const e of events) seen.add(e.source); + return Array.from(seen).sort(); + }, [events]); + + const matched = useMemo(() => { + return events.filter((e) => { + const sourceMatch = sourceFilter === "__all__" || e.source === sourceFilter; + const destMatch = destContains.trim() === "" || + e.destination.toLowerCase().includes(destContains.trim().toLowerCase()); + return sourceMatch && destMatch; + }); + }, [events, sourceFilter, destContains]); + + const handleApply = () => { + if (!newDestination.trim() || matched.length === 0) return; + for (const e of matched) { + updateEvent(e.id, { destination: newDestination.trim() }); + } + setApplied(matched.length); + setNewDestination(""); + setTimeout(() => setApplied(null), 3000); + }; + + if (events.length === 0) return null; + + return ( +
+
+

Bulk action

+

Batch-edit destinations

+

+ Replace the destination on multiple events at once — useful when a calendar stores a room number but you need a full address. +

+
+ +
+ {/* Filters */} +
+
+ + +
+ +
+ + { setDestContains(e.target.value); setApplied(null); }} + placeholder="e.g. HS, Seminarraum, Room …" + className="brand-input px-3 py-2 text-sm" + /> +
+
+ + {/* Preview */} +
+

+ Matched events ({matched.length}) +

+ {matched.length === 0 ? ( +

No events match the current filters.

+ ) : ( +
    + {matched.map((e) => ( +
  • + {e.title} + + {e.destination} · {format(e.eventTime, "dd MMM")} + +
  • + ))} +
+ )} +
+ + {/* Replace with */} +
+
+ + { setNewDestination(e.target.value); setApplied(null); }} + placeholder="e.g. Universitätsplatz 3, 8010 Graz" + className="brand-input px-3 py-2 text-sm" + /> +
+ +
+ + {applied !== null && ( +

+ ✓ Updated destination on {applied} event{applied === 1 ? "" : "s"}. +

+ )} +
+
+ ); +} diff --git a/apps/web/src/app/calendar/page.tsx b/apps/web/src/app/calendar/page.tsx index d1416f9..1f087dd 100644 --- a/apps/web/src/app/calendar/page.tsx +++ b/apps/web/src/app/calendar/page.tsx @@ -6,6 +6,7 @@ import { useOriginStation } from "@/hooks/useOriginStation"; import CalendarView from "./CalendarView"; import DayEvents from "./DayEvents"; import CalendarPanel from "./CalendarPanel"; +import BatchEditPanel from "./BatchEditPanel"; export default function CalendarPage() { const { events } = useEventsStore(); @@ -20,8 +21,9 @@ export default function CalendarPage() {

View and manage every appointment from a single departure-focused calendar.

-
+
+
diff --git a/apps/web/src/app/event/EventCard.tsx b/apps/web/src/app/event/EventCard.tsx index 605037e..915c63b 100644 --- a/apps/web/src/app/event/EventCard.tsx +++ b/apps/web/src/app/event/EventCard.tsx @@ -11,11 +11,13 @@ import { useClock } from "@/hooks/useClock"; import { useDepartureTime } from "@/hooks/useDepartureTime"; import { useReminderSettings } from "@/hooks/useReminderSettings"; import { useWienerLinien } from "@/hooks/useWienerLinien"; +import { useEventsStore } from "@/hooks/useEventsStore"; import type { Event, Station } from "@timetoleave/core"; import TrainSection from "./TrainSection"; import BikeSection from "./BikeSection"; import WienerLinienSection from "./WienerLinienSection"; import CountdownBadge from "@/app/ui/CountdownBadge"; +import AddEventModal from "@/app/add-event/AddEventModal"; interface EventCardProps { event: Event; @@ -23,6 +25,15 @@ interface EventCardProps { } export default function EventCard({ event, originStation }: EventCardProps) { + const [editOpen, setEditOpen] = useState(false); + const { removeEvent } = useEventsStore(); + + const handleRemove = () => { + if (window.confirm(`Remove "${event.title}"?`)) { + removeEvent(event.id); + } + }; + const destStation = useDestinationStation(event.destination); const { @@ -83,13 +94,30 @@ export default function EventCard({ event, originStation }: EventCardProps) { ]; return ( + <>

Next stop

{event.title}

- +
+ + + +
@@ -178,5 +206,7 @@ export default function EventCard({ event, originStation }: EventCardProps) { )}
+ setEditOpen(false)} editEvent={event} /> + ); } diff --git a/apps/web/src/app/event/__tests__/EventCard.test.tsx b/apps/web/src/app/event/__tests__/EventCard.test.tsx index d843b4e..0f33d24 100644 --- a/apps/web/src/app/event/__tests__/EventCard.test.tsx +++ b/apps/web/src/app/event/__tests__/EventCard.test.tsx @@ -95,6 +95,18 @@ vi.mock("@/hooks/useClock", () => ({ }), })); +vi.mock("@/hooks/useEventsStore", () => ({ + useEventsStore: () => ({ + events: [], + addEvent: vi.fn(), + updateEvent: vi.fn(), + removeEvent: vi.fn(), + clearEvents: vi.fn(), + setEvents: vi.fn(), + mergeEvents: vi.fn(), + }), +})); + vi.mock("@/lib/countdown-utils", () => ({ calculateCountdown: () => ({ label: "No deadline set", diff --git a/package-lock.json b/package-lock.json index 33b136b..66fd9fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@timetoleave/core": "*", "expo": "~54.0.33", "expo-calendar": "~15.0.8", + "expo-dev-client": "~6.0.21", "expo-location": "~19.0.8", "expo-notifications": "~0.32.17", "expo-status-bar": "~3.0.9", @@ -9733,6 +9734,79 @@ "react-native": "*" } }, + "node_modules/expo-dev-client": { + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.21.tgz", + "integrity": "sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "6.0.21", + "expo-dev-menu": "7.0.19", + "expo-dev-menu-interface": "2.0.0", + "expo-manifests": "~1.0.11", + "expo-updates-interface": "~2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz", + "integrity": "sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "expo-dev-menu": "7.0.19", + "expo-manifests": "~1.0.11" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/expo-dev-launcher/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/expo-dev-menu": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz", + "integrity": "sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz", + "integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.22", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", @@ -9757,6 +9831,12 @@ "react-native": "*" } }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -9767,6 +9847,19 @@ "react": "*" } }, + "node_modules/expo-manifests": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz", + "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -9818,6 +9911,15 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", + "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -9905,6 +10007,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index a608839..b492646 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -6,6 +6,7 @@ import type { CalendarEvent, Journey, Station, + WienerLinienDeparture, } from "@timetoleave/core"; import { hafasDateTime } from "@timetoleave/core"; @@ -213,15 +214,15 @@ export class ApiClient { ], }); - const stations = result?.svcReqL?.[0]?.res?.match?.locL ?? []; + const stations: HafasLocation[] = result?.svcReqL?.[0]?.res?.match?.locL ?? []; if (stations.length === 0) return null; // Filter to only "S" (station) type results, then pick the closest - const stationResults = stations.filter((s) => s.type === "S"); + const stationResults = stations.filter((s: HafasLocation) => s.type === "S"); if (stationResults.length === 0) return null; // Find the closest station by Euclidean distance - const closest = stationResults.reduce((best, candidate) => { + const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => { const bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng); const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng); return candDist < bestDist ? candidate : best; @@ -229,4 +230,17 @@ export class ApiClient { return closest; } + + async monitorStops(stopIds: string[]): Promise { + if (stopIds.length === 0) return []; + const params = new URLSearchParams(); + for (const id of stopIds) { + params.append("stopIds", id); + } + const url = `${this.baseUrl}/api/wienerlinien/monitor?${params.toString()}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Monitor failed: ${res.status}`); + const data: { departures?: WienerLinienDeparture[] } = await res.json(); + return data.departures ?? []; + } }