Compare commits

..

6 commits

Author SHA1 Message Date
137dc83bf8 Merge pull request 'fix: issues #60 #61 #62 #63 — inbox, refresh, subtask depth, chevron/detail' (#64) from issue-60-fix-duplicate-inbox into master 2026-04-09 01:33:40 +00:00
le king fu
78471543c3 fix: separate subtask expand chevron from detail view icon (#63)
Split TaskItem into two independent states:
- `expanded` (chevron): toggles subtask visibility, only shown when
  subtasks exist
- `detailOpen` (search icon + title click): opens detail panel with
  notes, priority, edit/delete actions

The two actions are fully independent — expanding subtasks does not
open the detail view and vice versa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:15:59 -04:00
le king fu
21020406b2 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) <noreply@anthropic.com>
2026-04-08 21:13:43 -04:00
le king fu
894ac03072 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) <noreply@anthropic.com>
2026-04-08 21:04:55 -04:00
le king fu
6c36ebcce5 fix: wrap inbox merge in transaction, revert seed to random UUID (#60)
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) <noreply@anthropic.com>
2026-04-08 20:54:55 -04:00
le king fu
d9daf9eda4 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) <noreply@anthropic.com>
2026-04-08 20:50:12 -04:00
10 changed files with 206 additions and 97 deletions

View file

@ -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={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

View file

@ -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={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

View file

@ -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 */}
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => 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]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
{/* Subtasks — only for root tasks (not subtasks themselves) */}
{!task?.parentId && (
<>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => 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]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
<TextInput
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
</View>
</>
)}
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>

View file

@ -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<string, unknown>) || {};
const incomingIsInbox = d.isInbox as boolean | undefined;
const listValues = {
id: entityId,
userId,
name: (data as Record<string, unknown>)?.name as string || 'Untitled',
color: (data as Record<string, unknown>)?.color as string | undefined,
icon: (data as Record<string, unknown>)?.icon as string | undefined,
position: (data as Record<string, unknown>)?.position as number | undefined,
isInbox: (data as Record<string, unknown>)?.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)

View file

@ -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

View file

@ -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 */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
{/* Expand subtasks toggle — only shown when subtasks exist */}
{subtasks.length > 0 ? (
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
{/* Checkbox */}
<button
@ -147,12 +153,12 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
{task.completed && <Check size={12} />}
</button>
{/* Title */}
{/* Title — click opens detail */}
<span
className={`flex-1 text-sm cursor-pointer ${
task.completed ? "line-through text-foreground/50" : ""
}`}
onClick={() => setExpanded(!expanded)}
onClick={() => setDetailOpen(!detailOpen)}
>
{task.title}
</span>
@ -174,10 +180,20 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
{subtasks.filter((s) => s.completed).length}/{subtasks.length}
</span>
)}
{/* Detail view toggle */}
<button
onClick={() => setDetailOpen(!detailOpen)}
className={`p-0.5 shrink-0 transition-colors ${
detailOpen ? "text-bleu" : "text-foreground/30 hover:text-foreground/60"
}`}
>
<Search size={14} />
</button>
</div>
{/* Expanded view */}
{expanded && (
{/* Detail view */}
{detailOpen && (
<div className="px-3 pb-3 pt-1 border-t border-border-light dark:border-border-dark">
{editing ? (
<div className="space-y-2">
@ -261,12 +277,14 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
>
{t("task.edit")}
</button>
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
{t("task.addSubtask")}
</button>
{depth < 1 && (
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
{t("task.addSubtask")}
</button>
)}
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
@ -282,7 +300,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
</div>
{/* Subtask form */}
{showSubtaskForm && expanded && (
{showSubtaskForm && detailOpen && (
<div style={{ marginLeft: 24 }} className="mb-1.5">
<TaskForm
listId={task.listId}
@ -292,8 +310,8 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
</div>
)}
{/* Subtasks */}
{subtasks.map((sub) => (
{/* Subtasks — toggled by chevron */}
{expanded && subtasks.map((sub) => (
<TaskItem key={sub.id} task={sub} depth={depth + 1} />
))}
</div>

View file

@ -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 (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
<div className="mb-6">
<h2 className="text-xl font-semibold mb-3">{listName}</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">{listName}</h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 text-foreground/40 hover:text-foreground transition-colors disabled:opacity-50"
title={t("task.refresh")}
>
<RefreshCw size={18} className={refreshing ? "animate-spin" : ""} />
</button>
</div>
<Suspense fallback={null}>
<FilterBar />
</Suspense>

View file

@ -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',

View file

@ -20,6 +20,7 @@
"subtaskPlaceholder": "New subtask...",
"notesPlaceholder": "Notes...",
"empty": "No tasks",
"refresh": "Refresh",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",

View file

@ -20,6 +20,7 @@
"subtaskPlaceholder": "Nouvelle sous-tâche...",
"notesPlaceholder": "Notes...",
"empty": "Aucune tâche",
"refresh": "Rafraîchir",
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",