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

60 KiB
Raw Blame History

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)

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)
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:

// 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]

// 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

// 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

// 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

// 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)

// 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

// 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

/* 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)

// 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)

// 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)

Secondary (MEDIUM confidence)

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)