Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
/**
|
|
* 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 (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flex: 1,
|
|
gap: 'var(--space-3)',
|
|
padding: 'var(--space-6) var(--space-4)',
|
|
textAlign: 'center',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
aria-live="polite"
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary)',
|
|
}}
|
|
>
|
|
Nothing here yet
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
color: 'var(--color-text-muted)',
|
|
maxWidth: '240px',
|
|
}}
|
|
>
|
|
Add your first item below.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<SyncState>('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<ListItemsResponse>(['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<ListItemsResponse>(['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: () => {
|
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
|
void 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<ListItemsResponse>(['list', parsedListId]);
|
|
|
|
queryClient.setQueryData<ListItemsResponse>(['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: () => {
|
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
|
void 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<ListItemsResponse>(['list', parsedListId], (old) => ({
|
|
items: (old?.items ?? []).filter((item) => item.id !== itemId),
|
|
}));
|
|
},
|
|
// No onError rollback — delete-wins (D-09)
|
|
onSettled: () => {
|
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
|
void 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<ListItemsResponse>(['list', parsedListId]);
|
|
|
|
// Optimistic: update the moved item's rank in cache immediately
|
|
queryClient.setQueryData<ListItemsResponse>(['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: () => {
|
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
|
void 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 (
|
|
<div
|
|
style={{
|
|
padding: 'var(--space-4)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
color: 'var(--color-text-primary)',
|
|
}}
|
|
>
|
|
Invalid list.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
background: 'var(--color-surface)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-2)',
|
|
padding: 'var(--space-3) var(--space-4)',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
minHeight: '56px',
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
void navigate('/lists');
|
|
}}
|
|
aria-label="Back to lists"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: 'var(--space-2)',
|
|
color: 'var(--color-text-primary)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
minWidth: '44px',
|
|
minHeight: '44px',
|
|
}}
|
|
>
|
|
<ChevronLeft size={20} aria-hidden="true" />
|
|
</button>
|
|
<h1
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary)',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{/* List name not available without an extra fetch; shown as placeholder.
|
|
Plan 05/06 can enrich from the ['lists'] cache by listId. */}
|
|
List
|
|
</h1>
|
|
|
|
{/* Live sync indicator — connected/reconnecting/disconnected (LIST-04, D-11) */}
|
|
<LiveSyncIndicator state={syncState} />
|
|
</div>
|
|
|
|
{/* Content area */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
{isLoading && (
|
|
<div
|
|
style={{
|
|
padding: 'var(--space-6) var(--space-4)',
|
|
color: 'var(--color-text-muted)',
|
|
textAlign: 'center',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
}}
|
|
>
|
|
Loading…
|
|
</div>
|
|
)}
|
|
|
|
{isError && (
|
|
<div
|
|
style={{
|
|
padding: 'var(--space-6) var(--space-4)',
|
|
color: 'var(--color-destructive)',
|
|
textAlign: 'center',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
}}
|
|
>
|
|
Failed to load items.
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
|
|
|
|
{/* Active items section — wrapped in DndContext + SortableContext (LIST-03) */}
|
|
{activeItems.length > 0 && (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={activeItems.map((i) => i.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div role="list" aria-label="Active items">
|
|
{activeItems.map((item) => (
|
|
<div key={item.id} role="listitem">
|
|
<ItemRow
|
|
item={item}
|
|
isActive={true}
|
|
onCheck={handleCheck}
|
|
onDelete={handleDelete}
|
|
// Newly optimistic items have negative id and dim opacity
|
|
optimisticOpacity={item.id < 0 ? 0.6 : 1}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
|
|
{/* Completed section (D-05) — collapsible, default expanded */}
|
|
{completedItems.length > 0 && (
|
|
<div>
|
|
<button
|
|
onClick={() => setCompletedExpanded((prev) => !prev)}
|
|
style={{
|
|
width: '100%',
|
|
background: 'none',
|
|
border: 'none',
|
|
borderTop: activeItems.length > 0 ? '1px solid var(--color-border)' : 'none',
|
|
padding: 'var(--space-3) var(--space-4)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
cursor: 'pointer',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 400,
|
|
textAlign: 'left',
|
|
minHeight: '44px',
|
|
}}
|
|
aria-expanded={completedExpanded}
|
|
>
|
|
Completed ({completedItems.length})
|
|
</button>
|
|
|
|
{completedExpanded && (
|
|
<div role="list" aria-label="Completed items">
|
|
{completedItems.map((item) => (
|
|
<div key={item.id} role="listitem">
|
|
<ItemRow
|
|
item={item}
|
|
isActive={false}
|
|
onCheck={handleCheck}
|
|
onDelete={handleDelete}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sticky add-item input */}
|
|
<AddItemInput onAdd={handleAdd} isPending={addMutation.isPending} />
|
|
</div>
|
|
);
|
|
}
|