docs(03): finalize phase plan (8 plans, verified)
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user