Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
12 KiB
Phase 4: Shared Lists + Live Sync - Context
Gathered: 2026-06-07 Status: Ready for planning
## Phase BoundaryDeliver app-native shared lists (stored in MariaDB, NOT CalDAV/Fastmail) with real-time co-edit sync:
- Create and delete named lists (LIST-01)
- Add, check off, and delete items (LIST-02)
- Reorder items by drag-and-drop (LIST-03)
- Live co-edit sync over SSE — one member's change appears for the other within seconds, surviving a brief reconnect (LIST-04, success criterion 3)
Lists are entirely app-owned data — no CalDAV write-back, no Fastmail involvement. This is the one track independent of the calendar write path.
⚠️ ENTRY GATE (D-14, issue #1034 — STILL UNVERIFIED as of 2026-06-07): The 5-minute SSE-over-Pangolin smoke test must PASS before the live-sync layer is built (/api/sse/heartbeat held open 5+ min through the tunnel without being cut — see docs/deployment.md Gate 2 row 5). This is an operator/infra task requiring the Authelia+Pangolin/Newt rig. If it FAILS: fix Pangolin idle-timeout/buffering, OR the polling fallback (decided below) becomes mandatory rather than optional. Do not build live sync on an unverified transport.
Sharing model (List & item behavior)
- D-01: Lists support shared and private visibility. New lists default to Shared (visible+editable by both members); creator can toggle a single list to Private. Default-shared chosen deliberately — the grocery/family-hub use case is collaborative and default-private would add friction to the primary action.
- D-02: Data model is a
list_sharesjoin table (list has anowner; join table records who each list is shared with) — NOT a simple boolean. v1 UI is only shared/private, but the schema must be member-count-agnostic so granular N-recipient sharing is a future UI addition, not a migration. - D-03: A private list still live-syncs across its owner's own devices (phone + tablet); it is never pushed to other members.
- D-04: SSE fan-out MUST be scoped to who can see a list. A list's change events broadcast only to members with access (owner + shares), never to all connected clients. This is the load-bearing consequence of the sharing model — get it right or private lists leak.
Item behavior
- D-05: Checked-off items sink to a "completed" section at the bottom (active items stay on top). Not strikethrough-in-place, not immediate-disappear — keeps the active list clean for groceries while preserving "what was done."
- D-06: Confirm-on-delete for whole lists only. Individual items delete instantly (live sync makes mistakes visible; easy to re-add). Reuse Phase 3's
DeleteConfirmationDialogcomponent for the list-delete dialog.
Live feel & conflict resolution
- D-07: Optimistic UI — the editing member's change shows instantly, then reconciles against the server (rollback on rejection). Fits the low-friction constraint. Use React Query optimistic updates.
- D-08: Per-field writes + per-field last-write-wins ("field-level merge", BOUNDED — no CRDT). The API PATCHes only the changed field (
checked,text, orposition), not the whole row; the server applies last-write-wins per field on a server timestamp. Result: "one toggles checked while the other edits text" → both stick. Same-field collisions fall back to last-write-wins. Do NOT build CRDTs or per-field vector clocks. - D-09: Delete-wins — if one member deletes an item while the other edits it, deletion is final; the in-flight edit is dropped (editor sees it vanish via live sync). Edits never resurrect deleted items.
Reconnect & transport (success criterion 3)
- D-10: Full refetch on reconnect — on SSE reconnect, React Query invalidates and refetches the affected list(s) fresh. No server-side event log / Last-Event-ID replay. Lists are tiny so refetch is cheap and guaranteed-correct.
- D-11: Silent auto-recover with capped backoff, then a visible indicator. Reconnect silently with bounded (capped exponential) backoff; after backoff is exhausted, surface a visible "disconnected / updates paused" indicator and stop hammering. NOTE for planner: raw
EventSourceauto-reconnects forever with no backoff control — implementing bounded backoff + a give-up indicator requires wrappingEventSourcein a manual reconnect loop or using a small SSE client lib. - D-12: Polling fallback via React Query
refetchIntervalif SSE is unavailable/flaky through Pangolin. Already have React Query; trivial to add. Guarantees criterion 3 even if the tunnel misbehaves. (Mandatory if the entry-gate smoke test fails.)
Reordering (LIST-03)
- D-13: String-based fractional rank for item positions (e.g., the
fractional-indexingapproach) — NOT raw floats (precision exhausts fast on repeated mid-point inserts) and NOT integer-renumber (a single move rewrites many rows, noisy over SSE). A move rewrites only the moved item's rank — one-row write, plays well with live sync and concurrent reorders. - D-14: Animate to new order when a remote reorder arrives (smooth transition, matches the live-sync promise).
- D-15: Last-write-wins with brief settle on concurrent reorder of the same item — both see their local drag instantly (optimistic), server resolves to the last write, both converge within ~1s. No drag-locking / drag-state broadcasting.
Navigation / app shell
- D-16: Bottom tab bar (Calendar | Lists) — thumb-reachable, matches native iOS/Android, low-friction for the non-technical member. Currently
App.tsxrendersCalendarShelldirectly with no nav. - D-17: Add react-router for real URLs (e.g.
/lists/:id). No router is installed today. Real URLs enable Phase 5 push deep-linking ("tap to open Groceries"), browser back button, and PWA shortcuts. Small dependency that pays off next phase.
Project-level principle (applies beyond this phase)
- D-18: Design for N family members, not hard-coded two. Schema, auth/access checks, and SSE fan-out must be member-count-agnostic. Same philosophy as treating Fastmail as a generic provider — set the framework now for future expansion to more family members. The
list_sharestable (D-02) and scoped fan-out (D-04) are the first applications.
Claude's Discretion (deferred to research/planner)
- Fan-out mechanism: in-memory EventEmitter vs Redis pub/sub. API runs as a single Node process today (no replicas), so in-memory is the YAGNI default; Redis is in docker-compose but
ioredisis NOT installed. Planner must address this explicitly and justify the choice against D-18 (multi-process future). - Exact position-rank datatype/column, SSE auth/middleware wiring, and React Query cache-key structure.
Reviewed Todos
- Adopt drizzle generate+migrate workflow (retire
db:pushon MariaDB) — directly relevant: Phase 4 adds new tables (lists,list_items,list_shares).drizzle-kit pushis unsafe on populated MariaDB (emits false destructive diff — see memory). New tables MUST usedrizzle-kit generate+migrate, notpush. Folded as a hard constraint on this phase's schema work.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Entry gate & transport
docs/deployment.md§"SSE idle timeout (Phase 4 dependency, issue #1034)" and §"Gate 2 — Live verification checklist" row 5 — the SSE-over-Pangolin smoke-test procedure that is this phase's entry gateapps/api/src/routes/sse.ts— existing/api/sse/heartbeatSSE pattern (HonostreamSSE,stream.abortedloop); the live-list SSE endpoint(s) build on this
Prior decisions & requirements
.planning/ROADMAP.md§"Phase 4: Shared Lists + Live Sync" — goal, success criteria, entry gate.planning/REQUIREMENTS.md— LIST-01 through LIST-04.planning/STATE.md§Decisions — D-14 (SSE-over-WebSocket choice, entry gate), real-time transport notes.planning/phases/01-foundation-broker-spike/01-CONTEXT.md§D-08 — why the SSE smoke test was folded into Phase 1 to de-risk Phase 4 transport
Schema & code patterns
apps/api/src/db/schema.ts— Drizzle table conventions (mysqlTable, indexes, unique keys,references/onDelete); model new list tables on theseapps/pwa/src/components/DeleteConfirmationDialog.tsx— reuse for list-delete confirmation (D-06)apps/pwa/src/App.tsx/apps/pwa/src/components/CalendarShell.tsx— current shell with no router; tab-bar + react-router (D-16/D-17) wrap this
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
apps/api/src/routes/sse.ts— working HonostreamSSEheartbeat; the live-list event stream extends this pattern (auth via existing/api/*middleware).apps/pwa/src/components/DeleteConfirmationDialog.tsx— Phase 3 confirmation dialog, reuse for list delete.- React Query + Zustand already established (CLAUDE.md split: React Query = server state, Zustand = UI-only state). Optimistic updates (D-07) and polling fallback (D-12) use React Query; tab/route UI state is Zustand-adjacent.
- CSS token layer + colorUtils from Phase 2 available for list theming.
Established Patterns
/api/*routes sit behind OIDC middleware (or dev-auth bypass) — list routes inherit this; identity resolved tousers.idvia oidc iss+sub (D-10 from prior phases).- Drizzle schema conventions in
schema.ts: int autoincrement PKs,references(() => x.id, { onDelete: 'cascade' }), composite unique keys, named indexes. - Schema migrations: generate+migrate, never
pushon MariaDB (see Reviewed Todos).
Integration Points
- New
/api/lists(+ items + SSE) routes mount inapps/api/src/index.tsalongsideeventsRouter,sseRouter. - New
lists/list_items/list_sharestables inapps/api/src/db/schema.ts. - PWA gains a router + bottom tab bar in
App.tsx; Lists surface is a sibling ofCalendarShell. - SSE fan-out must integrate with the (TBD) in-memory-vs-Redis pub/sub decision;
redisservice exists in docker-compose,ioredisnot yet a dependency.
</code_context>
## Specific Ideas- "Sink to bottom" for checked items modeled on a clean active-list / completed-section split (grocery-list mental model).
- Sharing UI vision (future): pick specific recipients from the user DB; v1 collapses this to shared/private but the
list_sharesmodel preserves the path. - Backoff-then-pause reconnect UX: "set backoff and then display an indicator to pause more updates" — i.e., don't retry forever silently; tell the user when data may be stale.
- Anonymous list sharing via a unique public URL (share a list with a non-member through a link) — NEW CAPABILITY, its own phase. Introduces unauthenticated access that bypasses the Authelia OIDC model (every
/api/*route is currently authenticated), plus link-token generation, revocation, and abuse handling. Explicitly out of scope for Phase 4; revisit as a dedicated "external/guest sharing" phase. - Granular per-recipient sharing UI (a member picker) — the
list_sharesdata model (D-02) supports it, but no picker UI in v1 (only two members; "shared" == shared with the other person). Becomes relevant once the household has 3+ members (D-18). - List metadata (icons, per-list colors, max items) — not raised as required; standard approaches fine unless a future UI phase wants them.
Phase: 4-Shared Lists + Live Sync Context gathered: 2026-06-07