diff --git a/.planning/phases/02-calendar-display/02-PATTERNS.md b/.planning/phases/02-calendar-display/02-PATTERNS.md new file mode 100644 index 0000000..a484a4f --- /dev/null +++ b/.planning/phases/02-calendar-display/02-PATTERNS.md @@ -0,0 +1,723 @@ +# 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