From 8049a53a2bd3f6fe4351c80063d6cf422cfdae64 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 19:37:42 +0200 Subject: [PATCH] fix(todo): fix DnD reorder flicker and remove project dot indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace key-based sync with ID-set comparison to prevent $effect from reverting drag-and-drop reorders; use dropInProgress flag with 1s timeout - Add proper dnd-shadow-placeholder div instead of filtering SHADOW_PLACEHOLDER_ITEM_ID - Remove project dot from TaskItem (redundant visual indicator) - Reduce content-wrapper padding (1rem → 0.5rem mobile, 1.5rem → 1rem desktop) Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/lib/components/TaskItem.svelte | 13 --- .../web/src/lib/components/TaskList.svelte | 89 ++++++++++++------- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/apps/todo/apps/web/src/lib/components/TaskItem.svelte b/apps/todo/apps/web/src/lib/components/TaskItem.svelte index 18c692db7..8a6f44cd1 100644 --- a/apps/todo/apps/web/src/lib/components/TaskItem.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskItem.svelte @@ -515,11 +515,6 @@ {dueDateText()} {/if} - - - {#if projectColor()} -
- {/if} @@ -1141,14 +1136,6 @@ color: #9ca3af; } - /* Project dot */ - .project-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 9999px; - flex-shrink: 0; - } - /* Expand button */ .expand-btn { padding: 0.25rem; diff --git a/apps/todo/apps/web/src/lib/components/TaskList.svelte b/apps/todo/apps/web/src/lib/components/TaskList.svelte index 64c24265a..beb025d6c 100644 --- a/apps/todo/apps/web/src/lib/components/TaskList.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskList.svelte @@ -2,7 +2,7 @@ import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import type { Task, UpdateTaskInput } from '@todo/shared'; import TaskItem from './TaskItem.svelte'; - import { getContext } from 'svelte'; + import { getContext, untrack } from 'svelte'; import { tasksStore } from '$lib/stores/tasks.svelte'; import type { Project } from '@todo/shared'; import { getActiveProjects } from '$lib/data/task-queries'; @@ -160,20 +160,35 @@ // Track which task is being animated for completion let animatingTaskId = $state(null); - // Create a stable key from task IDs and updatedAt to detect real changes - let lastTaskKey = ''; + // After a drop, ignore external syncs until the timeout clears + let dropInProgress = false; - // Sync items with tasks when IDs change OR when tasks are updated + // Sync items with tasks prop — but preserve local order during/after DnD $effect(() => { - // Include updatedAt in the key to detect task updates - const currentKey = tasks - .map((t) => `${t.id}:${t.updatedAt || ''}`) - .sort() - .join(','); - if (currentKey !== lastTaskKey) { - items = [...tasks]; - lastTaskKey = currentKey; - } + // Subscribe to tasks (the reactive dependency) + const currentTasks = tasks; + + // Read items without subscribing to avoid infinite loop + untrack(() => { + const taskIds = new Set(currentTasks.map((t) => t.id)); + const itemIds = new Set(items.map((t) => t.id)); + + // Check if the actual set of IDs changed (task added or removed) + const idsChanged = + taskIds.size !== itemIds.size || + currentTasks.some((t) => !itemIds.has(t.id)) || + items.some((t) => !taskIds.has(t.id)); + + if (idsChanged) { + // Real structural change — full resync + items = [...currentTasks]; + dropInProgress = false; + } else if (!dropInProgress) { + // Same IDs — update task data in current order (no reorder flicker) + const taskMap = new Map(currentTasks.map((t) => [t.id, t])); + items = items.map((item) => taskMap.get(item.id) || item); + } + }); }); const flipDurationMs = 200; @@ -207,12 +222,12 @@ tasksStore.reorderTasks(taskIds); } - // Update local state and sync lastTaskKey to prevent $effect from reverting + // Update local state and block sync from reverting order items = newItems; - lastTaskKey = newItems - .map((t) => `${t.id}:${t.updatedAt || ''}`) - .sort() - .join(','); + dropInProgress = true; + setTimeout(() => { + dropInProgress = false; + }, 1000); } async function handleToggleComplete(task: Task) { @@ -242,21 +257,25 @@ onconsider={handleDndConsider} onfinalize={handleDndFinalize} > - {#each items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)} - -
handleContextMenu(e, task)}> - handleToggleComplete(task)} - onDelete={() => handleDelete(task.id)} - onExpand={() => handleExpandTask(task.id)} - onCollapse={handleCollapseTask} - onSave={(data) => handleSaveTask(task.id, data)} - /> -
+ {#each items as task (task.id)} + {#if task.id === SHADOW_PLACEHOLDER_ITEM_ID} +
+ {:else} + +
handleContextMenu(e, task)}> + handleToggleComplete(task)} + onDelete={() => handleDelete(task.id)} + onExpand={() => handleExpandTask(task.id)} + onCollapse={handleCollapseTask} + onSave={(data) => handleSaveTask(task.id, data)} + /> +
+ {/if} {/each} {#if items.length === 0}
@@ -342,6 +361,10 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); } + .dnd-shadow-placeholder { + min-height: 3rem; + } + /* Shadow placeholder (where dragged item will land) */ :global(.task-list [data-is-dnd-shadow-item-hint]) { background: rgba(139, 92, 246, 0.06); diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 64498d6fc..ab2327713 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -605,7 +605,7 @@ max-width: 900px; margin-left: auto; margin-right: auto; - padding: 1rem; + padding: 0.5rem; } .content-wrapper.full-width { @@ -616,7 +616,7 @@ @media (min-width: 640px) { .content-wrapper { - padding: 1.5rem; + padding: 1rem; } .content-wrapper.full-width { padding-left: 0;