Add stage 5: list detail page with items UI and drag reorder

API: new PUT /api/lists/{id}/items/order takes the full ordered id set and
rewrites positions 0..n-1 in a transaction (422 unless the set matches the
list exactly). TodoItemRepository gains idsForList() and reorder().

Frontend: lists on the home page are now links to /lists/:id (ListView).
ListView shows the list title, a "M of N done" summary, and each item as a
drag handle + checkbox + inline-editable text (saved on blur) + delete
button, with a create-item form at the bottom. Drag-and-drop uses
vuedraggable; on drop the whole order is persisted via the new endpoint and
the response replaces local state, with a resync-on-error fallback. New
items store; items store is also reset on logout.

Tests: reorder happy path, incomplete-set rejection, owner scoping. Backend
suite: 23 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:59:51 +01:00
co-authored by Claude Sonnet 5
parent dd2ab5b7d3
commit b506b83b1e
16 changed files with 568 additions and 15 deletions
+43
View File
@@ -106,6 +106,49 @@ final class TodoItemRepository
$this->pdo->prepare('DELETE FROM todo_items WHERE id = :id')->execute(['id' => $id]);
}
/**
* IDs of every item in the list, in current position order.
*
* @return int[]
*/
public function idsForList(int $listId): array
{
$stmt = $this->pdo->prepare(
'SELECT id FROM todo_items WHERE list_id = :list ORDER BY position ASC, id ASC'
);
$stmt->execute(['list' => $listId]);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
}
/**
* Assign positions 0..n-1 to the given items in one transaction.
*
* @param int[] $orderedIds every item id in the list, exactly once
* @return TodoItemRow[] the list's items in their new order
*/
public function reorder(int $listId, array $orderedIds): array
{
$stmt = $this->pdo->prepare(
'UPDATE todo_items SET position = :position,
updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . '
WHERE id = :id AND list_id = :list'
);
$this->pdo->beginTransaction();
try {
foreach (array_values($orderedIds) as $position => $id) {
$stmt->execute(['position' => $position, 'id' => $id, 'list' => $listId]);
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $this->allForList($listId);
}
private function nextPosition(int $listId): int
{
$stmt = $this->pdo->prepare(