# 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