---
phase: 04-shared-lists-live-sync
plan: '04'
subsystem: api-routes, pwa-components
tags: [item-crud, fractional-rank, optimistic-ui, D-05, D-06, D-07, D-08, D-09, D-13, tdd, list-02]
dependency_graph:
  requires:
    - 04-01 (list_items schema, BrowserRouter, react-router)
    - 04-02 (listAccess.ts, listEmitter.ts primitives)
    - 04-03 (listsRouter + ListsIndex + ListCard — prerequisite list data layer)
  provides:
    - POST/GET /api/lists/:id/items with fractional rank (D-13)
    - PATCH /api/list-items/:id per-field LWW (D-08, exactly-one-field zod refine)
    - DELETE /api/list-items/:id delete-wins (D-09)
    - rank.ts: rankForAppend + rankBetween (fractional-indexing wrappers)
    - ListDetail with active/completed split (D-05), optimistic mutations (D-07/D-09)
    - ItemRow with checkbox, plain-text text, GripVertical slot, swipe/hover delete
    - AddItemInput sticky bottom input
    - listsClient item functions: fetchListItems, addItem, patchListItem, deleteItem
  affects:
    - apps/api/src/routes/lists.ts (item routes added, listItemsRouter exported)
    - apps/api/src/index.ts (listItemsRouter mounted at /api/list-items)
    - apps/api/src/lib/rank.ts (new)
    - apps/api/tests/lib/rank.test.ts (new)
    - apps/api/tests/routes/lists.test.ts (item route tests added)
    - apps/pwa/src/api/listsClient.ts (item functions added)
    - apps/pwa/src/routes/ListDetail.tsx (placeholder replaced with real implementation)
    - apps/pwa/src/routes/ListDetail.test.tsx (todo stubs replaced with real tests)
    - apps/pwa/src/components/ItemRow.tsx (new)
    - apps/pwa/src/components/AddItemInput.tsx (new)
tech_stack:
  added:
    - fractional-indexing (already installed from Plan 04-01)
  patterns:
    - rankForAppend wraps generateKeyBetween(lastRank, null)
    - patchItemSchema .partial().refine(exactly one field) for D-08/T-04-07
    - listItemsRouter separate from listsRouter, mounted at /api/list-items
    - Optimistic mutations: onMutate/onError/onSettled against ['list', listId]
    - Delete-wins: no onError rollback in deleteMutation (D-09)
    - Uncheck recomputes rank to active-bottom in same DB write (Open Question 2)
key_files:
  created:
    - apps/api/src/lib/rank.ts
    - apps/api/tests/lib/rank.test.ts
    - apps/pwa/src/components/ItemRow.tsx
    - apps/pwa/src/components/AddItemInput.tsx
  modified:
    - apps/api/src/routes/lists.ts (item routes, listItemsRouter export)
    - apps/api/src/index.ts (listItemsRouter mount)
    - apps/api/tests/routes/lists.test.ts (25 new tests)
    - apps/pwa/src/api/listsClient.ts (item functions + ListItemsResponse type)
    - apps/pwa/src/routes/ListDetail.tsx (placeholder replaced)
    - apps/pwa/src/routes/ListDetail.test.tsx (7 real tests)
decisions:
  - 'listItemsRouter exported separately from listsRouter; mounted at /api/list-items so PATCH/DELETE resolve at /api/list-items/:id per RESEARCH architecture diagram'
  - 'Uncheck rank: recompute to active-bottom (generateKeyBetween(lastActiveRank, null)) in same write per Open Question 2 from 04-RESEARCH.md'
  - 'Optimistic add uses negative id as temporary identifier (item.id < 0 → dim opacity 0.6)'
  - 'Delete-wins: no onError rollback in deleteMutation; onSettled invalidates to reconcile'
  - 'GripVertical drag handle present in ItemRow but non-functional (Plan 05 wires dnd-kit)'
metrics:
  duration: '~11 minutes'
  completed: '2026-06-09'
  task_count: 2
  file_count: 10
---

# Phase 4 Plan 4: Item CRUD + Checked-Sink Vertical Slice Summary

**One-liner:** Item CRUD vertical slice (LIST-02) — POST/GET/PATCH/DELETE item endpoints with fractional rank (D-13), per-field LWW (D-08), delete-wins (D-09), and ListDetail active/completed split with optimistic mutations (D-05/D-07).

## TDD Gate Compliance

| Gate                                                | Commit    | Status                                                            |
| --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
| RED — 16 failing item route tests + rank unit tests | b1dc9b8   | PASS — 16 route tests failed (404), rank.test.ts failed (no impl) |
| GREEN — rank.ts + item routes + listItemsRouter     | 5e31514   | PASS — all 48 tests pass                                          |
| REFACTOR                                            | (skipped) | Implementation was clean on first pass                            |

## Tasks Completed

| Task  | Name                                                        | Commit  | Files                                              |
| ----- | ----------------------------------------------------------- | ------- | -------------------------------------------------- |
| RED   | Failing tests for item routes + rank helpers                | b1dc9b8 | tests/routes/lists.test.ts, tests/lib/rank.test.ts |
| GREEN | rank.ts + item endpoints + listItemsRouter + index.ts mount | 5e31514 | rank.ts, lists.ts, index.ts                        |
| 2     | ListDetail + ItemRow + AddItemInput + listsClient item fns  | 6da9c2a | 5 files (2 new, 3 modified)                        |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Routing] listItemsRouter exported separately from listsRouter**

