22 KiB
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):
// 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):
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):
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):
let parsed: ReturnType<typeof ICAL.parse>
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):
// 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):
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):
// 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):
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):
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):
// 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):
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):
// 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):
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):
// 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:
// 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):
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):
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):
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 DTENDexdate-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):
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
// 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):
export async function fetchMe(): Promise<MeResponse> {
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<MeResponse>
}
New fetchEvents must follow same shape:
// 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<OccurrencesResponse> {
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<OccurrencesResponse>
}
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):
export interface MeUser {
id: number
displayName: string | null
color: string
}
Function export pattern (lines 22–34 of client.ts):
export async function fetchMe(): Promise<MeResponse> { ... }
// → hydrateEvents follows same: export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[]
Temporal polyfill import — must be registered before any Temporal usage:
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):
export const WEEK_START_DAY = 0 // 0 = Sunday; Schedule-X uses 7 = Sunday (translate before passing)
Key translation note — document inline per RESEARCH.md:
// 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):
function ColorSwatch({ color }: { color: string }) {
return (
<span
style={{
background: color,
border: '1px solid rgba(0,0,0,0.1)',
}}
aria-label={`Color: ${color}`}
/>
)
}
Target function signatures:
// 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:
// 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):
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
})
Events query — extend this pattern:
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):
{meQuery.isLoading && (
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
)}
{meQuery.isError && (
<div style={{ color: '#991b1b', ... }}>Sign-in required</div>
)}
{meQuery.data && (
<MemberBadge user={meQuery.data.user} />
)}
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):
function MemberBadge({ user }: { user: MeUser }) {
return (
<div style={{ display: 'flex', alignItems: 'center', ... }}>
Target interface:
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
dangerouslySetInnerHTMLfor 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:
{meQuery.isLoading && (
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
)}
Skeleton shimmer approach — CSS animation, no third-party library:
/* 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:
<div aria-busy="true" aria-label="Loading calendar">
{/* shimmer placeholders */}
</div>
apps/pwa/src/main.tsx (config — modify existing)
Analog: self
Current structure (lines 1–21 of main.tsx):
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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
Add before all other imports (Temporal polyfill must be first):
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
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
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
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
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
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
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<MeResponse>
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)
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