Files
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

12 KiB

Phase 4: Shared Lists + Live Sync - Context

Gathered: 2026-06-07 Status: Ready for planning

## Phase Boundary

Deliver 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.

## Implementation Decisions

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_shares join table (list has an owner; 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 DeleteConfirmationDialog component 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, or position), 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 EventSource auto-reconnects forever with no backoff control — implementing bounded backoff + a give-up indicator requires wrapping EventSource in a manual reconnect loop or using a small SSE client lib.
  • D-12: Polling fallback via React Query refetchInterval if 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-indexing approach) — 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.tsx renders CalendarShell directly 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_shares table (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 ioredis is 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:push on MariaDB) — directly relevant: Phase 4 adds new tables (lists, list_items, list_shares). drizzle-kit push is unsafe on populated MariaDB (emits false destructive diff — see memory). New tables MUST use drizzle-kit generate + migrate, not push. 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 gate
  • apps/api/src/routes/sse.ts — existing /api/sse/heartbeat SSE pattern (Hono streamSSE, stream.aborted loop); 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 these
  • apps/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 Hono streamSSE heartbeat; 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 to users.id via 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 push on MariaDB (see Reviewed Todos).

Integration Points

  • New /api/lists (+ items + SSE) routes mount in apps/api/src/index.ts alongside eventsRouter, sseRouter.
  • New lists / list_items / list_shares tables in apps/api/src/db/schema.ts.
  • PWA gains a router + bottom tab bar in App.tsx; Lists surface is a sibling of CalendarShell.
  • SSE fan-out must integrate with the (TBD) in-memory-vs-Redis pub/sub decision; redis service exists in docker-compose, ioredis not 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_shares model 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.
## Deferred Ideas
  • 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_shares data 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