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
This commit is contained in:
2026-05-13 09:45:55 +02:00
parent de9a8606ab
commit 9e461aab1a
22 changed files with 632 additions and 168 deletions
+12 -5
View File
@@ -5,12 +5,12 @@
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "dark",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#007AFF" "backgroundColor": "#090816"
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
@@ -23,7 +23,7 @@
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF" "backgroundColor": "#090816"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
@@ -31,7 +31,8 @@
"permissions": [ "permissions": [
"android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_FINE_LOCATION",
"android.permission.POST_NOTIFICATIONS", "android.permission.POST_NOTIFICATIONS",
"android.permission.INTERNET" "android.permission.INTERNET",
"android.permission.ACCESS_COARSE_LOCATION"
] ]
}, },
"web": { "web": {
@@ -41,6 +42,12 @@
"expo-location", "expo-location",
"expo-notifications" "expo-notifications"
], ],
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy" "privacyPolicyUrl": "https://timetoleave.app/privacy-policy",
"extra": {
"eas": {
"projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec"
}
},
"owner": "floegger"
} }
} }
+1
View File
@@ -20,6 +20,7 @@
"@timetoleave/core": "*", "@timetoleave/core": "*",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-calendar": "~15.0.8", "expo-calendar": "~15.0.8",
"expo-dev-client": "~6.0.21",
"expo-location": "~19.0.8", "expo-location": "~19.0.8",
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
+31 -11
View File
@@ -91,7 +91,8 @@ describe('calendar service', () => {
const result = await fetchNativeEvents(startDate, endDate); 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({ expect(result[0]).toEqual({
id: 'evt1', id: 'evt1',
title: 'Team Meeting', title: 'Team Meeting',
@@ -99,13 +100,6 @@ describe('calendar service', () => {
eventTime: new Date('2025-01-15T10:00:00'), eventTime: new Date('2025-01-15T10:00:00'),
source: 'native:cal1', 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( expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
['cal1', 'cal2'], ['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.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true); mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]); mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
@@ -123,7 +143,7 @@ describe('calendar service', () => {
id: 'evt1', id: 'evt1',
calendarId: 'cal1', calendarId: 'cal1',
title: null as unknown as string, title: null as unknown as string,
location: null, location: 'Wien Hbf',
startDate: null as unknown as string | Date, startDate: null as unknown as string | Date,
}, },
] as Calendar.Event[]); ] as Calendar.Event[]);
@@ -132,7 +152,7 @@ describe('calendar service', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].title).toBe('Untitled Event'); expect(result[0].title).toBe('Untitled Event');
expect(result[0].destination).toBe(''); expect(result[0].destination).toBe('Wien Hbf');
}); });
}); });
}); });
@@ -1,6 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { Journey } from '@timetoleave/core'; import type { Journey } from '@timetoleave/core';
import { loadNotificationSettings } from '../store/eventStore';
interface DepartureTimeResult { interface DepartureTimeResult {
departureTime: Date | null; departureTime: Date | null;
+2 -2
View File
@@ -7,8 +7,8 @@ type Theme = 'dark' | 'light';
function getDefaultTheme(): Theme { function getDefaultTheme(): Theme {
// React Native doesn't have window.matchMedia, but we can use a simple default // 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 // The web app uses a dark-first theme, so we match that default
return 'light'; return 'dark';
} }
/** /**
+23 -39
View File
@@ -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( export function useWienerLinien(
lat: number | undefined, lat: number | undefined,
lng: number | undefined, lng: number | undefined,
@@ -37,37 +33,38 @@ export function useWienerLinien(
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const stopIdsRef = useRef<string[]>([]); const stopIdsRef = useRef<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
// Effect for fetching stops and initial departures const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
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(() => { useEffect(() => {
cancelledRef.current = false; cancelledRef.current = false;
const resetState = () => { if (lat === undefined || lng === undefined) {
setStops([]); setStops([]);
setDepartures([]); setDepartures([]);
setError(null); setError(null);
setLoading(false); setLoading(false);
};
if (lat === undefined || lng === undefined) {
resetState();
return; return;
} }
const debounceTimer = setTimeout(async () => { const debounceTimer = setTimeout(async () => {
if (cancelledRef.current) return; if (cancelledRef.current) return;
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Fetch nearby stops
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500); const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
if (cancelledRef.current) return; if (cancelledRef.current) return;
@@ -78,20 +75,10 @@ export function useWienerLinien(
const ids = stopsList.map((s) => s.id); const ids = stopsList.map((s) => s.id);
stopIdsRef.current = ids; stopIdsRef.current = ids;
// Chain monitor fetch for departures await fetchMonitor(ids);
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
}
}
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
if (cancelledRef.current) 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); setLoading(false);
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -99,25 +86,22 @@ export function useWienerLinien(
return () => { return () => {
cancelledRef.current = true; cancelledRef.current = true;
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
abortRef.current?.abort();
abortRef.current = null;
}; };
}, [lat, lng, radius]); }, [lat, lng, radius, fetchMonitor]);
// Effect for periodic departures refresh // Periodic departures refresh
useEffect(() => { useEffect(() => {
if (stops.length === 0) return; if (stops.length === 0) return;
const intervalId = setInterval(async () => { const intervalId = setInterval(() => {
const currentIds = stopIdsRef.current; const ids = stopIdsRef.current;
if (currentIds.length === 0) return; if (ids.length > 0) {
fetchMonitor(ids);
// Refresh logic would go here if we had the monitor API }
// For now, this is a placeholder for future implementation
}, REFRESH_INTERVAL_MS); }, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [stops.length]); }, [stops.length, fetchMonitor]);
return { stops, departures, loading, error }; return { stops, departures, loading, error };
} }
+3 -3
View File
@@ -16,12 +16,12 @@ const Root = createNativeStackNavigator<RootStack>();
export default function AppNavigator() { export default function AppNavigator() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}> <SafeAreaView style={{ flex: 1, backgroundColor: '#090816' }}>
<StatusBar style="auto" /> <StatusBar style="light" />
<NavigationContainer> <NavigationContainer>
<Root.Navigator <Root.Navigator
initialRouteName="EventList" initialRouteName="EventList"
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }} screenOptions={{ headerStyle: { backgroundColor: '#17112A' }, headerTintColor: '#F4F1EA' }}
> >
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} /> <Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} /> <Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
+50 -9
View File
@@ -25,13 +25,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
const [dateStr, setDateStr] = useState(''); const [dateStr, setDateStr] = useState('');
const [timeStr, setTimeStr] = useState(''); const [timeStr, setTimeStr] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const colors = dark ? { const colors = dark ? {
background: '#1c1c1e', background: '#090816',
card: '#2c2c2e', card: '#17112A',
text: '#f2f2f2', text: '#F4F1EA',
subtext: '#aeaeb2', subtext: 'rgba(244,241,234,0.5)',
accent: '#0a84ff', accent: '#8B5CF6',
border: '#38383a', border: '#38383a',
error: '#ff453a', error: '#ff453a',
} : { } : {
@@ -39,7 +40,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
card: '#ffffff', card: '#ffffff',
text: '#1c1c1e', text: '#1c1c1e',
subtext: '#8e8e93', subtext: '#8e8e93',
accent: '#007AFF', accent: '#B23CFF',
border: '#e5e5ea', border: '#e5e5ea',
error: '#FF3B30', error: '#FF3B30',
}; };
@@ -97,11 +98,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
await addEvent(event); await addEvent(event);
} }
navigation.goBack(); setSuccess(true);
setTimeout(() => {
navigation.goBack();
}, 1500);
}; };
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: colors.background, position: 'relative' }]}>
<View style={styles.form}> <View style={styles.form}>
<Text style={[styles.label, { color: colors.text }]}>Titel</Text> <Text style={[styles.label, { color: colors.text }]}>Titel</Text>
<TextInput <TextInput
@@ -156,6 +160,20 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
<Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text> <Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{success && (
<View style={[styles.successOverlay, { backgroundColor: dark ? 'rgba(28,28,30,0.95)' : 'rgba(255,255,255,0.95)' }]}>
<View style={styles.successContent}>
<View style={styles.successCircle}>
<Text style={styles.checkmark}></Text>
</View>
<Text style={[styles.successTitle, { color: colors.text }]}>Erfolg!</Text>
<Text style={[styles.successSubtitle, { color: colors.subtext }]}>
{route.params?.editEventId ? 'Termin aktualisiert' : 'Termin hinzugefügt'}
</Text>
</View>
</View>
)}
</View> </View>
); );
} }
@@ -174,7 +192,7 @@ const styles = StyleSheet.create({
}, },
errorText: { fontSize: 14, marginBottom: 8 }, errorText: { fontSize: 14, marginBottom: 8 },
saveBtn: { saveBtn: {
backgroundColor: '#007AFF', backgroundColor: '#8B5CF6',
paddingVertical: 14, paddingVertical: 14,
borderRadius: 12, borderRadius: 12,
alignItems: 'center', alignItems: 'center',
@@ -183,4 +201,27 @@ const styles = StyleSheet.create({
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
cancelBtn: { marginTop: 12 }, cancelBtn: { marginTop: 12 },
cancelText: { fontSize: 16, fontWeight: '600' }, 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 },
}); });
@@ -30,25 +30,25 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
const [count, setCount] = useState<number | null>(null); const [count, setCount] = useState<number | null>(null);
const colors = dark ? { const colors = dark ? {
background: '#1c1c1e', background: '#090816',
card: '#2c2c2e', card: '#17112A',
text: '#f2f2f2', text: '#F4F1EA',
subtext: '#aeaeb2', subtext: 'rgba(244,241,234,0.5)',
accent: '#0a84ff', accent: '#8B5CF6',
border: '#38383a', border: '#38383a',
error: '#ff453a', error: '#ff453a',
success: '#30d158', success: '#30d158',
purple: '#bf5af2', purple: '#B23CFF',
} : { } : {
background: '#f2f2f7', background: '#f2f2f7',
card: '#ffffff', card: '#ffffff',
text: '#1c1c1e', text: '#1c1c1e',
subtext: '#8e8e93', subtext: '#8e8e93',
accent: '#007AFF', accent: '#B23CFF',
border: '#e5e5ea', border: '#e5e5ea',
error: '#FF3B30', error: '#FF3B30',
success: '#34C759', success: '#34C759',
purple: '#5856D6', purple: '#8B5CF6',
}; };
const handleImport = async () => { const handleImport = async () => {
@@ -209,7 +209,7 @@ const styles = StyleSheet.create({
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 }, successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
successText: { color: '#fff', fontSize: 14 }, successText: { color: '#fff', fontSize: 14 },
importBtn: { importBtn: {
backgroundColor: '#007AFF', backgroundColor: '#8B5CF6',
paddingVertical: 14, paddingVertical: 14,
borderRadius: 12, borderRadius: 12,
alignItems: 'center', alignItems: 'center',
+33 -8
View File
@@ -150,11 +150,11 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
// Theme-based colors // Theme-based colors
const colors = dark ? { const colors = dark ? {
background: '#1c1c1e', background: '#090816',
card: '#2c2c2e', card: '#17112A',
text: '#f2f2f2', text: '#F4F1EA',
subtext: '#aeaeb2', subtext: 'rgba(244,241,234,0.5)',
accent: '#0a84ff', accent: '#8B5CF6',
border: '#38383a', border: '#38383a',
warning: '#ff9f0a', warning: '#ff9f0a',
error: '#ff453a', error: '#ff453a',
@@ -163,7 +163,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
card: '#ffffff', card: '#ffffff',
text: '#1c1c1e', text: '#1c1c1e',
subtext: '#8e8e93', subtext: '#8e8e93',
accent: '#007AFF', accent: '#B23CFF',
border: '#e5e5ea', border: '#e5e5ea',
warning: '#FF9500', warning: '#FF9500',
error: '#FF3B30', error: '#FF3B30',
@@ -395,8 +395,8 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
</View> </View>
)} )}
{/* WienerLinien nearby stops */} {/* WienerLinien nearby stops + departures */}
{wienerLinien.stops.length > 0 && ( {(wienerLinien.loading || wienerLinien.stops.length > 0) && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}> <View style={[styles.journeys, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text> <Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
{wienerLinien.loading ? ( {wienerLinien.loading ? (
@@ -404,6 +404,25 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
<ActivityIndicator size="small" color={colors.accent} /> <ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen</Text> <Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen</Text>
</View> </View>
) : wienerLinien.departures.length > 0 ? (
wienerLinien.departures.slice(0, 8).map((dep, i) => (
<View
key={`${dep.stopId}-${dep.lineName}-${i}`}
style={[styles.departureCard, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<View style={styles.departureRow}>
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
</View>
<Text style={[styles.departureDirection, { color: colors.text }]} numberOfLines={1}>
{dep.direction}
</Text>
<Text style={[styles.departureMinutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
</Text>
</View>
</View>
))
) : ( ) : (
wienerLinien.stops.slice(0, 5).map((stop) => ( wienerLinien.stops.slice(0, 5).map((stop) => (
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}> <View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
@@ -477,6 +496,12 @@ const styles = StyleSheet.create({
mapPlaceholderText: { fontSize: 14 }, mapPlaceholderText: { fontSize: 14 },
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 }, stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
stopName: { fontSize: 14, fontWeight: '500' }, stopName: { fontSize: 14, fontWeight: '500' },
departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 },
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const },
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
departureDirection: { flex: 1, fontSize: 13 },
departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const },
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnText: { fontSize: 15, fontWeight: '600' }, refreshBtnText: { fontSize: 15, fontWeight: '600' },
}); });
+9 -9
View File
@@ -29,18 +29,18 @@ export function EventListScreen({ navigation }: ScreenProps) {
const [, setTick] = useState(0); const [, setTick] = useState(0);
const colors = dark ? { const colors = dark ? {
background: '#1c1c1e', background: '#090816',
card: '#2c2c2e', card: '#17112A',
text: '#f2f2f2', text: '#F4F1EA',
subtext: '#aeaeb2', subtext: 'rgba(244,241,234,0.5)',
accent: '#0a84ff', accent: '#8B5CF6',
delete: '#ff453a', delete: '#FF3B30',
} : { } : {
background: '#f2f2f7', background: '#f2f2f7',
card: '#ffffff', card: '#ffffff',
text: '#1c1c1e', text: '#1c1c1e',
subtext: '#8e8e93', subtext: '#8e8e93',
accent: '#007AFF', accent: '#B23CFF',
delete: '#FF3B30', delete: '#FF3B30',
}; };
@@ -197,7 +197,7 @@ const styles = StyleSheet.create({
deleteText: { fontSize: 13 }, deleteText: { fontSize: 13 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { fontSize: 20, marginBottom: 16 }, 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' }, addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
fab: { fab: {
position: 'absolute', position: 'absolute',
@@ -206,7 +206,7 @@ const styles = StyleSheet.create({
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 28, borderRadius: 28,
backgroundColor: '#007AFF', backgroundColor: '#8B5CF6',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', shadowColor: '#000',
+36 -11
View File
@@ -29,6 +29,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]); const [results, setResults] = useState<Station[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({ const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
bufferMinutes: 30, bufferMinutes: 30,
enabled: true, enabled: true,
@@ -41,11 +42,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const colors = dark ? { const colors = dark ? {
background: '#1c1c1e', background: '#090816',
card: '#2c2c2e', card: '#17112A',
text: '#f2f2f2', text: '#F4F1EA',
subtext: '#aeaeb2', subtext: 'rgba(244,241,234,0.5)',
accent: '#0a84ff', accent: '#8B5CF6',
border: '#38383a', border: '#38383a',
success: '#30d158', success: '#30d158',
} : { } : {
@@ -53,7 +54,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
card: '#ffffff', card: '#ffffff',
text: '#1c1c1e', text: '#1c1c1e',
subtext: '#8e8e93', subtext: '#8e8e93',
accent: '#007AFF', accent: '#B23CFF',
border: '#e5e5ea', border: '#e5e5ea',
success: '#34C759', success: '#34C759',
}; };
@@ -72,14 +73,18 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const searchStation = useCallback(async (q: string) => { const searchStation = useCallback(async (q: string) => {
if (q.trim().length < 2) { if (q.trim().length < 2) {
setResults([]); setResults([]);
setSearchError(null);
return; return;
} }
setSearching(true); setSearching(true);
setSearchError(null);
try { try {
const stations = await api.searchStation(q.trim()); const stations = await api.searchStation(q.trim());
setResults(stations); setResults(stations);
if (stations.length === 0) setSearchError('Keine Stationen gefunden.');
} catch { } catch {
setResults([]); setResults([]);
setSearchError('API nicht erreichbar. Läuft der Server auf deinem Gerät? Überprüfe EXPO_PUBLIC_API_BASE_URL in .env.');
} finally { } finally {
setSearching(false); setSearching(false);
} }
@@ -87,6 +92,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const onQueryChange = (text: string) => { const onQueryChange = (text: string) => {
setQuery(text); setQuery(text);
setSearchError(null);
// Proper debounce using useRef — no `any` // Proper debounce using useRef — no `any`
if (searchTimerRef.current) clearTimeout(searchTimerRef.current); if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => searchStation(text), 400); searchTimerRef.current = setTimeout(() => searchStation(text), 400);
@@ -121,8 +127,10 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
// Try the WienerLinien nearby-stops proxy first // Try the WienerLinien nearby-stops proxy first
let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null; let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null;
let apiReachable = false;
try { try {
stops = await api.findNearbyStops(userLat, userLng, 2000); stops = await api.findNearbyStops(userLat, userLng, 2000);
apiReachable = true;
} catch { } catch {
// API unavailable, will fall back to HAFAS LocMatch below // 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) // Fallback: use HAFAS LocMatch directly (same pattern as the web app)
const nearestStation = await api.findNearestStationByCoords(userLat, userLng); try {
if (!nearestStation) { const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); if (!nearestStation) {
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
return;
}
await selectStation(nearestStation);
return; 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) { } 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" accessibilityLabel="Station suchen"
/> />
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />} {searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
{searchError && (
<Text style={[styles.errorText, { color: '#ff453a' }]}>{searchError}</Text>
)}
{origin && ( {origin && (
<Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text> <Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text>
)} )}
@@ -344,6 +368,7 @@ const styles = StyleSheet.create({
}, },
locBtnText: { fontSize: 15, fontWeight: '500' }, locBtnText: { fontSize: 15, fontWeight: '500' },
locStatus: { fontSize: 12, marginTop: 6 }, locStatus: { fontSize: 12, marginTop: 6 },
errorText: { fontSize: 13, marginTop: 6 },
advancedToggle: { advancedToggle: {
marginTop: 12, marginTop: 12,
marginBottom: 12, marginBottom: 12,
+9 -7
View File
@@ -31,11 +31,13 @@ export async function fetchNativeEvents(
const calendarIds = calendars.map((c) => c.id); const calendarIds = calendars.map((c) => c.id);
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate); const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
return events.map((evt) => ({ return events
id: evt.id, .filter((evt) => evt.location && evt.location.trim().length > 0)
title: evt.title ?? 'Untitled Event', .map((evt) => ({
destination: evt.location ?? '', id: evt.id,
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()), title: evt.title ?? 'Untitled Event',
source: `native:${evt.calendarId}`, destination: evt.location!.trim(),
})); eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
source: `native:${evt.calendarId}`,
}));
} }
@@ -18,5 +18,5 @@ export type {
NotificationBehavior, NotificationBehavior,
NotificationRequest, NotificationRequest,
NotificationRequestInput, NotificationRequestInput,
SchedulableTriggerInput, SchedulableTriggerInputTypes as SchedulableTriggerInput,
} from 'expo-notifications'; } from 'expo-notifications';
+1 -1
View File
@@ -5,7 +5,7 @@
export type RootStack = { export type RootStack = {
EventList: undefined; EventList: undefined;
EventDetail: { eventId: string }; EventDetail: { eventId: string };
AddEvent: { editEventId?: string }; AddEvent: undefined | { editEventId?: string };
Settings: undefined; Settings: undefined;
CalendarImport: undefined; CalendarImport: undefined;
}; };
+73 -47
View File
@@ -1,92 +1,108 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { format } from "date-fns";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
import { useEventsStore } from "@/hooks/useEventsStore"; import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event } from "@timetoleave/core";
type AddEventModalProps = { type AddEventModalProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
editEvent?: Event;
className?: string; className?: string;
}; };
const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, className = "" }) => { const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEvent, className = "" }) => {
const isEditing = !!editEvent;
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [destination, setDestination] = useState(""); const [destination, setDestination] = useState("");
const [eventTime, setEventTime] = useState(""); const [eventTime, setEventTime] = useState("");
const [eventDate, setEventDate] = useState(""); const [eventDate, setEventDate] = useState("");
const [loading, setLoading] = useState(false); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) { if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) return;
return;
}
setLoading(true); setLoading(true);
try { try {
const eventTimeDate = new Date(`${eventDate} ${eventTime}`); const eventTimeDate = new Date(`${eventDate}T${eventTime}`);
await addEvent({ if (isEditing && editEvent) {
id: `manual-${Date.now()}`, updateEvent(editEvent.id, {
title: title.trim(), title: title.trim(),
destination: destination.trim(), destination: destination.trim(),
eventTime: eventTimeDate, eventTime: eventTimeDate,
source: "manual", });
}); } else {
await addEvent({
id: `manual-${Date.now()}`,
title: title.trim(),
destination: destination.trim(),
eventTime: eventTimeDate,
source: "manual",
});
}
// Reset form and close modal setSuccess(true);
setTitle(""); setTimeout(() => {
setDestination(""); onClose();
setEventTime(""); setSuccess(false);
setEventDate(""); }, 1200);
onClose();
} catch (err) { } catch (err) {
console.error("Error adding event:", err); console.error("Error saving event:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Close modal when clicking outside
const handleBackdropClick = (e: React.MouseEvent) => { const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) onClose();
onClose();
}
}; };
// Close modal with Escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") onClose();
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
}; };
if (isOpen) document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]); }, [isOpen, onClose]);
if (!isOpen) { if (!isOpen) return null;
return null;
}
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}> <div className={`brand-panel w-full max-w-md rounded-2xl relative ${className}`}>
<div className="p-6"> <div className="p-6">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p> <p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3> {isEditing ? "Edit event" : "Manual event"}
</p>
<h3 className="mb-6 text-2xl font-bold text-white">
{isEditing ? "Edit departure target" : "Add a departure target"}
</h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"> <label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
@@ -103,10 +119,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
/> />
</div> </div>
<div> <div>
<label <label htmlFor="event-destination" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
htmlFor="event-destination"
className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
>
Destination Destination
</label> </label>
<input <input
@@ -152,11 +165,24 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? <>Add Event</> : <>Add Event</>} {isEditing ? "Save Changes" : "Add Event"}
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
{success && (
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-[#090816]/90 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-[#34C759] to-[#30d158] text-3xl shadow-lg">
</div>
<p className="text-lg font-semibold text-white">
{isEditing ? "Changes saved" : "Event added"}
</p>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -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<string>("__all__");
const [destContains, setDestContains] = useState("");
const [newDestination, setNewDestination] = useState("");
const [applied, setApplied] = useState<number | null>(null);
const sources = useMemo(() => {
const seen = new Set<string>();
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 (
<div className="brand-panel overflow-hidden rounded-2xl">
<div className="border-b border-white/10 p-4">
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Bulk action</p>
<h3 className="text-lg font-semibold text-white">Batch-edit destinations</h3>
<p className="mt-1 text-sm text-[#F4F1EA]/55">
Replace the destination on multiple events at once useful when a calendar stores a room number but you need a full address.
</p>
</div>
<div className="p-4 space-y-4">
{/* Filters */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label htmlFor="batch-source" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Calendar source
</label>
<select
id="batch-source"
value={sourceFilter}
onChange={(e) => { setSourceFilter(e.target.value); setApplied(null); }}
className="brand-input px-3 py-2 text-sm"
>
<option value="__all__">All sources ({events.length} events)</option>
{sources.map((s) => (
<option key={s} value={s}>
{sourceLabel(s)} ({events.filter((e) => e.source === s).length})
</option>
))}
</select>
</div>
<div>
<label htmlFor="batch-dest-filter" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Current destination contains
</label>
<input
id="batch-dest-filter"
type="text"
value={destContains}
onChange={(e) => { setDestContains(e.target.value); setApplied(null); }}
placeholder="e.g. HS, Seminarraum, Room …"
className="brand-input px-3 py-2 text-sm"
/>
</div>
</div>
{/* Preview */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Matched events ({matched.length})
</p>
{matched.length === 0 ? (
<p className="text-sm text-[#F4F1EA]/40">No events match the current filters.</p>
) : (
<ul className="max-h-48 overflow-y-auto space-y-1 rounded-xl border border-white/10 bg-black/20 p-2">
{matched.map((e) => (
<li key={e.id} className="flex items-baseline justify-between gap-3 rounded-lg px-2 py-1.5 text-sm hover:bg-white/[0.04]">
<span className="font-medium text-white truncate">{e.title}</span>
<span className="shrink-0 text-[#F4F1EA]/46 text-xs">
{e.destination} · {format(e.eventTime, "dd MMM")}
</span>
</li>
))}
</ul>
)}
</div>
{/* Replace with */}
<div className="flex gap-3 items-end">
<div className="flex-1">
<label htmlFor="batch-new-dest" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
Replace destination with
</label>
<input
id="batch-new-dest"
type="text"
value={newDestination}
onChange={(e) => { setNewDestination(e.target.value); setApplied(null); }}
placeholder="e.g. Universitätsplatz 3, 8010 Graz"
className="brand-input px-3 py-2 text-sm"
/>
</div>
<Button
onClick={handleApply}
disabled={!newDestination.trim() || matched.length === 0}
className="shrink-0"
>
Apply to {matched.length} event{matched.length === 1 ? "" : "s"}
</Button>
</div>
{applied !== null && (
<p className="text-sm font-medium text-emerald-400">
Updated destination on {applied} event{applied === 1 ? "" : "s"}.
</p>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { useOriginStation } from "@/hooks/useOriginStation";
import CalendarView from "./CalendarView"; import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents"; import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel"; import CalendarPanel from "./CalendarPanel";
import BatchEditPanel from "./BatchEditPanel";
export default function CalendarPage() { export default function CalendarPage() {
const { events } = useEventsStore(); const { events } = useEventsStore();
@@ -20,8 +21,9 @@ export default function CalendarPage() {
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p> <p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
</div> </div>
<div className="mb-6"> <div className="mb-6 space-y-4">
<CalendarPanel /> <CalendarPanel />
<BatchEditPanel />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+31 -1
View File
@@ -11,11 +11,13 @@ import { useClock } from "@/hooks/useClock";
import { useDepartureTime } from "@/hooks/useDepartureTime"; import { useDepartureTime } from "@/hooks/useDepartureTime";
import { useReminderSettings } from "@/hooks/useReminderSettings"; import { useReminderSettings } from "@/hooks/useReminderSettings";
import { useWienerLinien } from "@/hooks/useWienerLinien"; import { useWienerLinien } from "@/hooks/useWienerLinien";
import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event, Station } from "@timetoleave/core"; import type { Event, Station } from "@timetoleave/core";
import TrainSection from "./TrainSection"; import TrainSection from "./TrainSection";
import BikeSection from "./BikeSection"; import BikeSection from "./BikeSection";
import WienerLinienSection from "./WienerLinienSection"; import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge"; import CountdownBadge from "@/app/ui/CountdownBadge";
import AddEventModal from "@/app/add-event/AddEventModal";
interface EventCardProps { interface EventCardProps {
event: Event; event: Event;
@@ -23,6 +25,15 @@ interface EventCardProps {
} }
export default function EventCard({ event, originStation }: 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 destStation = useDestinationStation(event.destination);
const { const {
@@ -83,13 +94,30 @@ export default function EventCard({ event, originStation }: EventCardProps) {
]; ];
return ( return (
<>
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5"> <div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p> <p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p>
<h3 className="text-2xl font-bold text-white">{event.title}</h3> <h3 className="text-2xl font-bold text-white">{event.title}</h3>
</div> </div>
<CountdownBadge countdown={countdown} status={status} /> <div className="flex items-start gap-2">
<CountdownBadge countdown={countdown} status={status} />
<button
onClick={() => setEditOpen(true)}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-brand-fuchsia/40 hover:text-white"
aria-label="Edit event"
>
Edit
</button>
<button
onClick={handleRemove}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-red-500/50 hover:text-red-400"
aria-label="Remove event"
>
Remove
</button>
</div>
</div> </div>
<div className="mb-5 grid gap-3 sm:grid-cols-2"> <div className="mb-5 grid gap-3 sm:grid-cols-2">
@@ -178,5 +206,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
)} )}
</div> </div>
</div> </div>
<AddEventModal isOpen={editOpen} onClose={() => setEditOpen(false)} editEvent={event} />
</>
); );
} }
@@ -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", () => ({ vi.mock("@/lib/countdown-utils", () => ({
calculateCountdown: () => ({ calculateCountdown: () => ({
label: "No deadline set", label: "No deadline set",
+118
View File
@@ -23,6 +23,7 @@
"@timetoleave/core": "*", "@timetoleave/core": "*",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-calendar": "~15.0.8", "expo-calendar": "~15.0.8",
"expo-dev-client": "~6.0.21",
"expo-location": "~19.0.8", "expo-location": "~19.0.8",
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
@@ -9733,6 +9734,79 @@
"react-native": "*" "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": { "node_modules/expo-file-system": {
"version": "19.0.22", "version": "19.0.22",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
@@ -9757,6 +9831,12 @@
"react-native": "*" "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": { "node_modules/expo-keep-awake": {
"version": "15.0.8", "version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -9767,6 +9847,19 @@
"react": "*" "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": { "node_modules/expo-modules-autolinking": {
"version": "3.0.25", "version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
@@ -9818,6 +9911,15 @@
"react-native": "*" "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": { "node_modules/expo/node_modules/ansi-styles": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@@ -9905,6 +10007,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+17 -3
View File
@@ -6,6 +6,7 @@ import type {
CalendarEvent, CalendarEvent,
Journey, Journey,
Station, Station,
WienerLinienDeparture,
} from "@timetoleave/core"; } from "@timetoleave/core";
import { hafasDateTime } 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; if (stations.length === 0) return null;
// Filter to only "S" (station) type results, then pick the closest // Filter to only "S" (station) type results, then pick the closest
const stationResults = stations.filter((s) => s.type === "S"); const stationResults = stations.filter((s: HafasLocation) => s.type === "S");
if (stationResults.length === 0) return null; if (stationResults.length === 0) return null;
// Find the closest station by Euclidean distance // 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 bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng);
const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng); const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng);
return candDist < bestDist ? candidate : best; return candDist < bestDist ? candidate : best;
@@ -229,4 +230,17 @@ export class ApiClient {
return closest; return closest;
} }
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {
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 ?? [];
}
} }