From 2ca23e276fd214884f21fffb6bfa5b77f2d54090 Mon Sep 17 00:00:00 2001 From: tech08mag Date: Mon, 10 Aug 2026 11:41:25 +0200 Subject: [PATCH] feat(app): subtask categories, live refresh on save, nested subtask fixes --- carry-your-live/app/(tabs)/calendar.tsx | 141 +++++-------- carry-your-live/app/(tabs)/index.tsx | 65 +++++- carry-your-live/app/(tabs)/settings.tsx | 14 +- carry-your-live/app/_layout.tsx | 1 + carry-your-live/app/add-task.tsx | 19 +- carry-your-live/app/subtask-detail.tsx | 32 ++- carry-your-live/app/task-detail.tsx | 94 ++++++--- .../src/components/CategoryFilter.tsx | 25 ++- .../src/components/CategorySelector.tsx | 198 +++++++++++------- .../src/components/QuickAddBar.tsx | 12 +- .../src/components/SubtaskItem.tsx | 12 +- carry-your-live/src/components/TaskItem.tsx | 16 +- carry-your-live/src/components/TaskList.tsx | 110 +++++----- carry-your-live/src/database/migrations.ts | 18 ++ carry-your-live/src/database/schema.ts | 5 +- carry-your-live/src/database/sync.ts | 5 + carry-your-live/src/hooks/useSubtasks.ts | 2 + carry-your-live/src/hooks/useTasks.tsx | 32 ++- carry-your-live/src/models/Subtask.ts | 2 + carry-your-live/src/theme.tsx | 8 - carry-your-live/src/types/index.ts | 3 + carry-your-live/src/utils/taskActions.ts | 11 +- 22 files changed, 518 insertions(+), 307 deletions(-) diff --git a/carry-your-live/app/(tabs)/calendar.tsx b/carry-your-live/app/(tabs)/calendar.tsx index 31ae45f..db87f50 100644 --- a/carry-your-live/app/(tabs)/calendar.tsx +++ b/carry-your-live/app/(tabs)/calendar.tsx @@ -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 { useRouter, useFocusEffect } from 'expo-router'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Header } from '@/components/Header'; import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks'; +import { useSubtasks } from '@/hooks/useSubtasks'; import { useTaskModals } from '@/hooks/useTaskModals'; import { useSettings } from '@/theme'; -import { useCategories, useDatabase } from '@/hooks/useDatabase'; +import { useCategories } from '@/hooks/useDatabase'; import { toggleTaskComplete } from '@/utils/taskActions'; import { QuickAddBar } from '@/components/QuickAddBar'; import { OptionPickerModal } from '@/components/OptionPickerModal'; -import { SubtaskData } from '@/types'; import Task from '@/models/Task'; 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 { desaturate } from '@/theme'; import type { ThemeColors } from '@/theme'; @@ -28,7 +27,6 @@ const MONTH_NAMES = [ export default function CalendarScreen() { const router = useRouter(); const { theme } = useSettings(); - const { collections } = useDatabase(); const categories = useCategories(); const { modals, openTaskEdit } = useTaskModals(); @@ -36,27 +34,31 @@ export default function CalendarScreen() { const [selectedDate, setSelectedDate] = useState(() => new Date()); const [monthPickerVisible, setMonthPickerVisible] = useState(false); const [yearPickerVisible, setYearPickerVisible] = useState(false); - const [subtasksMap, setSubtasksMap] = useState>({}); + const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks(); const visibleMonthRef = useRef(visibleMonth); visibleMonthRef.current = visibleMonth; const selectedDateRef = useRef(selectedDate); selectedDateRef.current = selectedDate; + const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate); + const monthTasks = useTasksInMonth(visibleMonth); + const refreshMonthTasks = monthTasks.refresh; + useFocusEffect( useCallback(() => { const sub = BackHandler.addEventListener('hardwareBackPress', () => true); + refreshDayTasks(); + refreshMonthTasks(); + refreshSubtasks(); return () => sub.remove(); - }, []) + }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]) ); const translateX = useRef(new Animated.Value(0)).current; const animatingRef = useRef(false); const gridWidthRef = useRef(WIDTH); - const { tasks: selectedDayTasks } = useTasksByDate(selectedDate); - const monthTasks = useTasksInMonth(visibleMonth); - const weeks = useMemo(() => { const first = startOfMonth(visibleMonth); const offset = (first.getDay() + 6) % 7; // week starts Monday @@ -67,67 +69,22 @@ export default function CalendarScreen() { return rows; }, [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( - (dir: 1 | -1) => { + (dir: 1 | -1, animate = true) => { 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; const w = gridWidthRef.current || WIDTH; const target = dir === 1 ? -w : w; 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); translateX.setValue(-target); Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { @@ -138,6 +95,18 @@ export default function CalendarScreen() { [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( () => Gesture.Pan() @@ -153,10 +122,10 @@ export default function CalendarScreen() { const dx = e.translationX; if (dx <= -w / 4) { translateX.stopAnimation(); - transitionTo(1); + transitionTo(1, false); } else if (dx >= w / 4) { translateX.stopAnimation(); - transitionTo(-1); + transitionTo(-1, false); } else { Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start(); } @@ -184,7 +153,10 @@ export default function CalendarScreen() { const handleToggleComplete = useCallback(async (taskId: string) => { await toggleTaskComplete(taskId); - }, []); + refreshDayTasks(); + refreshMonthTasks(); + refreshSubtasks(); + }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); return ( @@ -217,11 +189,12 @@ export default function CalendarScreen() { - setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}> - {format(visibleMonth, 'yyyy')} - + setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}> + {format(visibleMonth, 'yyyy')} + + transitionTo(1)} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} @@ -316,15 +289,10 @@ export default function CalendarScreen() { - {(subtasksMap[task.id] ?? []).length > 0 && ( + {(subtasksByTask.get(task.id) ?? []).length > 0 && ( - {(subtasksMap[task.id] ?? []).map((sub) => ( - router.push({ pathname: '/subtask-detail', params: { id: sub.id } })} - activeOpacity={0.7} - > + {(subtasksByTask.get(task.id) ?? []).map((sub) => ( + @@ -334,7 +302,7 @@ export default function CalendarScreen() { > {sub.title} - + ))} )} @@ -343,7 +311,10 @@ export default function CalendarScreen() { )} - + {modals(() => {})} @@ -461,7 +432,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, monthSelectorGroup: { - alignItems: 'center', + alignItems: 'flex-start', + flex: 1, }, monthButton: { flexDirection: 'row', @@ -476,10 +448,11 @@ const styles = StyleSheet.create({ }, yearButton: { marginTop: -2, + paddingHorizontal: 6, }, yearLabel: { - fontSize: 13, - fontWeight: '500', + fontSize: 17, + fontWeight: '700', }, weekdayRow: { flexDirection: 'row', diff --git a/carry-your-live/app/(tabs)/index.tsx b/carry-your-live/app/(tabs)/index.tsx index fd78d33..f34b912 100644 --- a/carry-your-live/app/(tabs)/index.tsx +++ b/carry-your-live/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ -import React, { useCallback } from 'react'; -import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native'; import { useFocusEffect } from 'expo-router'; import { Header } from '@/components/Header'; import { CategoryFilter } from '@/components/CategoryFilter'; @@ -7,11 +7,14 @@ import { TaskList } from '@/components/TaskList'; import { QuickAddBar } from '@/components/QuickAddBar'; import { useDatabase } from '@/hooks/useDatabase'; import { useSettings } from '@/theme'; +import Svg, { Path, Circle } from 'react-native-svg'; export default function TasksScreen() { const { isReady } = useDatabase(); - const { theme } = useSettings(); - const [selectedCategory, setSelectedCategory] = React.useState('all'); + const { theme, showCompleted, setShowCompleted } = useSettings(); + const [selectedCategories, setSelectedCategories] = useState([]); + + const categoryIds = useMemo(() => selectedCategories, [selectedCategories]); useFocusEffect( useCallback(() => { @@ -32,13 +35,41 @@ export default function TasksScreen() {
- + + setShowCompleted(!showCompleted)} + activeOpacity={0.8} + accessibilityRole="switch" + accessibilityLabel="Show completed tasks" + accessibilityState={{ checked: showCompleted }} + > + + + {showCompleted && ( + + )} + + + Completed + + - + @@ -51,13 +82,25 @@ const styles = StyleSheet.create({ }, categoryFilterWrapper: { 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: { flex: 1, }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, }); \ No newline at end of file diff --git a/carry-your-live/app/(tabs)/settings.tsx b/carry-your-live/app/(tabs)/settings.tsx index a8a2321..9d135fb 100644 --- a/carry-your-live/app/(tabs)/settings.tsx +++ b/carry-your-live/app/(tabs)/settings.tsx @@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory'; export default function SettingsScreen() { 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 [picker, setPicker] = useState(null); const [editingCategory, setEditingCategory] = useState(null); @@ -176,6 +176,18 @@ export default function SettingsScreen() { onPress={() => setPicker('sort')} showChevron /> + + } + /> + ); diff --git a/carry-your-live/app/add-task.tsx b/carry-your-live/app/add-task.tsx index 747f599..4e599ae 100644 --- a/carry-your-live/app/add-task.tsx +++ b/carry-your-live/app/add-task.tsx @@ -17,7 +17,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useDatabase } from '@/hooks/useDatabase'; import { database, collections } from '@/database'; -import { TaskFormData } from '@/types'; +import { TaskFormData, tagsToString } from '@/types'; import { useSettings } from '@/theme'; import { scheduleTaskReminder } from '@/services/notifications'; 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), description: z.string().max(1000).optional(), categoryId: z.string().optional(), + tags: z.array(z.string()).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), dueDate: z.date().nullable().optional(), dueTime: z.string().optional(), @@ -60,6 +61,7 @@ export default function AddTaskScreen() { title: '', description: '', categoryId: initialCategory, + tags: initialCategory ? [initialCategory] : [], priority: 'none', dueDate: initialDate, dueTime: '', @@ -83,7 +85,7 @@ export default function AddTaskScreen() { formState: { errors }, } = methods; - const categoryId = watch('categoryId'); + const tags = watch('tags') ?? []; const priority = watch('priority'); const repeat = watch('repeat'); const repeatInterval = watch('repeatInterval') ?? 1; @@ -94,10 +96,10 @@ export default function AddTaskScreen() { const assigneeId = watch('assigneeId'); React.useEffect(() => { - if (!categoryId && initialCategory) { - setValue('categoryId', initialCategory); + if (tags.length === 0 && initialCategory) { + setValue('tags', [initialCategory]); } - }, [initialCategory, categoryId, setValue]); + }, [initialCategory, tags, setValue]); const onSubmit = async (data: TaskFormData) => { if (!isReady) return; @@ -107,7 +109,7 @@ export default function AddTaskScreen() { const seriesId = data.repeat !== 'none' ? `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; @@ -116,6 +118,7 @@ export default function AddTaskScreen() { t.title = data.title.trim(); t.description = data.description || ''; t.categoryId = resolvedCategoryId; + t.tags = tagsToString(data.tags || []); t.priority = data.priority; t.completed = false; t.dueDate = dueDateTimestamp; @@ -184,8 +187,8 @@ export default function AddTaskScreen() { keyboardShouldPersistTaps="handled" > setValue('categoryId', value)} + value={tags} + onChange={(value) => setValue('tags', value)} error={errors.categoryId?.message} /> { - try { - const subtask = await collections.subtasks.find(id); + const subscription = collections.subtasks.findAndObserve(id).subscribe({ + next: (subtask: any) => { if (!mounted) return; reset({ title: subtask.title, description: subtask.description, + categoryId: subtask.categoryId || '', priority: subtask.priority, dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null, dueTime: subtask.dueTime, @@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() { allDay: subtask.allDay ?? false, repeat: subtask.repeat, 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, reminders: subtask.reminders || '', assigneeId: subtask.assigneeId ?? null, }); setLoaded(true); - } catch { + }, + error: () => { if (mounted) setNotFound(true); - } - })(); + }, + complete: () => { + if (mounted) setNotFound(true); + }, + }); - return () => { mounted = false; }; + return () => { + mounted = false; + subscription.unsubscribe(); + }; }, [id, isReady, reset]); const onSubmit = async (data: SubtaskFormData) => { @@ -114,6 +125,7 @@ export default function SubtaskDetailScreen() { await updateSubtask(id, { title: data.title, description: data.description || '', + categoryId: data.categoryId || '', priority: data.priority, dueDate: data.dueDate ? data.dueDate.getTime() : 0, dueTime: data.dueTime || '', @@ -187,6 +199,10 @@ export default function SubtaskDetailScreen() { /> )} /> + setValue('categoryId', value[0] ?? '')} + /> setValue('priority', value)} diff --git a/carry-your-live/app/task-detail.tsx b/carry-your-live/app/task-detail.tsx index c5bec8b..3d13f7d 100644 --- a/carry-your-live/app/task-detail.tsx +++ b/carry-your-live/app/task-detail.tsx @@ -19,10 +19,11 @@ import { z } from 'zod'; import { useDatabase } from '@/hooks/useDatabase'; import { database, collections } from '@/database'; import { Q } from '@nozbe/watermelondb'; -import { TaskFormData } from '@/types'; +import { TaskFormData, parseTaskTags, tagsToString } from '@/types'; import { useSettings } from '@/theme'; import { deleteTaskOccurrences } from '@/utils/taskActions'; import { scheduleTaskReminder } from '@/services/notifications'; +import { recordTombstonesInBatch } from '@/database/tombstones'; import { useFriends } from '@/hooks/useFriends'; 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), description: z.string().max(1000).optional(), categoryId: z.string().optional(), + tags: z.array(z.string()).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), dueDate: z.date().nullable().optional(), dueTime: z.string().optional(), @@ -86,6 +88,7 @@ export default function TaskDetailScreen() { title: '', description: '', categoryId: '', + tags: [], priority: 'none', dueDate: null, dueTime: '', @@ -102,7 +105,7 @@ export default function TaskDetailScreen() { const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; - const categoryId = watch('categoryId'); + const tags = watch('tags') ?? []; const priority = watch('priority'); const repeat = watch('repeat'); const repeatInterval = watch('repeatInterval') ?? 1; @@ -127,6 +130,7 @@ export default function TaskDetailScreen() { title: task.title, description: task.description, categoryId: task.categoryId, + tags: parseTaskTags(task.tags, task.categoryId), priority: task.priority, dueDate: task.dueDate ? new Date(task.dueDate) : null, dueTime: task.dueTime, @@ -160,14 +164,12 @@ export default function TaskDetailScreen() { const task = await collections.tasks.find(id); savedTask = task; const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); - for (const subtask of existingSubtasks) { - await subtask.destroyPermanently(); - } await task.update((t) => { t.title = data.title.trim(); 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.dueDate = dueDateTimestamp; t.dueTime = data.dueTime || ''; @@ -184,32 +186,64 @@ export default function TaskDetailScreen() { t.updatedAt = now; }); - if (data.subtasks && data.subtasks.length > 0) { - for (let i = 0; i < data.subtasks.length; i++) { - const subtask = data.subtasks[i]; - if (subtask.title.trim()) { - await collections.subtasks.create((s) => { - s.taskId = task.id; - s.title = subtask.title.trim(); - 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.assigneeId = null; - s.order = i; - s.createdAt = now; + const keptIds = new Set(); + let order = 0; + for (const formItem of data.subtasks ?? []) { + const trimmed = formItem.title.trim(); + if (!trimmed) continue; + if (formItem._key) { + const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId); + if (existing) { + keptIds.add(existing.id); + await existing.update((s) => { + s.title = trimmed; + s.order = order; 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" > setValue('categoryId', value)} + value={tags} + onChange={(value) => setValue('tags', value)} error={errors.categoryId?.message} /> void; + selected: string[]; + onSelect: (categoryIds: string[]) => void; } export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { @@ -15,6 +15,19 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { const { theme } = useSettings(); 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 ( onSelect('all')} + selected={!isAnythingSelected} + onPress={() => onSelect([])} theme={theme} /> {categories.map((category) => ( @@ -36,8 +49,8 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { id={category.id} name={category.name} color={category.color} - selected={selected === category.id} - onPress={() => onSelect(category.id)} + selected={selectedSet.has(category.id)} + onPress={() => toggle(category.id)} theme={theme} /> ))} diff --git a/carry-your-live/src/components/CategorySelector.tsx b/carry-your-live/src/components/CategorySelector.tsx index 99d1df1..0fd2b17 100644 --- a/carry-your-live/src/components/CategorySelector.tsx +++ b/carry-your-live/src/components/CategorySelector.tsx @@ -5,8 +5,8 @@ import { useSettings } from '@/theme'; import Svg, { Path } from 'react-native-svg'; interface CategorySelectorProps { - value: string; - onChange: (value: string) => void; + value: string[]; + onChange: (value: string[]) => void; error?: string; } @@ -15,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro const { theme } = useSettings(); 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 ( - Category + Tags setShowModal(true)} activeOpacity={0.8} accessibilityRole="button" - accessibilityLabel="Select category" - accessibilityHint="Opens a list of categories to choose from" + accessibilityLabel="Select tags" + accessibilityHint="Opens a list of tags to choose from" > - - - {selectedCategory?.name || 'None'} - + {selectedCategories.length > 0 ? ( + + {selectedCategories.slice(0, 3).map((c) => ( + + + {c.name} + + ))} + + ) : ( + None + )} @@ -51,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro setShowModal(false)}> - Select Category - setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker"> - - + Select Tags + + { onChange([]); setShowModal(false); }} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel="Clear all tags" + > + Clear + + setShowModal(false)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityRole="button" + accessibilityLabel="Done selecting tags" + > + Done + + - { onChange(''); setShowModal(false); }} - activeOpacity={0.8} - accessibilityRole="radio" - accessibilityLabel="No category" - accessibilityState={{ selected: !value }} - > - - - None - - {!value && ( - - - - )} - - {categories.map((category) => ( - { onChange(category.id); setShowModal(false); }} - activeOpacity={0.8} - accessibilityRole="radio" - accessibilityLabel={`Category ${category.name}`} - accessibilityState={{ selected: value === category.id }} - > - - - {category.name} - - {value === category.id && ( - - - - )} - - ))} + + A task can have multiple tags. Selected: {selected.size} + + {categories.map((category) => { + const isSelected = selected.has(category.id); + return ( + toggle(category.id)} + activeOpacity={0.8} + accessibilityRole="checkbox" + accessibilityLabel={`Tag ${category.name}`} + accessibilityState={{ checked: isSelected }} + > + + + {category.name} + + {isSelected && ( + + + + )} + + ); + })} @@ -141,11 +163,25 @@ const styles = StyleSheet.create({ }, selectorContent: { flex: 1, + paddingRight: 8, }, - selectorRow: { + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + }, + chip: { flexDirection: 'row', alignItems: 'center', - gap: 10, + gap: 6, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + borderWidth: 1, + }, + chipText: { + fontSize: 12, + fontWeight: '600', }, colorCircle: { width: 12, @@ -185,13 +221,27 @@ const styles = StyleSheet.create({ padding: 20, borderBottomWidth: 1, }, + modalHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, modalTitle: { fontSize: 18, fontWeight: '700', }, - closeText: { - fontSize: 22, - fontWeight: '300', + clearText: { + fontSize: 14, + fontWeight: '500', + }, + doneText: { + fontSize: 15, + fontWeight: '700', + }, + modalHint: { + fontSize: 12, + paddingHorizontal: 4, + paddingBottom: 4, }, modalContent: { padding: 12, diff --git a/carry-your-live/src/components/QuickAddBar.tsx b/carry-your-live/src/components/QuickAddBar.tsx index 9dd5dea..15e5c53 100644 --- a/carry-your-live/src/components/QuickAddBar.tsx +++ b/carry-your-live/src/components/QuickAddBar.tsx @@ -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 { database, collections } from '@/database'; import { useCategories } from '@/hooks/useDatabase'; import { useSettings } from '@/theme'; import { OptionPickerModal } from '@/components/OptionPickerModal'; +import { tagsToString } from '@/types'; import Svg, { Path } from 'react-native-svg'; import { subscribeToQuickAdd } from '@/utils/quickAddFocus'; @@ -20,7 +21,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const inputRef = useRef(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(() => { return subscribeToQuickAdd(() => { @@ -40,6 +45,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { t.title = trimmed; t.description = ''; t.categoryId = categoryId || ''; + t.tags = tagsToString(categoryId ? [categoryId] : []); t.priority = 'none'; t.completed = false; t.dueDate = dueDate; @@ -111,7 +117,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { ({ 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} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onClose={() => setCategoryPickerVisible(false)} diff --git a/carry-your-live/src/components/SubtaskItem.tsx b/carry-your-live/src/components/SubtaskItem.tsx index 1f68e06..35476fb 100644 --- a/carry-your-live/src/components/SubtaskItem.tsx +++ b/carry-your-live/src/components/SubtaskItem.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { View, StyleSheet } from 'react-native'; import { SubtaskData } from '@/types'; import { TaskItem } from './TaskItem'; -import { useSettings } from '@/theme'; interface SubtaskItemProps { subtask: SubtaskData; @@ -19,6 +18,7 @@ interface SubtaskItemProps { onDragEnd?: (absoluteY: number) => void; depth?: number; categoryColor?: string; + categoryColorResolver?: (categoryId: string | undefined) => string | undefined; registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void; hoveredId?: string | null; } @@ -38,13 +38,16 @@ export const SubtaskItem = React.memo(function SubtaskItem({ onDragEnd, depth = 1, categoryColor, + categoryColorResolver, registerRef, hoveredId, }: SubtaskItemProps) { - const { theme } = useSettings(); const [expanded, setExpanded] = useState(false); const hasChildren = subtask.subtasks && subtask.subtasks.length > 0; const hovered = hoveredId === subtask.id; + const effectiveColor = subtask.categoryId + ? categoryColorResolver?.(subtask.categoryId) ?? categoryColor + : categoryColor; const handleExpand = () => setExpanded(!expanded); @@ -75,7 +78,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({ onDragStart={() => onDragStart?.(subtask)} onDragUpdate={onDragUpdate} onDragEnd={onDragEnd} - categoryColor={categoryColor} + categoryColor={effectiveColor} /> {hasChildren && expanded && ( @@ -99,7 +102,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({ onDragUpdate={onDragUpdate} onDragEnd={onDragEnd} depth={depth + 1} - categoryColor={categoryColor} + categoryColor={effectiveColor} + categoryColorResolver={categoryColorResolver} registerRef={registerRef} hoveredId={hoveredId} /> diff --git a/carry-your-live/src/components/TaskItem.tsx b/carry-your-live/src/components/TaskItem.tsx index 42a29d4..a79ff49 100644 --- a/carry-your-live/src/components/TaskItem.tsx +++ b/carry-your-live/src/components/TaskItem.tsx @@ -42,9 +42,10 @@ interface TaskItemProps { indented?: boolean; depth?: number; 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 [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [dragTranslateX] = React.useState(new Animated.Value(0)); @@ -215,9 +216,16 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => - {categoryColor ? ( - - ) : null} + {(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => ( + 0 && { marginLeft: -6 }, + ]} + /> + ))} void; } @@ -39,10 +41,10 @@ const DropIndicator = ({ theme }: { theme: any }) => ( ); -export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) { +export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) { 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 categoryColors = useMemo(() => { @@ -58,7 +60,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp const [selectedIds, setSelectedIds] = useState>(new Set()); const [hoverTaskId, setHoverTaskId] = useState(null); const [expandedTasks, setExpandedTasks] = useState>(new Set()); - const [subtasksMap, setSubtasksMap] = useState>(new Map()); + const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks(); const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const itemRefs = useRef>(new Map()); const subtaskRefs = useRef>(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 sorted = [...tasks]; switch (sortBy) { @@ -104,13 +111,27 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); 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]); + useFocusEffect( + useCallback(() => { + refreshTasks(); + refreshSubtasks(); + }, [refreshTasks, refreshSubtasks]) + ); + const onRefresh = useCallback(() => { setRefreshing(true); + refreshTasks(); + refreshSubtasks(); setTimeout(() => setRefreshing(false), 600); - }, []); + }, [refreshTasks, refreshSubtasks]); const exitSelection = useCallback(() => { setSelectionMode(false); @@ -140,22 +161,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp }); }, [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) => { await toggleTaskComplete(taskId); - refreshAll(); - }, [refreshAll]); + refreshTasks(); + }, [refreshTasks]); const toggleExpand = useCallback(async (taskId: string) => { setExpandedTasks((prev) => { @@ -167,23 +176,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp } return next; }); + }, []); - const isCurrentlyExpanded = expandedTasks.has(taskId); - 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) => { + const handleSubtaskToggle = useCallback(async (subtaskId: string) => { await toggleSubtaskComplete(subtaskId); - await fetchSubtasks(taskId); - }, [fetchSubtasks]); + refreshTasks(); + refreshSubtasks(); + }, [refreshTasks, refreshSubtasks]); const handleBulkDelete = useCallback(() => { 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 () => { await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId))); exitSelection(); - refreshAll(); }, }, ]); - }, [selectedIds, exitSelection, refreshAll]); + }, [selectedIds, exitSelection]); const handleBulkComplete = useCallback(async () => { await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true))); exitSelection(); - refreshAll(); - }, [selectedIds, exitSelection, refreshAll]); + }, [selectedIds, exitSelection]); const measureItems = useCallback(async () => { const positions: Record = {}; @@ -323,17 +320,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId; (async () => { await convertTaskToSubtask(state.taskId, parentTaskId, target); - await fetchSubtasks(parentTaskId); - refreshAll(); })(); } else { (async () => { await convertTaskToSubtask(state.taskId, target); - refreshAll(); })(); } } - }, [findHoverTarget, refreshAll, fetchSubtasks]); + }, [findHoverTarget]); const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => { subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) }; @@ -377,14 +371,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp await setSubtaskParent(state.subtaskId, target); } else if (target !== state.parentTaskId) { await moveSubtaskToTask(state.subtaskId, target); - await fetchSubtasks(target); } } else { await convertSubtaskToTask(state.subtaskId); } - await fetchSubtasks(state.parentTaskId); - refreshAll(); - }, [findHoverTarget, fetchSubtasks, refreshAll]); + }, [findHoverTarget]); const renderItem = useCallback( ({ item, index }: { item: Task; index: number }) => { @@ -392,6 +383,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp const itemSubtasks = subtasksMap.get(item.id) ?? []; const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above'; const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below'; + const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id)); return ( {showDropAbove && } @@ -425,6 +417,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp onReorderEnd={handleDragEnd} selectedIds={selectedIds} categoryColor={categoryColors.get(item.categoryId)} + categoryColors={tagColors} + categoryColorResolver={categoryColorResolver} /> {showDropBelow && } @@ -454,6 +448,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp handleSubtaskDragStart, handleSubtaskDragUpdate, handleSubtaskDragEnd, + registerSubtaskRef, + categoryColorResolver, categoryColors, ] ); @@ -503,7 +499,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp contentContainerStyle={styles.listContent} /> - {modals(refreshAll)} + {modals(() => {})} {selectionMode && ( @@ -566,6 +562,8 @@ interface TaskRowProps { onReorderEnd: (absoluteY: number, translationY: number) => void; selectedIds: Set; categoryColor?: string; + categoryColors?: (string | undefined)[]; + categoryColorResolver?: (categoryId: string | undefined) => string | undefined; } const TaskRow = React.memo(function TaskRow({ @@ -598,6 +596,8 @@ const TaskRow = React.memo(function TaskRow({ onReorderEnd, selectedIds, categoryColor, + categoryColors, + categoryColorResolver, }: TaskRowProps) { const sortedSubtasks = useMemo( () => subtasks.slice().sort((a, b) => a.order - b.order), @@ -627,6 +627,7 @@ const TaskRow = React.memo(function TaskRow({ onReorderUpdate={onReorderUpdate} onReorderEnd={onReorderEnd} categoryColor={categoryColor} + categoryColors={categoryColors} /> {expanded && subtasks.length > 0 && ( @@ -636,16 +637,17 @@ const TaskRow = React.memo(function TaskRow({ subtask={sub} hoveredId={hoverTaskId} registerRef={registerSubtaskRef} - onToggle={() => onSubtaskToggle(sub.id, task.id)} + onToggle={(sub) => onSubtaskToggle(sub.id, task.id)} onDelete={() => onSubtaskDelete(sub)} onMenuOpen={() => onSubtaskMenuOpen(sub)} selected={selectedIds.has(sub.id)} selectionMode={selectionMode} draggable - onDragStart={() => onSubtaskDragStart(sub.id, task.id)} + onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)} onDragUpdate={onSubtaskDragUpdate} onDragEnd={onSubtaskDragEnd} categoryColor={categoryColor} + categoryColorResolver={categoryColorResolver} /> ))} diff --git a/carry-your-live/src/database/migrations.ts b/carry-your-live/src/database/migrations.ts index d1cd02a..9c2d5bc 100644 --- a/carry-your-live/src/database/migrations.ts +++ b/carry-your-live/src/database/migrations.ts @@ -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 }], + }), + ], + }, ], }); diff --git a/carry-your-live/src/database/schema.ts b/carry-your-live/src/database/schema.ts index 3f9b1d6..d5e3212 100644 --- a/carry-your-live/src/database/schema.ts +++ b/carry-your-live/src/database/schema.ts @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export const schema = appSchema({ - version: 18, + version: 20, tables: [ tableSchema({ name: 'categories', @@ -45,6 +45,9 @@ export const schema = appSchema({ columns: [ { name: 'task_id', type: 'string', isIndexed: 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: 'description', type: 'string', isOptional: true }, { name: 'priority', type: 'string', isOptional: true }, diff --git a/carry-your-live/src/database/sync.ts b/carry-your-live/src/database/sync.ts index 61dfd54..2292b53 100644 --- a/carry-your-live/src/database/sync.ts +++ b/carry-your-live/src/database/sync.ts @@ -151,6 +151,8 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio id: s.id, taskId: s.taskId, parentSubtaskId: s.parentSubtaskId || null, + categoryId: s.categoryId || '', + tags: s.tags || '', title: s.title, description: s.description ?? '', priority: s.priority ?? 'none', @@ -532,6 +534,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise { await collections.subtasks.create((s) => { s.taskId = String(row.taskId ?? ''); 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.description = String(row.description ?? ''); s.priority = row.priority ?? 'none'; @@ -558,6 +562,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise { await local.update((s) => { s.taskId = String(row.taskId ?? s.taskId); 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.description = String(row.description ?? s.description); s.priority = row.priority ?? s.priority; diff --git a/carry-your-live/src/hooks/useSubtasks.ts b/carry-your-live/src/hooks/useSubtasks.ts index 9a652bd..e8dd00a 100644 --- a/carry-your-live/src/hooks/useSubtasks.ts +++ b/carry-your-live/src/hooks/useSubtasks.ts @@ -6,6 +6,8 @@ function mapRow(s: any): SubtaskData { id: s.id, taskId: s.taskId, parentSubtaskId: s.parentSubtaskId || null, + categoryId: s.categoryId || '', + tags: s.tags || '', title: s.title, description: s.description || '', priority: (s.priority || 'none') as SubtaskData['priority'], diff --git a/carry-your-live/src/hooks/useTasks.tsx b/carry-your-live/src/hooks/useTasks.tsx index 9e66113..26de36c 100644 --- a/carry-your-live/src/hooks/useTasks.tsx +++ b/carry-your-live/src/hooks/useTasks.tsx @@ -1,6 +1,6 @@ import { useDatabase } from './useDatabase'; import { Q } from '@nozbe/watermelondb'; -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import Task from '../models/Task'; import { startOfMonth, endOfMonth } from 'date-fns'; @@ -8,6 +8,8 @@ export function useTasksInMonth(monthDate: Date) { const { collections } = useDatabase(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const range = useMemo(() => { const s = startOfMonth(monthDate); @@ -42,7 +44,7 @@ export function useTasksInMonth(monthDate: Date) { mounted = false; subscription.unsubscribe(); }; - }, [collections, range.start, range.end]); + }, [collections, range.start, range.end, refreshKey]); const byDay = useMemo(() => { const map: Record = {}; @@ -55,13 +57,15 @@ export function useTasksInMonth(monthDate: Date) { return map; }, [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 [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const cutoff = useMemo(() => { if (maxAheadDays === undefined) return null; @@ -75,8 +79,14 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f let mounted = true; const conditions: any[] = []; - if (categoryId && categoryId !== 'all') { - conditions.push(Q.where('category_id', categoryId)); + if (categoryIds.length > 0) { + 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) { @@ -111,15 +121,17 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f mounted = false; subscription.unsubscribe(); }; - }, [collections, categoryId, showCompleted, cutoff]); + }, [collections, categoryIds, showCompleted, cutoff, refreshKey]); - return { tasks, loading }; + return { tasks, loading, refresh }; } export function useTasksByDate(date: Date) { const { collections } = useDatabase(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const startOfDay = useMemo(() => { const d = new Date(date); @@ -156,7 +168,7 @@ export function useTasksByDate(date: Date) { mounted = false; subscription.unsubscribe(); }; - }, [collections, startOfDay, endOfDay]); + }, [collections, startOfDay, endOfDay, refreshKey]); - return { tasks, loading }; + return { tasks, loading, refresh }; } diff --git a/carry-your-live/src/models/Subtask.ts b/carry-your-live/src/models/Subtask.ts index c0d32a9..a8cc919 100644 --- a/carry-your-live/src/models/Subtask.ts +++ b/carry-your-live/src/models/Subtask.ts @@ -12,6 +12,8 @@ export default class Subtask extends Model { @field('task_id') taskId!: string; @field('parent_subtask_id') parentSubtaskId!: string | null; + @field('category_id') categoryId!: string; + @field('tags') tags!: string; @field('title') title!: string; @field('description') description!: string; @field('priority') priority!: Priority; diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index 3796136..d485dd6 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -130,8 +130,6 @@ interface SettingsContextType { setTodoAheadDays: (value: number) => void; showCompleted: boolean; setShowCompleted: (value: boolean) => void; - calendarCategoryId: string; - setCalendarCategoryId: (value: string) => void; theme: ThemeColors; } @@ -145,7 +143,6 @@ const STORAGE_KEYS = { accentColor: 'settings:accentColor', todoAheadDays: 'settings:todoAheadDays', showCompleted: 'settings:showCompleted', - calendarCategoryId: 'settings:calendarCategoryId', }; const SettingsContext = createContext(null); @@ -188,7 +185,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const [accentColor, setAccentColor] = useStoredSetting(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); const [todoAheadDays, setTodoAheadDays] = useStoredSetting(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS); const [showCompleted, setShowCompleted] = useStoredSetting(STORAGE_KEYS.showCompleted, false); - const [calendarCategoryId, setCalendarCategoryId] = useStoredSetting(STORAGE_KEYS.calendarCategoryId, ''); const theme = useMemo(() => colors(accentColor), [accentColor]); @@ -210,8 +206,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) { setTodoAheadDays, showCompleted, setShowCompleted, - calendarCategoryId, - setCalendarCategoryId, theme, }), [ @@ -231,8 +225,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) { setTodoAheadDays, showCompleted, setShowCompleted, - calendarCategoryId, - setCalendarCategoryId, theme, ] ); diff --git a/carry-your-live/src/types/index.ts b/carry-your-live/src/types/index.ts index 3a55a13..d47df85 100644 --- a/carry-your-live/src/types/index.ts +++ b/carry-your-live/src/types/index.ts @@ -144,6 +144,8 @@ export interface SubtaskData { id: string; taskId: string; parentSubtaskId: string | null; + categoryId: string; + tags: string; title: string; description: string; priority: Priority; @@ -191,6 +193,7 @@ export interface TaskFormData { export interface SubtaskFormData { title: string; description?: string; + categoryId?: string; priority: Priority; dueDate: Date | null; dueTime?: string; diff --git a/carry-your-live/src/utils/taskActions.ts b/carry-your-live/src/utils/taskActions.ts index 1c1b84a..5f34c92 100644 --- a/carry-your-live/src/utils/taskActions.ts +++ b/carry-your-live/src/utils/taskActions.ts @@ -9,6 +9,7 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData { id: s.id, taskId: s.taskId ?? taskId ?? '', parentSubtaskId: s.parentSubtaskId || null, + categoryId: s.categoryId || '', title: s.title, description: s.description || '', priority: (s.priority || 'none') as Priority, @@ -235,6 +236,7 @@ export async function toggleSubtaskComplete(subtaskId: string): Promise { export interface SubtaskUpdateData { title: string; description?: string; + categoryId?: string; priority: Priority; dueDate: number; dueTime?: string; @@ -253,6 +255,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): await subtask.update((s) => { s.title = data.title.trim(); s.description = data.description || ''; + if (data.categoryId !== undefined) s.categoryId = data.categoryId; s.priority = data.priority; s.dueDate = data.dueDate; s.dueTime = data.dueTime || ''; @@ -314,6 +317,7 @@ export async function duplicateSubtask(subtaskId: string): Promise { clone = await collections.subtasks.create((s) => { s.taskId = subtask.taskId; + s.categoryId = subtask.categoryId || ''; s.title = subtask.title; s.description = subtask.description; s.priority = subtask.priority; @@ -407,6 +411,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string, const created = await collections.subtasks.create((s) => { s.taskId = parentTaskId; s.parentSubtaskId = parentSubtaskId; + s.categoryId = task.categoryId || ''; s.title = task.title; s.description = task.description; s.priority = task.priority; @@ -430,6 +435,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string, await collections.subtasks.create((s) => { s.taskId = parentTaskId; s.parentSubtaskId = created.id; + s.categoryId = subtask.categoryId || ''; s.title = subtask.title; s.description = subtask.description; s.priority = subtask.priority; @@ -470,7 +476,8 @@ export async function convertSubtaskToTask(subtaskId: string): Promise { const task = await collections.tasks.create((t) => { t.title = subtask.title; 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.completed = subtask.completed; t.dueDate = subtask.dueDate; @@ -747,6 +754,7 @@ export async function reorderTasks(taskIds: string[]): Promise { export interface CreateSubtaskData { taskId: string; parentSubtaskId?: string | null; + categoryId?: string; title: string; description?: string; priority?: Priority; @@ -773,6 +781,7 @@ export async function createSubtask(data: CreateSubtaskData): Promise { const subtask = await collections.subtasks.create((s) => { s.taskId = data.taskId; s.parentSubtaskId = data.parentSubtaskId || null; + s.categoryId = data.categoryId || ''; s.title = data.title.trim(); s.description = data.description || ''; s.priority = data.priority || 'none';