Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
T

567 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 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