Add mobile services, web proxy, and Gemma4 agent loop

- Mobile: add push notification and calendar sync services with full test suite (calendar, eventStore, notifications,
  screens)
- Mobile: add EAS build config and Jest setup
- Web: add API proxy, update useDestinationStation/useJourneys hooks, add middleware tests
- Web: update next.config and rebuild
- Agent: add Gemma4-based agent loop (agent_base, ttl_agent, ts_agent)
- Docs: add privacy policy, post-MVP plan, and aider rules
This commit is contained in:
2026-05-11 18:32:54 +02:00
parent 20159262c1
commit 5bcfafcbaf
120 changed files with 8626 additions and 860 deletions
@@ -11,7 +11,8 @@ import {
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 { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
@@ -64,21 +65,85 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
}
};
const handleSyncNative = async () => {
setLoading(true);
setError(null);
setCount(null);
try {
// Fetch events from the next 30 days
const now = new Date();
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater);
// Load existing events to avoid duplicates
const existing = await loadEvents();
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of nativeEvents) {
if (!existingIds.has(evt.id)) {
await addEvent(evt);
added++;
}
}
setCount(added);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync fehlgeschlagen');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.heading}>Kalender-Import</Text>
<Text style={styles.description}>
Gib eine ICS-Kalender-URL ein, um Termine automatisch zu importieren.
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
</Text>
<TextInput
style={styles.input}
placeholder="https://calendar.google.com/calendar/ical/..."
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
<View style={styles.section}>
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
<TextInput
style={styles.input}
placeholder="https://calendar.google.com/calendar/ical/..."
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
<TouchableOpacity
style={[styles.importBtn, loading && styles.importBtnDisabled]}
onPress={handleImport}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.importBtnText}>ICS Importieren</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Geräte-Kalender Sync</Text>
<Text style={styles.sectionDesc}>
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
</Text>
<TouchableOpacity
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
onPress={handleSyncNative}
disabled={loading}
>
<Text style={styles.importBtnText}>📅 Kalender Sync</Text>
</TouchableOpacity>
</View>
{error && (
<View style={styles.errorBanner}>
@@ -94,18 +159,6 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
</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()}
@@ -120,7 +173,11 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
content: { padding: 20 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 16, lineHeight: 20 },
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
@@ -129,7 +186,7 @@ const styles = StyleSheet.create({
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
marginBottom: 16,
marginBottom: 12,
},
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
errorText: { color: '#fff', fontSize: 14 },
@@ -140,7 +197,9 @@ const styles = StyleSheet.create({
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginBottom: 12,
},
nativeBtn: {
backgroundColor: '#5856D6',
},
importBtnDisabled: { opacity: 0.6 },
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },