feat(app): subtask categories, live refresh on save, nested subtask fixes

This commit is contained in:
2026-08-10 11:41:25 +02:00
parent f3bcd78e49
commit 2ca23e276f
22 changed files with 518 additions and 307 deletions
+57 -84
View File
@@ -1,19 +1,18 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks'; import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals'; import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useCategories, useDatabase } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete } from '@/utils/taskActions'; import { toggleTaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { SubtaskData } from '@/types';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns'; import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme'; import { desaturate } from '@/theme';
import type { ThemeColors } from '@/theme'; import type { ThemeColors } from '@/theme';
@@ -28,7 +27,6 @@ const MONTH_NAMES = [
export default function CalendarScreen() { export default function CalendarScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { collections } = useDatabase();
const categories = useCategories(); const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals(); const { modals, openTaskEdit } = useTaskModals();
@@ -36,27 +34,31 @@ export default function CalendarScreen() {
const [selectedDate, setSelectedDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date());
const [monthPickerVisible, setMonthPickerVisible] = useState(false); const [monthPickerVisible, setMonthPickerVisible] = useState(false);
const [yearPickerVisible, setYearPickerVisible] = useState(false); const [yearPickerVisible, setYearPickerVisible] = useState(false);
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({}); const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const visibleMonthRef = useRef(visibleMonth); const visibleMonthRef = useRef(visibleMonth);
visibleMonthRef.current = visibleMonth; visibleMonthRef.current = visibleMonth;
const selectedDateRef = useRef(selectedDate); const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate; selectedDateRef.current = selectedDate;
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh;
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true); const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
return () => sub.remove(); return () => sub.remove();
}, []) }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
); );
const translateX = useRef(new Animated.Value(0)).current; const translateX = useRef(new Animated.Value(0)).current;
const animatingRef = useRef(false); const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH); const gridWidthRef = useRef<number>(WIDTH);
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
const weeks = useMemo(() => { const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth); const first = startOfMonth(visibleMonth);
const offset = (first.getDay() + 6) % 7; // week starts Monday const offset = (first.getDay() + 6) % 7; // week starts Monday
@@ -67,67 +69,22 @@ export default function CalendarScreen() {
return rows; return rows;
}, [visibleMonth]); }, [visibleMonth]);
const loadSubtasks = useCallback(
async (taskId: string): Promise<[string, SubtaskData[]]> => {
const subs = await collections.subtasks
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
.fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
}));
return [taskId, mapped];
},
[collections.subtasks]
);
useEffect(() => {
let active = true;
setSubtasksMap({});
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
if (!active) return;
setSubtasksMap(Object.fromEntries(entries));
});
return () => {
active = false;
};
}, [selectedDayTasks, loadSubtasks]);
const handleDayPress = useCallback((day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
setVisibleMonth(day);
}
}, []);
const transitionTo = useCallback( const transitionTo = useCallback(
(dir: 1 | -1) => { (dir: 1 | -1, animate = true) => {
if (animatingRef.current) return; if (animatingRef.current) return;
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
if (!animate) {
setVisibleMonth(next);
translateX.setValue(0);
return;
}
animatingRef.current = true; animatingRef.current = true;
const w = gridWidthRef.current || WIDTH; const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w; const target = dir === 1 ? -w : w;
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => { Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
setVisibleMonth(next); setVisibleMonth(next);
translateX.setValue(-target); translateX.setValue(-target);
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
@@ -138,6 +95,18 @@ export default function CalendarScreen() {
[translateX] [translateX]
); );
const handleDayPress = useCallback(
(day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
} else {
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
}
},
[router, transitionTo]
);
const pan = useMemo( const pan = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
@@ -153,10 +122,10 @@ export default function CalendarScreen() {
const dx = e.translationX; const dx = e.translationX;
if (dx <= -w / 4) { if (dx <= -w / 4) {
translateX.stopAnimation(); translateX.stopAnimation();
transitionTo(1); transitionTo(1, false);
} else if (dx >= w / 4) { } else if (dx >= w / 4) {
translateX.stopAnimation(); translateX.stopAnimation();
transitionTo(-1); transitionTo(-1, false);
} else { } else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start(); Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
} }
@@ -184,7 +153,10 @@ export default function CalendarScreen() {
const handleToggleComplete = useCallback(async (taskId: string) => { const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await toggleTaskComplete(taskId);
}, []); refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -217,11 +189,12 @@ export default function CalendarScreen() {
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
</View> </View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(1)} onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
@@ -316,15 +289,10 @@ export default function CalendarScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{(subtasksMap[task.id] ?? []).length > 0 && ( {(subtasksByTask.get(task.id) ?? []).length > 0 && (
<View style={styles.bullets}> <View style={styles.bullets}>
{(subtasksMap[task.id] ?? []).map((sub) => ( {(subtasksByTask.get(task.id) ?? []).map((sub) => (
<TouchableOpacity <View key={sub.id} style={styles.bulletRow}>
key={sub.id}
style={styles.bulletRow}
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
activeOpacity={0.7}
>
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}> <Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} /> <Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
</Svg> </Svg>
@@ -334,7 +302,7 @@ export default function CalendarScreen() {
> >
{sub.title} {sub.title}
</Text> </Text>
</TouchableOpacity> </View>
))} ))}
</View> </View>
)} )}
@@ -343,7 +311,10 @@ export default function CalendarScreen() {
)} )}
</ScrollView> </ScrollView>
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} /> <QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{modals(() => {})} {modals(() => {})}
@@ -461,7 +432,8 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
monthSelectorGroup: { monthSelectorGroup: {
alignItems: 'center', alignItems: 'flex-start',
flex: 1,
}, },
monthButton: { monthButton: {
flexDirection: 'row', flexDirection: 'row',
@@ -476,10 +448,11 @@ const styles = StyleSheet.create({
}, },
yearButton: { yearButton: {
marginTop: -2, marginTop: -2,
paddingHorizontal: 6,
}, },
yearLabel: { yearLabel: {
fontSize: 13, fontSize: 17,
fontWeight: '500', fontWeight: '700',
}, },
weekdayRow: { weekdayRow: {
flexDirection: 'row', flexDirection: 'row',
+54 -11
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter'; import { CategoryFilter } from '@/components/CategoryFilter';
@@ -7,11 +7,14 @@ import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export default function TasksScreen() { export default function TasksScreen() {
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const { theme } = useSettings(); const { theme, showCompleted, setShowCompleted } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all'); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -32,13 +35,41 @@ export default function TasksScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="ToDo" showLogo={false} /> <Header title="ToDo" showLogo={false} />
<View style={styles.categoryFilterWrapper}> <View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} /> <CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
<TouchableOpacity
style={[
styles.completedToggle,
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
]}
onPress={() => setShowCompleted(!showCompleted)}
activeOpacity={0.8}
accessibilityRole="switch"
accessibilityLabel="Show completed tasks"
accessibilityState={{ checked: showCompleted }}
>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle
cx={12}
cy={12}
r={9}
stroke={showCompleted ? theme.accent : theme.textMuted}
strokeWidth={2}
fill="none"
/>
{showCompleted && (
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
Completed
</Text>
</TouchableOpacity>
</View> </View>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.kbAvoid} style={styles.kbAvoid}
behavior="padding" behavior="padding"
> >
<TaskList categoryId={selectedCategory} /> <TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar /> <QuickAddBar />
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
}, },
categoryFilterWrapper: { categoryFilterWrapper: {
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
paddingRight: 12,
},
completedToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 1.5,
alignSelf: 'center',
},
completedToggleText: {
fontSize: 12,
fontWeight: '600',
}, },
kbAvoid: { kbAvoid: {
flex: 1, flex: 1,
}, },
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
}); });
+13 -1
View File
@@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings(); const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
const categories = useCategories(); const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null); const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null); const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
@@ -176,6 +176,18 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')} onPress={() => setPicker('sort')}
showChevron showChevron
/> />
<ListItem
title="Show Completed Tasks"
subtitle="Hide or show finished tasks in the Todo list"
rightElement={
<Switch
value={showCompleted}
onValueChange={setShowCompleted}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem <ListItem
title="Show Calendar Tasks" title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`} subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
+1
View File
@@ -33,6 +33,7 @@ function RootNavigator() {
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="add-task" /> <Stack.Screen name="add-task" />
<Stack.Screen name="task-detail" /> <Stack.Screen name="task-detail" />
<Stack.Screen name="day-view" />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
+11 -8
View File
@@ -17,7 +17,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { TaskFormData } from '@/types'; import { TaskFormData, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { scheduleTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -26,6 +26,7 @@ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -60,6 +61,7 @@ export default function AddTaskScreen() {
title: '', title: '',
description: '', description: '',
categoryId: initialCategory, categoryId: initialCategory,
tags: initialCategory ? [initialCategory] : [],
priority: 'none', priority: 'none',
dueDate: initialDate, dueDate: initialDate,
dueTime: '', dueTime: '',
@@ -83,7 +85,7 @@ export default function AddTaskScreen() {
formState: { errors }, formState: { errors },
} = methods; } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
@@ -94,10 +96,10 @@ export default function AddTaskScreen() {
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
React.useEffect(() => { React.useEffect(() => {
if (!categoryId && initialCategory) { if (tags.length === 0 && initialCategory) {
setValue('categoryId', initialCategory); setValue('tags', [initialCategory]);
} }
}, [initialCategory, categoryId, setValue]); }, [initialCategory, tags, setValue]);
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
if (!isReady) return; if (!isReady) return;
@@ -107,7 +109,7 @@ export default function AddTaskScreen() {
const seriesId = data.repeat !== 'none' const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}` ? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: ''; : '';
const resolvedCategoryId = data.categoryId || ''; const resolvedCategoryId = (data.tags && data.tags[0]) || '';
let createdTask: any = null; let createdTask: any = null;
@@ -116,6 +118,7 @@ export default function AddTaskScreen() {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = resolvedCategoryId; t.categoryId = resolvedCategoryId;
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.completed = false; t.completed = false;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
@@ -184,8 +187,8 @@ export default function AddTaskScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
+24 -8
View File
@@ -18,12 +18,14 @@ import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types'; import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions'; import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { CategorySelector } from '@/components/CategorySelector';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
const subtaskSchema = z.object({ const subtaskSchema = z.object({
title: z.string().trim().min(1, 'Subtask name is required').max(100), title: z.string().trim().min(1, 'Subtask name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
defaultValues: { defaultValues: {
title: '', title: '',
description: '', description: '',
categoryId: '',
priority: 'none', priority: 'none',
dueDate: null, dueDate: null,
dueTime: '', dueTime: '',
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const priority = watch('priority'); const priority = watch('priority');
const categoryId = watch('categoryId');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
if (!id || !isReady) return; if (!id || !isReady) return;
let mounted = true; let mounted = true;
(async () => { const subscription = collections.subtasks.findAndObserve(id).subscribe({
try { next: (subtask: any) => {
const subtask = await collections.subtasks.find(id);
if (!mounted) return; if (!mounted) return;
reset({ reset({
title: subtask.title, title: subtask.title,
description: subtask.description, description: subtask.description,
categoryId: subtask.categoryId || '',
priority: subtask.priority, priority: subtask.priority,
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null, dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
dueTime: subtask.dueTime, dueTime: subtask.dueTime,
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
allDay: subtask.allDay ?? false, allDay: subtask.allDay ?? false,
repeat: subtask.repeat, repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1, repeatInterval: subtask.repeatInterval || 1,
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (subtask.reminder || 'none') as Reminder, reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '', reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null, assigneeId: subtask.assigneeId ?? null,
}); });
setLoaded(true); setLoaded(true);
} catch { },
error: () => {
if (mounted) setNotFound(true); if (mounted) setNotFound(true);
} },
})(); complete: () => {
if (mounted) setNotFound(true);
},
});
return () => { mounted = false; }; return () => {
mounted = false;
subscription.unsubscribe();
};
}, [id, isReady, reset]); }, [id, isReady, reset]);
const onSubmit = async (data: SubtaskFormData) => { const onSubmit = async (data: SubtaskFormData) => {
@@ -114,6 +125,7 @@ export default function SubtaskDetailScreen() {
await updateSubtask(id, { await updateSubtask(id, {
title: data.title, title: data.title,
description: data.description || '', description: data.description || '',
categoryId: data.categoryId || '',
priority: data.priority, priority: data.priority,
dueDate: data.dueDate ? data.dueDate.getTime() : 0, dueDate: data.dueDate ? data.dueDate.getTime() : 0,
dueTime: data.dueTime || '', dueTime: data.dueTime || '',
@@ -187,6 +199,10 @@ export default function SubtaskDetailScreen() {
/> />
)} )}
/> />
<CategorySelector
value={categoryId ? [categoryId] : []}
onChange={(value) => setValue('categoryId', value[0] ?? '')}
/>
<PrioritySelector <PrioritySelector
value={priority} value={priority}
onChange={(value) => setValue('priority', value)} onChange={(value) => setValue('priority', value)}
+64 -30
View File
@@ -19,10 +19,11 @@ import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { TaskFormData } from '@/types'; import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions'; import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { recordTombstonesInBatch } from '@/database/tombstones';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
@@ -30,6 +31,7 @@ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -86,6 +88,7 @@ export default function TaskDetailScreen() {
title: '', title: '',
description: '', description: '',
categoryId: '', categoryId: '',
tags: [],
priority: 'none', priority: 'none',
dueDate: null, dueDate: null,
dueTime: '', dueTime: '',
@@ -102,7 +105,7 @@ export default function TaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
@@ -127,6 +130,7 @@ export default function TaskDetailScreen() {
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
tags: parseTaskTags(task.tags, task.categoryId),
priority: task.priority, priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate) : null, dueDate: task.dueDate ? new Date(task.dueDate) : null,
dueTime: task.dueTime, dueTime: task.dueTime,
@@ -160,14 +164,12 @@ export default function TaskDetailScreen() {
const task = await collections.tasks.find(id); const task = await collections.tasks.find(id);
savedTask = task; savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
for (const subtask of existingSubtasks) {
await subtask.destroyPermanently();
}
await task.update((t) => { await task.update((t) => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = data.categoryId || task.categoryId || ''; t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || ''; t.dueTime = data.dueTime || '';
@@ -184,32 +186,64 @@ export default function TaskDetailScreen() {
t.updatedAt = now; t.updatedAt = now;
}); });
if (data.subtasks && data.subtasks.length > 0) { const keptIds = new Set<string>();
for (let i = 0; i < data.subtasks.length; i++) { let order = 0;
const subtask = data.subtasks[i]; for (const formItem of data.subtasks ?? []) {
if (subtask.title.trim()) { const trimmed = formItem.title.trim();
await collections.subtasks.create((s) => { if (!trimmed) continue;
s.taskId = task.id; if (formItem._key) {
s.title = subtask.title.trim(); const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
s.description = ''; if (existing) {
s.priority = 'none'; keptIds.add(existing.id);
s.completed = false; await existing.update((s) => {
s.dueDate = 0; s.title = trimmed;
s.dueTime = ''; s.order = order;
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.assigneeId = null;
s.order = i;
s.createdAt = now;
s.updatedAt = now; s.updatedAt = now;
}); });
order++;
continue;
} }
} }
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.categoryId = task.categoryId || '';
s.title = trimmed;
s.description = '';
s.priority = 'none';
s.completed = false;
s.dueDate = 0;
s.dueTime = '';
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.reminders = '';
s.assigneeId = null;
s.order = order++;
s.createdAt = now;
s.updatedAt = now;
});
}
const removedIds: string[] = [];
for (const existing of existingSubtasks) {
if (existing.parentSubtaskId) continue;
if (keptIds.has(existing.id)) continue;
removedIds.push(existing.id);
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
for (const child of children) {
await child.update((c) => {
c.parentSubtaskId = null;
c.updatedAt = now;
});
}
await existing.destroyPermanently();
}
if (removedIds.length > 0) {
await recordTombstonesInBatch('subtasks', removedIds);
} }
}); });
@@ -261,8 +295,8 @@ export default function TaskDetailScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -6,8 +6,8 @@ import { useSettings, ThemeColors } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryFilterProps { interface CategoryFilterProps {
selected: string; selected: string[];
onSelect: (categoryId: string) => void; onSelect: (categoryIds: string[]) => void;
} }
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
@@ -15,6 +15,19 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const router = useRouter(); const router = useRouter();
const selectedSet = new Set(selected);
const isAnythingSelected = selected.length > 0;
const toggle = (id: string) => {
const next = new Set(selectedSet);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onSelect(Array.from(next));
};
return ( return (
<ScrollView <ScrollView
horizontal horizontal
@@ -26,8 +39,8 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
id="all" id="all"
name="All" name="All"
color="#9E9E9E" color="#9E9E9E"
selected={selected === 'all'} selected={!isAnythingSelected}
onPress={() => onSelect('all')} onPress={() => onSelect([])}
theme={theme} theme={theme}
/> />
{categories.map((category) => ( {categories.map((category) => (
@@ -36,8 +49,8 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
id={category.id} id={category.id}
name={category.name} name={category.name}
color={category.color} color={category.color}
selected={selected === category.id} selected={selectedSet.has(category.id)}
onPress={() => onSelect(category.id)} onPress={() => toggle(category.id)}
theme={theme} theme={theme}
/> />
))} ))}
@@ -5,8 +5,8 @@ import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
interface CategorySelectorProps { interface CategorySelectorProps {
value: string; value: string[];
onChange: (value: string) => void; onChange: (value: string[]) => void;
error?: string; error?: string;
} }
@@ -15,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
const { theme } = useSettings(); const { theme } = useSettings();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const selectedCategory = categories.find(c => c.id === value); const selected = new Set(value);
const selectedCategories = categories.filter((c) => selected.has(c.id));
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onChange(Array.from(next));
};
const summary =
selectedCategories.length === 0
? 'None'
: selectedCategories
.slice(0, 2)
.map((c) => c.name)
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text> <Text style={[styles.label, { color: theme.text }]}>Tags</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.selectorButton, styles.selectorButton,
@@ -28,14 +47,22 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
onPress={() => setShowModal(true)} onPress={() => setShowModal(true)}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select category" accessibilityLabel="Select tags"
accessibilityHint="Opens a list of categories to choose from" accessibilityHint="Opens a list of tags to choose from"
> >
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
<View style={styles.selectorRow}> {selectedCategories.length > 0 ? (
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} /> <View style={styles.chipRow}>
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text> {selectedCategories.slice(0, 3).map((c) => (
</View> <View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
</View>
))}
</View>
) : (
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
)}
</View> </View>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
@@ -51,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}> <Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}> <Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker"> <View style={styles.modalHeaderRight}>
<Text style={[styles.closeText, { color: theme.textMuted }]}></Text> <TouchableOpacity
</TouchableOpacity> onPress={() => { onChange([]); setShowModal(false); }}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Clear all tags"
>
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setShowModal(false)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityRole="button"
accessibilityLabel="Done selecting tags"
>
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
</TouchableOpacity>
</View>
</View> </View>
<ScrollView contentContainerStyle={styles.modalContent}> <ScrollView contentContainerStyle={styles.modalContent}>
<TouchableOpacity <Text style={[styles.modalHint, { color: theme.textMuted }]}>
style={[ A task can have multiple tags. Selected: {selected.size}
styles.modalOption, </Text>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, {categories.map((category) => {
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, const isSelected = selected.has(category.id);
]} return (
onPress={() => { onChange(''); setShowModal(false); }} <TouchableOpacity
activeOpacity={0.8} key={category.id}
accessibilityRole="radio" style={[
accessibilityLabel="No category" styles.modalOption,
accessibilityState={{ selected: !value }} { backgroundColor: theme.card, borderColor: theme.borderStrong },
> isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} /> ]}
<Text style={[ onPress={() => toggle(category.id)}
styles.categoryName, activeOpacity={0.8}
{ color: theme.textSecondary }, accessibilityRole="checkbox"
!value && { color: theme.accentStrong, fontWeight: '600' }, accessibilityLabel={`Tag ${category.name}`}
]}> accessibilityState={{ checked: isSelected }}
None >
</Text> <View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
{!value && ( <Text style={[
<Svg width={20} height={20} viewBox="0 0 24 24"> styles.categoryName,
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> { color: theme.textSecondary },
</Svg> isSelected && { color: theme.text, fontWeight: '600' },
)} ]}>
</TouchableOpacity> {category.name}
{categories.map((category) => ( </Text>
<TouchableOpacity {isSelected && (
key={category.id} <Svg width={20} height={20} viewBox="0 0 24 24">
style={[ <Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
styles.modalOption, </Svg>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, )}
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, </TouchableOpacity>
]} );
onPress={() => { onChange(category.id); setShowModal(false); }} })}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`Category ${category.name}`}
accessibilityState={{ selected: value === category.id }}
>
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
<Text style={[
styles.categoryName,
{ color: theme.textSecondary },
value === category.id && { color: theme.accentStrong, fontWeight: '600' },
]}>
{category.name}
</Text>
{value === category.id && (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</ScrollView> </ScrollView>
</Pressable> </Pressable>
</Pressable> </Pressable>
@@ -141,11 +163,25 @@ const styles = StyleSheet.create({
}, },
selectorContent: { selectorContent: {
flex: 1, flex: 1,
paddingRight: 8,
}, },
selectorRow: { chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
chip: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 6,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
borderWidth: 1,
},
chipText: {
fontSize: 12,
fontWeight: '600',
}, },
colorCircle: { colorCircle: {
width: 12, width: 12,
@@ -185,13 +221,27 @@ const styles = StyleSheet.create({
padding: 20, padding: 20,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
modalHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
modalTitle: { modalTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
closeText: { clearText: {
fontSize: 22, fontSize: 14,
fontWeight: '300', fontWeight: '500',
},
doneText: {
fontSize: 15,
fontWeight: '700',
},
modalHint: {
fontSize: 12,
paddingHorizontal: 4,
paddingBottom: 4,
}, },
modalContent: { modalContent: {
padding: 12, padding: 12,
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native'; import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { tagsToString } from '@/types';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { subscribeToQuickAdd } from '@/utils/quickAddFocus'; import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
@@ -20,7 +21,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; const visibleCategories = useMemo(
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
[categories]
);
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
useEffect(() => { useEffect(() => {
return subscribeToQuickAdd(() => { return subscribeToQuickAdd(() => {
@@ -40,6 +45,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
t.title = trimmed; t.title = trimmed;
t.description = ''; t.description = '';
t.categoryId = categoryId || ''; t.categoryId = categoryId || '';
t.tags = tagsToString(categoryId ? [categoryId] : []);
t.priority = 'none'; t.priority = 'none';
t.completed = false; t.completed = false;
t.dueDate = dueDate; t.dueDate = dueDate;
@@ -111,7 +117,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal <OptionPickerModal
visible={categoryPickerVisible} visible={categoryPickerVisible}
title="Select Category" title="Select Category"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={categoryId} selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet } from 'react-native';
import { SubtaskData } from '@/types'; import { SubtaskData } from '@/types';
import { TaskItem } from './TaskItem'; import { TaskItem } from './TaskItem';
import { useSettings } from '@/theme';
interface SubtaskItemProps { interface SubtaskItemProps {
subtask: SubtaskData; subtask: SubtaskData;
@@ -19,6 +18,7 @@ interface SubtaskItemProps {
onDragEnd?: (absoluteY: number) => void; onDragEnd?: (absoluteY: number) => void;
depth?: number; depth?: number;
categoryColor?: string; categoryColor?: string;
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void; registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
hoveredId?: string | null; hoveredId?: string | null;
} }
@@ -38,13 +38,16 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragEnd, onDragEnd,
depth = 1, depth = 1,
categoryColor, categoryColor,
categoryColorResolver,
registerRef, registerRef,
hoveredId, hoveredId,
}: SubtaskItemProps) { }: SubtaskItemProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0; const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const hovered = hoveredId === subtask.id; const hovered = hoveredId === subtask.id;
const effectiveColor = subtask.categoryId
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
: categoryColor;
const handleExpand = () => setExpanded(!expanded); const handleExpand = () => setExpanded(!expanded);
@@ -75,7 +78,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragStart={() => onDragStart?.(subtask)} onDragStart={() => onDragStart?.(subtask)}
onDragUpdate={onDragUpdate} onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
categoryColor={categoryColor} categoryColor={effectiveColor}
/> />
</View> </View>
{hasChildren && expanded && ( {hasChildren && expanded && (
@@ -99,7 +102,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragUpdate={onDragUpdate} onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
depth={depth + 1} depth={depth + 1}
categoryColor={categoryColor} categoryColor={effectiveColor}
categoryColorResolver={categoryColorResolver}
registerRef={registerRef} registerRef={registerRef}
hoveredId={hoveredId} hoveredId={hoveredId}
/> />
+12 -4
View File
@@ -42,9 +42,10 @@ interface TaskItemProps {
indented?: boolean; indented?: boolean;
depth?: number; depth?: number;
categoryColor?: string; categoryColor?: string;
categoryColors?: (string | undefined)[];
} }
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor }: TaskItemProps) { export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0)); const [dragTranslateX] = React.useState(new Animated.Value(0));
@@ -215,9 +216,16 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<View style={styles.categoryDotSlot}> <View style={styles.categoryDotSlot}>
{categoryColor ? ( {(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => (
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} /> <View
) : null} key={`${color}-${i}`}
style={[
styles.categoryDot,
{ backgroundColor: color },
i > 0 && { marginLeft: -6 },
]}
/>
))}
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.checkCircle} style={styles.checkCircle}
+56 -54
View File
@@ -1,11 +1,13 @@
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useTasks } from '@/hooks/useTasks'; import { useTasks } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useTaskModals } from '@/hooks/useTaskModals'; import { useTaskModals } from '@/hooks/useTaskModals';
import { useFocusEffect } from 'expo-router';
import { TaskItem } from './TaskItem'; import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem'; import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types'; import { TaskData, SubtaskData, parseTaskTags } from '@/types';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { import {
@@ -17,12 +19,12 @@ import {
moveSubtaskToTask, moveSubtaskToTask,
setSubtaskParent, setSubtaskParent,
toggleSubtaskComplete, toggleSubtaskComplete,
fetchSubtaskTree,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
import Svg, { Path, Rect } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
interface TaskListProps { interface TaskListProps {
categoryId?: string; categoryIds?: string[];
showCompleted?: boolean;
onSelectionChange?: (active: boolean) => void; onSelectionChange?: (active: boolean) => void;
} }
@@ -39,10 +41,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
</View> </View>
); );
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) { export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
const { theme, sortBy, todoAheadDays } = useSettings(); const { theme, sortBy, todoAheadDays } = useSettings();
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays); const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
const categories = useCategories(); const categories = useCategories();
const categoryColors = useMemo(() => { const categoryColors = useMemo(() => {
@@ -58,7 +60,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null); const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map()); const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map()); const itemRefs = useRef<Map<string, View>>(new Map());
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map()); const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
@@ -88,6 +90,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
}, []); }, []);
const categoryColorResolver = useCallback(
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
[categoryColors]
);
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
const sorted = [...tasks]; const sorted = [...tasks];
switch (sortBy) { switch (sortBy) {
@@ -104,13 +111,27 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break; break;
} }
return sorted; const active: typeof sorted = [];
const done: typeof sorted = [];
for (const t of sorted) {
(t.completed ? done : active).push(t);
}
return [...active, ...done];
}, [tasks, sortBy]); }, [tasks, sortBy]);
useFocusEffect(
useCallback(() => {
refreshTasks();
refreshSubtasks();
}, [refreshTasks, refreshSubtasks])
);
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
refreshTasks();
refreshSubtasks();
setTimeout(() => setRefreshing(false), 600); setTimeout(() => setRefreshing(false), 600);
}, []); }, [refreshTasks, refreshSubtasks]);
const exitSelection = useCallback(() => { const exitSelection = useCallback(() => {
setSelectionMode(false); setSelectionMode(false);
@@ -140,22 +161,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}); });
}, [onSelectionChange]); }, [onSelectionChange]);
const fetchSubtasks = useCallback(async (taskId: string) => {
const withNested = await fetchSubtaskTree(taskId);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, []);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string) => { const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await toggleTaskComplete(taskId);
refreshAll(); refreshTasks();
}, [refreshAll]); }, [refreshTasks]);
const toggleExpand = useCallback(async (taskId: string) => { const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => { setExpandedTasks((prev) => {
@@ -167,23 +176,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
return next; return next;
}); });
}, []);
const isCurrentlyExpanded = expandedTasks.has(taskId); const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
if (isCurrentlyExpanded) {
setSubtasksMap((prev) => {
const next = new Map(prev);
next.delete(taskId);
return next;
});
} else {
await fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
await toggleSubtaskComplete(subtaskId); await toggleSubtaskComplete(subtaskId);
await fetchSubtasks(taskId); refreshTasks();
}, [fetchSubtasks]); refreshSubtasks();
}, [refreshTasks, refreshSubtasks]);
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [ Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
@@ -194,17 +193,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onPress: async () => { onPress: async () => {
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId))); await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection(); exitSelection();
refreshAll();
}, },
}, },
]); ]);
}, [selectedIds, exitSelection, refreshAll]); }, [selectedIds, exitSelection]);
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true))); await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
exitSelection(); exitSelection();
refreshAll(); }, [selectedIds, exitSelection]);
}, [selectedIds, exitSelection, refreshAll]);
const measureItems = useCallback(async () => { const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {}; const positions: Record<string, { top: number; bottom: number }> = {};
@@ -323,17 +320,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId; const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
(async () => { (async () => {
await convertTaskToSubtask(state.taskId, parentTaskId, target); await convertTaskToSubtask(state.taskId, parentTaskId, target);
await fetchSubtasks(parentTaskId);
refreshAll();
})(); })();
} else { } else {
(async () => { (async () => {
await convertTaskToSubtask(state.taskId, target); await convertTaskToSubtask(state.taskId, target);
refreshAll();
})(); })();
} }
} }
}, [findHoverTarget, refreshAll, fetchSubtasks]); }, [findHoverTarget]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => { const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) }; subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
@@ -377,14 +371,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
await setSubtaskParent(state.subtaskId, target); await setSubtaskParent(state.subtaskId, target);
} else if (target !== state.parentTaskId) { } else if (target !== state.parentTaskId) {
await moveSubtaskToTask(state.subtaskId, target); await moveSubtaskToTask(state.subtaskId, target);
await fetchSubtasks(target);
} }
} else { } else {
await convertSubtaskToTask(state.subtaskId); await convertSubtaskToTask(state.subtaskId);
} }
await fetchSubtasks(state.parentTaskId); }, [findHoverTarget]);
refreshAll();
}, [findHoverTarget, fetchSubtasks, refreshAll]);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => { ({ item, index }: { item: Task; index: number }) => {
@@ -392,6 +383,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const itemSubtasks = subtasksMap.get(item.id) ?? []; const itemSubtasks = subtasksMap.get(item.id) ?? [];
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above'; const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below'; const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
return ( return (
<View> <View>
{showDropAbove && <DropIndicator theme={theme} />} {showDropAbove && <DropIndicator theme={theme} />}
@@ -425,6 +417,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onReorderEnd={handleDragEnd} onReorderEnd={handleDragEnd}
selectedIds={selectedIds} selectedIds={selectedIds}
categoryColor={categoryColors.get(item.categoryId)} categoryColor={categoryColors.get(item.categoryId)}
categoryColors={tagColors}
categoryColorResolver={categoryColorResolver}
/> />
{showDropBelow && <DropIndicator theme={theme} />} {showDropBelow && <DropIndicator theme={theme} />}
</View> </View>
@@ -454,6 +448,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
handleSubtaskDragStart, handleSubtaskDragStart,
handleSubtaskDragUpdate, handleSubtaskDragUpdate,
handleSubtaskDragEnd, handleSubtaskDragEnd,
registerSubtaskRef,
categoryColorResolver,
categoryColors, categoryColors,
] ]
); );
@@ -503,7 +499,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
/> />
{modals(refreshAll)} {modals(() => {})}
{selectionMode && ( {selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}> <View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
@@ -566,6 +562,8 @@ interface TaskRowProps {
onReorderEnd: (absoluteY: number, translationY: number) => void; onReorderEnd: (absoluteY: number, translationY: number) => void;
selectedIds: Set<string>; selectedIds: Set<string>;
categoryColor?: string; categoryColor?: string;
categoryColors?: (string | undefined)[];
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
} }
const TaskRow = React.memo(function TaskRow({ const TaskRow = React.memo(function TaskRow({
@@ -598,6 +596,8 @@ const TaskRow = React.memo(function TaskRow({
onReorderEnd, onReorderEnd,
selectedIds, selectedIds,
categoryColor, categoryColor,
categoryColors,
categoryColorResolver,
}: TaskRowProps) { }: TaskRowProps) {
const sortedSubtasks = useMemo( const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order), () => subtasks.slice().sort((a, b) => a.order - b.order),
@@ -627,6 +627,7 @@ const TaskRow = React.memo(function TaskRow({
onReorderUpdate={onReorderUpdate} onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd} onReorderEnd={onReorderEnd}
categoryColor={categoryColor} categoryColor={categoryColor}
categoryColors={categoryColors}
/> />
{expanded && subtasks.length > 0 && ( {expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}> <View style={styles.subtaskList}>
@@ -636,16 +637,17 @@ const TaskRow = React.memo(function TaskRow({
subtask={sub} subtask={sub}
hoveredId={hoverTaskId} hoveredId={hoverTaskId}
registerRef={registerSubtaskRef} registerRef={registerSubtaskRef}
onToggle={() => onSubtaskToggle(sub.id, task.id)} onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)} onDelete={() => onSubtaskDelete(sub)}
onMenuOpen={() => onSubtaskMenuOpen(sub)} onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)} selected={selectedIds.has(sub.id)}
selectionMode={selectionMode} selectionMode={selectionMode}
draggable draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)} onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onSubtaskDragUpdate} onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd} onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor} categoryColor={categoryColor}
categoryColorResolver={categoryColorResolver}
/> />
))} ))}
</View> </View>
@@ -202,5 +202,23 @@ export const migrations = schemaMigrations({
}), }),
], ],
}, },
{
toVersion: 19,
steps: [
addColumns({
table: 'subtasks',
columns: [{ name: 'category_id', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 20,
steps: [
addColumns({
table: 'subtasks',
columns: [{ name: 'tags', type: 'string', isOptional: true }],
}),
],
},
], ],
}); });
+4 -1
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({ export const schema = appSchema({
version: 18, version: 20,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'categories', name: 'categories',
@@ -45,6 +45,9 @@ export const schema = appSchema({
columns: [ columns: [
{ name: 'task_id', type: 'string', isIndexed: true }, { name: 'task_id', type: 'string', isIndexed: true },
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true }, { name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'category_id', type: 'string', isOptional: true },
{ name: 'tags', type: 'string', isOptional: true },
{ name: 'category_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'title', type: 'string' }, { name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true }, { name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true }, { name: 'priority', type: 'string', isOptional: true },
+5
View File
@@ -151,6 +151,8 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title, title: s.title,
description: s.description ?? '', description: s.description ?? '',
priority: s.priority ?? 'none', priority: s.priority ?? 'none',
@@ -532,6 +534,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
await collections.subtasks.create((s) => { await collections.subtasks.create((s) => {
s.taskId = String(row.taskId ?? ''); s.taskId = String(row.taskId ?? '');
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : '';
s.tags = String(row.tags ?? '');
s.title = String(row.title ?? ''); s.title = String(row.title ?? '');
s.description = String(row.description ?? ''); s.description = String(row.description ?? '');
s.priority = row.priority ?? 'none'; s.priority = row.priority ?? 'none';
@@ -558,6 +562,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
await local.update((s) => { await local.update((s) => {
s.taskId = String(row.taskId ?? s.taskId); s.taskId = String(row.taskId ?? s.taskId);
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
s.title = String(row.title ?? s.title); s.title = String(row.title ?? s.title);
s.description = String(row.description ?? s.description); s.description = String(row.description ?? s.description);
s.priority = row.priority ?? s.priority; s.priority = row.priority ?? s.priority;
+2
View File
@@ -6,6 +6,8 @@ function mapRow(s: any): SubtaskData {
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title, title: s.title,
description: s.description || '', description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'], priority: (s.priority || 'none') as SubtaskData['priority'],
+22 -10
View File
@@ -1,6 +1,6 @@
import { useDatabase } from './useDatabase'; import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import Task from '../models/Task'; import Task from '../models/Task';
import { startOfMonth, endOfMonth } from 'date-fns'; import { startOfMonth, endOfMonth } from 'date-fns';
@@ -8,6 +8,8 @@ export function useTasksInMonth(monthDate: Date) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const range = useMemo(() => { const range = useMemo(() => {
const s = startOfMonth(monthDate); const s = startOfMonth(monthDate);
@@ -42,7 +44,7 @@ export function useTasksInMonth(monthDate: Date) {
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, range.start, range.end]); }, [collections, range.start, range.end, refreshKey]);
const byDay = useMemo(() => { const byDay = useMemo(() => {
const map: Record<number, Task[]> = {}; const map: Record<number, Task[]> = {};
@@ -55,13 +57,15 @@ export function useTasksInMonth(monthDate: Date) {
return map; return map;
}, [tasks]); }, [tasks]);
return { tasks, byDay, loading }; return { tasks, byDay, loading, refresh };
} }
export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = false, maxAheadDays?: number) { export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const cutoff = useMemo(() => { const cutoff = useMemo(() => {
if (maxAheadDays === undefined) return null; if (maxAheadDays === undefined) return null;
@@ -75,8 +79,14 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
let mounted = true; let mounted = true;
const conditions: any[] = []; const conditions: any[] = [];
if (categoryId && categoryId !== 'all') { if (categoryIds.length > 0) {
conditions.push(Q.where('category_id', categoryId)); const tagConditions = categoryIds.map((id) =>
Q.or(
Q.where('tags', Q.like(`%,${id},%`)),
Q.where('category_id', id)
)
);
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
} }
if (showCompleted === true) { if (showCompleted === true) {
@@ -111,15 +121,17 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, categoryId, showCompleted, cutoff]); }, [collections, categoryIds, showCompleted, cutoff, refreshKey]);
return { tasks, loading }; return { tasks, loading, refresh };
} }
export function useTasksByDate(date: Date) { export function useTasksByDate(date: Date) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const startOfDay = useMemo(() => { const startOfDay = useMemo(() => {
const d = new Date(date); const d = new Date(date);
@@ -156,7 +168,7 @@ export function useTasksByDate(date: Date) {
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, startOfDay, endOfDay]); }, [collections, startOfDay, endOfDay, refreshKey]);
return { tasks, loading }; return { tasks, loading, refresh };
} }
+2
View File
@@ -12,6 +12,8 @@ export default class Subtask extends Model {
@field('task_id') taskId!: string; @field('task_id') taskId!: string;
@field('parent_subtask_id') parentSubtaskId!: string | null; @field('parent_subtask_id') parentSubtaskId!: string | null;
@field('category_id') categoryId!: string;
@field('tags') tags!: string;
@field('title') title!: string; @field('title') title!: string;
@field('description') description!: string; @field('description') description!: string;
@field('priority') priority!: Priority; @field('priority') priority!: Priority;
-8
View File
@@ -130,8 +130,6 @@ interface SettingsContextType {
setTodoAheadDays: (value: number) => void; setTodoAheadDays: (value: number) => void;
showCompleted: boolean; showCompleted: boolean;
setShowCompleted: (value: boolean) => void; setShowCompleted: (value: boolean) => void;
calendarCategoryId: string;
setCalendarCategoryId: (value: string) => void;
theme: ThemeColors; theme: ThemeColors;
} }
@@ -145,7 +143,6 @@ const STORAGE_KEYS = {
accentColor: 'settings:accentColor', accentColor: 'settings:accentColor',
todoAheadDays: 'settings:todoAheadDays', todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted', showCompleted: 'settings:showCompleted',
calendarCategoryId: 'settings:calendarCategoryId',
}; };
const SettingsContext = createContext<SettingsContextType | null>(null); const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -188,7 +185,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS); const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false); const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [calendarCategoryId, setCalendarCategoryId] = useStoredSetting<string>(STORAGE_KEYS.calendarCategoryId, '');
const theme = useMemo(() => colors(accentColor), [accentColor]); const theme = useMemo(() => colors(accentColor), [accentColor]);
@@ -210,8 +206,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
calendarCategoryId,
setCalendarCategoryId,
theme, theme,
}), }),
[ [
@@ -231,8 +225,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
calendarCategoryId,
setCalendarCategoryId,
theme, theme,
] ]
); );
+3
View File
@@ -144,6 +144,8 @@ export interface SubtaskData {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null; parentSubtaskId: string | null;
categoryId: string;
tags: string;
title: string; title: string;
description: string; description: string;
priority: Priority; priority: Priority;
@@ -191,6 +193,7 @@ export interface TaskFormData {
export interface SubtaskFormData { export interface SubtaskFormData {
title: string; title: string;
description?: string; description?: string;
categoryId?: string;
priority: Priority; priority: Priority;
dueDate: Date | null; dueDate: Date | null;
dueTime?: string; dueTime?: string;
+10 -1
View File
@@ -9,6 +9,7 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
id: s.id, id: s.id,
taskId: s.taskId ?? taskId ?? '', taskId: s.taskId ?? taskId ?? '',
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
title: s.title, title: s.title,
description: s.description || '', description: s.description || '',
priority: (s.priority || 'none') as Priority, priority: (s.priority || 'none') as Priority,
@@ -235,6 +236,7 @@ export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
export interface SubtaskUpdateData { export interface SubtaskUpdateData {
title: string; title: string;
description?: string; description?: string;
categoryId?: string;
priority: Priority; priority: Priority;
dueDate: number; dueDate: number;
dueTime?: string; dueTime?: string;
@@ -253,6 +255,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
await subtask.update((s) => { await subtask.update((s) => {
s.title = data.title.trim(); s.title = data.title.trim();
s.description = data.description || ''; s.description = data.description || '';
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
s.priority = data.priority; s.priority = data.priority;
s.dueDate = data.dueDate; s.dueDate = data.dueDate;
s.dueTime = data.dueTime || ''; s.dueTime = data.dueTime || '';
@@ -314,6 +317,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
clone = await collections.subtasks.create((s) => { clone = await collections.subtasks.create((s) => {
s.taskId = subtask.taskId; s.taskId = subtask.taskId;
s.categoryId = subtask.categoryId || '';
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.priority = subtask.priority; s.priority = subtask.priority;
@@ -407,6 +411,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
const created = await collections.subtasks.create((s) => { const created = await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = parentSubtaskId; s.parentSubtaskId = parentSubtaskId;
s.categoryId = task.categoryId || '';
s.title = task.title; s.title = task.title;
s.description = task.description; s.description = task.description;
s.priority = task.priority; s.priority = task.priority;
@@ -430,6 +435,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
await collections.subtasks.create((s) => { await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = created.id; s.parentSubtaskId = created.id;
s.categoryId = subtask.categoryId || '';
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.priority = subtask.priority; s.priority = subtask.priority;
@@ -470,7 +476,8 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
const task = await collections.tasks.create((t) => { const task = await collections.tasks.create((t) => {
t.title = subtask.title; t.title = subtask.title;
t.description = subtask.description || ''; t.description = subtask.description || '';
t.categoryId = parent.categoryId; t.categoryId = subtask.categoryId || parent.categoryId || '';
t.tags = tagsToString([subtask.categoryId || parent.categoryId].filter(Boolean));
t.priority = subtask.priority; t.priority = subtask.priority;
t.completed = subtask.completed; t.completed = subtask.completed;
t.dueDate = subtask.dueDate; t.dueDate = subtask.dueDate;
@@ -747,6 +754,7 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
export interface CreateSubtaskData { export interface CreateSubtaskData {
taskId: string; taskId: string;
parentSubtaskId?: string | null; parentSubtaskId?: string | null;
categoryId?: string;
title: string; title: string;
description?: string; description?: string;
priority?: Priority; priority?: Priority;
@@ -773,6 +781,7 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
const subtask = await collections.subtasks.create((s) => { const subtask = await collections.subtasks.create((s) => {
s.taskId = data.taskId; s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null; s.parentSubtaskId = data.parentSubtaskId || null;
s.categoryId = data.categoryId || '';
s.title = data.title.trim(); s.title = data.title.trim();
s.description = data.description || ''; s.description = data.description || '';
s.priority = data.priority || 'none'; s.priority = data.priority || 'none';