From 5fc1365ced070ea254c444fabd2d55209af7099d Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 20 Feb 2026 20:54:06 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Phase=202=20=E2=80=94=20tags,=20s?= =?UTF-8?q?ort,=20filters,=20recurrence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tags: create/edit/delete tags with color picker modal, assign to tasks - Sort: sort tasks by position, priority, due date, title, created date - Filters: filter by status, priority, due date, tag - Recurrence: daily/weekly/monthly/yearly with auto-creation on completion - Fix removeTagFromTask bug (was deleting all tags instead of specific one) - Tag editor redesigned as modal for better keyboard UX Co-Authored-By: Claude Opus 4.6 --- app/(tabs)/index.tsx | 52 +++- app/(tabs)/settings.tsx | 261 +++++++++++++++---- app/list/[id].tsx | 66 +++-- app/task/[id].tsx | 180 ++++++------- app/task/new.tsx | 166 +++++++----- src/components/FilterMenu.tsx | 234 +++++++++++++++++ src/components/SortMenu.tsx | 86 ++++++ src/components/task/TagChip.tsx | 51 ++++ src/components/task/TaskItem.tsx | 43 ++- src/db/migrations/0001_sticky_arachne.sql | 16 ++ src/db/migrations/meta/0001_snapshot.json | 302 ++++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/migrations/migrations.js | 4 +- src/db/repository/tags.ts | 56 ++++ src/db/repository/tasks.ts | 118 ++++++++- src/db/schema.ts | 21 +- src/i18n/en.json | 44 +++- src/i18n/fr.json | 44 +++- src/lib/recurrence.ts | 18 ++ src/stores/useTaskStore.ts | 64 +++++ 20 files changed, 1582 insertions(+), 251 deletions(-) create mode 100644 src/components/FilterMenu.tsx create mode 100644 src/components/SortMenu.tsx create mode 100644 src/components/task/TagChip.tsx create mode 100644 src/db/migrations/0001_sticky_arachne.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json create mode 100644 src/db/repository/tags.ts create mode 100644 src/lib/recurrence.ts create mode 100644 src/stores/useTaskStore.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 1ee075e..58e5845 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,16 +1,21 @@ import { useEffect, useState, useCallback } from 'react'; import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native'; import { useRouter } from 'expo-router'; -import { Plus } from 'lucide-react-native'; +import { Plus, ArrowUpDown, Filter } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import * as Haptics from 'expo-haptics'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { useTaskStore } from '@/src/stores/useTaskStore'; import { getTasksByList, toggleComplete, deleteTask } from '@/src/db/repository/tasks'; import { getInboxId } from '@/src/db/repository/lists'; +import { getTagsForTask } from '@/src/db/repository/tags'; import TaskItem from '@/src/components/task/TaskItem'; +import SortMenu from '@/src/components/SortMenu'; +import FilterMenu from '@/src/components/FilterMenu'; +type Tag = { id: string; name: string; color: string }; type Task = { id: string; title: string; @@ -18,20 +23,35 @@ type Task = { priority: number; dueDate: Date | null; position: number; + recurrence: string | null; + tags?: Tag[]; }; export default function InboxScreen() { const { t } = useTranslation(); const router = useRouter(); const [tasks, setTasks] = useState([]); + const [showSort, setShowSort] = useState(false); + const [showFilter, setShowFilter] = useState(false); const systemScheme = useColorScheme(); const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); + const loadTasks = useCallback(async () => { - const result = await getTasksByList(getInboxId()); - setTasks(result as Task[]); - }, []); + const result = await getTasksByList(getInboxId(), { + sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, + }); + // Load tags for each task + const withTags = await Promise.all( + result.map(async (task) => ({ + ...task, + tags: await getTagsForTask(task.id), + })) + ); + setTasks(withTags as Task[]); + }, [sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate]); useEffect(() => { loadTasks(); @@ -63,17 +83,30 @@ export default function InboxScreen() { ]); }; + const filtersActive = hasActiveFilters(); + return ( + {/* Toolbar */} + + setShowSort(true)} className="mr-3 p-1"> + + + setShowFilter(true)} className="relative p-1"> + + {filtersActive && ( + + )} + + + {tasks.length === 0 ? ( - {t('empty.inbox')} + {filtersActive ? t('empty.list') : t('empty.inbox')} ) : ( @@ -100,6 +133,9 @@ export default function InboxScreen() { > + + setShowSort(false)} isDark={isDark} /> + setShowFilter(false)} isDark={isDark} /> ); } diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index a470ed3..169f2a7 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,20 +1,39 @@ -import { View, Text, Pressable, useColorScheme } from 'react-native'; +import { useState, useEffect, useCallback } from 'react'; +import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform } from 'react-native'; import { useTranslation } from 'react-i18next'; -import { Sun, Moon, Smartphone } from 'lucide-react-native'; +import { Sun, Moon, Smartphone, Plus, Trash2, Pencil } from 'lucide-react-native'; import Constants from 'expo-constants'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags'; import i18n from '@/src/i18n'; type ThemeMode = 'light' | 'dark' | 'system'; +const TAG_COLORS = ['#4A90A4', '#C17767', '#8BA889', '#D4A574', '#7B68EE', '#E57373', '#4DB6AC']; + export default function SettingsScreen() { const { t } = useTranslation(); const systemScheme = useColorScheme(); const { theme, locale, setTheme, setLocale } = useSettingsStore(); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const [tagsList, setTagsList] = useState<{ id: string; name: string; color: string }[]>([]); + const [showTagModal, setShowTagModal] = useState(false); + const [editingTagId, setEditingTagId] = useState(null); + const [tagName, setTagName] = useState(''); + const [tagColor, setTagColor] = useState(TAG_COLORS[0]); + + const loadTags = useCallback(async () => { + const result = await getAllTags(); + setTagsList(result); + }, []); + + useEffect(() => { + loadTags(); + }, [loadTags]); + const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [ { value: 'light', label: t('settings.light'), icon: Sun }, { value: 'dark', label: t('settings.dark'), icon: Moon }, @@ -26,23 +45,56 @@ export default function SettingsScreen() { i18n.changeLanguage(newLocale); }; + const openNewTag = () => { + setEditingTagId(null); + setTagName(''); + setTagColor(TAG_COLORS[0]); + setShowTagModal(true); + }; + + const openEditTag = (tag: { id: string; name: string; color: string }) => { + setEditingTagId(tag.id); + setTagName(tag.name); + setTagColor(tag.color); + setShowTagModal(true); + }; + + const handleSaveTag = async () => { + if (!tagName.trim()) return; + if (editingTagId) { + await updateTag(editingTagId, tagName.trim(), tagColor); + } else { + await createTag(tagName.trim(), tagColor); + } + setShowTagModal(false); + loadTags(); + }; + + const handleDeleteTag = (id: string) => { + Alert.alert(t('tag.deleteConfirm'), '', [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('common.delete'), + style: 'destructive', + onPress: async () => { + await deleteTag(id); + loadTags(); + }, + }, + ]); + }; + return ( - + {/* Theme Section */} {t('settings.theme')} - + {themeOptions.map((option) => { const Icon = option.icon; const isActive = theme === option.value; @@ -50,22 +102,11 @@ export default function SettingsScreen() { setTheme(option.value)} - className={`flex-row items-center border-b px-4 py-3.5 ${ - isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' - } ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`} + className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`} > - + {option.label} @@ -79,36 +120,22 @@ export default function SettingsScreen() { {/* Language Section */} {t('settings.language')} - + {(['fr', 'en'] as const).map((lang) => { const isActive = locale === lang; return ( handleLocaleChange(lang)} - className={`border-b px-4 py-3.5 ${ - isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' - } ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`} + className={`border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`} > {lang === 'fr' ? 'Français' : 'English'} @@ -119,21 +146,149 @@ export default function SettingsScreen() { - {/* About Section */} + {/* Tags Section */} + + + {t('tag.tags')} + + + + + + + {tagsList.length === 0 ? ( + + + {t('tag.noTags')} + + + ) : ( + tagsList.map((tag) => ( + openEditTag(tag)} + className={`flex-row items-center justify-between border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} + > + + + + {tag.name} + + + + + handleDeleteTag(tag.id)} className="ml-3 p-1"> + + + + + )) + )} + + + + {/* Tag Create/Edit Modal */} + + + setShowTagModal(false)} className="flex-1 justify-center bg-black/40 px-6"> + e.stopPropagation()} + className={`rounded-2xl px-5 pb-5 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`} + > + {/* Modal title */} + + {editingTagId ? t('tag.editTag') : t('tag.newTag')} + + + {/* Name input */} + + + {/* Color picker */} + + {TAG_COLORS.map((c) => ( + setTagColor(c)} + className="h-8 w-8 items-center justify-center rounded-full" + style={{ backgroundColor: c, borderWidth: tagColor === c ? 3 : 0, borderColor: isDark ? '#F5F5F5' : '#1A1A1A' }} + > + {tagColor === c && ( + + )} + + ))} + + + {/* Preview */} + {tagName.trim().length > 0 && ( + + + + + {tagName.trim()} + + + + )} + + {/* Buttons */} + + setShowTagModal(false)} className="mr-3 px-4 py-2"> + + {t('common.cancel')} + + + + + {t('common.save')} + + + + + + + + + {/* About Section */} + {t('settings.about')} - + Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'} @@ -142,6 +297,6 @@ export default function SettingsScreen() { - + ); } diff --git a/app/list/[id].tsx b/app/list/[id].tsx index 2b06554..509dcb6 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -1,16 +1,21 @@ import { useEffect, useState, useCallback } from 'react'; import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { ArrowLeft, Plus } from 'lucide-react-native'; +import { ArrowLeft, Plus, ArrowUpDown, Filter } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import * as Haptics from 'expo-haptics'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { useTaskStore } from '@/src/stores/useTaskStore'; import { getTasksByList, toggleComplete, deleteTask } from '@/src/db/repository/tasks'; import { getAllLists } from '@/src/db/repository/lists'; +import { getTagsForTask } from '@/src/db/repository/tags'; import TaskItem from '@/src/components/task/TaskItem'; +import SortMenu from '@/src/components/SortMenu'; +import FilterMenu from '@/src/components/FilterMenu'; +type Tag = { id: string; name: string; color: string }; type Task = { id: string; title: string; @@ -18,6 +23,8 @@ type Task = { priority: number; dueDate: Date | null; position: number; + recurrence: string | null; + tags?: Tag[]; }; export default function ListDetailScreen() { @@ -26,21 +33,33 @@ export default function ListDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const [tasks, setTasks] = useState([]); const [listName, setListName] = useState(''); + const [showSort, setShowSort] = useState(false); + const [showFilter, setShowFilter] = useState(false); const systemScheme = useColorScheme(); const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); + const loadData = useCallback(async () => { if (!id) return; - const result = await getTasksByList(id); - setTasks(result as Task[]); + const result = await getTasksByList(id, { + sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, + }); + const withTags = await Promise.all( + result.map(async (task) => ({ + ...task, + tags: await getTagsForTask(task.id), + })) + ); + setTasks(withTags as Task[]); const lists = await getAllLists(); const list = lists.find((l) => l.id === id); if (list) { setListName(list.isInbox ? t('list.inbox') : list.name); } - }, [id, t]); + }, [id, t, sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate]); useEffect(() => { loadData(); @@ -72,23 +91,38 @@ export default function ListDetailScreen() { ]); }; + const filtersActive = hasActiveFilters(); + return ( {/* Header */} - router.back()} className="mr-3 p-1"> - - - - {listName} - + + router.back()} className="mr-3 p-1"> + + + + {listName} + + + + setShowSort(true)} className="mr-3 p-1"> + + + setShowFilter(true)} className="relative p-1"> + + {filtersActive && ( + + )} + + {tasks.length === 0 ? ( @@ -117,7 +151,6 @@ export default function ListDetailScreen() { /> )} - {/* FAB */} router.push(`/task/new?listId=${id}` as any)} className="absolute bottom-6 right-6 h-14 w-14 items-center justify-center rounded-full bg-bleu" @@ -125,6 +158,9 @@ export default function ListDetailScreen() { > + + setShowSort(false)} isDark={isDark} /> + setShowFilter(false)} isDark={isDark} /> ); } diff --git a/app/task/[id].tsx b/app/task/[id].tsx index bf82de6..eeef74a 100644 --- a/app/task/[id].tsx +++ b/app/task/[id].tsx @@ -10,7 +10,7 @@ import { Platform, } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { ArrowLeft, Plus, Trash2, Calendar, X } from 'lucide-react-native'; +import { ArrowLeft, Plus, Trash2, Calendar, X, Repeat } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import * as Haptics from 'expo-haptics'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; @@ -18,6 +18,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { getPriorityOptions } from '@/src/lib/priority'; +import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; import { getTaskById, updateTask, @@ -26,7 +27,8 @@ import { createTask, toggleComplete, } from '@/src/db/repository/tasks'; - +import { getAllTags, getTagsForTask, setTagsForTask } from '@/src/db/repository/tags'; +import TagChip from '@/src/components/task/TagChip'; type TaskData = { id: string; @@ -36,13 +38,10 @@ type TaskData = { priority: number; dueDate: Date | null; listId: string; + recurrence: string | null; }; -type SubtaskData = { - id: string; - title: string; - completed: boolean; -}; +type SubtaskData = { id: string; title: string; completed: boolean }; export default function TaskDetailScreen() { const { t } = useTranslation(); @@ -58,13 +57,17 @@ export default function TaskDetailScreen() { const [priority, setPriority] = useState(0); const [dueDate, setDueDate] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); + const [recurrence, setRecurrence] = useState(null); const [subtasks, setSubtasks] = useState([]); const [newSubtask, setNewSubtask] = useState(''); + const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); + const [selectedTagIds, setSelectedTagIds] = useState([]); useEffect(() => { if (!id) return; loadTask(); loadSubtasks(); + getAllTags().then(setAvailableTags); }, [id]); const loadTask = async () => { @@ -75,6 +78,10 @@ export default function TaskDetailScreen() { setNotes(result.notes ?? ''); setPriority(result.priority); setDueDate(result.dueDate ? new Date(result.dueDate) : null); + setRecurrence(result.recurrence ?? null); + + const taskTags = await getTagsForTask(id!); + setSelectedTagIds(taskTags.map((t) => t.id)); }; const loadSubtasks = async () => { @@ -89,7 +96,9 @@ export default function TaskDetailScreen() { notes: notes.trim() || undefined, priority, dueDate, + recurrence, }); + await setTagsForTask(task.id, selectedTagIds); router.back(); }; @@ -110,11 +119,7 @@ export default function TaskDetailScreen() { const handleAddSubtask = async () => { if (!newSubtask.trim() || !task) return; - await createTask({ - title: newSubtask.trim(), - listId: task.listId, - parentId: task.id, - }); + await createTask({ title: newSubtask.trim(), listId: task.listId, parentId: task.id }); setNewSubtask(''); loadSubtasks(); }; @@ -130,6 +135,12 @@ export default function TaskDetailScreen() { if (date) setDueDate(date); }; + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((i) => i !== tagId) : [...prev, tagId] + ); + }; + if (!task) { return ( @@ -142,9 +153,7 @@ export default function TaskDetailScreen() { {/* Header */} router.back()} className="p-1"> @@ -154,9 +163,7 @@ export default function TaskDetailScreen() { - - {t('common.save')} - + {t('common.save')} @@ -184,12 +191,7 @@ export default function TaskDetailScreen() { /> {/* Priority */} - + {t('task.priority')} @@ -197,32 +199,12 @@ export default function TaskDetailScreen() { setPriority(opt.value)} - className={`mr-2 rounded-full border px-3 py-1.5 ${ - priority === opt.value - ? 'border-transparent' - : isDark - ? 'border-[#3A3A3A]' - : 'border-[#E5E7EB]' - }`} + className={`mr-2 rounded-full border px-3 py-1.5 ${priority === opt.value ? 'border-transparent' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined} > - - + + {t(opt.labelKey)} @@ -231,33 +213,15 @@ export default function TaskDetailScreen() { {/* Due Date */} - + {t('task.dueDate')} setShowDatePicker(true)} - className={`flex-row items-center rounded-lg border px-3 py-2.5 ${ - isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' - }`} + className={`flex-row items-center rounded-lg border px-3 py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} > - + {dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')} {dueDate && ( @@ -267,30 +231,66 @@ export default function TaskDetailScreen() { )} {showDatePicker && ( - + + )} + + {/* Recurrence */} + + {t('recurrence.label')} + + + setRecurrence(null)} + className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${recurrence === null ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} + > + + {t('recurrence.none')} + + + {RECURRENCE_OPTIONS.map((opt) => ( + setRecurrence(recurrence === opt ? null : opt)} + className={`mb-1 mr-2 flex-row items-center rounded-full border px-3 py-1.5 ${recurrence === opt ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} + > + + + {t(`recurrence.${opt}`)} + + + ))} + + + {/* Tags */} + {availableTags.length > 0 && ( + <> + + {t('tag.tags')} + + + {availableTags.map((tag) => ( + toggleTag(tag.id)} + /> + ))} + + )} {/* Subtasks */} - + {t('task.subtasks')} {subtasks.map((sub) => ( handleToggleSubtask(sub.id)} - className={`flex-row items-center border-b py-2.5 ${ - isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' - }`} + className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} > - {sub.completed && ( - - ✓ - - )} + {sub.completed && } {sub.title} diff --git a/app/task/new.tsx b/app/task/new.tsx index 213634c..c683c3d 100644 --- a/app/task/new.tsx +++ b/app/task/new.tsx @@ -9,14 +9,18 @@ import { Platform, } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { X, Calendar } from 'lucide-react-native'; +import { X, Calendar, Repeat } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; +import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { createTask } from '@/src/db/repository/tasks'; import { getInboxId, getAllLists } from '@/src/db/repository/lists'; +import { getAllTags, setTagsForTask } from '@/src/db/repository/tags'; import { getPriorityOptions } from '@/src/lib/priority'; +import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; +import TagChip from '@/src/components/task/TagChip'; export default function NewTaskScreen() { const { t } = useTranslation(); @@ -33,20 +37,28 @@ export default function NewTaskScreen() { const [showDatePicker, setShowDatePicker] = useState(false); const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId()); const [lists, setLists] = useState<{ id: string; name: string; isInbox: boolean }[]>([]); + const [recurrence, setRecurrence] = useState(null); + const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); + const [selectedTagIds, setSelectedTagIds] = useState([]); useEffect(() => { getAllLists().then(setLists); + getAllTags().then(setAvailableTags); }, []); const handleSave = async () => { if (!title.trim()) return; - await createTask({ + const taskId = await createTask({ title: title.trim(), notes: notes.trim() || undefined, priority, dueDate: dueDate ?? undefined, listId: selectedListId, + recurrence: recurrence ?? undefined, }); + if (selectedTagIds.length > 0) { + await setTagsForTask(taskId, selectedTagIds); + } router.back(); }; @@ -55,6 +67,12 @@ export default function NewTaskScreen() { if (date) setDueDate(date); }; + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId] + ); + }; + return ( {/* Header */} @@ -104,9 +122,7 @@ export default function NewTaskScreen() { {/* Priority */} {t('task.priority')} @@ -117,30 +133,17 @@ export default function NewTaskScreen() { key={opt.value} onPress={() => setPriority(opt.value)} className={`mr-2 rounded-full border px-3 py-1.5 ${ - priority === opt.value - ? 'border-transparent' - : isDark - ? 'border-[#3A3A3A]' - : 'border-[#E5E7EB]' + priority === opt.value ? 'border-transparent' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' }`} style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined} > - + {t(opt.labelKey)} @@ -152,30 +155,18 @@ export default function NewTaskScreen() { {/* Due Date */} {t('task.dueDate')} setShowDatePicker(true)} - className={`flex-row items-center rounded-lg border px-3 py-2.5 ${ - isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' - }`} + className={`flex-row items-center rounded-lg border px-3 py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} > {dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')} @@ -187,21 +178,84 @@ export default function NewTaskScreen() { )} {showDatePicker && ( - + + )} + + {/* Recurrence */} + + {t('recurrence.label')} + + + setRecurrence(null)} + className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${ + recurrence === null ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' + }`} + > + + {t('recurrence.none')} + + + {RECURRENCE_OPTIONS.map((opt) => ( + setRecurrence(recurrence === opt ? null : opt)} + className={`mb-1 mr-2 flex-row items-center rounded-full border px-3 py-1.5 ${ + recurrence === opt ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' + }`} + > + + + {t(`recurrence.${opt}`)} + + + ))} + + + {/* Tags */} + {availableTags.length > 0 && ( + <> + + {t('tag.tags')} + + + {availableTags.map((tag) => ( + toggleTag(tag.id)} + /> + ))} + + )} {/* List selector */} {lists.length > 1 && ( <> {t('nav.lists')} @@ -212,24 +266,14 @@ export default function NewTaskScreen() { key={list.id} onPress={() => setSelectedListId(list.id)} className={`mb-2 mr-2 rounded-full border px-3 py-1.5 ${ - selectedListId === list.id - ? 'border-bleu bg-bleu/10' - : isDark - ? 'border-[#3A3A3A]' - : 'border-[#E5E7EB]' + selectedListId === list.id ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' }`} > {list.isInbox ? t('list.inbox') : list.name} @@ -239,6 +283,8 @@ export default function NewTaskScreen() { )} + + ); diff --git a/src/components/FilterMenu.tsx b/src/components/FilterMenu.tsx new file mode 100644 index 0000000..510c32d --- /dev/null +++ b/src/components/FilterMenu.tsx @@ -0,0 +1,234 @@ +import { useState, useEffect } from 'react'; +import { View, Text, Pressable, Modal, ScrollView } from 'react-native'; +import { Filter, X } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; + +import { colors } from '@/src/theme/colors'; +import { useTaskStore, type FilterCompleted, type FilterDueDate } from '@/src/stores/useTaskStore'; +import { getPriorityOptions } from '@/src/lib/priority'; +import { getAllTags } from '@/src/db/repository/tags'; +import TagChip from '@/src/components/task/TagChip'; + +interface FilterMenuProps { + visible: boolean; + onClose: () => void; + isDark: boolean; +} + +const completedOptions: { value: FilterCompleted; labelKey: string }[] = [ + { value: 'all', labelKey: 'filter.all' }, + { value: 'active', labelKey: 'filter.active' }, + { value: 'completed', labelKey: 'filter.completed' }, +]; + +const dueDateOptions: { value: FilterDueDate; labelKey: string }[] = [ + { value: 'all', labelKey: 'filter.all' }, + { value: 'today', labelKey: 'filter.today' }, + { value: 'week', labelKey: 'filter.thisWeek' }, + { value: 'overdue', labelKey: 'filter.overdue' }, + { value: 'noDate', labelKey: 'filter.noDate' }, +]; + +export default function FilterMenu({ visible, onClose, isDark }: FilterMenuProps) { + const { t } = useTranslation(); + const { + filterPriority, filterTag, filterCompleted, filterDueDate, + setFilterPriority, setFilterTag, setFilterCompleted, setFilterDueDate, clearFilters, + } = useTaskStore(); + + const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); + const priorityOpts = getPriorityOptions(isDark); + + useEffect(() => { + if (visible) { + getAllTags().then(setAvailableTags); + } + }, [visible]); + + return ( + + + e.stopPropagation()} + className={`max-h-[70%] rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`} + > + + {/* Header */} + + + {t('filter.filter')} + + + + + Reset + + + + + + + + + {/* Status filter */} + + {t('task.completed')} + + + {completedOptions.map((opt) => ( + setFilterCompleted(opt.value)} + className={`mr-2 rounded-full border px-3 py-1.5 ${ + filterCompleted === opt.value + ? 'border-bleu bg-bleu/10' + : isDark + ? 'border-[#3A3A3A]' + : 'border-[#E5E7EB]' + }`} + > + + {t(opt.labelKey)} + + + ))} + + + {/* Priority filter */} + + {t('task.priority')} + + + setFilterPriority(null)} + className={`mr-2 rounded-full border px-3 py-1.5 ${ + filterPriority === null + ? 'border-bleu bg-bleu/10' + : isDark + ? 'border-[#3A3A3A]' + : 'border-[#E5E7EB]' + }`} + > + + {t('filter.all')} + + + {priorityOpts.slice(1).map((opt) => ( + setFilterPriority(filterPriority === opt.value ? null : opt.value)} + className={`mr-2 rounded-full border px-3 py-1.5 ${ + filterPriority === opt.value + ? 'border-transparent' + : isDark + ? 'border-[#3A3A3A]' + : 'border-[#E5E7EB]' + }`} + style={filterPriority === opt.value ? { backgroundColor: opt.color + '20' } : undefined} + > + + + + {t(opt.labelKey)} + + + + ))} + + + {/* Due date filter */} + + {t('task.dueDate')} + + + {dueDateOptions.map((opt) => ( + setFilterDueDate(opt.value)} + className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${ + filterDueDate === opt.value + ? 'border-bleu bg-bleu/10' + : isDark + ? 'border-[#3A3A3A]' + : 'border-[#E5E7EB]' + }`} + > + + {t(opt.labelKey)} + + + ))} + + + {/* Tag filter */} + {availableTags.length > 0 && ( + <> + + {t('tag.tags')} + + + setFilterTag(null)} + /> + {availableTags.map((tag) => ( + setFilterTag(filterTag === tag.id ? null : tag.id)} + /> + ))} + + + )} + + + + + ); +} diff --git a/src/components/SortMenu.tsx b/src/components/SortMenu.tsx new file mode 100644 index 0000000..e6ceb61 --- /dev/null +++ b/src/components/SortMenu.tsx @@ -0,0 +1,86 @@ +import { View, Text, Pressable, Modal } from 'react-native'; +import { ArrowUpDown, ArrowUp, ArrowDown, X } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; + +import { colors } from '@/src/theme/colors'; +import { useTaskStore, type SortBy } from '@/src/stores/useTaskStore'; + +interface SortMenuProps { + visible: boolean; + onClose: () => void; + isDark: boolean; +} + +const sortOptions: { value: SortBy; labelKey: string }[] = [ + { value: 'position', labelKey: 'sort.position' }, + { value: 'priority', labelKey: 'sort.priority' }, + { value: 'dueDate', labelKey: 'sort.dueDate' }, + { value: 'title', labelKey: 'sort.title' }, + { value: 'createdAt', labelKey: 'sort.createdAt' }, +]; + +export default function SortMenu({ visible, onClose, isDark }: SortMenuProps) { + const { t } = useTranslation(); + const { sortBy, sortOrder, setSortBy, setSortOrder } = useTaskStore(); + + return ( + + + e.stopPropagation()} + className={`rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`} + > + {/* Header */} + + + {t('sort.sortBy')} + + + + + + + {/* Sort options */} + {sortOptions.map((opt) => { + const isActive = sortBy === opt.value; + return ( + { + if (isActive) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(opt.value); + // Priority defaults to desc (high first), others to asc + setSortOrder(opt.value === 'priority' ? 'desc' : 'asc'); + } + }} + className={`flex-row items-center justify-between rounded-lg px-3 py-3 ${ + isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : '' + }`} + > + + {t(opt.labelKey)} + + {isActive && ( + sortOrder === 'asc' + ? + : + )} + + ); + })} + + + + ); +} diff --git a/src/components/task/TagChip.tsx b/src/components/task/TagChip.tsx new file mode 100644 index 0000000..6cdb649 --- /dev/null +++ b/src/components/task/TagChip.tsx @@ -0,0 +1,51 @@ +import { View, Text, Pressable } from 'react-native'; +import { X } from 'lucide-react-native'; + +interface TagChipProps { + name: string; + color: string; + isDark: boolean; + onRemove?: () => void; + onPress?: () => void; + small?: boolean; + selected?: boolean; +} + +export default function TagChip({ name, color, isDark, onRemove, onPress, small, selected }: TagChipProps) { + const Wrapper = onPress ? Pressable : View; + + return ( + + + + {name} + + {onRemove && ( + + + + )} + + ); +} diff --git a/src/components/task/TaskItem.tsx b/src/components/task/TaskItem.tsx index f52a91e..9206728 100644 --- a/src/components/task/TaskItem.tsx +++ b/src/components/task/TaskItem.tsx @@ -1,5 +1,5 @@ import { View, Text, Pressable } from 'react-native'; -import { Check, Trash2 } from 'lucide-react-native'; +import { Check, Trash2, Repeat } from 'lucide-react-native'; import { format } from 'date-fns'; import { fr, enUS } from 'date-fns/locale'; import { useTranslation } from 'react-i18next'; @@ -14,6 +14,8 @@ interface TaskItemProps { completed: boolean; priority: number; dueDate: Date | null; + recurrence?: string | null; + tags?: { id: string; name: string; color: string }[]; }; isDark: boolean; onToggle: () => void; @@ -35,7 +37,7 @@ export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }: {/* Checkbox */} {task.title} - {task.dueDate && ( - - {format(new Date(task.dueDate), 'd MMM yyyy', { locale: dateLocale })} - - )} + + {/* Meta row: date, tags, recurrence */} + + {task.dueDate && ( + + {format(new Date(task.dueDate), 'd MMM', { locale: dateLocale })} + + )} + {task.recurrence && ( + + + + )} + {task.tags && task.tags.length > 0 && task.tags.map((tag) => ( + + + + {tag.name} + + + ))} + {/* Priority dot */} diff --git a/src/db/migrations/0001_sticky_arachne.sql b/src/db/migrations/0001_sticky_arachne.sql new file mode 100644 index 0000000..87bbd63 --- /dev/null +++ b/src/db/migrations/0001_sticky_arachne.sql @@ -0,0 +1,16 @@ +CREATE TABLE `tags` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `color` text DEFAULT '#4A90A4' NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `task_tags` ( + `task_id` text NOT NULL, + `tag_id` text NOT NULL, + PRIMARY KEY(`task_id`, `tag_id`), + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `tasks` ADD `recurrence` text; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..5f50653 --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,302 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d7c6471-bc3c-4111-8166-9729923db06b", + "prevId": "67121461-af03-441b-8c25-f88e630334d7", + "tables": { + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_inbox": { + "name": "is_inbox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#4A90A4'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_tags": { + "name": "task_tags", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_tags_task_id_tasks_id_fk": { + "name": "task_tags_task_id_tasks_id_fk", + "tableFrom": "task_tags", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_tags_tag_id_tags_id_fk": { + "name": "task_tags_tag_id_tags_id_fk", + "tableFrom": "task_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_tags_task_id_tag_id_pk": { + "columns": [ + "task_id", + "tag_id" + ], + "name": "task_tags_task_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "due_date": { + "name": "due_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_list_id_lists_id_fk": { + "name": "tasks_list_id_lists_id_fk", + "tableFrom": "tasks", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index c83f21f..1657b87 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1771632828781, "tag": "0000_bitter_phalanx", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771637151512, + "tag": "0001_sticky_arachne", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/migrations/migrations.js b/src/db/migrations/migrations.js index 2938c4d..1270b68 100644 --- a/src/db/migrations/migrations.js +++ b/src/db/migrations/migrations.js @@ -2,11 +2,13 @@ import journal from './meta/_journal.json'; import m0000 from './0000_bitter_phalanx.sql'; +import m0001 from './0001_sticky_arachne.sql'; export default { journal, migrations: { - m0000 + m0000, +m0001 } } \ No newline at end of file diff --git a/src/db/repository/tags.ts b/src/db/repository/tags.ts new file mode 100644 index 0000000..348c941 --- /dev/null +++ b/src/db/repository/tags.ts @@ -0,0 +1,56 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '../client'; +import { tags, taskTags } from '../schema'; +import { randomUUID } from '@/src/lib/uuid'; + +export async function getAllTags() { + return db.select().from(tags).orderBy(tags.name); +} + +export async function createTag(name: string, color: string) { + const id = randomUUID(); + await db.insert(tags).values({ + id, + name, + color, + createdAt: new Date(), + }); + return id; +} + +export async function updateTag(id: string, name: string, color: string) { + await db.update(tags).set({ name, color }).where(eq(tags.id, id)); +} + +export async function deleteTag(id: string) { + await db.delete(taskTags).where(eq(taskTags.tagId, id)); + await db.delete(tags).where(eq(tags.id, id)); +} + +export async function getTagsForTask(taskId: string) { + const rows = await db + .select({ tag: tags }) + .from(taskTags) + .innerJoin(tags, eq(taskTags.tagId, tags.id)) + .where(eq(taskTags.taskId, taskId)); + return rows.map((r) => r.tag); +} + +export async function setTagsForTask(taskId: string, tagIds: string[]) { + await db.delete(taskTags).where(eq(taskTags.taskId, taskId)); + if (tagIds.length > 0) { + await db.insert(taskTags).values( + tagIds.map((tagId) => ({ taskId, tagId })) + ); + } +} + +export async function addTagToTask(taskId: string, tagId: string) { + await db.insert(taskTags).values({ taskId, tagId }).onConflictDoNothing(); +} + +export async function removeTagFromTask(taskId: string, tagId: string) { + await db + .delete(taskTags) + .where(and(eq(taskTags.taskId, taskId), eq(taskTags.tagId, tagId))); +} diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index a6e56d9..8c8d940 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -1,14 +1,97 @@ -import { eq, and, isNull, desc, asc } from 'drizzle-orm'; +import { eq, and, isNull, desc, asc, gte, lte, lt, sql, inArray } from 'drizzle-orm'; import { db } from '../client'; -import { tasks } from '../schema'; +import { tasks, taskTags } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; +import { getNextOccurrence, type RecurrenceType } from '@/src/lib/recurrence'; +import { startOfDay, endOfDay, endOfWeek, startOfWeek } from 'date-fns'; +import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore'; + +export interface TaskFilters { + sortBy?: SortBy; + sortOrder?: SortOrder; + filterPriority?: number | null; + filterTag?: string | null; + filterCompleted?: FilterCompleted; + filterDueDate?: FilterDueDate; +} + +export async function getTasksByList(listId: string, filters?: TaskFilters) { + const conditions = [eq(tasks.listId, listId), isNull(tasks.parentId)]; + + // Filter: completed status + if (filters?.filterCompleted === 'active') { + conditions.push(eq(tasks.completed, false)); + } else if (filters?.filterCompleted === 'completed') { + conditions.push(eq(tasks.completed, true)); + } + + // Filter: priority + if (filters?.filterPriority != null) { + conditions.push(eq(tasks.priority, filters.filterPriority)); + } + + // Filter: due date + if (filters?.filterDueDate && filters.filterDueDate !== 'all') { + const now = new Date(); + const todayStart = startOfDay(now); + const todayEnd = endOfDay(now); + + switch (filters.filterDueDate) { + case 'today': + conditions.push(gte(tasks.dueDate, todayStart)); + conditions.push(lte(tasks.dueDate, todayEnd)); + break; + case 'week': + conditions.push(gte(tasks.dueDate, startOfWeek(now, { weekStartsOn: 1 }))); + conditions.push(lte(tasks.dueDate, endOfWeek(now, { weekStartsOn: 1 }))); + break; + case 'overdue': + conditions.push(lt(tasks.dueDate, todayStart)); + conditions.push(eq(tasks.completed, false)); + break; + case 'noDate': + conditions.push(isNull(tasks.dueDate)); + break; + } + } + + // If filtering by tag, get task IDs that have that tag first + let tagTaskIds: string[] | null = null; + if (filters?.filterTag) { + const taggedRows = await db + .select({ taskId: taskTags.taskId }) + .from(taskTags) + .where(eq(taskTags.tagId, filters.filterTag)); + tagTaskIds = taggedRows.map((r) => r.taskId); + if (tagTaskIds.length === 0) return []; + conditions.push(inArray(tasks.id, tagTaskIds)); + } + + // Sort + const orderClauses = getOrderClauses(filters?.sortBy ?? 'position', filters?.sortOrder ?? 'asc'); -export async function getTasksByList(listId: string) { return db .select() .from(tasks) - .where(and(eq(tasks.listId, listId), isNull(tasks.parentId))) - .orderBy(asc(tasks.position), desc(tasks.createdAt)); + .where(and(...conditions)) + .orderBy(...orderClauses); +} + +function getOrderClauses(sortBy: SortBy, sortOrder: SortOrder) { + const dir = sortOrder === 'asc' ? asc : desc; + switch (sortBy) { + case 'priority': + return [dir(tasks.priority), asc(tasks.position)]; + case 'dueDate': + return [dir(tasks.dueDate), asc(tasks.position)]; + case 'title': + return [dir(tasks.title)]; + case 'createdAt': + return [dir(tasks.createdAt)]; + case 'position': + default: + return [asc(tasks.position), desc(tasks.createdAt)]; + } } export async function getSubtasks(parentId: string) { @@ -31,6 +114,7 @@ export async function createTask(data: { priority?: number; dueDate?: Date; parentId?: string; + recurrence?: string; }) { const now = new Date(); const id = randomUUID(); @@ -50,6 +134,7 @@ export async function createTask(data: { parentId: data.parentId ?? null, completed: false, position: maxPosition + 1, + recurrence: data.recurrence ?? null, createdAt: now, updatedAt: now, }); @@ -65,6 +150,7 @@ export async function updateTask( dueDate?: Date | null; listId?: string; completed?: boolean; + recurrence?: string | null; } ) { const updates: Record = { ...data, updatedAt: new Date() }; @@ -79,14 +165,34 @@ export async function updateTask( export async function toggleComplete(id: string) { const task = await getTaskById(id); if (!task) return; - await updateTask(id, { completed: !task.completed }); + + const nowCompleting = !task.completed; + await updateTask(id, { completed: nowCompleting }); + + // If completing a recurring task, create the next occurrence + if (nowCompleting && task.recurrence && task.dueDate) { + const nextDate = getNextOccurrence( + new Date(task.dueDate), + task.recurrence as RecurrenceType + ); + await createTask({ + title: task.title, + listId: task.listId, + notes: task.notes ?? undefined, + priority: task.priority, + dueDate: nextDate, + recurrence: task.recurrence, + }); + } } export async function deleteTask(id: string) { // Delete subtasks first const subtasks = await getSubtasks(id); for (const sub of subtasks) { + await db.delete(taskTags).where(eq(taskTags.taskId, sub.id)); await db.delete(tasks).where(eq(tasks.id, sub.id)); } + await db.delete(taskTags).where(eq(taskTags.taskId, id)); await db.delete(tasks).where(eq(tasks.id, id)); } diff --git a/src/db/schema.ts b/src/db/schema.ts index c753295..00ecabf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; export const lists = sqliteTable('lists', { id: text('id').primaryKey(), @@ -22,6 +22,25 @@ export const tasks = sqliteTable('tasks', { listId: text('list_id').notNull().references(() => lists.id), parentId: text('parent_id'), position: integer('position').notNull().default(0), + recurrence: text('recurrence'), // 'daily' | 'weekly' | 'monthly' | 'yearly' | null createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); + +export const tags = sqliteTable('tags', { + id: text('id').primaryKey(), + name: text('name').notNull(), + color: text('color').notNull().default('#4A90A4'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +}); + +export const taskTags = sqliteTable( + 'task_tags', + { + taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }), + tagId: text('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }), + }, + (table) => [ + primaryKey({ columns: [table.taskId, table.tagId] }), + ] +); diff --git a/src/i18n/en.json b/src/i18n/en.json index 92c07e6..0225d49 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -6,7 +6,8 @@ "edit": "Edit", "add": "Add", "done": "Done", - "confirm": "Confirm" + "confirm": "Confirm", + "none": "None" }, "task": { "title": "Title", @@ -40,6 +41,47 @@ "taskCount_one": "{{count}} task", "taskCount_other": "{{count}} tasks" }, + "tag": { + "tags": "Tags", + "newTag": "New tag", + "namePlaceholder": "Tag name...", + "deleteConfirm": "Are you sure you want to delete this tag?", + "manage": "Manage tags", + "addTag": "Add a tag", + "noTags": "No tags", + "editTag": "Edit tag" + }, + "sort": { + "sortBy": "Sort by", + "position": "Position", + "priority": "Priority", + "dueDate": "Due date", + "title": "Title", + "createdAt": "Date created", + "ascending": "Ascending", + "descending": "Descending", + "groupBy": "Group by", + "noGroup": "None" + }, + "filter": { + "filter": "Filter", + "all": "All", + "active": "Active", + "completed": "Completed", + "today": "Today", + "thisWeek": "This week", + "overdue": "Overdue", + "noDate": "No date", + "activeFilters": "Active filters" + }, + "recurrence": { + "label": "Recurrence", + "none": "None", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + }, "settings": { "title": "Settings", "theme": "Theme", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index daa1d24..c738e85 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -6,7 +6,8 @@ "edit": "Modifier", "add": "Ajouter", "done": "Terminé", - "confirm": "Confirmer" + "confirm": "Confirmer", + "none": "Aucun" }, "task": { "title": "Titre", @@ -40,6 +41,47 @@ "taskCount_one": "{{count}} tâche", "taskCount_other": "{{count}} tâches" }, + "tag": { + "tags": "Étiquettes", + "newTag": "Nouvelle étiquette", + "namePlaceholder": "Nom de l'étiquette...", + "deleteConfirm": "Voulez-vous vraiment supprimer cette étiquette ?", + "manage": "Gérer les étiquettes", + "addTag": "Ajouter une étiquette", + "noTags": "Aucune étiquette", + "editTag": "Modifier l'étiquette" + }, + "sort": { + "sortBy": "Trier par", + "position": "Position", + "priority": "Priorité", + "dueDate": "Date d'échéance", + "title": "Titre", + "createdAt": "Date de création", + "ascending": "Croissant", + "descending": "Décroissant", + "groupBy": "Grouper par", + "noGroup": "Aucun" + }, + "filter": { + "filter": "Filtrer", + "all": "Toutes", + "active": "Actives", + "completed": "Terminées", + "today": "Aujourd'hui", + "thisWeek": "Cette semaine", + "overdue": "En retard", + "noDate": "Sans date", + "activeFilters": "Filtres actifs" + }, + "recurrence": { + "label": "Récurrence", + "none": "Aucune", + "daily": "Quotidien", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "yearly": "Annuel" + }, "settings": { "title": "Paramètres", "theme": "Thème", diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts new file mode 100644 index 0000000..a60b26b --- /dev/null +++ b/src/lib/recurrence.ts @@ -0,0 +1,18 @@ +import { addDays, addWeeks, addMonths, addYears } from 'date-fns'; + +export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly'; + +export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly']; + +export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date { + switch (recurrence) { + case 'daily': + return addDays(dueDate, 1); + case 'weekly': + return addWeeks(dueDate, 1); + case 'monthly': + return addMonths(dueDate, 1); + case 'yearly': + return addYears(dueDate, 1); + } +} diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts new file mode 100644 index 0000000..b1305eb --- /dev/null +++ b/src/stores/useTaskStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt'; +export type SortOrder = 'asc' | 'desc'; +export type FilterCompleted = 'all' | 'active' | 'completed'; +export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate'; + +interface TaskStoreState { + sortBy: SortBy; + sortOrder: SortOrder; + filterPriority: number | null; + filterTag: string | null; + filterCompleted: FilterCompleted; + filterDueDate: FilterDueDate; + setSortBy: (sortBy: SortBy) => void; + setSortOrder: (sortOrder: SortOrder) => void; + setFilterPriority: (priority: number | null) => void; + setFilterTag: (tagId: string | null) => void; + setFilterCompleted: (filter: FilterCompleted) => void; + setFilterDueDate: (filter: FilterDueDate) => void; + clearFilters: () => void; + hasActiveFilters: () => boolean; +} + +export const useTaskStore = create()( + persist( + (set, get) => ({ + sortBy: 'position', + sortOrder: 'asc', + filterPriority: null, + filterTag: null, + filterCompleted: 'all', + filterDueDate: 'all', + setSortBy: (sortBy) => set({ sortBy }), + setSortOrder: (sortOrder) => set({ sortOrder }), + setFilterPriority: (filterPriority) => set({ filterPriority }), + setFilterTag: (filterTag) => set({ filterTag }), + setFilterCompleted: (filterCompleted) => set({ filterCompleted }), + setFilterDueDate: (filterDueDate) => set({ filterDueDate }), + clearFilters: () => + set({ + filterPriority: null, + filterTag: null, + filterCompleted: 'all', + filterDueDate: 'all', + }), + hasActiveFilters: () => { + const s = get(); + return ( + s.filterPriority !== null || + s.filterTag !== null || + s.filterCompleted !== 'all' || + s.filterDueDate !== 'all' + ); + }, + }), + { + name: 'simpl-liste-tasks', + storage: createJSONStorage(() => AsyncStorage), + } + ) +);