docs: complete v1.2 research (stack, features, architecture, pitfalls)

- 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>
This commit is contained in:
Lucas Berger
2026-06-19 09:27:35 -04:00
co-authored by Claude Opus 4.8
parent 303484d0ab
commit 6e1c9ca924
4 changed files with 1446 additions and 1055 deletions
+306 -251
View File
@@ -1,306 +1,361 @@
# Feature Research
**Domain:** Self-hosted family organization hub — operability & polish milestone (v1.1)
**Researched:** 2026-06-10
**Confidence:** HIGH (v1.0 is shipped; v1.1 features are well-understood domain problems with clear prior art)
**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.1 features**. v1.0 features (calendar, event CRUD, lists, push, OIDC, PWA) are shipped and validated; they are listed as dependencies, not re-researched here.
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.
---
## Feature Landscape
## Category 1: Multi-Provider Calendar Support
### Table Stakes (Users Expect These)
### What "Good" Looks Like
Features that must exist in v1.1 to avoid the product feeling unfinished for real household use.
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.
| Feature | Why Expected | Complexity | Notes |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Per-event reminder selector on event form | Every calendar app (Apple, Google, Fastmail) has this. The current hardcoded 15-min is a bug, not a feature. | MEDIUM | Drop-down of offsets (None / 5 min / 10 min / 15 min / 30 min / 1 hour / 2 hours / 1 day / 2 days) written as VALARM TRIGGER:-PTxM/H or TRIGGER:-P1D in the .ics. "None" default = no VALARM element emitted. |
| "None" is the default alarm state | Apple Calendar defaults new events to "None" alert unless the user has changed their Calendar > Settings > Alerts default. Google Calendar defaults to "30 minutes". The PWA should mirror "None" as the explicit no-alarm state — no alarm = no push. | LOW | Must not silently inherit a global default from Fastmail's own app-password user preferences. Emit no VALARM when "None". |
| Preserve existing VALARMs on edit | If an event was created in Apple Calendar or Fastmail's native client with a specific reminder, editing it in the PWA must not silently strip that reminder. | MEDIUM | tsdav + ical.js round-trip: parse VALARM on fetch, display the closest-matching preset (or "custom" fallback), write back on save. Explicitly handle the case where an existing VALARM is not in the preset list. |
| Scheduler honors per-event lead | The push scheduler must fire at `event_start - trigger_offset`, not a hardcoded 15 min. If no VALARM, fire nothing. | MEDIUM | Depends on outbox/scheduler already built in v1.0. Requires storing the absolute fire-time in the DB so the scheduler does not re-parse ical on every tick. |
| Admin Settings UI (role-gated) | Self-hosted apps cannot require SSH/DB-console access for routine admin. The two tasks (rotate app password, mark shared calendar) are operator-level but must be doable from the browser. | MEDIUM | Single admin flag on the users row; admin sees a Settings section hidden from the other member. Both tasks are currently manual DB writes — table stakes to remove that dependency. |
| Initial setup wizard (first run) | Without a wizard, first-time deploy requires hand-editing env files in the right order, running VAPID key generation manually, and hoping the DB connection string is correct. Nextcloud/Gitea/Authelia all ship a first-run wizard for exactly this reason. | HIGH | Gate on a `setup_complete` flag persisted in the DB or a dotfile. Must validate each credential before advancing: DB ping, OIDC discovery endpoint reachable, VAPID keys structurally valid, app password CalDAV test connection. |
| Event-driven outbox drain | Current ~15s latency (polling interval) makes edits feel sluggish when the user sees the event unchanged for 10+ seconds after saving. Every other calendar app (Google, Apple, Fastmail native) round-trips writes in under 2 seconds perceived. | MEDIUM | The existing transactional outbox guarantees durability. The fix is to trigger an immediate drain on INSERT to the outbox table, rather than waiting for the next scheduled tick. |
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.
### Differentiators (Competitive Advantage for This Product)
### Table Stakes
Features that go beyond what a user would minimally expect — meaningful for this specific 2-person self-hosted context.
| 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 |
| Feature | Value Proposition | Complexity | Notes |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Multiple reminders per event (up to 2) | Apple Calendar supports multiple alerts per event. The iCalendar spec (RFC 5545) allows multiple VALARM components in one VEVENT. Most household events benefit from a "1 day before" + "30 min before" pair. | MEDIUM | Add a second optional reminder offset selector on the form. Emit two VALARM blocks in the .ics. Parse up to 2 existing VALARMs. Do not expose this in v1.1 if it risks slipping the phase; single-alarm is the floor. |
| All-day event reminder semantics matching Apple Calendar | Apple Calendar fires all-day alerts at 9 AM on the alert day (same day or 1 day before, etc.), not at 00:00. Google Calendar fires at 11:50 PM the night before for a "10-min" all-day offset, which is jarring. The Apple convention (morning-of) is the correct UX for this household's non-technical Apple member. | LOW | When the event is all-day and the reminder offset is "on the day" or "1 day before", the VALARM TRIGGER is written as a day-relative offset (`TRIGGER:-P1D` or `TRIGGER:P0D`). The scheduler fires at 9:00 AM on the resolved day (not midnight). This is a scheduler config constant, not user-settable. |
| CI regression gating on PR | Prevents the class of regressions introduced during v1.0 (tsc passes but runtime breaks, API test failures not caught until manual verify). Gitea Actions is already available on the self-hosted Gitea instance. | MEDIUM | Lint + typecheck (both apps) + unit tests (Vitest) + API integration tests against a MariaDB service container. Fails PR merge if any step fails. Significantly reduces the human verification burden per phase. |
| Mobile-emulated Playwright test harness | The CLAUDE.md preference is to use playwright-cli for validation rather than asking the operator. A mobile-emulated (iPhone viewport, touch, user-agent) authenticated harness lets the assistant catch mobile-only layout regressions, modal overflow, and form usability issues before handing off for iOS-hardware verification. | MEDIUM | Playwright `devices['iPhone 15']` or equivalent device preset, session reuse via `storageState`, DEV_AUTH_BYPASS=true for CI. Does not replace real-device iOS tests (push, standalone mode). |
| Docker image publish from CI | Currently the image is built manually. Auto-publishing on merge to main means Unraid can pull the latest image without an SSH session. | LOW | Gitea Packages registry or Docker Hub. Triggered on PR merge to main (not on every PR). |
### Differentiators
### Anti-Features (Explicitly Exclude)
| 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 |
Features that appear reasonable but are wrong for a 2-person self-hosted household. Flag these as scope creep.
### Anti-Features
| Feature | Why Requested | Why to Exclude | What to Do Instead |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User-facing notification preferences page (per-member mute, granular notification types) | Every commercial app has this. Feels like a natural extension of reminder settings. | Two users, both presumably want reminders. This adds a settings surface that 100% of users must click through and that creates support burden (why am I not getting reminders?). The non-technical member should never need to configure this. | Set sensible defaults (all push types on) and never ask. If one member doesn't want push, they decline the browser permission prompt — the OS handles it. |
| Reminder "snooze" in the notification payload | RFC 9074 defines a snooze mechanism via sibling VALARM components. Fantastical supports it. | Implementing reliable snooze requires creating a new VALARM in the .ics (PUT back to Fastmail) from the service worker notification click handler. That is a write path triggered from a background service worker — a significant reliability and complexity risk. | Dismiss and re-add a reminder manually if needed. The use case is too rare for this household to justify the implementation risk. |
| Setup wizard re-run / reset | Advanced users might want to re-run parts of the wizard (e.g., rotate VAPID keys). | For a 2-person deployment, the operator can edit the env file or use the Admin Settings page for app-password rotation. A re-runnable wizard adds state-management complexity (partial completion, rollback). | Admin Settings covers the post-setup operational cases (app password rotation, shared calendar designation). VAPID key rotation is a documented manual step (generate, update env, redeploy). |
| Full audit log in Admin Settings | Some self-hosted admin panels (Gitea, Nextcloud) ship an audit log of administrative actions. | Two users. Both equal operators. There is no adversarial scenario between two family members that requires an audit trail. | N/A — omit entirely. |
| Calendar provider abstraction / plugin system | Would make the admin panel a "configure any CalDAV provider" experience. | The constraint is Fastmail + this specific household. A provider abstraction layer adds 3x the surface area for zero current benefit. | Hard-code Fastmail CalDAV principal discovery. Document the URL in the admin panel for transparency. If a second provider is ever needed, add it as a targeted feature in v2. |
| Self-service member onboarding via wizard | The setup wizard bootstraps the operator. Member onboarding (per-member app password collection) is a distinct problem (backlog 999.5). | Mixing these into one wizard creates a flow that only the operator completes — the other member would encounter a half-configured wizard. | Keep operator setup wizard and member onboarding as separate concerns. Admin Settings covers the operator side of member credential management. |
| Health dashboard / status page in Admin Settings | Uptime graphs, service health indicators, DB query stats. Seen in Nextcloud admin. | Single Docker host, two users. If the app is down, both users know immediately. There is no ops team monitoring this. | Docker logs + Unraid dashboard are sufficient. The setup wizard validates connectivity once; that is the only point of truth needed. |
| E2E test suite that runs on real iOS Safari | Complete mobile coverage in CI | Real iOS Safari requires physical device or paid cloud service (BrowserStack). Not reproducible in a self-hosted Gitea runner. | Playwright mobile emulation covers layout/interaction. Real-device iOS tests remain a human gate for push + standalone mode (as in v1.0). |
| 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 12 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
```
[Per-event reminder UI]
──requires──> [Event create/edit form] (v1.0, shipped)
──requires──> [VALARM parse on CalDAV fetch] (tsdav + ical.js, v1.0 partial)
──requires──> [Push scheduler with per-event fire time] (outbox/scheduler, v1.0 shipped)
└──enhanced by──> [Faster write-back] (drain triggers scheduler sooner)
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)
[Faster write-back]
└──requires──> [Transactional outbox] (v1.0, shipped)
└──enhanced by──> [Per-event reminder UI] (alarm fire times stored at write time)
Stub provider
└──enables──> hermetic Playwright CI tests for all multi-provider features
[Admin Settings UI]
└──requires──> [OIDC login + role field on users table] (v1.0, shipped — needs admin flag)
└──requires──> [App password encrypted storage] (v1.0, shipped)
└──enhances──> [Setup wizard] (wizard hands off to Admin Settings for post-setup credential rotation)
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)
[Setup wizard]
└──requires──> [DB connection] (before anything else can be validated)
└──requires──> [VAPID key generation utility] (web-push keygen, v1.0 shipped in some form)
└──requires──> [OIDC config storage] (env vars or DB config table)
└──gates──> [All other features] (wizard must complete before app is usable)
└──does NOT require──> [Admin Settings UI] (wizard is first-run only; Admin Settings is post-run)
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)
[Gitea CI]
└──requires──> [Gitea act_runner deployed on Unraid host]
└──requires──> [MariaDB service container support in act_runner]
└──requires──> [Vitest unit tests + API integration tests] (v1.0, partially written)
└──enables──> [Mobile-browser test harness] (harness runs as a CI step)
Zero-Setup DB Bootstrap
└──depends on──> Drizzle migration files (all phases, exist)
└──enables──> clean first-run: bootstrap schema → setup wizard → admin config
[Mobile-browser test harness]
└──requires──> [Playwright + mobile device config]
└──requires──> [DEV_AUTH_BYPASS or stored auth session]
└──enhanced by──> [Gitea CI] (harness most valuable when gating PRs automatically)
PWA Dark Mode
└──depends on──> semantic tokens in tokens.css (Phase 17, exists)
└──independent of all other v1.2 features (fully parallelizable)
```
### Dependency Notes
- **Faster write-back is a force multiplier.** It makes the per-event reminder UX feel correct — a user sets a reminder, saves, and expects the scheduler to know about it now, not in 15 seconds. Build faster write-back before or alongside per-event reminders.
- **Admin Settings requires an `is_admin` flag.** v1.0 shipped no role distinction. The schema migration to add `users.is_admin` (default false, first user true) is a prerequisite for the Admin Settings route guard and is trivial.
- **Setup wizard gates the app.** Until the wizard completes, no calendar or list feature should render. The wizard is a deploy-time concern for the operator; it is not a UX concern for the non-technical member (she never sees it — the operator runs it once).
- **Gitea CI depends on act_runner.** If the self-hosted Gitea does not yet have an act_runner container deployed, CI cannot run. This is an infrastructure prerequisite, not a code concern.
- **Mobile harness does not block CI.** CI (lint/typecheck/unit/integration) can ship first. The mobile harness is an additive step.
- **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.
---
## MVP Definition (v1.1)
## v1.2 Feature Prioritization
### Must Ship (table stakes for "operability" claim)
- [ ] Per-event reminder selector — None / preset offsets — single alarm minimum
- [ ] Scheduler uses per-event VALARM trigger offset, fires nothing on None
- [ ] Existing VALARMs preserved on edit round-trip
- [ ] Admin Settings — rotate/re-enter Fastmail app password per member, mark shared calendar
- [ ] Setup wizard — DB, OIDC, VAPID, app password — validated before completion
- [ ] Faster write-back — event-driven outbox drain, edits land in ~1s perceived
### Should Ship (differentiators, don't let them slip the milestone)
- [ ] Gitea CI — lint + typecheck + unit + API integration on PR
- [ ] Mobile-browser test harness — iPhone viewport, authenticated, usable by assistant in playwright-cli
### Defer (not v1.1 scope)
- [ ] Multiple reminders per event (2x VALARM) — v1.1 stretch; defer to v1.2 if risky
- [ ] Self-service member onboarding via wizard (backlog 999.5) — separate feature, separate phase
- [ ] Docker image auto-publish from CI — nice to have; ship manually until CI is stable
| 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 |
---
## Feature Prioritization Matrix
## Key UX Notes for Non-Technical Apple Member
| Feature | User Value | Implementation Cost | Priority |
| --------------------------------------- | ---------- | ------------------- | -------- |
| Per-event reminder selector | HIGH | MEDIUM | P1 |
| Scheduler per-event VALARM | HIGH | MEDIUM | P1 |
| Faster write-back | HIGH | MEDIUM | P1 |
| Admin Settings — app password mgmt | HIGH | MEDIUM | P1 |
| Admin Settings — shared calendar toggle | HIGH | LOW | P1 |
| Setup wizard | HIGH | HIGH | P1 |
| Gitea CI (regression gate) | MEDIUM | MEDIUM | P1 |
| Mobile Playwright harness | MEDIUM | LOW | P2 |
| Multiple reminders per event | MEDIUM | MEDIUM | P2 |
| All-day reminder at 9 AM semantics | MEDIUM | LOW | P2 |
| Docker image auto-publish | LOW | LOW | P3 |
The wife's experience is a hard constraint on every feature in this milestone:
**Priority key:**
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.
- P1: Must have for milestone claim
- P2: High value, ship if no risk to P1
- P3: Nice to have, defer
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.
## Per-Feature Expected Behavior Reference
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).
### 1. Per-Event Reminders
**Reminder selector options (matching Apple Calendar + Google Calendar intersection):**
| Label | VALARM TRIGGER value | Notes |
| ----------------- | -------------------- | ------------------------------------------------------ |
| None | (no VALARM emitted) | Default for new events |
| 5 minutes before | `TRIGGER:-PT5M` | |
| 10 minutes before | `TRIGGER:-PT10M` | |
| 15 minutes before | `TRIGGER:-PT15M` | Current hardcoded default — becomes an explicit choice |
| 30 minutes before | `TRIGGER:-PT30M` | |
| 1 hour before | `TRIGGER:-PT1H` | |
| 2 hours before | `TRIGGER:-PT2H` | |
| 1 day before | `TRIGGER:-P1D` | |
| 2 days before | `TRIGGER:-P2D` | |
**All-day event semantics:** When saving an all-day event with any reminder, the scheduler fires at 09:00 AM local time on the target day (computed as `allday_date + offset_days`). Do not fire at midnight. Apple Calendar uses 9 AM as the "on the day" time for all-day alerts; this matches the wife's expectation.
**Round-trip on edit:** On form open for an existing event, read the first VALARM TRIGGER duration from the parsed ical.js component and select the nearest matching preset. If the existing TRIGGER does not match any preset (e.g., `TRIGGER:-PT7M` from some other client), display a "Custom (preserve)" option and do not strip it on save unless the user explicitly changes the selector. On save with a preset change, replace the VALARM. On save with "None" selected, remove all VALARMs.
**Scheduler contract:** Store `alarm_fire_at` (absolute UTC timestamp) in the `events` or a separate `event_alarms` table at write time. The scheduler queries `alarm_fire_at <= NOW() AND fired = false` — no ical re-parse at fire time. This is the existing outbox/scheduler pattern already in v1.0; extend it.
### 2. Admin Settings
**Scope (exactly this, no more):**
1. **Per-member Fastmail app password** — masked input to re-enter or rotate the encrypted credential stored in the DB. Show member name + "last updated" timestamp. On save, attempt a CalDAV `PROPFIND` test request with the new credential before committing. Surface pass/fail inline. No separate page — a section within Settings.
2. **Shared calendar designation** — list all synced calendars (display name + Fastmail collection URL) with a radio button selecting which one is `is_shared = true`. Currently requires a direct DB write (D-16 debt). One click + confirm. Show the current selection highlighted.
**Role gate:** A single `users.is_admin TINYINT(1) DEFAULT 0` flag. The first user created during the setup wizard gets `is_admin = 1`. The route `/api/admin/*` and the Admin Settings UI section return 403 for non-admin users. No role management UI — the non-technical member never sees this section.
**What Admin Settings is NOT:** It is not a full server configuration panel, not a user management screen, not an audit log, not a health dashboard. It is two operational tasks that currently require DB console access.
### 3. Setup Wizard
**When it runs:** On first visit to the app when a `setup_complete` record is absent from the DB (or a `SETUP_COMPLETE=false` env flag, whichever is simpler). After completion, a `setup_complete = true` record is written and the wizard never appears again.
**Step order (each step validates before advancing):**
| Step | Fields | Validation |
| ------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 1. Welcome | None — explains what the wizard does | None |
| 2. Database | Already connected (MariaDB creds are in env at container start). Show "connected" status. | DB ping; show error + instructions if failed |
| 3. App URL | External URL (used for OIDC redirect) | URL format check; attempt a `HEAD` to itself if reachable |
| 4. OIDC | Client ID, client secret, issuer URL, redirect URI (pre-filled) | Fetch `{issuer}/.well-known/openid-configuration`; show discovered endpoints; fail if unreachable |
| 5. Session secret | Auto-generated 32-byte hex string (user can override) | Length >= 32 chars |
| 6. Encryption key | Auto-generated 32-byte hex string for `APP_PASSWORD_ENCRYPTION_KEY` | Length == 32 bytes |
| 7. VAPID keys | Auto-generate button (calls `webpush.generateVAPIDKeys()`) | Structural check — public key is a valid base64url-encoded P-256 point |
| 8. Admin account | Select from OIDC-discovered members OR enter the sub/preferred_username manually | Not empty |
| 9. Fastmail app password | App password for the primary calendar account | Test CalDAV `PROPFIND` to `https://caldav.fastmail.com/dav/principals/user/<email>/`; show pass/fail |
| 10. Confirm + save | Summary of all inputs | Writes config to DB/env; sets `setup_complete`; redirects to app |
**Validation UX:** Inline per-field — show a green checkmark or red error directly below the field as soon as the user leaves it (blur event) or clicks a test button. "Next" button is disabled until the current step passes validation. Show human-readable error messages: "Could not reach the OIDC issuer — is Authelia running?" not "fetch failed: ERR_CONNECTION_REFUSED".
**Back navigation:** Every step allows going back. Already-validated steps retain their values. Do not re-validate automatically on back; re-validate on "Next".
**Failure surface:** If the DB step fails (impossible at startup in Docker but possible in dev), show a non-wizard error page with setup instructions — the wizard itself cannot run without a DB.
### 4. Faster Write-Back
**Target perceived latency:** < 2 seconds from "Save" click to the event appearing correctly in the calendar view. This matches Google Calendar and Fastmail native behavior.
**Current latency:** Up to ~15 seconds (outbox poll interval) + 5-minute CalDAV read-back poller.
**Approach:**
1. The existing optimistic-202 response already updates the React Query cache immediately on save (latency = 0 for the UI). The gap is the CalDAV write-back actually landing, which matters for reminders and cross-device visibility.
2. On INSERT to the `outbox` table, emit an event (in-process EventEmitter or a Redis pub/sub message if the outbox worker is in a separate process) that triggers an immediate drain attempt.
3. On successful CalDAV PUT, emit an SSE `calendar-updated` event to connected clients so React Query invalidates the calendar cache and re-fetches. The re-fetch is the "write landed" confirmation.
4. Reduce the read-back poller interval from 5 min to 30 s as a fallback — not the primary path, just the safety net.
**Durability guarantee preserved:** The outbox row is not deleted until the CalDAV PUT succeeds. The optimistic-202 pattern is unchanged. The drain is just triggered eagerly instead of lazily.
**What "near-immediate" does NOT mean:** CalDAV is a synchronous HTTP PUT. If Fastmail is slow (>1s), the write takes >1s. The goal is to eliminate the artificial polling delay, not to change network physics.
### 5. Gitea CI
**Trigger:** On `pull_request` to `main`.
**Steps:**
1. Checkout
2. pnpm install (cached)
3. TypeScript typecheck — `pnpm -r tsc --noEmit` (both `apps/api` and `apps/pwa`)
4. ESLint — `pnpm -r lint`
5. Vitest unit tests — `pnpm -r test:unit`
6. API integration tests — spin up MariaDB service container, run `pnpm --filter api test:integration` with `DB_HOST=127.0.0.1`
**Service container pattern:** Gitea Actions uses the same `services:` syntax as GitHub Actions. MariaDB `mariadb:11` with `MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE` env vars. `options: --health-cmd="mariadb-admin ping -h localhost" --health-interval=10s --health-retries=5` to gate the test step on DB readiness.
**Docker image publish:** Separate workflow, trigger `push` to `main` (after PR merge). Builds and pushes to the Gitea Container Registry. Not part of the PR workflow — keeps PR checks fast.
**Known limitation:** Gitea act_runner in Docker has incomplete service volume support. Do not mount host volumes in the services block. The MariaDB service container using env-based config (no volume) is reliable.
### 6. Mobile-Browser Test Harness
**What it is:** A reusable Playwright configuration profile using `devices['iPhone 15']` (or equivalent) with stored auth state (`DEV_AUTH_BYPASS=true` or a saved `storageState` JSON from a prior login), usable by the assistant via `playwright-cli` without re-authenticating on every run.
**What it covers:**
- Mobile viewport layout (bottom nav, drawer sizing, touch targets >= 44px)
- Calendar view rendering at iPhone screen width
- Event form usability on mobile (reminder selector visible, not clipped)
- List co-edit interactions on mobile
**What it does NOT cover:** Real iOS Safari, Web Push delivery, standalone-mode OIDC redirect, iOS-specific service worker quirks. Those remain human gates.
**Integration with CI:** A `test:e2e:mobile` script in `apps/pwa/package.json`. Run in CI as an optional step (allowed to fail without blocking merge) until the harness is proven stable, then graduate to blocking.
**Auth strategy for CI:** `DEV_AUTH_BYPASS=true` with a known test user id. The harness does not run the full OIDC flow — it injects the bypass session directly. This matches the v1.0 dev-stack bring-up pattern.
---
## Competitor / Prior Art Reference
| Feature | Apple Calendar | Google Calendar | Fastmail native | Nextcloud | This Product (v1.1 target) |
| --------------------- | ---------------------------------------------- | ----------------------------------------------- | -------------------- | -------------------- | ----------------------------------------------- |
| Reminder presets | None / 5m / 15m / 30m / 1h / 2h / 1d / 2d / 1w | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d | None / 15m / 1h / 1d | N/A | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d |
| Multiple alarms | Yes (up to 5) | Yes (up to 5) | Yes | N/A | V1.1: 1; stretch: 2 |
| All-day reminder time | 9 AM on alert day | 11:50 PM night before (jarring) | Morning | N/A | 9 AM (Apple convention) |
| Admin credential mgmt | N/A | N/A | N/A | Yes (complex) | Minimal: 2 tasks only |
| Setup wizard | N/A | N/A | N/A | Yes (3-step minimal) | 10-step validated |
| Write-back latency | ~1s | ~1s | ~1s | Varies | Target ~1s (from ~15s) |
| CI | N/A | N/A | N/A | GitHub Actions | Gitea Actions |
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
- [Apple Calendar default alert settings (Mac support)](https://support.apple.com/guide/calendar/change-default-alert-settings-icl4407ddb59/mac)
- [Google Calendar notifications (Android)](https://support.google.com/calendar/answer/37242?hl=en)
- [iCalendar RFC 5545 — VALARM component](https://icalendar.org/iCalendar-RFC-5545/3-6-6-alarm-component.html)
- [RFC 9074 — VALARM Extensions](https://datatracker.ietf.org/doc/html/rfc9074)
- [iCalendar TRIGGER property spec](https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html)
- [Nextcloud installation wizard](https://docs.nextcloud.com/server/stable/admin_manual/installation/installation_wizard.html)
- [Wizard UI patterns — LogRocket UX](https://blog.logrocket.com/ux-design/creating-setup-wizard-when-you-shouldnt/)
- [NN/G Wizards: Definition and Design Recommendations](https://www.nngroup.com/articles/wizards/)
- [Outbox Pattern — Conduktor](https://www.conduktor.io/glossary/outbox-pattern-for-reliable-event-publishing/)
- [Transactional Outbox with Optimistic Sending](https://www.npiontko.pro/2025/05/26/outbox-pattern-optimistic)
- [Optimistic UI Patterns — Simon Hearne](https://simonhearne.com/2021/optimistic-ui-patterns/)
- [Gitea Actions — Act Runner docs](https://docs.gitea.com/usage/actions/act-runner)
- [Gitea Actions Docker builds](https://blog.diblasio.social/posts/gitea_builder/)
- [Playwright PWA mobile testing](https://dev.to/pritig/how-playwright-simplifies-ui-testing-for-progressive-web-apps-pwas-9n8)
- [Playwright emulation docs](https://playwright.dev/docs/emulation)
- [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.1Operability & Polish_
_Researched: 2026-06-10_
*Feature research for: FamilySync v1.2multi-provider calendar, self-service onboarding, multiple reminders, dark mode, zero-setup DB, dev/CI stub provider*
*Researched: 2026-06-19*