docs(03): finalize phase plan (8 plans, verified)

This commit is contained in:
Lucas Berger
2026-06-05 17:08:16 -04:00
parent 9dd08d28d1
commit 93302cf942
7 changed files with 696 additions and 57 deletions
@@ -0,0 +1,566 @@
# Phase 3: Event Write-Back + PWA Install - Pattern Map
**Mapped:** 2026-06-05
**Files analyzed:** 12 new/modified files
**Analogs found:** 10 / 12
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/db/schema.ts` | model | CRUD | `apps/api/src/db/schema.ts` (extend existing) | exact |
| `apps/api/src/broker/write.ts` | service | request-response | `apps/api/src/broker/client.ts` | role-match |
| `apps/api/src/broker/vevent.ts` | utility | transform | `apps/api/src/broker/sync.ts` (ical.js usage) | role-match |
| `apps/api/src/broker/outboxWorker.ts` | service | batch | `apps/api/src/broker/poller.ts` | exact |
| `apps/api/src/routes/events.ts` | route | request-response | `apps/api/src/routes/events.ts` (extend existing) | exact |
| `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` (extend) | exact |
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` | role-match |
| `apps/pwa/src/components/InstallPrompt.tsx` | component | event-driven | `apps/pwa/src/components/EmptyState.tsx` | partial |
| `apps/pwa/src/api/client.ts` | utility | request-response | `apps/pwa/src/api/client.ts` (extend existing) | exact |
| `apps/pwa/vite.config.ts` | config | — | `apps/pwa/vite.config.ts` (extend existing) | exact |
| `apps/api/tests/broker/outboxWorker.test.ts` | test | batch | `apps/api/tests/broker/sync.test.ts` | role-match |
| `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/routes/events.test.ts` (extend) | exact |
---
## Pattern Assignments
### `apps/api/src/db/schema.ts` — add `calendarOutbox` table + `objectUrl` column on `calendarEvents`
**Analog:** `apps/api/src/db/schema.ts` (lines 1112, existing file)
**Imports pattern** (lines 112):
```typescript
import {
mysqlTable,
varchar,
text,
int,
date,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
```
Add `mysqlEnum` to the import list — already used in the research pattern but not yet in schema.ts.
**Existing table pattern** (lines 86112) — copy this structure for `calendarOutbox`:
```typescript
export const calendarEvents = mysqlTable(
'calendar_events',
{
id: int().primaryKey().autoincrement(),
calendarId: int('calendar_id')
.notNull()
.references(() => calendars.id, { onDelete: 'cascade' }),
uid: varchar('uid', { length: 512 }).notNull(),
etag: varchar('etag', { length: 256 }),
// ...
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
],
)
```
**New column on `calendarEvents`** — add `objectUrl` after `etag`:
```typescript
objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated by sync.ts from obj.url
```
**References pattern** (lines 4047) — copy for `calendarOutbox.userId`:
```typescript
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
```
---
### `apps/api/src/broker/write.ts` — new file, tsdav PUT/DELETE wrapper
**Analog:** `apps/api/src/broker/client.ts` (lines 132)
**File header and imports pattern** (client.ts lines 112):
```typescript
/**
* [JSDoc comment with source citations]
* Source: https://...
*/
import { createDAVClient } from 'tsdav'
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>
```
**Export pattern** — named exports, no default (matches all broker files):
```typescript
import type { FastmailClient } from './client.js'
import type { DAVCalendar } from 'tsdav'
export async function createCalendarEvent(...): Promise<Response> { ... }
export async function updateCalendarEvent(...): Promise<Response> { ... }
export async function deleteCalendarEvent(...): Promise<Response> { ... }
```
**Import extension `.js`** — all broker imports use `.js` suffix (e.g., `'./client.js'`, `'../db/client.js'`). Required for ESM with TypeScript.
---
### `apps/api/src/broker/vevent.ts` — new file, ical.js VEVENT builder
**Analog:** `apps/api/src/broker/sync.ts` (lines 1127) — existing ical.js usage
**ical.js import pattern** (sync.ts line 20):
```typescript
import ICAL from 'ical.js'
```
**ical.js parse → component pattern** (sync.ts lines 7286) — the reverse direction (build vs parse) uses the same ICAL.Component/ICAL.Time API:
```typescript
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
```
**D-13 all-day vs timed split** (sync.ts lines 89101) — must mirror this exact split in the builder:
```typescript
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
const allDay: boolean = dtstart?.isDate ?? false
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
```
**Error isolation pattern** (sync.ts lines 7478):
```typescript
try {
parsed = ICAL.parse(obj.data as string)
} catch {
// Malformed VCALENDAR — skip but do not crash the sync
continue
}
```
---
### `apps/api/src/broker/outboxWorker.ts` — new file, outbox drain loop
**Analog:** `apps/api/src/broker/poller.ts` (lines 185) — closest match, exact role
**File header JSDoc pattern** (poller.ts lines 116):
```typescript
/**
* CalDAV broker poller — runs every 5 minutes via node-cron.
*
* Responsibilities (D-13, D-02):
* - ...
*
* runPoll is exported for unit testing (inject mocks via vi.mock at the module level).
* startBrokerPoller wraps it in node-cron's 5-minute schedule.
*
* Source: https://github.com/node-cron/node-cron (v4 stable basic API)
*/
```
**Imports pattern** (poller.ts lines 1825):
```typescript
import { schedule } from 'node-cron'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberCredentials, calendars } from '../db/schema.js'
import { decryptPassword } from './crypto.js'
import { createFastmailClient } from './client.js'
import { syncCalendar } from './sync.js'
```
Replace with: `and`, `lte`, `eq` from `drizzle-orm`; `calendarOutbox`, `calendars` from schema; `syncCalendar` from `./sync.js`; write functions from `./write.js`.
**Exported runX + startX pair pattern** (poller.ts lines 3585):
```typescript
// runPoll exported for unit testing
export async function runPoll(): Promise<void> { ... }
// startBrokerPoller wraps it in a schedule
export function startBrokerPoller(): void {
schedule('*/5 * * * *', () => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err)
})
})
}
```
Outbox worker follows: `export async function runOutboxDrain()` + `export function startOutboxWorker()`.
**Per-item error isolation pattern** (poller.ts lines 6572):
```typescript
} catch (err) {
// Log the error but do NOT log the app password or key (T-03-04)
console.error(
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
err instanceof Error ? err.message : String(err),
)
}
```
**Drizzle select + where + limit pattern** (poller.ts lines 4753):
```typescript
const [stored] = await db
.select()
.from(calendars)
.where(eq(calendars.url, davCal.url))
.limit(1)
```
**Drizzle update pattern** — extend from sync.ts `onDuplicateKeyUpdate` shape:
```typescript
await db.update(calendarOutbox)
.set({ status: 'done' })
.where(eq(calendarOutbox.id, row.id))
```
---
### `apps/api/src/routes/events.ts` — extend with write endpoints + sync-status
**Analog:** `apps/api/src/routes/events.ts` (lines 1141, existing file)
**File header invariant comment** (lines 115) — copy verbatim and extend:
```typescript
/**
* Architecture invariant (T-03-02, broker-boundary):
* This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly.
* All Fastmail I/O is owned exclusively by the broker module (src/broker/).
* No tsdav import here; no createFastmailClient import here.
*/
```
**Hono router + zValidator pattern** (lines 1741):
```typescript
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq, lte, lt } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendars, users } from '../db/schema.js'
export const eventsRouter = new Hono()
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
```
**Route handler + zValidator + try/catch error pattern** (lines 53141):
```typescript
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// ... input validation ...
try {
const rows = await db.select(...).from(...).where(...)
return c.json({ occurrences: allOccurrences })
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
})
```
New write endpoints follow the same shape: `eventsRouter.post('/create', zValidator('json', createSchema), async (c) => { ... })`.
**Auth identity pattern** (from me.ts lines 3344) — write endpoints need current user:
```typescript
const devUser = c.get('user')
if (devUser) {
// dev bypass path
}
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
}
```
---
### `apps/pwa/src/components/EventDetailPopover.tsx` — add edit/delete to reserved footer
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (lines 380388, reserved footer)
**Reserved footer (lines 380388)** — Phase 3 wires buttons here:
```tsx
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
<div
aria-hidden="true"
style={{
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
marginTop: 'var(--space-4)',
}}
/>
```
Replace with real content. Remove `aria-hidden="true"`.
**Button style pattern** (lines 235251) — copy close button style for action buttons:
```tsx
<button
aria-label="Close"
onClick={handleClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: 'var(--color-text-secondary)',
borderRadius: 'var(--space-1)',
padding: 0,
}}
>
```
**Design token usage** — all spacing/color uses CSS vars (not hardcoded values):
- `var(--color-surface-raised)`, `var(--color-text-primary)`, `var(--color-text-secondary)`, `var(--color-border-subtle)`
- `var(--space-2)`, `var(--space-3)`, `var(--space-4)`, `var(--space-6)`
- `var(--text-body-size)`, `var(--text-heading-size)`, `var(--font-family-base)`
**XSS guard pattern** (T-02e-01, lines 283285) — all text content as plain JSX children:
```tsx
{/* Plain text child only — XSS guard (T-02e-01) */}
{occurrence.title}
```
EventForm must follow this: all field values rendered as plain-text children, never `dangerouslySetInnerHTML`.
**Zustand + TanStack Query pattern** (lines 109137):
```tsx
const { openEventId, setOpenEventId } = useCalendarStore()
const queryClient = useQueryClient()
// Read from TanStack Query cache — do not store server data in Zustand
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
queryKey: ['events'],
})
```
---
### `apps/pwa/src/components/EventForm.tsx` — new file, create/edit form
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (role-match — same overlay surface)
**Modal/overlay structure** — copy the backdrop + dialog pattern from EventDetailPopover (lines 202221):
```tsx
<>
{/* Backdrop */}
<div
data-testid="popover-backdrop"
onClick={handleClose}
style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay)', zIndex: 199 }}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="..."
tabIndex={-1}
style={dialogStyle}
>
```
**Escape + focus trap useEffect pattern** (lines 143159):
```tsx
useEffect(() => {
if (!activeId) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [activeId])
useEffect(() => {
if (activeId && dialogRef.current) dialogRef.current.focus()
}, [activeId])
```
**Responsive phone/desktop detection** (lines 165199) — copy the `isPhone` / `dialogStyle` pattern.
**TanStack Query mutation pattern** — use `useMutation` from `@tanstack/react-query` (same import, already in stack):
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
// On success: queryClient.invalidateQueries({ queryKey: ['events'] })
```
---
### `apps/pwa/src/components/InstallPrompt.tsx` — new file, iOS/Android install
**Analog:** `apps/pwa/src/components/EmptyState.tsx` (partial — informational UI surface)
No close analog. Use the design token and component conventions from EventDetailPopover:
- CSS vars for all spacing/color
- Plain-text JSX children (no dangerouslySetInnerHTML)
- 44px minimum touch targets on all buttons
- `useEffect` for event listener cleanup (same pattern as popover Escape handler)
**Standalone detection** — no existing analog; use RESEARCH.md Pattern 6 directly.
---
### `apps/pwa/src/api/client.ts` — add write calls + sync-status poll
**Analog:** `apps/pwa/src/api/client.ts` (lines 1106, extend)
**Fetch function pattern** (lines 89102):
```typescript
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>
}
```
New write functions follow the same shape. POST/PATCH/DELETE calls:
```typescript
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
const res = await fetch('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`POST /api/events/create failed: ${res.status}`)
return res.json() as Promise<CreateEventResponse>
}
```
**Interface-first pattern** (lines 1474) — define TypeScript interfaces before the fetch functions. All request/response shapes declared as exported interfaces.
---
### `apps/pwa/vite.config.ts` — add VitePWA plugin
**Analog:** `apps/pwa/vite.config.ts` (lines 113, extend existing)
**Existing config** (lines 113):
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/health': 'http://localhost:3000',
'/api': 'http://localhost:3000',
'/callback': 'http://localhost:3000',
},
},
})
```
Keep the proxy block exactly as-is. Add `VitePWA` to `plugins` array. The `/callback` proxy entry is critical — it must remain so the SW denylist matches the actual handler.
---
## Shared Patterns
### Auth guard in write route handlers
**Source:** `apps/api/src/routes/me.ts` lines 2949
**Apply to:** All new POST/PATCH/DELETE handlers in `routes/events.ts`
```typescript
const devUser = c.get('user')
if (devUser) {
// dev bypass — use devUser.id as userId
}
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
}
```
Also import `'../auth/devBypass.js'` as a side-effect to get the ContextVariableMap augmentation (see me.ts line 25).
### Error handling in route handlers
**Source:** `apps/api/src/routes/events.ts` lines 136140
**Apply to:** All route handlers
```typescript
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
```
Use consistent `[module/file] description:` log prefix format.
### ESM import extension
**Source:** All existing broker and route files
**Apply to:** All new TypeScript files
All project imports use `.js` extension suffix on relative imports:
`'./client.js'`, `'../db/client.js'`, `'../db/schema.js'`, `'./sync.js'`
### Drizzle DB mock in tests
**Source:** `apps/api/tests/routes/events.test.ts` lines 2952
**Apply to:** `outboxWorker.test.ts`, extended `events.test.ts`
```typescript
// Chain of mocks matching the Drizzle query builder
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
const mockFromFn = vi.fn().mockReturnValue({ where: mockWhereFn })
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
vi.mock('../../src/db/client.js', () => ({
db: { select: mockSelectFn, insert: mockInsert, update: mockUpdate },
}))
```
### OIDC mock in tests
**Source:** `apps/api/tests/routes/events.test.ts` lines 2226
**Apply to:** All new route tests
```typescript
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
getAuth: () => null,
}))
```
### TanStack Query integration in React components
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 26, 111112
**Apply to:** `EventForm.tsx`, `InstallPrompt.tsx`
```tsx
import { useQueryClient } from '@tanstack/react-query'
// ...
const queryClient = useQueryClient()
// On write success: invalidate events cache
queryClient.invalidateQueries({ queryKey: ['events'] })
```
### Zustand UI state (not server state)
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 109110
**Apply to:** `EventForm.tsx`
```tsx
const { openEventId, setOpenEventId } = useCalendarStore()
```
EventForm visibility/mode (create vs edit) is UI state → Zustand. Event data is server state → TanStack Query.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/pwa/src/components/InstallPrompt.tsx` (iOS walkthrough) | component | event-driven | No precedent for install-prompt or browser-API-driven components in codebase |
---
## Metadata
**Analog search scope:** `apps/api/src/`, `apps/pwa/src/`, `apps/api/tests/`
**Files scanned:** 14 source files read
**Pattern extraction date:** 2026-06-05