# Phase 2: Calendar Display - Pattern Map **Mapped:** 2026-06-04 **Files analyzed:** 17 new/modified files **Analogs found:** 15 / 17 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | | ------------------------------------------------ | ---------- | ---------------- | ------------------------------------------- | ------------- | | `apps/api/src/db/schema.ts` | model | CRUD | self (modify) | exact | | `apps/api/src/broker/expand.ts` | utility | transform | `apps/api/src/broker/sync.ts` | role-match | | `apps/api/src/routes/events.ts` | route | request-response | self (modify) + `apps/api/src/routes/me.ts` | exact | | `apps/api/src/auth/devBypass.ts` | middleware | request-response | `apps/api/src/auth/middleware.ts` | role-match | | `apps/api/src/index.ts` | config | request-response | self (modify) | exact | | `apps/api/tests/broker/expand.test.ts` | test | transform | `apps/api/tests/broker/poller.test.ts` | role-match | | `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/health.test.ts` | role-match | | `apps/pwa/vitest.config.ts` | config | — | `apps/api/vitest.config.ts` | role-match | | `apps/pwa/src/styles/tokens.css` | utility | — | none | no analog | | `apps/pwa/src/styles/tokens.ts` | utility | — | none | no analog | | `apps/pwa/src/styles/index.css` | utility | — | none | no analog | | `apps/pwa/src/lib/calendarConfig.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial | | `apps/pwa/src/lib/hydrateEvents.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial | | `apps/pwa/src/lib/colorUtils.ts` | utility | transform | `apps/pwa/src/App.tsx` (ColorSwatch) | partial | | `apps/pwa/src/store/calendarStore.ts` | store | event-driven | none | no analog | | `apps/pwa/src/components/CalendarShell.tsx` | component | request-response | `apps/pwa/src/App.tsx` | role-match | | `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/App.tsx` (MemberBadge) | partial | | `apps/pwa/src/components/AppNav.tsx` | component | — | `apps/pwa/src/App.tsx` | partial | | `apps/pwa/src/components/ViewToolbar.tsx` | component | event-driven | `apps/pwa/src/App.tsx` | partial | | `apps/pwa/src/components/ColorLegend.tsx` | component | — | `apps/pwa/src/App.tsx` (MemberBadge) | partial | | `apps/pwa/src/components/SkeletonCalendar.tsx` | component | — | `apps/pwa/src/App.tsx` (loading state) | partial | | `apps/pwa/src/api/client.ts` | utility | request-response | self (modify) | exact | | `apps/pwa/src/main.tsx` | config | — | self (modify) | exact | --- ## Pattern Assignments ### `apps/api/src/db/schema.ts` (model — modify existing) **Analog:** self **Add to `calendarEvents` table — Drizzle column pattern** (lines 84–105 of current file): ```typescript // New columns to add — follow existing column declaration style exactly: hasRrule: boolean('has_rrule').default(false).notNull(), isShared: boolean('is_shared').default(false).notNull(), // on calendars table, not calendarEvents // On calendars table — add alongside existing columns: isShared: boolean('is_shared').default(false).notNull(), // Index pattern to copy for hasRrule (copy idx_calendar_events_dtstart_utc style): index('idx_calendar_events_has_rrule').on(t.hasRrule), ``` **Import pattern** (lines 1–11 of existing schema.ts): ```typescript import { mysqlTable, varchar, text, int, date, timestamp, boolean, index, unique, } from 'drizzle-orm/mysql-core'; ``` --- ### `apps/api/src/broker/expand.ts` (utility, transform — new file) **Analog:** `apps/api/src/broker/sync.ts` **Imports pattern** (lines 1–8 of sync.ts): ```typescript import ICAL from 'ical.js'; import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendars, calendarEvents } from '../db/schema.js'; ``` **ICAL.parse + Component pipeline pattern** (lines 78–91 of sync.ts): ```typescript let parsed: ReturnType; try { parsed = ICAL.parse(obj.data as string); } catch { // Malformed VCALENDAR — skip but do not crash the sync continue; } const comp = new ICAL.Component(parsed); const vevent = comp.getFirstSubcomponent('vevent'); if (!vevent) continue; const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null; ``` **allDay detection pattern** (lines 88–98 of sync.ts): ```typescript // D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column const allDay: boolean = dtstart?.isDate ?? false; ``` **Error handling pattern** (lines 75–78 of sync.ts): ```typescript try { parsed = ICAL.parse(obj.data as string); } catch { continue; // malformed VCALENDAR — skip silently } ``` **VTIMEZONE registration — must come before RecurExpansion** (from RESEARCH.md Pattern 1): ```typescript // CRITICAL: Register VTIMEZONE 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 })); } } ``` --- ### `apps/api/src/routes/events.ts` (route, request-response — modify existing) **Analog:** `apps/api/src/routes/me.ts` + current `events.ts` **Route file structure pattern** (lines 1–29 of me.ts): ```typescript import { Hono } from 'hono'; import { getAuth } from '../auth/middleware.js'; import { upsertUser } from '../auth/user.js'; export const meRouter = new Hono(); meRouter.get('/', async (c) => { const auth = await getAuth(c); if (!auth) { return c.json({ error: 'Unauthorized' }, 401); } // ... business logic return c.json({ user: { id, displayName, color } }); }); ``` **Zod query param validation pattern** — follow `@hono/zod-validator` (from CLAUDE.md stack; no existing example yet — planner must scaffold): ```typescript import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; const eventsQuerySchema = z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }); eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { const { start, end } = c.req.valid('query'); // ... }); ``` **Drizzle join pattern** (from sync.ts lines 59, 99 + schema.ts foreign key pattern): ```typescript // Pattern: db.select().from(table).where(eq(...)).limit(1) // For join: db.select().from(calendarEvents) // .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) // .innerJoin(users, eq(calendars.userId, users.id)) // .where(...) ``` **Error handling pattern** (lines 16–26 of health.ts): ```typescript try { // ... return c.json({ ok: true, db: 'up' }); } catch (err) { console.error('[health] DB round-trip failed:', err); return c.json({ ok: false, db: 'down' }, 503); } ``` --- ### `apps/api/src/auth/devBypass.ts` (middleware — new file) **Analog:** `apps/api/src/auth/middleware.ts` **Middleware export pattern** (lines 24–26 of middleware.ts): ```typescript // middleware.ts uses re-export; devBypass.ts uses named function export export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth'; ``` **Hono middleware handler signature** (from Hono docs + RESEARCH.md Pattern 5): ```typescript import type { MiddlewareHandler } from 'hono'; export function devAuthBypass(): MiddlewareHandler { // Hard production guard FIRST — before reading any env var if (process.env.NODE_ENV === 'production') { return async (_c, next) => next(); } if (process.env.DEV_AUTH_BYPASS !== 'true') { return async (_c, next) => next(); } return async (c, next) => { c.set('user', DEV_USER); await next(); }; } ``` --- ### `apps/api/src/index.ts` (config — modify existing) **Analog:** self **Middleware mount order pattern** (lines 14–29 of index.ts): ```typescript // OIDC callback BEFORE auth guard (T-02-02) app.get('/callback', (c) => processOAuthCallback(c)); // Unauthenticated routes BEFORE the guard app.route('/health', healthRouter); // Auth guard on /api/* app.use('/api/*', oidcAuthMiddleware()); // Protected routes after guard app.route('/api/me', meRouter); app.route('/api/events', eventsRouter); ``` **Dev bypass mount pattern** — devBypass must be mounted BEFORE oidcAuthMiddleware: ```typescript // In dev: swap oidcAuthMiddleware for devAuthBypass when bypass is active // The bypass short-circuits the OIDC redirect entirely app.use('/api/*', devAuthBypass()); // no-op passthrough when NODE_ENV=production or flag not set app.use('/api/*', oidcAuthMiddleware()); // Note: devAuthBypass sets c.set('user', DEV_USER) so oidcAuthMiddleware is still called // but getAuth(c) will find the injected user. See RESEARCH.md Pattern 5 for alternate approach. ``` --- ### `apps/api/tests/broker/expand.test.ts` (test — new file) **Analog:** `apps/api/tests/broker/poller.test.ts` **Test file structure** (lines 1–14 of poller.test.ts): ```typescript import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // vi.mock hoisted to module top by Vitest vi.mock('../../src/broker/sync.js', () => ({ syncCalendar: mockSyncCalendar, })); ``` **describe/it/expect pattern** (lines 71–121 of poller.test.ts): ```typescript describe('broker poller — runPoll', () => { beforeEach(() => { vi.clearAllMocks(); // reset arrays and mock implementations }); it('skips syncCalendar when ctag is unchanged', async () => { const { runPoll } = await import('../../src/broker/poller.js'); // arrange await runPoll(); // assert expect(mockSyncCalendar).not.toHaveBeenCalled(); }); }); ``` **Error resilience test pattern** (lines 176–195 of poller.test.ts): ```typescript it('handles decryptPassword failure gracefully without crashing the poller', async () => { (decryptPassword as Mock).mockImplementationOnce(() => { throw new Error('Decryption failed'); }); await expect(runPoll()).resolves.not.toThrow(); expect(mockSyncCalendar).not.toHaveBeenCalled(); }); ``` **Fixture files** — create in `apps/api/tests/fixtures/` (new directory): - `weekly-dst.ics` — weekly RRULE spanning March DST (America/New_York) - `allday-birthday.ics` — DATE-type annual event, no DTEND - `exdate-series.ics` — weekly series with one EXDATE --- ### `apps/api/tests/routes/events.test.ts` (test — new file) **Analog:** `apps/api/tests/health.test.ts` **Route test pattern** (lines 1–38 of health.test.ts): ```typescript import { describe, it, expect, vi } from 'vitest'; vi.mock('../src/db/client.js', () => ({ db: { execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), }, })); describe('GET /health', () => { it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => { const { app } = await import('../src/index.js'); const res = await app.request('/health'); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; db: string }; expect(body.ok).toBe(true); }); }); ``` **app.request() pattern for Hono route tests** — use `app.request('/api/events?start=2026-06-01&end=2026-07-01')` following the same import-in-test pattern. --- ### `apps/pwa/vitest.config.ts` (config — new file) **Analog:** `apps/api/vitest.config.ts` ```typescript // Copy this exactly, add jsdom environment for React: import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', // differs from API (node) globals: true, }, }); ``` --- ### `apps/pwa/src/api/client.ts` (utility, request-response — modify existing) **Analog:** self **Existing function pattern to copy** (lines 22–34 of client.ts): ```typescript export async function fetchMe(): Promise { const res = await fetch('/api/me', { credentials: 'include', }); if (!res.ok) { throw new Error(`GET /api/me failed: ${res.status}`); } return res.json() as Promise; } ``` **New `fetchEvents` must follow same shape:** ```typescript // Replace the existing fetchEvents (no-window version) with a windowed version: export interface CalendarOccurrence { /* from shared types */ } export interface OccurrencesResponse { occurrences: CalendarOccurrence[]; } export async function fetchEvents(start: string, end: string): Promise { const res = await fetch(`/api/events?start=${start}&end=${end}`, { credentials: 'include', }); if (!res.ok) { throw new Error(`GET /api/events failed: ${res.status}`); } return res.json() as Promise; } ``` --- ### `apps/pwa/src/lib/hydrateEvents.ts` (utility, transform — new file) **Analog:** `apps/pwa/src/api/client.ts` (typed transform pattern) **Interface definition pattern** (lines 12–16 of client.ts): ```typescript export interface MeUser { id: number; displayName: string | null; color: string; } ``` **Function export pattern** (lines 22–34 of client.ts): ```typescript export async function fetchMe(): Promise { ... } // → hydrateEvents follows same: export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] ``` **Temporal polyfill import** — must be registered before any Temporal usage: ```typescript import 'temporal-polyfill/global'; // registers Temporal on globalThis; import in main.tsx first ``` --- ### `apps/pwa/src/lib/calendarConfig.ts` (utility, transform — new file) **Analog:** `apps/pwa/src/api/client.ts` (typed constants + factory function) **Exported constant pattern** (lines 12–16 of client.ts as reference for typed exports): ```typescript export const WEEK_START_DAY = 0; // 0 = Sunday; Schedule-X uses 7 = Sunday (translate before passing) ``` **Key translation note** — document inline per RESEARCH.md: ```typescript // WEEK_START_DAY=0 (JS/date-fns Sunday) → Schedule-X firstDayOfWeek=7 (Temporal Sunday) const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY; ``` --- ### `apps/pwa/src/lib/colorUtils.ts` (utility, transform — new file) **Analog:** `apps/pwa/src/App.tsx` (ColorSwatch inline style, lines 18–34) **Color inline style pattern to extend** (lines 18–34 of App.tsx): ```typescript function ColorSwatch({ color }: { color: string }) { return ( ) } ``` **Target function signatures:** ```typescript // container = main hex at 15% opacity blended over white export function hexToContainer(hex: string): string; // returns CSS hex or rgba // onContainer = main hex darkened 40% export function hexToOnContainer(hex: string): string; // convenience: all three for Schedule-X lightColors export function deriveScheduleXColors(main: string): { main: string; container: string; onContainer: string; }; ``` --- ### `apps/pwa/src/store/calendarStore.ts` (store, event-driven — new file) **No existing Zustand analog in codebase.** Follow RESEARCH.md state contract: ```typescript // State shape from UI-SPEC § State Management Contract: interface CalendarStore { selectedView: string; // persisted in localStorage per breakpointGroup selectedDate: string; // ISO string; not persisted openEventId: string | null; // null = popover closed calendarRange: { start: string; end: string }; // drives TanStack Query key setSelectedView: (view: string) => void; setSelectedDate: (date: string) => void; setOpenEventId: (id: string | null) => void; setCalendarRange: (range: { start: string; end: string }) => void; } ``` --- ### `apps/pwa/src/components/CalendarShell.tsx` (component, request-response — new file) **Analog:** `apps/pwa/src/App.tsx` **TanStack Query usage pattern** (lines 58–63 of App.tsx): ```typescript const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }); ``` **Events query — extend this pattern:** ```typescript const eventsQuery = useQuery({ queryKey: ['events', start, end], queryFn: () => fetchEvents(start, end), retry: 2, staleTime: 5 * 60 * 1000, }); ``` **Loading/error conditional render pattern** (lines 77–92 of App.tsx): ```typescript {meQuery.isLoading && (
Loading...
)} {meQuery.isError && (
Sign-in required
)} {meQuery.data && ( )} ``` **Component file structure** (App.tsx overall shape): - Inline interfaces at top - Sub-components declared before default export - Default export is the root component - No CSS modules yet — inline styles or className with `var(--token)` strings --- ### `apps/pwa/src/components/EventDetailPopover.tsx` (component — new file) **Analog:** `apps/pwa/src/App.tsx` (MemberBadge component, lines 36–55) **Component prop interface pattern** (lines 36–38 of App.tsx): ```typescript function MemberBadge({ user }: { user: MeUser }) { return (
``` **Target interface:** ```typescript interface EventDetailPopoverProps { eventId: string | null; // null = closed onClose: () => void; // event data resolved from Zustand openEventId → TanStack Query cache lookup } ``` **Accessibility pattern** from UI-SPEC: - Focus trap while open; Escape closes - Close button: `aria-label="Close"`; min 44px touch target - Never use `dangerouslySetInnerHTML` for event title/description (XSS guard) --- ### `apps/pwa/src/components/SkeletonCalendar.tsx` (component — new file) **Analog:** `apps/pwa/src/App.tsx` loading state (lines 77–80) **Loading pattern to replace:** ```typescript {meQuery.isLoading && (
Loading...
)} ``` **Skeleton shimmer approach** — CSS animation, no third-party library: ```css /* In tokens.css or inline: */ @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } /* Apply: background: linear-gradient(90deg, var(--color-surface-dim), var(--color-border-subtle), var(--color-surface-dim)); background-size: 200% 100%; animation: shimmer 1.5s infinite; */ ``` **aria-busy pattern** per UI-SPEC: ```tsx
{/* shimmer placeholders */}
``` --- ### `apps/pwa/src/main.tsx` (config — modify existing) **Analog:** self **Current structure** (lines 1–21 of main.tsx): ```typescript import React from 'react' import ReactDOM from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App.js' const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000, }, }, }) ReactDOM.createRoot(document.getElementById('root')!).render( , ) ``` **Add before all other imports** (Temporal polyfill must be first): ```typescript import 'temporal-polyfill/global'; // registers Temporal on globalThis FIRST import '@schedule-x/theme-default/dist/index.css'; // Schedule-X layout engine CSS import './styles/tokens.css'; // token overrides (must come after SX CSS) ``` --- ## Shared Patterns ### Authentication Guard (all API routes) **Source:** `apps/api/src/index.ts` lines 24–29 ```typescript app.use('/api/*', oidcAuthMiddleware()); app.route('/api/me', meRouter); app.route('/api/events', eventsRouter); ``` **Apply to:** All new/modified route files. Dev bypass mounts before this, not instead. ### Hono Route Error Handling **Source:** `apps/api/src/routes/health.ts` lines 16–26 ```typescript try { await db.execute(sql`SELECT 1`); return c.json({ ok: true, db: 'up' }); } catch (err) { console.error('[health] DB round-trip failed:', err); return c.json({ ok: false, db: 'down' }, 503); } ``` **Apply to:** `routes/events.ts` — wrap the windowed query + expansion in try/catch, return 503 on DB error. ### Drizzle Upsert Pattern **Source:** `apps/api/src/broker/sync.ts` lines 39–56 ```typescript await db .insert(calendars) .values({ ... }) .onDuplicateKeyUpdate({ set: { ... } }) ``` **Apply to:** Any schema migration that adds columns — upsert pattern unchanged. ### D-13 allDay Discrimination **Source:** `apps/api/src/broker/sync.ts` lines 88–98 ```typescript const allDay: boolean = dtstart?.isDate ?? false; // dtstartDate: for all-day, convert YYYY-MM-DD → Date at midnight UTC const dtstartDateValue: Date | null = allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null; const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null; ``` **Apply to:** `broker/expand.ts` — preserve the same discrimination when building CalendarOccurrence output. All-day `start` field must be `'YYYY-MM-DD'` (not a datetime string). Timed `start` must be a timezone-offset ISO string. ### TanStack Query Usage **Source:** `apps/pwa/src/App.tsx` lines 58–70 ```typescript const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }); ``` **Apply to:** All data-fetching components. Events query uses `retry: 2`. Server data never enters Zustand. ### Fetch Client with Credentials **Source:** `apps/pwa/src/api/client.ts` lines 22–34 ```typescript const res = await fetch('/api/me', { credentials: 'include' }); if (!res.ok) { throw new Error(`GET /api/me failed: ${res.status}`); } return res.json() as Promise; ``` **Apply to:** All new `client.ts` functions (`fetchEvents`). The `credentials: 'include'` is required for the OIDC session cookie. ### CSS Token Usage in Components **Source:** `apps/pwa/src/App.tsx` lines 37–55 (inline style approach) ```typescript style={{ background: '#f0f9ff', // ← Phase 1: hardcoded border: `2px solid ${user.color}`, }} ``` **Apply to (Phase 2 rule):** Replace all hardcoded hex/px values with `var(--token-name)` CSS custom properties. The existing App.tsx hardcoded values must also be migrated. No hardcoded colors in any Phase 2 component. --- ## No Analog Found | File | Role | Data Flow | Reason | | ------------------------------------- | ------- | ------------ | ------------------------------------------------------------- | | `apps/pwa/src/styles/tokens.css` | utility | — | No CSS token layer exists; Phase 2 introduces it from scratch | | `apps/pwa/src/styles/tokens.ts` | utility | — | No TypeScript token mirror exists | | `apps/pwa/src/styles/index.css` | utility | — | No global CSS exists; current App.tsx uses inline styles only | | `apps/pwa/src/store/calendarStore.ts` | store | event-driven | No Zustand store exists in codebase yet; first Zustand usage | --- ## Metadata **Analog search scope:** `apps/api/src/`, `apps/api/tests/`, `apps/pwa/src/` **Files read:** 15 **Pattern extraction date:** 2026-06-04