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.
This commit is contained in:
@@ -8,27 +8,27 @@
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `apps/api/src/db/schema.ts` | model (modify) | CRUD | self | exact |
|
||||
| `apps/api/src/db/migrations/0002_lists_schema.sql` | migration | batch | `0001_calendars_user_url_unique.sql` | role-match |
|
||||
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | none in codebase | no analog |
|
||||
| `apps/api/src/routes/lists.ts` | route/controller | CRUD | `apps/api/src/routes/events.ts` | exact |
|
||||
| `apps/api/src/routes/sse.ts` | route (modify) | streaming | self | exact |
|
||||
| `apps/api/src/index.ts` | config (modify) | request-response | self | exact |
|
||||
| `apps/pwa/src/App.tsx` | component (modify) | request-response | self | exact |
|
||||
| `apps/pwa/src/components/BottomTabBar.tsx` | component | request-response | `apps/pwa/src/components/AppNav.tsx` | role-match |
|
||||
| `apps/pwa/src/routes/ListsIndex.tsx` | component | CRUD | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
|
||||
| `apps/pwa/src/routes/ListDetail.tsx` | component | CRUD + event-driven | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
|
||||
| `apps/pwa/src/components/ListCard.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/ItemRow.tsx` | component | event-driven | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/AddItemInput.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/CreateListSheet.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | none — novel | no analog |
|
||||
| `apps/pwa/src/components/ListsEmptyState.tsx` | component | request-response | `apps/pwa/src/components/SkeletonCalendar.tsx` | role-match |
|
||||
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | none in codebase | no analog |
|
||||
| `apps/pwa/src/api/listsClient.ts` | utility | request-response | `apps/pwa/src/api/client.ts` | exact |
|
||||
| `apps/pwa/src/store/listsStore.ts` | store | request-response | `apps/pwa/src/store/calendarStore.ts` | exact |
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
| -------------------------------------------------- | ------------------ | ------------------- | ------------------------------------------------------ | ------------- |
|
||||
| `apps/api/src/db/schema.ts` | model (modify) | CRUD | self | exact |
|
||||
| `apps/api/src/db/migrations/0002_lists_schema.sql` | migration | batch | `0001_calendars_user_url_unique.sql` | role-match |
|
||||
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | none in codebase | no analog |
|
||||
| `apps/api/src/routes/lists.ts` | route/controller | CRUD | `apps/api/src/routes/events.ts` | exact |
|
||||
| `apps/api/src/routes/sse.ts` | route (modify) | streaming | self | exact |
|
||||
| `apps/api/src/index.ts` | config (modify) | request-response | self | exact |
|
||||
| `apps/pwa/src/App.tsx` | component (modify) | request-response | self | exact |
|
||||
| `apps/pwa/src/components/BottomTabBar.tsx` | component | request-response | `apps/pwa/src/components/AppNav.tsx` | role-match |
|
||||
| `apps/pwa/src/routes/ListsIndex.tsx` | component | CRUD | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
|
||||
| `apps/pwa/src/routes/ListDetail.tsx` | component | CRUD + event-driven | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
|
||||
| `apps/pwa/src/components/ListCard.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/ItemRow.tsx` | component | event-driven | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/AddItemInput.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/CreateListSheet.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
|
||||
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | none — novel | no analog |
|
||||
| `apps/pwa/src/components/ListsEmptyState.tsx` | component | request-response | `apps/pwa/src/components/SkeletonCalendar.tsx` | role-match |
|
||||
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | none in codebase | no analog |
|
||||
| `apps/pwa/src/api/listsClient.ts` | utility | request-response | `apps/pwa/src/api/client.ts` | exact |
|
||||
| `apps/pwa/src/store/listsStore.ts` | store | request-response | `apps/pwa/src/store/calendarStore.ts` | exact |
|
||||
|
||||
---
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
**Analog:** self — read `apps/api/src/db/schema.ts` lines 1–163 in full above.
|
||||
|
||||
**Imports pattern** (lines 1–13):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
mysqlTable,
|
||||
@@ -48,11 +49,13 @@ import {
|
||||
boolean,
|
||||
index,
|
||||
unique,
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
```
|
||||
|
||||
Note: `mysqlEnum` is imported for `calendarOutbox` but is not needed for list tables. Import only what the new tables use.
|
||||
|
||||
**Table definition pattern** (lines 40–54, `memberCredentials` — simplest table with FK):
|
||||
|
||||
```typescript
|
||||
export const memberCredentials = mysqlTable(
|
||||
'member_credentials',
|
||||
@@ -66,18 +69,20 @@ export const memberCredentials = mysqlTable(
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
},
|
||||
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Composite unique key pattern** (lines 61–86, `calendars`):
|
||||
|
||||
```typescript
|
||||
(t) => [
|
||||
index('idx_calendars_user_id').on(t.userId),
|
||||
unique('uniq_calendar_user_url').on(t.userId, t.url),
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
**New tables to append** — follow the schema from RESEARCH.md §Database Schema Design exactly:
|
||||
|
||||
- `lists` — int PK, `owner_id` FK to `users`, `name varchar(255)`, `is_shared boolean DEFAULT true`, `created_at`, `updated_at`; index on `owner_id`
|
||||
- `listShares` — int PK, `list_id` FK to `lists` cascade, `user_id` FK to `users` cascade, `created_at`; unique on `(list_id, user_id)`, index on `user_id`
|
||||
- `listItems` — int PK, `list_id` FK to `lists` cascade, `text varchar(500)`, `checked boolean DEFAULT false`, `rank varchar(255)`, `created_at`, `updated_at`; composite index on `(list_id, rank)`, index on `(list_id, checked)`
|
||||
@@ -89,6 +94,7 @@ export const memberCredentials = mysqlTable(
|
||||
**Analog:** `apps/api/src/routes/events.ts` (full file above)
|
||||
|
||||
**File header doc-block pattern** (lines 1–22 of events.ts):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Lists router — list + item CRUD with SSE fan-out trigger.
|
||||
@@ -104,33 +110,35 @@ export const memberCredentials = mysqlTable(
|
||||
```
|
||||
|
||||
**Imports pattern** (lines 24–37 of events.ts):
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, or, eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { lists, listItems, listShares, users } from '../db/schema.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { publishListEvent } from '../lib/listEmitter.js'
|
||||
import '../auth/devBypass.js'
|
||||
|
||||
export const listsRouter = new Hono()
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, or, eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { lists, listItems, listShares, users } from '../db/schema.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { publishListEvent } from '../lib/listEmitter.js';
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const listsRouter = new Hono();
|
||||
```
|
||||
|
||||
**`resolveUserId` helper** — copy verbatim from `events.ts` lines 59–76. This function is duplicated per router (not extracted to a shared module) — maintain that pattern.
|
||||
|
||||
**Zod schema pattern** (lines 82–105 of events.ts):
|
||||
|
||||
```typescript
|
||||
const createListSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
isShared: z.boolean().default(true),
|
||||
})
|
||||
});
|
||||
|
||||
const createItemSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
})
|
||||
});
|
||||
|
||||
// Per-field PATCH — enforce exactly one field per D-08
|
||||
const patchItemSchema = z
|
||||
@@ -142,53 +150,60 @@ const patchItemSchema = z
|
||||
.partial()
|
||||
.refine((obj) => Object.keys(obj).length === 1, {
|
||||
message: 'PATCH must update exactly one field',
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Route handler pattern** — GET with auth + access check + try/catch (lines 122–221 of events.ts):
|
||||
|
||||
```typescript
|
||||
listsRouter.get('/', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
// SELECT lists WHERE owner_id = ? OR id IN (SELECT list_id FROM list_shares WHERE user_id = ?)
|
||||
const rows = await db
|
||||
.select({ /* ... */ })
|
||||
.select({
|
||||
/* ... */
|
||||
})
|
||||
.from(lists)
|
||||
.where(or(eq(lists.ownerId, currentUserId), /* join with listShares */ ))
|
||||
.where(or(eq(lists.ownerId, currentUserId) /* join with listShares */));
|
||||
|
||||
return c.json({ lists: rows })
|
||||
return c.json({ lists: rows });
|
||||
} catch (err) {
|
||||
console.error('[lists] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Fan-out trigger pattern** — call after every successful write:
|
||||
|
||||
```typescript
|
||||
// After insert/update/delete succeeds:
|
||||
publishListEvent(listId, { type: 'item:added', listId, payload: newItem })
|
||||
publishListEvent(listId, { type: 'item:added', listId, payload: newItem });
|
||||
```
|
||||
|
||||
**Ownership verification pattern** (lines 326–339 of events.ts):
|
||||
|
||||
```typescript
|
||||
// Verify list access before any item mutation
|
||||
const [listRow] = await db
|
||||
.select({ ownerId: lists.ownerId })
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.where(eq(lists.id, listId));
|
||||
|
||||
if (!listRow) return c.json({ error: 'Not found' }, 404)
|
||||
if (!listRow) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const isOwner = listRow.ownerId === currentUserId
|
||||
const [shareRow] = isOwner ? [{}] : await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(and(eq(listShares.listId, listId), eq(listShares.userId, currentUserId)))
|
||||
const isOwner = listRow.ownerId === currentUserId;
|
||||
const [shareRow] = isOwner
|
||||
? [{}]
|
||||
: await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(and(eq(listShares.listId, listId), eq(listShares.userId, currentUserId)));
|
||||
|
||||
if (!isOwner && !shareRow) return c.json({ error: 'Access denied' }, 403)
|
||||
if (!isOwner && !shareRow) return c.json({ error: 'Access denied' }, 403);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -198,23 +213,25 @@ if (!isOwner && !shareRow) return c.json({ error: 'Access denied' }, 403)
|
||||
**Analog:** self — `apps/api/src/routes/sse.ts` lines 1–40 (full file above).
|
||||
|
||||
**Core streamSSE pattern** (lines 28–40):
|
||||
|
||||
```typescript
|
||||
sseRouter.get('/heartbeat', (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
let id = 0
|
||||
let id = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString(), id }),
|
||||
event: 'heartbeat',
|
||||
id: String(id++),
|
||||
})
|
||||
await stream.sleep(10_000)
|
||||
});
|
||||
await stream.sleep(10_000);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**New `/lists` endpoint** extends this with:
|
||||
|
||||
- `resolveUserId(c)` call first → 401 on null (same as events.ts pattern)
|
||||
- `getAccessibleListIds(userId)` DB query before `streamSSE` call
|
||||
- `subscribeListEvents(listId, handler)` loop inside `streamSSE`
|
||||
@@ -223,39 +240,39 @@ sseRouter.get('/heartbeat', (c) => {
|
||||
|
||||
```typescript
|
||||
sseRouter.get('/lists', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const accessibleListIds = await getAccessibleListIds(userId)
|
||||
const accessibleListIds = await getAccessibleListIds(userId);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribers: Array<() => void> = []
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
for (const listId of accessibleListIds) {
|
||||
const unsub = subscribeListEvents(listId, async (event) => {
|
||||
if (stream.aborted) return
|
||||
if (stream.aborted) return;
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
})
|
||||
})
|
||||
unsubscribers.push(unsub)
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
let tick = 0
|
||||
let tick = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString() }),
|
||||
event: 'heartbeat',
|
||||
id: String(tick++),
|
||||
})
|
||||
await stream.sleep(30_000)
|
||||
});
|
||||
await stream.sleep(30_000);
|
||||
}
|
||||
|
||||
unsubscribers.forEach((unsub) => unsub())
|
||||
})
|
||||
})
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
@@ -265,28 +282,28 @@ sseRouter.get('/lists', async (c) => {
|
||||
**No codebase analog** — this is new. Use the pattern from RESEARCH.md Finding 1 verbatim:
|
||||
|
||||
```typescript
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
emitter.setMaxListeners(200)
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(200);
|
||||
|
||||
export type ListEvent = {
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
|
||||
listId: number
|
||||
payload: unknown
|
||||
}
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted';
|
||||
listId: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export function publishListEvent(listId: number, event: ListEvent): void {
|
||||
emitter.emit(`list:${listId}`, event)
|
||||
emitter.emit(`list:${listId}`, event);
|
||||
}
|
||||
|
||||
export function subscribeListEvents(
|
||||
listId: number,
|
||||
handler: (event: ListEvent) => void,
|
||||
): () => void {
|
||||
const channel = `list:${listId}`
|
||||
emitter.on(channel, handler)
|
||||
return () => emitter.off(channel, handler)
|
||||
const channel = `list:${listId}`;
|
||||
emitter.on(channel, handler);
|
||||
return () => emitter.off(channel, handler);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -297,24 +314,27 @@ export function subscribeListEvents(
|
||||
**Analog:** self — lines 1–83 (full file above).
|
||||
|
||||
**Route mount pattern** (lines 59–61):
|
||||
|
||||
```typescript
|
||||
app.route('/api/me', meRouter)
|
||||
app.route('/api/events', eventsRouter)
|
||||
app.route('/api/sse', sseRouter)
|
||||
app.route('/api/me', meRouter);
|
||||
app.route('/api/events', eventsRouter);
|
||||
app.route('/api/sse', sseRouter);
|
||||
```
|
||||
|
||||
**Add after `sseRouter` mount:**
|
||||
|
||||
```typescript
|
||||
import { listsRouter } from './routes/lists.js'
|
||||
import { listsRouter } from './routes/lists.js';
|
||||
// ...
|
||||
app.route('/api/lists', listsRouter)
|
||||
app.route('/api/lists', listsRouter);
|
||||
```
|
||||
|
||||
**Auto-migrate pattern** — add before `startBrokerPoller()` (line 65):
|
||||
|
||||
```typescript
|
||||
import { migrate } from 'drizzle-orm/mysql2/migrator'
|
||||
import { migrate } from 'drizzle-orm/mysql2/migrator';
|
||||
// ...
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' })
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
```
|
||||
|
||||
---
|
||||
@@ -324,19 +344,20 @@ await migrate(db, { migrationsFolder: './src/db/migrations' })
|
||||
**Analog:** self — lines 1–5 (full file above). Currently a one-liner.
|
||||
|
||||
**Transform to** (pattern from RESEARCH.md Finding 5):
|
||||
|
||||
```tsx
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||
import { CalendarShell } from './components/CalendarShell.js'
|
||||
import { ListsIndex } from './routes/ListsIndex.js'
|
||||
import { ListDetail } from './routes/ListDetail.js'
|
||||
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||
import { CalendarShell } from './components/CalendarShell.js';
|
||||
import { ListsIndex } from './routes/ListsIndex.js';
|
||||
import { ListDetail } from './routes/ListDetail.js';
|
||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
@@ -350,7 +371,7 @@ function AppShell() {
|
||||
</Routes>
|
||||
<BottomTabBar />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -361,9 +382,10 @@ function AppShell() {
|
||||
**Analog:** `apps/pwa/src/components/AppNav.tsx` (nav component with active-state links — check that file if needed for CSS token conventions)
|
||||
|
||||
**Key pattern** — NavLink with isActive callback (from RESEARCH.md Finding 5):
|
||||
|
||||
```tsx
|
||||
import { NavLink } from 'react-router'
|
||||
import { CalendarDays, List } from 'lucide-react'
|
||||
import { NavLink } from 'react-router';
|
||||
import { CalendarDays, List } from 'lucide-react';
|
||||
|
||||
export function BottomTabBar() {
|
||||
return (
|
||||
@@ -380,22 +402,16 @@ export function BottomTabBar() {
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<NavLink
|
||||
to="/calendar"
|
||||
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
|
||||
>
|
||||
<NavLink to="/calendar" className={({ isActive }) => (isActive ? 'tab tab--active' : 'tab')}>
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<span>Calendar</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/lists"
|
||||
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
|
||||
>
|
||||
<NavLink to="/lists" className={({ isActive }) => (isActive ? 'tab tab--active' : 'tab')}>
|
||||
<List size={22} aria-hidden="true" />
|
||||
<span>Lists</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -408,47 +424,52 @@ CSS tokens: use `var(--color-surface)`, `var(--color-border)`, `var(--color-text
|
||||
**Analog:** `apps/pwa/src/api/client.ts` lines 1–53 (full pattern above).
|
||||
|
||||
**Imports + credential pattern**:
|
||||
|
||||
```typescript
|
||||
// credentials: 'include' on every fetch — session cookie required (same as client.ts)
|
||||
const BASE = '/api'
|
||||
const BASE = '/api';
|
||||
|
||||
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
|
||||
return res
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
|
||||
return res;
|
||||
}
|
||||
```
|
||||
|
||||
**Type + function pattern** (mirrors client.ts):
|
||||
|
||||
```typescript
|
||||
export interface List {
|
||||
id: number
|
||||
name: string
|
||||
isShared: boolean
|
||||
ownerId: number
|
||||
id: number;
|
||||
name: string;
|
||||
isShared: boolean;
|
||||
ownerId: number;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: number
|
||||
listId: number
|
||||
text: string
|
||||
checked: boolean
|
||||
rank: string
|
||||
id: number;
|
||||
listId: number;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
rank: string;
|
||||
}
|
||||
|
||||
export async function fetchLists(): Promise<{ lists: List[] }> {
|
||||
return apiFetch('/lists').then((r) => r.json())
|
||||
return apiFetch('/lists').then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function createList(payload: { name: string; isShared: boolean }): Promise<{ id: number }> {
|
||||
export async function createList(payload: {
|
||||
name: string;
|
||||
isShared: boolean;
|
||||
}): Promise<{ id: number }> {
|
||||
return apiFetch('/lists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((r) => r.json())
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function patchListItem(
|
||||
@@ -459,7 +480,7 @@ export async function patchListItem(
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -470,17 +491,18 @@ export async function patchListItem(
|
||||
**Analog:** `apps/pwa/src/store/calendarStore.ts` lines 1–89 (full file above).
|
||||
|
||||
**Imports + create pattern** (lines 26–27 of calendarStore.ts):
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface ListsStore {
|
||||
// UI-only state — no server data
|
||||
activeTab: 'calendar' | 'lists'
|
||||
createListSheetOpen: boolean
|
||||
activeTab: 'calendar' | 'lists';
|
||||
createListSheetOpen: boolean;
|
||||
// ...
|
||||
|
||||
setActiveTab: (tab: 'calendar' | 'lists') => void
|
||||
setCreateListSheetOpen: (open: boolean) => void
|
||||
setActiveTab: (tab: 'calendar' | 'lists') => void;
|
||||
setCreateListSheetOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useListsStore = create<ListsStore>()((set) => ({
|
||||
@@ -488,7 +510,7 @@ export const useListsStore = create<ListsStore>()((set) => ({
|
||||
createListSheetOpen: false,
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
Convention from calendarStore.ts: no `persist` middleware used in this project — state is ephemeral (view persistence done manually with localStorage in calendarStore; lists UI state does not need persistence).
|
||||
@@ -500,40 +522,42 @@ Convention from calendarStore.ts: no `persist` middleware used in this project
|
||||
**Analog:** `apps/pwa/src/components/CalendarShell.tsx` lines 1–60 (header shown above).
|
||||
|
||||
**Data fetching pattern** — useQuery with credentials (from CalendarShell + client.ts):
|
||||
|
||||
```tsx
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchLists, createList, deleteList } from '../api/listsClient.js'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchLists, createList, deleteList } from '../api/listsClient.js';
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['lists'],
|
||||
queryFn: fetchLists,
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**State branches** — mirror CalendarShell: `isLoading` → skeleton/empty, `isError` → error state with retry, `data` → render list. CalendarShell uses `isLoading` / `isError` / success branches explicitly.
|
||||
|
||||
**useMutation with optimistic update** (from RESEARCH.md Finding 6):
|
||||
|
||||
```tsx
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (listId: number) => deleteList(listId),
|
||||
onMutate: async (listId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['lists'] })
|
||||
const previous = queryClient.getQueryData(['lists'])
|
||||
await queryClient.cancelQueries({ queryKey: ['lists'] });
|
||||
const previous = queryClient.getQueryData(['lists']);
|
||||
queryClient.setQueryData(['lists'], (old: any) => ({
|
||||
...old,
|
||||
lists: old.lists.filter((l: any) => l.id !== listId),
|
||||
}))
|
||||
return { previous }
|
||||
}));
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(['lists'], context.previous)
|
||||
if (context?.previous) queryClient.setQueryData(['lists'], context.previous);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**DeleteConfirmationDialog reuse** — import from `../components/DeleteConfirmationDialog.js` and render conditionally. The existing dialog is tightly coupled to `calendarStore`; create a new list-delete confirmation component (`ListDeleteDialog`) that mirrors its structure but is driven by `listsStore`. Do NOT modify the existing dialog — it is stable (D-06 says reuse, but the implementation is wired to calendarStore).
|
||||
@@ -545,22 +569,24 @@ const deleteMutation = useMutation({
|
||||
**Analog:** `apps/pwa/src/components/CalendarShell.tsx`
|
||||
|
||||
**SSE + polling pattern** (from RESEARCH.md Finding 4):
|
||||
|
||||
```tsx
|
||||
import { useListSSE } from '../hooks/useListSSE.js'
|
||||
import { useListSSE } from '../hooks/useListSSE.js';
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['list', listId],
|
||||
queryFn: () => fetchListItems(listId),
|
||||
refetchInterval: 30_000, // D-12: polling fallback always active
|
||||
})
|
||||
});
|
||||
|
||||
useListSSE({ listId, onStateChange: setSyncState })
|
||||
useListSSE({ listId, onStateChange: setSyncState });
|
||||
```
|
||||
|
||||
**Active / completed section split** (D-05):
|
||||
|
||||
```tsx
|
||||
const activeItems = items.filter((i) => !i.checked).sort(/* by rank ASC */)
|
||||
const completedItems = items.filter((i) => i.checked)
|
||||
const activeItems = items.filter((i) => !i.checked).sort(/* by rank ASC */);
|
||||
const completedItems = items.filter((i) => i.checked);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -568,6 +594,7 @@ const completedItems = items.filter((i) => i.checked)
|
||||
### `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (reuse — no modification)
|
||||
|
||||
**This file is not modified.** A new `ListDeleteDialog.tsx` mirrors its structure:
|
||||
|
||||
- Same modal layout: fixed backdrop + centered dialog
|
||||
- Same CSS tokens: `var(--color-overlay)`, `var(--color-surface-raised)`, `var(--color-destructive)`, `var(--space-*)`, `var(--text-*)`, `var(--font-family-base)`
|
||||
- Same `useMutation` + `onSuccess` → close pattern (lines 64–76 of DeleteConfirmationDialog.tsx)
|
||||
@@ -582,14 +609,16 @@ Key lines to copy for the modal skeleton (lines 86–208 of DeleteConfirmationDi
|
||||
**Analog:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (for mutation + CSS token patterns)
|
||||
|
||||
**dnd-kit drag handle pattern** (from RESEARCH.md Finding 7):
|
||||
|
||||
```tsx
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
export function ItemRow({ item, onCheck, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: item.id })
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -615,7 +644,7 @@ export function ItemRow({ item, onCheck, onDelete }) {
|
||||
</button>
|
||||
{/* ... checkbox, text, delete button */}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -624,6 +653,7 @@ export function ItemRow({ item, onCheck, onDelete }) {
|
||||
### `apps/pwa/src/hooks/useListSSE.ts` (hook, event-driven)
|
||||
|
||||
**No codebase analog.** Use the pattern from RESEARCH.md Finding 4 verbatim. Key conventions:
|
||||
|
||||
- `useCallback` for `connect` to keep the `useEffect` dependency stable
|
||||
- `esRef`, `attemptsRef`, `timerRef` — all `useRef` (not state) to avoid re-render loops
|
||||
- Close `es` on error before scheduling retry (prevents browser auto-reconnect stacking with manual reconnect)
|
||||
@@ -635,51 +665,64 @@ export function ItemRow({ item, onCheck, onDelete }) {
|
||||
## Shared Patterns
|
||||
|
||||
### Auth / User Resolution
|
||||
|
||||
**Source:** `apps/api/src/routes/events.ts` lines 59–76
|
||||
**Apply to:** `apps/api/src/routes/lists.ts`
|
||||
|
||||
```typescript
|
||||
async function resolveUserId(c: any): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
Copy verbatim — do not extract to a shared module (existing convention duplicates this per router).
|
||||
|
||||
### Error Handling (API routes)
|
||||
|
||||
**Source:** `apps/api/src/routes/events.ts` (every handler's catch block)
|
||||
**Apply to:** `apps/api/src/routes/lists.ts`
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// ... db operations ...
|
||||
return c.json({ /* result */ })
|
||||
return c.json({
|
||||
/* result */
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[lists/<endpoint>] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/<endpoint>] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
```
|
||||
|
||||
Pattern: 503 on caught exceptions, not 500. `console.error` with a `[module/endpoint]` prefix tag.
|
||||
|
||||
### 401 Guard Pattern
|
||||
|
||||
**Source:** `apps/api/src/routes/events.ts` (every handler, lines 125–126):
|
||||
|
||||
```typescript
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
```
|
||||
|
||||
First two lines of every protected handler.
|
||||
|
||||
### CSS Token Usage (PWA components)
|
||||
|
||||
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (all inline styles)
|
||||
**Apply to:** all new PWA components
|
||||
Token set in use:
|
||||
|
||||
- `var(--color-surface)`, `var(--color-surface-raised)`, `var(--color-overlay)`
|
||||
- `var(--color-text-primary)`, `var(--color-text-secondary)`
|
||||
- `var(--color-destructive)`, `var(--color-border)`
|
||||
@@ -688,44 +731,56 @@ Token set in use:
|
||||
- Minimum touch target: `minHeight: '44px'` (buttons/rows)
|
||||
|
||||
### Fetch with Credentials (PWA API client)
|
||||
|
||||
**Source:** `apps/pwa/src/api/client.ts` lines 36–39
|
||||
**Apply to:** `apps/pwa/src/api/listsClient.ts`
|
||||
|
||||
```typescript
|
||||
const res = await fetch('/api/...', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual', // only for /api/me; not required for data endpoints
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
All list API calls use `credentials: 'include'`. `redirect: 'manual'` is only needed for the initial session check (`/api/me`) — not for list CRUD endpoints.
|
||||
|
||||
### TanStack Query Keys
|
||||
|
||||
**Apply to:** `apps/pwa/src/routes/ListsIndex.tsx`, `apps/pwa/src/routes/ListDetail.tsx`
|
||||
|
||||
```typescript
|
||||
// List of lists
|
||||
queryKey: ['lists']
|
||||
queryKey: ['lists'];
|
||||
|
||||
// Items for a specific list
|
||||
queryKey: ['list', listId] // listId is a number
|
||||
queryKey: ['list', listId]; // listId is a number
|
||||
```
|
||||
|
||||
Invalidate `['lists']` after create/delete list. Invalidate `['list', listId]` after any item mutation or SSE event for that list.
|
||||
|
||||
### Lucide Icons (PWA)
|
||||
|
||||
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` line 27, CalendarShell.tsx line 43
|
||||
**Apply to:** all new PWA components
|
||||
|
||||
```tsx
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Trash2 } from 'lucide-react';
|
||||
// Usage: <Trash2 size={16} aria-hidden="true" />
|
||||
```
|
||||
|
||||
Always pass `aria-hidden="true"` to decorative icons. Use `size={16}` for inline/dense contexts, `size={22}` for navigation tabs.
|
||||
|
||||
### Zustand Store Shape
|
||||
|
||||
**Source:** `apps/pwa/src/store/calendarStore.ts`
|
||||
**Apply to:** `apps/pwa/src/store/listsStore.ts`
|
||||
|
||||
- Use `create<StoreInterface>()((set) => ({ ... }))` — no `persist`, no `immer`
|
||||
- Actions are inline setter functions, not separate files
|
||||
- UI-only state: no server data, no async in store actions (mutations live in components via `useMutation`)
|
||||
|
||||
### Plain-text XSS Guard (PWA)
|
||||
|
||||
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (comment `/* Plain text — XSS guard */` on every text node, line 133 etc.)
|
||||
**Apply to:** all new PWA components that render user-supplied strings (list names, item text)
|
||||
Never use `dangerouslySetInnerHTML`. All user content is a JSX text child.
|
||||
@@ -734,11 +789,11 @@ Never use `dangerouslySetInnerHTML`. All user content is a JSX text child.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | No EventEmitter or pub/sub pattern exists in the codebase. Use RESEARCH.md Finding 1 pattern. |
|
||||
| File | Role | Data Flow | Reason |
|
||||
| ----------------------------------------------- | --------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | No EventEmitter or pub/sub pattern exists in the codebase. Use RESEARCH.md Finding 1 pattern. |
|
||||
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | No live-sync state indicator exists. Novel UI component — use CSS token conventions and the "disconnected / updates paused" UX from D-11. |
|
||||
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | No custom hooks exist in the codebase. Novel — use RESEARCH.md Finding 4 pattern. |
|
||||
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | No custom hooks exist in the codebase. Novel — use RESEARCH.md Finding 4 pattern. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user