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

22 KiB
Raw Blame History

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):

// 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):

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):

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):

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):

// 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):

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 129 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 1626 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 2426 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 1429 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 114 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 71121 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 176195 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 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):

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 2234 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 1216 of client.ts):

export interface MeUser {
  id: number
  displayName: string | null
  color: string
}

Function export pattern (lines 2234 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 1216 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 1834)

Color inline style pattern to extend (lines 1834 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 5863 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 7792 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 3655)

Component prop interface pattern (lines 3638 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 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:

{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 121 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 2429

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

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

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

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

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

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)

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