diff --git a/.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md b/.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md new file mode 100644 index 0000000..9255c79 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md @@ -0,0 +1,205 @@ +# Phase 3: Event Write-Back + PWA Install - Context + +**Gathered:** 2026-06-05 +**Status:** Ready for planning + + +## Phase Boundary + +Members can **create, edit, and delete events** that are written back to the correct +Fastmail calendar through the existing CalDAV broker boundary (PUT / DELETE via tsdav — +never a direct Fastmail call from the UI). The app becomes an **installable PWA** +(web manifest + service worker, HTTPS) with a **guided iOS Add-to-Home-Screen walkthrough** +and Android install handling. This phase also carries the **Phase 1 Gate 2 live-auth +verification** (D-14): real Authelia OIDC login over the public Pangolin URL including the +**iOS standalone-PWA** flow, session persistence, and distinct stable per-member colors. + +Requirements: CAL-04 (create timed/all-day), CAL-05 (edit), CAL-06 (delete), +CAL-07 (create recurring — whole-series only), PWA-01 (installable), PWA-02 (guided install). + +**Out of scope (other phases / later):** +- Single-occurrence and "this-and-following" recurring edits (CAL-09 / CAL-10) — **v1.x**. +- Shared lists + live SSE sync (Phase 4); Web Push notifications (Phase 5). +- Wall-display / kiosk theme (v2). +- SSE as a transport — **must not be relied on in Phase 3** (unverified until Phase 4 gate, D-14). + + + +## Implementation Decisions + +### Target-calendar selection (write target) +- **D-01:** **Default target = remember last-used per member.** Seed/first-time default is + the **creator's own personal calendar** (always exists; no shared calendar may exist yet, D-16). +- **D-02:** **Calendar picker is shown only when the member has >1 writable calendar.** With a + single writable calendar (e.g. personal only, before a shared Family calendar exists) the + picker is hidden entirely — zero friction for the non-technical member. It appears once a + shared Family calendar is present. +- **D-03:** **Writable set = the member's own personal calendar + the shared Family calendar** + (when shared read-write to them in Fastmail). The **other member's personal calendar is a + read-only overlay** — never a write target. Matches the two-equal-partners model. +- **D-04:** **Edit may move an event to a different calendar.** Implemented as CalDAV + **delete-from-old + create-on-new** (not an in-place move). Researcher/planner must handle + the UID/etag implications and **partial-failure** (delete succeeded but create failed, or + vice-versa) safely. + +### Write feedback & sync (the load-bearing architecture decision) +- **D-05:** **Optimistic-accept + server-side outbox.** On save the UI optimistically reflects + the change with a "syncing…" indicator; the API writes a **`pending` row to a MariaDB outbox** + and returns immediately. A **backend worker drains the outbox** against Fastmail. +- **D-06:** **Re-sync on confirm.** When the queued write confirms, the worker triggers a + **targeted re-sync of just that one calendar** (not a full poll) so the MariaDB cache becomes + authoritative, then clears the pending state. (This is the async evolution of the operator's + initial "forced re-sync, then show" — same authoritative-cache guarantee, without a blocking + spinner.) +- **D-07:** **Retry policy — backoff transient, fail-fast hard errors.** + - *Transient* (network error, 5xx, timeout) → retry with **exponential backoff over a bounded + window** (a few minutes), keeping the "not synced yet" toast visible. + - *Hard* (401/403 auth, 400 validation) → **stop immediately** and surface a real + "didn't save" error to the user. (Transient failures must be short; anything non-transient + is a hard fail that won't self-resolve.) +- **D-08:** **Edit-conflict handling = detect + warn + reload latest.** Writes send `If-Match` + with the cached etag. On **412 Precondition Failed**, the write is **routed out of the retry + loop** into a conflict flow: re-sync that event from Fastmail and warn the user + ("this event changed elsewhere — review the latest version") before they retry. **No silent + last-write-wins overwrite.** +- **D-09:** **Sync-state is surfaced via polling, not SSE.** The "pending / not-synced" state the + toast reads must be exposed over a polled endpoint (or query refetch), because SSE-over-Pangolin + is unverified until the Phase 4 entry gate (D-14). Do **not** build Phase 3 sync feedback on SSE. + +### Carried forward — locked, NOT re-discussed +- **D-10:** The **edit/delete surface reuses `EventDetailPopover`** — it was built in Phase 2 with + a reserved footer action area specifically for this (Phase 2 D-08). Create can reuse the same + surface shell. +- **D-11:** **Recurring events: create + whole-series edit only** in v1. Single-occurrence + (RECURRENCE-ID/EXDATE) and "this-and-following" edits are **v1.x** (CAL-09/CAL-10). +- **D-12:** **Broker is the only Fastmail I/O boundary.** Routes touch only the MariaDB cache; + all PUT/DELETE goes through `src/broker/`. No tsdav import in route handlers. +- **D-13:** **Dev-auth bypass** stays available for local build/test (project D-14); live + Authelia verification is the Gate 2 item folded into this phase. + +### Claude's Discretion (researcher / planner decide) +- **Event form & fields** — exact field set (title, start/end, all-day toggle, location, + description) and layout. Build it into / alongside the reused `EventDetailPopover` surface (D-10). + Must be slick and low-friction for the non-technical Apple member. +- **Recurrence creation UX** — how whole-series RRULE is exposed (simple presets daily/weekly/ + monthly/yearly vs a small custom builder). Keep it minimal for v1; whole-series only (D-11). +- **iOS install onboarding** — trigger (auto-detect iOS-Safari-non-standalone vs help button vs + first-visit banner) and the annotated Add-to-Home-Screen walkthrough content. **Load-bearing:** + success criterion 4 requires a non-technical user to follow it independently; no install ⇒ no + push in Phase 5. +- **Android install** — `beforeinstallprompt` handling (custom button vs native prompt). +- **PWA tooling** — `vite-plugin-pwa` is in the recommended stack (CLAUDE.md) but **not yet + installed**; manifest + service worker config is the planner's call. Keep the service worker + conservative (don't break the OIDC redirect / standalone-PWA login flow — Gate 2 risk). +- Outbox worker mechanics (interval vs trigger, idempotency key, max-attempt count, dead-letter + surfacing) — implement to satisfy D-05/D-06/D-07. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Project decisions & scope +- `.planning/PROJECT.md` — core value, constraints, Key Decisions incl. **D-14** (dev-auth + bypass; live AUTH + iOS smoke folded into Phase 3), **D-15** (local Newt test rig for Gate 2), + **D-16** (shared Family calendar is a collection on the operator's primary account, may not + exist yet; `is_shared` flag marks it). +- `.planning/ROADMAP.md` §"Phase 3: Event Write-Back + PWA Install" — goal + the **6 success + criteria** (incl. criterion 6, the carried Gate 2 live-auth/iOS verification). +- `.planning/REQUIREMENTS.md` — CAL-04/05/06 (create/edit/delete), CAL-07 (recurring create), + PWA-01/02 (installable + guided install); v1.x CAL-09/10 (single-occurrence edits — OUT). + +### Phase 1/2 foundation this builds on +- `.planning/phases/02-calendar-display/02-CONTEXT.md` — design-token layer (D-01/02/03), + `EventDetailPopover` reuse-as-edit-surface (Phase 2 D-08), server-side expansion, color/owner model. +- `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` — per-member app-password + model (how each member's calendars are reached/written; informs the writable set, D-03). +- `docs/deployment.md` — **Gate 2 checklist** (live Authelia OIDC over Pangolin, iOS standalone + PWA) and the dev-auth bypass context. **Required reading for success criterion 6.** + +### Code this phase extends +- `apps/api/src/broker/client.ts` — tsdav `createDAVClient`; add PUT/DELETE write methods here + (broker boundary, D-12). +- `apps/api/src/broker/sync.ts` — REPORT→ical.js→upsert; the targeted single-calendar re-sync + (D-06) builds on this. +- `apps/api/src/broker/poller.ts` — ctag poller; the outbox worker is a sibling background process. +- `apps/api/src/routes/events.ts` — current read-only `GET /api/events`; add create/edit/delete + endpoints + the pending/sync-state surface (D-09) alongside. +- `apps/api/src/db/schema.ts` — `calendars` (userId, isShared, url), `calendarEvents` + (uid, etag, rawVevent, dtstart split). **New outbox table** lives here (D-05). +- `apps/pwa/src/components/EventDetailPopover.tsx` — reserved footer action area is the edit/delete + entry point (D-10). +- `apps/pwa/src/api/client.ts` — typed fetch client to extend with write calls + sync-state poll. +- `apps/pwa/vite.config.ts` — no PWA plugin yet; manifest + service worker added here (PWA-01). +- `CLAUDE.md` — locked stack incl. `vite-plugin-pwa` 1.3.0, tsdav write-back guidance + (PUT new .ics / DELETE by UID), iOS PWA constraints (16.4+, home-screen install required). + + + + +## Existing Code Insights + +### Reusable Assets +- `EventDetailPopover` (Phase 2) — read-only detail overlay with a **reserved footer for Phase 3 + edit/delete** (D-10); responsive bottom-sheet/popover, focus trap, XSS-safe plain-text rendering. +- `apps/pwa/src/api/client.ts` — typed `fetch` client (`fetchMe`, windowed `fetchEvents`, + `credentials: 'include'`); extend with create/edit/delete + sync-state poll. +- Broker module (`client.ts`/`sync.ts`/`poller.ts`) — owns all Fastmail I/O and ical.js parsing; + write methods and the outbox worker attach here. +- Design-token layer + Zustand UI store + TanStack Query — server state in Query, UI state in Zustand. + +### Established Patterns +- **Broker boundary (T-03-02):** routes read the MariaDB cache only; never import tsdav in a route. + Write-back must keep this — endpoint enqueues to the outbox; the broker worker does the CalDAV PUT. +- **D-13 schema split** for all-day (dtstartDate) vs timed (dtstartUtc) — write-back must produce + correct VEVENTs for both and never coerce DATE→DATETIME. +- **Idempotency:** `calendar_id + uid` composite unique key; etag tracked per event (drives D-08). +- Hono app exported without auto-start (testable); add write routes + outbox worker in that shape. + +### Integration Points +- **New MariaDB outbox table** (D-05): pending create/edit/delete operations with status, attempt + count, target calendar URL, payload/UID, etag for If-Match. +- **Outbox worker** (sibling to the ctag poller) drains the queue, applies D-07 retry/backoff, + triggers the targeted re-sync (D-06), and updates pending status read by the polled sync-state + endpoint (D-09). +- **Write endpoints** on the events router (create/edit/delete) that validate input (zod) and + enqueue rather than calling Fastmail inline. +- **PWA layer**: `vite-plugin-pwa` manifest + service worker (PWA-01) — keep the SW conservative + so it does not break the OIDC redirect / iOS standalone login (Gate 2, success criterion 6). + + + + +## Specific Ideas + +- The non-technical Apple member is the design center: the calendar picker disappears when there's + only one choice (D-02), saves never block on a spinner (D-05/D-06), and a failed sync shows a + clear, persistent "not synced yet" state rather than silently losing the edit. +- "Transient failures should be short; anything beyond that is a hard fail that won't auto-resolve" + — drives the bounded-backoff-then-fail policy (D-07). +- iOS install walkthrough must be followable independently with annotated screenshots — it's the + prerequisite for her getting Web Push in Phase 5. + + + + +## Deferred Ideas + +- **Single-occurrence / "this-and-following" recurring edits** (CAL-09/CAL-10) — v1.x; Phase 3 is + create + whole-series edit only. +- **Writing to the other member's personal calendar** — out; other members' personals are + read-only overlays in v1 (D-03). +- **SSE-based live sync-state push** — deferred to after the Phase 4 SSE-over-Pangolin gate; Phase 3 + surfaces sync state via polling (D-09). +- **Event form areas not deep-dived** (exact fields, recurrence-builder richness, iOS/Android + install UX) — left to researcher/planner discretion within the constraints above; not deferred + out of phase, just not operator-locked. + + + +--- + +*Phase: 3-Event Write-Back + PWA Install* +*Context gathered: 2026-06-05* diff --git a/.planning/phases/03-event-write-back-pwa-install/03-DISCUSSION-LOG.md b/.planning/phases/03-event-write-back-pwa-install/03-DISCUSSION-LOG.md new file mode 100644 index 0000000..fa7ed37 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-DISCUSSION-LOG.md @@ -0,0 +1,111 @@ +# Phase 3: Event Write-Back + PWA Install - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-06-05 +**Phase:** 3-Event Write-Back + PWA Install +**Areas discussed:** Target calendar pick, Write feedback & sync + +Areas offered but not selected (left to Claude's discretion): Event form & fields, iOS install onboarding. + +--- + +## Target calendar pick + +### Default target calendar for a new event +| Option | Description | Selected | +|--------|-------------|----------| +| Their own personal | New events default to the creator's own personal calendar | | +| Shared Family | Default to the shared Family calendar; personal is opt-out (needs fallback, may not exist) | | +| Remember last-used | Default to whatever calendar they last wrote to, persisted per member | ✓ | + +**User's choice:** Remember last-used +**Notes:** Seed/first-time default set to creator's own personal (always exists; shared may not, per D-16). + +### Calendar selector visibility +| Option | Description | Selected | +|--------|-------------|----------| +| Only when >1 writable | Hide picker when only one writable calendar exists; show once shared Family exists | ✓ | +| Always show | Always render the selector with default pre-selected | | + +**User's choice:** Only when >1 writable + +### Move event between calendars on edit +| Option | Description | Selected | +|--------|-------------|----------| +| Lock calendar on edit | Calendar fixed once created; defer cross-calendar move to v1.x | | +| Allow move | Edit may change target calendar (CalDAV delete-old + create-new) | ✓ | + +**User's choice:** Allow move +**Notes:** Researcher/planner must handle UID/etag and partial-failure safety. + +### Writable calendar set +| Option | Description | Selected | +|--------|-------------|----------| +| Own personal + shared Family | Write to own personal + shared Family; other member's personal is read-only overlay | ✓ | +| Any visible calendar | Allow writing to any aggregated calendar incl. other member's personal | | + +**User's choice:** Own personal + shared Family + +--- + +## Write feedback & sync + +### How the member sees their own change after save (poll-based cache) +| Option | Description | Selected | +|--------|-------------|----------| +| Optimistic + forced re-sync | Optimistic UI update + background targeted re-sync of that calendar | (evolved into) | +| Forced re-sync, then show | Synchronously re-sync that calendar, then refresh; ~0.5–1s spinner | ✓ (initial) | +| Wait for poll | Let the ctag poller pick it up next cycle; visibly laggy | | + +**User's choice:** Forced re-sync, then show — subsequently evolved (via the failure-handling answer) into optimistic-accept + queued write + re-sync on confirm. Same authoritative-cache guarantee, async instead of blocking. + +### Behavior when the CalDAV write fails +| Option | Description | Selected | +|--------|-------------|----------| +| Inline error + retry, keep form | Keep form + input, show inline error, manual retry | | +| Toast + silent rollback | Close form, roll back optimistic change, transient toast | | + +**User's choice:** Free-text — "accept the edit, queue it, keep a toast showing it isn't sync'd yet and keep retrying. Transient failures should be short; anything beyond that is a hard fail that won't auto-resolve." +**Notes:** Reframed the save path into an optimistic-accept + queued-write-with-retry model (outbox). + +### Edit-conflict (etag mismatch / 412) handling +| Option | Description | Selected | +|--------|-------------|----------| +| Detect + warn, reload latest | If-Match cached etag; on 412 abort, re-sync, warn before retry | ✓ | +| Last-write-wins | No If-Match; overwrite whatever is on Fastmail | | + +**User's choice:** Detect + warn, reload latest + +### Pending-write queue location +| Option | Description | Selected | +|--------|-------------|----------| +| Server-side outbox (MariaDB) | API persists pending row; backend worker drains with backoff; survives app close | ✓ | +| Client-side queue (PWA) | PWA holds + retries; stops when app closed unless persisted | | + +**User's choice:** Server-side outbox (MariaDB) + +### Transient vs hard-failure classification +| Option | Description | Selected | +|--------|-------------|----------| +| Backoff transient; fail-fast hard errors | Network/5xx/timeout → bounded backoff; 401/403/400/412 → stop/surface | ✓ | +| Let me refine the thresholds | Operator specifies retry window/backoff/status codes | | + +**User's choice:** Backoff transient; fail-fast hard errors (412 routed to the conflict-reload flow) + +--- + +## Claude's Discretion + +- Event form & fields (field set, layout) — build into / alongside the reused `EventDetailPopover`. +- Recurrence creation UX (presets vs custom builder) — whole-series only for v1. +- iOS install onboarding (trigger + annotated walkthrough) and Android `beforeinstallprompt`. +- `vite-plugin-pwa` manifest + service worker config (keep SW conservative re: OIDC/iOS login). +- Outbox worker mechanics (interval/trigger, idempotency key, max attempts, dead-letter). + +## Deferred Ideas + +- Single-occurrence / "this-and-following" recurring edits (CAL-09/CAL-10) — v1.x. +- Writing to the other member's personal calendar — out (read-only overlay in v1). +- SSE-based live sync-state push — after the Phase 4 SSE-over-Pangolin gate; Phase 3 uses polling.