# Stack Research **Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail **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; syncEvents(calendarId: string, since?: Date): Promise; createEvent(calendarId: string, event: Omit): Promise; updateEvent(calendarId: string, event: NormalizedEvent): Promise; deleteEvent(calendarId: string, uid: string): Promise; } ``` **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()( 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 `