# Pitfalls Research — v1.2 Multi-Provider, Theming & Zero-Setup **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 (Inherited — Do Not Re-Litigate) 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 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: Google Refresh Tokens in "Testing" Publishing Status Expire After 7 Days **What goes wrong:** 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. 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:** 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:** 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:** - 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:** 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: Google Calendar Recurrence Model Does Not Map Cleanly to iCalendar RRULE **What goes wrong:** 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`. 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. 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:** 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:** 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. 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:** - 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:** 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: Google syncToken Invalidation Triggers Silent Full Re-Sync That Overwrites Local Writes **What goes wrong:** 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:** 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:** 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). 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 ~1–2s; blocking the re-sync on it is acceptable. **Warning signs:** - 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:** Google Calendar provider phase (sync/poller plan). 410 handling must be in the acceptance criteria before the Google poller ships. --- ### Pitfall 4: Google All-Day Events Use DATE, Timed Events Use RFC 3339 — Timezone Handling Differs From CalDAV **What goes wrong:** 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`. 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. 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:** 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:** 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 all-day events: map `start.date` directly to `DTSTART;VALUE=DATE:20260701`, same as the CalDAV path. 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:** - 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:** 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: Provider Abstraction Refactor Regresses the Live Fastmail Path **What goes wrong:** 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: - 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. 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:** 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:** 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:** - 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:** 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: Multiple VALARMs — Duplicate Fire and Preserve-vs-Replace Ambiguity **What goes wrong:** 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. 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. 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 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:** 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. 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:** - 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:** 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: Drizzle Auto-Migrate on Boot on MariaDB 11 — The Three Failure Modes **What goes wrong:** 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: **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:** 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:** 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. 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:** - `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:** 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: PWA Dark Mode — Flash of Wrong Theme and Service Worker Cache Staling **What goes wrong:** The standard dark mode pattern — read `prefers-color-scheme` or a `localStorage` preference and apply a `data-theme` attribute on `` — 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. 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:** 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:** Prevent FOWT: inject a minimal inline `