# 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 1–112, existing file)

**Imports pattern** (lines 1–12):

```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 86–112) — 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 40–47) — 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 1–32)

**File header and imports pattern** (client.ts lines 1–12):

```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 1–127) — 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 72–86) — 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 89–101) — 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 74–78):

```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 1–85) — closest match, exact role

**File header JSDoc pattern** (poller.ts lines 1–16):

```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 18–25):

```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 35–85):

```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 65–72):

```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 47–53):

```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 1–141, existing file)

**File header invariant comment** (lines 1–15) — 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 17–41):

```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 53–141):

```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 33–44) — 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 380–388, reserved footer)

**Reserved footer (lines 380–388)** — 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 235–251) — 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 283–285) — 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 109–137):

```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 202–221):

```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 143–159):

```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 165–199) — 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 1–106, extend)

**Fetch function pattern** (lines 89–102):

```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 14–74) — 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 1–13, extend existing)

**Existing config** (lines 1–13):

```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 29–49
**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 136–140
**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 29–52
**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 22–26
**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, 111–112
**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 109–110
**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
