diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 53a6bd8..d2c4a12 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native'; +import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native'; import { useRouter } from 'expo-router'; import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; @@ -44,6 +44,7 @@ export default function InboxScreen() { const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; const isDraggingRef = useRef(false); + const [refreshing, setRefreshing] = useState(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); @@ -70,6 +71,12 @@ export default function InboxScreen() { return () => clearInterval(interval); }, [loadTasks]); + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await loadTasks(); + setRefreshing(false); + }, [loadTasks]); + const handleToggle = async (id: string) => { await toggleComplete(id); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -201,6 +208,14 @@ export default function InboxScreen() { onDragBegin={() => { isDraggingRef.current = true; }} onDragEnd={handleDragEnd} activationDistance={canDrag ? 0 : 10000} + refreshControl={ + + } /> )} diff --git a/app/list/[id].tsx b/app/list/[id].tsx index e81c045..e653816 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native'; +import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, @@ -61,6 +61,7 @@ export default function ListDetailScreen() { const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; const isDraggingRef = useRef(false); + const [refreshing, setRefreshing] = useState(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); @@ -95,6 +96,12 @@ export default function ListDetailScreen() { return () => clearInterval(interval); }, [loadData]); + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await loadData(); + setRefreshing(false); + }, [loadData]); + const handleToggle = async (taskId: string) => { await toggleComplete(taskId); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -249,6 +256,14 @@ export default function ListDetailScreen() { onDragBegin={() => { isDraggingRef.current = true; }} onDragEnd={handleDragEnd} activationDistance={canDrag ? 0 : 10000} + refreshControl={ + + } /> )} diff --git a/app/task/[id].tsx b/app/task/[id].tsx index 245900c..a540f1a 100644 --- a/app/task/[id].tsx +++ b/app/task/[id].tsx @@ -54,6 +54,7 @@ type TaskData = { priority: number; dueDate: Date | null; listId: string; + parentId: string | null; recurrence: string | null; }; @@ -400,67 +401,71 @@ export default function TaskDetailScreen() { )} - {/* Subtasks */} - - {t('task.subtasks')} - - {subtasks.map((sub) => ( - editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)} - onLongPress={() => handleEditSubtask(sub)} - className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} - > - - {sub.completed && } - - {editingSubtaskId === sub.id ? ( + {/* Subtasks — only for root tasks (not subtasks themselves) */} + {!task?.parentId && ( + <> + + {t('task.subtasks')} + + {subtasks.map((sub) => ( + editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)} + onLongPress={() => handleEditSubtask(sub)} + className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} + > + + {sub.completed && } + + {editingSubtaskId === sub.id ? ( + + ) : ( + + {sub.title} + + )} + handleDeleteSubtask(sub.id)} + className="ml-2 p-1.5" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + ))} + + {/* Add subtask */} + + - ) : ( - - {sub.title} - - )} - handleDeleteSubtask(sub.id)} - className="ml-2 p-1.5" - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - - - - ))} - - {/* Add subtask */} - - - - + + + )} diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index aebdf44..56541cd 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db/client'; import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema'; -import { eq, and, gte } from 'drizzle-orm'; +import { eq, and, gte, isNull } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; import { rateLimit } from '@/lib/rateLimit'; import { syncPushSchema, type SyncOperation } from '@/lib/validators'; @@ -197,15 +197,43 @@ async function processOperation(op: SyncOperation, userId: string) { switch (entityType) { case 'list': { if (action === 'create') { - await db.insert(slLists).values({ + const d = (data as Record) || {}; + const incomingIsInbox = d.isInbox as boolean | undefined; + + const listValues = { id: entityId, userId, - name: (data as Record)?.name as string || 'Untitled', - color: (data as Record)?.color as string | undefined, - icon: (data as Record)?.icon as string | undefined, - position: (data as Record)?.position as number | undefined, - isInbox: (data as Record)?.isInbox as boolean | undefined, - }).onConflictDoNothing(); + name: d.name as string || 'Untitled', + color: d.color as string | undefined, + icon: d.icon as string | undefined, + position: d.position as number | undefined, + isInbox: incomingIsInbox, + }; + + // If the incoming list is an inbox, check for an existing inbox and merge + if (incomingIsInbox) { + await db.transaction(async (tx) => { + const [existingInbox] = await tx + .select() + .from(slLists) + .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt))); + + if (existingInbox && existingInbox.id !== entityId) { + // Reassign all tasks (including subtasks) from the old inbox to the new one + await tx.update(slTasks) + .set({ listId: entityId, updatedAt: now }) + .where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId))); + // Soft-delete the old inbox + await tx.update(slLists) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(slLists.id, existingInbox.id)); + } + + await tx.insert(slLists).values(listValues).onConflictDoNothing(); + }); + } else { + await db.insert(slLists).values(listValues).onConflictDoNothing(); + } } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); await db.update(slLists) diff --git a/web/src/app/api/tasks/route.ts b/web/src/app/api/tasks/route.ts index aeaf4a2..ad9c9e7 100644 --- a/web/src/app/api/tasks/route.ts +++ b/web/src/app/api/tasks/route.ts @@ -25,16 +25,20 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'List not found' }, { status: 404 }); } - // If parentId, verify parent task belongs to user + // If parentId, verify parent task belongs to user and is not itself a subtask if (body.data.parentId) { const [parent] = await db - .select({ id: slTasks.id }) + .select({ id: slTasks.id, parentId: slTasks.parentId }) .from(slTasks) .where(and(eq(slTasks.id, body.data.parentId), eq(slTasks.userId, auth.userId))); if (!parent) { return NextResponse.json({ error: 'Parent task not found' }, { status: 404 }); } + + if (parent.parentId) { + return NextResponse.json({ error: 'Cannot create sub-subtasks (max 2 levels)' }, { status: 400 }); + } } const [task] = await db diff --git a/web/src/components/TaskItem.tsx b/web/src/components/TaskItem.tsx index 16627e8..faa1b8f 100644 --- a/web/src/components/TaskItem.tsx +++ b/web/src/components/TaskItem.tsx @@ -9,6 +9,7 @@ import { Calendar, Repeat, Check, + Search, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { Task } from "@/lib/types"; @@ -40,6 +41,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) { const { t } = useTranslation(); const router = useRouter(); const [expanded, setExpanded] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); const [editing, setEditing] = useState(false); const [title, setTitle] = useState(task.title); const [notes, setNotes] = useState(task.notes || ""); @@ -123,17 +125,21 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) { > {/* Main row */}
- {/* Expand toggle */} - + {/* Expand subtasks toggle — only shown when subtasks exist */} + {subtasks.length > 0 ? ( + + ) : ( + + )} {/* Checkbox */} - {/* Title */} + {/* Title — click opens detail */} setExpanded(!expanded)} + onClick={() => setDetailOpen(!detailOpen)} > {task.title} @@ -174,10 +180,20 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) { {subtasks.filter((s) => s.completed).length}/{subtasks.length} )} + + {/* Detail view toggle */} +
- {/* Expanded view */} - {expanded && ( + {/* Detail view */} + {detailOpen && (
{editing ? (
@@ -261,12 +277,14 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) { > {t("task.edit")} - + {depth < 1 && ( + + )} +
diff --git a/web/src/db/seed.ts b/web/src/db/seed.ts index 4072b06..0632530 100644 --- a/web/src/db/seed.ts +++ b/web/src/db/seed.ts @@ -19,6 +19,8 @@ async function seed() { .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true))); if (existing.length === 0) { + // Let the DB generate a random UUID — the sync endpoint handles + // inbox deduplication when mobile pushes its fixed-ID inbox. await db.insert(slLists).values({ userId, name: 'Inbox', diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 1de949f..857b386 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -20,6 +20,7 @@ "subtaskPlaceholder": "New subtask...", "notesPlaceholder": "Notes...", "empty": "No tasks", + "refresh": "Refresh", "edit": "Edit", "save": "Save", "cancel": "Cancel", diff --git a/web/src/i18n/fr.json b/web/src/i18n/fr.json index 7367b4a..4e659c0 100644 --- a/web/src/i18n/fr.json +++ b/web/src/i18n/fr.json @@ -20,6 +20,7 @@ "subtaskPlaceholder": "Nouvelle sous-tâche...", "notesPlaceholder": "Notes...", "empty": "Aucune tâche", + "refresh": "Rafraîchir", "edit": "Modifier", "save": "Enregistrer", "cancel": "Annuler",