Files
familysync/.planning/milestones/v1.0-phases/02-calendar-display/02-RESEARCH.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

932 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2: Calendar Display - Research
**Researched:** 2026-06-04
**Domain:** React PWA calendar rendering · CalDAV recurrence expansion · Timezone/DST correctness · CSS token theming
**Confidence:** HIGH (locked stack verified against live codebase and official docs)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Design-token layer — color, spacing, density, typography as CSS custom properties + TypeScript token object. No hard-coded colors/spacing in components.
- **D-02:** Ship only the "clean" theme in Phase 2. Token architecture must make a future kiosk theme a token-set swap, not a refactor.
- **D-03:** Clean theme is tuned for legibility at tablet distance (information density first-class).
- **D-04:** All four views: day, week, month, agenda.
- **D-05:** Device-adaptive default: phone → Agenda; tablet/desktop → Month. Persist last-used view per device.
- **D-06:** Per-member color fill from `users.color` (6-color palette). Shared-family calendar uses `#F25C7A`.
- **D-07:** Color legend always visible. Per-member show/hide filter deferred.
- **D-08:** Informational + tap-to-expand. Month = colored bars with title. EventDetailPopover is read-only in Phase 2 but must be reusable as Phase 3 edit surface.
- **D-09:** Recurring events expanded server-side. Client renders concrete occurrences.
- **D-10:** Single local timezone for v1. All-day events render with no date shift (D-13 schema already splits allDay/timed).
- **Calendar rendering library:** Schedule-X (`@schedule-x/react` + `@schedule-x/calendar`) — selected by UI-SPEC. CSS token override strategy documented there.
- **Week start day:** Sunday (WEEK_START_DAY = 0). `calendarConfig.ts` constant.
- **TanStack Query = server state; Zustand = UI state.** Server events never enter Zustand.
- **Broker boundary:** Routes never call tsdav. `/api/events` reads MariaDB cache only.
### Claude's Discretion
- Skeleton/loading states: build polished versions.
- Dev-auth bypass: documented env-flagged middleware injecting a fixed dev user. Must be off by default and impossible in production builds.
### Deferred Ideas (OUT OF SCOPE)
- Tablet/wall-display kiosk mode + Skylight display theme + runtime theme-switcher UI (v2).
- Per-member show/hide filter (add when membership > 2).
- Secondary timezone display toggle (v1.x).
- Single-occurrence / "this and following" recurring edits (v1.x).
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
| ------ | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| CAL-02 | User sees a unified, color-coded calendar that aggregates every accessible calendar into one view | §Backend: /api/events evolution; §Frontend: Schedule-X calendars config with per-calendar lightColors |
| CAL-03 | User can switch between week, month, day, and agenda/list views | §Schedule-X Views; all four views confirmed in @schedule-x/calendar v4.6.0 |
| CAL-07 | User can create a recurring event and see all its occurrences expanded correctly (display portion only — creation is Phase 3) | §Recurrence expansion pipeline; §DST correctness; §All-day event handling |
</phase_requirements>
---
## Summary
Phase 2 is a read-only calendar display layer built on top of the Phase 1 broker and schema. The backend work is evolving `/api/events` from a raw table dump to a display-ready shape: a windowed query that joins `calendarEvents → calendars → users.color`, expands recurring events via `ICAL.RecurExpansion`, and serializes concrete occurrences as JSON. The frontend work is building a React calendar shell around Schedule-X, wiring TanStack Query to the windowed endpoint, and implementing the CSS token layer.
The hardest technical areas are (1) the recurrence expansion pipeline — specifically extracting and registering VTIMEZONE components before calling `ICAL.RecurExpansion` so DST boundaries produce correct wall-clock times — and (2) the Schedule-X v4 event format, which requires `Temporal.ZonedDateTime` and `Temporal.PlainDate` objects rather than ISO strings. The server returns JSON; the frontend must hydrate those strings into Temporal objects before passing them to Schedule-X. Both conversions have well-known pitfall patterns documented below.
All-day event correctness is already partially solved by the D-13 schema split (Phase 1): `allDay=true` events have a `dtstartDate` DATE column with no time component. The API must pass `Temporal.PlainDate` for these, not a `Temporal.ZonedDateTime` derived from midnight UTC, or Schedule-X will shift the date.
**Primary recommendation:** Expand recurrences on the server using `ICAL.RecurExpansion` with registered VTIMEZONE, serialize occurrences as plain ISO date strings in JSON, and hydrate to Temporal objects in the React client layer before feeding Schedule-X.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
| --------------------------------------- | ------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| Event storage and polling | API / Backend | — | Phase 1 broker owns all Fastmail I/O; routes read MariaDB cache |
| Recurrence expansion | API / Backend | — | Server expands to concrete occurrences for the requested window; client renders, never expands (D-09) |
| All-day / timed discrimination | API / Backend | — | D-13 schema split already done in Phase 1; route must preserve and expose the distinction |
| Color and ownership join | API / Backend | — | `calendars.userId → users.color` join lives closest to the data; frontend just reads the color hex |
| Temporal object construction | Frontend (PWA) | — | Server sends plain strings; client converts to `Temporal.ZonedDateTime` / `Temporal.PlainDate` before Schedule-X |
| Calendar rendering (views) | Frontend (PWA) | — | Schedule-X renders day/week/month/agenda in the browser |
| Token layer / theming | Frontend (PWA) | — | CSS custom properties + TS token object; Schedule-X `--sx-color-*` vars overridden |
| View state, selected date, open popover | Frontend (PWA) — Zustand | — | UI-only state; never server data |
| Event list caching and re-fetch | Frontend (PWA) — TanStack Query | — | Cache key = `['events', start, end]`; invalidated on range change |
| Dev-auth bypass | API / Backend | — | Env-flagged middleware injecting fixed user; never active in production |
---
## Standard Stack
### Core (all versions verified against npm registry 2026-06-04)
| Library | Version | Purpose | Why Standard |
| ---------------------------- | ------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `@schedule-x/calendar` | 4.6.0 | Calendar engine (views, Temporal-based event model) | Selected in UI-SPEC; active maintenance; last published 2026-05-12 |
| `@schedule-x/react` | 4.1.0 | React adapter (`useCalendarApp`, `ScheduleXCalendar`) | Official React adapter; peer-requires `@schedule-x/calendar ^3.1.0 \|\| ^4.0.0`; 4.6.0 satisfies this |
| `@schedule-x/theme-default` | 4.6.0 | Default CSS layout; overridden by project tokens | Required for Schedule-X internal layout engine; all colors are token-overridden |
| `@schedule-x/event-modal` | 4.6.0 | `createEventModalPlugin()` for custom `eventModal` component | Required to replace default modal with `EventDetailPopover` |
| `@schedule-x/events-service` | 4.6.0 | `createEventsServicePlugin()` for dynamic event updates | Required to update events after TanStack Query fetches new window |
| `temporal-polyfill` | 0.3.2 | `Temporal` global polyfill for browsers without native support | `@schedule-x/calendar` peer-requires `temporal-polyfill@0.3.0`; 0.3.2 satisfies |
| `lucide-react` | 1.17.0 | Icon library (CalendarDays, X, MapPin, ChevronLeft/Right) | Specified in UI-SPEC; tree-shakeable; active maintenance |
| `ical.js` | 2.2.1 | VEVENT parse + `ICAL.RecurExpansion` for recurrence | Already in both `apps/api` and `apps/pwa`; Phase 1 pattern established |
| `rrule` | 2.8.1 | RRULE string parsing (used only if `ICAL.RecurExpansion` is insufficient) | Already in project stack per CLAUDE.md; last pub 2023-11-10 — treat as stable |
### No New Backend Dependencies Needed
Phase 1 already installed all required API packages: `ical.js`, `hono`, `drizzle-orm`, `mysql2`, `zod`. The recurrence expansion work (`ICAL.RecurExpansion`) uses ical.js already present. No new API npm packages are required.
### Installation (PWA only)
```bash
cd apps/pwa
pnpm add @schedule-x/calendar@4.6.0 @schedule-x/react@4.1.0 @schedule-x/theme-default@4.6.0 @schedule-x/event-modal@4.6.0 @schedule-x/events-service@4.6.0 temporal-polyfill@0.3.2 lucide-react@1.17.0
```
**Note on version mismatch:** `@schedule-x/react` tops out at 4.1.0 (last published 2026-01-21) while `@schedule-x/calendar` is at 4.6.0 (2026-05-12). The React adapter peer-depends on `^3.1.0 || ^4.0.0` for `@schedule-x/calendar` — 4.6.0 satisfies `^4.0.0`. They are **compatible**. [VERIFIED: npm registry]
**Note on `firstDayOfWeek`:** Schedule-X v4 uses Temporal numbering where `7 = Sunday`, NOT `0 = Sunday`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]. The UI-SPEC sets `WEEK_START_DAY = 0` as a constant — the constant must be converted: pass `7` to Schedule-X when the constant is `0`. Document this translation in `calendarConfig.ts`.
---
## Package Legitimacy Audit
slopcheck was not available at research time. All packages below were verified via official documentation or established source repos. No packages flagged as suspicious by manual review.
| Package | Registry | Age | Source Repo | Postinstall | Disposition |
| ---------------------------- | -------- | ------ | ----------------------------------------- | ----------- | ----------- |
| `@schedule-x/calendar` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/react` | npm | 2+ yrs | github.com/schedule-x/react | none | Approved |
| `@schedule-x/theme-default` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/event-modal` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/events-service` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `temporal-polyfill` | npm | 2+ yrs | github.com/fullcalendar/temporal-polyfill | none | Approved |
| `lucide-react` | npm | 4+ yrs | github.com/lucide-icons/lucide | none | Approved |
**Packages removed due to slopcheck [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
_slopcheck was unavailable at research time. All packages are tagged [VERIFIED: npm registry] based on official source repos confirmed via npm view. Planner should add `checkpoint:human-verify` before install if extra caution is warranted — this two-person household app is self-hosted with no third-party attack surface for these well-established packages._
---
## Architecture Patterns
### System Architecture Diagram
```
Fastmail CalDAV
[Broker Poller] ──5min ctag──▶ [MariaDB: calendarEvents]
[GET /api/events?start=&end=]
┌────┴────┐
│ Window │
│ filter │
│ (SQL) │
└────┬────┘
│ rawVevent rows + calendar.userId + users.color
[expandOccurrences()]
┌─────────────────────┐
│ ICAL.parse(rawVevent) │
│ ICAL.RecurExpansion │
│ VTIMEZONE register │
│ EXDATE filter │
│ allDay / timed split │
└──────────┬────────────┘
│ JSON: CalendarOccurrence[]
[TanStack Query: ['events', start, end]]
[hydrateEvents()] ← converts ISO→Temporal
[Schedule-X eventsService.set()]
┌───────────┴────────────┐
│ ScheduleXCalendar │
│ Day | Week | Month | │
│ Agenda │
└────────────────────────-┘
[EventDetailPopover] (custom eventModal)
```
### Recommended Project Structure
```
apps/
├── api/src/
│ ├── routes/
│ │ └── events.ts ← evolve: windowed query + expansion
│ └── broker/
│ └── expand.ts ← new: expandOccurrences() helper
└── pwa/src/
├── styles/
│ ├── tokens.css ← CSS custom properties (clean theme)
│ ├── tokens.ts ← TypeScript token object
│ └── index.css ← imports tokens.css + global resets
├── lib/
│ ├── calendarConfig.ts ← WEEK_START_DAY + Schedule-X config factory
│ └── colorUtils.ts ← derive container/onContainer from main hex
├── components/
│ ├── CalendarShell.tsx ← layout: AppNav + ViewToolbar + ColorLegend + SX
│ ├── AppNav.tsx
│ ├── ViewToolbar.tsx
│ ├── ColorLegend.tsx
│ ├── EventDetailPopover.tsx ← read-only; Phase 3 adds edit actions in footer
│ └── SkeletonCalendar.tsx
├── store/
│ └── calendarStore.ts ← Zustand: selectedView, selectedDate, openEventId, calendarRange
└── api/
└── client.ts ← extend with fetchEvents(start, end)
```
### Pattern 1: `/api/events` Windowed Query with Expansion
**What:** `GET /api/events?start=2026-06-01&end=2026-07-01` returns a flat array of concrete occurrences (no recurring master events, no raw VCALENDAR blobs). Each occurrence has all fields the UI needs: title, start (ISO string), end (ISO string), allDay, color, calendarId, calendarName, uid, occurrenceId (uid + dtstart for identity), location, description.
**Server implementation shape:**
```typescript
// apps/api/src/broker/expand.ts
// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
import ICAL from 'ical.js';
export interface CalendarOccurrence {
id: string; // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string;
calendarId: number;
calendarName: string;
ownerUserId: number;
color: string; // hex from users.color or shared-family constant
isShared: boolean; // true when calendar is the shared-family calendar
title: string;
start: string; // ISO 8601 with timezone offset: '2026-06-15T10:00:00+02:00[America/Toronto]'
// for all-day: 'DATE:2026-06-15' — use a distinct format so client knows
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
export function expandOccurrences(
rawVevent: string,
windowStart: Date,
windowEnd: Date,
calendarId: number,
calendarName: string,
ownerUserId: number,
color: string,
isShared: boolean,
): CalendarOccurrence[] {
const parsed = ICAL.parse(rawVevent);
const comp = new ICAL.Component(parsed);
// CRITICAL: Register VTIMEZONE components before RecurExpansion
// Without this, RecurExpansion uses UTC and DST transitions produce wrong wall-clock times
for (const vtimezone of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtimezone.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtimezone, tzid }));
}
}
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
const event = new ICAL.Event(vevent);
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
const uid = event.uid;
// Non-recurring event: single occurrence check
if (!event.isRecurring()) {
// ... check if within window, return single occurrence
}
// Recurring event: use RecurExpansion
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
const rangeStart = ICAL.Time.fromJSDate(windowStart, /* useUtc */ false);
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, /* useUtc */ false);
const occurrences: CalendarOccurrence[] = [];
let next: ICAL.Time | null;
while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
if (next.compare(rangeStart) < 0) continue;
// Build occurrence, compute end from duration
// ...
}
return occurrences;
}
```
**Key insight:** `ICAL.RecurExpansion` handles EXDATE exclusions internally — you do not need to extract and compare EXDATEs manually when using the high-level API. [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]
### Pattern 2: Schedule-X Event Format (Temporal, not ISO strings)
**What:** Schedule-X v4 requires `Temporal.ZonedDateTime` for timed events and `Temporal.PlainDate` for all-day events. The backend returns ISO strings; the client hydrates them.
**Critical finding:** Schedule-X v3 changed from the old `"2024-01-15 09:00"` ISO string format to Temporal objects. v4 continues this. You cannot pass plain strings. [VERIFIED: schedule-x.dev/blog/schedule-x-v3-temporal-api]
```typescript
// apps/pwa/src/lib/hydrateEvents.ts
// Source: https://schedule-x.dev/docs/calendar/events
import 'temporal-polyfill/global'; // registers Temporal on globalThis
import type { CalendarOccurrence } from '@familysync/shared'; // server type
export interface ScheduleXEvent {
id: string;
title: string;
start: Temporal.ZonedDateTime | Temporal.PlainDate;
end: Temporal.ZonedDateTime | Temporal.PlainDate;
calendarId: string; // must be string matching the key in calendars config
location?: string;
description?: string;
// custom business fields pass through
_familySync?: { uid: string; color: string; isShared: boolean };
}
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
return occurrences.map((occ) => {
if (occ.allDay) {
// All-day: use PlainDate — do NOT construct ZonedDateTime from midnight UTC
return {
id: occ.id,
title: occ.title,
start: Temporal.PlainDate.from(occ.start), // occ.start is 'YYYY-MM-DD'
end: Temporal.PlainDate.from(occ.end),
calendarId: String(occ.calendarId),
_familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
};
}
// Timed: use ZonedDateTime from the offset-aware ISO string the server returns
return {
id: occ.id,
title: occ.title,
start: Temporal.ZonedDateTime.from(occ.start),
end: Temporal.ZonedDateTime.from(occ.end),
calendarId: String(occ.calendarId),
location: occ.location ?? undefined,
description: occ.description ?? undefined,
_familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
};
});
}
```
### Pattern 3: Schedule-X Calendar Configuration
```typescript
// apps/pwa/src/lib/calendarConfig.ts
// Source: https://schedule-x.dev/docs/calendar/calendars
// Source: https://schedule-x.dev/docs/calendar/configuration
import {
createViewDay,
createViewWeek,
createViewMonthGrid,
createViewMonthAgenda,
} from '@schedule-x/calendar';
import { createEventsServicePlugin } from '@schedule-x/events-service';
import { createEventModalPlugin } from '@schedule-x/event-modal';
export const WEEK_START_DAY = 0; // 0 = Sunday in project convention; Schedule-X uses 7 = Sunday
// Schedule-X v4 firstDayOfWeek: Temporal numbering — 1=Mon, 7=Sun
// Must translate from project convention (0=Sun) to Schedule-X (7=Sun)
function toSXWeekStart(dayConvention: number): number {
return dayConvention === 0 ? 7 : dayConvention;
}
export interface MemberCalendarConfig {
id: string; // String(users.id)
name: string; // users.displayName
color: string; // users.color hex
}
export function buildCalendarConfig(members: MemberCalendarConfig[]) {
const calendars: Record<
string,
{ colorName: string; lightColors: { main: string; container: string; onContainer: string } }
> = {};
// Shared-family calendar: reserved rose color
calendars['shared'] = {
colorName: 'shared',
lightColors: deriveScheduleXColors('#F25C7A'),
};
// Per-member calendars keyed by String(userId)
for (const m of members) {
calendars[m.id] = {
colorName: `member-${m.id}`,
lightColors: deriveScheduleXColors(m.color),
};
}
return { calendars };
}
// UI-SPEC color derivation: container = main at 15% opacity over white, onContainer = main darkened 40%
function deriveScheduleXColors(main: string) {
return {
main,
container: hexWithOpacity(main, 0.15), // CSS rgba computed over #FFFFFF
onContainer: darkenHex(main, 0.4),
};
}
```
**`firstDayOfWeek` translation note:** The UI-SPEC declares `WEEK_START_DAY = 0` (Sunday in JS/date-fns convention). Schedule-X v4 uses Temporal convention where Sunday = 7. Pass `7` to Schedule-X when `WEEK_START_DAY === 0`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]
### Pattern 4: TanStack Query + onRangeUpdate Wiring
```typescript
// apps/pwa/src/components/CalendarShell.tsx (sketch)
import { useCalendarApp, ScheduleXCalendar } from '@schedule-x/react'
import { createViewDay, createViewWeek, createViewMonthGrid, createViewMonthAgenda } from '@schedule-x/calendar'
import { useQuery } from '@tanstack/react-query'
function CalendarShell() {
const rangeStore = useCalendarStore() // Zustand
const { start, end } = rangeStore.calendarRange
// TanStack Query — key includes the visible window
const eventsQuery = useQuery({
queryKey: ['events', start, end],
queryFn: () => fetchEvents(start, end),
retry: 2,
staleTime: 5 * 60 * 1000, // 5 min — poller refreshes broker every 5 min
})
const eventsService = useState(() => createEventsServicePlugin())[0]
const eventModal = useState(() => createEventModalPlugin())[0]
const calendar = useCalendarApp({
views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()],
defaultView: isMobile ? 'month-agenda' : 'month-grid', // D-05
firstDayOfWeek: 7, // Sunday — Temporal convention
calendars: buildCalendarConfig(members).calendars,
plugins: [eventsService, eventModal],
onRangeUpdate(range) {
// Schedule-X fires this when the user navigates to a new window
// range.start and range.end are ISO date strings in v4
rangeStore.setCalendarRange({ start: range.start, end: range.end })
// Setting Zustand range triggers queryKey change → TanStack Query re-fetches
},
})
// Sync TanStack Query result into Schedule-X eventsService
useEffect(() => {
if (eventsQuery.data) {
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
eventsService.set(sxEvents)
}
}, [eventsQuery.data])
return (
<ScheduleXCalendar
calendarApp={calendar}
customComponents={{ eventModal: EventDetailPopover }}
/>
)
}
```
### Pattern 5: Dev-Auth Bypass Middleware
```typescript
// apps/api/src/auth/devBypass.ts
// Active ONLY when DEV_AUTH_BYPASS=true AND NODE_ENV !== 'production'
// Injects a fixed dev user into the request context so oidcAuthMiddleware is skipped
import type { MiddlewareHandler } from 'hono';
const DEV_USER = {
id: 1,
oidcIss: 'dev',
oidcSub: 'dev-user',
displayName: 'Dev User',
color: '#4A90D9',
};
export function devAuthBypass(): MiddlewareHandler {
if (process.env.NODE_ENV === 'production') {
// Hard guard — never active in production regardless of env flag
return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next();
}
// Inject fixed dev user into Hono context (replaces getAuth(c) result)
return async (c, next) => {
c.set('user', DEV_USER);
await next();
};
}
```
Mount in `index.ts` BEFORE `oidcAuthMiddleware` on `/api/*` when bypass is active. The bypass must short-circuit the OIDC redirect — `oidcAuthMiddleware` must be conditionally swapped out, not just prepended. Cleanest pattern: `app.use('/api/*', devAuthBypass() ?? oidcAuthMiddleware())`.
### Anti-Patterns to Avoid
- **Putting timed events on CalendarShell with a plain ISO string:** Schedule-X v4 rejects ISO strings. Hydrate to Temporal before calling `eventsService.set()`.
- **Constructing `Temporal.ZonedDateTime` for all-day events:** All-day events must use `Temporal.PlainDate`. Using `ZonedDateTime` midnight UTC will shift the date in non-UTC local timezones.
- **Expanding RRULE in the client:** D-09 locks server-side expansion. Never import rrule or call `ICAL.RecurExpansion` in the React frontend.
- **Fetching all events without a window:** With 503+ cached events and recurring series expanding infinitely, an unwindowed `/api/events` call will time out or exhaust memory. The `?start=&end=` window is mandatory.
- **Skipping VTIMEZONE registration:** If `ICAL.TimezoneService.register()` is not called before `ICAL.RecurExpansion`, ical.js falls back to UTC for timezone-aware events. Events in summer DST will appear one hour off.
- **Using rrule directly when ical.js RecurExpansion is available:** `ICAL.RecurExpansion` handles EXDATE, RDATE, and VTIMEZONE in one integrated call. rrule only handles the RRULE string — you must separately handle EXDATEs and VTIMEZONE registration. Use rrule only as a fallback for parsing RRULE strings that `ICAL.RecurExpansion` cannot handle.
- **Hard-coding `0` as `firstDayOfWeek` in Schedule-X config:** Schedule-X v4 uses Temporal numbering (7 = Sunday, not 0). Passing `0` will default to Monday.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
| ------------------------------------- | ----------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Recurring event expansion with EXDATE | Custom RRULE iterator | `ICAL.RecurExpansion` | RecurExpansion handles RDATE, EXDATE, RECURRENCE-ID in one integrated iterator |
| All four calendar views | Custom React grid | `@schedule-x/calendar` views | Day/week/month/agenda correctly handling overlap, all-day banners, and touch is 3-6 weeks of work |
| Custom event modal | Custom DOM overlay | `createEventModalPlugin` + `customComponents.eventModal` | Schedule-X positions the modal relative to the event; re-use in Phase 3 is built-in |
| VTIMEZONE DST tables | Custom offset lookup | `ICAL.TimezoneService.register()` from parsed VTIMEZONE | The VTIMEZONE component in the ICS already contains the correct DST rules for the calendar's timezone |
| Calendar color derivation | Manual CSS computation | `colorUtils.ts` utility function (small, one-file) | The 15%/darken derivation is simple enough to implement inline; no third-party needed |
| iCalendar string parsing | Custom VCALENDAR parser | `ICAL.parse()` + `ICAL.Component` | VCALENDAR has pathological edge cases (folded lines, UTF-8 encoded params, VTIMEZONE nesting) |
**Key insight:** Calendar view rendering that handles overlap, drag handle exclusion zones, DST, all-day banners, and touch gestures for iOS correctly is multi-month work. Schedule-X exists precisely for this.
---
## Common Pitfalls
### Pitfall 1: `firstDayOfWeek` Temporal Numbering Mismatch
**What goes wrong:** Passing `0` to Schedule-X `firstDayOfWeek` instead of `7` causes week views to start on Monday (the default), silently ignoring Sunday.
**Why it happens:** JS/date-fns use `0 = Sunday`; Temporal (and therefore Schedule-X v4) uses `1 = Monday ... 7 = Sunday`.
**How to avoid:** The constant `WEEK_START_DAY = 0` in `calendarConfig.ts` is in the JS convention. Translate it: `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY`.
**Warning signs:** Week view starts on Monday even after setting Sunday; calendar headers show Mon as the first column.
### Pitfall 2: All-Day Events Shifting by One Day
**What goes wrong:** An all-day event for "June 15" appears on "June 14" (or "June 16") in the calendar.
**Why it happens:** If the all-day event's ISO string (`2026-06-15`) is passed to `Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z')`, the UTC midnight maps to June 14 in timezones west of UTC. Schedule-X expects `Temporal.PlainDate` for all-day events.
**How to avoid:** In `hydrateEvents()`, check `occ.allDay`. If `true`, use `Temporal.PlainDate.from(occ.start)` where `occ.start` is already `'YYYY-MM-DD'`. Never construct a ZonedDateTime for an all-day event. The D-13 schema split (Phase 1) already separates `dtstartDate` (DATE column, all-day) from `dtstartUtc` (TIMESTAMP, timed) — the API must expose this as a clean flag.
**Warning signs:** Birthday/holiday events appear one day early; affects users in UTC-N timezones (Americas).
### Pitfall 3: Missing VTIMEZONE Registration Causes DST-Shifted Occurrences
**What goes wrong:** A weekly meeting at 10:00 America/New_York produces occurrences at 10:00 UTC during EST (correct) and 10:00 UTC during EDT (one hour off — shows as 11:00 local time).
**Why it happens:** `ICAL.RecurExpansion` defers to `ICAL.TimezoneService` for timezone-aware ICAL.Time conversion. If the TZID is not registered, ical.js silently falls back to UTC, treating all occurrences as UTC regardless of DST.
**How to avoid:** Before calling `new ICAL.RecurExpansion(...)`, iterate `comp.getAllSubcomponents('vtimezone')` and call `ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component, tzid }))` for each one. Guard with `!ICAL.TimezoneService.has(tzid)` to avoid double-registration.
**Warning signs:** Recurring events across DST transitions display at the wrong wall-clock time by exactly ±1 hour.
### Pitfall 4: Schedule-X ISO String Events Silently Fail
**What goes wrong:** Events appear to not render, or Schedule-X throws a runtime error.
**Why it happens:** Schedule-X v4 dropped ISO string event format in v3. The old format was `{ start: "2024-01-15 09:00", end: "2024-01-15 10:00" }`. Passing these strings now produces a type error or silent failure.
**How to avoid:** All events passed to `eventsService.set()` must have `Temporal.ZonedDateTime` or `Temporal.PlainDate` for start/end. The `hydrateEvents()` function must run before `eventsService.set()`. Ensure `temporal-polyfill/global` is imported in `main.tsx` before any Schedule-X component mounts.
**Warning signs:** Calendar renders with no events even when the query returns data; console shows `Temporal is not defined`.
### Pitfall 5: Unwindowed `/api/events` Endpoint
**What goes wrong:** First page load fetches all 503+ events (as of the Phase 1 spike) plus all recurring occurrences expanded to "all time", causing the request to time out or return a 10 MB payload.
**Why it happens:** Phase 1 `/api/events` returns `db.select().from(calendarEvents)` — no window filter. This was fine as a proof-of-concept; it is unsuitable for the display layer.
**How to avoid:** The new `/api/events?start=YYYY-MM-DD&end=YYYY-MM-DD` endpoint filters `dtstartUtc BETWEEN start AND end` (for timed events) and `dtstartDate BETWEEN start AND end` (for all-day), then expands recurring masters within the window. The SQL filter is a pre-filter; `ICAL.RecurExpansion` does the precise window check. Non-recurring events can be filtered entirely in SQL.
**Warning signs:** Initial page load takes >3s; response payload >1 MB; memory usage spikes during expansion.
### Pitfall 6: `@schedule-x/react` Version Behind `@schedule-x/calendar`
**What goes wrong:** Potential API mismatch if `@schedule-x/react@4.1.0` does not expose new Schedule-X features added in `@schedule-x/calendar@4.44.6`.
**Why it happens:** The React adapter (`github.com/schedule-x/react`) has a separate release cadence from the core (`github.com/schedule-x/schedule-x`). React adapter was last published 2026-01-21; core was 2026-05-12.
**How to avoid:** The adapter peer-dep `^4.0.0` for `@schedule-x/calendar` is satisfied by 4.6.0 — the API contract is maintained. Limit usage to the API surface confirmed in docs: `useCalendarApp`, `ScheduleXCalendar`, `customComponents`. Test the integration in Wave 0 before building dependent components.
**Warning signs:** TypeScript errors on `useCalendarApp` options that are documented but not typed in `@schedule-x/react@4.1.0`.
### Pitfall 7: Dev-Auth Bypass Active in Production
**What goes wrong:** A `DEV_AUTH_BYPASS=true` env var accidentally set in production gives unauthenticated access to all `/api/*` routes.
**Why it happens:** Env vars leak into production containers via `.env` file copy mistakes or CI/CD misconfiguration.
**How to avoid:** The bypass middleware must have a hard `process.env.NODE_ENV === 'production'` guard as its FIRST check, before reading `DEV_AUTH_BYPASS`. The Docker Compose production configuration must NOT set `DEV_AUTH_BYPASS`. Document this in the env var table in `.env.example` with a warning comment.
**Warning signs:** `/api/me` returns a response without an Authelia session cookie in production.
---
## Code Examples
### VTIMEZONE Registration + ICAL.RecurExpansion (complete pattern)
```typescript
// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
// Source: https://kewisch.github.io/ical.js/api/
import ICAL from 'ical.js';
function expandVeventOccurrences(
rawVcalendar: string,
windowStart: Date,
windowEnd: Date,
): Array<{ dtstart: Date; dtend: Date; allDay: boolean }> {
const parsed = ICAL.parse(rawVcalendar);
const comp = new ICAL.Component(parsed);
// Step 1: Register all VTIMEZONE components in this VCALENDAR.
// Must happen BEFORE constructing ICAL.RecurExpansion.
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtz.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }));
}
}
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
const event = new ICAL.Event(vevent);
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
const allDay = dtstart.isDate;
const results: Array<{ dtstart: Date; dtend: Date; allDay: boolean }> = [];
const rangeStart = ICAL.Time.fromJSDate(windowStart, false);
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, false);
if (!event.isRecurring()) {
if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
const dtend = vevent.getFirstPropertyValue('dtend') as ICAL.Time | null;
results.push({
dtstart: dtstart.toJSDate(),
dtend: (dtend ?? dtstart).toJSDate(),
allDay,
});
}
return results;
}
// RecurExpansion handles RRULE + RDATE + EXDATE internally
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
let next: ICAL.Time | null;
while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
if (next.compare(rangeStart) < 0) continue;
// Compute end using the original event's duration
const duration = event.duration;
const occEnd = next.clone();
occEnd.addDuration(duration);
results.push({ dtstart: next.toJSDate(), dtend: occEnd.toJSDate(), allDay });
}
return results;
}
```
### All-Day Event: Server Format to Schedule-X PlainDate
```typescript
// Source: https://schedule-x.dev/docs/calendar/events
// The server sends allDay events with start: 'YYYY-MM-DD' (from dtstartDate column)
// The client must use Temporal.PlainDate — NOT ZonedDateTime
// WRONG (shifts date in negative-offset timezones):
{
start: Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z');
}
// CORRECT:
{
start: Temporal.PlainDate.from('2026-06-15');
}
```
### Schedule-X CSS Token Override Pattern
```css
/* apps/pwa/src/styles/tokens.css */
/* Source: https://schedule-x.dev — import theme-default, then override all --sx-color-* vars */
/* Import Schedule-X default layout CSS in main.tsx:
import '@schedule-x/theme-default/dist/index.css'
Import tokens.css after — these overrides take precedence */
:root {
/* Map Schedule-X color vars to project tokens */
--sx-color-primary: var(--color-member-0); /* current user's color */
--sx-color-on-primary: #ffffff;
--sx-color-surface: var(--color-surface);
--sx-color-on-surface: var(--color-text-primary);
--sx-color-on-surface-variant: var(--color-text-secondary);
--sx-color-outline: var(--color-border);
--sx-color-neutral: var(--color-surface-dim);
--sx-color-neutral-variant: var(--color-border-subtle);
/* Typography */
--sx-font-family: var(--font-family-base);
}
```
---
## Backend: `/api/events` Evolution
### Current state (Phase 1)
```typescript
// apps/api/src/routes/events.ts — current implementation
eventsRouter.get('/', async (c) => {
const events = await db.select().from(calendarEvents); // no window, no join, no expansion
return c.json({ events });
});
```
### Target state (Phase 2)
```typescript
// apps/api/src/routes/events.ts — evolved
eventsRouter.get('/', async (c) => {
const { start, end } = c.req.query();
// Zod-validate start/end as ISO dates
// SQL: calendarEvents JOIN calendars JOIN users
// WHERE (dtstartUtc BETWEEN start AND end) OR (dtstartDate BETWEEN start AND end)
// OR event.hasRrule (to catch recurring masters whose window occurrence may differ)
// For each row: call expandOccurrences(rawVevent, windowStart, windowEnd, ...)
// Return: { occurrences: CalendarOccurrence[] }
});
```
**SQL pre-filter strategy:** The SQL `WHERE` must also include events with an RRULE property that _started before_ the window, because a weekly meeting created 3 years ago can still have occurrences in the current window. Include a `hasRrule` boolean column (can be added via migration) or parse `rawVevent` in the expansion step and skip in-memory if no occurrences fall in window. The simpler approach: include all events where `dtstartUtc < windowEnd` (no lower bound) OR `dtstartDate < windowEnd`, then let `expandOccurrences` handle the window check. Add a schema migration to add a `hasRrule` boolean indexed column to `calendarEvents` to avoid scanning all historical events on every request.
**Shared calendar identification:** The `calendars` table has `userId` but no explicit `isShared` flag. The shared-family calendar is identified by being the one that has its `displayName = 'Calendar'` (from CAL-08-DECISION.md) or, more robustly, by convention (the broker user is the broker account, not a household member). The Phase 2 plan should resolve this: either add a `isShared` boolean to `calendars`, or identify shared calendars by comparing `calendars.userId` to a designated broker user ID.
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
| ------------------------------------------------- | ----------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------ |
| Schedule-X ISO string events `"YYYY-MM-DD HH:MM"` | `Temporal.ZonedDateTime` / `Temporal.PlainDate` | Schedule-X v3 (2024) | Server must return parseable strings; client must hydrate |
| `react-big-calendar` (moment/date-fns) | Schedule-X (Temporal-based) | 2024 ecosystem shift | react-big-calendar's CSS is hard to override; Schedule-X CSS tokens are first-class |
| `FullCalendar` open-source | Schedule-X (fully MIT) | 2024 for self-hosted | FullCalendar premium features are commercial; Schedule-X is fully open |
| rrule-only recurrence expansion | `ICAL.RecurExpansion` (higher-level) | ical.js 1.x+ | RecurExpansion integrates RRULE + RDATE + EXDATE; no separate EXDATE handling needed |
**Deprecated/outdated:**
- `react-big-calendar`: Not deprecated per se, but the CSS override story is significantly worse for a token-based design system. The UI-SPEC already rejected it.
- Schedule-X v2 ISO string format: Removed in v3. Any tutorial older than mid-2024 using string dates is wrong.
---
## Validation Architecture
### Test Framework
| Property | Value |
| ------------------ | --------------------------------------------------------------------------------- |
| Framework | Vitest (already configured in `apps/api/vitest.config.ts`) |
| Config file | `apps/api/vitest.config.ts` (exists); `apps/pwa` has no test setup — needs Wave 0 |
| Quick run command | `pnpm --filter @familysync/api test` |
| Full suite command | `pnpm -r test` (workspace-wide) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
| -------------------- | --------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------- | ------------ |
| CAL-02 (color) | Events returned with correct `color` field from `users.color` | unit | `pnpm --filter @familysync/api test -- tests/routes/events.test.ts` | ❌ Wave 0 |
| CAL-02 (aggregation) | Events from multiple calendars (multiple users) returned in single response | unit | same file | ❌ Wave 0 |
| CAL-03 (views) | Schedule-X renders without error with all four views configured | smoke | `pnpm --filter @familysync/pwa test -- calendar.spec.tsx` | ❌ Wave 0 |
| CAL-07 (recurrence) | `expandOccurrences()` returns correct occurrences for weekly RRULE in a 30-day window | unit | `pnpm --filter @familysync/api test -- tests/broker/expand.test.ts` | ❌ Wave 0 |
| CAL-07 (DST) | `expandOccurrences()` with America/New_York RRULE across March DST boundary returns correct wall-clock times | unit | same file | ❌ Wave 0 |
| CAL-07 (all-day) | `expandOccurrences()` for all-day event returns `allDay: true` and `start: 'YYYY-MM-DD'` with no time component | unit | same file | ❌ Wave 0 |
| CAL-07 (EXDATE) | `expandOccurrences()` excludes EXDATE occurrences from expansion | unit | same file | ❌ Wave 0 |
| CAL-07 (Temporal) | `hydrateEvents()` converts all-day occurrences to `Temporal.PlainDate` and timed to `Temporal.ZonedDateTime` | unit | `pnpm --filter @familysync/pwa test -- lib/hydrateEvents.test.ts` | ❌ Wave 0 |
### Fixture ICS Files (test corpus)
The most valuable test artifacts are fixture `.ics` files. Create in `apps/api/tests/fixtures/`:
| Filename | Contents | Tests |
| --------------------- | ------------------------------------------------------------------------------------------------- | ------------------ |
| `weekly-dst.ics` | Weekly meeting at 10:00 America/New_York spanning March DST transition (2026-03-01 to 2026-04-30) | CAL-07 DST |
| `allday-birthday.ics` | Annual birthday event (DATE type, no DTEND) | CAL-07 all-day |
| `exdate-series.ics` | Weekly series with one EXDATE (a skipped occurrence) | CAL-07 EXDATE |
| `multi-cal.ics` | Two separate VCALENDAR blobs to represent two members' events | CAL-02 aggregation |
These fixture files can be generated from real Fastmail ICS exports or hand-crafted with known-correct VTIMEZONE blocks.
### Sampling Rate
- **Per task commit:** `pnpm --filter @familysync/api test` (API unit tests, <5s)
- **Per wave merge:** `pnpm -r test` + `pnpm -r typecheck`
- **Phase gate:** Full suite green + `tsc --noEmit` clean in both workspaces before `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `apps/api/tests/broker/expand.test.ts` — covers CAL-07 recurrence + DST + EXDATE + all-day
- [ ] `apps/api/tests/routes/events.test.ts` — covers CAL-02 windowed query + color join
- [ ] `apps/pwa/vitest.config.ts` — Vitest not configured in PWA; needs `vitest` + `@testing-library/react` + `jsdom`
- [ ] `apps/pwa/src/lib/hydrateEvents.test.ts` — covers Temporal hydration + all-day guard
- [ ] `apps/pwa/src/lib/calendarConfig.test.ts` — covers `firstDayOfWeek` translation (0→7)
- [ ] `apps/pwa/package.json` — add `"test": "vitest run"` script + `vitest`, `@testing-library/react`, `jsdom` devDependencies
---
## Security Domain
`security_enforcement: true`, `security_asvs_level: 1` per config.json.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
| --------------------- | ----------------------------------------- | ------------------------------------------------------------------ |
| V2 Authentication | yes — dev bypass must not leak | `NODE_ENV === 'production'` hard guard in `devAuthBypass()` |
| V3 Session Management | carried from Phase 1 | `@hono/oidc-auth` JWT cookie (httpOnly + Secure + SameSite) |
| V4 Access Control | yes — `/api/events` must be authenticated | `oidcAuthMiddleware` on `/api/*` (Phase 1 pattern) |
| V5 Input Validation | yes — `?start=` and `?end=` query params | `zod` + `@hono/zod-validator`: validate ISO date format before SQL |
| V6 Cryptography | no new crypto in Phase 2 | — |
### Known Threat Patterns for This Phase
| Pattern | STRIDE | Standard Mitigation |
| ----------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------- |
| Dev-auth bypass left active in production | Elevation of privilege | Hard `NODE_ENV !== 'production'` guard; `.env.example` warning |
| SQL injection via `?start=` / `?end=` date params | Tampering | Zod ISO date validation; Drizzle parameterized queries |
| XSS via event title/description in EventDetailPopover | Tampering | React's default JSX escaping; never use `dangerouslySetInnerHTML` for event fields |
| Overfetch (no window) timing/DoS | Denial of service | Zod-enforce required `start` + `end` params; cap window to 90 days max |
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
| ---------------------- | ---------------------------- | ----------------- | ------------------------------------- | -------------------------------------------------- |
| Node.js 22 | API + PWA build | ✓ | (WSL2 dev env — assumed from Phase 1) | — |
| pnpm | Workspace install | ✓ | (Phase 1 used it) | — |
| MariaDB (Docker) | `/api/events` windowed query | ✓ | Phase 1 confirmed: 503 events cached | — |
| Temporal (browser) | Schedule-X v4 | Partial | Needs `temporal-polyfill` in PWA | `temporal-polyfill@0.3.2` — no fallback needed |
| Live Authelia/Pangolin | Full auth flow | ✗ (D-14 deferred) | — | Dev-auth bypass middleware (must build in Phase 2) |
**Missing with no fallback:** None. Dev-auth bypass covers the Authelia deferral.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------- |
| A1 | `ICAL.RecurExpansion` handles EXDATE internally when using the high-level API | Architecture Patterns | Planner would need to add manual EXDATE filtering in `expandOccurrences` |
| A2 | `@schedule-x/react@4.1.0` is API-compatible with `@schedule-x/calendar@4.6.0` for the features used (views, calendars, onRangeUpdate, customComponents) | Standard Stack | Version mismatch may cause TypeScript errors on newer options; test in Wave 0 |
| A3 | The shared-family calendar can be identified programmatically (by displayName or a new `isShared` column) without a schema migration | Backend: /api/events evolution | If not deterministic, Phase 2 plan must include a migration adding `calendars.isShared` |
| A4 | `onRangeUpdate` fires immediately on mount with the initial window | Architecture Patterns | If it does not fire on mount, initial fetch requires a separate first-render trigger |
**A1 verification:** The ical.js wiki states RecurExpansion "takes into account recurrence exceptions (RDATE and EXDATE)" [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]. Treat as HIGH confidence.
**A2 verification:** Peer dep `^4.0.0` satisfied by 4.6.0 [VERIFIED: npm registry]. API surface used (views, calendars, onRangeUpdate) is stable since v4.0.0. Treat as MEDIUM confidence — validate in Wave 0.
**A3 risk:** The Phase 1 spike showed Lucas's broker account has two calendars: "Calendar" and "USA Holidays". The shared-family calendar is the household-shared one. Since the per-member app-password model means each member's own calendars are fetched under their own credential, "shared" in the context of Phase 2 likely means a calendar explicitly shared at the Fastmail account level, not just a personal calendar. The plan should include a `checkpoint:human-verify` to confirm how to mark shared calendars, or default to: the calendar synced under the broker account is shared-family; calendars synced under member credentials are personal.
**A4 note:** Schedule-X fires `onRangeUpdate` when the view changes (navigation). Initial mount may not fire it. The TanStack Query initial key should be set from Zustand's default `calendarRange` (today ± buffer), not depend on `onRangeUpdate` for the first fetch.
---
## Open Questions
1. **Identifying the shared-family calendar**
- What we know: Phase 1 caches calendars under `calendars.userId`. Lucas's broker account has "Calendar" and "USA Holidays". The wife's personal calendar will be added.
- What's unclear: Which calendar(s) are "shared-family" vs "personal"? Is it deterministic from displayName? From which user account synced it? The UI-SPEC assigns the rose `#F25C7A` to the shared-family calendar.
- Recommendation: Add a `calendars.isShared` boolean column (default false). The operator marks the shared-family calendar during initial setup. Alternatively, treat "Calendar" (exact displayName match) from the broker account as shared — but this is fragile.
2. **`onRangeUpdate` initial mount behavior**
- What we know: Schedule-X fires `onRangeUpdate` on navigation. Docs do not specify if it fires on mount.
- What's unclear: Does the calendar fire `onRangeUpdate` immediately with the initial visible window, or only on user navigation?
- Recommendation: Do not rely on `onRangeUpdate` for the initial fetch. Set Zustand `calendarRange` to a sensible default (e.g., current month ± 1 week) on store initialization; use that as the initial TanStack Query key.
3. **Recurring masters with `dtstartUtc` before the window**
- What we know: A weekly meeting created 3 years ago has `dtstartUtc` from 3 years ago. The SQL pre-filter `WHERE dtstartUtc BETWEEN start AND end` will miss it entirely.
- What's unclear: The right balance between SQL efficiency and correctness.
- Recommendation: Add `hasRrule boolean` indexed column to `calendarEvents` (schema migration in Wave 0). Pre-filter: `WHERE (NOT hasRrule AND dtstartUtc BETWEEN start AND end) OR (hasRrule AND dtstartUtc < windowEnd)`. The expansion step then filters the precise window.
---
## Sources
### Primary (HIGH confidence)
- [schedule-x.dev/docs/frameworks/react](https://schedule-x.dev/docs/frameworks/react) — React adapter usage, views, eventsService plugin
- [schedule-x.dev/docs/calendar/calendars](https://schedule-x.dev/docs/calendar/calendars) — lightColors config, calendarId on events
- [schedule-x.dev/docs/calendar/events](https://schedule-x.dev/docs/calendar/events) — Temporal.ZonedDateTime / Temporal.PlainDate requirement
- [schedule-x.dev/docs/calendar/configuration](https://schedule-x.dev/docs/calendar/configuration) — firstDayOfWeek (Temporal: 7=Sunday), onRangeUpdate
- [schedule-x.dev/blog/schedule-x-v3-temporal-api](https://schedule-x.dev/blog/schedule-x-v3-temporal-api) — breaking change from ISO strings to Temporal in v3
- [github.com/kewisch/ical.js/wiki/Common-Use-Cases](https://github.com/kewisch/ical.js/wiki/Common-Use-Cases) — ICAL.RecurExpansion pattern, VTIMEZONE registration
- [github.com/kewisch/ical.js/wiki/Parsing-iCalendar](https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar) — ICAL.parse + ICAL.Component + ICAL.Event pipeline
- Phase 1 codebase: `apps/api/src/broker/sync.ts`, `apps/api/src/db/schema.ts`, CAL-08-DECISION.md — confirmed Phase 1 foundation
- `npm view` on all Phase 2 packages — versions and publish dates confirmed [VERIFIED: npm registry]
### Secondary (MEDIUM confidence)
- [schedule-x.dev/docs/calendar/major-version-migrations](https://schedule-x.dev/docs/calendar/major-version-migrations) — v2→v3 breaking changes (Temporal adoption confirmed)
- [schedule-x.dev/docs/calendar/plugins/event-modal](https://schedule-x.dev/docs/calendar/plugins/event-modal) — createEventModalPlugin + customComponents.eventModal
- WebSearch on rrule DST behavior — confirmed known issue with `tzid` parameter and UTC fallback; `ICAL.RecurExpansion` is the recommended alternative
### Tertiary (LOW confidence)
- WebSearch results on VTIMEZONE registration best practices — cross-verified with official ical.js wiki
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all packages verified on npm registry; Schedule-X selected in UI-SPEC
- Architecture (recurrence expansion): HIGH — ICAL.RecurExpansion documented in official ical.js wiki; Phase 1 sync.ts pattern extended
- Architecture (Schedule-X Temporal format): HIGH — verified against official Schedule-X docs
- firstDayOfWeek translation: HIGH — verified in Schedule-X configuration docs
- All-day event PlainDate requirement: HIGH — verified in Schedule-X events docs
- VTIMEZONE registration for DST: MEDIUM — pattern documented in ical.js wiki; ICAL.js DST behavior not independently regression-tested
- Shared calendar identification: LOW — depends on runtime data shape not fully inspected
**Research date:** 2026-06-04
**Valid until:** 2026-09-04 (90 days — Schedule-X v4 is in active development; re-verify if minor versions change significantly before execution)