docs(04): create phase plan — 6 plans across 5 waves for shared lists + live sync
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d89eb47483
commit
38fa6f448b
@@ -0,0 +1,196 @@
|
||||
---
|
||||
phase: 04-shared-lists-live-sync
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["04-03"]
|
||||
files_modified:
|
||||
- apps/api/src/routes/lists.ts
|
||||
- apps/api/src/routes/lists.test.ts
|
||||
- apps/api/src/lib/rank.ts
|
||||
- apps/api/src/lib/rank.test.ts
|
||||
- apps/pwa/src/api/listsClient.ts
|
||||
- apps/pwa/src/routes/ListDetail.tsx
|
||||
- apps/pwa/src/routes/ListDetail.test.tsx
|
||||
- apps/pwa/src/components/ItemRow.tsx
|
||||
- apps/pwa/src/components/AddItemInput.tsx
|
||||
- apps/pwa/src/App.tsx
|
||||
autonomous: true
|
||||
requirements: [LIST-02]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A member can add an item to a list and it appears at the bottom of the active section"
|
||||
- "A member can check an item off and it sinks to the Completed section (D-05)"
|
||||
- "A member can delete an individual item instantly with no confirmation (D-06)"
|
||||
- "Adding an item assigns a fractional rank so order is stable; PATCH updates exactly one field (D-08)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/lib/rank.ts"
|
||||
provides: "fractional rank helpers (append-to-end, between, move-to-active-bottom)"
|
||||
exports: ["rankForAppend", "rankBetween"]
|
||||
- path: "apps/pwa/src/routes/ListDetail.tsx"
|
||||
provides: "list detail with active/completed split + add/check/delete"
|
||||
min_lines: 60
|
||||
- path: "apps/pwa/src/components/ItemRow.tsx"
|
||||
provides: "item row with checkbox, text, delete"
|
||||
min_lines: 30
|
||||
- path: "apps/pwa/src/components/AddItemInput.tsx"
|
||||
provides: "sticky add-item input"
|
||||
min_lines: 20
|
||||
key_links:
|
||||
- from: "apps/pwa/src/routes/ListDetail.tsx"
|
||||
to: "/api/lists/:id/items + /api/list-items/:id"
|
||||
via: "useQuery(['list', listId]) + optimistic mutations"
|
||||
pattern: "list-items|/items"
|
||||
- from: "apps/api/src/routes/lists.ts"
|
||||
to: "fractional-indexing"
|
||||
via: "rankForAppend on item create / uncheck"
|
||||
pattern: "generateKeyBetween|rankForAppend"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the item-CRUD + checked-sink vertical slice (LIST-02): inside a list, a member can add items, check them off (sinking to a Completed section per D-05), and delete individual items instantly (D-06). Items get a stable fractional rank on creation (D-13 foundation, reused by Plan 05 reorder), and updates use per-field PATCH with single-field last-write-wins (D-08). Optimistic UI is wired here for add/check/delete (D-07/D-09).
|
||||
|
||||
MVP slice: after this plan a real user can fully manage the contents of a list — the core grocery/gift-ideas use case — replacing the temporary ListDetail placeholder from Plan 01.
|
||||
|
||||
Purpose: Build the item data layer (endpoints + rank assignment) and the ListDetail surface that consumes it, leaving live-sync (Plan 06) and drag-reorder (Plan 05) to layer on top.
|
||||
Output: item endpoints on listsRouter (POST items, per-field PATCH, DELETE); rank helpers; ListDetail/ItemRow/AddItemInput; App.tsx route points at the real ListDetail.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Item endpoints + fractional-rank assignment (LIST-02, D-05/D-08/D-09)</name>
|
||||
<files>apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts, apps/api/src/lib/rank.ts, apps/api/src/lib/rank.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/routes/lists.ts (listsRouter from Plan 03 — extend; access-check pattern)
|
||||
- apps/api/src/routes/lists.test.ts (item stubs)
|
||||
- apps/api/src/db/schema.ts (listItems)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 2 (fractional-indexing API), Finding 6 (per-field PATCH zod), §"Open Questions" item 2 (uncheck rank)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/lists.ts" (zod patchItemSchema, ownership verification)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: POST /api/lists/:id/items { text } inserts an item with a fractional rank placed AFTER the last active item (generateKeyBetween(lastActiveRank, null)); first item in an empty list gets generateKeyBetween(null,null) → "a0". (LIST-02, D-13)
|
||||
- Test: GET /api/lists/:id/items returns items access-gated by list membership; shape includes id/listId/text/checked/rank.
|
||||
- Test: PATCH /api/list-items/:id { checked:true } updates ONLY checked (per-field); body with two fields is rejected by zod .refine (D-08).
|
||||
- Test: PATCH /api/list-items/:id { checked:false } (uncheck) recomputes rank to append to the bottom of the active section (Open Question 2), in the same write.
|
||||
- Test: PATCH /api/list-items/:id { text } updates only text; updatedAt advances (LWW basis, D-08).
|
||||
- Test: DELETE /api/list-items/:id removes the item; a member without list access gets 403 (delete-wins semantics, D-09 — no resurrection path).
|
||||
- Test (rank.ts pure unit): rankForAppend(lastRank|null) and rankBetween(a,b) return valid fractional-indexing strings producing the expected ASC ordering.
|
||||
</behavior>
|
||||
<action>
|
||||
Create apps/api/src/lib/rank.ts wrapping fractional-indexing: `rankForAppend(lastRank: string | null): string` = generateKeyBetween(lastRank, null); `rankBetween(prev: string | null, next: string | null): string` = generateKeyBetween(prev, next). Pure functions; unit-tested.
|
||||
|
||||
Extend listsRouter (lists.ts) with item routes, each behind resolveUserId 401 + the list-access verification pattern from 04-PATTERNS (owner OR list_shares else 403) + try/catch-503:
|
||||
- POST /:id/items (zod: text 1..500) → compute rank via rankForAppend(last active item's rank), insert, return the row.
|
||||
- GET /:id/items → access-gated select ordered by rank ASC.
|
||||
- PATCH /list-items/:itemId (zod patchItemSchema: {checked?,text?,position?}.partial().refine(exactly one)) → apply single-field write with updatedAt=NOW(); on checked:false recompute rank to active-bottom in the same statement/transaction.
|
||||
- DELETE /list-items/:itemId → delete (delete-wins; no rollback path).
|
||||
Note the route paths: items-by-list use /:id/items (nested under lists); single-item mutations use /list-items/:itemId at the listsRouter root (matches RESEARCH architecture diagram). Mount accordingly so both resolve under /api. Do NOT add publishListEvent here — Plan 06 inserts fan-out triggers (leave a clearly commented seam after each successful write).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts src/lib/rank.test.ts && pnpm --filter @familysync/api typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- rank.ts tests green; ordering stable.
|
||||
- Item POST assigns active-bottom rank; per-field PATCH enforces exactly-one-field (zod refine) and is tested for checked/text/uncheck-rank.
|
||||
- DELETE works with access gating; no edit can resurrect a deleted item.
|
||||
- typecheck passes.
|
||||
</acceptance_criteria>
|
||||
<done>Item endpoints with fractional rank + per-field LWW PATCH + delete-wins, all access-gated; tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ListDetail with active/completed split + ItemRow + AddItemInput + optimistic UI (LIST-02, D-05/D-07/D-09)</name>
|
||||
<files>apps/pwa/src/api/listsClient.ts, apps/pwa/src/routes/ListDetail.tsx, apps/pwa/src/routes/ListDetail.test.tsx, apps/pwa/src/components/ItemRow.tsx, apps/pwa/src/components/AddItemInput.tsx, apps/pwa/src/App.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/routes/ListDetail.tsx (placeholder from Plan 01)
|
||||
- apps/pwa/src/routes/ListDetail.test.tsx (optimistic-update RED stub from Plan 01)
|
||||
- apps/pwa/src/components/CalendarShell.tsx (loading/error/success branch convention)
|
||||
- apps/pwa/src/api/listsClient.ts (add item fns here)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"ListDetail", §"ItemRow", §"AddItemInput", §"ListEmptyState", §"Optimistic Updates", §"Checked-Off Sink Behavior", §"Item Delete"
|
||||
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"ListDetail.tsx", §"ItemRow.tsx", §"listsClient.ts"
|
||||
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 6 (optimistic onMutate/onError/onSettled)
|
||||
</read_first>
|
||||
<action>
|
||||
Add item functions to listsClient.ts: fetchListItems(listId), addItem(listId,{text}), patchListItem(itemId, {checked} | {text} | {position}), deleteItem(itemId) — all credentials:'include'.
|
||||
|
||||
Replace the ListDetail placeholder (and point the App.tsx /lists/:listId route at the real ListDetail). ListDetail: read :listId from useParams; useQuery(['list', listId], fetchListItems) with refetchInterval:30000 (D-12 polling fallback active now; SSE hook layered in Plan 06). Split items into activeItems (!checked, sorted by rank ASC) and completedItems (checked) per D-05. Render header (back ChevronLeft, list name, kebab placeholder, sharing badge), active ItemRow list, a collapsible "Completed (N)" section (default expanded), AddItemInput sticky at bottom, and ListEmptyState when no items.
|
||||
|
||||
ItemRow.tsx per UI-SPEC: 44px min-height row, checkbox (20px visual / 44px touch, accent fill when checked), item text (plain-text JSX; line-through + muted when completed), instant delete affordance (swipe-left zone on phone / hover Trash2 on desktop, no confirmation per D-06). Include the GripVertical handle slot for active items but it is non-functional here (Plan 05 wires dnd-kit). Apply transition 'transform 150ms ease-out' so Plan 05's remote-reorder animation slot exists.
|
||||
|
||||
Wire optimistic mutations (D-07) with React Query onMutate/onError/onSettled against ['list', listId]: add (append optimistically at active bottom, opacity 0.6 until confirm, rollback on error), check (move to completed optimistically, rollback on error), delete (remove optimistically, NO rollback — delete-wins D-09).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/pwa exec vitest run src/routes/ListDetail.test.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ListDetail.test.tsx optimistic-update + rollback test (D-07) is now real and green.
|
||||
- Active/completed split renders per D-05; checking an item moves it to Completed.
|
||||
- Individual item delete is instant (no dialog); add shows optimistic pending state.
|
||||
- App.tsx /lists/:listId route renders the real ListDetail (placeholder removed).
|
||||
- PWA typecheck passes.
|
||||
- Browser check (`playwright-cli`): open a list, add "milk", check it off (sinks to Completed), delete an item (vanishes instantly). Record in SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<done>User can add, check off (sink), and delete items in a list with optimistic UI; tests green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser → item endpoints | client supplies text/checked/item id — untrusted |
|
||||
| API → MariaDB | item mutations gated by list access |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-05 | Elevation of Privilege | mutating items in a list the caller cannot access | mitigate | Every item handler verifies owner OR list_shares before read/write; 403 otherwise; tested |
|
||||
| T-04-07 | Tampering | overposting on item PATCH (writing fields beyond checked/text/position) | mitigate | zod patchItemSchema .partial().refine(exactly one field) — tested |
|
||||
| T-04-06 | Tampering | XSS via item text | mitigate | Item text rendered as plain-text JSX child; no dangerouslySetInnerHTML |
|
||||
| T-04-09 | Tampering | resurrecting a deleted item via an in-flight edit (D-09) | mitigate | DELETE is final; PATCH on a missing id affects zero rows (no upsert); delete-wins test asserts no resurrection |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts src/lib/rank.test.ts && pnpm --filter @familysync/pwa exec vitest run src/routes/ListDetail.test.tsx</automated>
|
||||
- `playwright-cli`: add / check / delete items in a real browser.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- LIST-02 satisfied: add, check-off (sink to Completed), delete items end to end.
|
||||
- Per-field PATCH (D-08) + delete-wins (D-09) + optimistic UI (D-07) in place.
|
||||
- Fractional rank assigned on create (foundation for Plan 05 reorder).
|
||||
</success_criteria>
|
||||
|
||||
<artifacts_produced>
|
||||
**Symbols/files this plan creates (exclude from drift verification):**
|
||||
- `apps/api/src/lib/rank.ts`: `rankForAppend`, `rankBetween` (+ rank.test.ts)
|
||||
- Item routes on listsRouter: POST /:id/items, GET /:id/items, PATCH /list-items/:itemId, DELETE /list-items/:itemId
|
||||
- listsClient additions: `fetchListItems`, `addItem`, `patchListItem`, `deleteItem`
|
||||
- Components: `ItemRow`, `AddItemInput`, real `ListDetail` (replaces Plan 01 placeholder)
|
||||
- App.tsx /lists/:listId now renders ListDetail
|
||||
</artifacts_produced>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/04-shared-lists-live-sync/04-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user