# Feature Research **Domain:** Family calendar PWA — v1.2 new capabilities (multi-provider, theming, zero-setup DB, dev/CI stub) **Researched:** 2026-06-19 **Confidence:** MEDIUM (Google Calendar API: MEDIUM via Context7/official docs; UX patterns: LOW via websearch, cross-checked across multiple sources and grounded in the existing codebase) --- ## Scope of This Document This document covers **only the six v1.2 feature areas**. v1.0/v1.1 features (calendar display, event CRUD, lists, push, OIDC, local auth, admin settings, setup wizard, per-event reminders, CI harness) are shipped and validated; they appear only as dependencies here. --- ## Category 1: Multi-Provider Calendar Support ### What "Good" Looks Like Apps like Skylight, Cozi, Morgen, and Fantastical converge on the same model: each provider/calendar account gets a unique color lane; events are visually identified by their source calendar's color; event detail view names the source calendar. Provider selection on event creation is a calendar/account dropdown that defaults to a configured "default calendar" — the user picks which calendar to write to before saving. There is no industry-standard "provider badge icon" per event in the calendar grid. Color is the primary (and sufficient) differentiator; the calendar name in the event detail view is the secondary indicator. ### Table Stakes | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| | Google Calendar events visible in the unified view alongside Fastmail events | The core value of multi-provider is the unified display; without it there is no v1.2 | HIGH | Requires provider abstraction (prerequisite) + Google Calendar API integration + self-service onboarding | | Per-calendar color assignment covering Google calendars | Users expect events to be visually distinct by source; Skylight and Cozi both do this | LOW | Phase 17 color-routing already exists for Fastmail; extend the same mechanism to cover Google calendar IDs | | Calendar picker on event creation — select which calendar/provider to write to | Without this, writes go to an ambiguous default; users lose trust in where events land | MEDIUM | Lists all writable calendars across all connected providers; defaults to the member's "default calendar" setting; requires the provider abstraction to expose a unified calendar list | | Calendar name shown in event detail view | Users need to know where the event lives, especially when both Fastmail and Google events are visible | LOW | Extend the existing event detail sheet with a "Calendar: [name]" line; already implicit in the data model | ### Differentiators | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| | Cross-provider event create — write to Fastmail or Google per event | Household members write to whichever calendar they actually use natively | MEDIUM | Write path must route to the correct provider's outbox; clean once the provider abstraction exists | | Per-provider sync status indicator visible in the calendar view header or Settings | Non-technical users immediately see if a provider is disconnected without hunting through menus | LOW | Badge or dot on provider icon; links to Settings for reconnect action | ### Anti-Features | Feature | Why Requested | Why Problematic | Alternative | |---------|---------------|-----------------|-------------| | Automatic color re-assignment when a new provider is added | Seems helpful to "harmonize" colors | Scrambles existing user expectations — the wife has learned which color means which calendar | Keep existing Fastmail calendar colors; assign new Google calendar colors from unused palette slots | | Two-way sync between Fastmail and Google (event mirroring) | "I want the same event on both calendars" | Infinite loop risk; duplicate events; out of milestone scope | Each event lives in exactly one provider; the unified view makes this invisible to the user | | CalDAV subscription URL for Google Calendar | Some power users subscribe to Google Calendar via CalDAV | Google deprecated unauthenticated CalDAV access; fragile and undocumented workaround | Use the Google Calendar REST API directly (already planned) | ### Complexity and Dependencies - **Keystone dependency:** The `CalendarProvider` interface (provider abstraction) must be defined and Fastmail refactored behind it before Google can be added as a second implementation. This must be Phase 21. - Depends on: **Self-service onboarding** (Category 2) — Google events cannot appear until the member has connected their Google account. - Existing feature dependency: the Fastmail broker in `apps/api/src/broker/` must be refactored behind a `CalendarProvider` interface; this is the highest-complexity step in v1.2. --- ## Category 2: Self-Service Provider Onboarding ### What "Good" Looks Like The industry-standard pattern (GoHighLevel, Zapier, Morgen, Fantastical) distinguishes two onboarding flows: - **App-password providers (Fastmail):** Short form — credential input + optional endpoint URL + "Verify & Save" button. Live validation: the app attempts a CalDAV PROPFIND before persisting. Error state names the problem in plain language ("Wrong password", "Can't reach Fastmail"). - **OAuth providers (Google):** Single "Connect Google Calendar" button → browser redirect to Google's consent screen → user approves → redirect back with auth code → app exchanges for access + refresh tokens → stores encrypted refresh token in DB. The token is never shown to the user. - **Status indicators:** After connection, each provider shows a health badge (green = healthy, yellow = expiring soon, red = disconnected). The red state shows a prominent "Reconnect" button — one tap re-runs the OAuth flow without removing and re-adding the connection. The reconnect pattern is non-negotiable for the non-technical Apple member. When her Google token expires (password change, 6-month inactivity, suspicious activity), she must see a visible banner in the calendar view — not discover the problem by noticing missing events days later. ### Table Stakes | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| | "Connect Google Calendar" OAuth button in member Settings | OAuth is the expected UX for Google — no one expects to paste an API key | MEDIUM | `googleapis` `generateAuthUrl({access_type:'offline', scope:['https://www.googleapis.com/auth/calendar']})` + callback route + store encrypted refresh token in `member_credentials` | | One-tap "Reconnect" button when Google token expires or is revoked | Token churn is inevitable; must not require removing and re-adding the connection | MEDIUM | Re-runs the OAuth flow; existing `member_credentials` row updated in place | | Fastmail app-password form with live CalDAV validation (member self-service) | Fastmail credential entry already exists in admin (Phase 10); now surfaced to the member in their own Settings | LOW | Reuse/move the existing `CredentialSheet` component and `/api/me/credential` route from Phase 10; already validated | | Connection health status per provider shown in member Settings | Non-technical users need ambient awareness that their calendar is connected | LOW | `GET /api/me/providers` returns per-provider status; show green/red indicator per entry | | Error states that name the problem without technical jargon | "Your Google Calendar isn't connected" not "401 Unauthorized" | LOW | UX copy layer over API error codes; inline on the Settings page | | Proactive disconnect banner on the calendar view itself | User sees the issue before discovering missing events | LOW | Banner above the calendar when any provider is in error state; links to Settings reconnect | ### Differentiators | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| | "Test connection" re-check button after credential save | Confirms the connection still works without navigating away | LOW | Same live-validation call as initial save; reusable endpoint | ### Anti-Features | Feature | Why Requested | Why Problematic | Alternative | |---------|---------------|-----------------|-------------| | Service-account / server-side Google auth (one token for the whole app) | Seems simpler | Service account can only access calendars it owns; per-user OAuth is required to write to a member's own Google Calendar | Per-user OAuth with per-member refresh token stored in `member_credentials` | | Storing Google OAuth tokens in a browser cookie or localStorage | Convenient for the frontend | Security anti-pattern — long-lived refresh tokens can be exfiltrated from the client | Encrypt and store in `member_credentials` table server-side, same as Fastmail app passwords | | JMAP for Fastmail calendar connection | Seems natural given Fastmail's JMAP investment | JMAP Calendar API is not available on Fastmail as of 2026 | CalDAV via tsdav (locked decision, documented in CLAUDE.md) | ### Google OAuth Technical Notes (MEDIUM confidence — official docs via Context7) - Scope: `https://www.googleapis.com/auth/calendar` — read + write events + calendar list. Classified as a "restricted" scope but **no Google verification is required** for self-hosted personal apps with fewer than 100 users (all known to the operator). - `access_type: 'offline'` is required to receive a refresh token on the first authorization. - Refresh tokens are only issued on first authorization unless `prompt: 'consent'` is forced. Listen for the `tokens` event and persist immediately. - Token auto-refresh: setting `oauth2Client.setCredentials({refresh_token})` causes `googleapis` to auto-refresh the access token transparently — no manual refresh loop needed in normal operation. - Revocation handling: catch `err.status === 401` from `refreshAccessToken()`, clear the stored token from DB, surface the reconnect banner. - Google's refresh token revocation triggers: user changes Google password, user revokes app access in Google account settings, Google detects suspicious activity, token unused for 6 months. ### Complexity and Dependencies - Depends on: **Provider abstraction** (Category 1) — credentials are stored and managed per-provider through the abstraction. - Existing feature dependency: `member_credentials` table exists (Phase 10); needs `provider_type = 'google'` and an encrypted `refresh_token` column. - Existing feature dependency: admin credential rotation patterns from Phase 10 are directly reusable for self-service credential management. - OAuth callback route must work inside the Safari standalone PWA on iOS — same constraint as the Authelia OIDC redirect in Phase 3 (load-bearing; must be validated on device). --- ## Category 3: Multiple Reminders Per Event ### What "Good" Looks Like Google Calendar: unlimited reminders via an "Add notification" link below the reminder list; each row is a lead-time picker + remove button. Apple Calendar: hard cap of 2 alerts ("Alert" + "Second Alert" fields — no add button beyond 2). Outlook: up to 2. Morgen: follows Google's unlimited model. For a family PWA, cap at 5 reminders. Real-world usage clusters at 1–2 per event ("1 day before" + "30 min before" is the canonical pair). The UI pattern is a vertical list of reminder rows, each with the existing lead-time picker and an X (remove) button; an "Add reminder" link below the list is disabled once the cap is reached. ### Table Stakes | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| | Add up to 5 reminders per event (list UI with add/remove per row) | Google Calendar sets this expectation; "1 day before AND 30 min before" is the most common household pattern | MEDIUM | The existing `reminderLeadMinutes` single-integer field (Phase 11) must be refactored to an array. VALARM serialization already exists (`buildTimedValarm`/`buildAllDayValarm`) — extend to emit N VALARMs | | Remove any individual reminder from the list | Without remove, users accumulate unwanted reminders over time | LOW | X button on each reminder row; list collapses to empty = no reminder (same as "None" today) | | Pre-populate all existing reminders on event edit | Phase 11 pre-populates one reminder; must now pre-populate all N | LOW | `classifyValarms`/`extractValarms` (Phase 11) already return arrays — thread the full array to the form | | Sensible cap with clear feedback | Prevents absurd configurations (10+ reminders per event) | LOW | Cap at 5; "Add reminder" link grays out at cap; no error toast needed | | Google Calendar provider: translate reminders to `event.reminders.overrides` format | Google Calendar uses `{method:'popup', minutes:N}` objects, not VALARM | MEDIUM | The provider abstraction's write path translates the internal reminder array to the correct format per provider; Fastmail → VALARMs, Google → `reminders.overrides` | ### Differentiators None — multiple reminders is table stakes from Google Calendar's baseline. Doing it without reminder duplication on edit and without stripping non-FamilySync VALARMs is what good execution looks like. ### Anti-Features | Feature | Why Requested | Why Problematic | Alternative | |---------|---------------|-----------------|-------------| | Reminder templates ("always remind me 1 day before for all my events") | Seems like a time-saver | Premature complexity; per-event reminder list already covers the use case | Per-event reminder list; per-user default reminder is a v1.3 consideration | | Reminder snooze from the notification payload | RFC 9074 defines this; Fantastical supports it | Requires a write back to the calendar from the service worker notification handler — a high-complexity, high-fragility path for a rare use case | Dismiss; re-add a reminder manually if needed | ### Complexity and Dependencies - Depends on: existing `reminderLeadMinutes` single-value field (Phase 11) — must migrate to a JSON array column (additive migration; old column deprecated, not dropped, to avoid downtime). - Existing feature dependency: `buildTimedValarm`/`buildAllDayValarm`/`classifyValarms`/`extractValarms`/`computeAlertInstantUtc` in the broker (Phase 11) — all handle arrays natively (ical.js returns arrays); only the serialization call site changes. - Existing feature dependency: variable-lead scheduler (Phase 11) — must schedule N reminders per event; dedup key changes from `uid:dtstartMs` to `uid:dtstartMs:leadMinutes`. - Ordering note: this can be implemented in parallel with provider abstraction, but the Google provider translation layer (`reminders.overrides`) is naturally built alongside the Google provider (Category 1/2). --- ## Category 4: PWA Dark Mode / Theming ### What "Good" Looks Like Three options: System (follows OS `prefers-color-scheme`), Light, Dark. The toggle lives in user Settings — not admin-only, both members control their own theme independently. Preference is persisted in localStorage (no server round-trip; no DB column needed). No flash-of-wrong-theme (FOIT) on load. Phase 17 already shipped semantic CSS custom properties (`tokens.css`) on `:root` — the groundwork is done. Dark mode requires: a `[data-theme="dark"]` override block in `tokens.css`, a blocking inline script in `index.html` `
` to set the attribute before first paint, and a three-option toggle in Settings. ### Table Stakes | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| | Respect OS `prefers-color-scheme` by default | Every modern app does this; iOS Dark Mode is heavily used by Apple users | LOW | CSS-only: `@media (prefers-color-scheme: dark)` override for `[data-theme="system"]` tokens; no JS needed for system mode | | Explicit System / Light / Dark toggle in Settings | Users override OS preference for specific apps routinely | LOW | Three-option segmented control in the Settings page (not admin-gated) | | No flash-of-wrong-theme on PWA load | FOIT is jarring and immediately noticeable — the non-technical Apple member will notice | MEDIUM | Blocking inline `