# 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*