567 lines
19 KiB
Markdown
567 lines
19 KiB
Markdown
# 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
|