Files
familysync/.planning/milestones/v1.0-phases/03-event-write-back-pwa-install/03-PATTERNS.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

20 KiB
Raw Blame History

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):

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:

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:

objectUrl: varchar('object_url', { length: 1024 }),  // CalDAV object URL; populated by sync.ts from obj.url

References pattern (lines 4047) — copy for calendarOutbox.userId:

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):

/**
 * [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):

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):

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:

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:

// 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):

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):

/**
 * 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):

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):

// 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):

} 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):

const [stored] = await db.select().from(calendars).where(eq(calendars.url, davCal.url)).limit(1);

Drizzle update pattern — extend from sync.ts onDuplicateKeyUpdate shape:

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:

/**
 * 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):

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):

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:

const devUser = c.get('user');
if (devUser) {
  // dev bypass path
}
const auth = await getAuth(c);
if (!auth) {
  return c.json({ error: 'Unauthorized' }, 401);
}

Analog: apps/pwa/src/components/EventDetailPopover.tsx (lines 380388, reserved footer)

Reserved footer (lines 380388) — Phase 3 wires buttons here:

{
  /* 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:

<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:

{
  /* 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):

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):

<>
  {/* 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):

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):

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):

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:

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):

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

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

} 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

// 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

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

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

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