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.
20 KiB
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):
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:
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 40–47) — 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 1–32)
File header and imports pattern (client.ts lines 1–12):
/**
* [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 1–127) — 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 72–86) — 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 89–101) — 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 74–78):
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):
/**
* 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):
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):
// 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):
} 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):
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 1–141, existing file)
File header invariant comment (lines 1–15) — 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 17–41):
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):
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:
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:
{
/* 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:
<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:
{
/* 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):
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):
<>
{/* 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):
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):
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
useEffectfor 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):
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 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):
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
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
} 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
// 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
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
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
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