12 KiB
Phase 3: Event Write-Back + PWA Install - Context
Gathered: 2026-06-05 Status: Ready for planning
## Phase BoundaryMembers 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).
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
pendingrow 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-Matchwith 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
EventDetailPopoversurface (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 —
beforeinstallprompthandling (custom button vs native prompt). - PWA tooling —
vite-plugin-pwais 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_refs>
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_sharedflag 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),EventDetailPopoverreuse-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— tsdavcreateDAVClient; 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-onlyGET /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-pwa1.3.0, tsdav write-back guidance (PUT new .ics / DELETE by UID), iOS PWA constraints (16.4+, home-screen install required).
</canonical_refs>
<code_context>
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— typedfetchclient (fetchMe, windowedfetchEvents,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 + uidcomposite 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-pwamanifest + service worker (PWA-01) — keep the SW conservative so it does not break the OIDC redirect / iOS standalone login (Gate 2, success criterion 6).
</code_context>
## 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.
- 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