- STACK.md: google-auth-library@10.7.0 + @googleapis/calendar@15.0.0 scoped packages (vs monolithic googleapis), OAuth2 flow, token storage, Google Calendar API event/reminder model - FEATURES.md: 6 feature categories (multi-provider, self-service onboarding, multiple reminders, dark mode, zero-setup DB, dev/CI stub), dependency graph, feature prioritization - ARCHITECTURE.md: CalendarProvider interface, provider factory, CalDavProvider wrapper, GoogleCalendarProvider, MockProvider, provider_tokens table schema, multi-reminder JSON column, OAuth callback routing, 7-component data flows - PITFALLS.md: 11 critical/medium pitfalls (refresh token 7-day expiry in testing status, Google recurrence mismatch, syncToken 410, timezone handling, provider abstraction regression, VALARM dedup key, auto-migrate failures, dark mode FOWT, OAuth callback through tunnel, token encryption, ESLint 10 breaking changes) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
362 lines
33 KiB
Markdown
362 lines
33 KiB
Markdown
# 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` `<head>` 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 `<script>` in `index.html` `<head>` reads `localStorage` and sets `data-theme` synchronously before Vite's JS bundle parses; must be a raw inline script (not `type="module"`) |
|
||
| Preference persisted in localStorage | Theme must survive PWA re-launch and page refresh | LOW | `localStorage.setItem('theme', 'dark'|'light'|'system')` on toggle; read in the inline init script |
|
||
| Calendar event colors readable in dark theme | Color-coded lanes are the core UX — they must not become unreadable on dark backgrounds | MEDIUM | Ensure hue-based event colors (rose, blue, amber) have sufficient contrast on the dark surface token; may need adjusted lightness values in the dark token override block |
|
||
|
||
### Differentiators
|
||
|
||
None — dark mode is table stakes in 2026 for any consumer app. The differentiator is doing it without FOIT.
|
||
|
||
### Anti-Features
|
||
|
||
| Feature | Why Requested | Why Problematic | Alternative |
|
||
|---------|---------------|-----------------|-------------|
|
||
| Per-member theme synced to the server (follows you across devices) | "My theme should be consistent on any device" | Adds a DB column + API round-trip for a cosmetic preference; minimal benefit for a 2-person household | localStorage only; set the preference on each device separately |
|
||
| Custom color theme editor | Power user appeal | Scope creep; the semantic token system supports it eventually but not in v1.2 | System/light/dark is the complete feature for this milestone |
|
||
|
||
### Complexity and Dependencies
|
||
|
||
- Depends on: Phase 17 semantic token layer (`tokens.css`) — already shipped; this feature extends it with a dark override block.
|
||
- Low implementation risk — primarily CSS changes plus a small inline script in `index.html`.
|
||
- Vite note: the blocking inline script must be injected into `index.html` as a literal string (use Vite's HTML transform plugin or a direct `<script>` tag in `index.html`). It cannot be an ES module (`type="module"`) because modules are deferred and execute after paint.
|
||
- Independent of all other v1.2 features — can be developed in a parallel phase with no ordering constraint.
|
||
|
||
---
|
||
|
||
## Category 5: Zero-Manual-Setup DB Bootstrap
|
||
|
||
### What "Good" Looks Like
|
||
|
||
The operator provides a MariaDB host, port, DB name, username, and password in env vars. On `docker compose up`, the API boots, calls Drizzle's `migrate()` programmatically before the HTTP server starts accepting requests, and all tables are created. On subsequent boots, `migrate()` is idempotent — already-applied migrations are skipped via the `__drizzle_migrations` tracking table. The operator never runs `drizzle-kit migrate` manually.
|
||
|
||
### Table Stakes
|
||
|
||
| Feature | Why Expected | Complexity | Notes |
|
||
|---------|--------------|------------|-------|
|
||
| Programmatic `migrate()` call in API boot sequence, before `serve()` starts | Self-hosted operators expect "docker compose up and it works" — hand-running CLI migrations is a deployment barrier | LOW | `import { migrate } from 'drizzle-orm/mysql2/migrator'`; call in `apps/api/src/index.ts` before the Hono server binds; wrap in try/catch with `process.exit(1)` on failure |
|
||
| Idempotent on repeated boot | Containers restart; migrations must not fail on the second run | LOW | Built into drizzle-kit migrate: tracks applied migrations in `__drizzle_migrations`; already-applied migrations are no-ops |
|
||
| DB readiness wait before migrating | The MariaDB container may not be ready when the API container starts | LOW | Retry loop (5 attempts, 2s exponential backoff) using `SELECT 1` before calling `migrate()`; `process.exit(1)` after all retries fail with a clear log message |
|
||
| Migration failure = fatal startup with a clear log message | Silent failure leaves the app running against a broken schema | LOW | `console.error('DB migration failed:', err)` + `process.exit(1)` |
|
||
|
||
### Differentiators
|
||
|
||
None — zero-setup bootstrap is the baseline operator expectation for modern self-hosted apps. Meeting it cleanly is the goal; there is no competitive angle here.
|
||
|
||
### Anti-Features
|
||
|
||
| Feature | Why Requested | Why Problematic | Alternative |
|
||
|---------|---------------|-----------------|-------------|
|
||
| `drizzle-kit push` in production | Simpler — no SQL migration files to manage | Destructive on schema conflicts; drops columns without warning; not safe for a production DB with real user data | `drizzle-kit generate` (committed SQL files) + programmatic `migrate()` at boot |
|
||
| App creates the database itself (not just tables) | Operators shouldn't need to pre-create the DB | Requires elevated `CREATE DATABASE` privileges that operators may not want to grant the app user | Document in deployment guide: "create a database named `familysync` and grant your app user full access to it" — 30 seconds of manual setup that avoids privilege escalation |
|
||
|
||
### Complexity and Dependencies
|
||
|
||
- Depends on: existing Drizzle migration files in `apps/api/src/db/migrations/` (all phases — already exist).
|
||
- Existing constraint from Phase 8/16 (D-20): MariaDB 11 does not have `mysqladmin ping`; the in-app readiness check must use a raw `SELECT 1` query via `mysql2`, not any CLI command.
|
||
- Low implementation risk: `drizzle-orm/mysql2/migrator` is already a transitive dependency; only the boot sequence wiring is new code.
|
||
- Enables: the existing setup wizard (Phase 12) to assume the schema exists when it runs — clean separation of concerns (schema bootstrap → setup wizard → admin config).
|
||
|
||
---
|
||
|
||
## Category 6: Dev-User Full-App Exercise (Stub Calendar Provider)
|
||
|
||
### What "Good" Looks Like
|
||
|
||
A `StubCalendarProvider` that implements the same `CalendarProvider` interface as Fastmail and Google, returning deterministic seed events from memory and mutating an in-memory Map on writes. Activated by `CALENDAR_PROVIDER=stub` (implicitly active alongside `DEV_AUTH_BYPASS=true`). The Playwright CI harness uses the stub so tests are hermetic — no live CalDAV or Google API calls, no Fastmail credentials required in CI.
|
||
|
||
Seed events must cover: timed events, all-day events, a recurring weekly series, events with reminders, past events, and future events — enough to exercise the full UI, reminder pipeline, and RRULE expansion without gaps.
|
||
|
||
### Table Stakes
|
||
|
||
| Feature | Why Expected | Complexity | Notes |
|
||
|---------|--------------|------------|-------|
|
||
| `CalendarProvider` interface with `fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent` | Required before any stub can exist; is also the keystone for provider abstraction (Category 1) | MEDIUM | Define in `apps/api/src/broker/providers/CalendarProvider.ts`; Fastmail and Google are implementations; stub is the third |
|
||
| Stub provider with seeded deterministic events | Developers and CI must exercise full event CRUD + reminder + multi-provider display without a live calendar | MEDIUM | In-memory `Map<string, CalendarEvent>`; seed data covers event varieties needed by Playwright tests; writes mutate the map and are visible to subsequent fetches in the same process |
|
||
| Stub activated by env var (`CALENDAR_PROVIDER=stub`) | Must be opt-in; never active in production | LOW | Read env at startup in provider resolution; route `CalendarProvider` selection accordingly |
|
||
| Stub integrated with Playwright CI harness | The existing CI harness (`DEV_AUTH_BYPASS=true`) must also activate the stub so calendar tests pass without Fastmail credentials | LOW | Add `CALENDAR_PROVIDER=stub` to the CI workflow env block alongside `DEV_AUTH_BYPASS=true`; no other change needed |
|
||
| Reminder scheduling works against stub events | Exercises the full reminder pipeline in CI without live events | LOW | Stub seed data includes events with reminder leads set; the scheduler fires against them normally |
|
||
|
||
### Differentiators
|
||
|
||
| Feature | Value Proposition | Complexity | Notes |
|
||
|---------|-------------------|------------|-------|
|
||
| Stub resets on API restart (intentionally stateless) | Keeps CI tests hermetic — no state leaks between test runs | LOW | In-memory Map is wiped on process restart; document this behavior explicitly |
|
||
| Seed events designed to cover Phase 11 edge cases | Exercises RRULE expansion, all-day timezone handling, multi-VALARM, and past/future reminder scheduling in CI | MEDIUM | Design seed data to match the Phase 11 test matrix: one recurring weekly event, one all-day multi-day event, one event with 2 reminders, one past event, one future event with 2-day reminder lead |
|
||
|
||
### Anti-Features
|
||
|
||
| Feature | Why Requested | Why Problematic | Alternative |
|
||
|---------|---------------|-----------------|-------------|
|
||
| Persisting stub events to SQLite or a test-specific MariaDB table | "So writes survive restart in dev" | Adds a dependency; defeats the hermetic purpose of a stub; blurs the line between dev and test state | Use a real Fastmail or Google provider for persistent personal dev; stub is for CI only |
|
||
| Shared stub state across parallel Playwright workers | Enables cross-test assertions | Race conditions on concurrent create/delete cause flaky tests | Each test must be self-contained; rely on seed data, not prior-test writes |
|
||
|
||
### Complexity and Dependencies
|
||
|
||
- Depends on: **`CalendarProvider` interface** — the stub is the third implementation; the interface must be defined before the stub can exist.
|
||
- Ordering note: the stub is needed early in v1.2 to unblock Playwright CI testing of multi-provider features before live Google credentials are available in CI. It should be implemented in Phase 21 alongside the provider interface.
|
||
- Existing feature dependency: `DEV_AUTH_BYPASS` pattern (Phases 7/8/13) — piggyback on the same env-var convention; no new CI infrastructure required.
|
||
|
||
---
|
||
|
||
## Feature Dependencies
|
||
|
||
```
|
||
CalendarProvider interface (provider abstraction)
|
||
├──required by──> Fastmail broker refactor (existing broker → implements interface)
|
||
├──required by──> Google Calendar provider implementation
|
||
├──required by──> Stub provider (dev/CI mode)
|
||
└──required by──> Calendar picker on event creation (needs unified provider list)
|
||
|
||
Stub provider
|
||
└──enables──> hermetic Playwright CI tests for all multi-provider features
|
||
|
||
Self-Service Google OAuth Onboarding
|
||
└──required by──> Google Calendar events visible in unified view
|
||
└──depends on──> member_credentials table (Phase 10, exists — needs google columns)
|
||
└──depends on──> CalendarProvider interface (credential storage is provider-scoped)
|
||
|
||
Multiple Reminders per Event
|
||
└──depends on──> reminderLeadMinutes field (Phase 11, exists — must become JSON array)
|
||
└──depends on──> VALARM serialization helpers (Phase 11, exist — handle arrays natively)
|
||
└──depends on──> variable-lead scheduler (Phase 11, exists — dedup key update only)
|
||
└──interacts with──> Google provider (reminders.overrides format translation)
|
||
|
||
Zero-Setup DB Bootstrap
|
||
└──depends on──> Drizzle migration files (all phases, exist)
|
||
└──enables──> clean first-run: bootstrap schema → setup wizard → admin config
|
||
|
||
PWA Dark Mode
|
||
└──depends on──> semantic tokens in tokens.css (Phase 17, exists)
|
||
└──independent of all other v1.2 features (fully parallelizable)
|
||
```
|
||
|
||
### Dependency Notes
|
||
|
||
- **Provider abstraction is the keystone.** Google Calendar, the calendar picker, and the stub all require `CalendarProvider` to be defined and Fastmail refactored behind it. This is Phase 21 and must complete before any other multi-provider phase starts.
|
||
- **Stub enables CI for everything else.** Once the interface exists and the stub is wired to `DEV_AUTH_BYPASS`, all subsequent multi-provider phases get hermetic CI for free.
|
||
- **Multiple reminders requires a DB migration.** `reminderLeadMinutes` (single integer) becomes a JSON array column. Use an additive migration — add new column, keep old column until v1.3 cleanup — to avoid breaking the running app during deploy.
|
||
- **Dark mode and zero-setup DB are independent** of all multi-provider work and can be developed in a parallel phase with no ordering constraints.
|
||
- **OAuth callback on iOS Safari standalone PWA.** The Google OAuth redirect-back must work in Safari standalone mode on the wife's iPhone — the same load-bearing constraint as the Authelia OIDC redirect in Phase 3. Must be a human gate before the Google onboarding feature is marked validated.
|
||
|
||
---
|
||
|
||
## v1.2 Feature Prioritization
|
||
|
||
| Feature | User Value | Implementation Cost | Priority |
|
||
|---------|------------|---------------------|----------|
|
||
| CalendarProvider interface + Fastmail refactor | HIGH (unlocks everything multi-provider) | MEDIUM | P1 |
|
||
| Stub provider for CI | HIGH (unlocks hermetic testing immediately) | LOW | P1 |
|
||
| Zero-setup DB bootstrap | HIGH (self-hoster operator UX) | LOW | P1 |
|
||
| Self-service Google OAuth onboarding + token reconnect | HIGH (wife's Google Calendar + non-technical UX) | MEDIUM | P1 |
|
||
| Google Calendar read (unified view) | HIGH (core v1.2 value proposition) | HIGH | P1 |
|
||
| Google Calendar write-back | HIGH (full parity with Fastmail) | MEDIUM | P1 |
|
||
| Token-expiry reconnect banner + one-tap reconnect | HIGH (non-technical Apple member must self-serve this) | LOW | P1 |
|
||
| Multiple reminders per event | MEDIUM (power user UX; v1.1 deferred this) | MEDIUM | P2 |
|
||
| PWA dark mode (System/Light/Dark) | MEDIUM (iOS Dark Mode users; polish) | LOW | P2 |
|
||
| Provider health status indicator in Settings | MEDIUM (ambient awareness without hunting menus) | LOW | P2 |
|
||
| CI dependency updates (999.18) | LOW (hygiene; not user-visible) | LOW | P3 |
|
||
|
||
---
|
||
|
||
## Key UX Notes for Non-Technical Apple Member
|
||
|
||
The wife's experience is a hard constraint on every feature in this milestone:
|
||
|
||
1. **Google onboarding.** Must be a single "Connect Google Calendar" tap that opens a recognizable Google consent screen — she knows what that looks like. The return flow back to FamilySync must work in Safari standalone PWA mode (same OIDC-redirect constraint validated in Phase 3). Do not require her to copy/paste anything.
|
||
|
||
2. **Token reconnect.** When her Google token expires, she must see a banner in the calendar view — not discover it by noticing missing events days later. The banner says "Google Calendar disconnected" with a "Reconnect" button. One tap, done. No re-adding the account from scratch.
|
||
|
||
3. **Calendar picker on event creation.** The picker must default to her most-used calendar; she should not have to think about it for routine events. The dropdown must use calendar names she recognizes ("Personal", "Family"), not provider-internal IDs or email addresses.
|
||
|
||
4. **Dark mode toggle.** In Settings, labeled "Appearance: System / Light / Dark" — simple three-option choice, no color wheel. System should be the default (respects her iPhone's Dark Mode setting automatically).
|
||
|
||
5. **Multiple reminders.** The add/remove reminder list must be tappable with one thumb on iPhone. The "Add reminder" link must meet the 44px minimum touch target. The list must not overflow off-screen on narrow viewports.
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
- [Best Digital Family Calendars 2026 — Morgen](https://www.morgen.so/blog-posts/digital-family-calendar)
|
||
- [Skylight vs Cozi — myskylight.com](https://myskylight.com/blog-best-family-calendar-app-busy-families-cozi/)
|
||
- [Best Family Calendar Apps 2026 — TextConcierge](https://textconcierge.ai/blog/articles/best-family-calendar-apps-2026/)
|
||
- [Google OAuth2 token model — Google for Developers](https://developers.google.com/identity/oauth2/web/guides/use-token-model)
|
||
- [Google Calendar OAuth2 token expiry — n8n Community](https://community.n8n.io/t/google-calendar-oauth2-api-token-expiring-every-once-in-a-while/11336)
|
||
- [Reconnect button pattern — GoHighLevel changelog](https://ideas.gohighlevel.com/changelog/reconnect-button-in-calendar-connections)
|
||
- [Why Google Calendar integration breaks — GoHighLevel](https://help.gohighlevel.com/support/solutions/articles/48001204159-why-google-calendar-integration-breaks)
|
||
- [How to add multiple alerts to calendar events — Morgen](https://www.morgen.so/guides/how-to-add-multiple-alerts-to-calendar-events)
|
||
- [Multiple calendar reminders — GeeksOnTour](https://geeksontour.com/2022/12/how-to-set-multiple-reminders-for-an-event-on-your-calendar/)
|
||
- [Google Calendar API concepts: reminders](https://developers.google.com/workspace/calendar/api/concepts/reminders)
|
||
- [Dark mode CSS guide 2026 — StudioLimb](https://www.studiolimb.com/guides/dark-mode-css-guide.html)
|
||
- [Best light/dark mode toggle in JavaScript — DEV Community](https://dev.to/whitep4nth3r/the-best-lightdark-mode-theme-toggle-in-javascript-368f)
|
||
- [Drizzle ORM migrations docs](https://orm.drizzle.team/docs/migrations)
|
||
- [drizzle-kit migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate)
|
||
- [Google Auth Library for Node.js — googleapis/google-auth-library-nodejs (Context7)](https://github.com/googleapis/google-auth-library-nodejs/blob/main/README.md)
|
||
- [Google Calendar API Node.js quickstart](https://developers.google.com/workspace/calendar/api/quickstart/nodejs)
|
||
- [Restricted scope verification — Google for Developers](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification)
|
||
- [Playwright CI docs](https://playwright.dev/docs/ci)
|
||
|
||
---
|
||
|
||
*Feature research for: FamilySync v1.2 — multi-provider calendar, self-service onboarding, multiple reminders, dark mode, zero-setup DB, dev/CI stub provider*
|
||
*Researched: 2026-06-19*
|