docs(02): add pattern map
This commit is contained in:
@@ -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<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):
|
||||||
|
```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<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 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<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 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 (
|
||||||
|
<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 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 && (
|
||||||
|
<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):
|
||||||
|
```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 77–80)
|
||||||
|
|
||||||
|
**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 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(
|
||||||
|
<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 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<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)
|
||||||
|
```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
|
||||||
Reference in New Issue
Block a user