Files
familysync/.planning/phases/02-calendar-display/02-PATTERNS.md
T

724 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 84105 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 111 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 18 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 7891 of sync.ts):
```typescript
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 8898 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 7578 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 129 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 1626 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 2426 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 1429 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 114 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 71121 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 176195 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 138 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 2234 of client.ts):
```typescript
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:**
```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<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 1216 of client.ts):
```typescript
export interface MeUser {
id: number
displayName: string | null
color: string
}
```
**Function export pattern** (lines 2234 of client.ts):
```typescript
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:
```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 1216 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 1834)
**Color inline style pattern to extend** (lines 1834 of App.tsx):
```typescript
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:**
```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 5863 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 7792 of App.tsx):
```typescript
{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 3655)
**Component prop interface pattern** (lines 3638 of App.tsx):
```typescript
function MemberBadge({ user }: { user: MeUser }) {
return (
<div style={{ display: 'flex', alignItems: 'center', ... }}>
```
**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 7780)
**Loading pattern to replace:**
```typescript
{meQuery.isLoading && (
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
)}
```
**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
<div aria-busy="true" aria-label="Loading calendar">
{/* shimmer placeholders */}
</div>
```
---
### `apps/pwa/src/main.tsx` (config — modify existing)
**Analog:** self
**Current structure** (lines 121 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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
```
**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 2429
```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 1626
```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 3956
```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 8898
```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 5870
```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 2234
```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<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 3755 (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