From d9daf9eda4b267e7e39bbe179ef5e47837095bbe Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 20:50:12 -0400 Subject: [PATCH 1/5] fix: resolve duplicate inbox on web after mobile sync (#60) When mobile syncs its inbox (fixed ID) to the web, check if an inbox already exists for the user. If so, reassign tasks and soft-delete the old inbox to prevent duplicates. Also harmonize seed.ts to use the same fixed inbox ID as mobile. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/app/api/sync/route.ts | 34 ++++++++++++++++++++++++++++------ web/src/db/seed.ts | 4 ++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index aebdf44..95ff08c 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,14 +197,36 @@ async function processOperation(op: SyncOperation, userId: string) { switch (entityType) { case 'list': { if (action === 'create') { + const d = (data as Record) || {}; + const incomingIsInbox = d.isInbox as boolean | undefined; + + // If the incoming list is an inbox, check for an existing inbox and merge + if (incomingIsInbox) { + const [existingInbox] = await db + .select() + .from(slLists) + .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt))); + + if (existingInbox && existingInbox.id !== entityId) { + // Reassign all tasks from the old inbox to the new one + await db.update(slTasks) + .set({ listId: entityId, updatedAt: now }) + .where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId))); + // Soft-delete the old inbox + await db.update(slLists) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(slLists.id, existingInbox.id)); + } + } + await db.insert(slLists).values({ 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, + 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, }).onConflictDoNothing(); } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); diff --git a/web/src/db/seed.ts b/web/src/db/seed.ts index 4072b06..4f48b0d 100644 --- a/web/src/db/seed.ts +++ b/web/src/db/seed.ts @@ -18,8 +18,12 @@ async function seed() { .from(slLists) .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true))); + // Use the same fixed inbox ID as the mobile app to avoid duplicates during sync + const INBOX_ID = '00000000-0000-0000-0000-000000000001'; + if (existing.length === 0) { await db.insert(slLists).values({ + id: INBOX_ID, userId, name: 'Inbox', isInbox: true, -- 2.45.2 From 6c36ebcce560592bb0d5e3f844652d8cd1614a81 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 20:54:55 -0400 Subject: [PATCH 2/5] fix: wrap inbox merge in transaction, revert seed to random UUID (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: 1. Wrap inbox deduplication (select, reassign tasks, soft-delete) in a db.transaction() for atomicity. 2. Revert seed.ts to use random UUID — a fixed ID shared across users would cause PK conflicts. The sync endpoint handles deduplication. 3. Subtasks share the same listId as their parent, so the reassign query already covers them (clarified in comment). Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/app/api/sync/route.ts | 60 +++++++++++++++++++++-------------- web/src/db/seed.ts | 6 ++-- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index 95ff08c..b0b35f9 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -202,32 +202,44 @@ async function processOperation(op: SyncOperation, userId: string) { // If the incoming list is an inbox, check for an existing inbox and merge if (incomingIsInbox) { - const [existingInbox] = await db - .select() - .from(slLists) - .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt))); + 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 from the old inbox to the new one - await db.update(slTasks) - .set({ listId: entityId, updatedAt: now }) - .where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId))); - // Soft-delete the old inbox - await db.update(slLists) - .set({ deletedAt: now, updatedAt: now }) - .where(eq(slLists.id, existingInbox.id)); - } + 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({ + id: entityId, + userId, + 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, + }).onConflictDoNothing(); + }); + } else { + await db.insert(slLists).values({ + id: entityId, + userId, + 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, + }).onConflictDoNothing(); } - - await db.insert(slLists).values({ - id: entityId, - userId, - 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, - }).onConflictDoNothing(); } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); await db.update(slLists) diff --git a/web/src/db/seed.ts b/web/src/db/seed.ts index 4f48b0d..0632530 100644 --- a/web/src/db/seed.ts +++ b/web/src/db/seed.ts @@ -18,12 +18,10 @@ async function seed() { .from(slLists) .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true))); - // Use the same fixed inbox ID as the mobile app to avoid duplicates during sync - const INBOX_ID = '00000000-0000-0000-0000-000000000001'; - 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({ - id: INBOX_ID, userId, name: 'Inbox', isInbox: true, -- 2.45.2 From 894ac0307223a50b0b7f50d86b98fbdfbe196756 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 21:04:55 -0400 Subject: [PATCH 3/5] feat: add refresh button on web + swipe-to-refresh on mobile (#61) Web: add a RefreshCw button next to the list title in TaskList that calls router.refresh() with a spin animation. Mobile: add RefreshControl to DraggableFlatList on both inbox and list detail screens, using the app's blue accent color. Also deduplicate list insert values in sync/route.ts (review feedback). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(tabs)/index.tsx | 17 ++++++++++++++++- app/list/[id].tsx | 17 ++++++++++++++++- web/src/app/api/sync/route.ts | 30 ++++++++++++------------------ web/src/components/TaskList.tsx | 26 +++++++++++++++++++++++--- web/src/i18n/en.json | 1 + web/src/i18n/fr.json | 1 + 6 files changed, 69 insertions(+), 23 deletions(-) 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/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index b0b35f9..56541cd 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -200,6 +200,16 @@ async function processOperation(op: SyncOperation, userId: string) { const d = (data as Record) || {}; const incomingIsInbox = d.isInbox as boolean | undefined; + const listValues = { + id: entityId, + userId, + 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) => { @@ -219,26 +229,10 @@ async function processOperation(op: SyncOperation, userId: string) { .where(eq(slLists.id, existingInbox.id)); } - await tx.insert(slLists).values({ - id: entityId, - userId, - 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, - }).onConflictDoNothing(); + await tx.insert(slLists).values(listValues).onConflictDoNothing(); }); } else { - await db.insert(slLists).values({ - id: entityId, - userId, - 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, - }).onConflictDoNothing(); + await db.insert(slLists).values(listValues).onConflictDoNothing(); } } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); diff --git a/web/src/components/TaskList.tsx b/web/src/components/TaskList.tsx index 7139eea..310d5e7 100644 --- a/web/src/components/TaskList.tsx +++ b/web/src/components/TaskList.tsx @@ -4,8 +4,9 @@ import type { Task } from "@/lib/types"; import { TaskItem } from "./TaskItem"; import { TaskForm } from "./TaskForm"; import { FilterBar } from "./FilterBar"; -import { ClipboardList } from "lucide-react"; -import { Suspense } from "react"; +import { ClipboardList, RefreshCw } from "lucide-react"; +import { Suspense, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; interface TaskListProps { @@ -17,12 +18,31 @@ interface TaskListProps { export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) { const { t } = useTranslation(); + const router = useRouter(); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + router.refresh(); + // Brief visual feedback + setTimeout(() => setRefreshing(false), 500); + }, [router]); return (
{/* Header */}
-

{listName}

+
+

{listName}

+ +
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", -- 2.45.2 From 21020406b2d9476ced36f622f9ea1a805a11fb10 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 21:13:43 -0400 Subject: [PATCH 4/5] fix: prevent sub-subtask creation, limit nesting to 2 levels (#62) Web: hide "Add subtask" button when depth >= 1 in TaskItem. API: reject task creation if parentId points to a task that already has a parentId (max depth validation). Mobile: hide subtask section in task detail when viewing a subtask. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/task/[id].tsx | 121 +++++++++++++++++--------------- web/src/app/api/tasks/route.ts | 8 ++- web/src/components/TaskItem.tsx | 14 ++-- 3 files changed, 77 insertions(+), 66 deletions(-) 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/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..1d0d7a8 100644 --- a/web/src/components/TaskItem.tsx +++ b/web/src/components/TaskItem.tsx @@ -261,12 +261,14 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) { > {t("task.edit")} - + {depth < 1 && ( + + )} + {/* 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 ? (
@@ -284,7 +300,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
{/* Subtask form */} - {showSubtaskForm && expanded && ( + {showSubtaskForm && detailOpen && (
)} - {/* Subtasks */} - {subtasks.map((sub) => ( + {/* Subtasks — toggled by chevron */} + {expanded && subtasks.map((sub) => ( ))}
-- 2.45.2