- **Found during:** GREEN phase — PATCH/DELETE routes at `listsRouter.patch('/list-items/:itemId')` resolved to `/api/lists/list-items/:id` not `/api/list-items/:id` as the tests expected and RESEARCH.md architecture diagram specified.
- **Issue:** The plan's note "Mount accordingly so both resolve under /api" required a second router export. Routes for single-item mutations must be at `/api/list-items/:id`, not nested under `/api/lists`.
- **Fix:** Added `export const listItemsRouter = new Hono()` in lists.ts for PATCH/DELETE routes; mounted it at `/api/list-items` in index.ts alongside the existing `listsRouter` at `/api/lists`. The two routers share the same helper functions (resolveUserId, checkListAccess, rankForAppend).
- **Files modified:** `apps/api/src/routes/lists.ts`, `apps/api/src/index.ts`
- **Commit:** 5e31514

## Playwright Browser Check

Ran against `http://localhost:5173/lists/284` (list id 284, Test Groceries) with API on port 3000 (DEV_AUTH_BYPASS=true):

1. `/lists/284` renders empty state: "Nothing here yet" + "Add your first item below." — PASS
2. Click input, type "milk", click Add → item appears in "Active items" list with checkbox + GripVertical handle — PASS
3. Click checkbox "milk" → item moves to "Completed (1)" section (sinks per D-05) — PASS
4. Hover over completed item → "Delete milk" button appears → click → item vanishes instantly, returns to "Nothing here yet" (no confirmation per D-06) — PASS

## Verification Results

### API Tests

- `tests/routes/lists.test.ts + tests/lib/rank.test.ts`: 48 passed (0 failed)
- rank.ts pure unit tests: 8 passed (rankForAppend/rankBetween ordering/stability)
- Item POST assigns rank "a0" for first item; subsequent items rank > prior — PASS
- Per-field PATCH zod refine (exactly one field) — two-field body → 400 — PASS
- Uncheck rank recompute to active-bottom in same write — PASS
- Access gating T-04-05: 403 for non-member on GET/POST/PATCH/DELETE — PASS
- Delete-wins D-09: PATCH after DELETE returns 404 (no resurrection) — PASS

### PWA Tests

- `src/routes/ListDetail.test.tsx`: 7 passed (0 failed)
- Optimistic check/uncheck/add/delete mutations
- Rollback on error restores previous state
- D-05 active/completed split verified
- D-09 delete-wins no-rollback verified

### TypeScript

- `pnpm --filter @familysync/api typecheck` — PASS
- `pnpm --filter @familysync/pwa exec tsc --noEmit` — PASS

## Known Stubs

| File                                  | Stub                                                               | Reason                                                                                                                                                              |
| ------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/pwa/src/routes/ListDetail.tsx`  | List header shows "List" (not the list name)                       | fetchListItems returns items only; list name not in the items response. Plan 05/06 can enrich from the ['lists'] cache. Non-blocking — user can still use the list. |
| `apps/api/src/routes/lists.ts`        | Plan 06 SSE seam comments (`publishListEvent` calls commented out) | Plan 06 adds fan-out once the SSE `/api/sse/lists` endpoint exists                                                                                                  |
| `apps/pwa/src/components/ItemRow.tsx` | GripVertical handle present but non-functional                     | Plan 05 wires dnd-kit; handle slot is structural as specified                                                                                                       |

The "List" heading stub does not prevent the plan's goal (add, check, delete items). Items are functionally correct. The heading will be enriched in Plan 05/06.

## Threat Surface Scan

All threats from the plan's threat model are mitigated:

| Threat ID                                           | Status    | Notes                                                                                         |
| --------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------- |
| T-04-05 (EoP — mutating items in inaccessible list) | Mitigated | checkListAccess() on every item handler; 403 tested for GET/POST/PATCH/DELETE                 |
| T-04-07 (Tampering — overposting on item PATCH)     | Mitigated | patchItemSchema .partial().refine(exactly one field); 400 on two-field body tested            |
| T-04-06 (Tampering — XSS via item text)             | Mitigated | Item text rendered as plain-text JSX child in ItemRow; no dangerouslySetInnerHTML             |
| T-04-09 (Tampering — resurrecting deleted item)     | Mitigated | DELETE final; PATCH on deleted id → 404 (no upsert); delete-wins test asserts no resurrection |

No new threat surface beyond the plan's trust boundaries.

## Self-Check: PASSED

- `apps/api/src/lib/rank.ts` — FOUND
- `apps/api/tests/lib/rank.test.ts` — FOUND
- `apps/api/src/routes/lists.ts` (POST /:id/items route) — FOUND
- `apps/api/src/index.ts` (listItemsRouter mounted at /api/list-items) — FOUND
- `apps/pwa/src/components/ItemRow.tsx` — FOUND
- `apps/pwa/src/components/AddItemInput.tsx` — FOUND
- `apps/pwa/src/routes/ListDetail.tsx` (real implementation, not placeholder) — FOUND
- `apps/pwa/src/api/listsClient.ts` (fetchListItems, addItem exported) — FOUND
- Commit b1dc9b8 (RED) — FOUND
- Commit 5e31514 (GREEN) — FOUND
- Commit 6da9c2a (Task 2) — FOUND
