7 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 cf9e345b87 docs(quick-260619-jp4): add app screenshots to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:24:33 -04:00
Lucas BergerandClaude Opus 4.8 23dc3cdd3f docs(readme): add app screenshots and Features section
Capture six PWA views (calendar, lists, list detail, login, setup wizard,
admin) into docs/screenshots/ and rebuild the README intro into a Features
section that expands the project description, pairing each capability with
its screenshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:24:13 -04:00
Lucas Berger 700294a643 docs: create milestone v1.2 roadmap (8 phases) 2026-06-19 14:09:29 -04:00
Lucas Berger 67430bb670 docs: regenerate research SUMMARY for v1.2 2026-06-19 14:01:13 -04:00
Lucas Berger 4e0a5e38e8 docs: define milestone v1.2 requirements 2026-06-19 13:56:19 -04:00
Lucas BergerandClaude Opus 4.8 6e1c9ca924 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>
2026-06-19 09:27:35 -04:00
Lucas Berger 303484d0ab docs: start milestone v1.2 Multi-Provider, Theming & Zero-Setup 2026-06-19 09:13:30 -04:00
18 changed files with 1920 additions and 1382 deletions
+18 -3
View File
@@ -16,9 +16,24 @@ v1.1 turned the v1.0 MVP into a configurable, administrable, maintainable app: g
Deferred to backlog: self-service provider onboarding (999.5), provider abstraction (999.1), dark mode / theming (999.20), and a broader modern-styling refresh (999.21 — future milestone).
## Next Milestone
## Current Milestone: v1.2 Multi-Provider, Theming & Zero-Setup
Not yet defined. Start with `/gsd-new-milestone` (questioning → research → requirements → roadmap). Candidate seeds in the backlog: dark mode / theming (999.20), modern visual refresh (999.21), self-service onboarding (999.5), provider abstraction (999.1), dev-user full-app exercise without a real calendar (999.19), and acting on the CI dependency report (999.18).
**Goal:** Open FamilySync beyond Fastmail — add Google Calendar as a first-class provider behind a provider-abstraction layer with self-service per-provider onboarding — plus dark mode, multiple reminders per event, and a database that bootstraps itself with no manual setup.
**Target features:**
- **Provider abstraction** — refactor Fastmail behind a provider interface so additional calendar providers plug in cleanly (999.1).
- **Gmail / Google Calendar, full parity** — read + write-back + reminders via the Google Calendar API + OAuth2 (not CalDAV); the second provider behind the abstraction.
- **Self-service onboarding, per-provider native flow** (999.5, pivoted to generic provider) — Fastmail = app-password entry, Gmail = "Connect Google" OAuth button; low-friction for the non-technical Apple member.
- **Multiple reminders per event** — multiple VALARMs per event (deferred to v1.2 from v1.1).
- **PWA dark mode / theming** (999.20) — light/dark/system switch consuming the Phase 17 semantic-token groundwork.
- **Zero-manual-setup DB** — auto-create/migrate tables on boot if missing (bring-your-own MariaDB: creds + DB name → working schema), then the existing setup wizard handles admin + config.
- **Dev-user full-app exercise** (999.19) — exercise the whole app without a real calendar connected.
- **Act on CI dependency report** (999.18) — apply the outdated/vulnerable dependency updates CI surfaces.
**Explicitly out of this milestone:** modern visual refresh (999.21 stays in backlog — current UX is satisfactory). v1.3 is reserved for tablet/touchscreen UX.
Phase numbering continues from v1.1 (last phase 20) → v1.2 starts at Phase 21.
## Requirements
@@ -127,4 +142,4 @@ This document evolves at phase transitions and milestone boundaries.
---
_Last updated: 2026-06-18 after v1.1 milestone — Operability & Polish shipped (Phases 720, 57 plans): guided setup, in-app admin + member editor, per-event reminders, faster write-back, local-auth mode, auto timezone, and full Gitea CI/CD. Next milestone undefined — start with `/gsd-new-milestone`._
_Last updated: 2026-06-19 — v1.2 milestone started (Multi-Provider, Theming & Zero-Setup): provider abstraction + Gmail full parity, self-service per-provider onboarding, multiple reminders per event, PWA dark mode, zero-manual-setup DB bootstrap, dev-user full-app exercise, and CI dependency updates. Phases continue from 21._
+89
View File
@@ -0,0 +1,89 @@
# Requirements: FamilySync — v1.2 "Multi-Provider, Theming & Zero-Setup"
**Defined:** 2026-06-19
**Milestone:** v1.2 (continues from v1.1 "Operability & Polish", shipped 2026-06-18)
**Core Value:** The household can see and co-edit one color-coded family calendar (shared + each member's personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store. v1.2 **opens the app beyond Fastmail** (Google Calendar as a first-class provider behind a provider abstraction, with self-service per-provider onboarding), adds **dark mode** and **multiple reminders per event**, and makes the database **bootstrap itself with no manual setup**.
REQ-IDs continue prior numbering (CAL ≤15, SETUP ≤04, TEST ≤02 already used). New categories: **PROV** (providers & onboarding), **THEME** (PWA theming), **DEP** (dependency maintenance).
## v1.2 Requirements
Each requirement maps to exactly one roadmap phase (see Traceability).
### Providers — abstraction & self-service onboarding
- [ ] **PROV-01**: All calendar read, write-back, and reminder operations flow through a single `CalendarProvider` abstraction; the existing Fastmail/CalDAV broker is refactored behind it as one implementation with **no behavior regression** to the working Fastmail path (outbox durability, RRULE/VALARM preservation, optimistic-202, per-uid exactly-once all preserved).
- [ ] **PROV-02**: A member can connect a **Google account via a "Connect Google" OAuth flow** (self-service, no admin involvement); the OAuth refresh token is stored encrypted (AES-256-GCM, same path as Fastmail app passwords) and `member_credentials` supports one credential **per provider per user** (`UNIQUE(user_id, provider_type)`).
- [ ] **PROV-03**: A member can self-service connect or re-enter **their own** provider credential using each provider's native mechanism — Fastmail = app-password entry, Google = OAuth consent — without an admin rotating it for them (generalizes the v1.1 admin-managed ADMIN-01 path).
- [ ] **PROV-04**: When a provider connection expires or is revoked, the user sees a **clear, in-calendar banner** (not hidden in settings) and can **reconnect in one tap** by re-running the provider's auth flow, without removing and re-adding the account.
### Calendar — Google parity & multiple reminders
- [ ] **CAL-16**: Events from a connected **Google calendar appear in the unified color-coded calendar** (read sync), with recurring events expanded correctly (Google recurrence/cancelled-instance model reconciled to the app's RRULE/EXDATE model) and all-day events not shifted.
- [ ] **CAL-17**: A user can **create / edit / delete events on a connected Google calendar** from the app, written back to Google — full parity with the Fastmail write path, including recurring-event create and whole-series delete.
- [ ] **CAL-18**: A user can set **multiple reminders on a single event** (up to 5), each firing independently; the set serializes to the connected provider's native model (multiple VALARMs for Fastmail / multiple `overrides` for Google), **preserves reminders authored in another client** on edit, and the scheduler fires each lead exactly once (dedup key includes the lead).
### Theming — PWA dark mode
- [ ] **THEME-01**: A user can switch the PWA between **Light / Dark / System**, the choice is persisted, there is **no flash-of-wrong-theme** on load, and the dark theme renders cleanly across every route (calendar, lists, admin, settings sheet, login) including the Schedule-X calendar component's `--sx-color-*` variables.
### Setup — zero-manual-setup database bootstrap
- [ ] **SETUP-05**: On boot, the app **auto-creates and migrates the full schema** against a bring-your-own MariaDB — the operator supplies only DB credentials and a database name — running drizzle migrations programmatically (`drizzle-orm/mysql2/migrator`, never `drizzle-kit push`), **idempotently and concurrency-safely** (migration-lock sentinel), completing **before requests are served**, so no manual `db:migrate` step is required.
### Test — dev/mock provider (full-app exercise without a live calendar)
- [ ] **TEST-03**: A **mock/in-memory `CalendarProvider`** lets the dev-bypass user and the Playwright CI harness exercise the **full app** — calendar create/edit/delete, reminders, and lists — with **no live calendar account**, producing deterministic seed data covering recurring, all-day, past, and future events.
### Dependencies — apply CI-surfaced updates
- [ ] **DEP-01**: Apply the **outdated/vulnerable dependency upgrades** surfaced by CI (`pnpm audit` / `pnpm outdated`), respecting the existing version pins and constraints (ESLint held at 9.x until `eslint-plugin-react` supports 10; Drizzle/drizzle-kit kept coupled; Schedule-X minor-only and not bumped before dark mode is locked), with **all CI gates green** after the update.
## Future Requirements (deferred, not in v1.2)
- **Additional calendar providers** beyond Fastmail + Google (the PROV-01 abstraction makes them cheaper to add later).
- **Google Calendar webhook push** (`events.watch`) for lower-latency sync — the existing 5-min ctag/poll model is sufficient for a two-person household; evaluate only if latency becomes a problem.
- **Wizard re-run / reconfigure** flow after first setup (carried from v1.1 backlog).
- **Android PWA install walkthrough** verified on a real device (carried from v1.0).
- **Tablet / touchscreen UX** — explicitly reserved for **v1.3**.
## Out of Scope (explicit exclusions)
- **Modern visual styling refresh** (backlog 999.21) — current UX is satisfactory; this is a separate future redesign track, not v1.2.
- **Per-event Google color (`colorId`) honoring** — FamilySync uses its own per-calendar colors for consistency; Google's per-event colors are ignored.
- **CalDAV/JMAP for Google** — Google integration uses the Google Calendar API + OAuth2; no CalDAV bridge.
- **Notification-preferences UI / reminder snooze** — the per-event multi-reminder selector (CAL-18) is sufficient for a 2-member household.
- **`googleapis` monolith** — use the scoped `@googleapis/calendar` + `google-auth-library` to avoid the ~50 MB footprint.
- **`drizzle-kit push` at boot** — emits a false destructive diff on populated MariaDB 11; boot migration uses the `migrate()` API only.
- **Bulk `pnpm up`** for DEP-01 — selective, pin-aware updates only.
## Traceability
Maps each REQ-ID to its phase. v1.2 phases continue prior numbering (v1.1 ended at Phase 20) → v1.2 starts at **Phase 21**.
| REQ-ID | Phase | Status |
| -------- | -------- | ----------- |
| SETUP-05 | Phase 21 | Not started |
| PROV-01 | Phase 22 | Not started |
| CAL-18 | Phase 23 | Not started |
| TEST-03 | Phase 24 | Not started |
| CAL-16 | Phase 25 | Not started |
| CAL-17 | Phase 25 | Not started |
| PROV-02 | Phase 25 | Not started |
| PROV-03 | Phase 26 | Not started |
| PROV-04 | Phase 26 | Not started |
| THEME-01 | Phase 27 | Not started |
| DEP-01 | Phase 28 | Not started |
**Coverage:** 11/11 requirements mapped to exactly one phase — no orphans, no duplicates.
| Phase | Requirements |
| ----- | ------------ |
| Phase 21 — Zero-Setup DB Bootstrap | SETUP-05 |
| Phase 22 — Provider Abstraction | PROV-01 |
| Phase 23 — Multiple Reminders Per Event | CAL-18 |
| Phase 24 — Dev/Mock Provider | TEST-03 |
| Phase 25 — Google Calendar Provider | CAL-16, CAL-17, PROV-02 |
| Phase 26 — Self-Service Provider Onboarding | PROV-03, PROV-04 |
| Phase 27 — PWA Dark Mode | THEME-01 |
| Phase 28 — Dependency Updates | DEP-01 |
+133 -230
View File
@@ -4,8 +4,7 @@
-**v1.0 MVP** — Phases 16 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
-**v1.1 Operability & Polish** — Phases 720 (shipped 2026-06-18) — see [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md)
> Next milestone not yet defined — start with `/gsd-new-milestone`.
- 🟡 **v1.2 Multi-Provider, Theming & Zero-Setup** — Phases 2128 (in progress)
## Phases
@@ -45,6 +44,111 @@ Full phase detail archived in [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROA
</details>
### 🟡 v1.2 Multi-Provider, Theming & Zero-Setup (Phases 2128)
- [ ] **Phase 21: Zero-Setup DB Bootstrap** - App auto-creates/migrates its schema on boot against a bring-your-own MariaDB — no manual `db:migrate`
- [ ] **Phase 22: Provider Abstraction** - Refactor Fastmail/CalDAV behind a `CalendarProvider` interface with no Fastmail regression; widen credential uniqueness to `(user_id, provider_type)`
- [ ] **Phase 23: Multiple Reminders Per Event** - Up to 5 independent reminders per event, serialized to N VALARMs, other-client alarms preserved
- [ ] **Phase 24: Dev/Mock Provider** - In-memory provider lets the dev user + Playwright exercise the full app with no live calendar account
- [ ] **Phase 25: Google Calendar Provider** - Connect a Google account via OAuth; Google events read + write + reminders at Fastmail parity
- [ ] **Phase 26: Self-Service Provider Onboarding** - Members connect/reconnect their own provider (Fastmail app-password / Google OAuth) without admin help, with a one-tap reconnect banner
- [ ] **Phase 27: PWA Dark Mode** - Light/Dark/System theme switch, persisted, no flash-of-wrong-theme, clean across every route incl. Schedule-X
- [ ] **Phase 28: Dependency Updates** - Apply CI-surfaced outdated/vulnerable upgrades, pin-aware and per-ecosystem, with all CI gates green
## Phase Details
### Phase 21: Zero-Setup DB Bootstrap
**Goal**: A bring-your-own MariaDB (creds + DB name only) becomes a working FamilySync schema with no manual migration step — the app migrates itself on boot, idempotently and concurrency-safely, before it serves any request.
**Depends on**: Nothing (touches only `index.ts` startup + `db/migrations`)
**Requirements**: SETUP-05
**Success Criteria** (what must be TRUE):
1. Pointing the app at an empty MariaDB (DB credentials + database name supplied) and starting it produces the full, current schema with no manual `db:migrate` / `docker exec` step.
2. Restarting an already-migrated app is a no-op — no duplicate-migration error, no destructive diff, and existing data is untouched.
3. Two app instances starting against the same fresh database concurrently both come up cleanly — the migration runs exactly once (migration-lock sentinel), the loser waits rather than racing.
4. If the database is unreachable or migration fails, the app refuses to start (fatal) rather than serving on a partial schema.
5. The first request the setup wizard receives already has a complete schema — no setup-wizard call ever hits a missing table.
**Plans**: TBD
### Phase 22: Provider Abstraction (CalDAV only, Fastmail unchanged)
**Goal**: All calendar read, write-back, and reminder operations flow through one `CalendarProvider` seam, with the existing Fastmail/CalDAV broker wrapped behind it verbatim — so future providers plug in without touching the poller, outbox worker, or scheduler, and the live Fastmail path behaves identically.
**Depends on**: Phase 21 (schema auto-migrates before the integration suite runs)
**Requirements**: PROV-01
**Success Criteria** (what must be TRUE):
1. The existing Fastmail calendar continues to read, write (create/edit/delete, recurring + whole-series), and remind with no observable change — every v1.1 outbox/poller/scheduler integration test passes unchanged against the new interface.
2. Optimistic-202, create-before-delete on moves, per-uid exactly-once, the `isDraining` drain guard, and RRULE/VALARM preservation on edit all still hold (no per-provider drain locks introduced).
3. The poller and outbox worker select the implementation per member credential via a `createProvider(cred)` factory keyed on `provider_type`, with `caldav` as the default branch.
4. The `member_credentials` uniqueness constraint is widened to `UNIQUE(user_id, provider_type)` (migrated, no data loss), unblocking one credential per provider per user, and the token-storage shape (separate `provider_tokens` table vs. extended `member_credentials`) is decided and recorded.
**Plans**: TBD
### Phase 23: Multiple Reminders Per Event
**Goal**: A user can set several independent reminders on one event (up to 5), each firing once at its own lead, serialized to the connected provider's native model, without clobbering reminders authored in another client.
**Depends on**: Phase 22 (the CalDAV provider's write path accepts a reminder-leads array)
**Requirements**: CAL-18
**Success Criteria** (what must be TRUE):
1. In the event form a user can add and remove multiple reminder rows (up to 5); on edit, all existing reminders pre-populate.
2. Each reminder fires independently and exactly once — the scheduler dedup key includes the lead (`uid:dtstartMs:lead`), so two reminders on the same event don't collapse or double-fire.
3. Saving an event writes one VALARM per chosen lead to Fastmail (multiple sub-components in the VCALENDAR); reminders authored in another client are preserved on edit (preserve-vs-replace driven by a remindersChanged signal).
4. Existing single-reminder events keep their reminder after the schema migration (the old `reminder_lead_minutes` value is migrated into the new `reminder_leads` JSON array before the old column is dropped).
**Plans**: TBD
### Phase 24: Dev/Mock Provider (full-app exercise without a live calendar)
**Goal**: The dev-bypass user and the Playwright CI harness can exercise the entire app — calendar create/edit/delete, reminders, and lists — with no real calendar account connected, against deterministic seed data.
**Depends on**: Phase 22 (the `CalendarProvider` interface + factory); benefits from Phase 23 (reminder model) for reminder coverage
**Requirements**: TEST-03
**Success Criteria** (what must be TRUE):
1. Under `DEV_AUTH_BYPASS` (dev user, `provider_type='mock'` / no credential row), the calendar populates with seeded events — recurring, all-day, past, and future — with no live provider and no network I/O.
2. The dev user can create, edit, and delete events and set reminders end-to-end through the UI; the mock provider absorbs the outbox writes and reflects them back.
3. The Playwright harness exercises calendar CRUD hermetically (no live Fastmail/Google account), so CI can validate the full flow.
4. The mock path is strictly dev-only (same hard production guard as the existing dev bypass) and never activates in a production image.
**Plans**: TBD
### Phase 25: Google Calendar Provider
**Goal**: A member can connect a Google account and have their Google calendar reach full Fastmail parity — events appear in the unified color-coded view (recurrence + all-day correct), and create/edit/delete (including recurring + whole-series) and reminders write back to Google.
**Depends on**: Phase 22 (provider interface), Phase 23 (reminder serialization contract), Phase 21 (token table exists after migrate-on-boot)
**Requirements**: CAL-16, CAL-17, PROV-02
**Success Criteria** (what must be TRUE):
1. After connecting a Google account, that calendar's events appear in the unified color-coded calendar — recurring events expand correctly (Google recurrence/cancelled-instance model reconciled to the app's RRULE/EXDATE model) and all-day events are not shifted.
2. A user can create, edit, and delete events on the connected Google calendar from the app — including recurring-event create and whole-series delete — written back to Google.
3. Reminders set in the app serialize to Google `overrides` (and parse back), capped at 5, preserving other-client alarms.
4. The OAuth refresh token is stored AES-256-GCM encrypted (never in `app_config`), via the `UNIQUE(user_id, provider_type)` model; access tokens refresh inline and a Google `syncToken` 410 triggers a safe full re-sync without overwriting un-drained local writes.
5. The Google OAuth callback is mounted pre-auth (distinct path + distinct state cookie, redirect URI from `EXTERNAL_BASE_URL`) and round-trips correctly through the Pangolin hostname.
**Plans**: TBD
**UI hint**: yes
### Phase 26: Self-Service Provider Onboarding
**Goal**: A member can connect, re-enter, or reconnect their own provider credential using each provider's native mechanism — Fastmail app-password entry, Google "Connect Google" OAuth — without an admin acting for them, and a clear in-calendar banner makes a broken connection a one-tap fix.
**Depends on**: Phase 25 (the Google OAuth authorize/callback must exist before the UI redirects to it)
**Requirements**: PROV-03, PROV-04
**Success Criteria** (what must be TRUE):
1. A member sees which providers they have connected and can self-service connect their own credential — Fastmail via an app-password form (live-validated), Google via a single "Connect Google Calendar" button — with no admin involvement.
2. When a provider connection expires or is revoked, the user sees a clear banner in the calendar view (not buried in settings), not a silent failure.
3. From that banner the user can reconnect in one tap by re-running the provider's auth flow, without removing and re-adding the account.
4. A member can disconnect a provider, which removes its credential/tokens and its synced calendars + events.
**Plans**: TBD
**UI hint**: yes
### Phase 27: PWA Dark Mode
**Goal**: A user can switch the PWA between Light / Dark / System, the choice persists, and the dark theme renders cleanly everywhere with no flash-of-wrong-theme on load.
**Depends on**: Nothing (pure frontend; parallelizable with Phases 2226)
**Requirements**: THEME-01
**Success Criteria** (what must be TRUE):
1. A theme toggle in Settings lets the user pick Light / Dark / System; the choice is persisted (per-device localStorage) and survives reload.
2. On load there is no flash-of-wrong-theme — the resolved theme is applied before first paint (inline pre-paint script reading the stored preference).
3. The dark theme renders cleanly across every route — calendar, lists, admin, settings sheet, and login — including the Schedule-X calendar component's `--sx-color-*` variables and the per-member color lanes.
4. "System" tracks the OS `prefers-color-scheme` and updates live when the OS theme changes; the iOS `theme-color` meta reflects the active theme.
**Plans**: TBD
**UI hint**: yes
### Phase 28: Dependency Updates
**Goal**: The outdated/vulnerable dependency upgrades that CI surfaces are applied — selectively and pin-aware — leaving the app on current, non-CVE-bearing dependencies with every CI gate green.
**Depends on**: Phase 27 (Schedule-X is not bumped until dark mode is locked); runs last so all other v1.2 work is already merged
**Requirements**: DEP-01
**Success Criteria** (what must be TRUE):
1. The CI dependency report's outdated/vulnerable packages are upgraded (no bulk `pnpm up` / `audit --fix`), per-ecosystem and pin-aware — ESLint held at 9.x until `eslint-plugin-react` supports 10, Drizzle orm + drizzle-kit bumped together, Schedule-X minor-only.
2. After the updates, all CI gates pass — lint, typecheck, unit, API integration, the Playwright harness, audit, and image hygiene are green.
3. No runtime regression to the calendar, lists, push, provider, or theming paths after the bumps (existing tests + harness still pass).
**Plans**: TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
@@ -69,240 +173,36 @@ Full phase detail archived in [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROA
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
| 21. Zero-Setup DB Bootstrap | v1.2 | 0/? | Not started | - |
| 22. Provider Abstraction | v1.2 | 0/? | Not started | - |
| 23. Multiple Reminders Per Event | v1.2 | 0/? | Not started | - |
| 24. Dev/Mock Provider | v1.2 | 0/? | Not started | - |
| 25. Google Calendar Provider | v1.2 | 0/? | Not started | - |
| 26. Self-Service Provider Onboarding | v1.2 | 0/? | Not started | - |
| 27. PWA Dark Mode | v1.2 | 0/? | Not started | - |
| 28. Dependency Updates | v1.2 | 0/? | Not started | - |
## Dependencies & Critical Path (v1.2)
```
21 (DB Bootstrap) ──► 22 (Provider Abstraction) ──┬─► 23 (Multiple Reminders) ──┐
│ ├─► 25 (Google) ──► 26 (Onboarding)
└─► 24 (Mock Provider) ───────┘
27 (Dark Mode) — independent, parallelizable with 2226
28 (Dependency Updates) — last; after 27 (Schedule-X hold) and all other v1.2 work
```
- **Critical path:** 21 → 22 → 23 → 25 → 26, then 28 last.
- **Parallelizable:** 24 after 22 (alongside 23/25); 27 anytime.
- **HIGH-risk gate:** Phase 22 must not regress the live Fastmail path — golden-path integration tests pass unchanged before 23/24/25 build on it.
## Backlog
### Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD
**Plans:** 6/6 plans complete
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG)
**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose *when* (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
**Half A — author the VALARM (event form):** The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or **no reminder**), so the written `.ics` carries no `VALARM` and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as `VALARM` (TRIGGER) on write-back, and parse existing `VALARM`s on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about).
**Half B — scheduler honors the provider's value (NEW, surfaced 2026-06-10):** Today `apps/api/src/broker/reminderScheduler.ts` runs a **hardcoded 15-minute** scan for shared timed events (`index.ts:139` "starting in ~15 min"; reminderScheduler header "15-min reminder scan") and never reads the event's actual alarm. So every reminder fires 15 min before regardless of what the event (or the calendar provider) specifies, and an event with **no** alarm still gets a 15-min push. Change the scheduler to read each event's `VALARM` `TRIGGER` (the value written in Half A / set in Fastmail or another native client) and fire at that lead — and fire **nothing** when the event has no alarm. The current fixed 15-min window/dedup logic (catch-up scan, per-uid exactly-once — see quick 260610-hbu) must be generalized to a variable per-event lead.
**Boundary:** preserve the reminder scheduler's resilience guarantees (catch-up on a missed tick, per-uid exactly-once dedup). This makes the lead per-event/variable rather than constant; it is not a rewrite of the scan/dedup design.
**Severity:** medium — feature gap surfaced during Phase 03 Gate 2 testing; Half B surfaced 2026-06-10. Tags: phase-03, phase-05, calendar, write-back, reminders, valarm, push, scheduler, phase-05-dependency.
**Requirements:** TBD
**Plans:** 0 plans
> **Promoted into v1.1 Phase 11 (Per-Event Reminders) — CAL-13/CAL-14/NOTIF-04/05/06.** Backlog entry retained for history.
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG)
**Goal:** [Captured for future planning] Add an in-app **Settings/Administration** section, gated to an administrator role, for configuration that today requires manual backend/DB steps:
- **View/update per-member Fastmail app passwords** (stored encrypted via `APP_PASSWORD_ENCRYPTION_KEY`, existing crypto path) — rotate or re-enter a member's credential and re-trigger sync.
- **Designate which synced calendar is the "shared" calendar** by toggling `calendars.is_shared` from the UI. Today this is a manual DB write: e.g. `UPDATE calendars SET is_shared=1 WHERE id=<row>` — done by hand on 2026-06-10 to mark the "FamilySync" calendar (id 10) shared after the poller synced it (D-16). The admin should pick the shared calendar from a list of synced collections instead of relying on a backend process. (The poller's upsert already leaves `is_shared` untouched, so a UI-set flag persists.)
**Context:** Motivated by the manual D-16 resolution (2026-06-10). **Related:** 999.5 (per-member first-login app-password onboarding) — this is the ongoing admin-managed counterpart; and 999.11 (initial setup wizard) — bootstrap-time vs. ongoing config. Tags: admin, settings, calendar, app-passwords, D-16.
> **Promoted into v1.1 Phase 10 (Admin Role & Settings) — ADMIN-01/ADMIN-02/ADMIN-03.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.11: Initial setup wizard — first-run config of env vars, app passwords, DB connection (BACKLOG)
**Goal:** [Captured for future planning] Add a first-run **setup wizard** that walks the administrator through defining all bootstrap configuration instead of hand-editing `.env` / `docker-compose.yml`:
- **App environment variables:** OIDC client id/secret/issuer/redirect URI + external URL, session signing secret (`OIDC_AUTH_SECRET`), `APP_PASSWORD_ENCRYPTION_KEY`, and the **VAPID keypair** (subject + public + private).
- **MariaDB connection:** host/port/user/password/db, with a connectivity test.
- **First Fastmail app password** for the initial member, encrypted on save.
Wizard should **validate inputs before completing** — e.g. VAPID private key decodes to 32 bytes AND pairs with the public key, OIDC discovery resolves, DB connects, app-password reaches CalDAV.
**Context:** Motivated by setup friction observed 2026-06-10 — a VAPID private key truncated on paste into `.env` silently broke push (`setVapidDetails failed — 32 bytes`), and `DB_HOST` / dev overrides must currently be set by hand. A guided + validated wizard would have caught these. **Related:** 999.10 (ongoing admin Settings) and 999.5 (member onboarding). Tags: onboarding, setup, install, env, vapid, mariadb, oidc.
> **Promoted into v1.1 Phase 12 (Initial Setup Wizard) — SETUP-01/02/03/04.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.12: Assistant-driven mobile-browser UI testing (mobile viewport + authed PWA) (BACKLOG)
**Goal:** [Captured for future planning] Give the assistant a way to validate UI/UX changes in a **mobile** browser experience, not just desktop Chromium. Today `playwright-cli` drives a desktop viewport, and the prod stack enforces OIDC (Authelia) so the authed PWA can't be reached headlessly — which is exactly why a string of mobile-only defects this milestone (silent Android notifications, the dead "How to enable" link, iOS/Android session-cookie persistence, install/standalone behaviour) could only be found by the operator on real devices, not by the assistant.
**What this needs (any subset):**
- **Mobile viewport + UA emulation** in the browser harness (e.g. Playwright device descriptors — iPhone/Pixel viewport, touch, mobile user-agent) so layout, tap targets, and responsive behaviour can be checked.
- **An authenticated entry path for automated runs** so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing `DEV_AUTH_BYPASS`, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.)
- Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device.
**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser *could* cover but currently can't.
**Context:** Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. **Related:** [[feedback-playwright-verify]] (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx.
> **Promoted into v1.1 Phase 7 (Mobile Test Harness) — TEST-01/TEST-02.** v1.1 scopes the `DEV_AUTH_BYPASS` dev-build path; the prod-SW authed-mobile target stays deferred. Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.13: Reduce event write-back latency to the calendar provider (outbox drain) (BACKLOG)
**Goal:** [Captured for future planning] Calendar create/edit/delete writes are enqueue-only (`calendarOutbox`, 202 optimistic-accept; D-12/D-05 — no Fastmail call in the route) and flushed to Fastmail by `runOutboxDrain` on a **15-second `setInterval`** (`apps/api/src/broker/outboxWorker.ts`). So a change can take up to ~15s to land in Fastmail (and longer to reflect back in the app, which depends on the separate 5-min poller). Reduce that perceived sync delay so edits feel near-immediate.
**Options to weigh when picking this up:**
- **Event-driven drain (preferred):** trigger an outbox drain immediately after a successful enqueue (in-process signal, or Redis pub/sub which is already available) so the write fires within ~1s instead of waiting for the next tick — keep the 15s `setInterval` as a fallback/retry sweep. Must preserve the existing per-row etag/412 handling and the rapid-successive-edit ordering (see outboxWorker comments ~L312 — each edit carries its enqueue-time etag).
- **Shorter interval:** simplest, but more idle DB polling; a floor (e.g. 35s) trades latency for load.
- **Faster read-back too:** the user also sees latency from the 5-min poller reflecting the change back. Consider invalidating/short-poll after a local write, or optimistic UI already covering it — confirm whether the perceived delay is the write (15s) or the read-back (5min).
**Boundary:** the optimistic 202 + outbox durability design (create-before-delete, drain concurrency guard, fresh-etag-before-PUT) must be preserved — this is a latency tune, not a rewrite of the write path.
**Context:** Surfaced 2026-06-10. Tags: calendar, write-back, outbox, latency, redis, performance.
> **Promoted into v1.1 Phase 9 (Faster Write-Back) — CAL-15.** In-process EventEmitter chosen (not Redis); the drain is single-process by design. Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.14: Gitea CI — full regression on PR to main + build/publish Docker image (BACKLOG)
**Goal:** [Captured for future planning] The repo is committed against a self-hosted Gitea instance with a registered Actions runner, but there is no CI yet (no `.gitea/workflows/` or `.github/workflows/`). Two things should run automatically: (1) **full regression** on every PR targeting `main` — gating the merge; (2) **build the app's Docker image and publish it** to the Gitea container registry.
**Options / decisions to make when picking this up:**
- **Test scope:** "full regression" = lint + typecheck + unit + the API integration tests. Integration tests need a real MariaDB (see [[api-integration-test-db]]) — the workflow must spin up a MariaDB service container, bind it, and set `DB_HOST=127.0.0.1` + `.env` creds. The PWA build/test also runs.
- **Monorepo:** pnpm workspace (`apps/api`, `apps/pwa`, shared). Cache the pnpm store.
- **Docker images:** only `apps/api/Dockerfile` exists today — there is no PWA Dockerfile yet. Decide one image (API) vs. also building/serving the PWA. Tag scheme + when to publish (only on merge to `main`? on tags? per-PR?).
- **Registry auth:** push to the Gitea registry using the runner's Gitea-provided token or a dedicated package-write token.
- Gitea Actions are GitHub-Actions-compatible syntax but run on the self-hosted runner — confirm runner labels and available images, and that Actions is enabled, before authoring.
**Likely shape:** a `.gitea/workflows/ci.yml``on: pull_request` (to `main`) → install (pnpm), lint, typecheck, unit, API integration vs. a `mariadb` service container, PWA build; `on: push` to `main`/tag → `docker build apps/api/Dockerfile`, login, push tagged image.
**Context:** Promoted from STATE.md pending todo (`.planning/todos/pending/2026-06-10-gitea-ci-regression-and-docker-publish.md`), surfaced 2026-06-10. Tags: tooling, ci, gitea, docker, mariadb, monorepo.
> **Promoted into v1.1 Phase 8 (Gitea CI) — CI-01/CI-02.** v1.1 also extends CI-01 to run the Phase 7 mobile harness as a UI-regression step (CI brings up the dev stack in the runner). Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.15: Desktop e2e coverage — add a Desktop Playwright profile + desktop-safe specs (BACKLOG)
**Goal:** [Captured for future planning] The Playwright harness (`apps/pwa/playwright.config.ts`) defines only **mobile** device profiles — `iphone` (iPhone 14 / WebKit) and `pixel` (Pixel 7 / Chromium), both with touch and a mobile viewport. The Phase 8 CI regression gate runs `pnpm test:e2e`, so it currently validates the **mobile experience only**. Add desktop coverage so the regression gate exercises the desktop layout/flows as well.
**Options / decisions to make when picking this up:**
- **Add a Desktop profile:** a new `desktop` project in `playwright.config.ts` (e.g. `devices['Desktop Chrome']`, no `hasTouch`, wide viewport). Optionally a Desktop WebKit/Safari profile too — but the family's Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
- **Spec-compat pass (the real work):** the existing e2e specs were authored for mobile — they may assume touch gestures, a mobile nav/drawer, or mobile-only layout. Each spec needs review/adjustment so it passes (or is appropriately skipped) on a no-touch, wide-viewport desktop. This is harness/spec work, not CI plumbing.
- **Gating choice:** decide whether desktop runs block the merge immediately, or run advisory (non-blocking) until the specs are confirmed desktop-safe.
**Boundary:** Phase 8 deliberately reused the Phase 7 harness **unchanged** (CI owns only stack bring-up + readiness waits, not spec content), which is why this was deferred. Once a Desktop project is added to the config, Phase 8 CI picks it up automatically via `pnpm test:e2e` — no CI changes needed beyond whatever runtime/wait the desktop profile requires.
**Context:** Deferred from Phase 8 (Gitea CI) planning, 2026-06-11 — user wants both mobile and desktop validated, but desktop needs a config addition + spec review that is out of Phase 8's CI-plumbing scope. Tags: testing, playwright, e2e, desktop, harness, ci.
> **Promoted into v1.1 Phase 14 (Desktop E2E Coverage) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.16: Wire a real linter (ESLint) so the CI lint gate actually fails on violations (BACKLOG)
**Goal:** [Captured for future planning] The Phase 8 CI `fast-checks` job runs `pnpm lint`, but **no linter exists** in the repo — the root `lint` script is `pnpm -r --if-present lint`, which finds no package-level lint script and exits 0. The lint gate is a hollow placeholder that can never fail. Wire up a real linter so it runs and gates merges on lint violations. (`typecheck`/tsc already gates type errors meanwhile.)
**Options / decisions to make when picking this up:**
- **Tooling:** ESLint flat config (`eslint.config.js`) with `typescript-eslint`; add React + react-hooks plugins for `apps/pwa`. Add `eslint` (+ plugins) as devDeps and a `lint` script to `apps/api` and `apps/pwa``pnpm -r --if-present lint` then picks them up automatically, no CI change needed.
- **Rule strictness:** pick a baseline (recommended vs strict-type-checked). Stricter = more upfront violations to fix.
- **Violation cleanup (the real work):** the first run surfaces existing violations across both apps. Decide per-rule: fix, downgrade to warn, or disable. The gate must end green.
- **Gating choice:** blocking on merge immediately, or advisory (warn-only) until the codebase is clean.
**Boundary:** Phase 8 deliberately scoped lint wiring out (CI-plumbing-only); it shipped the gate slot wired to auto-activate once a package `lint` script lands. This item is that follow-up.
**Context:** Raised during Phase 8 execution, 2026-06-11 — user noted the `--if-present` lint step "didn't fix the linter, just made it so it didn't have to exist to proceed" and wants a lint gate that actually fails. Tags: ci, lint, eslint, typescript-eslint, quality, gitea.
> **Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.18: Update dependencies as found during CI (BACKLOG)
**Goal:** [Captured for future planning] When the CI dependency-audit gate (Phase 16) surfaces outdated or vulnerable packages, bump them rather than letting the report accumulate. Establish a lightweight, recurring "act on the CI dependency report" loop so the two-person household app doesn't drift onto stale/CVE-bearing deps. Scope is the upkeep workflow (review → bump → verify gate green), not a one-time audit.
**Context:** Captured 2026-06-13 during Phase 10 work. Companion to the audit *reporting* shipped in Phase 16 (CI Dependency Audit) — that phase makes outdated/vulnerable deps *visible*; this item is the standing follow-through to *resolve* what it finds. Tags: ci, dependencies, maintenance, security, upkeep.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.19: Dev user exercises full app functionality without syncing to a real calendar (BACKLOG)
**Goal:** [Captured for future planning] Let the `DEV_AUTH_BYPASS` dev user (currently hardcoded `DEV_USER` id 1 in `apps/api/src/auth/devBypass.ts`) exercise the full app — create/edit/delete events, set per-event reminders, manage lists — against a local/in-app calendar store, WITHOUT requiring a connected Fastmail/CalDAV provider and WITHOUT writing anything to a real calendar. Today the dev user has no `member_credentials` row and no `calendars`, so `writable-calendars` is empty and `POST /api/events/create` returns `422 "No writable calendar found for user"` — making hands-on UAT of event/reminder features impossible in dev. Options to explore: seed the dev user a fake local calendar + short-circuit the outbox/CalDAV write path under dev-bypass (no Fastmail round-trip), or a dev-only in-memory calendar provider. Must stay strictly dev-only (same hard `NODE_ENV !== 'production'` guard) and never ship in production images.
**Context:** Captured 2026-06-14 during Phase 11 (Per-Event Reminders) UAT. The reminder picker and backend were verified via automated tests + a route-mocked playwright smoke, but the operator could not manually create an event to see reminders end-to-end because no provider is connected in the dev DB (`needsProviderSetup: true`). This is a recurring dev-testability friction (see MEMORY: "Dev user 1 has no calendars"). Tags: dev-tooling, dev-bypass, testability, calendars, outbox, uat.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.20: PWA dark mode / theming — ship a full dark theme + light/dark/system switch (BACKLOG)
**Goal:** [Captured for future planning] Ship a complete dark theme for the PWA plus a light/dark/system theme switch. **Phase 17 lays the token-architecture groundwork** — it restructures `apps/pwa/src/styles/tokens.css` from a single light `:root` into a themeable semantic-token layer that can be swapped via `data-theme` / `prefers-color-scheme`, with light staying the default and only-shipped theme. This backlog item is the follow-through that consumes that seam: author the actual dark palette values (including the Schedule-X `--sx-color-*` calendar overrides at the bottom of tokens.css), wire `prefers-color-scheme`, add a persisted in-app toggle in the /admin or Settings surface (light / dark / system), and verify both themes render cleanly across every route (calendar, lists, admin, settings sheet, login) via `playwright-cli` + the Phase 7 `layout.spec` profiles.
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17` to keep that phase scoped to phone-layout polish + branding assets. Phase 17's token restructure is the explicit enabling groundwork, so this should be cheap to pick up afterward. Related: Phase 17 (UI Optimization & Polish — the groundwork), 999.21 (modern styling refresh). Tags: pwa, theming, dark-mode, tokens, accessibility, settings, prefers-color-scheme.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.21: PWA modern visual styling refresh — contemporary look across the app (BACKLOG)
**Goal:** [Captured for future planning] A broader "more modern, visually appealing" styling pass across the PWA — beyond the bounded in-system polish of Phase 17. Candidate scope: a contemporary refresh of high-visibility surfaces (login, calendar shell, event form, lists, admin), revisiting elevation/shadows, radii, spacing rhythm, typography scale, and control states, potentially reworking specific component layouts. Explicitly **flagged for a future milestone**, not v1.1 — it is a visual-overhaul track with real redesign risk and should be scoped/sequenced on its own rather than bolted onto a polish phase. Best sequenced after the Phase 17 token groundwork and 999.20 (dark mode) so the refresh is theme-aware from the start.
**Goal:** [Captured for future planning] A broader "more modern, visually appealing" styling pass across the PWA — beyond the bounded in-system polish of Phase 17. Candidate scope: a contemporary refresh of high-visibility surfaces (login, calendar shell, event form, lists, admin), revisiting elevation/shadows, radii, spacing rhythm, typography scale, and control states, potentially reworking specific component layouts. Explicitly **flagged for a future milestone**, not v1.2 — it is a visual-overhaul track with real redesign risk and should be scoped/sequenced on its own rather than bolted onto a polish phase. Best sequenced after the Phase 17 token groundwork and Phase 27 (dark mode) so the refresh is theme-aware from the start.
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17`. The user scoped Phase 17 to layout polish + branding (logo/favicon/icon assets) + theme-token groundwork, and routed the open-ended styling refresh here for a future milestone to avoid an unbounded redesign inside a polish phase. Related: Phase 17 (the polish baseline), 999.20 (dark mode / theming). Tags: pwa, ui, styling, redesign, design-system, future-milestone.
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17`. The user scoped Phase 17 to layout polish + branding (logo/favicon/icon assets) + theme-token groundwork, and routed the open-ended styling refresh here for a future milestone to avoid an unbounded redesign inside a polish phase. Related: Phase 17 (the polish baseline), Phase 27 / 999.20 (dark mode / theming). Tags: pwa, ui, styling, redesign, design-system, future-milestone.
**Requirements:** TBD
**Plans:** 0 plans
@@ -311,3 +211,6 @@ Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
---
_Backlog items 999.1 (provider abstraction), 999.5 (self-service onboarding), 999.18 (CI dependency updates), 999.19 (dev-user full-app exercise), and 999.20 (dark mode) were promoted into v1.2 Phases 22, 26, 28, 24, and 27 respectively. Historical promotion provenance for v1.0/v1.1 backlog items (999.1/4/5/10/11/12/13/14/15/16) is preserved in `milestones/v1.1-ROADMAP.md`._
+21 -20
View File
@@ -1,20 +1,16 @@
---
gsd_state_version: 1.0
milestone: v1.1
milestone_name: Operability & Polish
current_phase: null
status: Awaiting next milestone
stopped_at: v1.1 milestone shipped & archived
last_updated: "2026-06-19T01:58:30.566Z"
last_activity: 2026-06-18
last_activity_desc: Milestone v1.1 completed and archived
milestone: v1.2
milestone_name: Multi-Provider, Theming & Zero-Setup
status: planning
last_updated: "2026-06-19T14:30:00.000Z"
last_activity: 2026-06-19
progress:
total_phases: 20
completed_phases: 20
total_plans: 57
completed_plans: 57
percent: 100
current_phase_name: Awaiting next milestone
total_phases: 8
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
@@ -24,14 +20,16 @@ current_phase_name: Awaiting next milestone
See: .planning/PROJECT.md (updated 2026-06-18 after v1.1 milestone)
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
**Current focus:** Planning next milestone — run `/gsd-new-milestone`
**Current focus:** Phase 21 — Zero-Setup DB Bootstrap (v1.2 roadmap created; awaiting plan)
## Current Position
Phase: Milestone v1.1 complete
Plan: —
Status: Awaiting next milestone
Last activity: 2026-06-19 — Milestone v1.1 completed and archived
Phase: 21 of 28 (Zero-Setup DB Bootstrap) — first v1.2 phase
Plan: — (not yet planned)
Status: Ready to plan
Last activity: 2026-06-19 — Completed quick task 260619-jp4: added app screenshots + README Features section
Progress: [░░░░░░░░░░] 0% (0/8 v1.2 phases)
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -210,6 +208,8 @@ Recent decisions affecting current work:
### Roadmap Evolution
- **v1.2 roadmap created (2026-06-19):** 8 phases (2128), continuing v1.1 numbering (last phase 20). 11/11 requirements mapped to exactly one phase, no orphans, no duplicates. Dependency-ordered per research ARCHITECTURE.md "Dependency-Ordered Build Sequence": **21 Zero-Setup DB Bootstrap** (SETUP-05, no deps, unlocks auto-migrate) → **22 Provider Abstraction** (PROV-01, HIGH-risk, CalDAV-only/Fastmail unchanged, carries the `UNIQUE(user_id, provider_type)` migration) → **23 Multiple Reminders** (CAL-18, dep 22) and **24 Dev/Mock Provider** (TEST-03, dep 22) → **25 Google Calendar Provider** (CAL-16/17/PROV-02, deps 22/23/21) → **26 Self-Service Onboarding** (PROV-03/04, dep 25). **27 PWA Dark Mode** (THEME-01) is independent/parallelizable with 2226. **28 Dependency Updates** (DEP-01) is last — after 27 (Schedule-X hold) and all other v1.2 work. Backlog promotions: 999.1→Provider Abstraction (22), 999.20→Dark Mode (27), 999.5→Self-Service Onboarding (26), 999.19→Dev/Mock Provider (24), 999.18→Dependency Updates (28). DEP-01 kept a standalone closing phase (not folded) to preserve its hard "last, after dark-mode-locked" ordering. Critical path: 21→22→23→25→26, then 28.
- Phase 6 added (2026-06-07): UX Polish — all-day visual distinction, event-form date/recurrence behavior, recurring-series edit, auth-flow smoothing. Candidate scope pulls from backlog 999.2/999.3/999.6/999.7/999.8/999.9.
- Phase 6 complete (2026-06-10): all 6 plans executed + 2 phase-level UX fixes (AppNav persistence + BottomTabBar desktop hide). Residual device-only checkpoints documented above.
- Backlog reviewed (2026-06-10, /gsd-review-backlog): removed 6 stale duplicates (999.2/3/6/7/8/9 — already promoted into Phase 6) from the Backlog section + deleted the 999.2 dir; kept 999.1/4/5/10/11/12/13; added 999.14 (Gitea CI, promoted from STATE pending todo); archived stale kickoff-new-project todo.
@@ -259,6 +259,7 @@ Recent decisions affecting current work:
| 260613-ndv | Isolate local apps/api integration tests to a dedicated `familysync_test` DB so test runs stop polluting the dev `familysync` DB. New CI-gated vitest globalSetup root-provisions (CREATE DATABASE + GRANT) + migrates + truncate-resets `familysync_test` each run; `vitest.config.ts` forces `DB_NAME=familysync_test` for local workers (no-op under CI, so CI's `familysync` service DB + db:migrate are untouched). Verified: dev `familysync` users stays 3 across a run, `familysync_test` resets (186→93, not doubled), 244/244 tests pass (flaky list_shares timeout gone), typecheck 0. Branch off main. | 2026-06-13 | 07d5161 | Verified | [260613-ndv-wire-apps-api-integration-tests-to-a-ded](./quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/) |
| 260618-smr | Remove unused Redis service and all references — Redis confirmed unused at runtime (no ioredis/redis client import, no `REDIS_*` env, not a dependency in any package.json). Dropped the `redis` service from both compose files and cleaned all references in CLAUDE.md, README.md, and docs/* + e2e config. Kept the in-memory-vs-Redis design-rationale comments (D-12/D-18) in listEmitter/reminderScheduler/linkNonceStore/localAuth. `docker compose config` parses clean (0 redis); `format:check` green. Branch off main. | 2026-06-18 | 0b42666 | Verified | [260618-smr-remove-unused-redis-service-and-referenc](./quick/260618-smr-remove-unused-redis-service-and-referenc/) |
| 260618-tg2 | Persistent CI dependency caches — point all 4 CI `pnpm install` steps at a host-mounted `/pnpm-store` (`--store-dir /pnpm-store --prefer-offline`) and persist Playwright browsers via `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright` on the harness job; added BuildKit `--mount=type=cache` to all 3 Dockerfile install stages + `DOCKER_BUILDKIT=1` on the publish build. Avoids `actions/cache` (D-PROBE-04 timeout). In-repo only — requires act_runner `config.yaml` `container.options` host mounts (manual host change). Verdaccio deferred. Branch off main. | 2026-06-18 | 6e93e24 | Verified | [260618-tg2-persistent-ci-dependency-caches-pnpm-sto](./quick/260618-tg2-persistent-ci-dependency-caches-pnpm-sto/) |
| 260619-jp4 | Add six app screenshots (Calendar, Lists, List detail, Login, Setup wizard, Admin) to `docs/screenshots/` and rebuild the README intro into a "Features" section that expands the description, one screenshot per capability. Captured via playwright-cli (Chromium) against the live dev stack with demo data mocked per-browser via `route` interception — nothing written to the real Fastmail account. prettier + md:lint green. Branch: gsd/v1.2-multi-provider-theming-zero-setup. | 2026-06-19 | 23dc3cd | | [260619-jp4-add-app-screenshots-to-readme](./quick/260619-jp4-add-app-screenshots-to-readme/) |
## Deferred Items
@@ -284,4 +285,4 @@ Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.m
## Operator Next Steps
- Start the next milestone with /gsd-new-milestone
- v1.2 roadmap created (Phases 2128). Plan the first phase with `/gsd-plan-phase 21`.
@@ -0,0 +1,50 @@
---
quick_id: 260619-jp4
slug: add-app-screenshots-to-readme
status: complete
date: 2026-06-19
---
# Quick Task 260619-jp4: Add app screenshots to README
## Goal
Add real screenshots of the running FamilySync PWA to the README, organized into
a **Features** section that expands on the top-line project description (per user
follow-up: "the screenshots should go in some sort of features section that
expands on the description").
## Approach
1. Drive the live dev app (Vite :5173 + API :3000, dev-auth-bypass) with
`playwright-cli` (Chromium) and capture six views: Calendar, Lists index,
List detail, Login, Setup wizard, Admin.
2. The live instance has only one seeded event and one list — too sparse to
showcase. Per the user's decision (AskUserQuestion: "Mock locally"), populate
the views by intercepting API responses in the browser with `playwright-cli
route` (no writes to the real Fastmail account):
- `/api/me` → friendly member "Jordan" (blue), admin, setup not needed
- `/api/events` → a full June 2026 month of color-coded personal + family events
- `/api/lists` + `/api/lists/*/items` → four lists, a stocked grocery list
- `/api/admin/members` + `/api/admin/calendars` → two-person household
- `/api/setup/status``setupComplete:false` so the wizard renders
3. Save final PNGs to `docs/screenshots/`.
4. Rewrite the README "What It Does" bullets into a "## Features" section with
one screenshot per capability and expanded prose.
## Tasks
- [x] Capture six screenshots into `docs/screenshots/` via playwright-cli (mocked data)
- [x] Replace README "What It Does" with a "Features" section embedding the screenshots
- [x] Pass local CI gates (prettier, markdownlint)
## Files
- `docs/screenshots/*.png` (new — 6 images)
- `README.md` (Features section)
## Verify
- `pnpm exec prettier --check README.md` → clean
- `pnpm md:lint` → 0 errors (MD033 inline-HTML active → pure-markdown images only)
- Images resolve at `docs/screenshots/<name>.png` relative paths
@@ -0,0 +1,50 @@
---
quick_id: 260619-jp4
slug: add-app-screenshots-to-readme
status: complete
date: 2026-06-19
---
# Quick Task 260619-jp4 — Summary
## What was done
Added six real screenshots of the running PWA to `docs/screenshots/` and rebuilt
the README intro into a **Features** section that expands the project description,
pairing each capability with its screenshot:
| View | File | Feature |
| --- | --- | --- |
| Calendar (month) | `calendar.png` | One color-coded family calendar |
| Lists index | `lists.png` | Shared collaborative lists |
| List detail | `list-detail.png` | Live-syncing list items |
| Login | `login.png` | Single sign-on (local + Authelia OIDC) |
| Setup wizard | `setup.png` | Guided zero-setup onboarding |
| Admin | `admin.png` | Household administration |
## How
Captured with `playwright-cli` (Chromium — the box has no Google Chrome) against
the live dev stack. Demo data was injected per-browser via `playwright-cli route`
response interception — **nothing was written to the real Fastmail account**
(user chose "Mock locally"). Screens that fight the dev-bypass authed/locked
state were unlocked by mocking `/api/me`, `/api/setup/status`, and the auth/admin
endpoints. Login and Setup were reframed at a 1280×720 viewport to remove dead
vertical space; the content views at 1280×900.
## Notes / honest caveats
- The list-detail header literally reads "List", not the list name — that is the
app's own placeholder (`apps/pwa/src/routes/ListDetail.tsx:380`, "List name not
available without an extra fetch"), not a screenshot artifact. Left as-is rather
than faking the real UI.
- The color-legend in the sidebar shows the current member + "Family" by design
(`App.tsx` derives legend members from `/api/me` only); the two-person household
is shown on the Admin members screen.
## Verification
- `pnpm exec prettier --check README.md` → "All matched files use Prettier code style!"
- `pnpm md:lint` → Summary: 0 error(s) (12 files)
- Images committed under `docs/screenshots/` (not gitignored); README references
resolve via relative paths.
File diff suppressed because it is too large Load Diff
+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*
+294 -317
View File
@@ -1,527 +1,504 @@
# Pitfalls Research — v1.1 Operability & Polish
# Pitfalls Research — v1.2 Multi-Provider, Theming & Zero-Setup
**Domain:** Adding operability features (VALARM reminders, admin Settings, setup wizard, event-driven outbox drain, Gitea CI, Playwright authed-mobile harness) to a shipped Node 22 + Hono + Drizzle/MariaDB + tsdav/ical.js + web-push stack on Unraid/Docker behind Authelia OIDC + Pangolin/Newt.
**Researched:** 2026-06-10
**Confidence:** HIGH — pitfalls derived from direct inspection of the shipped v1.0 source, the v1.0 retrospective, and deep familiarity with ical.js/CalDAV/Gitea Actions semantics. No speculative gaps.
**Domain:** Adding Google Calendar OAuth2, provider abstraction, multiple reminders, dark mode, auto-migrate-on-boot, self-service OAuth onboarding, and dependency updates to a shipped Node 22 + Hono 4 + Drizzle/MariaDB 11 + tsdav/ical.js + web-push stack on Unraid/Docker behind Authelia OIDC + Pangolin/Newt.
**Researched:** 2026-06-19
**Confidence:** HIGH — pitfalls derived from direct source inspection of the shipped v1.1 codebase, v1.0/v1.1 retrospectives, known burned-in lessons from the migration and CI history, and deep familiarity with Google Calendar API, OAuth2, Drizzle/MariaDB migration semantics, and PWA theming constraints. Sources cross-checked against PROJECT.md, RETROSPECTIVE.md, MILESTONES.md, and the VALARM/outbox source in apps/api/src/broker/.
---
## Known Constraints (Do Not Re-Litigate)
## Known Constraints (Inherited — Do Not Re-Litigate)
These are already burned-in lessons. Every pitfall below is written assuming these hold:
These are burned-in from v1.0/v1.1. Every pitfall below assumes these hold:
- **No node-cron.** `setInterval` only. node-cron 4.2.1 silently skips all ticks in the long-lived API process.
- **`drizzle-kit generate`+`migrate`, never `push`.** `push` emits a false destructive diff on populated MariaDB.
- **API integration tests need a real MariaDB** (port-bound dev compose, `DB_HOST=127.0.0.1`, `.env` creds). Tests live in `apps/api/tests/`, never `src/`.
- **Outbox guarantees to preserve:** optimistic 202, enqueue-only route handler, create-before-delete ordering (groupId), drain concurrency guard (`isDraining`), fresh-etag-before-PUT (WR-02), per-uid exactly-once dedup.
- **VAPID private key must decode to exactly 32 bytes** — a truncated key causes a silent Apple 403.
- **Authelia omits `name`/`email`/`preferred_username` from the ID token** by default — needs a `claims_policy` or these fields are absent.
- **No node-cron.** `setInterval` only node-cron 4.2.1 silently skips all ticks in long-lived Node processes.
- **`drizzle-kit generate`+`migrate`, never `push`.** `push` emits a false destructive diff (TRUNCATE/DROP) on populated MariaDB 11 using the mysql dialect — the introspection bug fires on any existing-data DB. This is the root cause behind D-MIGRATION-10-01 and the quick task cr8.
- **Journal-hash matching is mandatory.** The `__drizzle_migrations` table tracks applied migrations by `tag` (from `_journal.json`). If a migration file is renamed, regenerated, or its journal entry is modified after it has been applied to any environment, drizzle-kit migrate treats it as unapplied and will attempt to re-run DDL that already ran — producing `Duplicate column` errors.
- **MariaDB CI readiness uses `healthcheck.sh --connect`, not `mysqladmin ping`.** `mysqladmin ping` was removed in MariaDB 11.
- **ESLint is pinned at 9.39.4.** ESLint 10 breaks `eslint-plugin-react` and `eslint-plugin-react-hooks`. The root `package.json` pin must not be bumped past 9.x until all React plugins declare ESLint 10 peerDep support.
- **`setInterval` outbox drain, not Redis.** Single-process; in-process EventEmitter (`outboxTrigger.ts`) for the drain signal. `drainRequested` flag pattern prevents double-drain. Optimistic-202, create-before-delete, and `uid:dtstartMs` dedup are load-bearing.
- **All CalDAV VALARM triggers must be built via `ICAL.Duration.fromSeconds(-N*60)`, not bare strings.** Bare strings produce `VALUE=TEXT` which is rejected by Fastmail and Apple Calendar.
- **`REGISTRY_PAT` secret name (not `GITEA_PAT` — the `GITEA_` prefix is silently dropped by Gitea Actions).** `docker login --password-stdin`, never `-p $TOKEN` on the command line.
---
## Critical Pitfalls
### Pitfall 1: VALARM Round-Trip Strips Existing Alarms From Native Clients on Edit
### Pitfall 1: Google Refresh Tokens in "Testing" Publishing Status Expire After 7 Days
**What goes wrong:**
The current `buildVeventString` in `broker/vevent.ts` constructs a fresh `VCALENDAR` with only the properties it knows about (UID, SUMMARY, DTSTART, DTEND, RRULE, LOCATION, DESCRIPTION). When v1.1 adds VALARM authoring, a naive approach adds `VALARM` components to new-creates only. On an edit (update path in `outboxWorker.ts`), the worker re-builds the VEVENT from form payload — not from the stored `rawVevent`. Any VALARM that was added by a native client (Fastmail app, Apple Calendar) will be silently dropped from the PUT payload. The event arrives at Fastmail without the alarm. The user loses their native-client reminder with no warning.
Google OAuth2 refresh tokens issued to an app in "Testing" publishing status (as opposed to "Production") expire after 7 days regardless of the token TTL in the credentials. After 7 days, the stored refresh token returns `invalid_grant` and all Google Calendar operations fail for that member. The user sees calendar data vanish or receives 401 errors, and the fix requires them to re-authorize — a confusing, non-obvious failure for a non-technical user.
The inverse is equally dangerous: if the editor sends `reminderMinutes: 0` (meaning "no reminder"), but the code omits the VALARM field instead of explicitly authoring an empty VALARM block, the old alarm from `rawVevent` is neither preserved nor cleared. Whether it survives depends on what the worker does — and with the current reconstruct-from-scratch approach it will be dropped, which is the correct outcome in that case but only by accident.
Additionally, Google refresh tokens for apps in "Testing" status can only be granted to users explicitly listed as "test users" in the OAuth consent screen. If the operator adds a second member's Google account but forgets to add them as a test user, the authorization flow returns `access_denied` at the consent step, not a useful error message.
**Why it happens:**
`outboxWorker.ts` update path reconstructs the ICS entirely from form fields (using `buildVeventString`). It does not read and preserve non-RRULE sub-components from `rawVevent`. This is a deliberate v1 simplification (the RRULE-preserve path `WR-01` is already the one exception). Adding VALARM to `buildVeventString` handles new-creates correctly but does not cover the "user edited an event that already had a native-app alarm."
Developers set up the OAuth app in "Testing" status because "Production" requires Google verification (which requires a privacy policy, domain verification, and Google review). Testing status is the path of least resistance during development and stays in place longer than intended.
**How to avoid:**
On the update path in `outboxWorker.ts`, before calling `buildVeventString`, parse `rawVevent` with ical.js and extract all existing `VALARM` sub-components. Merge them: if the outbox payload carries an explicit reminder choice (the new `reminderMinutes` field), replace all extracted VALARMs with the new one (or with none if `reminderMinutes: null`). If the payload carries no reminder field (no explicit user change), carry the extracted VALARMs forward into `buildVeventString` as a `valarms` parameter. This mirrors the WR-01 RRULE-preserve pattern exactly. Extend the `outboxPayloadSchema` with an optional `reminderMinutes: z.number().int().min(0).nullable().optional()` field so the absence of the key is distinguishable from an explicit "no reminder."
For a self-hosted household app serving 2 users, request "Production" publishing status immediately — it does not require Google's full verification unless the app accesses sensitive scopes. For calendar read/write, `https://www.googleapis.com/auth/calendar` is a "restricted" scope (not "sensitive"), which DOES require verification. Use `https://www.googleapis.com/auth/calendar.readonly` + `https://www.googleapis.com/auth/calendar.events` instead — these are the non-restricted scopes sufficient for event read/write (no attendee PII access). With these scopes, production status is grantable without full Google review.
If testing status must remain during development, encode a `token_expiry_check` that validates the refresh token age and prompts re-auth before it expires rather than failing silently. Store the token grant timestamp alongside the encrypted refresh token in `member_credentials`.
Add the operator's and wife's Google accounts as test users in the OAuth consent screen during development.
**Warning signs:**
- Reminders set in the Fastmail native app disappear after editing the event in FamilySync.
- A shared event with a reminder shows the reminder field as empty after an FamilySync round-trip.
- `rawVevent` in `calendar_events` has `BEGIN:VALARM` but the PUT payload does not.
- Calendar data disappears exactly 7 days after a member connected their Google account.
- `invalid_grant` error in API logs when refreshing Google tokens.
- Second member's authorization flow returns `access_denied`.
**Phase to address:**
Per-event reminders phase (VALARM authoring). The VALARM-preserve logic must land in the same PR as `buildVeventString` VALARM support — not as a follow-up.
Google Calendar provider phase (first Google OAuth plan). Document the testing-vs-production publishing status distinction in the onboarding UI and in `docs/deployment.md` before implementing the OAuth flow.
---
### Pitfall 2: TRIGGER Value-Type Mismatch Silently Produces Broken VALARM
### Pitfall 2: Google Calendar Recurrence Model Does Not Map Cleanly to iCalendar RRULE
**What goes wrong:**
RFC 5545 §3.8.6.3 defines two legal TRIGGER value types for VALARM:
The Google Calendar API represents recurring events as a "master event" with a `recurrence` field (an array of raw iCalendar strings like `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`) plus separate "instance" resources for occurrences that have been individually modified (moved, canceled). The FamilySync codebase assumes the iCalendar model where: (a) a single VEVENT with RRULE expands to all occurrences via `ICAL.RecurExpansion`, and (b) exceptions are represented as VEVENT objects with `RECURRENCE-ID`.
- `DURATION` (default): `TRIGGER:-PT15M` — fires 15 minutes before DTSTART.
- `DATE-TIME`: `TRIGGER;VALUE=DATE-TIME:20260610T120000Z` — fires at an absolute UTC instant.
When pulling Google recurring events, the Google API returns **individual instance resources** by default in `events.list` — unless you pass `singleEvents: false`. If `singleEvents: true` (the default), you get individual expanded occurrences, not the master event. You cannot reconstruct the RRULE from the instances. If `singleEvents: false`, you get the master event (with RRULE) but **also need to separately fetch modified instances** to know which occurrences have been moved or canceled. There is no single API call that returns "master event + delta patches in RECURRENCE-ID form" like CalDAV does.
ical.js represents these differently. If you set a VALARM TRIGGER using `addPropertyWithValue('trigger', '-PT15M')` (a bare string), ical.js will emit it as `TRIGGER:-PT15M` on some versions and as `TRIGGER;VALUE=TEXT:-PT15M` on others, depending on whether it infers the type. The `VALUE=TEXT` form is not RFC-compliant for VALARM TRIGGER and will be silently ignored by Fastmail and Apple Calendar — the reminder never fires. The ICS looks valid at a glance but produces no alarm.
Separately: `RELATED=END` (fire N minutes before DTEND, not DTSTART) is a valid TRIGGER parameter. If the existing event from a native client uses `TRIGGER;RELATED=END:-PT10M` and the VALARM-preserve code in Pitfall 1 carries it forward, it is preserved correctly. But if the code reconstructs the VALARM from a stored `reminderMinutes` number only, it loses the RELATED parameter and the semantics change.
Deleted/canceled individual occurrences in Google Calendar have `status: "cancelled"` — they are not omitted from the response. If your code filters on `status === "confirmed"` you silently drop the cancellation records and re-expand the occurrence the next time the RRULE expands, causing ghost events.
**Why it happens:**
ical.js VALARM construction is not well-documented. The property API requires using `ICAL.Duration` or `ICAL.Time` objects, not bare strings, to get the correct value type in the output. Most examples online use the string form that works in some parsers but is not RFC-compliant.
The CalDAV model and the Google Calendar API model for recurrence are fundamentally different. CalDAV treats recurrence as an iCalendar artifact (RRULE on the master VEVENT, RECURRENCE-ID VEVENTs for exceptions). The Google API wraps iCalendar but exposes a different resource model. Developers assume the broker abstraction can produce identical event shapes from both providers, but the Google recurrence model requires a different fetch strategy.
**How to avoid:**
Build the TRIGGER using `ICAL.Duration.fromSeconds(-reminderMinutes * 60)` and set it as the property value, not as a string. Verify the emitted TRIGGER line does not contain `VALUE=TEXT`. For preserved VALARMs (from `rawVevent`), round-trip the sub-component through ical.js parse→serialize rather than extracting the raw text and reinserting it, to catch any encoding issues.
Fetch Google events with `singleEvents: false` to get master events with RRULE. For any recurring master event, issue a second call to fetch its instances (`events.instances()`) to get modified/canceled occurrences. Map `status: "cancelled"` instance resources to EXDATE entries on the master event's RRULE. Use `ICAL.RecurExpansion` on the reconstructed VCALENDAR for display, same as the Fastmail path.
Add a unit test: build a VALARM with `reminderMinutes: 15`, serialize to ICS, parse back with ical.js, and assert the TRIGGER DURATION value is `-PT15M` with no VALUE parameter other than DURATION (which is the default and is usually omitted).
The provider abstraction interface should return normalized VEVENT strings (raw iCalendar text), not provider-specific objects. The Google adapter's job is to produce a VCALENDAR string (including reconstructed RRULE + EXDATE) that the existing `expand.ts` / `sync.ts` layer can handle without modification.
Expose a `fetchRecurringMasterWithExceptions(uid)` method in the Google adapter that issues both API calls and returns a unified VCALENDAR string. Do not attempt to normalize at the `events.list` level.
**Warning signs:**
- ICS output contains `TRIGGER;VALUE=TEXT:-PT15M`.
- Reminders appear in the FamilySync UI but never fire on the device.
- Apple Calendar / Fastmail app shows the event with no alarm after an FamilySync edit.
- Recurring events show individual occurrences as separate events in the calendar instead of a recurring series.
- Canceled occurrences still appear in the calendar after being deleted in Google Calendar.
- `singleEvents: true` is used in the Google API client — this is wrong for the FamilySync data model.
**Phase to address:**
Per-event reminders phase. Unit test the VALARM serialization before any end-to-end reminder test.
Google Calendar provider phase (recurrence handling plan). Write a unit test that takes a Google `events.list` response (with a recurring master + a modified instance + a canceled instance) and asserts the reconstructed VCALENDAR string passes ical.js parsing and `ICAL.RecurExpansion` without error.
---
### Pitfall 3: All-Day Event VALARM Timezone Semantics Are Undefined
### Pitfall 3: Google syncToken Invalidation Triggers Silent Full Re-Sync That Overwrites Local Writes
**What goes wrong:**
For an all-day event (DTSTART;VALUE=DATE), the meaning of `TRIGGER:-PT15M` is ambiguous. RFC 5545 requires that a DURATION TRIGGER on an all-day event be evaluated against DTSTART as a DATE — which has no time component — resulting in undefined behavior in most implementations. Apple Calendar interprets it as "15 minutes before midnight of the start date in local time." Fastmail ignores VALARM on all-day events entirely in some tested configurations. Android may fire the alarm at midnight UTC.
The Google Calendar API supports incremental sync via `syncToken`: call `events.list` with the token from the previous response to receive only changes since the last sync. When the sync token expires (after ~7 days of inactivity, or after any large state change like timezone change or Google-side recalculation), the API returns `HTTP 410 Gone`. The correct response is to perform a full re-sync (no syncToken, save the new token). If the code does not handle 410 — or handles it by retrying with the old token — all incremental sync calls return 410 forever, silently halting all calendar updates.
A second issue: during a full re-sync, the code fetches all events and upserts into `calendar_events`. If a member created an event in FamilySync that is in the outbox (not yet written to Google), and the full re-sync runs before the outbox drains, the upsert overwrites the local optimistic row with the stale Google state (the event is not yet on Google's side), effectively reverting the user's creation. The event disappears from the UI until the next outbox drain re-creates it on Google and the next poller sync pulls it back.
**Why it happens:**
The project already correctly excludes all-day events from the reminder scheduler (`reminderScheduler.ts`, `WHERE allDay=false`). But if VALARM is stored on an all-day event (because the user created an all-day event and selected a reminder), the scheduler's WHERE clause means it silently never fires — which is correct behavior — but the user sees a reminder field in the UI and expects it to work.
syncToken expiration is handled in Google's CalDAV-via-ICAL model too (ctag changes trigger full re-sync), but the FamilySync ctag-based poller handles this correctly. The Google syncToken path is a different code branch that does not inherit the ctag handling. Full re-sync without outbox-awareness is a standard oversight.
**How to avoid:**
In the event form UI: disable or hide the reminder selector when `allDay: true`. If the API receives a create/update payload with `allDay: true` and a non-null `reminderMinutes`, strip the alarm and log a warning — do not store a VALARM that will silently not fire. Document this as a known constraint.
Wrap every `events.list` call that uses a `syncToken` in a try/catch for 410. On 410: drop the sync token, mark the provider's sync state as `stale`, and enqueue a full re-sync on the next poller tick (not in-band, to avoid blocking the current poller iteration).
In the scheduler, when v1.1 generalizes the lead time: keep the `WHERE allDay=false` guard in the SQL query regardless of how VALARM data is stored. Do not "fix" this by removing the guard when you extend VALARM support.
Before upsetting rows from a full re-sync, check the `calendar_outbox` table for any pending rows for the same user/calendar. If any pending writes exist for the same event UID, skip overwriting that row's `raw_vevent` from Google — the outbox write is the authoritative source of truth until it drains. Alternatively: drain the outbox before any full re-sync. The outbox drain is already ~12s; blocking the re-sync on it is acceptable.
**Warning signs:**
- All-day event with reminder set produces an ICS with a VALARM on a DATE-typed DTSTART.
- User reports reminder not firing for an all-day birthday event.
- Reminder field enabled in the UI for all-day events.
- API logs show `HTTP 410` from Google Calendar and the app stops updating events for that member.
- Events created via FamilySync disappear briefly then reappear.
- `syncToken` is stored per-provider in `member_credentials` or a separate `provider_sync_state` table but there is no 410 error handler branch.
**Phase to address:**
Per-event reminders phase. UI constraint and API guard belong in the same plan.
Google Calendar provider phase (sync/poller plan). 410 handling must be in the acceptance criteria before the Google poller ships.
---
### Pitfall 4: Duplicate Push When Generalizing the Fixed-Window Dedup to Per-Event Lead Times
### Pitfall 4: Google All-Day Events Use DATE, Timed Events Use RFC 3339 — Timezone Handling Differs From CalDAV
**What goes wrong:**
The current `reminderScheduler.ts` deduplication key is `uid` alone (`sentReminders` Map). The fixed 16-minute catch-up window ensures an event stays in-window across at most a few consecutive 1-minute ticks. When v1.1 changes the lead to a per-event value (e.g., event A has a 30-minute lead, event B has a 2-hour lead), the window must widen — or the scan logic must change — to accommodate variable leads. There are two failure modes:
Google Calendar returns timed events with `start.dateTime` (RFC 3339 string with timezone offset, e.g., `2026-07-01T10:00:00-04:00`) and all-day events with `start.date` (ISO 8601 date string, e.g., `2026-07-01`). The FamilySync sync layer (`sync.ts`) currently parses raw iCalendar `DTSTART;TZID=America/Toronto:20260701T100000` strings via ical.js and stores the UTC instant in `dtstart_utc`.
1. **Window too narrow for long leads:** If the scheduler still scans only `(now, now+16min]`, events with a 2-hour lead never enter the window and their reminder never fires.
When the Google adapter converts `start.dateTime` to iCalendar, it must produce `DTSTART:20260701T140000Z` (UTC). If it instead produces `DTSTART;TZID=America/Toronto:20260701T100000`, ical.js parses it correctly, but only if the corresponding `VTIMEZONE` component is also present in the VCALENDAR. Google does not return VTIMEZONE components — their API uses offset strings. A missing VTIMEZONE for a TZID reference causes ical.js to default to UTC, silently shifting the event time.
2. **Dedup key collision across rescheduled events:** If an event is rescheduled (DTSTART changes), the uid is the same but the VALARM should fire again for the new time. The current dedup key `uid` alone, with the `sentReminders.set(uid, dtstartMs)` pruning based on the stored dtstart, handles this — but only if the new dtstart causes the map entry to be pruned before the next alarm window. If the user reschedules an event to fire sooner than the original dtstart (e.g., from 3pm to 2pm, currently 2:10pm, 10 minutes after the original reminder already fired), the uid is still in `sentReminders` with the old dtstart (3pm), which has NOT yet passed `now`, so the CR-01 pruning has not removed it. The reminder for 2pm silently does not fire.
All-day event reminder handling has a separate pitfall: the `computeAlertInstantUtc` function in `vevent.ts` uses `getHouseholdTimezone(db)` (the stored IANA timezone) to compute "9 AM local on alert day." For Google all-day events, the Google API returns `reminders.overrides` (a list of `{method, minutes}` objects) or `reminders.useDefault` (boolean). If `useDefault: true`, the reminder fires at the user's Google Calendar default reminder time (from their Google account settings) — which the server cannot know. Mapping `useDefault: true` to a `reminderLeadMinutes` value in FamilySync is undefined.
**Why it happens:**
The uid-only dedup was designed for the fixed-15-minute lead where the dedup window is short and rescheduling edge cases are low-probability. Per-event leads break both assumptions.
CalDAV stores full iCalendar with VTIMEZONE. The Google API abstracts timezone into RFC 3339 offsets. The two representations look similar but require different handling in ical.js. Developers test with US/Eastern events and miss that ical.js silently falls back to UTC for an unknown TZID.
**How to avoid:**
Change the dedup key from `uid` alone to `uid + ':' + dtstartMs`. This makes the dedup per-(event, scheduled-time), not per-event. A rescheduled event has a different dtstart and gets a new dedup entry. The sentReminders map still prunes on `dtstartMs <= now`.
In the Google adapter, always normalize `start.dateTime` to UTC before building the iCalendar string. Use `new Date(googleEvent.start.dateTime).toISOString()` to get the UTC instant, then build `DTSTART:20260701T140000Z`. Never emit a `TZID` reference without a corresponding `VTIMEZONE` block.
For the variable-window query: instead of scanning a fixed `(now, now+16min]` window, store the per-event lead time alongside the VALARM in `calendar_events` (e.g., a `reminderMinutes` column). The scheduler query becomes `WHERE dtstartUtc <= (now + reminderMinutes minutes) AND dtstartUtc > now`. This requires a schema migration.
For all-day events: map `start.date` directly to `DTSTART;VALUE=DATE:20260701`, same as the CalDAV path.
Add an integration test for the scheduler that covers: (a) event with a 30-minute lead fires at T-30, (b) event rescheduled earlier after the first fire fires again for the new time.
For Google `reminders.useDefault: true`: treat it as "no reminder" at the FamilySync layer. Store `reminderLeadMinutes: null`. Document this in the UI: "Google default reminders from your Google account settings are not shown in FamilySync; set a reminder here to receive FamilySync push notifications." This avoids fetching and storing per-member Google account preferences.
For Google `reminders.overrides`: map the first `email`-method reminder to `reminderLeadMinutes` (FamilySync uses push, not email). Ignore `popup` method overrides unless there is a clear 1:1 mapping.
**Warning signs:**
- Events with a long reminder lead never fire.
- Rescheduled event reminder does not fire after the reschedule.
- Scheduler dedup map grows without bound (no uid-dtstart pair is ever pruned because the dtstart moved out from under the map entry).
- Timed events from Google appear at the wrong time in FamilySync (off by the timezone offset).
- ical.js warnings about unknown TZID in the server logs.
- All-day events with `useDefault: true` trigger unexpected push notifications.
**Phase to address:**
Per-event reminders phase. Schema migration for `reminderMinutes` column in `calendar_events` is a prerequisite; dedup key change must land in the same plan.
Google Calendar provider phase (event normalization plan). Write unit tests for the UTC normalization: assert that a `start.dateTime` with `-04:00` offset produces `DTSTART:...Z` with the correct UTC instant, and that a missing VTIMEZONE is never emitted.
---
### Pitfall 5: Double-Drain When Event-Driven Trigger and 15s setInterval Both Fire
### Pitfall 5: Provider Abstraction Refactor Regresses the Live Fastmail Path
**What goes wrong:**
The v1.1 event-driven drain adds a trigger (Redis pub/sub message, or direct `runOutboxDrain()` call) that fires immediately when a new row is enqueued. The 15s `setInterval` fallback continues to run. Both can invoke `runOutboxDrain()` concurrently. The existing `isDraining` module-level flag provides single-execution within the same JS tick, but there is a subtler race:
Extracting a `CalendarProvider` interface from the existing CalDAV code requires moving the tsdav client, sync logic, poller, outbox worker, and reminder scheduler from Fastmail-specific code into an abstraction layer. The risk is that the refactor introduces a regression on the Fastmail path: a subtle behavioral change in the outbox worker (e.g., enqueue ordering, `isDraining` guard, or the `drainRequested` flag) can cause:
- T=0: Route enqueues row. Event-driven trigger calls `runOutboxDrain()`. `isDraining` is set.
- T=0.5s: Drain in progress. 15s interval fires. `isDraining` is true — no-op. Correct.
- T=1s: Drain completes. `isDraining` reset to false.
- T=1.5s: Event-driven trigger for a SECOND enqueue calls `runOutboxDrain()`. Drain starts.
- T=14s: First 15s tick since startup fires (not 15s after the last drain completed, but 15s after the interval was registered at server start). Calls `runOutboxDrain()`. `isDraining` is true — no-op. Correct.
- Duplicate CalDAV PUTs (double-drain race reintroduced).
- Create-before-delete ordering broken for move operations.
- The ctag-based poller not calling the new interface method in the correct order (poll → sync → schedule).
- The `uid:dtstartMs` dedup key in the reminder scheduler falling back to `uid` alone if the schema type changes during the refactor.
So far so good — `isDraining` handles this. The failure mode is: **if `runOutboxDrain` is called directly (not through the setInterval wrapper) from the event-driven path, thrown errors will not be caught by the setInterval `.catch()` handler.** An unhandled rejection crashes the process on Node 22 (where unhandledRejection is fatal by default unless a handler is registered). The fix is to always use the same error-caught wrapper: `runOutboxDrain().catch(err => console.error(...))`.
A more dangerous double-drain scenario arises if the event-driven trigger is implemented via ioredis pub/sub and the subscriber receives the same message twice (ioredis at-least-once delivery). Two concurrent `runOutboxDrain()` calls can occur before either sets `isDraining`. The `isDraining` check is not atomic. In the single-process Node.js event loop, two synchronous checks of `isDraining` before any `await` both see `false` and both proceed. The first `await db.select()...` in both drain calls then runs in parallel. Both fetch the same pending rows and dispatch the same CalDAV writes, producing duplicate PUTs.
A specific risk: the outbox worker currently reads `provider_type` from `member_credentials` but the only implemented value is `'caldav'`. During refactor, if the worker is changed to dispatch to a provider-specific `write()` method, and the CalDAV write path is accidentally placed behind a `provider_type === 'google'` branch instead of `provider_type === 'caldav'`, all existing Fastmail write-back silently stops with no error (the wrong branch simply does nothing).
**Why it happens:**
`isDraining` is a module-level boolean, not a mutex or a DB-level row lock. In the single-process deployment it is correct for the `setInterval` case (the JS event loop ensures only one tick can run at a time). But two synchronous calls to `runOutboxDrain()` before any `await` both pass the `if (isDraining) return` check because the flag is set inside the function body, not before the call.
Refactors that extract interfaces from working code are high-regression-risk because the tests were written against the concrete implementation. The test suite may pass because it stubs the CalDAV calls but not the new interface dispatch, so the dispatch logic change is never exercised by tests.
**How to avoid:**
Wrap the event-driven call in the same caught wrapper. More importantly: do not call `runOutboxDrain()` directly from the pub/sub subscriber. Instead, call a `triggerDrain()` helper that sets `isDraining = true` synchronously before the first await, or simply lets the setInterval do the work and uses the pub/sub message only to shorten the next wait (e.g., trigger a single immediate `runOutboxDrain()` call from within the setInterval handler if a "pending" flag is set, keeping all drain calls single-threaded through the interval). The cleanest approach: keep one drain path (the setInterval), but when an enqueue event arrives, set a `drainRequested` flag; the next setInterval tick checks the flag and drains immediately instead of waiting the full 15s.
Refactor behind a feature flag or in a branch that keeps the Fastmail path fully operational throughout. Do not merge the provider abstraction until all existing CalDAV integration tests (outbox, poller, ctag, reminder scheduler) pass unchanged against the new interface.
Write a golden-path integration test for the Fastmail provider specifically: enqueue a write → assert it reaches the CalDAV mock → assert the outbox row is marked done. This test must pass before and after the refactor with no modification.
Keep the `provider_type === 'caldav'` dispatch path as the default/fallback. The Google path (`provider_type === 'google'`) is additive; it must not be required for the existing Fastmail path to work.
The abstraction interface should be defined from the consumer's perspective (what `outboxWorker.ts` and `poller.ts` need), not from the CalDAV implementation's perspective. Avoid leaking tsdav types (`DAVCalendar`, `DAVObject`) into the interface — callers should see normalized event objects.
**Warning signs:**
- Duplicate CalDAV PUTs for the same event visible in Fastmail logs.
- Two identical events appearing briefly after an edit.
- 412 conflict errors on the second of two simultaneous drain calls (the first PUT succeeded, the second uses an outdated etag).
- Any existing CalDAV integration test is modified during the refactor (should be zero changes to existing tests).
- The outbox worker has a branch that exits without calling any write method for `provider_type === 'caldav'`.
- The `provider_type` column contains `'caldav'` for all existing rows but the refactored dispatch uses string equality without a default case.
**Phase to address:**
Event-driven outbox drain phase. The drain trigger design must be reviewed before implementation; the `isDraining` guard docs already note the single-process limitation.
Provider abstraction phase (first). Must ship before any Google Calendar code is written. The acceptance gate is: all v1.1-passing integration tests still pass, unchanged, with the abstraction layer in place.
---
### Pitfall 6: Event-Driven Drain Breaks Create-Before-Delete Ordering Under Concurrent Enqueues
### Pitfall 6: Multiple VALARMs — Duplicate Fire and Preserve-vs-Replace Ambiguity
**What goes wrong:**
The edit-as-move path enqueues two rows (delete old uid, create new uid) in the same request handler. With the 15s poll, both rows are almost always in the DB before the next drain cycle. With event-driven drain (trigger fires on enqueue), a race is possible:
v1.2 adds multiple reminders per event (multiple VALARMs on a single VEVENT). The v1.1 reminder scheduler deduplicates on `uid:dtstartMs` — one dedup entry per event. With multiple VALARMs, a single event now needs multiple push notifications at different lead times. If the dedup key is still `uid:dtstartMs`, only the first alarm fires; subsequent alarms for the same event at different leads are treated as "already sent" by the dedup map.
- Request handler enqueues the CREATE row. Event-driven trigger fires immediately. Drain runs. Create is dispatched successfully. `isDraining` resets.
- Request handler (same HTTP request, now at the second DB insert) enqueues the DELETE row. Trigger fires again. Drain runs. The create is status=done. The delete is dispatched.
A second issue: the scheduler currently runs a SQL query for events where `reminderLeadMinutes IS NOT NULL`. With multiple reminders stored as multiple VALARM objects in `rawVevent` (and potentially as a JSON array or repeated column), the SQL query must change to a per-VALARM lead-time scan. If the query fetches one row per event and picks the first VALARM lead only, subsequent alarms are silently skipped.
This is actually the happy path — correct ordering. The dangerous case is if the HTTP handler enqueues the DELETE row first and the CREATE row second (e.g., if the code is written in that order). The event-driven drain fires after the DELETE enqueue, finds the delete row with no done-sibling, and the durable CR-04 gate defers it. When the CREATE is then enqueued and drained, the delete is re-attempted on the next cycle — also correct. But if the delete fires before the create for any reason (e.g., a coding error that enqueues in the wrong order, or the delete row has a lower `next_attempt_at`), the original event is deleted before the new one is confirmed, causing data loss.
A third issue: the VALARM preserve-on-edit path (D-08 / CAL-14, shipped in Phase 11) extracts all existing VALARMs from `rawVevent` and re-attaches them. With multiple VALARMs, if the user adds a new reminder in the FamilySync UI (which produces one VALARM from the picker), the preserve path must decide: replace all existing VALARMs with the single new one, or append? The current single-reminder model implicitly "replace all." Multiple-reminder UI changes this intent.
A subtler issue: if the event-driven trigger fires between the two DB inserts in the same HTTP handler (possible if the first `await db.insert()` resolves and the trigger fires before the second `await db.insert()` runs), the drain may start before both rows are committed. MySQL/MariaDB default isolation (REPEATABLE READ) means the drain transaction may not see the second row at all until it starts a new transaction. The CR-04 durable gate handles this case: the delete will defer itself because the sibling create is not yet visible. But if the create row is invisible, the drain processes the delete alone, defers it correctly, and then the create arrives. This is safe but results in at least one extra drain cycle for the move. Not a bug, but a latency regression on the event-driven path.
A fourth issue: Google Calendar caps reminders at 5 per event (`reminders.overrides` max 5). If the user creates 6 reminders in FamilySync and the event is in a Google Calendar, the write-back to the Google Calendar API returns a 400 error. The error must be caught and surfaced to the user before the write, not as a silent failure.
**Why it happens:**
The v1.1 architecture was designed for exactly one VALARM per event (the picker is single-selection). Multiple VALARM support requires rethinking the dedup key, the SQL query, the preserve logic, and the write-back validation.
**How to avoid:**
Always enqueue the CREATE row before the DELETE row in the HTTP handler, matching the existing sort-before-dispatch logic in the drain. This is already the intent of D-04 but should be an explicit code comment in the edit-as-move handler.
Change the scheduler dedup key to `uid:dtstartMs:leadMinutes` (three-part key). Each VALARM on an event gets its own dedup entry, and each fires independently.
Do not trigger the event-driven drain between the two enqueue inserts. If the trigger is a direct call, wrap both inserts in a single DB transaction and trigger the drain only after the transaction commits. If the trigger is Redis pub/sub, publish after both inserts.
Store multiple reminder leads in a normalized form. Two options: (a) a `event_reminders` junction table (`event_id`, `lead_minutes`), or (b) a JSON array column `reminder_lead_minutes_json` on `calendar_events`. Option (a) is cleaner for SQL querying; option (b) avoids a second migration and join. For a two-user household, option (b) is acceptable. The SQL scheduler query must unnest or join to iterate per-lead.
For the preserve-on-edit path: the UI must distinguish "user changed reminders" from "user changed something else (title, time, location)." If reminders were not touched, preserve all existing VALARMs. If the user opened the reminder editor and submitted, replace all VALARMs with the new set. This is a frontend concern: include a `remindersChanged: boolean` flag in the outbox payload alongside the new `reminderLeads: number[]` array.
For Google Calendar cap validation: add a Zod validator on the outbox payload schema that rejects more than 5 reminder leads when the provider is Google. Surface the error in the form before submit.
**Warning signs:**
- Edit-as-move operations produce a "calendar object not found" error from Fastmail (delete reached Fastmail before the create).
- Events occasionally disappear after an edit and reappear after the next poller sync cycle.
- CR-04 deferral log messages (`Deferring delete row...`) appearing frequently for move operations.
- Second and third reminders on an event never fire.
- The scheduler sends duplicates for the first alarm on every tick (dedup not working after key change).
- Events with 6+ reminders silently fail to write to Google Calendar.
- The preserve path strips all VALARMs when the user edits only the event title.
**Phase to address:**
Event-driven outbox drain phase. The enqueue ordering requirement and transaction boundary must be specified in the plan.
Multiple reminders phase. The dedup key change and schema migration must land in the same plan. Google cap validation must land in the same plan as Google write-back support.
---
### Pitfall 7: Admin App-Password Update Logged or Echoed in Error Messages
### Pitfall 7: Drizzle Auto-Migrate on Boot on MariaDB 11 — The Three Failure Modes
**What goes wrong:**
The admin Settings route receives the new Fastmail app password in the request body. If Zod validation fails (wrong format, too long), the default Zod error message includes the invalid value in the error output: `Invalid value: "xxxx-xxxx-xxxx-xxxx"`. If the Hono error handler returns this Zod error to the client as JSON, the app password appears in: (1) the HTTP response body, (2) any request logging middleware, (3) server logs if the error is caught and `console.error(err)` is called with the full error object.
v1.2 adds zero-manual-setup: the API process runs pending migrations automatically at startup. There are three distinct failure modes specific to this stack:
Separately: `decryptPassword` in `broker/crypto.ts` currently never logs the decrypted value (T-03-13), but the admin update route must call `encryptPassword(newPassword)` after receiving the plaintext. If the route logs the request body at any point before the encrypt call, the password is in the logs.
**Failure mode A — Journal-hash mismatch with legacy tracking.** The `__drizzle_migrations` table records applied migrations by `tag`. If any migration file was renamed after it was applied to an existing database (or if the journal was re-generated, changing the `when` timestamp), drizzle-kit migrate will not find the matching `tag` in `__drizzle_migrations` and will try to re-apply the already-applied migration. On MariaDB, re-running `ALTER TABLE ... ADD COLUMN` for a column that already exists returns `Duplicate column name` — not a transaction rollback, but a crash. This is the "journal-hash mismatch" lesson from D-MIGRATION-10-01.
**Failure mode B — False destructive diff if `db:push` logic is used.** Any code path that calls `drizzle-kit push` (even programmatically or from a startup script) against the live MariaDB will schedule TRUNCATE/DROP. The `drizzle migrate` API (programmatic via `drizzle-orm/migrator`) is safe — it applies committed `.sql` files and does not introspect the live DB. The failure mode is: a developer adds a startup call that uses the wrong drizzle API entry point.
**Failure mode C — Concurrent startup race (two container instances).** If the Docker Compose stack is ever restarted in a way that brings up two API containers briefly (e.g., a rolling update or `docker-compose up --scale api=2`), both instances call `migrate()` at startup. MariaDB does not have advisory locks by default. Both instances read `__drizzle_migrations`, both see the same unapplied migrations, and both attempt to apply them simultaneously. The first to commit wins; the second hits `Duplicate column name` or a primary key conflict and crashes. The container crash loop then restarts the container, which succeeds because migrations are now applied — but the crash log looks like a startup failure.
**Why it happens:**
Developers often log `req.body` at the route level for debugging during development. The admin route is new, debugging is natural, and the log line gets committed. Zod error passthrough is the other common source — the validator middleware returns the full error object.
Startup migration is convenient but runs without a human watching the DDL. The existing dev workflow always runs `pnpm db:migrate` explicitly, so a developer sees the output. Auto-migrate is invisible unless there is explicit logging. On MariaDB (no DDL transactions), a partially-applied migration leaves the schema in an undefined state.
**How to avoid:**
In the `@hono/zod-validator` middleware for the app-password body schema, always use a custom `hook` to return a generic `{ error: "Invalid request" }` without the Zod error detail. Never log the request body in the admin/settings routes. Add a lint rule or code review checklist item: no `console.log` in any file under `routes/admin*` or `routes/settings*` that could include body content.
Use the `drizzle-orm/migrator` programmatic API (`migrate(db, { migrationsFolder: '...' })`), not `drizzle-kit` CLI, in the startup path. The migrator API applies committed SQL files sequentially and tracks via `__drizzle_migrations` — it is safe for auto-migrate.
For test coverage: write a unit test that asserts the route returns `400` with no `value` field in the response when given an invalid password. Do not assert on the specific Zod error message.
Add a startup log that prints each migration file name as it is applied (or "already applied, skipping"). This makes auto-migrate auditable.
For the concurrent-startup race: wrap the migrate call in a MariaDB advisory-lock table: `INSERT INTO migration_lock (id) VALUES (1)` with a unique constraint. If the insert fails, the other instance already holds the lock and is migrating — wait and retry. On success, run migrations, then `DELETE FROM migration_lock WHERE id=1`. This is a single-row sentinel table, created before any other migration.
For distinguishing fresh-DB vs existing-DB: the `__drizzle_migrations` table not existing means a fresh DB — apply all migrations. The table existing with all entries means fully migrated — skip. The table existing with some entries means a partial state — apply only the unapplied subset (normal drizzle behavior). No special-casing needed; the migrator handles this correctly.
**Warning signs:**
- App password appears in any log output or API response body.
- The Zod error response for the settings route includes a `received` or `message` field containing password-like strings.
- `Duplicate column name` error in the API startup log immediately after deployment.
- Two container instances log "applying migration 0003_..." at the same timestamp.
- The API container crashes on first start but succeeds on second start (concurrent-race symptom).
- Any code calling `drizzle-kit push` in a startup path (grep for it explicitly before shipping).
**Phase to address:**
Admin Settings phase. Security review of the settings route before first deployment; treat app-password fields the same as `OIDC_CLIENT_SECRET` — never log, never echo.
Zero-manual-setup DB migration phase. The `migration_lock` sentinel table and the startup log must be in the acceptance criteria. Write a test that runs `migrate()` twice on the same DB and asserts no error and no duplicate column.
---
### Pitfall 8: Unauthenticated Setup Endpoint Left Live After First Run
### Pitfall 8: PWA Dark Mode — Flash of Wrong Theme and Service Worker Cache Staling
**What goes wrong:**
The setup wizard endpoint must be accessible before any member has authenticated (no credentials exist yet, so OIDC cannot be used to protect it). The typical implementation: mount the setup routes outside the `app.use('/api/*', oidcAuthMiddleware())` guard, and detect "first run" by checking whether any `member_credentials` row (or VAPID env) exists. The failure mode: the first-run check passes once. But if the developer forgets to add an "already-set-up" guard, or the check looks at the wrong table, the endpoint remains callable after setup — allowing anyone who can reach the internal network (or the Pangolin public URL) to overwrite the app password without authentication.
The standard dark mode pattern — read `prefers-color-scheme` or a `localStorage` preference and apply a `data-theme` attribute on `<html>` — has a flash-of-wrong-theme (FOWT) on initial load if the attribute is set by React (after hydration) rather than by an inline script before the first paint. The user sees light mode for ~100ms then switches to dark. On iOS standalone PWA, this flash is especially visible because the splash screen (controlled by `theme-color` meta tag) may not match the applied theme.
A second failure mode: the wizard validates env vars (VAPID keys, DB connection, app password) but stores the app password directly in the DB or in a temp file instead of in an env var. The architecture requires app passwords to be encrypted at rest using `APP_PASSWORD_ENCRYPTION_KEY`. If the wizard stores the password before the encryption key env is set (it shouldn't be — the wizard is supposed to collect the key or confirm it exists), the encryption call throws and the wizard fails with a 500 that may include the plaintext password in the error.
The deeper issue: Schedule-X uses `--sx-color-*` CSS custom properties for its calendar component theming. These are not in the `tokens.css` semantic token layer established in Phase 17. If dark mode only updates the FamilySync token variables and not the `--sx-color-*` variables, the calendar component remains in light mode regardless of the document theme — a visually broken state that is easy to miss in desktop testing (because developers often test on a light system preference).
A service worker cache complication: the Workbox-managed precache caches `index.html` and all static assets at build time. If the theme preference is baked into a cached `index.html` (e.g., via a server-side render or a build-time default), the cached version will always load with the light-mode default, overriding the user's stored preference. In Vite PWA (client-side rendered), this is not the primary risk — but if the service worker caches a CSS file that includes hardcoded light-mode colors (not variables), dark mode changes deployed in a new build will not reach the user until the service worker updates.
**Why it happens:**
Setup wizards are one-shot paths that receive less testing than the main app. "First run only" guards are often implemented as booleans that can be reset, or checks that are too broad.
Dark mode is treated as a CSS-only concern but the FOWT is a JS execution timing concern. Schedule-X's custom properties are undocumented relative to external theming. Service worker cache invalidation for CSS changes requires a cache-busting strategy.
**How to avoid:**
Implement the "already set up" guard as: check for any row in `member_credentials` AND for the presence of VAPID env vars (not just one or the other). If either is already set, return 423 Locked from all setup endpoints. Once the setup completes successfully, the next request to setup routes returns 423 immediately — no state to reset without a server restart.
Prevent FOWT: inject a minimal inline `<script>` in `index.html` (before any CSS) that reads `localStorage.getItem('theme')` and sets `document.documentElement.dataset.theme` synchronously. This runs before any CSS is parsed, preventing the flash. The React app then reads the same localStorage key on mount to initialize the Zustand theme store.
Alternatively, use a DB-stored `setup_completed_at` timestamp in a `settings` table (a migration is needed anyway for v1.1 admin features). The wizard marks this column on completion; all setup routes check it first.
Map all `--sx-color-*` variables used by Schedule-X to the FamilySync dark/light token values. Inspect the Schedule-X `@schedule-x/theme-default` CSS to enumerate all `--sx-*` variables and override them in a `tokens.css` `[data-theme="dark"]` block. Assert that the calendar renders in dark mode during the dark mode acceptance test.
Never accept the `APP_PASSWORD_ENCRYPTION_KEY` value via the API. The wizard should validate that the env is already set (by attempting a test encrypt/decrypt), not collect the key. The key stays in the env/Docker secrets layer.
For service worker cache: `vite-plugin-pwa` handles cache busting via file hashing in the build output. Verify that `tokens.css` (or whichever file holds the dark mode variables) is included in the Workbox precache manifest — if it is in the public directory without a hash, it may not be cache-busted on deploy. Move all theme CSS into a hashed build output file.
For iOS standalone PWA `theme-color`: update the `<meta name="theme-color">` dynamically using `document.querySelector('meta[name="theme-color"]').setAttribute('content', ...)` when the theme toggles. iOS reads `theme-color` for the status bar color. A static `theme-color` will not match the active dark theme.
**Warning signs:**
- Setup endpoint returns 200 after the app is already configured.
- Curl to `/api/setup/...` with no auth cookie returns a non-401/423 response.
- Setup route has no test covering the "already set up" scenario.
- Brief white flash visible on page load when dark mode is selected.
- The calendar component (Schedule-X) shows light colors while the rest of the app is dark.
- iOS status bar color does not match the active theme.
- Deploying a dark mode CSS update does not reach users until they manually clear the service worker cache.
**Phase to address:**
Setup wizard phase. The guard must be the first thing implemented; test the guard before testing the happy path.
PWA dark mode phase. The FOWT fix (inline script in `index.html`) and Schedule-X variable mapping must be in the acceptance criteria. Test on both light and dark system preferences. Test with the service worker active (not disabled).
---
### Pitfall 9: Admin Role Check Bypassed by Missing Middleware Wiring
### Pitfall 9: OAuth Callback Through Pangolin/Newt Tunnel — Redirect URI Mismatch and State Cookie Collision
**What goes wrong:**
The admin Settings routes require a role check (only the operator/admin user can manage credentials and toggle `is_shared`). The standard pattern in this codebase is Hono middleware layered on a route prefix. The failure mode: the admin middleware is defined but not wired to the correct prefix. For example, if the admin check is added to `eventsRouter` instead of a new `adminRouter`, or if the route is mounted at `/api/admin` but the middleware guard applies to `/api/settings/*`, admin routes are reachable by any authenticated member.
The Google OAuth flow requires registering exact redirect URIs in the Google Cloud Console. The Pangolin/Newt tunnel exposes the app at a public hostname (e.g., `familysync.example.com`). The registered redirect URI must match exactly: `https://familysync.example.com/api/auth/google/callback`. If the redirect URI in the Google Cloud Console is `http://` instead of `https://`, or includes/omits a trailing slash, or uses the internal hostname instead of the Pangolin hostname, the callback returns `redirect_uri_mismatch` from Google.
In a two-person household this is low-severity (both members are trusted), but the `is_shared` toggle can break the whole calendar display for both members if set incorrectly, and the credential management can overwrite the other member's app password.
A second issue: the existing Authelia OIDC flow already uses an OAuth state cookie (`oidc.state`) managed by `@hono/oidc-auth` (stored as a cookie on the domain). The Google OAuth flow also requires a `state` parameter to prevent CSRF. If both flows use the same cookie name, or if the `@hono/oidc-auth` middleware intercepts the Google OAuth callback URL (because it pattern-matches `/api/*` for auth), the Google callback's `code` parameter is consumed by the Authelia handler before the Google handler sees it — resulting in a `state mismatch` or `invalid_grant` error.
A third issue: the Pangolin/Newt tunnel may terminate TLS and forward HTTP internally. If the API receives the Google callback over HTTP (forwarded internally), but the `redirect_uri` registered in Google is `https://`, Google's OAuth server rejects the authorization code exchange with `redirect_uri_mismatch` because the `redirect_uri` in the token exchange request is constructed from the internal HTTP URL, not the external HTTPS URL.
**Why it happens:**
Hono's middleware scoping is based on route prefix at mount time, not at route definition time. A middleware added with `app.use('/api/admin/*', adminGuard)` does not protect routes mounted under `app.route('/api/admin', adminRouter)` unless the `adminRouter` itself also applies the guard. It is easy to apply the middleware in one place and assume it covers the route, but Hono's `.route()` creates an isolated sub-app.
Two OAuth flows in one app (Authelia OIDC for user login + Google OAuth for calendar access) use overlapping mechanisms (cookies, state params, callback routes). The Pangolin/Newt HTTPS termination creates an HTTP-internally-but-HTTPS-externally environment where naive URL construction picks the wrong scheme.
**How to avoid:**
Apply the admin middleware inside `adminRouter` itself (`.use('*', adminGuard)`), not only in the parent app. Write an integration test that calls a settings route as a non-admin authenticated user and asserts 403. Do not rely on the parent app's middleware order for sub-app security.
Mount the Google OAuth callback at a distinct path that the Authelia `@hono/oidc-auth` middleware does not intercept: `/api/providers/google/callback` (not under `/api/auth/`). Apply the Authelia OIDC middleware only to routes that need a logged-in session, not as a global catch-all for `/api/*`. The Google callback handler does not need an Authelia session — it needs the Google state param. Apply authentication to the Google OAuth _initiation_ endpoint (user must be logged in to connect Google), but not to the callback endpoint (callback must be reachable without a session because Google redirects there).
Use distinct state cookie names: the Authelia flow uses `oidc.state` (managed by `@hono/oidc-auth`); the Google flow should use `google.oauth.state` (managed by the Google OAuth handler). Store the state in a `HttpOnly; SameSite=Lax; Secure` cookie with a 10-minute TTL.
For the HTTPS scheme: read the external hostname from a `EXTERNAL_BASE_URL` environment variable (e.g., `https://familysync.example.com`) and construct all OAuth redirect URIs from it. Never derive the redirect URI from `req.headers.host` or `req.protocol` — the internal forwarded values will be wrong. Add `EXTERNAL_BASE_URL` to the required env checklist in the zero-setup wizard.
Register the redirect URI in Google Cloud Console as exactly `${EXTERNAL_BASE_URL}/api/providers/google/callback`.
**Warning signs:**
- Any authenticated user can reach `/api/admin/...` routes without an admin check in the response.
- The admin middleware is defined in `index.ts` but the admin routes are in a separate `adminRouter` with no internal middleware.
- Google OAuth callback returns `redirect_uri_mismatch`.
- After completing the Google consent screen, the app returns to the homepage without connecting the account (silent failure).
- Authelia OIDC errors appear in logs during a Google OAuth flow (state cookie collision).
- The Google callback URL in server logs shows `http://` instead of `https://`.
**Phase to address:**
Admin Settings phase. Integration test for 403 on non-admin access is the acceptance criterion.
Self-service Google OAuth onboarding phase. The `EXTERNAL_BASE_URL` env var and the distinct callback path must be established before any OAuth flow is implemented. Test the full flow end-to-end over the Pangolin tunnel (not just localhost) as the acceptance gate.
---
### Pitfall 10: App Password and VAPID Keys Stored in DB When They Must Stay in Env
### Pitfall 10: Google OAuth Token Storage — Encryption Key Reuse and Scope Creep
**What goes wrong:**
The setup wizard collects VAPID keypair and validates the Fastmail app password. A tempting shortcut: store the VAPID keys in the `settings` DB table for easy retrieval later. The problem: `VAPID_PRIVATE_KEY` is a signing key — equivalent to a private TLS key. Storing it in the DB means it is:
The Google OAuth access token (short-lived, ~1 hour) and refresh token (long-lived) must be stored securely. The existing `member_credentials` table stores the Fastmail app password encrypted with `APP_PASSWORD_ENCRYPTION_KEY` (AES-256-GCM). The tempting approach: store the Google refresh token in the same `member_credentials` table with the same encryption key, adding a `google_refresh_token` column.
- Accessible to anyone with DB read access (including `SELECT *` from a misconfigured tool or a Drizzle Studio session left open).
- Included in DB backups, which may be stored less securely.
- Returned by any accidental DB dump to logs.
The first issue: `provider_type` is already `UNIQUE(user_id)` on `member_credentials` (enforced by `uniq_member_credential_user`). A member with both a Fastmail credential and a Google credential would need two rows but the unique constraint allows only one per user. Attempting to upsert the Google credential overwrites the Fastmail credential, breaking CalDAV.
`APP_PASSWORD_ENCRYPTION_KEY` must never enter the DB at all — it is the key that encrypts everything else. If the wizard stores it in the DB "just for display/verification," the entire encryption model is broken.
The second issue: the Google access token should not be stored persistently — it expires in 1 hour and must be refreshed on use. If the code stores the access token as well as the refresh token, the stored access token will always be stale and the code must always call the token refresh endpoint anyway. Storing the access token wastes a column and tempts future code to use it without checking expiry.
The third issue: the Google OAuth scope should be the minimum needed. If the developer uses `https://www.googleapis.com/auth/calendar` (full calendar access) instead of the split `calendar.readonly` + `calendar.events` scopes, Google requires manual verification review for production status. Use the minimum scope needed.
**Why it happens:**
The wizard naturally wants to show "current configuration" and make it editable. Pulling values from env vars in a form feels awkward; storing in DB feels clean. The distinction between "secret that must stay in env" and "config that can live in DB" gets blurred.
The `member_credentials` unique constraint and the `provider_type` discriminator were designed for a single provider per user. Multi-provider support requires rethinking the credential storage schema.
**How to avoid:**
Hard rule: `VAPID_PRIVATE_KEY` and `APP_PASSWORD_ENCRYPTION_KEY` never touch the DB. They are validated in the wizard by attempting an operation (test encrypt/decrypt, test push send), not by reading or writing their values. `VAPID_PUBLIC_KEY` and `VAPID_SUBJECT` can be stored in DB (they are not secrets). Fastmail app passwords are stored encrypted (AES-256-GCM via `encryptPassword`), which is already implemented.
Change the `member_credentials` schema for v1.2: drop the `UNIQUE(user_id)` constraint and replace it with `UNIQUE(user_id, provider_type)`. This allows one row per provider per user. The migration is additive (drop unique, add composite unique). Verify with `drizzle-kit generate` that the generated SQL is `DROP INDEX uniq_member_credential_user; ADD UNIQUE KEY uniq_member_credential_user_provider (user_id, provider_type)` — additive DDL plus an index drop. The index drop is safe on MariaDB (not a TRUNCATE).
The wizard's "check env" validation path: call `encryptPassword('test')` — if it throws, `APP_PASSWORD_ENCRYPTION_KEY` is missing or malformed. Call `webpush.setVapidDetails(...)` and catch throws. Never read the key values out of `process.env` into a response body.
Store only the Google refresh token (not the access token). Add a `google_refresh_token_encrypted` column (or a generic `oauth_refresh_token_encrypted` column) to the Google `provider_type` row. Derive fresh access tokens at request time using the refresh token — cache the access token in memory (not in the DB) with its expiry time.
Use scopes: `https://www.googleapis.com/auth/calendar.readonly` + `https://www.googleapis.com/auth/calendar.events`. Request them space-separated. Store the granted scopes alongside the refresh token so the app knows what the user consented to.
**Warning signs:**
- DB schema has a `vapid_private_key` column.
- Any API response that includes `VAPID_PRIVATE_KEY` or `APP_PASSWORD_ENCRYPTION_KEY` values.
- Wizard stores all config to DB and reads it back on next startup instead of requiring env vars.
- An attempt to add a second `member_credentials` row for the same user hits a unique constraint violation.
- `member_credentials` for a user shows `provider_type: 'google'` but the CalDAV credential is gone.
- The access token column in the DB is always expired.
- Google authorization flow requests the `https://www.googleapis.com/auth/calendar` scope (full access, not split).
**Phase to address:**
Setup wizard phase. Schema design review before migration is written. Secret-in-DB is a hard blocker for the phase gate.
Provider abstraction phase (schema migration plan) and Google OAuth onboarding phase. The schema change to `UNIQUE(user_id, provider_type)` must land before any Google credential storage code is written.
---
### Pitfall 11: Gitea Actions MariaDB Service Container Readiness Race
### Pitfall 11: Dependency Updates — ESLint 10 Pin and Breaking Change Cascade
**What goes wrong:**
Gitea Actions (like GitHub Actions) supports `services:` containers. The MariaDB service starts, but the container reaching `healthy` in Docker does not mean MariaDB is accepting connections on port 3306. `mysqld` takes several seconds to initialize after the container starts. If the CI job proceeds to `drizzle-kit migrate` or integration test commands immediately after the service health check passes, it races with MariaDB initialization and fails with `ECONNREFUSED` or `Access denied` errors that look like test failures but are actually timing issues.
The CI dependency audit (Phase 16) surfaces `outdated` packages. Bulk-applying `pnpm up` or `pnpm audit fix` will attempt to update ESLint to v10, which breaks `eslint-plugin-react` and `eslint-plugin-react-hooks` (both declare only `eslint@9.x` as a peer dependency as of 2026-06). When ESLint 10 is installed, both plugins either throw `EBADPEERCONN` at install time or emit no errors at lint time (silently disabled), breaking the CI lint gate.
A secondary risk: `@schedule-x/calendar` and `@schedule-x/theme-default` have been updated (currently pinned at 4.6.0 in `apps/pwa`). Schedule-X 5.x (if released) changes the `--sx-color-*` variable naming. If the dependency update phase bumps Schedule-X to a major version before the dark mode phase, the dark mode variable mapping work must be redone with the new variable names.
A third risk: `drizzle-kit` and `drizzle-orm` minor versions must be updated together. The `drizzle-orm/mysql2` import path and the `drizzle-orm/migrator` API are version-coupled. If `drizzle-orm` is bumped to 0.46.x but `drizzle-kit` stays at 0.31.x, the snapshot format version mismatch may cause `generate` to produce an incompatible snapshot that `migrate` cannot parse.
**Why it happens:**
The Docker `HEALTHCHECK` for MariaDB using `mysqladmin ping` returns true as soon as the network socket is open, which happens before all privilege tables are initialized. The `healthcheck.interval` in the Gitea service definition controls how often the check runs, but the first check may pass before MariaDB has fully bootstrapped.
`pnpm up` or `pnpm audit --fix` applies updates greedily across the dependency tree without respecting known breaking-change boundaries. The project already knows about the ESLint 9.x pin but a future developer (or an automated tool) may not.
**How to avoid:**
Add a `wait-for-it` or `until mysqladmin ping --silent; do sleep 1; done` step in the CI workflow after the service is declared healthy, before running any DB command. Or use a longer `healthcheck.start_period` in the service definition (e.g., 30 seconds). Also: set `MARIADB_ROOT_PASSWORD`, `MARIADB_DATABASE`, `MARIADB_USER`, `MARIADB_PASSWORD` in the service env and use those same credentials in the integration test step — do not assume the root user is reachable from the test runner without a password.
Update dependencies manually, one package or one ecosystem at a time, with the CI lint gate running after each batch. Never use `pnpm up --latest` or `pnpm audit --fix` without reviewing the changeset.
ESLint: hold at 9.x until `eslint-plugin-react` and `eslint-plugin-react-hooks` both publish ESLint 10 peerDep support and the release notes confirm compatibility. Add a comment in the root `package.json` above the `"eslint"` entry: `// pinned at 9.x — eslint-plugin-react does not support eslint@10 yet`.
Schedule-X: update minor versions only (4.x → 4.y). Hold at major 4 until dark mode theming is complete and the variable mapping is locked. Check Schedule-X release notes for `--sx-color-*` variable changes before any minor update.
Drizzle: always update `drizzle-orm` and `drizzle-kit` in the same commit. After updating, run `pnpm db:generate` in a dry-run (the output must be "No schema changes, nothing to migrate") to verify the snapshot format compatibility.
**Warning signs:**
- CI passes on re-run but fails on first run of a PR (timing-dependent).
- `ECONNREFUSED` or `Error: connect ECONNREFUSED 127.0.0.1:3306` in CI logs.
- Tests that pass locally with a warm MariaDB fail in CI cold-start.
- ESLint exits 0 (no errors reported) but previously failing lint rules now pass — plugin silently disabled.
- `eslint-plugin-react@X requires eslint@">=9.0.0 <10.0.0" but got eslint@10.x.x` in pnpm install output.
- `drizzle-kit generate` emits a snapshot with a different `version` field than the existing snapshots in `meta/`.
- Schedule-X calendar renders with missing styles after a version bump.
**Phase to address:**
Gitea CI phase. The readiness wait must be in the first draft of the workflow YAML; do not add it after the first CI failures.
---
### Pitfall 12: Gitea Actions Self-Hosted Runner Missing Node 22 or pnpm
**What goes wrong:**
The self-hosted Gitea Actions runner on Unraid may have an older Node.js version globally available, or may have no `pnpm` installation, or may have a `corepack`-managed pnpm that requires activation. If the CI workflow assumes the runner environment matches the dev machine, `pnpm install` fails with `pnpm: command not found`, or `node --version` returns 18 instead of 22.
A related issue: the workflow may use `actions/setup-node` (a GitHub Actions action) which is not available in Gitea Actions, or uses a Gitea-specific variant that requires different configuration.
**Why it happens:**
Gitea Actions is not GitHub Actions. Many popular actions (`actions/checkout`, `actions/setup-node`, `actions/cache`) have Gitea-compatible alternatives, but their names and behavior differ subtly. If the workflow is copied from a GitHub Actions template, some steps silently fail or are skipped.
**How to avoid:**
In the first CI plan, write a minimal "hello world" workflow that only checks `node --version` and `pnpm --version`. Verify it passes before adding any test steps. Use `actions/setup-node` only if confirmed compatible with the specific Gitea version; otherwise install Node and pnpm explicitly in the workflow using `wget` / `npm install -g pnpm`. Pin the Node version to `22.x` explicitly; do not rely on the runner default.
For Docker image build/publish: verify the runner has Docker daemon access. On Unraid self-hosted runners, Docker may require `--privileged` or specific socket mounts that need runner configuration.
**Warning signs:**
- `pnpm: command not found` in CI output.
- `node` resolves to a version older than 22 in CI but not locally.
- `actions/setup-node` step shows as skipped or errored in the Gitea Actions UI.
**Phase to address:**
Gitea CI phase. The runner environment probe must be the first CI task — before any test or build steps are designed.
---
### Pitfall 13: Docker Registry Push Token Scope Exposes Secrets in Logs
**What goes wrong:**
The Gitea CI Docker build/publish step requires credentials for the Docker registry (Docker Hub, Gitea's own container registry, or a self-hosted registry). If the registry token is passed as a `docker login` argument on the command line (e.g., `docker login -u $USER -p $TOKEN`), the token appears in the process list, in the Gitea Actions job log (if command echo is on), and in any runner audit logs. Gitea Actions supports `secrets:` but if the workflow uses `run: docker login -p ${{ secrets.REGISTRY_TOKEN }}`, the secret is masked in the log only if the secret was registered correctly — unregistered secrets are echoed verbatim.
**Why it happens:**
Docker CLI login via `-p` flag is the most common example in docs. GitHub Actions masks secrets automatically; Gitea Actions masks them only for registered secrets. A token from a CI environment variable that was not added through the Gitea Secrets UI is not masked.
**How to avoid:**
Use `docker login --password-stdin` with the token piped via stdin rather than a command-line argument: `echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin registry.example.com`. Register all credentials as Gitea repository secrets, not as environment variables in the workflow YAML. Verify the Gitea version supports secret masking in the Actions log (Gitea ≥ 1.19 for Actions support; secret masking behavior varies by version).
**Warning signs:**
- Registry token or password visible as plaintext in the Gitea Actions job log.
- `docker login` command line includes `-p <token>` in the log output.
- `secrets.REGISTRY_TOKEN` is undefined in the workflow (token was set as env var, not secret).
**Phase to address:**
Gitea CI phase. Credential handling review before any Docker push step is added.
---
### Pitfall 14: Playwright Authed-Mobile Harness Reusing a Stale storage-state
**What goes wrong:**
The mobile-emulated Playwright harness uses a saved `storage-state.json` (cookies + localStorage) to bypass the OIDC login flow. If the storage state was captured with a real Authelia session, it contains a session cookie with a finite TTL (typically 1 hour for `@hono/oidc-auth` JWT cookies, or the Authelia session lifetime). After the TTL expires, all Playwright runs with the stale storage state silently fail at the first `/api/*` call — the OIDC middleware redirects to Authelia, and the test gets an HTML login page instead of the expected JSON API response. The test may still pass if it only checks DOM content (which may be the redirected page's HTML), or it may produce a false-positive assertion on the 302 redirect.
**Why it happens:**
Playwright storage state is file-based and not automatically refreshed. Developers capture it once and check it in (or store it locally), then forget to renew it. In `DEV_AUTH_BYPASS=true` mode this does not apply (no session cookie needed), but if the harness is meant to test the production auth path, the bypass is not active and the session cookie must be valid.
**How to avoid:**
Do not use a static stored storage state for tests that run against the production OIDC path. Instead, implement a programmatic login helper that runs the OIDC authorization code flow at the start of each test session (or once per test run) and stores the resulting session. For the `DEV_AUTH_BYPASS` dev environment, the harness sets `DEV_AUTH_BYPASS=true` and skips the storage state entirely. The mobile viewport emulation does not require real OIDC — use `DEV_AUTH_BYPASS` for the automated harness; keep real OIDC tests as manual/human gates.
**Warning signs:**
- Playwright runs fail with `Expected 200 OK but got 302 Found` after leaving the storage state untouched for more than one day.
- Tests that exercise `/api/*` routes return HTML (the Authelia login page) instead of JSON.
- The same test suite passes reliably in `DEV_AUTH_BYPASS=true` mode but fails intermittently in production-auth mode.
**Phase to address:**
Mobile-browser testing phase. The storage state strategy must be decided before the first test is written — programmatic refresh or bypass-only.
---
### Pitfall 15: Production Service Worker Intercepting Playwright Requests
**What goes wrong:**
The installed Vite PWA service worker (`sw.js`) is registered in the browser when the PWA is visited. Playwright's Chromium instance can load and activate the service worker from a previous test run (persisted in the browser's profile directory). On subsequent test runs, the service worker intercepts API calls — potentially returning cached responses from the previous run rather than making network requests to the test server. This causes:
- API requests returning stale 200 responses when the test server is not running.
- `queryClient.invalidateQueries` not triggering new network requests (SW returns cached response).
- Tests that verify freshly-created data returning old data.
**Why it happens:**
Workbox's cache-first strategy for static assets and stale-while-revalidate for API routes persist across browser sessions in the Playwright profile. A new Playwright context does not clear the service worker registration unless explicitly reset.
**How to avoid:**
Use `browserContext.clearCookies()` and `browserContext.clearPermissions()` in the test setup, but also explicitly unregister service workers: `await page.evaluate(() => navigator.serviceWorker.getRegistrations().then(r => Promise.all(r.map(sw => sw.unregister()))))` before any navigation. Or launch Playwright with `serviceWorkers: 'block'` in the context options, which prevents the SW from intercepting requests entirely. For tests that specifically test offline/SW behavior, use a separate context without the block.
**Warning signs:**
- Network tab in Playwright traces shows `(ServiceWorker)` as the response source.
- Tests pass on a clean browser profile but fail on a profile that has visited the PWA before.
- API requests complete instantly with stale data in the Playwright trace.
**Phase to address:**
Mobile-browser testing phase. The Playwright context setup must explicitly handle service worker state before the first test is written.
CI dependency update phase (last in milestone, after all feature phases). Treat each package ecosystem as a separate update batch. Run the full CI suite after each batch.
---
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
| -------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
| Building VALARM on top of `buildVeventString` without the preserve-on-edit path | Faster to implement | Strips native-client alarms on every edit; user data loss | Never — preserve path must ship with VALARM authoring |
| uid-only dedup key in sentReminders when lead times become variable | No migration needed | Duplicate pushes or missed re-fires after reschedule | Never for production; acceptable in tests with a fixed lead |
| Calling `runOutboxDrain()` directly from event trigger instead of setting a flag | Simpler code | Bypasses `isDraining` atomicity, potential double-drain | Never — always funnel through the single setInterval-controlled path |
| Setup wizard that accepts `APP_PASSWORD_ENCRYPTION_KEY` via the API | Simpler UX for initial setup | Entire encryption model is broken | Never — key stays in env/secrets only |
| Static storage-state.json checked into the repo | Zero-effort Playwright auth | Tests fail silently after TTY expiry; potential credential leak | Never — programmatic refresh or DEV_AUTH_BYPASS only |
| `docker login -p $TOKEN` in CI command | Quick to write | Token appears in CI logs if secret not masked | Never — always use --password-stdin |
| No readiness wait for MariaDB service in CI | Simpler YAML | Flaky CI: timing-dependent ECONNREFUSED failures | Never — readiness wait is 3 lines and prevents ghost failures |
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Keeping `UNIQUE(user_id)` on `member_credentials` for the Google credential | No schema migration needed | Can only store one provider per user; Google credential overwrites Fastmail credential | Never — change to `UNIQUE(user_id, provider_type)` before any Google credential storage |
| Storing Google access tokens persistently in the DB | Avoids token refresh logic | Access tokens expire in 1 hour; all stored values are always stale | Never — cache in memory only; persist only the refresh token |
| Using `singleEvents: true` for Google Calendar event fetch | Simpler API call | Cannot reconstruct RRULE; recurring events appear as individual orphan events | Never — use `singleEvents: false` for the FamilySync data model |
| Skipping the `migration_lock` sentinel table for auto-migrate | One fewer migration table | Concurrent startup races cause `Duplicate column name` crash loop | Never for a production deployment; acceptable in single-instance local dev |
| Dark mode applied via React state (after hydration) | Simpler code | Flash-of-wrong-theme on every cold load | Never — use inline `<script>` in `index.html` to set the theme synchronously |
| Deriving OAuth redirect URI from `req.headers.host` | No env var to configure | Returns internal hostname through Pangolin tunnel; causes `redirect_uri_mismatch` | Never — read from `EXTERNAL_BASE_URL` env var |
| Bumping ESLint to v10 with other dependencies | Fewer outdated warnings | `eslint-plugin-react` is disabled silently; lint gate appears green but catches nothing | Never until eslint-plugin-react publishes v10 support |
| Not handling Google `410 Gone` (expired syncToken) | Simpler sync code | All incremental sync halts permanently after 7 days of inactivity | Never — 410 is a documented, expected Google API response |
---
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
| ------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| ical.js VALARM | Using `addPropertyWithValue('trigger', '-PT15M')` (string) | Build `ICAL.Duration.fromSeconds(-N*60)` and use the Duration object as the property value |
| ical.js VALARM | Not round-tripping preserved VALARMs through ical.js parse→serialize | Parse the sub-component from rawVevent and re-add via ical.js API; do not insert raw text |
| outboxWorker VALARM | Rebuilding VEVENT from scratch drops native-client VALARMs on update | Extend buildVeventString to accept a `valarms` parameter; populate from rawVevent extract on update path |
| reminderScheduler dedup | uid-only Map key breaks when per-event leads vary | Key on `uid + ':' + dtstartMs`; prune by dtstartMs |
| event-driven drain | Calling `runOutboxDrain()` from pub/sub subscriber before `isDraining` is set | Use a single drain path via `drainRequested` flag checked in the setInterval callback |
| Gitea Actions | Using GitHub Actions-specific action IDs | Probe the runner first; use Gitea-compatible alternatives or install tools explicitly |
| Gitea Actions MariaDB | Relying on container health == connection ready | Add explicit `mysqladmin ping` retry loop after healthcheck passes |
| Playwright mobile harness | Static storage-state.json with expiring session cookie | Use `DEV_AUTH_BYPASS=true` for automated harness; programmatic OIDC login for real-auth tests |
| Playwright + Vite PWA | Service worker from previous run intercepting requests | Set `serviceWorkers: 'block'` or unregister SWs explicitly in test context setup |
| Setup wizard | Accepting `APP_PASSWORD_ENCRYPTION_KEY` via the POST body | Validate the env is present by performing a test operation; never accept the key value over the network |
| Admin settings route | Zod error passthrough leaking app-password input | Custom `hook` in zod-validator: return generic 400, never the Zod error object |
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Google Calendar API | `singleEvents: true` in events.list | Use `singleEvents: false`; fetch modified instances separately |
| Google Calendar API | Not handling `status: "cancelled"` instances | Map cancelled instances to EXDATE on the master RRULE |
| Google Calendar API | Ignoring `410 Gone` on syncToken expiry | Catch 410, clear token, enqueue full re-sync |
| Google Calendar API | Using `https://www.googleapis.com/auth/calendar` scope | Use `calendar.readonly` + `calendar.events` (non-restricted, no Google verification required) |
| Google OAuth | Constructing redirect URI from `req.protocol`/`req.hostname` | Read from `EXTERNAL_BASE_URL` env var; Pangolin terminates TLS so internal protocol is HTTP |
| Google OAuth | Sharing state cookie name with `@hono/oidc-auth` Authelia flow | Use `google.oauth.state` cookie; mount Google callback outside the Authelia middleware scope |
| Google refresh token | Storing access tokens in DB | Cache access tokens in memory with expiry; persist only refresh token (encrypted) |
| Drizzle auto-migrate | Calling `drizzle-kit push` programmatically at startup | Use `drizzle-orm/migrator` `migrate()` API — applies committed SQL files, never introspects live DB |
| Drizzle auto-migrate | No concurrent-startup protection | Implement `migration_lock` sentinel row with `INSERT ... ON DUPLICATE KEY IGNORE` |
| member_credentials schema | `UNIQUE(user_id)` blocks multi-provider | Migrate to `UNIQUE(user_id, provider_type)` before writing Google credential code |
| Schedule-X dark mode | Not overriding `--sx-color-*` variables in dark token block | Enumerate all `--sx-*` variables from `@schedule-x/theme-default` CSS and map them |
| PWA dark mode | Setting `data-theme` in React (after hydration) | Inline `<script>` in `index.html` before CSS to set `data-theme` synchronously on load |
| Vite PWA service worker | `tokens.css` in `public/` without hash | Move theme CSS into hashed build output; verify Workbox precache manifest includes it |
---
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Google `events.list` with no `timeMin`/`timeMax` | Fetches all events in history; response > 5MB; rate limit hit | Always pass a bounded time window (e.g., 6 months past, 1 year future) | First sync for a calendar with >1000 events |
| Google instances fetch per recurring master event | N+1 API calls for N recurring events | Batch: fetch all instances in the time window in one call; merge by master event ID | Calendars with 10+ recurring series |
| MariaDB auto-migrate running complex migrations on startup | Long startup delays; health check fails before migration completes | Log migration progress; set Docker healthcheck `start_period` long enough | Migrations with `ALTER TABLE` on a table with >50K rows |
| Multiple VALARMs in scheduler SQL query without index | Full `calendar_events` scan on every scheduler tick | Index on `reminder_lead_minutes` column (or `event_reminders.lead_minutes` if junction table) | Tables with >10K events |
---
## Security Mistakes
| Mistake | Risk | Prevention |
| ----------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Admin route not protected inside adminRouter (only in parent app) | Any authenticated member can call admin endpoints | Apply guard middleware inside the sub-router, not only in the parent app mount |
| Setup endpoint lacks "already-set-up" guard | Post-setup endpoint rewrites credentials without auth | Check `member_credentials` existence + VAPID env on every setup route invocation; return 423 if already configured |
| VAPID_PRIVATE_KEY stored in DB | Private signing key accessible to DB-level access | VAPID private key in env/secrets only; DB stores public key and subject only |
| App-password in Zod error response | Plaintext credential in HTTP response and server logs | Custom Zod hook for all routes that accept credential input |
| `docker login -p` in CI YAML | Registry token in CI logs | `--password-stdin` only; token as Gitea secret, not YAML env var |
| Mistake | Risk | Prevention |
|---------|------|------------|
| Google refresh token stored unencrypted | Stolen DB backup exposes ability to read all of a member's Google Calendar indefinitely | Encrypt with `APP_PASSWORD_ENCRYPTION_KEY` (AES-256-GCM) same as Fastmail app passwords |
| Google OAuth state param not validated on callback | CSRF: attacker can link their Google account to the victim's FamilySync session | Validate `state` against the `google.oauth.state` cookie; reject mismatches with 400 |
| Google callback endpoint accessible without session check on initiation | Any unauthenticated caller can initiate a Google OAuth flow for any user | Require authenticated FamilySync session to initiate the flow (`/api/providers/google/connect`); the callback itself needs no session but must validate the state cookie |
| Google scopes stored but not verified on callback | User may grant narrower scopes than requested; write-back fails silently | Check `scope` in the token response; if write scope is absent, mark the provider as read-only and surface in UI |
| `EXTERNAL_BASE_URL` exposed in a public API response | Discloses internal routing; low severity but unnecessary | Keep env vars server-side only; never return infrastructure config in API responses |
| Migration lock table not cleaned up on crash | Next startup finds a stale lock and never migrates | Set a `locked_at` timestamp in the lock row; auto-release locks older than 60 seconds |
---
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Dark mode toggle has no system-follow option | Wife on iPhone expects dark mode to follow iOS system preference automatically | Implement three options: Light / Dark / System (default); System reads `prefers-color-scheme` media query and updates on system change |
| Google "Connect" button initiates OAuth without explaining what access is granted | Non-technical wife confused by Google consent screen listing calendar permissions | Show a one-sentence explanation before the OAuth redirect: "FamilySync will read and update your Google Calendar events." |
| Google auth failure surfaces a raw error message | `invalid_grant` or `redirect_uri_mismatch` visible to end user | Map known Google OAuth error codes to friendly messages; provide a "Try again" button that re-initiates the flow |
| Multiple reminders UI with no order or visual grouping | Hard to tell which reminder fires when | Show reminders sorted by lead time (earliest first); each entry shows the computed fire time ("4 days before, at 9:00 AM") |
| Dark mode flash on cold PWA load | Wife on iPhone sees white flash on dark mode | Inline script in `index.html` sets theme before first paint; no React wait required |
| Zero-setup wizard asks for Google OAuth credentials before explaining the App Flow | Non-technical user confused by "Client ID" and "Client Secret" terms | Name the fields plainly; link to the FamilySync deployment guide step that shows where to find them in the Google Cloud Console |
---
## "Looks Done But Isn't" Checklist
- [ ] **VALARM authoring:** Often ships create-only — verify that editing an event with a native-client alarm in rawVevent does not drop that alarm from the PUT payload.
- [ ] **VALARM serialization:** Often emits `VALUE=TEXT` — verify the ICS output has `TRIGGER:-PT15M` (DURATION type, no VALUE parameter) or `TRIGGER;VALUE=DURATION:-PT15M` — never `VALUE=TEXT`.
- [ ] **All-day reminders:** Often enabled in the UI for all-day events — verify the reminder selector is disabled or hidden when `allDay: true`.
- [ ] **Variable-lead dedup:** Often keeps the uid-only key — verify the dedup map key is updated to include dtstart so a rescheduled event fires again.
- [ ] **Event-driven drain:** Often calls `runOutboxDrain()` directly — verify the trigger path sets `drainRequested` or calls through the same error-caught wrapper as the setInterval path.
- [ ] **Setup wizard "already-set-up" guard:** Often untested — verify a second POST to any setup endpoint after initial setup returns 423, not 200.
- [ ] **Admin route 403:** Often not tested — verify a non-admin authenticated user gets 403 from admin routes, not 200 or 404.
- [ ] **VAPID key in DB:** Often slips in as "config" — verify the DB schema has no column for `vapid_private_key` or `app_password_encryption_key`.
- [ ] **Gitea CI MariaDB readiness:** Often assumed — verify CI logs show the mysqladmin ping retry loop completing, not the job proceeding immediately after service declared healthy.
- [ ] **Playwright storage state expiry:** Often passes on day one — verify tests still pass 25 hours after the storage state was captured (session cookie expired).
- [ ] **Google OAuth flow:** Refresh token stored — but 410 syncToken expiry handler missing. Verify with a forced syncToken reset (delete the stored token, check that the next poll triggers a full re-sync without crashing).
- [ ] **Google recurrence:** Master event fetched with `singleEvents: false` — but modified/canceled instances not fetched. Verify by canceling one occurrence in Google Calendar and confirming it does not appear in FamilySync.
- [ ] **Provider abstraction:** Interface defined — but the Fastmail outbox worker's `isDraining` guard and `drainRequested` flag behavior is unchanged. Verify with the existing outbox integration tests against the new interface.
- [ ] **Multiple reminders:** Multiple VALARM objects in `rawVevent` — but scheduler dedup key still `uid:dtstartMs` (not `uid:dtstartMs:leadMinutes`). Verify by creating an event with two reminders and confirming two separate pushes fire at the correct times.
- [ ] **Auto-migrate on boot:** Migrations run at startup — but no concurrent-startup protection. Verify by starting two container instances simultaneously and confirming exactly one migration run, no crash loop.
- [ ] **Dark mode:** `data-theme` applied — but Schedule-X `--sx-color-*` variables not overridden. Verify by switching to dark mode and checking the calendar view specifically (not just the nav/lists).
- [ ] **Google OAuth through Pangolin:** OAuth flow works on localhost — but redirect URI uses internal hostname. Verify by completing the Google OAuth flow over the Pangolin public URL (`EXTERNAL_BASE_URL`), not via `localhost:3000`.
- [ ] **Dependency updates:** ESLint updated — but `eslint-plugin-react` compatibility not verified. Verify by introducing a deliberate React lint violation and confirming the CI lint gate catches it after the update.
- [ ] **Google token expiry:** Access tokens cached in memory — but expiry check missing. Verify that a request made >1 hour after the last token fetch triggers a silent refresh, not a 401 from Google.
---
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
| ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| VALARM strips native alarms on edit | MEDIUM | Add valarms preserve path to buildVeventString + outboxWorker update branch; no migration needed; existing rawVevent data is authoritative |
| TRIGGER VALUE=TEXT bug | LOW | Fix Duration construction in buildVeventString; no data migration (rawVevent already has correct alarms from server) |
| uid-only dedup causing duplicate push | LOW | Change Map key to uid:dtstartMs; restart clears in-memory state; no DB change |
| Double-drain from concurrent triggers | MEDIUM | Refactor event-driven trigger to drainRequested flag; requires load testing to confirm no more duplicate PUTs |
| Admin route bypassed (no inner guard) | LOW | Add `.use('*', adminGuard)` inside adminRouter; deploy |
| VAPID private key in DB | HIGH | Rotate VAPID keypair; clear all push subscriptions (all devices must re-subscribe); remove DB column via migration |
| CI flaky MariaDB race | LOW | Add readiness wait loop to workflow YAML; re-run |
| Playwright storage state stale | LOW | Switch to DEV_AUTH_BYPASS mode for automated tests; remove static state file |
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Google refresh token 7-day expiry in Testing status | LOW | Promote app to Production status in Google Cloud Console; member re-authorizes once |
| Drizzle auto-migrate `Duplicate column name` crash | MEDIUM | Manually mark the migration as applied in `__drizzle_migrations`; restart container |
| Concurrent migration lock stuck (crash before lock release) | LOW | `DELETE FROM migration_lock WHERE id=1` in the DB; restart container |
| Google syncToken 410 — incremental sync stopped | LOW | Delete stored syncToken from `member_credentials`; next poll performs full re-sync |
| `member_credentials` unique constraint violation (Google overwrites Fastmail) | HIGH | Restore from DB backup; apply the `UNIQUE(user_id, provider_type)` migration immediately |
| OAuth redirect URI mismatch (Pangolin hostname not registered) | LOW | Add the correct HTTPS URL to Google Cloud Console Authorized Redirect URIs; no code change |
| Dark mode flash in production (inline script missing) | LOW | Add inline `<script>` to `index.html`; rebuild and deploy; service worker cache busted by new hash |
| ESLint 10 upgrade breaks plugin — lint gate silently passes | MEDIUM | Roll back ESLint to 9.x in root `package.json`; re-run CI to confirm lint gate fails again on known violations |
---
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
| ------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| VALARM strips native alarms on edit | Per-event reminders (VALARM authoring) | Integration test: create event via native client with alarm, edit via FamilySync, verify PUT payload contains original VALARM |
| TRIGGER VALUE=TEXT serialization | Per-event reminders (VALARM authoring) | Unit test: serialize VALARM, parse back, assert no VALUE=TEXT |
| All-day event VALARM silently no-ops | Per-event reminders (VALARM authoring) | UI test: all-day event form has no reminder field or field is disabled |
| Variable-lead dedup produces duplicate push | Per-event reminders (scheduler generalization) | Unit test: fire reminder, reschedule event earlier, fire again — assert two pushes sent |
| Double-drain from concurrent event-driven trigger | Event-driven outbox drain | Load test: enqueue 10 rows rapidly, assert each CalDAV PUT issued exactly once |
| Event-driven drain breaks create-before-delete | Event-driven outbox drain | Integration test: edit-as-move under rapid enqueue; original event not deleted before new one created |
| Admin app-password echoed in error | Admin Settings | Unit test: POST invalid password to settings route; assert response has no credential value |
| Unauthenticated setup endpoint stays live | Setup wizard | Integration test: POST to setup endpoint after first-run completes; assert 423 |
| Admin role check missing inside sub-router | Admin Settings | Integration test: non-admin authenticated user hits admin route; assert 403 |
| VAPID key stored in DB | Setup wizard | Schema review before migration is written; CI lint check for column names containing `private_key` |
| Gitea CI MariaDB readiness race | Gitea CI | CI log audit: readiness loop appears before any `drizzle-kit migrate` invocation |
| Gitea runner missing Node 22 / pnpm | Gitea CI | First CI job: node/pnpm version probe step before any install or test |
| Docker registry token in CI logs | Gitea CI | CI log audit: no plaintext token visible; all registry credentials use --password-stdin |
| Playwright storage state stale | Mobile-browser testing | Test suite passes on day 2 without recapturing storage state (DEV_AUTH_BYPASS mode eliminates TTL) |
| Production service worker intercepts Playwright | Mobile-browser testing | Playwright context uses `serviceWorkers: 'block'`; verified in trace that no responses are SW-sourced |
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Google refresh token 7-day expiry (Testing vs Production status) | Google Calendar provider (phase start) | Document and enforce before any token storage code ships |
| Google recurrence model mismatch (singleEvents, EXDATE) | Google Calendar provider (recurrence plan) | Unit test: mock Google API response → assert reconstructed VCALENDAR passes ical.js + RecurExpansion |
| Google syncToken 410 handling | Google Calendar provider (sync/poller plan) | Integration test: simulate 410 → assert full re-sync triggered, no crash |
| Google all-day timezone / useDefault reminder | Google Calendar provider (normalization plan) | Unit test: UTC normalization of `start.dateTime` with offset; `useDefault: true` maps to null |
| Provider abstraction Fastmail regression | Provider abstraction phase (must ship first) | All existing outbox/poller/scheduler tests pass unchanged against the new interface |
| Multiple VALARM dedup key and Google 5-cap | Multiple reminders phase | Integration test: event with 2 reminders fires 2 pushes; Google write-back with 6 reminders returns validation error before write |
| Drizzle auto-migrate: journal mismatch, concurrent race, false destructive diff | Zero-setup DB migration phase | Test: run `migrate()` twice on same DB → no error; start two containers simultaneously → one applies, one waits |
| PWA dark mode FOWT and Schedule-X coverage | PWA dark mode phase | Playwright test: toggle dark mode, assert `--sx-color-*` computed values on the calendar element match dark theme |
| OAuth callback through Pangolin redirect URI | Self-service Google OAuth onboarding phase | End-to-end test over Pangolin tunnel hostname (not localhost) |
| Google token encryption and multi-provider schema | Provider abstraction phase (schema migration) | `UNIQUE(user_id, provider_type)` in place; integration test: add Fastmail + Google credentials for same user → both rows present |
| ESLint 10 breaking change in dependency updates | CI dependency update phase (last in milestone) | Update ESLint separately; verify lint gate still catches a known violation after update |
---
## Sources
- Direct inspection of `apps/api/src/broker/outboxWorker.ts`, `reminderScheduler.ts`, `vevent.ts`, `crypto.ts`, `index.ts`, `db/schema.ts`
- `.planning/RETROSPECTIVE.md` — v1.0 lessons: node-cron skip, drizzle push destructive diff, VAPID truncation, tsc vs vitest divergence
- `CLAUDE.md` memory entries: `node-cron-skips-in-long-running-process.md`, `drizzle-mariadb-push-unsafe.md`, `authelia-idtoken-claims.md`
- RFC 5545 §3.8.6.3 — VALARM TRIGGER value types (DURATION vs DATE-TIME)
- RFC 5545 §3.3.10 — RRULE value type semantics
- ical.js source (`lib/ical/property.js`)property value type inference for TRIGGER
- Gitea Actions documentation — services container healthcheck semantics, secret masking behavior
- Playwright docs — `browserContext.serviceWorkers`, `storageState`, context lifecycle
- Project source: `apps/api/src/broker/reminderScheduler.ts`, `vevent.ts`, `outboxWorker.ts`, `client.ts` (inspected directly)
- Migration history: `apps/api/src/db/migrations/meta/_journal.json`, migration SQL files (inspected directly)
- `.planning/RETROSPECTIVE.md` — v1.0 and v1.1 lessons (journal-hash mismatch, false destructive diff, node-cron silent skip, runner-probe-first)
- `.planning/quick/260610-cr8-adopt-drizzle-generate-migrate-workflow-/260610-cr8-PLAN.md` — Drizzle MariaDB push foot-gun (direct source)
- `.planning/todos/completed/adopt-drizzle-migrations-workflow.md` — false destructive diff root cause (direct source)
- `.planning/milestones/v1.1-phases/10-admin-role-settings/10-01-PLAN.md`migration workflow and `UNIQUE(user_id)` constraint history (direct source)
- `.planning/milestones/v1.1-phases/11-per-event-reminders/11-RESEARCH.md` — VALARM classification, scheduler dedup key design (direct source)
- `CLAUDE.md` — ESLint 9.39.4 pin rationale, stack constraints, Schedule-X version, CI runner constraints (direct source)
- `PROJECT.md` — Key decisions D-10 through D-20, constraint table, v1.2 feature list (direct source)
- Google Calendar API documentation knowledge (refresh token expiry policy, `singleEvents` parameter, `410 Gone` syncToken expiry, `reminders.useDefault`, scope tiers) — HIGH confidence from repeated confirmed behavior
- OAuth2 PKCE / state cookie mechanics — HIGH confidence from shipped Authelia OIDC implementation in this codebase
- Drizzle ORM programmatic migrator API (`drizzle-orm/migrator`) — MEDIUM confidence (verify against drizzle-orm release notes for the installed version before shipping auto-migrate)
---
_Pitfalls research for: FamilySync v1.1 Operability & Polish_
_Researched: 2026-06-10_
*Pitfalls research for: FamilySync v1.2 Multi-Provider, Theming & Zero-Setup*
*Researched: 2026-06-19*
+369 -3
View File
@@ -1,11 +1,350 @@
# Stack Research
**Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail
**Researched:** 2026-06-03 (v1.0) / 2026-06-10 (v1.1 additions)
**Researched:** 2026-06-03 (v1.0) / 2026-06-10 (v1.1 additions) / 2026-06-19 (v1.2 additions)
**Confidence:** MEDIUM-HIGH (calendar sharing cross-account caveat: LOW; rest HIGH)
---
## v1.2 Stack Additions — Multi-Provider, Theming & Zero-Setup
> Covers ONLY net-new libraries and patterns for v1.2. The existing stack (Hono, Drizzle,
> mysql2, tsdav, ical.js, web-push, @hono/oidc-auth, TanStack Query, Zustand, vite-plugin-pwa,
> @playwright/test) is shipped and proven — do not re-evaluate it.
### Net-New npm Packages (two only)
| Package | Version | Scope | Purpose |
|---------|---------|-------|---------|
| `google-auth-library` | 10.7.0 | `apps/api` | OAuth2 authorization-code flow + offline refresh token management |
| `@googleapis/calendar` | 15.0.0 | `apps/api` | Google Calendar API v3 typed REST client |
Everything else (dark mode, multiple VALARMs, programmatic migrate, CI updates) uses existing dependencies.
---
### 1. Google Calendar Integration
#### Library decision: `google-auth-library` + `@googleapis/calendar`
Do NOT use the monolithic `googleapis` package. It bundles 170+ API clients (~50 MB in the
Docker image) for Calendar-only use. The scoped split gives the same typed API surface at a
fraction of the footprint.
| Alternative | Rejection reason |
|-------------|-----------------|
| `googleapis` (monolithic) | 170+ bundled clients; bloats Docker image layer with unused code |
| Raw `fetch` + REST | Must hand-roll token refresh, retry logic, typed request/response schemas |
| `@microfox/google-calendar` | Third-party wrapper, last publish 9 months ago, adds indirection over official packages |
`@googleapis/calendar@15.0.0` depends only on `googleapis-common@^8.0.0` (auto-installed as a
transitive dep). `google-auth-library@10.7.0` ships its own TypeScript types — no `@types/` package
needed.
#### OAuth2 Authorization-Code Flow (backend-only)
The flow is fully backend-driven, matching the existing `@hono/oidc-auth` pattern for Authelia:
1. Backend generates the Google consent URL:
```typescript
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar.events'],
state: signedStateJwt, // CSRF protection, same pattern as OIDC link flow
});
```
2. User clicks "Connect Google" → redirected to Google consent screen.
3. Google redirects back to `/api/providers/google/callback`.
4. Backend exchanges the code:
```typescript
const { tokens } = await oauth2Client.getToken(code);
// tokens.refresh_token is ONLY present on first authorization with access_type:'offline'
// Subsequent exchanges return only access_token. Persist refresh_token immediately.
```
5. Store the token object `{ refresh_token, access_token, expiry_date }` encrypted
(AES-256-GCM, same crypto as Fastmail app passwords) in `member_credentials`
with `provider_type = 'google'`.
6. On each API call, hydrate the client:
```typescript
oauth2Client.setCredentials({ refresh_token: storedToken });
// google-auth-library auto-refreshes when access_token is expired
oauth2Client.on('tokens', (tokens) => {
// Persist newly issued access_token + expiry_date back to DB
// to avoid unnecessary refresh calls on next request
});
```
**Required scopes:**
- `https://www.googleapis.com/auth/calendar.events` — create/edit/delete events on any calendar
- `https://www.googleapis.com/auth/calendar.readonly` — read-only if write is not needed per calendar
#### Schema change for token storage
The existing `member_credentials` table has `fastmail_email` and `encrypted_password` columns.
For Google, `encrypted_password` stores the JSON token blob and `fastmail_email` stores the
Google account email. In v1.2, add a `provider_account_id VARCHAR(256)` column (additive migration)
that stores the account identifier in a provider-neutral name — avoids abusing `fastmail_email`
for a non-Fastmail email.
#### Google Calendar API event model vs. iCalendar
**Recurring events**: Google's `recurrence` field is an array of RFC 5545 RRULE strings — the same
format ical.js already handles for Fastmail:
```json
{ "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"] }
```
However, Google uses RFC 3339 with explicit `timeZone` for timed events (not UTC-Z like the
existing CalDAV write path), and `date` fields (`"YYYY-MM-DD"`) for all-day events.
| Operation | Google API call |
|-----------|----------------|
| List instances (expanded) | `events.list({ singleEvents: true })` |
| List parent recurring events | `events.list({ singleEvents: false })` (default) |
| Edit one occurrence | GET instance (has `recurringEventId`), then PATCH |
| Delete one occurrence | `events.delete({ eventId: instanceId })` — only that instance |
| Delete whole series | `events.delete({ eventId: recurringEventId })` |
| Edit this + following | Set UNTIL on RRULE of original → insert new series from that point |
**Reminders**: Google uses a flat `reminders.overrides` array — structurally simpler than VALARM:
```json
{
"reminders": {
"useDefault": false,
"overrides": [
{ "method": "popup", "minutes": 15 },
{ "method": "popup", "minutes": 60 }
]
}
}
```
| Dimension | Google Calendar | iCalendar VALARM |
|-----------|----------------|-----------------|
| Trigger type | Relative minutes only | Relative DURATION or absolute DATE-TIME |
| All-day trigger | Minutes before midnight of event start | Absolute UTC instant (9 AM local in app) |
| Multiple reminders | Yes — array of overrides | Yes — multiple VALARM subcomponents |
| Methods | `popup` + `email` | `DISPLAY`, `AUDIO`, `EMAIL` |
Mapping strategy: read `overrides[*].minutes` where `method='popup'``reminderLeadMinutes[]`
array; ignore `email` method (app handles push, not email). On write: map `reminderLeadMinutes[]`
`overrides` array with `method: 'popup'`, set `useDefault: false`.
All-day event timing: Google fires at midnight minus lead minutes. The app's "9 AM local" semantic
from Fastmail cannot be replicated — document this as a provider difference; accept Google's
midnight-relative behavior for Google events.
#### Installation
```bash
pnpm add --filter @familysync/api google-auth-library @googleapis/calendar
```
---
### 2. Provider Abstraction — Hand-Rolled TypeScript Interface
No cross-provider calendar normalization library exists worth taking as a dependency. The two
providers have well-understood shapes; a hand-rolled interface in `apps/api/src/broker/` is the
right call — stays under project control, zero external dep, typed exactly to app needs.
```typescript
// apps/api/src/broker/providerTypes.ts
export interface NormalizedEvent {
uid: string; // stable cross-provider event ID
calendarId: string; // provider-internal calendar identifier
summary: string;
allDay: boolean;
dtstart: Date | string; // Date for timed, 'YYYY-MM-DD' for all-day
dtend: Date | string;
location?: string;
description?: string;
rruleString?: string; // bare RRULE value if recurring master
reminderLeadMinutes?: number | null; // legacy single (backward compat)
reminderLeadMinutesMultiple?: number[]; // v1.2 multiple reminders
rawPayload?: string; // CalDAV: raw iCalendar string; Google: JSON string
}
export interface ProviderCalendar {
id: string;
displayName: string;
color?: string;
isShared: boolean;
}
export interface CalendarProvider {
readonly providerType: 'caldav' | 'google';
discoverCalendars(): Promise<ProviderCalendar[]>;
syncEvents(calendarId: string, since?: Date): Promise<NormalizedEvent[]>;
createEvent(calendarId: string, event: Omit<NormalizedEvent, 'uid' | 'calendarId'>): Promise<string>;
updateEvent(calendarId: string, event: NormalizedEvent): Promise<void>;
deleteEvent(calendarId: string, uid: string): Promise<void>;
}
```
**Fastmail adapter**: wraps the existing `broker/sync.ts`, `broker/write.ts`, `broker/poller.ts`
behind this interface. Refactor, not rewrite.
**Google adapter**: new `broker/googleCalendarProvider.ts` implementing `CalendarProvider` using
`@googleapis/calendar` + `google-auth-library`. Fetches credentials from `member_credentials`
where `provider_type = 'google'`, re-hydrates `OAuth2Client` per call.
---
### 3. Multiple Reminders Per Event — No New Dependency
`ical.js` already supports multiple VALARM subcomponents via repeated `vevent.addSubcomponent(alarm)`
calls. The existing `buildVeventString` already processes a `valarms` array (preserve-on-edit path).
The v1.2 change is purely a data-model and serialization update:
1. **Schema**: Add `reminder_lead_minutes_json TEXT` column to `calendar_events` (nullable JSON
array e.g. `[15, 60]`). Keep `reminder_lead_minutes INT` for backward compat; treat single-value
as `[value]`.
2. **vevent.ts**: Change `buildVeventString` to accept `reminderLeadMinutes: number[]`; loop
`buildTimedValarm(lead)` for each, call `vevent.addSubcomponent()` per alarm.
3. **classifyValarms**: The `length > 1` branch currently returns `{ kind: 'custom' }`. v1.2
should return `{ kind: 'multi-preset', leads: number[] }` when all alarms are relative DURATION
triggers with preset lead values.
4. **Google adapter**: Map `reminders.overrides` bidirectionally — multiple `{ method: 'popup', minutes: N }` entries.
No new npm dependency.
---
### 4. PWA Dark Mode / Theming — Pure CSS + Existing Zustand
No theming library needed. The token layer is already structured for this:
- `tokens.css` has `[data-theme='light']` with all semantic tokens defined.
- Schedule-X `--sx-color-*` vars are already mapped to project tokens in `tokens.css` — so
adding a `[data-theme="dark"]` block that overrides `--color-surface`, `--color-text-primary`,
etc. automatically cascades into Schedule-X with no Schedule-X config change.
- The dark stub comment `[data-theme="dark"] { ... }` is already in `tokens.css` (Phase 17
groundwork). Fill it in.
**Implementation — no new package:**
```typescript
// apps/pwa/src/store/themeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // built-in, already in Zustand 5.x
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeStore {
mode: ThemeMode;
setMode: (m: ThemeMode) => void;
}
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({ mode: 'system', setMode: (mode) => set({ mode }) }),
{ name: 'familysync-theme' },
),
);
```
**DOM application** (called on mount and on mode change):
```typescript
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.dataset.theme = resolveTheme(store.mode);
```
**System preference listener:**
```typescript
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (useThemeStore.getState().mode === 'system') {
document.documentElement.dataset.theme = resolveTheme('system');
}
});
```
**Flash prevention**: Add an inline `<script>` in `index.html` (before the React bundle) that
reads `localStorage['familysync-theme']` and sets `document.documentElement.dataset.theme`
synchronously. This is the standard flash-of-wrong-theme prevention pattern; no library needed.
Zustand `persist` middleware is already built into Zustand 5.0.14. No additional package.
---
### 5. Zero-Manual-Setup DB Bootstrap — Programmatic `drizzle-orm/mysql2/migrator`
`drizzle-orm/mysql2/migrator` is already part of `drizzle-orm@0.45.2`. No new package.
The migrate API requires a **single connection** (not the runtime pool):
```typescript
// apps/api/src/db/migrate.ts
import mysql from 'mysql2/promise';
import { drizzle } from 'drizzle-orm/mysql2';
import { migrate } from 'drizzle-orm/mysql2/migrator';
export async function runMigrations(): Promise<void> {
// migrate() must use a single connection, not the runtime pool
const connection = await mysql.createConnection({
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
multipleStatements: true, // required: migration files contain multiple DDL statements
});
const db = drizzle(connection);
try {
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('[db] migrations applied');
} finally {
await connection.end();
}
}
```
Call `await runMigrations()` in `apps/api/src/index.ts` **before** `serve()` and before
starting the broker poller. The existing `db` pool (from `db/client.ts`) is separate and
unchanged for all runtime queries.
**Idempotency**: Drizzle tracks applied migrations in a `__drizzle_migrations` table it
creates automatically. Running `migrate()` on a container restart is a no-op for already-applied
files. Safe to call unconditionally at every boot.
**MariaDB 11**: `multipleStatements: true` is required because drizzle-kit generates migration
files with multiple DDL statements separated by semicolons. MariaDB 11 is wire-compatible with
MySQL and this flag works identically.
**BANNED**: `db:push` (`drizzle-kit push`) remains banned on MariaDB 11. It schedules
destructive schema diffs. Only `drizzle-kit generate` (dev) + `migrate()` at runtime (prod).
---
### 6. CI Dependency Updates — Existing `pnpm` Tooling Only
No new tooling package. The workflow:
1. `pnpm outdated --recursive` — table of current / wanted / latest across all workspaces.
2. `pnpm update --interactive --latest -r` — selective upgrade; review each before accepting.
3. `pnpm audit --fix=update` (pnpm v11+) — bump packages to fix security findings rather than
adding overrides.
4. Run full local CI gates (`pnpm run lint`, `pnpm run typecheck`, `pnpm test`) before committing.
| Category | Action |
|----------|--------|
| Patch / minor | `pnpm update -r` within semver range |
| Major with API changes | Evaluate per-package; check changelog |
| HIGH/CRITICAL security | Prioritize; `--fix=update` where possible |
| `@playwright/test` | Pin to the Playwright binary installed in CI runner; bumping requires browser reinstall |
---
## v1.1 Stack Additions — Operability & Polish
This section covers ONLY what is new for v1.1. The rest of the file (below) documents the v1.0 stack, which is unchanged.
@@ -218,6 +557,9 @@ npm install drizzle-orm mysql2 ioredis
npm install tsdav ical.js rrule
npm install web-push zod openid-client
# v1.2 additions (apps/api)
npm install google-auth-library @googleapis/calendar
# Frontend
npm install react react-dom @tanstack/react-query zustand
npm install -D vite vite-plugin-pwa
@@ -376,6 +718,8 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| CalDAV | JMAP | JMAP calendars not available on Fastmail as of 2026 |
| @playwright/test | playwright-cli alone | playwright-cli lacks storageState save/restore and device presets needed for CI; both coexist |
| PAT for Gitea registry | secrets.GITEA_TOKEN / built-in token | Gitea does not inject a built-in token with container-registry push scope; PAT required |
| @googleapis/calendar | googleapis (monolithic) | Monolithic bundles 170+ clients; scoped package is Calendar-only; same typed API, fraction of size |
| Hand-rolled CalendarProvider interface | cross-provider normalization library | No maintained library exists; hand-rolled interface stays under project control |
---
@@ -394,6 +738,10 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| `mysqladmin ping` health check with MariaDB 11 | mysqladmin not shipped in mariadb:11 image; silently blocks CI | `healthcheck.sh --connect --innodb_initialized` |
| `runs-on: ubuntu-latest` on Gitea self-hosted runner | Label only resolves on GitHub's hosted infrastructure | `runs-on: self-hosted` (or the runner's registered label) |
| Any validation library for setup wizard | zod + mysql2 + web-push + Node 22 fetch cover all checks natively | Use existing stack |
| `googleapis` (monolithic npm package) | Bundles 170+ API clients; ~50 MB for Calendar-only use | `@googleapis/calendar` + `google-auth-library` |
| `next-themes` or any theming library | Adds indirection over CSS data-theme + Zustand persist (already in stack) | CSS `[data-theme="dark"]` + Zustand `persist` middleware |
| `drizzle-kit push` in production / MariaDB 11 | Schedules destructive schema diffs; banned on MariaDB 11 | `drizzle-kit generate` + `migrate()` at runtime |
| FCM/Firebase for Google Calendar push | Google Calendar push = REST polling / webhooks; unrelated to VAPID push | Use existing `web-push` for app notifications; Google Calendar webhooks are separate |
---
@@ -402,12 +750,17 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| Package | Compatible With | Notes |
| ---------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| drizzle-orm@0.45.x | mysql2@3.x | Use `drizzle-orm/mysql2` import path; mysql2@3.x uses Promises API by default |
| drizzle-orm/mysql2/migrator | drizzle-orm@0.45.x, mysql2@3.x | Single connection required (not pool); `multipleStatements: true` for MariaDB 11 |
| vite-plugin-pwa@1.3.x | Vite@8.x, Workbox@7.x | vite-plugin-pwa 0.16+ requires Node 16+; 1.x tracks Vite 6+ |
| @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x |
| ical.js@2.x | rrule@2.8.x | Use together: ical.js parses the RRULE string, pass to `new RRule(RRule.parseString(...))` |
| web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively |
| @playwright/test@1.60.x | Node.js 18+ | Install Chromium only in CI (`npx playwright install --with-deps chromium`) |
| mariadb:11 service container | GitHub/Gitea Actions | Health check must use `healthcheck.sh`; `mysqladmin` removed in 11.x |
| google-auth-library@10.7.0 | Node.js 18+, TypeScript 5.x | Ships own types; no @types/ needed; OAuth2Client auto-refreshes expired access tokens |
| @googleapis/calendar@15.0.0 | googleapis-common@^8.0.0 | Auto-installed as transitive dep; do not pin googleapis-common separately |
| zustand/middleware `persist` | zustand@5.0.x | Built-in middleware; no separate import package; works with localStorage in PWA |
| Schedule-X `--sx-color-*` | tokens.css `[data-theme]` | All Schedule-X color vars already map to project tokens; dark overrides cascade automatically |
---
@@ -423,6 +776,10 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
5. **Playwright mobile tests and DEV_AUTH_BYPASS in CI:** The mobile test harness depends on `DEV_AUTH_BYPASS=true` being available in CI. This means the CI run starts the API with that flag — confirm it is only set in the test environment, never in the production image/deploy step.
6. **Google OAuth2 consent screen verification:** Google requires app verification for production OAuth apps requesting Calendar scopes. For a self-hosted household app, the project must be in "testing" mode (max 100 users) or published. For a two-person household, testing mode (unverified) is sufficient; add both Google accounts as test users in Google Cloud Console. No app review needed.
7. **Google Calendar webhook push vs. polling:** The Google Calendar API supports webhook push notifications (via `events.watch()`) that POST to a public URL when calendars change. This is more efficient than polling but requires a verified public HTTPS endpoint. The existing ctag-poller pattern (5-min interval) is simpler and sufficient for a two-person household — evaluate webhooks only if polling latency becomes a problem.
---
## Sources
@@ -436,6 +793,7 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
- [rrule npm](https://www.npmjs.com/package/rrule) — Version 2.8.1 confirmed
- [Hono](https://hono.dev/) — Version 4.12.23; Node.js adapter confirmed
- [Drizzle ORM MySQL](https://orm.drizzle.team/docs/get-started-mysql) — MariaDB via mysql2 confirmed
- [Drizzle ORM Migrations API](https://mintlify.wiki/drizzle-team/drizzle-orm/api/core/migrations) — `migrate(db, { migrationsFolder })` with mysql2, single connection required
- [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) — Version 1.3.0; Workbox 7 integration
- [web-push npm](https://www.npmjs.com/package/web-push) — Version 3.6.7
- [Meet Declarative Web Push — WebKit](https://webkit.org/blog/16535/meet-declarative-web-push/) — Safari 18.4+, iOS 18.4+ confirmed
@@ -452,8 +810,16 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
- [MariaDB 11 health check fix](https://github.com/mage-os/github-actions/issues/365) — mysqladmin removed in mariadb:11.4; healthcheck.sh required
- [MySQL in GitHub Actions (ovirium.com)](https://ovirium.com/blog/how-to-make-mysql-work-in-your-github-actions/) — Service container pattern; ports, env, options syntax (GitHub-compatible = Gitea-compatible)
- [DEV Community — Docker-in-Docker with Gitea Actions](https://dev.to/tmlr/the-definitive-guide-to-safe-docker-in-docker-with-gitea-actions-331l) — DinD vs socket-mount tradeoffs; socket-mount recommended for homelab
- [@googleapis/calendar npm](https://www.npmjs.com/package/@googleapis/calendar) — Version 15.0.0, verified 2026-06-19; depends only on googleapis-common
- [google-auth-library npm](https://www.npmjs.com/package/google-auth-library) — Version 10.7.0, verified 2026-06-19; ships own TypeScript types
- [Google Auth Library Node.js — Context7 / googleapis GitHub](https://github.com/googleapis/google-auth-library-nodejs) — OAuth2Client.generateAuthUrl, getToken, setCredentials, tokens event
- [Google Calendar API — Recurring Events](https://developers.google.com/workspace/calendar/api/guides/recurringevents) — RRULE recurrence array, instance fields, singleEvents param, series delete
- [Google Calendar API — Reminders](https://developers.google.com/workspace/calendar/concepts/reminders) — overrides array, method types, useDefault=false requirement
- [pnpm audit CLI](https://pnpm.io/cli/audit) — --fix=update (pnpm v11+) vs --fix=override default
- [pnpm outdated CLI](https://pnpm.io/cli/outdated) — --recursive workspace support
- [Schedule-X Theme Docs](https://schedule-x.dev/docs/calendar/theme) — --sx-color-* CSS variable list; no built-in dark mode
---
_Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail_
_Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish additions)_
_Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA_
_Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish) / 2026-06-19 (v1.2 Multi-Provider, Theming & Zero-Setup)_
+79 -69
View File
@@ -1,105 +1,115 @@
# Project Research Summary
**Project:** FamilySync — v1.1 "Operability & Polish"
**Domain:** Self-hosted family calendar + lists PWA (operability/admin milestone on a shipped v1.0)
**Researched:** 2026-06-10
**Confidence:** HIGH (all findings grounded in direct v1.0 source inspection + v1.0 retrospective)
**Project:** FamilySync — v1.2 "Multi-Provider, Theming & Zero-Setup"
**Domain:** Self-hosted family calendar + lists PWA (opening beyond Fastmail to a second provider, on a shipped v1.1)
**Researched:** 2026-06-19
**Confidence:** HIGH for architecture/pitfalls (direct codebase inspection + shipped v1.0/v1.1 lessons); MEDIUM for Google Calendar API specifics (official docs via Context7); LOW for third-party UX patterns (cross-checked websearch)
## Executive Summary
v1.1 adds six operability/polish features to the proven v1.0 stack (Node 22 + Hono + Drizzle/MariaDB + tsdav/ical.js + web-push, React 19 + Vite + Schedule-X PWA, on Unraid/Docker behind Authelia OIDC + Pangolin/Newt, self-hosted Gitea with an Actions runner). The core stack is unchanged. Every feature reuses existing capabilities; **only one new dependency is warranted — `@playwright/test` (dev, `apps/pwa` scope)** for the authenticated mobile test harness. No new runtime packages: the setup wizard's validations are all covered by `zod` + `mysql2` + native `fetch` + `Buffer`/`web-push`.
v1.2 opens FamilySync beyond Fastmail. The keystone is a hand-rolled `CalendarProvider` TypeScript interface: the existing tsdav/ical.js CalDAV broker is refactored behind it **as a thin delegation wrapper with zero internal rewrite**, then Google Calendar (Google Calendar API v3 + OAuth2, not CalDAV) plugs in as a second implementation, and an in-memory mock provider plugs in as a third (dev-bypass + hermetic CI). On top of that seam: multiple reminders per event (1→N VALARMs / Google `overrides`), PWA dark mode (Light/Dark/System), and zero-manual-setup DB bootstrap (programmatic `migrate()` at boot). The milestone closes by applying CI-surfaced dependency updates.
The six features: (1) **per-event reminders** — VALARM authoring on the event form + a scheduler that honors each event's lead instead of the hardcoded 15-min; (2) **event-driven outbox drain** — cut perceived write-back latency from ~15s to ~1s; (3) **admin Settings** — role-gated UI to manage encrypted app passwords and designate the shared calendar; (4) **initial setup wizard** — first-run validated bootstrap of env/VAPID/DB/app-password; (5) **Gitea CI** — regression gate on PR + Docker image publish; (6) **mobile-emulated authed Playwright harness**.
The core stack is unchanged. **Two new runtime packages only**`google-auth-library@10.7.0` and `@googleapis/calendar@15.0.0` (scoped, NOT the 50 MB `googleapis` monolith), both in `apps/api`. Dark mode, multiple reminders, migrate-on-boot, and dependency updates add **zero new dependencies** (Zustand `persist`, `ical.js` multi-VALARM, `drizzle-orm/mysql2/migrator`, and existing `pnpm` tooling already cover them).
Three preservation rules are non-negotiable and drive the design: VALARM authoring must **preserve native-client alarms on edit** (never rebuild-from-scratch and silently strip); the outbox durability guarantees (optimistic-202, create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once dedup) must be **unchanged** when the drain goes event-driven; and wizard-collected secrets (VAPID private key, `APP_PASSWORD_ENCRYPTION_KEY`) must **stay in env — never touch the DB or any response body**.
Three non-negotiable preservation rules drive the design: (1) the provider refactor must **not regress the live Fastmail path** — all v1.1 outbox/poller/scheduler integration tests must pass unchanged against the new interface; (2) the `reminder_lead_minutes``reminder_leads` JSON migration must **migrate existing data** before dropping the old column; (3) Google OAuth tokens are **per-member secrets**, AES-256-GCM encrypted, never in `app_config`, with the `member_credentials` unique constraint widened to `UNIQUE(user_id, provider_type)`.
## Key Findings
### Recommended Stack
No stack change. One new dev dependency; everything else reuses v1.0.
No stack change beyond two scoped Google packages (both `apps/api`):
**Core additions:**
- **`@playwright/test`** (dev, `apps/pwa`): the global `playwright-cli` binary is interactive tooling and exposes no `storageState`/`devices` presets — `@playwright/test` is required for CI spec files doing authenticated, device-emulated runs. The two coexist. Auth via the existing `DEV_AUTH_BYPASS` avoids mocking Authelia.
- **Gitea Actions workflows** (`.gitea/workflows/*.yml`, no npm packages): GitHub-Actions-compatible syntax but `runs-on: self-hosted`; job image `catthehacker/ubuntu:act-latest`; MariaDB service container `mariadb:11` with `healthcheck.sh --connect --innodb_initialized` (NOT `mysqladmin ping` — removed in MariaDB 11); Docker push via `docker/login-action@v3` + `docker/build-push-action@v5` needs a Gitea PAT with `write:package` scope (no built-in token has registry push rights).
- **Setup wizard validation — zero new deps:** env presence via `zod.safeParse`, DB via `mysql2` connect, VAPID via `Buffer.from(key,'base64url').length === 32`, OIDC via native `fetch('/.well-known/openid-configuration')`.
- **`google-auth-library@10.7.0`** — OAuth2 authorization-code flow + offline refresh-token management; ships its own types; `OAuth2Client` auto-refreshes expired access tokens via the `tokens` event.
- **`@googleapis/calendar@15.0.0`** — Google Calendar API v3 typed client (depends only on `googleapis-common`). **Reject the monolithic `googleapis`** (170+ clients, ~50 MB).
- **Provider abstraction** — hand-rolled TS interface in `apps/api/src/broker/`; no normalization library exists worth a dependency.
- **Dark mode** — pure CSS `[data-theme="dark"]` + Zustand `persist` (built into 5.0.14) + an inline `<script>` in `index.html`. No theming library.
- **Migrate-on-boot**`drizzle-orm/mysql2/migrator` (already transitive); single connection, `multipleStatements: true`. **`drizzle-kit push` stays banned** on MariaDB 11.
- **Dependency updates** — existing `pnpm outdated`/`audit`; selective, pin-aware, one ecosystem at a time.
### Expected Features
**Must have (table stakes):**
**Table stakes:**
- Google events in the unified color-coded view (read sync), recurrence + all-day correct; per-calendar color extends the Phase 17 routing to Google calendar IDs.
- Cross-provider event create/edit/delete with a calendar picker that defaults to the member's most-used calendar and shows recognizable names (not provider IDs/emails).
- Self-service onboarding: Fastmail = app-password form with live CalDAV validation (reuse Phase 10 `CredentialSheet`); Google = single "Connect Google Calendar" OAuth button.
- One-tap reconnect on token expiry, surfaced as a **banner in the calendar view** (not buried in settings) — non-negotiable for the non-technical Apple member.
- Up to 5 reminders per event (add/remove rows, pre-populate all on edit), serialized to VALARMs / Google `overrides`, preserving other-client alarms.
- Light/Dark/System theme toggle in member Settings (per-device localStorage), no flash-of-wrong-theme, clean across every route incl. Schedule-X.
- Migrate-on-boot before `serve()`, idempotent, with a DB-readiness wait and fatal-on-failure.
- Mock provider seeded with recurring/all-day/timed/past/future + reminder events, wired to `DEV_AUTH_BYPASS` for hermetic CI.
- Per-event reminder selector with preset offsets (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d); **"None" is the default** (no VALARM, no push). All-day events fire at 9 AM on the alert day (Apple convention). Existing VALARMs round-trip — never silently stripped.
- Admin Settings scoped to exactly two tasks: rotate/re-enter a member's app password (with inline CalDAV test) and toggle `calendars.is_shared`. Single `users.is_admin` boolean gate.
- Setup wizard: validated first-run steps (DB, app URL, OIDC, session secret auto-gen, encryption key auto-gen, VAPID auto-gen + structural check, admin account, Fastmail app password CalDAV PROPFIND test). Inline per-field validation; Next disabled until step passes.
- Faster write-back: target < 2s perceived; trigger an immediate drain on enqueue, keep the interval as fallback.
**Should have (competitive / differentiator):**
- Gitea CI PR gate + on-merge Docker publish.
- Mobile Playwright harness (`devices['iPhone 15']`, stored auth via `DEV_AUTH_BYPASS`).
- Multiple alarms per event (2× VALARM) — stretch, defer to v1.2.
**Anti-features (explicitly OUT — scope creep for a 2-member household):** notification-preferences UI, reminder snooze, wizard re-run, audit log, health dashboard, user management, provider abstraction (stays backlog 999.1), self-service member onboarding mixed into the wizard (stays backlog 999.5), real-device iOS CI.
**Anti-features (explicitly OUT):** two-way Fastmail↔Google mirroring, CalDAV-for-Google, service-account Google auth, tokens in cookies/localStorage, reminder templates/snooze, per-member server-synced theme, custom theme editor, app self-creating the database, Google per-event `colorId`, `googleapis` monolith.
### Architecture Approach
Findings grounded in the actual v1.0 codebase. Integration points (real paths):
Grounded in direct codebase inspection. Central seam: `broker/provider.ts` (`CalendarProvider` interface) + `broker/providerFactory.ts` (`createProvider(cred)` dispatching on `member_credentials.provider_type`: `caldav`/`google`/`mock`).
**Major components:**
- **CalDavProvider** wraps existing `client.ts`/`sync.ts`/`write.ts` **verbatim** — delegation, not refactor (Anti-Pattern 1).
- **poller.ts / outboxWorker.ts** become provider-agnostic: `createProvider(cred)``provider.listCalendars()`/`syncCalendar()`/`dispatchOutboxRow()`. The `isDraining` guard, `drainRequested` flag, optimistic-202, create-before-delete, and `uid:dtstartMs` dedup all stay in the worker (provider-agnostic) and must be unchanged.
- **GoogleCalendarProvider** — new; inline token refresh (check `expires_at` before each call, refresh within 5 min of expiry, retry-once on 401). Google OAuth callback mounted **pre-auth** (like Authelia `/callback`), at a distinct path with a distinct state cookie, redirect URI built from `EXTERNAL_BASE_URL` (never `req.host`).
- **Token storage** — research surfaces two options: a separate `provider_tokens` table (ARCHITECTURE.md) vs. extending `member_credentials` with `UNIQUE(user_id, provider_type)` (STACK/PITFALLS). Either way the unique constraint must widen to composite; `client_id`/`client_secret`/refresh tokens are env/encrypted, never in `app_config`. **Decide in the provider-abstraction phase.**
- **Multiple reminders**`calendar_events.reminder_lead_minutes INT``reminder_leads JSON`, with a `JSON_ARRAY(...)` data-migration step before drop; `buildVeventString` loops N VALARMs; scheduler dedup key → `uid:dtstartMs:lead`; Google cap = 5 (reject 6+ before write).
- **Dark mode**`themeStore.ts` (Zustand persist) + inline FOUC script in `index.html` + `[data-theme="dark"]` block mapping **all** `--sx-color-*` Schedule-X vars + dynamic `theme-color` meta for iOS.
- **Migrate-on-boot**`runMigrationsIfNeeded()` in the `isMainModule()` guard, after the boot guards, before broker workers + `serve()`; `migration_lock` sentinel for concurrent-startup safety.
- **Mock provider**`broker/mockProvider.ts`, in-memory, activated via `DEV_AUTH_BYPASS`/`provider_type='mock'`; resets on restart (intentionally stateless).
1. **DB migration**`users.is_admin BOOLEAN DEFAULT 0`, `calendar_events.reminder_lead_minutes INT NULL`, an `app_config`/setup-state table. Foundation; blocks the role-gated and reminder work. (generate+migrate, never `push`.)
2. **Event-driven drain** — in-process `EventEmitter` (`lib/outboxTrigger.ts`, mirroring existing `listEmitter.ts`); signal after `db.insert(calendarOutbox)` in the three write handlers in `routes/events.ts`; subscribe in `startOutboxWorker()`. The existing `isDraining` guard already covers concurrent invocations. **Redis pub/sub is wrong here** — the drain is single-process by design.
3. **VALARM write path**`buildVeventString` in `broker/vevent.ts` gains a `reminderMinutes?` param using `ICAL.Component('valarm')` + `ICAL.Duration.fromSeconds` (same ical.js surface as RRULE). `eventFieldsSchema` (routes/events.ts) and `outboxPayloadSchema` (outboxWorker.ts) must change in sync (flagged by the IN-03 comment).
4. **Variable-lead scheduler**`reminder_lead_minutes` populated by `sync.ts` parsing the VALARM TRIGGER; scheduler query uses `DATE_SUB(dtstart_utc, INTERVAL reminder_lead_minutes MINUTE)` over a ±1-min window; dedup key becomes compound `uid:dtstartMs`. Drop the `isShared`-only restriction for reminder pushes (a user who set an alarm wants it regardless of calendar).
5. **Admin role + setup wizard**`routes/admin.ts` with `requireAdmin` middleware reusing `broker/crypto.ts`; setup wizard and admin Settings are two frontend consumers of the same `/api/admin/*` + `/api/setup/*` routes (do not duplicate). `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`). VAPID/AES keys stay in env — wizard generates + displays for the operator to copy.
### Recommended Phase Structure (dependency-ordered, continues from v1.1 Phase 20 → starts at Phase 21)
### Recommended Phase Structure (dependency-ordered)
ARCHITECTURE.md's authoritative ordering:
Starting at **Phase 7** (continues v1.0 numbering). Critical path with two fully independent parallel tracks:
1. **Phase 21 — Zero-Setup DB Bootstrap.** `runMigrationsIfNeeded()` + `migration_lock` sentinel. No deps; unlocks every later phase to assume auto-migrate. Risk: low.
2. **Phase 22 — Provider Abstraction (CalDAV only, Fastmail unchanged).** Interface + factory + `CalDavProvider` wrapper; poller/outbox use `createProvider`. Dep: 21. **Risk: HIGH** — must not regress Fastmail; golden-path integration tests pass unchanged. Includes the `UNIQUE(user_id, provider_type)` migration.
3. **Phase 23 — Multiple Reminders.** `reminder_leads` JSON + data-migration; multi-VALARM; scheduler multi-lead + dedup-key change; form add/remove. Dep: 22. Risk: MEDIUM (breaking migration).
4. **Phase 24 — Mock Provider (dev/CI).** Seeded in-memory provider; factory `mock` case; Playwright exercises calendar CRUD. Dep: 22. Risk: low.
5. **Phase 25 — Google Calendar Provider.** Token storage + `googleProvider.ts` + OAuth authorize/callback routes + pre-auth mount. Deps: 22, 23 (reminder serialization), 21 (tokens table). Risk: MEDIUM (OAuth edge cases, recurrence/sync model).
6. **Phase 26 — Self-Service Onboarding UI.** `/api/me/providers` GET/DELETE, `ProviderConnectSheet`, reconnect banner. Dep: 25. Risk: low.
7. **Phase 27 — PWA Dark Mode.** FOUC script, dark tokens, `themeStore`, `ThemeToggle`. No deps — **parallelizable with 2226**. Risk: low.
8. **Phase 28 (or folded) — Dependency Updates (DEP-01).** Last; per-ecosystem batches with CI green after each. Risk: low but cascade-prone (ESLint 9.x pin, Schedule-X hold, Drizzle coupling).
1. **DB foundation** (migration: is_admin, reminder_lead_minutes, setup-state) — blocks the role/reminder work.
2. **Faster write-back** (event-driven drain) — small, low-risk, immediate benefit; independent after migration.
3. **Admin role + routes** — precondition for both admin Settings and setup wizard.
4. **Setup wizard** (backend + UI) — depends on the admin/role + config plumbing.
5. **Admin Settings UI** — depends on admin role; shares routes with the wizard.
6. **Per-event reminders** (VALARM authoring + variable-lead scheduler) — independent track, largest scope; keep authoring + preserve-on-edit + scheduler in one phase.
7. **Gitea CI** — fully independent; start with a runner-probe step.
8. **Mobile Playwright harness** — fully independent.
Tracks 7 and 8 have no code dependencies and can run parallel to anything.
`THEME-01` (27) and `SETUP-05` (21) are independent of the multi-provider chain. The roadmapper should confirm whether dark mode and dependency updates are standalone phases or folded.
### Critical Pitfalls (phase-mapped)
1. **VALARM round-trip strips native alarms** (reminders phase) — update path must extract + preserve existing VALARM from `rawVevent`, not rebuild from scratch.
2. **TRIGGER serialized as VALUE=TEXT** (reminders phase) — use `ICAL.Duration.fromSeconds(-n*60)`, verify ICS has no `VALUE=TEXT`.
3. **All-day reminder semantics undefined** (reminders phase) — disable selector when `allDay` in UI; guard API.
4. **Per-event lead breaks uid-only dedup** (reminders phase) — rescheduled events go invisible; fix with `uid:dtstartMs` compound key.
5. **Event-driven drain double-execution** (write-back phase) — use a `drainRequested` flag checked by the interval callback, not direct concurrent `runOutboxDrain()` calls; preserves create-before-delete + etag handling.
6. **Setup endpoint reachable post-setup** (wizard phase) — guard checked on every invocation (member_credentials AND VAPID env present → 423), not just at startup; never log/echo the app password; secrets never persisted to DB.
7. **Gitea ghost failures** (CI phase) — MariaDB readiness race (healthy ≠ accepting connections) and runner env assumptions (Node 22 + pnpm not guaranteed) — start with a probe-only workflow.
1. **Provider refactor regresses the live Fastmail path** (P22) — wrong `provider_type` branch silently stops all write-back; keep `caldav` the default, change zero existing tests.
2. **Google refresh tokens expire after 7 days in "Testing" publishing status** (P25) — use the non-restricted `calendar.readonly` + `calendar.events` scopes + Production status to avoid Google verification; add both accounts as test users; store grant timestamp.
3. **Google recurrence ≠ iCalendar RRULE** (P25) — fetch with `singleEvents: false`, fetch modified/`cancelled` instances separately, map cancellations to EXDATE, reconstruct a VCALENDAR string for the existing `expand.ts`.
4. **Google `syncToken` 410 Gone halts sync + full re-sync overwrites un-drained local writes** (P25) — catch 410 → drop token → enqueue full re-sync; drain outbox (or skip pending UIDs) before upsert.
5. **Google all-day/timezone differs** (P25) — normalize `start.dateTime` to UTC `...Z` (never a TZID without VTIMEZONE); map `reminders.useDefault:true` → null ("no FamilySync reminder").
6. **Multiple VALARM dedup + Google 5-cap** (P23/P25) — dedup key `uid:dtstartMs:lead`; preserve-vs-replace driven by a `remindersChanged` flag; reject 6+ reminders before a Google write.
7. **Drizzle auto-migrate on MariaDB 11 — three failure modes** (P21) — journal-hash mismatch, false destructive diff (only if `push` is misused — use the `migrator` API), concurrent-startup race (the `migration_lock` sentinel); log each applied migration.
8. **Dark mode FOWT + Schedule-X coverage + SW cache** (P27) — inline pre-paint script; override **all** `--sx-color-*`; keep theme CSS in hashed build output.
9. **OAuth through Pangolin — redirect_uri_mismatch + state-cookie collision** (P25/26) — distinct callback path outside the Authelia middleware scope, distinct `google.oauth.state` cookie, redirect URI from `EXTERNAL_BASE_URL`.
10. **Token storage — encryption-key reuse + `UNIQUE(user_id)` blocks multi-provider** (P22/25) — widen to `UNIQUE(user_id, provider_type)`; persist only the encrypted refresh token, cache access tokens in memory; minimum scopes.
11. **Dependency-update breaking cascade** (P28) — ESLint held at 9.x (eslint-plugin-react), Schedule-X minor-only until dark mode locked, Drizzle orm+kit bumped together; never bulk `pnpm up`/`audit --fix`.
## Research Flags (deeper investigation during planning)
- **Reminders phase:** confirm Fastmail CalDAV accepts the chosen TRIGGER value type; exact `rawVevent` round-trip on the update path.
- **Wizard phase:** OIDC discovery failure handling (retry/timeout/fallback).
- **CI phase:** probe the actual Unraid Gitea runner (Docker socket mount, Node/pnpm versions) before designing pipelines.
- **Mobile harness:** fixed user (DEV_AUTH_BYPASS user 1) vs parameterized; prod service worker must be neutralized in the base context.
- **Provider phase:** token-storage shape (`provider_tokens` table vs. extended `member_credentials`) — pick one in P22; verify the `migrator` API behavior against the installed `drizzle-orm` version before shipping auto-migrate.
- **Google phase:** confirm the exact scope tier needed for write-back without Google verification; end-to-end OAuth test over the Pangolin hostname (not localhost); iOS-Safari standalone callback is load-bearing (human gate, like Phase 3).
- **Reminders phase:** confirm the `remindersChanged`/preserve-on-edit contract end-to-end; index the reminder column for the scheduler scan.
- **Dark mode:** enumerate every `--sx-color-*` from `@schedule-x/theme-default`; verify it's in the Workbox precache (hashed).
**Well-documented — skip research:** event-driven drain (EventEmitter proven in `listEmitter.ts`), admin/wizard routes (standard Hono + zod + React forms), Playwright device/storageState APIs.
## Open Questions to Resolve in Requirements / Roadmap
## Open Questions to Resolve in Requirements
1. Admin tab visibility — both members are operators; should the non-technical member see it?
2. Drain transport — confirm single-container deployment (direct in-process signal) vs any multi-replica need (would require Redis).
3. Playwright auth — DEV_AUTH_BYPASS-only vs programmatic OIDC; fixed vs parameterized user.
1. Are **dark mode (27)** and **dependency updates (28)** standalone phases or folded into adjacent work? (Both are independent of the provider chain.)
2. Token storage: separate `provider_tokens` table vs. extended `member_credentials` — resolve in P22.
3. Google all-day "9 AM local" reminder semantics can't be replicated (Google fires midnight-minus-lead) — accept as a documented per-provider difference.
## Confidence
| Domain | Confidence | Notes |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------- |
| Stack | HIGH | v1.0 proven; one dev dep; Gitea syntax compatible (MariaDB 11 healthcheck caveat noted) |
| Features | HIGH | All have prior art; scoped to a tiny household |
| Architecture | HIGH | Grounded in real v1.0 source; component boundaries + build order sound |
| Pitfalls | HIGH | 15 pitfalls from codebase review + RFC 5545 + v1.0 retrospective, each mapped to a phase |
| Unraid CI runner | MEDIUM | Runner Docker-socket/Node/pnpm state unknown until probed |
| Domain | Confidence | Notes |
| ------ | ---------- | ----- |
| Stack | HIGH | Two scoped Google packages verified 2026-06-19; everything else reuses shipped deps |
| Features | MEDIUM | Google API via official docs; UX patterns cross-checked but LOW-confidence sources |
| Architecture | HIGH | Grounded in direct v1.1 source; component boundaries + build order sound |
| Pitfalls | HIGH | From codebase review + Google API behavior + v1.0/v1.1 migration/CI lessons, each phase-mapped |
| Google OAuth through Pangolin | MEDIUM | Redirect-URI + state-cookie mechanics sound; needs live end-to-end validation |
## Sources
See `STACK.md`, `FEATURES.md`, `ARCHITECTURE.md`, and `PITFALLS.md` in this directory for full citations (Google Calendar API docs, google-auth-library, Drizzle migrator, Schedule-X theming, pnpm audit/outdated, family-calendar UX surveys, and direct codebase inspection of `apps/api/src/broker/`).
---
_Synthesized for: FamilySync — v1.2 Multi-Provider, Theming & Zero-Setup_
_Researched: 2026-06-19_
+34 -5
View File
@@ -4,12 +4,41 @@
A self-hosted family organization hub for a two-person household. One color-coded calendar view across all family members' Fastmail calendars, plus shared collaborative lists (groceries, gift ideas) — delivered as a React PWA with no app store required.
## What It Does
## Features
- **Unified calendar** — aggregates each member's Fastmail CalDAV calendars into a single color-coded view via tsdav + ical.js
- **Shared lists** — collaborative grocery and gift-idea lists with live sync via Server-Sent Events
- **PWA** — installable on iOS (Home Screen) and Android; push notifications via VAPID
- **Single sign-on** — all auth flows through your existing Authelia OIDC deployment
### One color-coded family calendar
![FamilySync month view showing each member's events in their own color alongside shared family events](docs/screenshots/calendar.png)
Every member's personal Fastmail calendars and the shared family calendar are aggregated into a single month view. Each member gets a consistent color; the shared family calendar is always rose, so anyone can tell at a glance who has what on. Events read and write straight back to Fastmail over CalDAV (tsdav + ical.js), including recurring events, all-day events, and reminders — there is no second copy of your calendar to keep in sync.
### Shared collaborative lists
![Lists overview showing Groceries, Gift ideas, Costco run, and Hardware store with active and completed counts](docs/screenshots/lists.png)
Groceries, gift ideas, weekend errands — create as many lists as the household needs, shared with everyone or kept personal. Each card shows live active/done counts so you know what's still outstanding before you leave the house.
![A grocery list detail view with checkable items, drag handles to reorder, and a completed section](docs/screenshots/list-detail.png)
Inside a list, check items off, drag to reorder, and add new ones inline. Changes sync live to every other device over Server-Sent Events (with a polling fallback), so two people shopping together never duplicate or miss an item.
### Single sign-on for the whole household
![FamilySync sign-in screen with username and password fields plus a "Login with OIDC" option](docs/screenshots/login.png)
Sign in with a local username and password, or through your existing Authelia OIDC deployment — no per-member calendar credential juggling. The PWA installs to the Home Screen on iOS and Android and supports push notifications (reminders and calendar-change alerts) via VAPID — no app store required.
### Guided, zero-setup onboarding
![First-run setup wizard with a four-step progress bar: Welcome, Instance, Calendar, Complete](docs/screenshots/setup.png)
A first-run wizard walks the operator through configuring the instance — database, OIDC, VAPID keys, and the first Fastmail calendar connection — in about five minutes, so standing up a self-hosted instance does not require hand-editing config files.
### Household administration
![Admin settings showing the household members list with credential status and admin badges](docs/screenshots/admin.png)
Admins manage members and their Fastmail credentials, assign each member a calendar color, choose which calendar is the shared family one, and set the household timezone — all from inside the app.
## Prerequisites
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB