feat(backend): subtask category support (schema, validation, sync, routes)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user