diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4108171 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,57 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'Merge pull request') + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute next version + id: version + run: | + latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) + if [ -z "$latest" ]; then + next="v1.0.0" + else + latest=${latest#v} + IFS='.' read -r major minor patch <<< "$latest" + next="v${major}.${minor}.$((patch + 1))" + fi + echo "next=${next}" >> "$GITHUB_OUTPUT" + + - name: Build changelog + id: changelog + run: | + prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$prev_tag" ]; then + log=$(git log --oneline --no-merges main | head -n 40) + else + log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40) + fi + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.next }} + name: ${{ steps.version.outputs.next }} + body: ${{ steps.changelog.outputs.body }} + generate_release_notes: false diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ace6a53..0bede9b 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -44,6 +44,7 @@ export const tasks = pgTable('tasks', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }), + tags: text('tags').notNull().default(''), title: text('title').notNull(), description: text('description').notNull().default(''), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), @@ -84,6 +85,7 @@ export const subtasks = pgTable('subtasks', { userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }), parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }), + categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }), title: text('title').notNull(), description: text('description').notNull().default(''), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), diff --git a/backend/src/routes/subtasks.ts b/backend/src/routes/subtasks.ts index 1aac289..4eeb034 100644 --- a/backend/src/routes/subtasks.ts +++ b/backend/src/routes/subtasks.ts @@ -82,6 +82,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => userId, taskId: req.params.taskId, parentSubtaskId, + categoryId: data.categoryId ?? null, title: data.title, description: data.description ?? '', priority: data.priority ?? 'none', diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts index 236e46c..c4a3472 100644 --- a/backend/src/routes/sync.ts +++ b/backend/src/routes/sync.ts @@ -235,6 +235,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => { title: task.title, description: task.description, categoryId: task.categoryId, + tags: task.tags ?? '', priority: task.priority, completed: task.completed, dueDate: task.dueDate, @@ -290,6 +291,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => { .set({ taskId: sub.taskId, parentSubtaskId: sub.parentSubtaskId ?? null, + categoryId: sub.categoryId ?? null, title: sub.title, description: sub.description ?? '', priority: sub.priority ?? 'none', diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 495fbba..795987d 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -135,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => { id: taskId, userId, categoryId: data.categoryId, + tags: data.tags ?? '', title: data.title, description: data.description ?? '', priority: data.priority ?? 'none', @@ -159,6 +160,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => { userId, taskId, title: st.title, + categoryId: (st as any).categoryId ?? null, completed: false, order: index, createdAt: now, diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 32dc1d7..9aa68e3 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -39,6 +39,7 @@ export const taskCreateSchema = z.object({ title: z.string().min(1).max(100), description: z.string().max(1000).optional(), categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(), + tags: z.string().max(500).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), dueDate: z.number().int().min(0).optional(), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), @@ -52,13 +53,14 @@ export const taskCreateSchema = z.object({ reminders: z.string().max(100).optional(), assigneeId: z.string().nullable().optional(), completedAt: z.number().int().min(0).nullable().optional(), - subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(), + subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(), }); export const taskUpdateSchema = z.object({ title: z.string().min(1).max(100).optional(), description: z.string().max(1000).optional(), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)), + tags: z.string().max(500).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), completed: z.boolean().optional(), dueDate: z.number().int().min(0).optional(), @@ -72,6 +74,7 @@ export const taskUpdateSchema = z.object({ export const subtaskCreateSchema = z.object({ title: z.string().min(1).max(100), description: z.string().max(1000).optional(), + categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), dueDate: z.number().int().min(0).optional(), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), @@ -91,6 +94,7 @@ export const subtaskCreateSchema = z.object({ export const subtaskUpdateSchema = z.object({ title: z.string().min(1).max(100).optional(), description: z.string().max(1000).optional(), + categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), completed: z.boolean().optional(), dueDate: z.number().int().min(0).optional(), 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} /> (); + + const day = useMemo(() => { + const parsed = dateParam ? new Date(dateParam) : new Date(); + return Number.isNaN(parsed.getTime()) ? new Date() : parsed; + }, [dateParam]); + const dayStart = useMemo(() => startOfDay(day), [day]); + + const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks(); + const { tasks: dayTasks, loading, refresh } = useTasksByDate(day); + + useFocusEffect( + useCallback(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => false); + refreshSubtasks(); + refresh(); + return () => sub.remove(); + }, [refreshSubtasks, refresh]) + ); + + const subtasksOnDay = useMemo(() => { + const map = new Map(); + for (const [taskId, roots] of subtasksByTask) { + const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day)); + if (due.length > 0) map.set(taskId, due); + } + return map; + }, [subtasksByTask, day]); + + const handleToggleComplete = useCallback(async (taskId: string) => { + await toggleTaskComplete(taskId); + refresh(); + refreshSubtasks(); + }, [refresh, refreshSubtasks]); + + const handleToggleSubtask = useCallback(async (subtaskId: string) => { + await toggleSubtaskComplete(subtaskId); + refreshSubtasks(); + }, [refreshSubtasks]); + + return ( + +
+ + + {loading ? ( + + Loading... + + ) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? ( + + No tasks on this day + + ) : ( + <> + {dayTasks.map((task) => { + const subs = subtasksOnDay.get(task.id) ?? []; + const openCount = subs.filter((s) => !s.completed).length; + const cat = categories.find((c) => c.id === task.categoryId); + return ( + + + handleToggleComplete(task.id)} + activeOpacity={0.7} + accessibilityRole="checkbox" + accessibilityState={{ checked: task.completed }} + > + + {task.completed ? ( + <> + + + + ) : ( + + )} + + + + router.push({ pathname: '/task-detail', params: { id: task.id } })} + activeOpacity={0.7} + > + + {task.title} + + {openCount > 0 && ( + + {openCount} open subtask{openCount === 1 ? '' : 's'} + + )} + {!task.completed && ( + + {cat && ( + + {cat.name} + + )} + {task.dueTime ? ( + {task.dueTime}{task.endTime ? `–${task.endTime}` : ''} + ) : task.dueDate ? ( + {format(task.dueDate, 'HH:mm')} + ) : null} + + )} + + + openTaskEdit(task.id)} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`Edit ${task.title}`} + > + + + + + + + ); + })} + {Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => { + const parent = dayTasks.find((t) => t.id === taskId); + if (parent) return []; + return subs.map((sub) => ( + + + handleToggleSubtask(sub.id)} + activeOpacity={0.7} + accessibilityRole="checkbox" + accessibilityState={{ checked: sub.completed }} + > + + {sub.completed ? ( + <> + + + + ) : ( + + )} + + + router.push({ pathname: '/subtask-detail', params: { id: sub.id } })} + activeOpacity={0.7} + > + + {sub.title} + + {sub.dueTime ? ( + {sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''} + ) : null} + + + + )); + })} + + )} + + + + {modals(() => {})} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + kbAvoid: { + flex: 1, + }, + scrollBody: { + flex: 1, + }, + scrollContent: { + paddingTop: 12, + paddingBottom: 16, + }, + emptyState: { + alignItems: 'center', + paddingVertical: 64, + }, + emptyText: { + fontSize: 15, + fontWeight: '600', + }, + eventCard: { + marginHorizontal: 16, + marginBottom: 8, + borderRadius: 16, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 12, + }, + eventRow: { + flexDirection: 'row', + alignItems: 'center', + }, + checkCircle: { + width: 30, + marginRight: 12, + }, + eventTouch: { + flex: 1, + }, + eventTitle: { + fontSize: 15, + fontWeight: '500', + }, + eventCompleted: { + textDecorationLine: 'line-through', + color: '#9E9E9E', + }, + eventSub: { + fontSize: 13, + marginTop: 2, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 4, + }, + tagChip: { + paddingHorizontal: 7, + paddingVertical: 2, + borderRadius: 6, + maxWidth: 140, + }, + tagText: { + color: '#FFFFFF', + fontSize: 10, + fontWeight: '600', + }, + timeText: { + fontSize: 12.5, + fontWeight: '500', + }, + menuButton: { + padding: 4, + marginLeft: 6, + }, +}); \ No newline at end of file diff --git a/carry-your-live/app/subtask-detail.tsx b/carry-your-live/app/subtask-detail.tsx index 4b6e7a3..0d1f434 100644 --- a/carry-your-live/app/subtask-detail.tsx +++ b/carry-your-live/app/subtask-detail.tsx @@ -18,12 +18,14 @@ import { collections } from '@/database'; import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types'; import { useSettings } from '@/theme'; import { updateSubtask, deleteSubtask } from '@/utils/taskActions'; +import { CategorySelector } from '@/components/CategorySelector'; import { useFriends } from '@/hooks/useFriends'; import Svg, { Path } from 'react-native-svg'; const subtaskSchema = z.object({ title: z.string().trim().min(1, 'Subtask name is required').max(100), description: z.string().max(1000).optional(), + categoryId: z.string().optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), dueDate: z.date().nullable().optional(), dueTime: z.string().optional(), @@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() { defaultValues: { title: '', description: '', + categoryId: '', priority: 'none', dueDate: null, dueTime: '', @@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() { const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const priority = watch('priority'); + const categoryId = watch('categoryId'); const repeat = watch('repeat'); const repeatInterval = watch('repeatInterval') ?? 1; const repeatDays = watch('repeatDays') ?? []; @@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() { if (!id || !isReady) return; let mounted = true; - (async () => { - 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 c21eb6f..9c2d5bc 100644 --- a/carry-your-live/src/database/migrations.ts +++ b/carry-your-live/src/database/migrations.ts @@ -193,5 +193,32 @@ export const migrations = schemaMigrations({ }), ], }, + { + toVersion: 18, + steps: [ + addColumns({ + table: 'tasks', + columns: [{ name: 'tags', type: 'string', isOptional: true }], + }), + ], + }, + { + 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 375e7fb..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: 17, + version: 20, tables: [ tableSchema({ name: 'categories', @@ -19,6 +19,7 @@ export const schema = appSchema({ { name: 'title', type: 'string' }, { name: 'description', type: 'string' }, { name: 'category_id', type: 'string', isIndexed: true }, + { name: 'tags', type: 'string', isOptional: true }, { name: 'priority', type: 'string' }, { name: 'completed', type: 'boolean', isIndexed: true }, { name: 'completed_at', type: 'number', isOptional: true }, @@ -44,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 435b80e..2292b53 100644 --- a/carry-your-live/src/database/sync.ts +++ b/carry-your-live/src/database/sync.ts @@ -117,6 +117,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio title: t.title, description: t.description, categoryId: t.categoryId, + tags: t.tags || '', priority: t.priority, completed: t.completed, dueDate: t.dueDate, @@ -150,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', @@ -451,6 +454,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise { t.title = String(row.title ?? ''); t.description = String(row.description ?? ''); t.categoryId = String(row.categoryId ?? ''); + t.tags = String(row.tags ?? ''); t.priority = row.priority ?? 'none'; t.completed = Boolean(row.completed); t.dueDate = Number(row.dueDate ?? 0); @@ -476,6 +480,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise { t.title = String(row.title ?? t.title); t.description = String(row.description ?? t.description); t.categoryId = String(row.categoryId ?? t.categoryId); + t.tags = String(row.tags ?? t.tags ?? ''); t.priority = row.priority ?? t.priority; t.completed = Boolean(row.completed ?? t.completed); t.dueDate = Number(row.dueDate ?? t.dueDate); @@ -529,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'; @@ -555,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 new file mode 100644 index 0000000..e8dd00a --- /dev/null +++ b/carry-your-live/src/hooks/useSubtasks.ts @@ -0,0 +1,87 @@ +import { useDatabase } from './useDatabase'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SubtaskData } from '@/types'; +function mapRow(s: any): SubtaskData { + return { + 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'], + 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: [], + }; +} + +function buildTrees(rows: any[]): Map { + const nodes = new Map(); + for (const row of rows) { + nodes.set(row.id, mapRow(row)); + } + const rootsByTask = new Map(); + for (const node of nodes.values()) { + if (node.parentSubtaskId && nodes.has(node.parentSubtaskId)) { + const parent = nodes.get(node.parentSubtaskId)!; + parent.subtasks.push(node); + } else { + const list = rootsByTask.get(node.taskId) ?? []; + list.push(node); + rootsByTask.set(node.taskId, list); + } + } + for (const list of rootsByTask.values()) { + list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + const sortDeep = (list: SubtaskData[]) => { + for (const node of list) { + node.subtasks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + sortDeep(node.subtasks); + } + }; + for (const list of rootsByTask.values()) { + sortDeep(list); + } + return rootsByTask; +} + +export function useSubtasks(): { map: Map; refresh: () => void } { + const { collections } = useDatabase(); + const [rows, setRows] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); + + useEffect(() => { + let mounted = true; + const subscription = collections.subtasks + .query() + .observe() + .subscribe({ + next: (result) => { + if (mounted) setRows(result); + }, + error: () => {}, + }); + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, [collections.subtasks, refreshKey]); + + const map = useMemo(() => buildTrees(rows), [rows]); + + return { map, refresh }; +} \ No newline at end of file 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/models/Task.ts b/carry-your-live/src/models/Task.ts index 4e9bb11..6c93388 100644 --- a/carry-your-live/src/models/Task.ts +++ b/carry-your-live/src/models/Task.ts @@ -13,6 +13,7 @@ export default class Task extends Model { @field('title') title!: string; @field('description') description!: string; @field('category_id') categoryId!: string; + @field('tags') tags!: string; @field('priority') priority!: Priority; @field('completed') completed!: boolean; @field('completed_at') completedAt!: number | null; diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index db4fecc..d485dd6 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -128,6 +128,8 @@ interface SettingsContextType { setAccentColor: (value: string) => void; todoAheadDays: number; setTodoAheadDays: (value: number) => void; + showCompleted: boolean; + setShowCompleted: (value: boolean) => void; theme: ThemeColors; } @@ -140,6 +142,7 @@ const STORAGE_KEYS = { reminderPreference: 'settings:reminderPreference', accentColor: 'settings:accentColor', todoAheadDays: 'settings:todoAheadDays', + showCompleted: 'settings:showCompleted', }; const SettingsContext = createContext(null); @@ -181,6 +184,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const [apiUrl, setApiUrl] = useStoredSetting(API_URL_KEY, DEFAULT_API_BASE_URL); 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 theme = useMemo(() => colors(accentColor), [accentColor]); @@ -200,6 +204,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) { setAccentColor, todoAheadDays, setTodoAheadDays, + showCompleted, + setShowCompleted, theme, }), [ @@ -217,6 +223,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) { setAccentColor, todoAheadDays, setTodoAheadDays, + showCompleted, + setShowCompleted, theme, ] ); diff --git a/carry-your-live/src/types/index.ts b/carry-your-live/src/types/index.ts index 4193c69..d47df85 100644 --- a/carry-your-live/src/types/index.ts +++ b/carry-your-live/src/types/index.ts @@ -99,11 +99,30 @@ export interface CategoryData { order: number; } +export function tagsToString(tags: string[]): string { + return `,${tags.filter(Boolean).join(',')},`; +} + +export function tagsFromString(raw: string | null | undefined): string[] { + if (!raw) return []; + return raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); +} + +export function parseTaskTags(tagsRaw: string | null | undefined, categoryId: string): string[] { + const parsed = tagsFromString(tagsRaw); + if (parsed.length > 0) return parsed; + return categoryId ? [categoryId] : []; +} + export interface TaskData { id: string; title: string; description: string; categoryId: string; + tags?: string; priority: Priority; completed: boolean; dueDate: number; @@ -125,6 +144,8 @@ export interface SubtaskData { id: string; taskId: string; parentSubtaskId: string | null; + categoryId: string; + tags: string; title: string; description: string; priority: Priority; @@ -154,6 +175,7 @@ export interface TaskFormData { title: string; description?: string; categoryId: string; + tags?: string[]; priority: Priority; dueDate: Date | null; dueTime?: string; @@ -171,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/categoryActions.ts b/carry-your-live/src/utils/categoryActions.ts index 23f10e3..330ef0b 100644 --- a/carry-your-live/src/utils/categoryActions.ts +++ b/carry-your-live/src/utils/categoryActions.ts @@ -1,6 +1,7 @@ import { database, collections } from '@/database'; import { Q } from '@nozbe/watermelondb'; import { recordTombstonesInBatch } from '@/database/tombstones'; +import { tagsFromString, tagsToString, parseTaskTags } from '@/types'; export async function createCategory(name: string, color: string): Promise { await database.write(async () => { @@ -36,7 +37,18 @@ export async function deleteCategory(categoryId: string): Promise { const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch(); for (const task of tasks) { await task.update((t) => { - t.categoryId = fallback?.id ?? ''; + const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId); + t.categoryId = fallback?.id ?? tags[0] ?? ''; + t.tags = tagsToString(tags); + t.updatedAt = new Date(); + }); + } + + const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch(); + for (const task of tagReferencedTasks) { + const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId); + await task.update((t) => { + t.tags = tagsToString(tags); t.updatedAt = new Date(); }); } diff --git a/carry-your-live/src/utils/taskActions.ts b/carry-your-live/src/utils/taskActions.ts index 1e2e10a..5f34c92 100644 --- a/carry-your-live/src/utils/taskActions.ts +++ b/carry-your-live/src/utils/taskActions.ts @@ -1,6 +1,6 @@ import { database, collections } from '@/database'; import { Q } from '@nozbe/watermelondb'; -import { Priority, Repeat, Reminder, SubtaskData } from '@/types'; +import { Priority, Repeat, Reminder, SubtaskData, tagsToString } from '@/types'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { recordTombstonesInBatch } from '@/database/tombstones'; @@ -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; @@ -621,6 +628,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise { t.title = task.title; t.description = task.description; t.categoryId = task.categoryId; + t.tags = task.tags || ''; t.priority = task.priority; t.completed = false; t.dueDate = nextDate.getTime(); @@ -664,6 +672,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi const task = await collections.tasks.find(taskId); await task.update((t) => { t.categoryId = categoryId; + t.tags = tagsToString(categoryId ? [categoryId] : []); t.updatedAt = new Date(); }); }); @@ -690,6 +699,7 @@ export async function duplicateTask(taskId: string): Promise { t.title = task.title; t.description = task.description; t.categoryId = task.categoryId; + t.tags = task.tags || ''; t.priority = task.priority; t.completed = false; t.dueDate = task.dueDate; @@ -744,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; @@ -770,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';