724 lines
22 KiB
Markdown
724 lines
22 KiB
Markdown
# 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
|