Files
adiuva/progress.txt
2026-02-19 17:02:41 +01:00

107 lines
9.4 KiB
Plaintext

## Codebase Patterns
- `alias(table, 'alias_name')` from `drizzle-orm/sqlite-core` enables self-joins (e.g., clients → parentClients for hierarchy)
- `sql<T>\`CASE WHEN ... THEN ... ELSE ... END\`` for conditional SELECT fields (e.g., clientName vs subClientName based on parentId)
- `or(like(col1, pattern), like(col2, pattern))` for multi-column search; SQLite LIKE on NULL columns safely returns NULL (falsy) so OR is safe
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflict with electron-forge's externalize-deps plugin
- electron-trpc uses `exposeElectronTRPC()` in preload and `createIPCHandler({ router, windows })` in main; renderer uses `ipcLink()` from `electron-trpc/renderer`
- appRouter lives at `src/main/router/index.ts`; renderer client at `src/renderer/lib/trpc.ts`
- `@/*` path alias maps to `src/renderer/*` (configured in tsconfig.json paths)
- Drizzle ORM with better-sqlite3 (sync driver): SELECT queries MUST end with `.all()` to execute; INSERT/UPDATE/DELETE MUST end with `.run()`
- `inArray(column, values)` works with nullable columns when values is `string[]` (TypeScript covariance allows string[] → (string | null)[])
- All DB tables use `CREATE TABLE IF NOT EXISTS` for non-destructive migrations
- All IDs are UUIDs generated via `crypto.randomUUID()`
- TypeScript strict mode + noUncheckedIndexedAccess enabled; always account for possible undefined on array access
- electron-store@8 (CJS) used for app settings; use lazy init pattern `getStore()` like `getDb()` to avoid calling before app ready
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
---
## 2026-02-19 - US-003
- What was implemented:
- Installed: electron-trpc, @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod
- Created `src/main/router/index.ts` with full appRouter: stub routers for health, clients, projects, tasks, checkpoints, notes, ai
- Updated `src/preload/index.ts` to call `exposeElectronTRPC()`
- Updated `src/main/index.ts` to call `createIPCHandler({ router: appRouter, windows: [win] })`; `createWindow()` now returns `BrowserWindow`
- Created `src/renderer/lib/trpc.ts` with `createTRPCReact<AppRouter>()`
- Updated `src/renderer/index.tsx` to wrap app in `TRPCProvider` + `QueryClientProvider`
- Updated `src/renderer/routes/index.tsx` to call `trpc.health.ping.useQuery()` and display 'tRPC IPC bridge: pong'
- Files changed: package.json, package-lock.json, prd.json, src/main/index.ts, src/main/router/index.ts (new), src/preload/index.ts, src/renderer/index.tsx, src/renderer/lib/trpc.ts (new), src/renderer/routes/index.tsx
- **Learnings for future iterations:**
- electron-trpc `exposeElectronTRPC` is imported from `electron-trpc/main` (not a separate package)
- `ipcLink` is imported from `electron-trpc/renderer` in the renderer process
- `createTRPCReact<AppRouter>()` requires importing the AppRouter type from the main process router — this is a type-only import so it doesn't bundle main process code into renderer
- The TRPCProvider must wrap QueryClientProvider (or be a sibling); both need the same queryClient instance
- Stub routers return empty arrays or null — they will be replaced in US-005 through US-008
---
## 2026-02-19 - US-004
- What was implemented:
- Installed: electron-store@8 (CJS-compatible, for persistent app settings), @fontsource/geist (self-hosted Geist font), eslint-import-resolver-typescript (ESLint path alias fix)
- Created `src/main/store.ts` with lazy `getStore()` pattern using electron-store
- Added `settings` tRPC sub-router with `getSidebarCollapsed` query and `setSidebarCollapsed` mutation
- Updated `src/renderer/components/layout/AppShell.tsx` to: persist sidebar collapse via tRPC, add right-edge 'keep scrolling for AI' vertical label with ChevronDown icon
- Updated `src/renderer/globals.css`: replaced Google Fonts CDN with @fontsource/geist imports (weights 400/500/600)
- Updated `index.html`: removed Google Fonts CDN links
- Updated `.eslintrc.json`: added eslint-import-resolver-typescript to fix @/* alias resolution (fixed all 7 pre-existing lint errors)
- Files changed: .eslintrc.json, index.html, package.json, package-lock.json, src/main/router/index.ts, src/main/store.ts (new), src/renderer/components/layout/AppShell.tsx, src/renderer/globals.css
- **Learnings for future iterations:**
- Use electron-store@8 (not v9+) — v9+ is ESM-only and breaks with CommonJS main process
- electron-store must NOT be initialized at module import time (before app.ready); use lazy `getStore()` like `getDb()` pattern
- For sidebar/UI state loaded from IPC: use `localState ?? queryData ?? default` pattern to avoid flash while query resolves
- @fontsource packages are the npm equivalent of Google Fonts — import weight-specific CSS files (e.g., `@fontsource/geist/400.css`)
- ESLint `import/no-unresolved` requires `eslint-import-resolver-typescript` with `alwaysTryTypes: true` to resolve TypeScript path aliases
- The `writingMode: 'vertical-rl'` + `transform: 'rotate(180deg)'` CSS pattern creates bottom-to-top text for vertical affordance labels
---
## 2026-02-19 - US-006
- What was implemented:
- Full `projectsRouter` replacing stubs in `src/main/router/index.ts`
- Added `and` to drizzle-orm imports
- `projects.list`: uses `and()` with optional conditions for `clientId` filter and archived filter (defaults to active only)
- `projects.listAll`: returns only `{ id, name }` columns for dropdown use
- `projects.get`: `.all()` then `result[0] ?? null` pattern for nullable single-record lookup
- `projects.create`: inserts with UUID, status='active', createdAt=Date.now()
- `projects.update`: partial set object — only sets defined fields
- `projects.delete`: nulls `tasks.projectId` for all tasks in the project, then deletes the project
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `and(...conditions)` from drizzle-orm accepts `(SQL | undefined)[]` — pass `undefined` for optional conditions and drizzle filters them out automatically
- For nullable single-record queries: use `.all()` and `result[0] ?? null` (strict mode forbids `.get()` direct null return without this pattern)
- `and()` returns `SQL<unknown> | undefined` which `.where()` accepts directly (no extra wrapping needed)
---
## 2026-02-19 - US-005
- What was implemented:
- Full clients tRPC router replacing stubs in `src/main/router/index.ts`
- Added imports: `eq`, `asc`, `inArray` from `drizzle-orm`; `getDb` from `../db`; `clients`, `projects`, `tasks` from `../db/schema`
- `clients.list`: `db.select().from(clients).orderBy(asc(clients.name)).all()`
- `clients.create`: inserts with `crypto.randomUUID()` + `Date.now()` via `.run()`
- `clients.update`: partial update — only sets fields that are defined in input, skips if no-op
- `clients.delete`: checks for child clients and child projects; returns `{ error: string }` payload if any exist; otherwise deletes and returns `{ success: true }`
- `clients.deleteWithCascade`: BFS loop collects all descendant client IDs, finds their projects, nulls `projectId` on orphaned tasks, deletes projects, then deletes all clients
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- Drizzle ORM with better-sqlite3 sync driver: SELECT must call `.all()` to get an array; INSERT/UPDATE/DELETE must call `.run()` to execute — NOT calling these causes TypeScript errors (query builder ≠ result)
- `inArray(nullableColumn, string[])` is TypeScript-safe because `string[]` is assignable to `(string | null)[]` via covariance
- Guard against empty arrays before using `inArray` — while `allClientIds` is never empty (starts with input.id), `projectIds` could be empty; guarded with `if (projectIds.length > 0)` block
- `@typescript-eslint/no-non-null-assertion` is configured as a warning (not error) in this project — `queue.shift()!` is fine after a `length > 0` check
---
## 2026-02-19 - US-007
- What was implemented:
- Full `tasksRouter` replacing stubs in `src/main/router/index.ts`
- Added imports: `or`, `like`, `sql` from `drizzle-orm`; `alias` from `drizzle-orm/sqlite-core`
- `tasks.list`: LEFT JOINs projects → clients → parentClients (alias for self-join); CASE WHEN for clientName/subClientName breadcrumb fields; `and()` with optional conditions for projectId/status/search; `like()` OR search on title+description; CASE expression for priority ordering
- `tasks.create`: inserts with UUID, defaults (status='todo', priority='medium'), createdAt=Date.now()
- `tasks.update`: partial set object — only sets defined fields
- `tasks.delete`: deletes by id, returns `{ success: true }`
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `alias(table, 'alias_name')` is from `drizzle-orm/sqlite-core` (NOT `drizzle-orm`) for SQLite self-joins
- `sql<T>\`CASE WHEN ${col} IS NOT NULL THEN ${alias.col} ELSE ${col} END\`` for conditional field selection using drizzle template literals
- `or(like(col1, pattern), like(col2, pattern))` composes safely — null columns evaluate to NULL (falsy) in WHERE
- For priority ordering: `asc(sql\`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END\`)` puts high priority first
---