feat(backend): subtask category support (schema, validation, sync, routes)

This commit is contained in:
2026-08-10 11:41:25 +02:00
parent 4c3d1a118c
commit f3bcd78e49
15 changed files with 451 additions and 4 deletions
+286
View File
@@ -0,0 +1,286 @@
import React, { useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { SubtaskData } from '@/types';
import { format, startOfDay } from 'date-fns';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
function isDayMatch(timestamp: number, day: Date): boolean {
const d = new Date(timestamp);
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
}
export default function DayViewScreen() {
const router = useRouter();
const { theme } = useSettings();
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const day = useMemo(() => {
const parsed = dateParam ? new Date(dateParam) : new Date();
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}, [dateParam]);
const dayStart = useMemo(() => startOfDay(day), [day]);
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
refreshSubtasks();
refresh();
return () => sub.remove();
}, [refreshSubtasks, refresh])
);
const subtasksOnDay = useMemo(() => {
const map = new Map<string, SubtaskData[]>();
for (const [taskId, roots] of subtasksByTask) {
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
if (due.length > 0) map.set(taskId, due);
}
return map;
}, [subtasksByTask, day]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refresh();
refreshSubtasks();
}, [refresh, refreshSubtasks]);
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks();
}, [refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{loading ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
</View>
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
</View>
) : (
<>
{dayTasks.map((task) => {
const subs = subtasksOnDay.get(task.id) ?? [];
const openCount = subs.filter((s) => !s.completed).length;
const cat = categories.find((c) => c.id === task.categoryId);
return (
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleComplete(task.id)}
activeOpacity={0.7}
accessibilityRole="checkbox"
accessibilityState={{ checked: task.completed }}
>
<Svg width={30} height={30} viewBox="0 0 24 24">
{task.completed ? (
<>
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.eventTouch}
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
numberOfLines={1}
>
{task.title}
</Text>
{openCount > 0 && (
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
{openCount} open subtask{openCount === 1 ? '' : 's'}
</Text>
)}
{!task.completed && (
<View style={styles.metaRow}>
{cat && (
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
</View>
)}
{task.dueTime ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
) : task.dueDate ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
) : null}
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={() => openTaskEdit(task.id)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Edit ${task.title}`}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View>
</View>
);
})}
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
const parent = dayTasks.find((t) => t.id === taskId);
if (parent) return [];
return subs.map((sub) => (
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleSubtask(sub.id)}
activeOpacity={0.7}
accessibilityRole="checkbox"
accessibilityState={{ checked: sub.completed }}
>
<Svg width={30} height={30} viewBox="0 0 24 24">
{sub.completed ? (
<>
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.eventTouch}
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
numberOfLines={1}
>
{sub.title}
</Text>
{sub.dueTime ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
) : null}
</TouchableOpacity>
</View>
</View>
));
})}
</>
)}
</ScrollView>
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
</KeyboardAvoidingView>
{modals(() => {})}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
scrollBody: {
flex: 1,
},
scrollContent: {
paddingTop: 12,
paddingBottom: 16,
},
emptyState: {
alignItems: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 15,
fontWeight: '600',
},
eventCard: {
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkCircle: {
width: 30,
marginRight: 12,
},
eventTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
},
eventSub: {
fontSize: 13,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 4,
},
tagChip: {
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 6,
maxWidth: 140,
},
tagText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
timeText: {
fontSize: 12.5,
fontWeight: '500',
},
menuButton: {
padding: 4,
marginLeft: 6,
},
});