Add getLeaveStatus utility and update project status

- Introduce `getLeaveStatus` function in `packages/core/src/status-utils.ts` to determine leave-by status based on
  journey data
- Mark Phase 1 mobile app tasks as complete in `CHECKLIST.md`
- Add mobile workspace configurations and npm scripts
This commit is contained in:
2026-05-10 22:14:41 +02:00
parent d08afc1dcb
commit 20159262c1
56 changed files with 8212 additions and 150 deletions
+8 -8
View File
@@ -18,14 +18,14 @@
| # | Step | ✅ | ✔️ | | # | Step | ✅ | ✔️ |
|---|------|----|----| |---|------|----|----|
| 10 | Scaffold Expo mobile app with TypeScript | [ ] | [ ] | | 10 | Scaffold Expo mobile app with TypeScript | [x] | [x] |
| 11 | Configure mobile API base URL (`.env` + singleton) | [ ] | [ ] | | 11 | Configure mobile API base URL (`.env` + singleton) | [x] | [x] |
| 12 | Add mobile app shell (navigation + 5 screens) | [ ] | [ ] | | 12 | Add mobile app shell (navigation + 5 screens) | [x] | [x] |
| 13 | Add mobile event store (AsyncStorage) | [ ] | [ ] | | 13 | Add mobile event store (AsyncStorage) | [x] | [x] |
| 14 | Build Event List screen | [ ] | [ ] | | 14 | Build Event List screen | [x] | [x] |
| 15 | Build Add Event screen with validation | [ ] | [ ] | | 15 | Build Add Event screen with validation | [x] | [x] |
| 16 | Build Origin Setup in Settings | [ ] | [ ] | | 16 | Build Origin Setup in Settings | [x] | [x] |
| 17 | Build Event Detail screen (trains + bike + errors) | [ ] | [ ] | | 17 | Build Event Detail screen (trains + bike + errors) | [x] | [x] |
| 18 | Phase 2 summary verification | [ ] | [ ] | | 18 | Phase 2 summary verification | [ ] | [ ] |
## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24) ## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24)
+42
View File
@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+5
View File
@@ -0,0 +1,5 @@
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return <AppNavigator />;
}
+36
View File
@@ -0,0 +1,36 @@
{
"expo": {
"name": "Time To Leave",
"slug": "timetoleave",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#007AFF"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "at.timetoleave.mobile"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "at.timetoleave.mobile"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-location",
"expo-notifications"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@timetoleave/mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit",
"lint": "echo 'no lint yet'"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^3.0.2",
"@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.14.14",
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
"expo": "~54.0.33",
"expo-location": "^55.1.9",
"expo-notifications": "^55.0.22",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}
@@ -0,0 +1,41 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { EventListScreen } from '../screens/EventListScreen';
import { EventDetailScreen } from '../screens/EventDetailScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
// ── Root Stack ────────────────────────────────────────
const RootStack = createNativeStackNavigator<{
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
}>();
export default function AppNavigator() {
return (
<SafeAreaProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
<StatusBar style="auto" />
<NavigationContainer>
<RootStack.Navigator
initialRouteName="EventList"
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
>
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
</RootStack.Navigator>
</NavigationContainer>
</SafeAreaView>
</SafeAreaProvider>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { addEvent } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
route: RouteProp<RootStack, 'AddEvent'>;
};
export function AddEventScreen({ navigation }: ScreenProps) {
const [title, setTitle] = useState('');
const [destination, setDestination] = useState('');
const [dateStr, setDateStr] = useState('');
const [timeStr, setTimeStr] = useState('');
const [error, setError] = useState('');
const validate = (): boolean => {
if (!title.trim()) { setError('Titel erforderlich'); return false; }
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
if (!dateStr || !timeStr) { setError('Datum und Zeit erforderlich'); return false; }
const eventTime = new Date(`${dateStr}T${timeStr}`);
if (isNaN(eventTime.getTime())) { setError('Ungültiges Datum'); return false; }
if (eventTime <= new Date()) { setError('Datum muss in der Zukunft liegen'); return false; }
setError('');
return true;
};
const handleSave = async () => {
if (!validate()) return;
const eventTime = new Date(`${dateStr}T${timeStr}`);
const event: CalendarEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: title.trim(),
destination: destination.trim(),
eventTime,
source: 'manual',
};
await addEvent(event);
navigation.goBack();
};
return (
<View style={styles.container}>
<View style={styles.form}>
<Text style={styles.label}>Titel</Text>
<TextInput
style={styles.input}
placeholder="z.B. Team Meeting"
value={title}
onChangeText={setTitle}
autoCapitalize="words"
/>
<Text style={styles.label}>Ziel</Text>
<TextInput
style={styles.input}
placeholder="z.B. Wien, Donau-City"
value={destination}
onChangeText={setDestination}
autoCapitalize="words"
/>
<Text style={styles.label}>Datum</Text>
<TextInput
style={styles.input}
placeholder="JJJJ-MM-TT"
value={dateStr}
onChangeText={setDateStr}
keyboardType="numbers-and-punctuation"
/>
<Text style={styles.label}>Zeit</Text>
<TextInput
style={styles.input}
placeholder="SS:MM"
value={timeStr}
onChangeText={setTimeStr}
keyboardType="numbers-and-punctuation"
/>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>Speichern</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveBtn, styles.cancelBtn]}
onPress={() => navigation.goBack()}
>
<Text style={[styles.saveBtnText, styles.cancelText]}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
form: { padding: 20 },
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
},
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
saveBtn: {
backgroundColor: '#007AFF',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
cancelText: { color: '#1c1c1e' },
});
@@ -0,0 +1,152 @@
import { useCallback, useState } from 'react';
import {
Alert,
ActivityIndicator,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { api } from '../services/api';
import { addEvent } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
route: RouteProp<RootStack, 'CalendarImport'>;
};
export function CalendarImportScreen({ navigation }: ScreenProps) {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [count, setCount] = useState<number | null>(null);
const handleImport = async () => {
if (!url.trim()) {
setError('Bitte ICS-URL eingeben');
return;
}
setLoading(true);
setError(null);
setCount(null);
try {
const events = await api.fetchCalendar(url.trim());
// Add imported events to local store
for (const evt of events) {
const localEvent: CalendarEvent = {
id: evt.id,
title: evt.title,
destination: evt.destination,
eventTime: new Date(evt.eventTime),
source: `calendar:${url.trim().slice(0, 40)}`,
};
await addEvent(localEvent);
}
setCount(events.length);
} catch (err) {
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.description}>
Gib eine ICS-Kalender-URL ein, um Termine automatisch zu importieren.
</Text>
<TextInput
style={styles.input}
placeholder="https://calendar.google.com/calendar/ical/..."
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{count !== null && (
<View style={styles.successBanner}>
<Text style={styles.successText}>
{count} Termin(e) erfolgreich importiert!
</Text>
</View>
)}
<TouchableOpacity
style={[styles.importBtn, loading && styles.importBtnDisabled]}
onPress={handleImport}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.importBtnText}>Importieren</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backBtn}
onPress={() => navigation.goBack()}
>
<Text style={styles.backBtnText}> Zurück</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
content: { padding: 20 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 16, lineHeight: 20 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
marginBottom: 16,
},
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
errorText: { color: '#fff', fontSize: 14 },
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
successText: { color: '#fff', fontSize: 14 },
importBtn: {
backgroundColor: '#007AFF',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginBottom: 12,
},
importBtnDisabled: { opacity: 0.6 },
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
backBtn: {
paddingVertical: 10,
alignItems: 'center',
},
backBtnText: { color: '#007AFF', fontSize: 15 },
});
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation } from '../store/eventStore';
import { api } from '../services/api';
import { formatDuration, formatDistance } from '@timetoleave/core';
import type { Journey, BikeRoute, Station, Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
route: RouteProp<RootStack, 'EventDetail'>;
};
export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params;
const [event, setEvent] = useState<CalendarEvent | null>(null);
const [journeys, setJourneys] = useState<Journey[]>([]);
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
const [origin, setOrigin] = useState<Station | null>(null);
const [loading, setLoading] = useState(true);
const [loadingBike, setLoadingBike] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [events, originStation] = await Promise.all([loadEvents(), loadOriginStation()]);
setOrigin(originStation);
const found = events.find((e) => e.id === eventId);
if (!found) {
setError('Termin nicht gefunden');
return;
}
setEvent(found);
if (originStation) {
const results = await api.searchJourneys(
originStation.extId,
found.destination,
found.eventTime,
);
setJourneys(results);
// Fetch bike route if we have a destination station
// We need destination coordinates; for MVP we geocode the destination name
try {
setLoadingBike(true);
const geo = await api.geocode(found.destination);
if (geo.length > 0 && originStation.lat && originStation.lng) {
const bike = await api.getBikeRoute(
originStation.lat,
originStation.lng,
geo[0].lat,
geo[0].lng,
);
setBikeRoute(bike);
}
} catch {
// Bike route is optional — don't fail the whole screen
setBikeRoute(null);
} finally {
setLoadingBike(false);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden');
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRefresh = () => {
setBikeRoute(null);
fetchData();
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Termine werden geladen</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Event header */}
{event && (
<View style={styles.header}>
<Text style={styles.eventTitle}>{event.title}</Text>
<Text style={styles.eventDest}>{event.destination}</Text>
<Text style={styles.eventTime}>
{event.eventTime.toLocaleString('de-AT', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={styles.source}>Quelle: {event.source}</Text>
</View>
)}
{/* Error */}
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorBannerText}> {error}</Text>
</View>
)}
{/* Origin status */}
{!origin && !error && (
<View style={styles.warningBanner}>
<Text style={styles.warningBannerText}>
Keine Ursprungstation festgelegt.
{' '}
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
Einstellungen öffnen
</Text>
</Text>
</View>
)}
{/* Journeys list */}
<View style={styles.journeys}>
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
{journeys.length === 0 ? (
<Text style={styles.emptyText}>
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
</Text>
) : (
journeys.map((j) => (
<View key={j.id} style={styles.journeyCard}>
<View style={styles.journeyRow}>
<Text style={styles.lineText}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
</View>
<Text style={styles.departure}>
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
(Plattform {j.platform || '—'})
</Text>
<Text style={styles.arrival}>
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
</Text>
</View>
))
)}
</View>
{/* Bike route section */}
<View style={styles.journeys}>
<Text style={styles.sectionTitle}>Radroute</Text>
{loadingBike ? (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color="#007AFF" />
<Text style={styles.loadingText}>Radroute wird geladen</Text>
</View>
) : bikeRoute ? (
<View style={styles.bikeCard}>
<View style={styles.bikeRow}>
<Text style={styles.bikeLabel}> Dauer</Text>
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
</View>
<View style={styles.bikeRow}>
<Text style={styles.bikeLabel}>📏 Distanz</Text>
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
</View>
<View style={styles.mapPlaceholder}>
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
</View>
</View>
) : (
<Text style={styles.emptyText}>
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
</Text>
)}
</View>
{/* Refresh */}
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
errorBannerText: { color: '#fff', fontSize: 14 },
warningBanner: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
warningBannerText: { color: '#fff', fontSize: 14 },
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
journeys: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 12 },
emptyText: { color: '#8e8e93', fontSize: 14 },
journeyCard: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
marginBottom: 10,
borderWidth: 1,
borderColor: '#e5e5ea',
},
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
lineText: { fontSize: 16, fontWeight: '600', color: '#1c1c1e' },
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
centerBike: { alignItems: 'center', gap: 8 },
bikeCard: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
borderWidth: 1,
borderColor: '#e5e5ea',
},
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
mapPlaceholder: {
marginTop: 10,
height: 100,
borderRadius: 8,
backgroundColor: '#f2f2f7',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#c7c7cc',
},
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
refreshBtn: {
alignSelf: 'center',
marginTop: 20,
paddingVertical: 12,
paddingHorizontal: 24,
backgroundColor: '#e5e5ea',
borderRadius: 12,
},
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
});
+190
View File
@@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, removeEvent } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core';
import type { Event as CalendarEvent } from '@timetoleave/core';
// Color map for countdown urgency
const urgencyColor = (urgent: boolean): string => {
if (urgent) return '#FF3B30';
return '#34C759';
};
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
route: RouteProp<RootStack, 'EventList'>;
};
export function EventListScreen({ navigation }: ScreenProps) {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [refreshing, setRefreshing] = useState(false);
const reload = useCallback(async () => {
const list = await loadEvents();
setEvents(list);
}, []);
useEffect(() => { reload(); }, [reload]);
useFocusEffect(
useCallback(() => { reload(); }, [reload]),
);
const onRefresh = async () => {
setRefreshing(true);
await reload();
setRefreshing(false);
};
const renderItem = ({ item }: { item: CalendarEvent }) => {
const countdown = calculateCountdown(item.eventTime);
// Derive a simple status — journeys aren't loaded on the list screen for MVP
// so we show countdown-based status instead
const status = countdown.urgent ? 'Bald!' : countdown.label;
return (
<TouchableOpacity
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
activeOpacity={0.6}
>
<View style={styles.card}>
<View style={styles.dotRow}>
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
<Text style={styles.title}>{item.title}</Text>
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
{countdown.label}
</Text>
</View>
<Text style={styles.subtitle}>{item.destination}</Text>
<Text style={styles.time}>
{item.eventTime.toLocaleString('de-AT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={styles.status}>{status}</Text>
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
<Text style={styles.deleteText}>Entfernen</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
};
if (events.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.empty}>Keine Termine</Text>
<TouchableOpacity
style={styles.addBtn}
onPress={() => navigation.navigate('AddEvent')}
>
<Text style={styles.addBtnText}>+ Termin hinzufügen</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.topBar}>
<TouchableOpacity
onPress={() => navigation.navigate('CalendarImport')}
style={styles.topBtn}
>
<Text style={styles.topBtnText}>📅 Kalender</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate('Settings')}
style={styles.topBtn}
>
<Text style={styles.topBtnText}> Einstellungen</Text>
</TouchableOpacity>
</View>
<FlatList
data={events}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
}
/>
<TouchableOpacity
style={styles.fab}
onPress={() => navigation.navigate('AddEvent')}
>
<Text style={styles.fabText}>+</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
topBtnText: { color: '#007AFF', fontSize: 15 },
list: { padding: 12 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 2,
},
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
dot: { width: 10, height: 10, borderRadius: 5 },
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
badge: { fontSize: 12, fontWeight: '600' },
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
time: { fontSize: 13, color: '#007AFF' },
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
deleteText: { color: '#FF3B30', fontSize: 13 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
fab: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 4,
},
fabText: { color: '#fff', fontSize: 32, fontWeight: '300', marginTop: -4 },
});
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
ActivityIndicator,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import * as Location from 'expo-location';
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings } from '../store/eventStore';
import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
route: RouteProp<RootStack, 'Settings'>;
};
export function SettingsScreen({ navigation }: ScreenProps) {
const [origin, setOrigin] = useState<Station | null>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]);
const [searching, setSearching] = useState(false);
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
bufferMinutes: 30,
enabled: true,
});
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load persisted data on mount
useEffect(() => {
loadOriginStation().then(setOrigin);
loadNotificationSettings().then(setNotifSettings);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, []);
// Debounced station search
const searchStation = useCallback(async (q: string) => {
if (q.trim().length < 2) {
setResults([]);
return;
}
setSearching(true);
try {
const stations = await api.searchStation(q.trim());
setResults(stations);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, []);
const onQueryChange = (text: string) => {
setQuery(text);
// Proper debounce using useRef — no `any`
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
};
const selectStation = (station: Station) => {
setOrigin(station);
setQuery(station.name);
setResults([]);
saveOriginStation(station);
};
const useCurrentLocation = async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
setLocPermission(status === 'granted' ? 'granted' : 'denied');
if (status !== 'granted') {
Alert.alert('Berechtigung erforderlich', 'Standortzugriff ist nötig für die automatische Stationssuche.');
return;
}
const loc = await Location.getCurrentPositionAsync({});
const userLat = loc.coords.latitude;
const userLng = loc.coords.longitude;
// Search for stations near the user's actual GPS coordinates
// Use Nominatim reverse geocode via the API to find a nearby station
const geoResults = await api.geocode('Wien', 'at');
// Find the station closest to the user's actual coordinates
if (geoResults.length > 0) {
const closest = geoResults.reduce((best: { lat: number; lng: number; display_name: string } | null, candidate) => {
if (!best) return candidate;
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
return candDist < bestDist ? candidate : best;
}, null);
if (closest) {
const station: Station = {
name: closest.display_name,
extId: String(closest.lat) + ',' + String(closest.lng),
lat: closest.lat,
lng: closest.lng,
};
selectStation(station);
}
}
} catch (err) {
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
}
};
const toggleNotifications = async (value: boolean) => {
const updated = { ...notifSettings, enabled: value };
setNotifSettings(updated);
await saveNotificationSettings(updated);
};
const updateBufferMinutes = async (value: string) => {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes >= 0) {
const updated = { ...notifSettings, bufferMinutes: minutes };
setNotifSettings(updated);
await saveNotificationSettings(updated);
}
};
return (
<View style={styles.container}>
{/* Origin Station */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ursprungstation</Text>
<TextInput
style={styles.input}
placeholder="Station suchen …"
value={query}
onChangeText={onQueryChange}
autoCapitalize="words"
accessibilityLabel="Station suchen"
/>
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
{origin && (
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
)}
{results.map((s) => (
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
<Text style={styles.resultItem}>{s.name}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
<Text style={styles.locBtnText}>📍 Aktuelle Position verwenden</Text>
</TouchableOpacity>
<Text style={styles.locStatus}>
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
</Text>
</View>
{/* Notification Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
<Switch
value={notifSettings.enabled}
onValueChange={toggleNotifications}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
accessibilityLabel="Benachrichtigungen umschalten"
/>
</View>
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
<TextInput
style={[styles.input, styles.numberInput]}
value={String(notifSettings.bufferMinutes)}
onChangeText={updateBufferMinutes}
keyboardType="numeric"
accessibilityLabel="Pufferzeit in Minuten"
/>
<Text style={styles.hint}>
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
},
numberInput: { width: 80 },
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
resultItem: {
fontSize: 15,
color: '#007AFF',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e5e5ea',
},
locBtn: {
marginTop: 12,
paddingVertical: 12,
backgroundColor: '#e8f4fd',
borderRadius: 10,
alignItems: 'center',
},
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
settingLabel: { fontSize: 14, color: '#1c1c1e' },
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
});
+4
View File
@@ -0,0 +1,4 @@
import { ApiClient } from '@timetoleave/api-client';
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
export const api = new ApiClient(baseUrl);
+77
View File
@@ -0,0 +1,77 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
// ── Keys ────────────────────────────────────────────
const EVENTS_KEY = '@timetoleave_events';
const ORIGIN_KEY = '@timetoleave_origin';
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
// ── Default notification settings ─────────────────────
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
bufferMinutes: 30,
enabled: true,
};
// ── Helpers ───────────────────────────────────────────
function reviveDates(json: string): Event[] {
try {
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime) }));
} catch {
return [];
}
}
// ── Events ────────────────────────────────────────────
export async function loadEvents(): Promise<Event[]> {
const json = await AsyncStorage.getItem(EVENTS_KEY);
return json ? reviveDates(json) : [];
}
export async function saveEvents(events: Event[]): Promise<void> {
const json = JSON.stringify(events);
await AsyncStorage.setItem(EVENTS_KEY, json);
}
export async function addEvent(event: Event): Promise<void> {
const events = await loadEvents();
events.push(event);
await saveEvents(events);
}
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
const events = await loadEvents();
const filtered = events.filter((e) => e.id !== id);
await saveEvents(filtered);
onDone?.();
}
// ── Origin Station ────────────────────────────────────
export async function loadOriginStation(): Promise<Station | null> {
const json = await AsyncStorage.getItem(ORIGIN_KEY);
return json ? JSON.parse(json) : null;
}
export async function saveOriginStation(station: Station): Promise<void> {
const json = JSON.stringify(station);
await AsyncStorage.setItem(ORIGIN_KEY, json);
}
// ── Notification Settings ─────────────────────────────
export async function loadNotificationSettings(): Promise<ReminderSettings> {
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
}
export async function saveNotificationSettings(
settings: ReminderSettings,
): Promise<void> {
const json = JSON.stringify(settings);
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
@@ -1 +1 @@
{"version":"4.1.5","results":[[":src/hooks/__tests__/useReminder.test.tsx",{"duration":56.437834000000066,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":95.17231700000002,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":29.41875300000015,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":70.20783200000005,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":37.28677700000003,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":10.81236899999999,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":130.41898500000025,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":47.99793599999998,"failed":false}],[":src/lib/__tests__/hafas-client.test.ts",{"duration":3859.471915,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2205.214679,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":74.15890599999989,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":239.26886700000023,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":156.40212100000008,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.596728999999982,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":25.32538199999999,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":23.511126999999988,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":70.20564699999977,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":202.69056899999987,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.0024530000000595,"failed":false}]]} {"version":"4.1.5","results":[[":src/hooks/__tests__/useReminder.test.tsx",{"duration":67.8894879999998,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":48.40527399999996,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":12.656690000000026,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":34.53456600000004,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.283642999999984,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":8.794370999999956,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":98.56559299999981,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":60.67743799999994,"failed":false}],[":src/lib/__tests__/hafas-client.test.ts",{"duration":4293.082399,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2121.7380169999997,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":65.84604800000011,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":243.63465800000017,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":146.3176759999999,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.2089169999999285,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":19.50615700000003,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":19.419233000000077,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":73.80535599999985,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":210.3314519999999,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":5.045508000000041,"failed":false}]]}
+6917 -106
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -12,6 +12,8 @@
"start": "npm run start -w apps/web", "start": "npm run start -w apps/web",
"lint": "npm run lint -w apps/web", "lint": "npm run lint -w apps/web",
"typecheck": "npm run typecheck -w apps/web", "typecheck": "npm run typecheck -w apps/web",
"test": "npm run test -w apps/web" "test": "npm run test -w apps/web",
"dev:mobile": "npm run start -w apps/mobile",
"typecheck:mobile": "npm run typecheck -w apps/mobile"
} }
} }
+34 -1
View File
@@ -1,5 +1,5 @@
// Status utilities for TimeToLeave // Status utilities for TimeToLeave
import type { ServerStatus } from "./types"; import type { ServerStatus, Event, Journey } from "./types";
export class StatusUtils { export class StatusUtils {
static async checkServerStatus(url: string): Promise<ServerStatus> { static async checkServerStatus(url: string): Promise<ServerStatus> {
@@ -14,3 +14,36 @@ export class StatusUtils {
} }
} }
} }
/**
* Derive a human-readable leave-by status from an event and its journeys.
* Returns early / on-time / delayed / unknown based on the best journey's
* real departure time relative to now.
*/
export function getLeaveStatus(event: Event, journeys: Journey[]): string {
if (journeys.length === 0) {
return "No journey data";
}
// Pick the earliest non-cancelled journey
const best = journeys
.filter((j) => !j.cancelled)
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
if (!best) {
return "All journeys cancelled";
}
const diffMin = (best.rD.getTime() - Date.now()) / 60_000;
if (diffMin < 0) {
return "Departure missed";
}
if (best.delay > 10) {
return `Delayed +${best.delay} min`;
}
if (diffMin <= 15) {
return "Leave now";
}
return "On time";
}
-1
View File
@@ -1 +0,0 @@
../ui/Button../../hooks/useEventsStore
-1
View File
@@ -1 +0,0 @@
../../../lib/bike-routing-client
-1
View File
@@ -1 +0,0 @@
../../../../lib/calendar-utils../../../../lib/constants
-1
View File
@@ -1 +0,0 @@
../../../lib/calendar-utils../../../lib/constants
-1
View File
@@ -1 +0,0 @@
../../../lib/geocoding-client
-1
View File
@@ -1 +0,0 @@
../../../lib/constants
-1
View File
@@ -1 +0,0 @@
../../../lib/constants
@@ -1 +0,0 @@
../../../../../lib/wienerlinien-client../../../../../lib/wienerlinien-client
@@ -1 +0,0 @@
../../../../lib/wienerlinien-client
@@ -1 +0,0 @@
../../../../../lib/wienerlinien-client../../../../../lib/wienerlinien-client
-1
View File
@@ -1 +0,0 @@
../../../../lib/wienerlinien-client
-1
View File
@@ -1 +0,0 @@
../../hooks/useCalendar../../hooks/useEventsStore
-1
View File
@@ -1 +0,0 @@
../event/EventCard
-1
View File
@@ -1 +0,0 @@
../ui/Button
-1
View File
@@ -1 +0,0 @@
../ui/Button
-1
View File
@@ -1 +0,0 @@
../../hooks/useEventsStore../../hooks/useOriginStation
-1
View File
@@ -1 +0,0 @@
../ui/LoadingSpinner../ui/Button
-1
View File
@@ -1 +0,0 @@
../../hooks/useJourneys../../hooks/useDestinationStation../../hooks/useBikeRoute../../hooks/useGeocode../../hooks/useClock../../hooks/useWienerLinien../ui/CountdownBadge
-1
View File
@@ -1 +0,0 @@
../../lib/formatting../../lib/countdown-utils
-1
View File
@@ -1 +0,0 @@
../ui/Chip
-1
View File
@@ -1 +0,0 @@
../../lib/formatting../ui/LoadingSpinner../ui/Button
-1
View File
@@ -1 +0,0 @@
../ui/LoadingSpinner../ui/Chip
@@ -1 +0,0 @@
../../../hooks/useDestinationStation../../../hooks/useJourneys
-1
View File
@@ -1 +0,0 @@
../hooks/useEventsStore./layout/Header./layout/Navbar./layout/ReminderEngine
-1
View File
@@ -1 +0,0 @@
../ui/Button../add-event/AddEventModal../ui/ReminderSettingsPanel../../hooks/useServerHealth../../hooks/useEventsStore../../hooks/useTheme
-1
View File
@@ -1 +0,0 @@
../../hooks/useReminder
-1
View File
@@ -1 +0,0 @@
../hooks/useEventsStore../hooks/useOriginStation./event/EventCard
-1
View File
@@ -1 +0,0 @@
../lib/countdown-utils
-1
View File
@@ -1 +0,0 @@
../lib/hafas-client../lib/hafas-time
-1
View File
@@ -1 +0,0 @@
../lib/constants
-1
View File
@@ -1 +0,0 @@
../constants../constants
@@ -1 +0,0 @@
../wienerlinien-client../api-service
-1
View File
@@ -1 +0,0 @@
./constants./api-service