Milestone v1.0: FamilySync MVP #1
+26
-2
@@ -141,7 +141,31 @@ Plans:
|
|||||||
2. Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items
|
2. Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items
|
||||||
3. When one member adds or checks off an item, the other member sees the change appear in the list within a few seconds without refreshing — even if they reconnect after a brief network gap
|
3. When one member adds or checks off an item, the other member sees the change appear in the list within a few seconds without refreshing — even if they reconnect after a brief network gap
|
||||||
|
|
||||||
**Plans**: TBD
|
**Entry gate status (2026-06-08):** CLEARED — SSE-over-Pangolin smoke test PASSED (35 heartbeats over ~6 min, buffering off, no cut). Live sync may be built directly on SSE; polling fallback (D-12) retained as belt-and-suspenders.
|
||||||
|
|
||||||
|
**Plans**: 6 plans
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [ ] 04-01-PLAN.md — Foundation + app shell: deps install (+ legitimacy gate), list tables generate+migrate [BLOCKING], API test harness + Wave-0 RED stubs, react-router + BottomTabBar + empty ListsIndex (D-13/D-16/D-17/D-18)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1)*
|
||||||
|
|
||||||
|
- [ ] 04-02-PLAN.md — TDD: scoped in-memory fan-out (listEmitter) + getAccessibleListIds access scope — the load-bearing D-04 no-leak primitive (LIST-04)
|
||||||
|
- [ ] 04-03-PLAN.md — List CRUD slice: POST/GET/PATCH/DELETE /api/lists with scoped access + auto-share-on-create + ListsIndex/ListCard/CreateListSheet/ListDeleteDialog (LIST-01, D-01/D-02/D-06)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2)*
|
||||||
|
|
||||||
|
- [ ] 04-04-PLAN.md — Item CRUD + checked-sink slice: item endpoints + fractional rank + per-field LWW PATCH + ListDetail/ItemRow/AddItemInput + optimistic UI (LIST-02, D-05/D-07/D-08/D-09)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3)*
|
||||||
|
|
||||||
|
- [ ] 04-05-PLAN.md — Reorder slice: dnd-kit sortable + generateKeyBetween rank + one-row position PATCH + animate-on-remote (LIST-03, D-13/D-14/D-15)
|
||||||
|
|
||||||
|
**Wave 5** *(blocked on Waves 2 + 4)*
|
||||||
|
|
||||||
|
- [ ] 04-06-PLAN.md — Live-sync slice: scoped /api/sse/lists + fan-out triggers + useListSSE bounded-backoff hook + LiveSyncIndicator + polling fallback (LIST-04, D-04/D-10/D-11/D-12)
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 5: Web Push Notifications
|
### Phase 5: Web Push Notifications
|
||||||
@@ -189,7 +213,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
|
|||||||
| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 |
|
| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 |
|
||||||
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
|
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
|
||||||
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
|
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
|
||||||
| 4. Shared Lists + Live Sync | 0/? | Not started | - |
|
| 4. Shared Lists + Live Sync | 0/6 | Planned | - |
|
||||||
| 5. Web Push Notifications | 0/? | Not started | - |
|
| 5. Web Push Notifications | 0/? | Not started | - |
|
||||||
| 6. UX Polish | 0/? | Not started | - |
|
| 6. UX Polish | 0/? | Not started | - |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
phase: 04-shared-lists-live-sync
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/pwa/package.json
|
||||||
|
- apps/api/package.json
|
||||||
|
- apps/api/src/db/schema.ts
|
||||||
|
- apps/api/src/db/migrations/0002_lists_schema.sql
|
||||||
|
- apps/api/test/setup.ts
|
||||||
|
- apps/api/vitest.config.ts
|
||||||
|
- apps/api/src/routes/lists.test.ts
|
||||||
|
- apps/api/src/lib/listEmitter.test.ts
|
||||||
|
- apps/pwa/src/hooks/useListSSE.test.ts
|
||||||
|
- apps/pwa/src/routes/ListDetail.test.tsx
|
||||||
|
- apps/pwa/src/App.tsx
|
||||||
|
- apps/pwa/src/components/BottomTabBar.tsx
|
||||||
|
- apps/pwa/src/routes/ListsIndex.tsx
|
||||||
|
- apps/pwa/src/store/listsStore.ts
|
||||||
|
autonomous: false
|
||||||
|
requirements: [LIST-01, LIST-02, LIST-03, LIST-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can tap a 'Lists' tab in a bottom tab bar and land on a /lists route"
|
||||||
|
- "The /lists route renders an empty state when no lists exist"
|
||||||
|
- "The new lists/list_items/list_shares tables exist in MariaDB after migration"
|
||||||
|
- "API test harness runs and the Phase 4 RED test stubs execute (failing, not erroring on import)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/db/schema.ts"
|
||||||
|
provides: "lists, listShares, listItems Drizzle tables"
|
||||||
|
contains: "export const lists"
|
||||||
|
- path: "apps/api/src/db/migrations/0002_lists_schema.sql"
|
||||||
|
provides: "additive CREATE TABLE migration for the three list tables"
|
||||||
|
contains: "CREATE TABLE"
|
||||||
|
- path: "apps/pwa/src/components/BottomTabBar.tsx"
|
||||||
|
provides: "Calendar | Lists bottom tab navigation"
|
||||||
|
min_lines: 25
|
||||||
|
- path: "apps/pwa/src/routes/ListsIndex.tsx"
|
||||||
|
provides: "Lists surface with empty state"
|
||||||
|
min_lines: 20
|
||||||
|
- path: "apps/api/src/routes/lists.test.ts"
|
||||||
|
provides: "RED test stubs for LIST-01/02/03/04 API behavior"
|
||||||
|
contains: "describe"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/pwa/src/App.tsx"
|
||||||
|
to: "/lists"
|
||||||
|
via: "react-router Route + BottomTabBar NavLink"
|
||||||
|
pattern: "lists"
|
||||||
|
- from: "apps/api/src/db/schema.ts"
|
||||||
|
to: "MariaDB"
|
||||||
|
via: "drizzle-kit generate + migrate"
|
||||||
|
pattern: "mysqlTable\\('lists'"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Establish the Phase 4 foundation as a thin, runnable end-to-end shell: install the four new npm dependencies, add the three list tables to the Drizzle schema and apply them via a generated migration, scaffold the API test harness with the Phase 4 Wave-0 RED test stubs, and add react-router + a bottom tab bar so the user can navigate to a (currently empty) Lists surface.
|
||||||
|
|
||||||
|
This is the MVP first slice: after this plan a real user can tap "Lists" and see the Lists surface render (empty state). No list data yet — later slices fill it in. Wave 0 test stubs are created here so every downstream task has an `<automated>` target per 04-VALIDATION.md.
|
||||||
|
|
||||||
|
Purpose: De-risk the transport/routing/schema/test plumbing before any list feature is built, and satisfy the [BLOCKING] generate+migrate schema constraint once for all later DB-dependent work.
|
||||||
|
Output: New deps installed; three tables migrated; API vitest harness + 4 RED stub test files; router + BottomTabBar + ListsIndex empty state; listsStore (UI-only).
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
**As a** household member, **I want to** create and manage shared named lists with real-time co-edit sync, **so that** my partner and I see each other's list edits appear within seconds without refreshing. (This plan delivers the navigable shell; later plans fill in CRUD, reorder, and live sync.)
|
||||||
|
</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/STATE.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="checkpoint:human-verify" gate="blocking-human">
|
||||||
|
<name>Task 1: Package legitimacy gate for the SUS-flagged react-router</name>
|
||||||
|
<files>apps/pwa/package.json</files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Package Legitimacy Audit"
|
||||||
|
</read_first>
|
||||||
|
<what-built>Nothing yet — this gate precedes the install in Task 2.</what-built>
|
||||||
|
<action>
|
||||||
|
Per the Package Legitimacy Audit, three packages (@dnd-kit/core, @dnd-kit/sortable, fractional-indexing) are verdict OK and auto-approved. `react-router` is flagged SUS only because version 7.17.0 was published 2026-06-04 (version-recency false positive); the package is the canonical React Router (remix-run, ~12 yrs, 47.5M/wk). Surface this to the operator for a one-time confirm before installing, since legitimacy checkpoints are never auto-approvable.
|
||||||
|
</action>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Open https://www.npmjs.com/package/react-router and confirm publisher is `remix-run`/`react-router` org with multi-year history and ~47M weekly downloads.
|
||||||
|
2. Confirm version 7.x is the current major.
|
||||||
|
</how-to-verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Operator types "approved" (or names a pinned version) before Task 2 runs.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<resume-signal>Type "approved" to proceed with the install, or specify an alternate version.</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Install new dependencies + scaffold API test harness with Wave-0 RED stubs</name>
|
||||||
|
<files>apps/pwa/package.json, apps/api/package.json, apps/api/test/setup.ts, apps/api/vitest.config.ts, apps/api/src/routes/lists.test.ts, apps/api/src/lib/listEmitter.test.ts, apps/pwa/src/hooks/useListSSE.test.ts, apps/pwa/src/routes/ListDetail.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/vitest.config.ts
|
||||||
|
- apps/api/src/db/client.ts
|
||||||
|
- apps/pwa/src/api/client.test.ts (existing PWA test convention)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-VALIDATION.md §"Wave 0 Requirements" and §"Per-Task Verification Map"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Standard Stack" → "New Dependencies"
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Install PWA deps: react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing via `pnpm --filter @familysync/pwa add`. Install API dep fractional-indexing via `pnpm --filter @familysync/api add` (needed server-side for rank generation). Do NOT install ioredis — fan-out is in-memory EventEmitter per RESEARCH discretion (justified in Plan 02).
|
||||||
|
|
||||||
|
Scaffold the API test harness: the API currently has zero test files. Create `apps/api/test/setup.ts` and reference it from `apps/api/vitest.config.ts` (`test.setupFiles`). The setup file must establish how DB-backed route tests connect — point at the local MariaDB via the existing `apps/api/src/db/client.ts` pool (DB_HOST/DB_NAME from env), and provide a per-test cleanup (truncate lists/list_items/list_shares between tests). Pure-logic tests (listEmitter, fractional rank) do NOT need the DB.
|
||||||
|
|
||||||
|
Create the four Wave-0 RED stub test files listed in 04-VALIDATION.md, each with `describe`/`it.todo` or `it(... )` blocks that compile and FAIL (red) rather than error on import — they import the not-yet-existing modules behind a guard or use `it.todo` placeholders that downstream plans convert to real assertions:
|
||||||
|
- apps/api/src/routes/lists.test.ts — LIST-01/02/03/04 API behavior stubs
|
||||||
|
- apps/api/src/lib/listEmitter.test.ts — scoped fan-out correctness (D-04) stubs
|
||||||
|
- apps/pwa/src/hooks/useListSSE.test.ts — D-11 bounded backoff (mock EventSource) stubs
|
||||||
|
- apps/pwa/src/routes/ListDetail.test.tsx — D-07 optimistic update + rollback stubs
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api test 2>&1 | grep -Eiq 'todo|fail|no tests|passed' && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts src/routes/ListDetail.test.tsx 2>&1 | grep -Eiq 'todo|fail|passed'</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `react-router`, `@dnd-kit/core`, `@dnd-kit/sortable`, `fractional-indexing` appear in apps/pwa/package.json dependencies.
|
||||||
|
- `fractional-indexing` appears in apps/api/package.json dependencies.
|
||||||
|
- `ioredis` is NOT added to either package.json.
|
||||||
|
- `apps/api/test/setup.ts` exists and is referenced by `setupFiles` in apps/api/vitest.config.ts.
|
||||||
|
- All four Wave-0 test files exist and run (todo/red), not import-error.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>New deps installed (no ioredis), API test harness runs, four RED/todo stub files present and executing.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Add list tables to schema and apply via generate+migrate [BLOCKING]</name>
|
||||||
|
<files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_lists_schema.sql</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/db/schema.ts (full file — table conventions)
|
||||||
|
- apps/api/src/db/migrations/0001_calendars_user_url_unique.sql (prior migration shape)
|
||||||
|
- apps/api/drizzle.config.ts
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Database Schema Design"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/db/schema.ts"
|
||||||
|
- $HOME/.claude/projects/-home-luc-Projects-familysync/memory/drizzle-mariadb-push-unsafe.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Append three tables to apps/api/src/db/schema.ts following the exact conventions in 04-RESEARCH §Database Schema Design and the analog patterns in 04-PATTERNS:
|
||||||
|
- `lists`: int autoincrement PK, `ownerId` int('owner_id') references users.id onDelete cascade notNull, `name` varchar(255) notNull, `isShared` boolean('is_shared') default true notNull (D-01), `createdAt` timestamp defaultNow notNull, `updatedAt` timestamp defaultNow onUpdateNow; index idx_lists_owner_id on ownerId.
|
||||||
|
- `listShares` (D-02, member-count-agnostic join table): int PK, `listId` int('list_id') references lists.id onDelete cascade notNull, `userId` int('user_id') references users.id onDelete cascade notNull, `createdAt` timestamp defaultNow notNull; unique('uniq_list_share') on (listId, userId), index idx_list_shares_user_id on userId.
|
||||||
|
- `listItems`: int PK, `listId` int('list_id') references lists.id onDelete cascade notNull, `text` varchar(500) notNull, `checked` boolean default false notNull, `rank` varchar(255) notNull (D-13 fractional-indexing string), `createdAt`, `updatedAt`; index idx_list_items_list_id_rank on (listId, rank), index idx_list_items_list_id_checked on (listId, checked).
|
||||||
|
|
||||||
|
Then generate and apply the migration. This is [BLOCKING]: run `pnpm --filter @familysync/api db:generate` to produce `apps/api/src/db/migrations/0002_lists_schema.sql` (+ journal/snapshot). REVIEW the generated SQL — it MUST be additive (CREATE TABLE only, NO DROP/TRUNCATE of existing tables). Then run `pnpm --filter @familysync/api db:migrate` to apply. NEVER run `db:push` / `drizzle-kit push` — it emits a false destructive diff on populated MariaDB (hard project constraint). Build/type checks pass without the live migration, so this task is mandatory and must complete before any DB-dependent verification in later plans.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "mysqlTable('lists'" apps/api/src/db/schema.ts && grep -q "mysqlTable('list_shares'" apps/api/src/db/schema.ts && grep -q "mysqlTable('list_items'" apps/api/src/db/schema.ts && test -f apps/api/src/db/migrations/0002_lists_schema.sql && grep -iq 'CREATE TABLE' apps/api/src/db/migrations/0002_lists_schema.sql && ! grep -iE 'DROP TABLE `?(users|calendars|calendar_events|calendar_outbox|member_credentials)' apps/api/src/db/migrations/0002_lists_schema.sql && pnpm --filter @familysync/api typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Three tables present in schema.ts with the column/index/FK shapes above.
|
||||||
|
- 0002_lists_schema.sql exists, contains CREATE TABLE for lists/list_items/list_shares, and contains NO DROP/TRUNCATE of any pre-existing table.
|
||||||
|
- `db:migrate` applied successfully (migration recorded in drizzle journal).
|
||||||
|
- `pnpm --filter @familysync/api typecheck` passes.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>list/list_items/list_shares tables exist in MariaDB via additive generate+migrate; typecheck green; no push used.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 4: Add react-router + BottomTabBar + empty ListsIndex shell</name>
|
||||||
|
<files>apps/pwa/src/App.tsx, apps/pwa/src/components/BottomTabBar.tsx, apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/store/listsStore.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/App.tsx (current one-liner)
|
||||||
|
- apps/pwa/src/components/CalendarShell.tsx (state-branch + data-fetch conventions)
|
||||||
|
- apps/pwa/src/components/AppNav.tsx (nav/active-state + CSS token conventions)
|
||||||
|
- apps/pwa/src/store/calendarStore.ts (Zustand shape convention)
|
||||||
|
- apps/pwa/vite.config.ts (confirm navigateFallback already covers /lists/*)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"Layout: App Shell Changes", §"BottomTabBar", §"ListsIndex", §"ListsEmptyState"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/pwa/src/App.tsx", §"BottomTabBar.tsx", §"ListsIndex.tsx", §"listsStore.ts"
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Transform App.tsx into a BrowserRouter shell (react-router declarative mode, NO data router/loaders): routes `/` → Navigate replace to `/calendar`, `/calendar` → CalendarShell, `/lists` → ListsIndex, `/lists/:listId` → ListDetail. ListDetail does not exist yet — for this plan render a temporary placeholder route element (a stub component that says the list view is coming) so the route resolves; Plan 04 replaces it. Render BottomTabBar as a sibling of `<Routes>`.
|
||||||
|
|
||||||
|
Create BottomTabBar.tsx: fixed-bottom 56px + env(safe-area-inset-bottom), background var(--color-surface-dim), border-top var(--color-border), two equal NavLink tabs (CalendarDays→/calendar, List→/lists) with isActive callback applying accent var(--color-member-0) to icon+label and a 2px active indicator; inactive var(--color-text-muted); 13px label; ≥44px touch target; z-index 200. On desktop (≥768px) the existing AppNav sidebar remains; per UI-SPEC add a "Lists" NavLink there too (sidebar) — do this without breaking the existing AppNav signature.
|
||||||
|
|
||||||
|
Create ListsIndex.tsx: full-height scrollable column, "Lists" heading, useQuery(['lists'], fetchLists) where fetchLists is imported from a minimal listsClient (create only the fetchLists function + List type here if listsClient does not yet exist; Plan 03 expands it). Render ListsEmptyState ("No lists yet" / "Tap + to create your first shared list…") when there are zero lists; render a placeholder card stack otherwise. Wire isLoading/isError/success branches mirroring CalendarShell. Include the "+ New List" FAB affordance (non-functional placeholder is acceptable here; Plan 03 wires CreateListSheet).
|
||||||
|
|
||||||
|
Create listsStore.ts (Zustand, UI-only): activeTab and createListSheetOpen state with setters, following calendarStore conventions (no persist, no immer).
|
||||||
|
|
||||||
|
Confirm vite.config.ts navigateFallback ('/index.html') + denylist already cover SPA deep-links to /lists/* (it does per Phase 3 config) — if a denylist entry would block /lists, fix it; otherwise leave unchanged and note in SUMMARY.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec vitest run src/components/CalendarShell.test.tsx 2>&1 | grep -Eiq 'passed' && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "BrowserRouter" apps/pwa/src/App.tsx && grep -q "to=\"/lists\"" apps/pwa/src/components/BottomTabBar.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- App.tsx wraps the app in BrowserRouter with /calendar, /lists, /lists/:listId routes; existing CalendarShell still mounts at /calendar.
|
||||||
|
- BottomTabBar renders Calendar and Lists NavLinks with active-state accent and ≥44px targets.
|
||||||
|
- ListsIndex renders the empty state copy from UI-SPEC when no lists exist.
|
||||||
|
- listsStore exports activeTab/createListSheetOpen with setters (no server data).
|
||||||
|
- PWA typecheck passes; existing CalendarShell test still green.
|
||||||
|
- Browser check (project convention): `playwright-cli` navigates to /lists and observes the "No lists yet" empty state and the bottom tab bar with an active "Lists" tab. Record the observation in SUMMARY.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>User can tap the Lists tab and land on the empty Lists surface; calendar still works; router + tab bar in place.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → /api/* | All list/SSE requests cross here; untrusted client input |
|
||||||
|
| API → MariaDB | Drizzle parameterized queries only |
|
||||||
|
| drizzle-kit → MariaDB (migration) | DDL applied to a populated production-shaped DB |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-01 | Tampering | drizzle-kit push truncating populated tables | mitigate | generate+migrate ONLY; verify 0002 SQL has no DROP/TRUNCATE of existing tables before applying (Task 3 gate) |
|
||||||
|
| T-04-02 | Information Disclosure | scoped-fan-out leak (D-04) — foundational | mitigate | Schema models access via list_shares join table + owner_id (this plan); enforcement lands in Plans 02/03/06; negative test seeded in lists.test.ts here |
|
||||||
|
| T-04-SC | Tampering | npm installs (react-router SUS, dnd-kit, fractional-indexing) | mitigate | Legitimacy audit in RESEARCH; blocking human checkpoint (Task 1) for the SUS react-router before install |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @familysync/api typecheck` and `pnpm --filter @familysync/pwa exec tsc --noEmit` both pass.
|
||||||
|
- `pnpm --filter @familysync/api test` runs (Wave-0 stubs red/todo, not erroring).
|
||||||
|
- 0002_lists_schema.sql is additive; migration applied; three tables queryable.
|
||||||
|
- `playwright-cli` confirms /lists renders the empty state with the bottom tab bar.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- New deps installed (no ioredis); API test harness operational.
|
||||||
|
- Three list tables migrated additively (no push).
|
||||||
|
- Router + BottomTabBar live; Lists tab navigates to an empty Lists surface.
|
||||||
|
- Four Wave-0 RED stub test files exist and execute.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
**Symbols/files this plan creates (exclude from drift verification — they are new):**
|
||||||
|
- Tables: `lists`, `list_shares`, `list_items` (apps/api/src/db/schema.ts)
|
||||||
|
- Migration: `apps/api/src/db/migrations/0002_lists_schema.sql` (+ journal/snapshot)
|
||||||
|
- API test harness: `apps/api/test/setup.ts`; setupFiles wiring in `apps/api/vitest.config.ts`
|
||||||
|
- RED stub tests: `apps/api/src/routes/lists.test.ts`, `apps/api/src/lib/listEmitter.test.ts`, `apps/pwa/src/hooks/useListSSE.test.ts`, `apps/pwa/src/routes/ListDetail.test.tsx`
|
||||||
|
- Components: `BottomTabBar` (apps/pwa/src/components/BottomTabBar.tsx), `ListsIndex` (apps/pwa/src/routes/ListsIndex.tsx), temporary ListDetail placeholder route element
|
||||||
|
- Store: `useListsStore` (apps/pwa/src/store/listsStore.ts) with activeTab/createListSheetOpen
|
||||||
|
- App.tsx now exports a BrowserRouter-wrapped App + AppShell
|
||||||
|
- (Possibly) initial `apps/pwa/src/api/listsClient.ts` with `fetchLists` + `List` type
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/04-shared-lists-live-sync/04-01-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
phase: 04-shared-lists-live-sync
|
||||||
|
plan: 02
|
||||||
|
type: tdd
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["04-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/lib/listEmitter.ts
|
||||||
|
- apps/api/src/lib/listEmitter.test.ts
|
||||||
|
- apps/api/src/lib/listAccess.ts
|
||||||
|
- apps/api/src/lib/listAccess.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [LIST-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "An event published for a list is delivered only to subscribers of that list's channel"
|
||||||
|
- "A subscriber to list A receives no events published for list B"
|
||||||
|
- "getAccessibleListIds(userId) returns owned list ids plus list ids shared via list_shares, and nothing else"
|
||||||
|
- "Unsubscribing stops further delivery to that handler"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/lib/listEmitter.ts"
|
||||||
|
provides: "in-memory scoped pub/sub: publishListEvent, subscribeListEvents"
|
||||||
|
exports: ["publishListEvent", "subscribeListEvents", "ListEvent"]
|
||||||
|
- path: "apps/api/src/lib/listAccess.ts"
|
||||||
|
provides: "getAccessibleListIds(userId) access-scope query"
|
||||||
|
exports: ["getAccessibleListIds"]
|
||||||
|
- path: "apps/api/src/lib/listEmitter.test.ts"
|
||||||
|
provides: "scoped fan-out correctness tests (D-04)"
|
||||||
|
contains: "describe"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/lib/listEmitter.ts"
|
||||||
|
to: "node:events EventEmitter"
|
||||||
|
via: "module-level singleton keyed by list:${listId}"
|
||||||
|
pattern: "emit\\(`list:"
|
||||||
|
- from: "apps/api/src/lib/listAccess.ts"
|
||||||
|
to: "lists + list_shares tables"
|
||||||
|
via: "owner_id OR list_shares.user_id query"
|
||||||
|
pattern: "listShares"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build and test-first the load-bearing live-sync primitive: an in-memory, per-list-scoped event emitter (`listEmitter.ts`) plus the access-scope query (`listAccess.ts`) that together guarantee D-04 — a list's change events reach ONLY members with access to that list, never all connected clients and never non-shared members.
|
||||||
|
|
||||||
|
This is a dedicated TDD plan because it is pure, testable business logic (`expect(deliveredEvents).toEqual([...])`) and it is the single highest-correctness-risk seam in the phase (private-list leakage). The SSE endpoint (Plan 06) and the route fan-out triggers (Plans 03–06) consume these two functions.
|
||||||
|
|
||||||
|
Purpose: Get scoped fan-out provably correct in isolation before any SSE wiring, with the negative test ("private-list events NOT delivered to a non-owner") proven green.
|
||||||
|
Output: `publishListEvent`/`subscribeListEvents` (in-memory EventEmitter singleton) and `getAccessibleListIds(userId)`, both fully unit-tested.
|
||||||
|
|
||||||
|
**Fan-out mechanism justification (D-18):** In-memory EventEmitter, not Redis. The API runs as a single Node process (no replicas), so Redis pub/sub adds a network hop, an ioredis dependency, and operational overhead for zero benefit. D-18 (N-member / multi-process-agnostic design) is satisfied by the abstraction boundary: callers use `publishListEvent`/`subscribeListEvents` and never touch the EventEmitter directly, so a future Redis swap is mechanical inside `listEmitter.ts`. ioredis is intentionally NOT installed in Phase 4.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<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-VALIDATION.md
|
||||||
|
@apps/api/src/db/schema.ts
|
||||||
|
@apps/api/src/db/client.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<feature>
|
||||||
|
<name>Scoped in-memory list event fan-out + access-scope query (D-04)</name>
|
||||||
|
<files>
|
||||||
|
apps/api/src/lib/listEmitter.ts, apps/api/src/lib/listEmitter.test.ts,
|
||||||
|
apps/api/src/lib/listAccess.ts, apps/api/src/lib/listAccess.test.ts
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/lib/listEmitter.test.ts (RED stub from Plan 01 — convert to real assertions)
|
||||||
|
- apps/api/src/db/schema.ts (lists, listShares tables created in Plan 01)
|
||||||
|
- apps/api/src/routes/events.ts lines 1-110 (db query + drizzle and/or/eq conventions)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 1 + Finding 3 (verbatim patterns)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/lib/listEmitter.ts"
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
listEmitter (pure, no DB):
|
||||||
|
- Test 1 (RED first): publishListEvent(1, ev) delivers ev to a handler subscribed via subscribeListEvents(1, h); handler called exactly once with ev.
|
||||||
|
- Test 2 (the D-04 negative, critical): a handler subscribed to list 1 receives NOTHING when publishListEvent(2, ev) is called. This is the "private-list events NOT emitted to a non-owner subscriber" assertion from 04-VALIDATION.md.
|
||||||
|
- Test 3: the unsubscribe function returned by subscribeListEvents stops delivery — after calling it, a subsequent publish to that list does not invoke the handler.
|
||||||
|
- Test 4: multiple handlers on the same list channel all receive the event.
|
||||||
|
- ListEvent type union: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted', shape { type, listId, payload }.
|
||||||
|
|
||||||
|
listAccess (DB-backed, uses the test DB harness from Plan 01):
|
||||||
|
- Test 5: getAccessibleListIds returns ids of lists the user OWNS.
|
||||||
|
- Test 6: getAccessibleListIds returns ids of lists shared to the user via list_shares.
|
||||||
|
- Test 7 (D-04): getAccessibleListIds does NOT return another user's private (non-shared, non-owned) list id.
|
||||||
|
- Test 8: result has no duplicates when a list is both owned and (erroneously) shared.
|
||||||
|
</behavior>
|
||||||
|
<implementation>
|
||||||
|
listEmitter.ts: module-level `new EventEmitter()` with setMaxListeners(200); channel key `list:${listId}`; publishListEvent emits, subscribeListEvents registers on() and returns an off() closure. Use the RESEARCH Finding 1 pattern verbatim.
|
||||||
|
|
||||||
|
listAccess.ts: `getAccessibleListIds(userId: number): Promise<number[]>` — select lists.id where lists.ownerId = userId, union select listShares.listId where listShares.userId = userId, dedupe into a number[]. Use drizzle eq from the events.ts pattern. (Implementation choice: either two selects merged in JS per RESEARCH Finding 3, or a single OR query joined to list_shares — either is acceptable; the tests assert behavior, not query shape.)
|
||||||
|
|
||||||
|
Follow RED → GREEN → REFACTOR: write the failing tests first (convert the Plan 01 stub), confirm they fail, implement minimally to green, refactor only if obvious.
|
||||||
|
</implementation>
|
||||||
|
</feature>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| publisher (route handler) → subscriber (SSE stream) | A leak here exposes one member's private list to another |
|
||||||
|
| API → MariaDB | access-scope query must not over-return list ids |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-02 | Information Disclosure | scoped fan-out leak (D-04) — load-bearing | mitigate | Per-list channel keying (`list:${listId}`) + getAccessibleListIds scoped to owner_id OR list_shares; proven by Test 2 (cross-list isolation) and Test 7 (private list excluded) |
|
||||||
|
| T-04-03 | Information Disclosure | getAccessibleListIds over-returning ids | mitigate | Test 7 asserts a non-owned, non-shared list id is absent; Test 8 asserts dedupe |
|
||||||
|
| T-04-04 | Denial of Service | EventEmitter max-listeners warning under many SSE connections | accept | setMaxListeners(200) headroom (100 members × 2 devices); single-process scale is bounded for a household app |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/lib/listEmitter.test.ts src/lib/listAccess.test.ts</automated>
|
||||||
|
- Test 2 (cross-list isolation) and Test 7 (private list excluded) MUST be present and green.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- RED commit: failing listEmitter/listAccess tests (incl. the D-04 negative).
|
||||||
|
- GREEN commit: implementation passes all tests.
|
||||||
|
- REFACTOR commit (if any): tests still green.
|
||||||
|
- ioredis NOT introduced.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
**Symbols/files this plan creates (exclude from drift verification):**
|
||||||
|
- `apps/api/src/lib/listEmitter.ts` exporting `publishListEvent(listId, event)`, `subscribeListEvents(listId, handler): () => void`, type `ListEvent`
|
||||||
|
- `apps/api/src/lib/listAccess.ts` exporting `getAccessibleListIds(userId): Promise<number[]>`
|
||||||
|
- Tests: `apps/api/src/lib/listEmitter.test.ts`, `apps/api/src/lib/listAccess.test.ts`
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/04-shared-lists-live-sync/04-02-SUMMARY.md` with RED/GREEN/REFACTOR notes and commit list.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
phase: 04-shared-lists-live-sync
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["04-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/routes/lists.ts
|
||||||
|
- apps/api/src/routes/lists.test.ts
|
||||||
|
- apps/api/src/index.ts
|
||||||
|
- apps/pwa/src/api/listsClient.ts
|
||||||
|
- apps/pwa/src/routes/ListsIndex.tsx
|
||||||
|
- apps/pwa/src/components/ListCard.tsx
|
||||||
|
- apps/pwa/src/components/CreateListSheet.tsx
|
||||||
|
- apps/pwa/src/components/ListDeleteDialog.tsx
|
||||||
|
- apps/pwa/src/components/ListsEmptyState.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [LIST-01]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A member can create a named list and it appears in their lists"
|
||||||
|
- "A new shared list auto-populates list_shares rows for the other household members (D-01/D-02)"
|
||||||
|
- "GET /api/lists returns only lists the member owns or that are shared with them (D-04)"
|
||||||
|
- "A member can delete a list (with confirmation) and its items/shares cascade-delete (D-06)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/routes/lists.ts"
|
||||||
|
provides: "POST/GET/PATCH/DELETE /api/lists with scoped access + zod validation"
|
||||||
|
exports: ["listsRouter"]
|
||||||
|
- path: "apps/pwa/src/components/CreateListSheet.tsx"
|
||||||
|
provides: "new-list form with shared/private toggle (default shared)"
|
||||||
|
min_lines: 30
|
||||||
|
- path: "apps/pwa/src/components/ListCard.tsx"
|
||||||
|
provides: "list summary card navigating to /lists/:id"
|
||||||
|
min_lines: 25
|
||||||
|
- path: "apps/pwa/src/components/ListDeleteDialog.tsx"
|
||||||
|
provides: "list-delete confirmation (D-06)"
|
||||||
|
min_lines: 25
|
||||||
|
key_links:
|
||||||
|
- from: "apps/pwa/src/routes/ListsIndex.tsx"
|
||||||
|
to: "/api/lists"
|
||||||
|
via: "useQuery + useMutation in listsClient"
|
||||||
|
pattern: "fetchLists|createList"
|
||||||
|
- from: "apps/api/src/routes/lists.ts"
|
||||||
|
to: "list_shares"
|
||||||
|
via: "auto-insert shares on create + scoped GET"
|
||||||
|
pattern: "listShares"
|
||||||
|
- from: "apps/api/src/index.ts"
|
||||||
|
to: "listsRouter"
|
||||||
|
via: "app.route('/api/lists', listsRouter)"
|
||||||
|
pattern: "api/lists"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Deliver the list-CRUD vertical slice end to end (LIST-01): a member can create a named list (defaulting to Shared), see it in their list index, and delete it with confirmation. The slice spans UI (ListsIndex/ListCard/CreateListSheet/ListDeleteDialog) → API (POST/GET/PATCH/DELETE /api/lists) → DB (lists + list_shares), with server-enforced scoped access (D-04) so a member only ever sees their own and shared lists.
|
||||||
|
|
||||||
|
MVP slice: after this plan a real user can create and delete lists — a capability they did not have after Plan 01's empty shell.
|
||||||
|
|
||||||
|
Purpose: Establish the lists router (the analog every later list/item endpoint extends) with correct access control and the auto-share-on-create behavior, plus the lists-index UI.
|
||||||
|
Output: listsRouter mounted at /api/lists; ListsIndex wired to real data; CreateListSheet + ListCard + ListDeleteDialog; listsClient typed functions.
|
||||||
|
</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: Lists router — POST/GET/PATCH/DELETE /api/lists with scoped access (LIST-01, D-01/D-02/D-04/D-06)</name>
|
||||||
|
<files>apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts, apps/api/src/index.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/routes/events.ts (full — resolveUserId, zod schemas, handler/try-catch/401 conventions)
|
||||||
|
- apps/api/src/routes/lists.test.ts (RED stub from Plan 01)
|
||||||
|
- apps/api/src/index.ts (route mount order)
|
||||||
|
- apps/api/src/auth/user.ts (upsertUser, deriveDisplayName signatures)
|
||||||
|
- apps/api/src/db/schema.ts (lists, listShares, listItems, users)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/lists.ts" + §"Shared Patterns"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Open Questions" item 3 (auto-populate list_shares)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: POST /api/lists { name, isShared:true } inserts a lists row owned by the caller AND inserts list_shares rows for every other user (not the creator). (LIST-01, D-01, Open Question 3)
|
||||||
|
- Test: POST /api/lists { name, isShared:false } inserts the list with NO list_shares rows.
|
||||||
|
- Test: GET /api/lists returns lists where owner_id = caller OR caller is in list_shares; does NOT return another member's private list (D-04 security-critical).
|
||||||
|
- Test: GET /api/lists includes an item-count summary per list (active/done) for the card badge; assert the field is present.
|
||||||
|
- Test: DELETE /api/lists/:id by the owner removes the list and cascades items + shares; a non-owner/non-sharee gets 403; unknown id gets 404.
|
||||||
|
- Test: PATCH /api/lists/:id updates name and/or isShared by an authorized member; toggling isShared false→true (re)populates shares, true→false removes non-owner shares.
|
||||||
|
- Test: zod rejects name > 255 or empty.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create apps/api/src/routes/lists.ts exporting `listsRouter` (Hono). Copy the `resolveUserId` helper verbatim from events.ts (per project convention it is duplicated per router, not extracted). Apply the 401 guard + try/catch-503 conventions on every handler. Define zod schemas: createListSchema (name 1..255, isShared default true), patchListSchema (name?/isShared?, at least one).
|
||||||
|
|
||||||
|
Implement handlers: POST / (create list; if isShared, query users for all member ids except creator and insert list_shares rows — YAGNI auto-share per Open Question 3); GET / (scoped select: owner_id = caller OR id IN list_shares.userId = caller, returning id/name/isShared/ownerId + per-list item counts); PATCH /:id (authorized update of name/isShared, reconciling list_shares on visibility change); DELETE /:id (owner-only delete is the safe default; cascade handles items/shares). Verify list access with the ownership/share-check pattern from 04-PATTERNS before any mutation.
|
||||||
|
|
||||||
|
Mount in index.ts: `import { listsRouter }` and `app.route('/api/lists', listsRouter)` after the sseRouter mount (so it sits behind the OIDC/dev-bypass guard). Do NOT add fan-out emit calls here yet — Plan 06 adds publishListEvent triggers once the SSE endpoint exists (leave a commented seam, note it in SUMMARY). NOTE: per-field item PATCH and item endpoints are Plan 04; this plan is lists only.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && grep -q "app.route('/api/lists'" apps/api/src/index.ts && pnpm --filter @familysync/api typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lists.test.ts: all create/get/delete/patch/scope tests green, including the D-04 "private list of another member is NOT returned by GET /api/lists" assertion.
|
||||||
|
- Shared-create auto-inserts list_shares for other members; private-create inserts none.
|
||||||
|
- listsRouter mounted at /api/lists in index.ts; typecheck passes.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>POST/GET/PATCH/DELETE /api/lists work with server-enforced scoped access and auto-share-on-create; tests green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: ListsIndex wired to real data + ListCard + CreateListSheet + ListDeleteDialog (LIST-01, D-01/D-06)</name>
|
||||||
|
<files>apps/pwa/src/api/listsClient.ts, apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/components/ListCard.tsx, apps/pwa/src/components/CreateListSheet.tsx, apps/pwa/src/components/ListDeleteDialog.tsx, apps/pwa/src/components/ListsEmptyState.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/routes/ListsIndex.tsx (placeholder shell from Plan 01)
|
||||||
|
- apps/pwa/src/api/client.ts (credentials:'include' fetch convention)
|
||||||
|
- apps/pwa/src/api/listsClient.ts (fetchLists/List from Plan 01, if present)
|
||||||
|
- apps/pwa/src/components/DeleteConfirmationDialog.tsx (modal/focus-trap/CSS-token pattern to mirror)
|
||||||
|
- apps/pwa/src/store/listsStore.ts (createListSheetOpen)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"ListsIndex", §"ListCard", §"CreateListSheet", §"ListsEmptyState", §"Sharing Toggle", §"Copywriting Contract", §"List Delete"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"ListsIndex.tsx", §"listsClient.ts", §"DeleteConfirmationDialog reuse"
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Expand apps/pwa/src/api/listsClient.ts with credentials:'include' typed functions: fetchLists, createList({name,isShared}), patchList(id, {...}), deleteList(id), plus List/ListItem types (ListItem used by Plan 04). Follow the client.ts apiFetch wrapper convention.
|
||||||
|
|
||||||
|
Build CreateListSheet.tsx per UI-SPEC: bottom sheet (mobile) / centered modal (desktop), heading "New list", auto-focused name input (placeholder "e.g. Groceries"), Shared/Private toggle defaulting to Shared (D-01), "Create" button (accent var(--color-member-0), disabled while name empty, destructive border on blank-submit attempt), "Cancel". On create: useMutation(createList) with optimistic insert into ['lists'] + onError rollback + onSettled invalidate; close sheet on success. Open/close driven by listsStore.createListSheetOpen.
|
||||||
|
|
||||||
|
Build ListCard.tsx per UI-SPEC: rounded card, list name (heading), "N items / N active · M done" badge, "Shared" pill for shared lists (nothing for private), ChevronRight; whole card taps through to /lists/:id via react-router navigate/Link; swipe/long-press (phone) or hover X (desktop) reveals Delete which opens ListDeleteDialog. All user text as plain-text JSX (XSS guard).
|
||||||
|
|
||||||
|
Build ListDeleteDialog.tsx by mirroring DeleteConfirmationDialog structure (do NOT modify the existing one — it is wired to calendarStore): same modal layout, backdrop, role="dialog"/aria-modal, Escape-to-close, focus-on-open, CSS tokens; heading "Delete list?", body '"{name}" and all its items will be permanently removed.', Cancel + destructive Delete (D-06). On confirm: useMutation(deleteList) optimistic removal from ['lists'] + navigate back to /lists; failure toast "Couldn't delete. Try again."
|
||||||
|
|
||||||
|
Replace the ListsIndex placeholder card stack with real ListCard rendering from useQuery(['lists']); ListsEmptyState when zero lists; FAB ("+ New List") opens CreateListSheet.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa exec vitest run src/components/DeleteConfirmationDialog.test.tsx 2>&1 | grep -Eiq 'passed' && grep -q "createList" apps/pwa/src/api/listsClient.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- listsClient exports fetchLists/createList/patchList/deleteList + List/ListItem types.
|
||||||
|
- CreateListSheet defaults to Shared, disables Create on empty name, creates via optimistic mutation.
|
||||||
|
- ListCard shows name + count badge + "Shared" pill (shared only) and navigates to /lists/:id.
|
||||||
|
- ListDeleteDialog confirms before delete and does not modify DeleteConfirmationDialog.tsx.
|
||||||
|
- PWA typecheck passes; existing DeleteConfirmationDialog test still green.
|
||||||
|
- Browser check (`playwright-cli`): create a list named "Groceries" → it appears as a card with a "Shared" pill; open delete dialog → confirm → card disappears. Record in SUMMARY.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>User can create (shared by default) and delete named lists through the UI, backed by scoped API; counts and sharing badge render.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → /api/lists | client supplies name/isShared/list id — all untrusted |
|
||||||
|
| API → MariaDB | scoped queries enforce who can see/mutate a list |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-05 | Elevation of Privilege | accessing another member's private list via direct id (GET/DELETE/PATCH /api/lists/:id) | mitigate | Every handler resolves caller via resolveUserId and verifies owner_id OR list_shares before returning/mutating; 403 otherwise; tested |
|
||||||
|
| T-04-02 | Information Disclosure | GET /api/lists leaking non-shared lists | mitigate | Scoped WHERE owner_id = caller OR id IN list_shares; negative test asserts another member's private list is absent (D-04) |
|
||||||
|
| T-04-06 | Tampering | XSS via list name | mitigate | List names rendered as plain-text JSX children only; no dangerouslySetInnerHTML (T-03-15 pattern) |
|
||||||
|
| T-04-07 | Tampering | overposting on PATCH (fields beyond name/isShared) | mitigate | zod patchListSchema whitelists name/isShared only |
|
||||||
|
| T-04-08 | Elevation of Privilege | self-adding to list_shares | mitigate | Shares are server-managed only (auto-populated on create/visibility change); no client-writable shares endpoint exposed in Phase 4 |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||||
|
- `playwright-cli`: create + delete a list end to end.
|
||||||
|
- D-04 negative test green.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- LIST-01 satisfied: create + delete named lists end to end.
|
||||||
|
- Shared-by-default with server-managed list_shares; scoped GET enforced.
|
||||||
|
- listsRouter is the analog later item/SSE plans extend.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
**Symbols/files this plan creates (exclude from drift verification):**
|
||||||
|
- `apps/api/src/routes/lists.ts` exporting `listsRouter` (POST/GET/PATCH/DELETE /api/lists); local `resolveUserId` copy
|
||||||
|
- `app.route('/api/lists', listsRouter)` mount in apps/api/src/index.ts
|
||||||
|
- `apps/pwa/src/api/listsClient.ts`: `fetchLists`, `createList`, `patchList`, `deleteList`, types `List`, `ListItem`
|
||||||
|
- Components: `CreateListSheet`, `ListCard`, `ListDeleteDialog`, `ListsEmptyState`
|
||||||
|
- Real-data `ListsIndex` (replaces Plan 01 placeholder)
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/04-shared-lists-live-sync/04-03-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
phase: 04-shared-lists-live-sync
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: ["04-04"]
|
||||||
|
files_modified:
|
||||||
|
- apps/pwa/src/routes/ListDetail.tsx
|
||||||
|
- apps/pwa/src/components/ItemRow.tsx
|
||||||
|
- apps/pwa/src/api/listsClient.ts
|
||||||
|
- apps/api/src/lib/rank.test.ts
|
||||||
|
- apps/api/src/routes/lists.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [LIST-03]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A member can drag an active item to a new position and the order persists"
|
||||||
|
- "A reorder writes only the moved item's rank (one-row write), not a renumber"
|
||||||
|
- "Touch drag requires a deliberate long-press on the handle (no accidental drags while scrolling)"
|
||||||
|
- "A reorder arriving from another member animates to the new position rather than hard-snapping (D-14)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/pwa/src/components/ItemRow.tsx"
|
||||||
|
provides: "dnd-kit sortable item with drag handle"
|
||||||
|
contains: "useSortable"
|
||||||
|
- path: "apps/pwa/src/routes/ListDetail.tsx"
|
||||||
|
provides: "DndContext/SortableContext over active items with onDragEnd → rank PATCH"
|
||||||
|
contains: "DndContext"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/pwa/src/routes/ListDetail.tsx"
|
||||||
|
to: "PATCH /api/list-items/:id { position }"
|
||||||
|
via: "onDragEnd computes generateKeyBetween + optimistic patch"
|
||||||
|
pattern: "generateKeyBetween|position"
|
||||||
|
- from: "apps/pwa/src/components/ItemRow.tsx"
|
||||||
|
to: "@dnd-kit/sortable"
|
||||||
|
via: "useSortable handle listeners"
|
||||||
|
pattern: "useSortable"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Deliver the drag-to-reorder vertical slice (LIST-03): a member can drag an active item to a new position using @dnd-kit, and the move persists as a single-row fractional-rank write (D-13). Touch drag requires a 200ms long-press on the handle (no accidental drags); concurrent reorders converge via last-write-wins (D-15); and a reorder that arrives from another member animates to its new position rather than hard-snapping (D-14).
|
||||||
|
|
||||||
|
MVP slice: after this plan a real user can reorder list items — the last interactive capability of the lists surface — building directly on the items rendered in Plan 04.
|
||||||
|
|
||||||
|
Purpose: Layer drag-and-drop and client-side fractional-rank computation onto the existing ItemRow/ListDetail, reusing the server-side per-field position PATCH already built in Plan 04.
|
||||||
|
Output: dnd-kit DndContext/SortableContext in ListDetail; sortable ItemRow with handle-scoped listeners + sensors; client computes the new rank via generateKeyBetween and PATCHes position optimistically.
|
||||||
|
</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">
|
||||||
|
<name>Task 1: Sortable ItemRow + DndContext reorder with optimistic rank PATCH (LIST-03, D-13/D-14/D-15)</name>
|
||||||
|
<files>apps/pwa/src/components/ItemRow.tsx, apps/pwa/src/routes/ListDetail.tsx, apps/pwa/src/api/listsClient.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/components/ItemRow.tsx (from Plan 04 — add useSortable; handle slot already present)
|
||||||
|
- apps/pwa/src/routes/ListDetail.tsx (active-items rendering from Plan 04)
|
||||||
|
- apps/pwa/src/api/listsClient.ts (patchListItem supports { position })
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 7 (dnd-kit + handle + sensors + rank-on-drop)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"ItemRow.tsx"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"Drag-to-Reorder", §"Accessibility Baseline" (keyboard reorder)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Make ItemRow sortable: use useSortable({ id: item.id }) from @dnd-kit/sortable; attach setNodeRef + style (CSS.Transform.toString(transform), transition fallback 'transform 150ms ease-out' for D-14 remote animation, opacity 0.8 + slight scale-down when isDragging). Attach drag listeners to the GripVertical handle button ONLY (not the whole row) so taps on checkbox/text/delete still work. Drag handle only on active items (completed items not reorderable per UI-SPEC).
|
||||||
|
|
||||||
|
In ListDetail, wrap the active-items list in DndContext (collisionDetection={closestCenter}) + SortableContext (items = active item ids, verticalListSortingStrategy). Configure sensors via useSensors: PointerSensor/MouseSensor immediate, TouchSensor with activationConstraint { delay: 200, tolerance: 5 } (no accidental drags), and KeyboardSensor for the accessibility keyboard-reorder fallback.
|
||||||
|
|
||||||
|
onDragEnd: ignore no-op (no over / same id). Compute the destination index after the move; derive prevRank/nextRank from the active list at the destination and compute newRank = generateKeyBetween(prevRank, nextRank) (fractional-indexing). Fire an optimistic reorder mutation: setQueryData(['list', listId]) to reflect the new order immediately (snap), then patchListItem(itemId, { position: newRank }); onError animate back / rollback to previous; onSettled invalidate. Only the moved item's rank is written (one-row PATCH — D-13). Concurrent same-item reorder converges by server LWW on updatedAt (D-15) — no drag-state broadcasting.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "useSortable" apps/pwa/src/components/ItemRow.tsx && grep -q "DndContext" apps/pwa/src/routes/ListDetail.tsx && grep -q "generateKeyBetween" apps/pwa/src/routes/ListDetail.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- ItemRow uses useSortable with listeners on the handle only; completed items have no handle.
|
||||||
|
- ListDetail wraps active items in DndContext/SortableContext with Pointer/Touch(delay 200)/Keyboard sensors.
|
||||||
|
- onDragEnd computes newRank via generateKeyBetween and issues a single-item position PATCH optimistically with rollback.
|
||||||
|
- PWA typecheck passes.
|
||||||
|
- Browser check (`playwright-cli`): drag an item to a new position; the new order persists after a reload (rank written). Record in SUMMARY. (Touch long-press + keyboard reorder are dnd-kit built-ins; note manual/device coverage where playwright cannot simulate long-press reliably.)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>User can drag-reorder active items; move persists as a one-row rank write; remote reorders animate.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Strengthen server-side reorder ordering tests (LIST-03, D-13)</name>
|
||||||
|
<files>apps/api/src/lib/rank.test.ts, apps/api/src/routes/lists.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/lib/rank.test.ts (from Plan 04)
|
||||||
|
- apps/api/src/routes/lists.test.ts (PATCH position coverage)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-VALIDATION.md (LIST-03 row: "PATCH new rank produces correct fractional order")
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Common Pitfalls" Pitfall 2 (precision)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Add server-side tests proving reorder correctness: (a) repeated mid-point inserts via rankBetween produce strictly increasing distinct strings over many iterations (precision does not collapse — Pitfall 2); (b) PATCH /api/list-items/:id { position } updates only rank and a subsequent GET returns items in the new ASC order; (c) moving an item between two neighbors yields a rank strictly between theirs. These align the LIST-03 row in 04-VALIDATION.md to a green automated check. No production behavior change — Plan 04 already implements the PATCH position path.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/lib/rank.test.ts src/routes/lists.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- LIST-03 ordering test ("PATCH new rank produces correct fractional order") is present and green.
|
||||||
|
- Mid-point-insert precision test passes for many iterations.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Server-side reorder ordering + rank precision are covered by green automated tests.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → PATCH /api/list-items/:id { position } | client supplies the new rank string — untrusted |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-07 | Tampering | client sending position alongside other fields | mitigate | zod patchItemSchema refine (exactly one field) already enforces position-only PATCH (Plan 04); reasserted by tests |
|
||||||
|
| T-04-05 | Elevation of Privilege | reordering items in an inaccessible list | mitigate | PATCH list-items access-gated (owner OR list_shares) from Plan 04 |
|
||||||
|
| T-04-10 | Denial of Service | pathological "zipper" inserts growing rank strings | accept | VARCHAR(255) headroom; fractional-indexing degrades gracefully; rebalance available via generateNKeysBetween if ever needed (not in scope) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/lib/rank.test.ts src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||||
|
- `playwright-cli`: drag-reorder persists across reload.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- LIST-03 satisfied: drag-to-reorder works, persists as a single-row rank write.
|
||||||
|
- Touch long-press + keyboard reorder available; remote reorders animate (D-14).
|
||||||
|
- Reorder ordering + precision covered by automated tests.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
**Symbols/files this plan creates (exclude from drift verification):**
|
||||||
|
- ItemRow gains useSortable + handle-scoped drag listeners
|
||||||
|
- ListDetail gains DndContext/SortableContext + useSensors + onDragEnd rank computation
|
||||||
|
- Additional rank/order tests in rank.test.ts and lists.test.ts (no new production endpoints — reuses Plan 04 PATCH position)
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/04-shared-lists-live-sync/04-05-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
phase: 04-shared-lists-live-sync
|
||||||
|
plan: 06
|
||||||
|
type: execute
|
||||||
|
wave: 5
|
||||||
|
depends_on: ["04-02", "04-04", "04-05"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/routes/sse.ts
|
||||||
|
- apps/api/src/routes/lists.ts
|
||||||
|
- apps/api/src/routes/lists.test.ts
|
||||||
|
- apps/pwa/src/hooks/useListSSE.ts
|
||||||
|
- apps/pwa/src/hooks/useListSSE.test.ts
|
||||||
|
- apps/pwa/src/components/LiveSyncIndicator.tsx
|
||||||
|
- apps/pwa/src/routes/ListDetail.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [LIST-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "When one member adds/checks/deletes/reorders an item, the other member's open list updates within seconds without a manual refresh"
|
||||||
|
- "A private list's events are never delivered to a member who is not its owner (D-04)"
|
||||||
|
- "On SSE reconnect the client full-refetches the affected list (D-10)"
|
||||||
|
- "After capped backoff is exhausted, the UI shows an 'Updates paused' indicator and stops hammering (D-11); polling keeps data fresh (D-12)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/routes/sse.ts"
|
||||||
|
provides: "GET /api/sse/lists scoped SSE stream"
|
||||||
|
contains: "/lists"
|
||||||
|
- path: "apps/pwa/src/hooks/useListSSE.ts"
|
||||||
|
provides: "bounded-backoff EventSource wrapper invalidating React Query"
|
||||||
|
exports: ["useListSSE"]
|
||||||
|
- path: "apps/pwa/src/components/LiveSyncIndicator.tsx"
|
||||||
|
provides: "connected/reconnecting/disconnected indicator"
|
||||||
|
min_lines: 20
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/routes/lists.ts"
|
||||||
|
to: "publishListEvent"
|
||||||
|
via: "fan-out trigger after every successful item/list write"
|
||||||
|
pattern: "publishListEvent"
|
||||||
|
- from: "apps/api/src/routes/sse.ts"
|
||||||
|
to: "subscribeListEvents + getAccessibleListIds"
|
||||||
|
via: "scoped per-list subscription inside streamSSE"
|
||||||
|
pattern: "subscribeListEvents|getAccessibleListIds"
|
||||||
|
- from: "apps/pwa/src/hooks/useListSSE.ts"
|
||||||
|
to: "/api/sse/lists"
|
||||||
|
via: "EventSource(withCredentials) → invalidateQueries"
|
||||||
|
pattern: "EventSource"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Deliver the live-sync vertical slice (LIST-04, success criterion 3): wire the scoped SSE endpoint (`GET /api/sse/lists`), emit fan-out events from every list/item write (consuming the Plan 02 emitter), and add the bounded-backoff EventSource client hook + LiveSyncIndicator so one member's edits appear for the other within seconds — surviving a brief reconnect — without leaking private-list events (D-04).
|
||||||
|
|
||||||
|
MVP slice: this is the final capability that makes the lists "shared and live" rather than single-user. All CRUD/reorder built in Plans 03–05 becomes collaborative.
|
||||||
|
|
||||||
|
Purpose: Connect the proven scoped fan-out primitive (Plan 02) to real route writes and to a robust client (bounded backoff per D-11, full-refetch-on-reconnect per D-10, polling fallback per D-12), and prove the load-bearing no-leak invariant at the HTTP/route layer.
|
||||||
|
Output: /api/sse/lists endpoint; publishListEvent triggers in lists.ts; useListSSE hook; LiveSyncIndicator; ListDetail consumes the hook and renders the indicator.
|
||||||
|
</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
|
||||||
|
@apps/api/src/routes/sse.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Scoped /api/sse/lists endpoint + fan-out triggers on every write (LIST-04, D-04/D-10)</name>
|
||||||
|
<files>apps/api/src/routes/sse.ts, apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/routes/sse.ts (existing /heartbeat streamSSE pattern — extend)
|
||||||
|
- apps/api/src/routes/lists.ts (item/list write handlers from Plans 03–04 — add emit seams)
|
||||||
|
- apps/api/src/lib/listEmitter.ts (publishListEvent, subscribeListEvents — Plan 02)
|
||||||
|
- apps/api/src/lib/listAccess.ts (getAccessibleListIds — Plan 02)
|
||||||
|
- apps/api/src/routes/lists.test.ts (LIST-04 stub incl. private-list no-leak)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/sse.ts" (the /lists endpoint pattern verbatim)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 1 + Finding 3
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: a successful POST item / PATCH item / DELETE item / list:update / list:delete causes publishListEvent to fire with the matching ListEvent type for that listId. (LIST-04)
|
||||||
|
- Test (D-04, load-bearing): an event published for member A's PRIVATE list is NOT delivered to member B's /api/sse/lists subscription — B's accessible-list set (getAccessibleListIds) excludes it, so B never subscribes to that channel. This is the "private-list events NOT emitted to a non-owner subscriber" assertion in 04-VALIDATION.md, asserted at the route/subscription layer (Plan 02 proved it at the emitter layer).
|
||||||
|
- Test: a member subscribed via /api/sse/lists DOES receive events for a list shared with them.
|
||||||
|
- Test: the endpoint returns 401 when unauthenticated.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Extend sseRouter (sse.ts) with `GET /lists` following the 04-PATTERNS pattern: resolveUserId → 401 on null; const accessibleListIds = await getAccessibleListIds(userId); inside streamSSE, for each accessible listId call subscribeListEvents(listId, handler) where the handler writes an SSE event (event: event.type, data: JSON.stringify(event)) when !stream.aborted; run a 30s heartbeat loop; on exit call every unsubscribe. (resolveUserId: reuse the lists.ts copy or import a shared helper consistently — match the existing duplication convention.)
|
||||||
|
|
||||||
|
Add publishListEvent fan-out triggers in lists.ts after every successful write (the seams left in Plans 03–04): item:added after POST item, item:updated after PATCH item, item:deleted after DELETE item, list:updated after PATCH list, list:deleted after DELETE list. Each carries { type, listId, payload } with the minimal payload needed; the client uses events only to trigger invalidate/refetch (D-10), so payload need not be the full row.
|
||||||
|
|
||||||
|
Mount: /api/sse/lists is already under /api/sse (sseRouter mounted in index.ts) — no index.ts change needed beyond what exists. Confirm it sits behind the OIDC/dev-bypass guard.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && grep -q "publishListEvent" apps/api/src/routes/lists.ts && grep -q "/lists" apps/api/src/routes/sse.ts && pnpm --filter @familysync/api typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- GET /api/sse/lists subscribes only to getAccessibleListIds channels; 401 when unauthenticated.
|
||||||
|
- Every list/item write emits the correct ListEvent via publishListEvent.
|
||||||
|
- The D-04 route-layer no-leak test (private list of member A not delivered to member B) is present and green.
|
||||||
|
- typecheck passes.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Scoped SSE stream live; writes fan out to accessible subscribers only; no-leak invariant proven at the route layer.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: useListSSE bounded-backoff hook + LiveSyncIndicator + ListDetail wiring (LIST-04, D-10/D-11/D-12)</name>
|
||||||
|
<files>apps/pwa/src/hooks/useListSSE.ts, apps/pwa/src/hooks/useListSSE.test.ts, apps/pwa/src/components/LiveSyncIndicator.tsx, apps/pwa/src/routes/ListDetail.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/hooks/useListSSE.test.ts (D-11 bounded-backoff RED stub from Plan 01)
|
||||||
|
- apps/pwa/src/routes/ListDetail.tsx (already has refetchInterval:30000 from Plan 04)
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 4 (EventSource wrapper verbatim) + Pitfall 3 + Pitfall 7
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"useListSSE.ts"
|
||||||
|
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"LiveSyncIndicator", §"Live Sync + Reconnect"
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test (D-11): with a mocked EventSource that always errors, the hook retries on the backoff schedule 250→500→1000→2000→4000→cap 8000ms and, after the capped attempts are exhausted (≥6), transitions to 'disconnected' and STOPS scheduling further reconnects.
|
||||||
|
- Test: on a successful (mocked) open, the hook resets the attempt counter, reports 'connected', and invalidates ['list', listId] (full refetch on reconnect, D-10).
|
||||||
|
- Test: on a received list-change event, the hook invalidates ['list', listId].
|
||||||
|
- Test: the hook closes the EventSource and clears timers on unmount (no reconnect storm — Pitfall 3).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create apps/pwa/src/hooks/useListSSE.ts using the RESEARCH Finding 4 pattern verbatim: refs for the EventSource/attempt-count/timer (not state), connect() in useCallback, BACKOFF_STEPS_MS=[250,500,1000,2000,4000,8000], MAX_ATTEMPTS=length; new EventSource('/api/sse/lists',{withCredentials:true}); on open → reset attempts, onStateChange('connected'), invalidateQueries(['list',listId]); on each list-change event type → invalidateQueries(['list',listId]); on error → es.close(), if attempts≥MAX → onStateChange('disconnected') and stop, else onStateChange('reconnecting') and setTimeout(connect, backoff[attempt++]); cleanup closes es + clears timer on unmount. Convert the Plan 01 stub into these real assertions (mock EventSource).
|
||||||
|
|
||||||
|
Create LiveSyncIndicator.tsx per UI-SPEC: connected = 8px green dot (var(--color-member-1)), reconnecting = pulsing muted dot + "Reconnecting…", disconnected = red dot + "Updates paused"; role="status" with the aria-labels from UI-SPEC; role="alert" for the disconnected state.
|
||||||
|
|
||||||
|
Wire into ListDetail: call useListSSE({ listId, onStateChange: setSyncState }) and render LiveSyncIndicator in the header. Keep refetchInterval:30000 as the always-on polling fallback (D-12) so data stays fresh even when SSE is 'disconnected'. (Consider hoisting the single SSE connection so it does not reconnect on every list navigation — acceptable to keep it in ListDetail for Phase 4 per RESEARCH note; document the choice.)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "useListSSE" apps/pwa/src/routes/ListDetail.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- useListSSE.test.ts: bounded-backoff exhaustion test (D-11) and reconnect-invalidate test (D-10) are real and green.
|
||||||
|
- Hook uses withCredentials:true and closes EventSource on error before scheduling retry (no storm).
|
||||||
|
- LiveSyncIndicator renders connected/reconnecting/disconnected with correct ARIA.
|
||||||
|
- ListDetail consumes the hook + renders the indicator; refetchInterval polling fallback retained.
|
||||||
|
- PWA typecheck passes.
|
||||||
|
- Browser check (`playwright-cli`, two contexts where feasible): in context A add an item; context B's open list reflects it within a few seconds without manual refresh. Record in SUMMARY. (Cross-device/iOS-standalone live co-edit remains a device-only manual check per 04-VALIDATION.md.)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Live co-edit works: one member's edits appear for the other within seconds, with bounded reconnect + visible paused state + polling fallback.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| API publisher → SSE subscribers | the load-bearing leak boundary (D-04) |
|
||||||
|
| browser EventSource → /api/sse/lists | session cookie must cross (withCredentials); endpoint behind OIDC |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-02 | Information Disclosure | scoped-fan-out leak (D-04) — load-bearing | mitigate | /api/sse/lists subscribes ONLY to getAccessibleListIds channels; route-layer test asserts member B never receives member A's private-list events |
|
||||||
|
| T-04-01 | Spoofing/AuthZ | unauthenticated SSE subscription | mitigate | resolveUserId → 401; endpoint behind OIDC middleware; EventSource sends session cookie via withCredentials (Pitfall 7) |
|
||||||
|
| T-04-11 | Denial of Service | EventSource reconnect storm | mitigate | es.close() on error + manual bounded-backoff setTimeout; give-up after MAX_ATTEMPTS (Pitfall 3) |
|
||||||
|
| T-04-12 | Information Disclosure | over-broad event payload exposing other lists' data | mitigate | Payload carries only { type, listId, minimal } and is per-list-channel scoped; client uses it solely to trigger invalidate/refetch (D-10) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||||
|
- `playwright-cli` two-context live-update check.
|
||||||
|
- D-04 route-layer no-leak test green.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- LIST-04 satisfied: live co-edit within seconds, surviving a brief reconnect.
|
||||||
|
- D-04 no-leak proven at both emitter (Plan 02) and route (this plan) layers.
|
||||||
|
- D-10 full-refetch-on-reconnect, D-11 bounded backoff + paused indicator, D-12 polling fallback all in place.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
**Symbols/files this plan creates (exclude from drift verification):**
|
||||||
|
- `GET /api/sse/lists` endpoint on sseRouter (apps/api/src/routes/sse.ts)
|
||||||
|
- `publishListEvent(...)` fan-out triggers in apps/api/src/routes/lists.ts (item:added/updated/deleted, list:updated/deleted)
|
||||||
|
- `apps/pwa/src/hooks/useListSSE.ts` exporting `useListSSE` (bounded-backoff EventSource wrapper)
|
||||||
|
- `apps/pwa/src/components/LiveSyncIndicator.tsx`
|
||||||
|
- ListDetail wiring of useListSSE + LiveSyncIndicator
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/04-shared-lists-live-sync/04-06-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user