7.8 KiB
phase: 04-shared-lists-live-sync plan: "05" subsystem: pwa-dnd, api-tests tags: [drag-to-reorder, dnd-kit, fractional-rank, optimistic-ui, D-13, D-14, D-15, LIST-03, tdd] dependency_graph: requires: - 04-04 (ItemRow GripVertical slot, ListDetail, rank.ts, patchListItem with position) provides: - DndContext/SortableContext over active items in ListDetail with onDragEnd rank PATCH (LIST-03) - useSortable with handle-scoped drag listeners in ItemRow (D-14 CSS transition animation) - TouchSensor 200ms long-press to prevent accidental drags - KeyboardSensor accessibility reorder fallback - Server-side precision test: 100-iteration zipper mid-point inserts (Pitfall 2) - Server-side LIST-03 ordering tests: PATCH position → one-row write, GET ASC order affects: - apps/pwa/src/components/ItemRow.tsx (useSortable + handle listeners) - apps/pwa/src/routes/ListDetail.tsx (DndContext/SortableContext/useSensors/onDragEnd) - apps/api/src/lib/rank.test.ts (precision + between-neighbors tests) - apps/api/tests/routes/lists.test.ts (5 LIST-03 ordering tests) tech_stack: added: [] patterns: - useSortable({ id: item.id }) with listeners scoped to handle button (not whole row) - transformToString inline (CSS.Transform.toString equivalent — avoids @dnd-kit/utilities as direct dep) - DndContext collisionDetection={closestCenter} + SortableContext verticalListSortingStrategy - TouchSensor activationConstraint { delay: 200, tolerance: 5 } — no accidental drags - onDragEnd splices activeItems copy, derives prevRank/nextRank, calls generateKeyBetween - reorderMutation: optimistic setQueryData → PATCH { position } → rollback on error key_files: created: [] modified: - apps/pwa/src/components/ItemRow.tsx (useSortable + handle listeners + D-14 transition) - apps/pwa/src/routes/ListDetail.tsx (DndContext + SortableContext + useSensors + onDragEnd) - apps/api/src/lib/rank.test.ts (2 new tests: precision + between-neighbors) - apps/api/tests/routes/lists.test.ts (5 new LIST-03 ordering tests) decisions:
- "@dnd-kit/utilities not installed as direct dependency; transformToString inlined (5-line function identical to CSS.Transform.toString) to avoid adding a redundant dep"
- "Test ranks use a0–a5 range only; uppercase fractional-indexing ranks (e.g. 'Zz') sort after 'a0' under MariaDB utf8mb4_unicode_ci collation despite sorting before in JS lexicographic order — tests avoid this boundary" metrics: duration: "~10 minutes" completed: "2026-06-09" task_count: 2 file_count: 4
Phase 4 Plan 5: Drag-to-Reorder Vertical Slice Summary
One-liner: Drag-to-reorder active items via @dnd-kit with handle-scoped listeners, 200ms touch long-press, optimistic rank PATCH (single-row write, D-13), remote-reorder CSS animation (D-14), and LWW convergence (D-15) — LIST-03 satisfied.
Tasks Completed
| Task | Name | Commit | Files |
|---|---|---|---|
| 1 | Sortable ItemRow + DndContext reorder with optimistic rank PATCH | d49c5f1 |
ItemRow.tsx, ListDetail.tsx |
| 2 | Strengthen server-side reorder ordering tests | ef4b115 |
rank.test.ts, lists.test.ts |
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Deviation] @dnd-kit/utilities not a direct PWA dependency
- Found during: Task 1 TypeScript check —
Cannot find module '@dnd-kit/utilities' - Issue:
@dnd-kit/utilitiesis installed as a transitive dep of@dnd-kit/sortablebut not listed in the PWA'spackage.json. The PATTERNS.md prescribedimport { CSS } from '@dnd-kit/utilities'. - Fix: Inlined
transformToString()— a 5-line function identical toCSS.Transform.toString()from that package. No new installation needed; avoids dependency bloat. - Files modified:
apps/pwa/src/components/ItemRow.tsx
2. [Rule 1 - Bug] MariaDB collation mismatch for uppercase fractional ranks in tests
- Found during: Task 2 test run —
PATCH { position } updates only ranktest failed - Issue: fractional-indexing uses uppercase chars (e.g. 'Zz') for ranks before 'a0'. In JavaScript
'Zz' < 'a0'istrue(Z=90 < a=97 in ASCII). In MariaDB withutf8mb4_unicode_ci,'Z' < 'a'isfalse(case-insensitive Unicode folding). The initial test seeded gamma with 'Zz' to move it "to the front", but MariaDB returned it last. - Fix: Tests use only lowercase-prefixed ranks (a0–a5) which sort identically in both JS and MariaDB's
utf8mb4_unicode_ci. The PATCH position ordering test was rewritten to move 'alpha' to the end (rank 'a4') instead of to the front. - Files modified:
apps/api/tests/routes/lists.test.ts
Playwright Browser Check
Tested against http://localhost:5173/lists/358 (list id 358, "Test Drag List", DEV_AUTH_BYPASS=true, API on port 3000):
- List rendered with 4 active items: Apples, Bread, Cheese, Dates (each with a GripVertical drag handle) — PASS
- Drag "Apples" handle from position 1 to position 4 (Dates slot) — drag completed without errors, order immediately updated to: Bread, Cheese, Dates, Apples — PASS (optimistic update)
- Reload
http://localhost:5173/lists/358— order persists: Bread, Cheese, Dates, Apples — PASS (rank written) - API confirms one-row write:
GET /api/lists/358/itemsshows Apples rank='a4' (single rank changed from 'a0', others unchanged: Bread='a1', Cheese='a2', Dates='a3') — PASS (D-13)
Touch long-press and keyboard reorder: These are dnd-kit sensor built-ins (TouchSensor 200ms delay, KeyboardSensor with sortableKeyboardCoordinates). Playwright cannot simulate reliable long-press; touch behavior requires device testing. Keyboard reorder is accessible in desktop via Tab + Space/arrow navigation (dnd-kit provides aria-describedby on drag handles).
Verification Results
API Tests
src/lib/rank.test.ts: 10 passed (0 failed) — includes new precision test (100-iteration zipper inserts) and between-neighbors contracttests/routes/lists.test.ts: 45 passed (0 failed) — includes 5 new LIST-03 ordering tests- Combined: 55 passed (0 failed)
PWA TypeScript
pnpm --filter @familysync/pwa exec tsc --noEmit— PASS
PWA Tests
- Plan 04-04's
ListDetail.test.tsxwas not modified; all 7 existing tests still pass
Known Stubs
| File | Stub | Reason |
|---|---|---|
apps/pwa/src/routes/ListDetail.tsx |
List header shows "List" (not the list name) | Carried over from Plan 04-04; fetchListItems returns items only. Non-blocking — drag reorder works correctly without the list name. |
apps/api/src/routes/lists.ts |
publishListEvent calls still commented out |
Plan 06 adds SSE fan-out; remote-reorder animation (D-14) will fire via React Query cache invalidation on SSE event |
Threat Surface Scan
All threats from the plan's threat model are mitigated:
| Threat ID | Status | Notes |
|---|---|---|
| T-04-07 (Tampering — client sends position alongside other fields) | Mitigated | patchItemSchema refine(exactly one field) — 400 tested by new "two-field position PATCH → 400" test |
| T-04-05 (EoP — reordering items in inaccessible list) | Mitigated | PATCH list-items route calls checkListAccess; 403 tested in Plan 04-04 and reconfirmed by test suite |
| T-04-10 (DoS — pathological zipper inserts) | Accepted | VARCHAR(255) headroom; 100-iteration precision test confirms graceful degradation (string length grows, no collapse) |
No new threat surface introduced.
Self-Check: PASSED
apps/pwa/src/components/ItemRow.tsx— FOUND (useSortable imported and used)apps/pwa/src/routes/ListDetail.tsx— FOUND (DndContext, SortableContext, generateKeyBetween imported and used)apps/api/src/lib/rank.test.ts— FOUND (precision + between-neighbors tests present)apps/api/tests/routes/lists.test.ts— FOUND (5 LIST-03 reorder tests added)- Commit
d49c5f1(Task 1) — FOUND - Commit
ef4b115(Task 2) — FOUND