/** * ListDetail — Single list view (/lists/:listId). * * Delivers LIST-02 + LIST-03 + LIST-04: * - Fetches items via useQuery(['list', listId]) with 30s polling fallback (D-12) * - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05) * - Optimistic mutations for add / check / delete (D-07/D-09) * - Delete-wins: no rollback on item delete (D-09) * - Per-field LWW PATCH: check/uncheck sends { checked } only (D-08) * - Drag-to-reorder (dnd-kit): DndContext + SortableContext over active items (LIST-03, D-13) * - onDragEnd: computes generateKeyBetween, PATCHes { position } optimistically, rolls back on error * - TouchSensor with 200ms delay + 5px tolerance — no accidental scroll drags * - KeyboardSensor for accessibility keyboard-reorder fallback * - Remote reorders animate via CSS.Transform.toString in ItemRow (D-14) * - Concurrent reorder converges via server last-write-wins (D-15) * * Live sync (LIST-04, D-10/D-11/D-12): * - useListSSE wires /api/sse/lists with bounded backoff reconnect (D-11) * - On SSE event: invalidates ['list', listId] → background refetch (D-10) * - LiveSyncIndicator renders connection status in the header * - refetchInterval:30000 polling fallback always active (D-12) * - Note: SSE connection lives in ListDetail (one stream per list navigation). * A future optimization could hoist this to the Lists route level — acceptable * for Phase 4 per RESEARCH note; Phase 5 push will own the session lifecycle. * * Security: item text rendered as plain-text JSX children (T-04-06 XSS guard). */ import { useState } from 'react' import { useParams, useNavigate } from 'react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { ChevronLeft } from 'lucide-react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensors, useSensor, type DragEndEvent, } from '@dnd-kit/core' import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { generateKeyBetween } from 'fractional-indexing' import { fetchListItems, addItem, patchListItem, deleteItem, } from '../api/listsClient.js' import { ItemRow } from '../components/ItemRow.js' import { AddItemInput } from '../components/AddItemInput.js' import { LiveSyncIndicator } from '../components/LiveSyncIndicator.js' import { useListSSE } from '../hooks/useListSSE.js' import type { SyncState } from '../hooks/useListSSE.js' import type { ListItem, ListItemsResponse } from '../api/listsClient.js' /** * ListEmptyState — shown inside ListDetail when list has no items. */ function ListEmptyState() { return (
Nothing here yet
Add your first item below.
) } export function ListDetail() { const { listId } = useParams<{ listId: string }>() const navigate = useNavigate() const queryClient = useQueryClient() const parsedListId = Number(listId) const [completedExpanded, setCompletedExpanded] = useState(true) const [syncState, setSyncState] = useState('connected') // ── Live sync (LIST-04) ────────────────────────────────────────────────── // Bounded-backoff SSE hook (D-11). On open: invalidates ['list', listId] (D-10). // On event: invalidates ['list', listId] → background refetch. // Polling fallback: refetchInterval:30000 below stays always active (D-12). useListSSE({ listId: parsedListId, onStateChange: setSyncState }) // ── dnd-kit sensors ──────────────────────────────────────────────────────── // PointerSensor: desktop mouse — immediate activation // TouchSensor: mobile touch — 200ms long-press + 5px tolerance to avoid // accidental drags while scrolling (no accidental drags constraint) // KeyboardSensor: accessibility keyboard-reorder fallback (WCAG) const sensors = useSensors( useSensor(PointerSensor), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ) // ── Data fetch ───────────────────────────────────────────────────────────── // refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06) const { data, isLoading, isError } = useQuery({ queryKey: ['list', parsedListId], queryFn: () => fetchListItems(parsedListId), refetchInterval: 30_000, enabled: !isNaN(parsedListId), }) // Split into active (unchecked, sorted rank ASC) and completed (checked) per D-05 const allItems: ListItem[] = data?.items ?? [] const activeItems = allItems .filter((i) => !i.checked) .sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0)) const completedItems = allItems.filter((i) => i.checked) // ── Add item mutation (D-07 optimistic: append at active bottom, opacity 0.6) ── const addMutation = useMutation({ mutationFn: (text: string) => addItem(parsedListId, { text }), onMutate: async (text: string) => { await queryClient.cancelQueries({ queryKey: ['list', parsedListId] }) const previous = queryClient.getQueryData(['list', parsedListId]) // Compute optimistic rank: after the last active item const lastRank = activeItems.at(-1)?.rank ?? null const optimisticRank = generateKeyBetween(lastRank, null) const optimisticItem: ListItem = { id: -Date.now(), // temporary negative id listId: parsedListId, text, checked: false, rank: optimisticRank, } queryClient.setQueryData(['list', parsedListId], (old) => ({ items: [...(old?.items ?? []), optimisticItem], })) return { previous, optimisticItem } }, onError: (_err, _text, context) => { // Rollback on rejection if (context?.previous) { queryClient.setQueryData(['list', parsedListId], context.previous) } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['list', parsedListId] }) }, }) // ── Check/uncheck mutation (D-07 optimistic: move section immediately, rollback on error) ── const checkMutation = useMutation({ mutationFn: ({ itemId, checked }: { itemId: number; checked: boolean }) => patchListItem(itemId, { checked }), onMutate: async ({ itemId, checked }) => { await queryClient.cancelQueries({ queryKey: ['list', parsedListId] }) const previous = queryClient.getQueryData(['list', parsedListId]) queryClient.setQueryData(['list', parsedListId], (old) => ({ items: (old?.items ?? []).map((item) => item.id === itemId ? { ...item, checked } : item, ), })) return { previous } }, onError: (_err, _vars, context) => { if (context?.previous) { queryClient.setQueryData(['list', parsedListId], context.previous) } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['list', parsedListId] }) }, }) // ── Delete item mutation (D-09 delete-wins: NO rollback on error) ── const deleteMutation = useMutation({ mutationFn: (itemId: number) => deleteItem(itemId), onMutate: async (itemId: number) => { await queryClient.cancelQueries({ queryKey: ['list', parsedListId] }) // Remove item optimistically — no previous state saved (delete-wins D-09) queryClient.setQueryData(['list', parsedListId], (old) => ({ items: (old?.items ?? []).filter((item) => item.id !== itemId), })) }, // No onError rollback — delete-wins (D-09) onSettled: () => { queryClient.invalidateQueries({ queryKey: ['list', parsedListId] }) }, }) // ── Reorder mutation (D-13 single-row rank write, D-15 LWW convergence) ── const reorderMutation = useMutation({ mutationFn: ({ itemId, position }: { itemId: number; position: string }) => patchListItem(itemId, { position }), onMutate: async ({ itemId, position }) => { await queryClient.cancelQueries({ queryKey: ['list', parsedListId] }) const previous = queryClient.getQueryData(['list', parsedListId]) // Optimistic: update the moved item's rank in cache immediately queryClient.setQueryData(['list', parsedListId], (old) => ({ items: (old?.items ?? []).map((item) => item.id === itemId ? { ...item, rank: position } : item, ), })) return { previous } }, onError: (_err, _vars, context) => { // Rollback: animate back to previous order (D-14 — CSS transition in ItemRow handles animation) if (context?.previous) { queryClient.setQueryData(['list', parsedListId], context.previous) } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['list', parsedListId] }) }, }) // ── Handlers ─────────────────────────────────────────────────────────────── function handleAdd(text: string) { addMutation.mutate(text) } function handleCheck(itemId: number, checked: boolean) { checkMutation.mutate({ itemId, checked }) } function handleDelete(itemId: number) { deleteMutation.mutate(itemId) } /** * onDragEnd: compute new fractional rank and issue a single-item PATCH (D-13). * * Algorithm: * 1. Find the dragged item's current position in activeItems. * 2. Find the target position (over.id). * 3. Derive prevRank/nextRank from the active list at the destination. * 4. Compute newRank = generateKeyBetween(prevRank, nextRank). * 5. Optimistically update cache, then PATCH { position: newRank }. * Only the moved item's rank is written (one-row PATCH — D-13). * * Concurrent reorder of the same item from another member: server LWW on * updatedAt resolves to the last write — both clients converge within ~1s * via SSE (Plan 06) or the 30s polling fallback (D-12, D-15). */ function handleDragEnd(event: DragEndEvent) { const { active, over } = event // No-op: dropped outside or on itself if (!over || active.id === over.id) return // Find source and destination indices in the sorted active list const activeIndex = activeItems.findIndex((i) => i.id === active.id) const overIndex = activeItems.findIndex((i) => i.id === over.id) if (activeIndex === -1 || overIndex === -1) return // Build the new order by moving the item to the target position const newOrder = [...activeItems] const [moved] = newOrder.splice(activeIndex, 1) newOrder.splice(overIndex, 0, moved) // Derive prevRank/nextRank from the reordered list at the moved item's new position const prevRank = newOrder[overIndex - 1]?.rank ?? null const nextRank = newOrder[overIndex + 1]?.rank ?? null const newRank = generateKeyBetween(prevRank, nextRank) reorderMutation.mutate({ itemId: Number(active.id), position: newRank }) } // ── Loading / error states ───────────────────────────────────────────────── if (isNaN(parsedListId)) { return (
Invalid list.
) } // ── Render ───────────────────────────────────────────────────────────────── return (
{/* Header */}

{/* List name not available without an extra fetch; shown as placeholder. Plan 05/06 can enrich from the ['lists'] cache by listId. */} List

{/* Live sync indicator — connected/reconnecting/disconnected (LIST-04, D-11) */}
{/* Content area */}
{isLoading && (
Loading…
)} {isError && (
Failed to load items.
)} {!isLoading && !isError && allItems.length === 0 && } {/* Active items section — wrapped in DndContext + SortableContext (LIST-03) */} {activeItems.length > 0 && ( i.id)} strategy={verticalListSortingStrategy} >
{activeItems.map((item) => (
))}
)} {/* Completed section (D-05) — collapsible, default expanded */} {completedItems.length > 0 && (
{completedExpanded && (
{completedItems.map((item) => (
))}
)}
)}
{/* Sticky add-item input */}
) }