docs(04): capture phase context

This commit is contained in:
Lucas Berger
2026-06-07 21:32:09 -04:00
parent 35871cd8cf
commit 05e1c9e556
2 changed files with 253 additions and 0 deletions
@@ -0,0 +1,131 @@
# Phase 4: Shared Lists + Live Sync - Context
**Gathered:** 2026-06-07
**Status:** Ready for planning
<domain>
## 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.
</domain>
<decisions>
## 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.
</decisions>
<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>
<specifics>
## 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.
</specifics>
<deferred>
## 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.
</deferred>
---
*Phase: 4-Shared Lists + Live Sync*
*Context gathered: 2026-06-07*