# CapaKraken – Projekt-Learnings ## Format **Datum | Kategorie | Problem → Lösung** --- ## Learnings ### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design - Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments. - `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned). - Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`. - Assignment slices derived from `countWorkingDaysInOverlap()` per month, category code from project's utilization category. - tRPC `client` is a reserved router key — renamed to `clientEntity` to avoid collision with built-in `.client` property. - React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead. ### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files - `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state. - Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow. - For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields). ### 2026-03-12 | Build | NextAuth portable export typing - `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`. - If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing. ### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction - Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services - `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events - `packages/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application` - Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport) - `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases - `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces - `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes ### 2026-03-12 | Architecture | Dashboard query extraction into application layer - Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`. - Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services. - Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices. - Added `packages/application/src/__tests__/dashboard.test.ts` so the extraction is covered at the package boundary rather than only indirectly through API procedures. - While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter. ### 2026-03-12 | Architecture | Estimating foundation slice - Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports. - Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model. - Shared estimate enums/types/schemas now live in `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`. - Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components. ### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder - Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error. - Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge. - Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef` to track drag source key, then `onDrop` calls the `reorder()` function. - Virtual scroll for table rows is complex: absolute positioning breaks `` column widths. Cursor-based infinite scroll (50 rows/page) is sufficient for typical resource counts (<500). Skip virtualizer for HTML tables. - JSONB field name in Prisma schema is `dynamicFields` (not `customFields`). Always verify the actual Prisma model field name before writing filter/update code. - CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines. ### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22) - Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues. - `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead. - Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs. - `useSearchParams` requires `` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access. ### 2026-03-11 | Security | Phase 0 critical fixes - `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution. - `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`. - `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI. - `@capakraken/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing). - `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig. ### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation **Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects. **Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation. **Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`. ### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern **Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries. **Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total. ### 2026-03-09 | Performance | recomputeValueScores sequential updates **Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop. **Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip. ### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create **Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`. **Fix:** Use `ctx.dbUser?.id` directly. ### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents `AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing. ### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array `trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list. ### 2026-03-08 | Focus Trap | useFocusTrap hook pattern For modal focus trapping: create a `panelRef = useRef(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel. ### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell **Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war. **Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten. **Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten. --- ### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen **Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert. **Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause. **Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin. --- ### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder **Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten. **Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`). **Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen. --- ### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes **Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt. **Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`. --- ### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert **Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root. **Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben. **Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen. --- ### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline **Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode). **Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt. **Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben. --- ### 2026-02-xx | Architektur | tRPC-Routen-Registrierung **Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert. **Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`. **Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt. --- ### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen **Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab. **Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem. **Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern. --- ### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum **Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind. **Lösung:** An Client-Grenzen `as unknown as SharedType` casten: - `project as unknown as Project` - `form.orderType as unknown as OrderType` - `resource.skills as unknown as SkillEntry[]` --- ### 2026-02-xx | Architektur | SSE statt WebSockets **Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket. **Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies. **Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations. --- ### 2026-03-06 | Refactoring | 1863-Zeilen-Komponente aufteilen **Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar. **Lösung:** Schrittweise Extraktion: 1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit) 2. Heatmap-Utilities → `heatmapUtils.ts` 3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook 4. Header-JSX → `TimelineHeader.tsx` 5. Toolbar-JSX → `TimelineToolbar.tsx` **Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler. **Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt. --- ### 2026-03-06 | Architektur | Redis Pub/Sub für SSE **Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen. **Lösung:** `ioredis` in `@capakraken/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `capakraken:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery. **Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5. **Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose. --- ### 2026-03-06 | Auth | Argon2 statt SHA-256 **Problem:** SHA-256 ohne Salt ist dev-only und nicht produktionstauglich. **Lösung:** `@node-rs/argon2` als Drop-in für Auth.js v5 Credentials Provider. `verify(hash, password)` ersetzt den SHA-256-Vergleich. Seed nutzt `await hash("password")` (Salt wird automatisch generiert). **Seed-Bug entdeckt:** `prisma.vacation.deleteMany({})` fehlte im Cleanup-Block → FK-Constraint-Fehler. Behoben. --- ### 2026-03-06 | Performance | @tanstack/react-virtual für Timeline **Problem:** Timeline renderte alle Resource-Rows auf einmal (auch off-screen). **Lösung:** `useVirtualizer` mit `getScrollElement: () => scrollContainerRef.current`. Virtualisierter Container hat `position: relative; height: getTotalSize()`, jede Row `position: absolute; transform: translateY(...)`. **Sticky Labels:** Funktionieren trotz `position: absolute` – Sticky arbeitet gegen den horizontalen Scroll-Container (nicht den vertikalen). **Nicht virtualisiert:** Project-View (typisch 20-40 Gruppen, kein Performance-Problem). --- ### 2026-03-06 | Build | Laufender Dev-Server überlebt Prisma-Schema-Änderung nicht **Problem:** Nach `prisma generate` + `rm -rf .next` durch einen Hintergrund-Agenten lief der Dev-Server noch mit dem alten Node.js-Prozess. Der gecachte Prisma-Client kannte das neue `Notification`-Model nicht → `ctx.db.notification` war `undefined` → 500 Internal Server Error auf allen Seiten die den neuen Router nutzten (NotificationBell pollt beim Start). **Root Cause:** `rm -rf .next` löscht den Build-Cache für neue Requests. Aber der **laufende Node.js-Prozess** hat den alten PrismaClient bereits im Heap. Dieser wird erst bei Prozess-Neustart neu initialisiert. **Lösung:** Dev-Server-Prozess töten und neu starten: `fuser -k 3100/tcp && pnpm dev` **Regel:** Nach jeder Prisma-Schema-Änderung die durch einen Agenten im Hintergrund gemacht wird: Dev-Server immer manuell neu starten. Symptom: 500 auf allen Seiten obwohl tsc sauber ist. --- ### 2026-03-06 | Playwright | Falsche API für page.click() mit hasText **Problem:** `page.click("button", { hasText: "..." })` ist keine valide Playwright-API. **Lösung:** `page.locator("button", { hasText: "..." }).click()` – zuerst Locator erstellen, dann `.click()` aufrufen. --- ### 2026-03-06 | Role-Migration | Namens-Inkonsistenz bei Legacy-Daten **Problem:** 61 von 133 Allocations mit `role`-String konnten nicht auf `roleId` gemappt werden (z.B. "Unreal Dev" vs. "Unreal Developer"). Ursache: Seed wurde überarbeitet, alte Allocation-Daten nicht migriert. **Lösung:** Migration-Script erstellt, das case-insensitiv matched. 72 Allocations korrekt backgefüllt. Für die 61 Unmatched bleibt der `role`-String als Fallback. **Für künftige Projekte:** Bei Seed-Überarbeitungen auch Migrations-Script für bestehende Produktionsdaten vorbereiten. --- ### 2026-03-06 | Architektur | Parallele Agenten auf demselben File — Race Condition vermeidbar **Problem:** P7.2 und P7.3 modifizierten beide `useTimelineDrag.ts` in parallel gestarteten Agenten. **Ergebnis:** Kein Konflikt, weil P7.3 später abschloss und den von P7.2 bereits geänderten File las, bevor er seine eigenen Änderungen hinzufügte. **Risiko:** Wenn beide Agenten exakt gleichzeitig fertig würden, könnte einer die Änderungen des anderen überschreiben. **Regel:** Bei parallelen Agenten, die dieselbe Datei modifizieren könnten, immer prüfen ob die Änderungen unabhängig sind — oder den zweiten Agenten anweisen, den File nach Start des ersten Agenten zu lesen. --- ### 2026-03-06 | UX | Touch-Drag Disambiguierung **Pattern:** `touchStartRef = useRef({ x, y, decided })` — beim ersten Touchstart Position merken. Im `touchmove`: wenn `dy > dx` und noch nicht decided → vertical scroll, kein Drag. Wenn `dx > dy` → horizontal drag, `decided = true`, `preventDefault()`. **Wichtig:** `e.changedTouches[0]` statt `e.touches[0]` bei `touchend` verwenden (touches ist bei touchend leer). **iOS Safari:** `touch-action: none` (CSS) oder `style={{ touchAction: "none" }}` auf dem Canvas-Div verhindert Browser-Scroll-Interception. --- ### 2026-03-06 | Architektur | Undo/Redo ohne Hook-Kopplung **Pattern:** Optionaler `onAllocationMoved?: (snapshot) => void` Callback in `useTimelineDrag`, der im `onSuccess` der Mutation aufgerufen wird. Konsumenten können History-Stack führen ohne den Hook zu kennen. **Vorteil:** Klar getrennte Verantwortlichkeiten — der Drag-Hook kennt keine History-Logik. **Keyboard-Shortcut:** Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y im `useEffect` mit `window.addEventListener("keydown", ...)`. --- ### 2026-03-06 | Auth | Prisma upsert mit `update: {}` updatet Passwort nicht **Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich. **Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix. **Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen: ```typescript const adminHash = await hash("admin123"); prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } }); ``` **Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen. --- ### 2026-03-06 | Feature | Multi-Format-Export über einen Route Handler **Pattern:** Einen Route Handler (`/api/reports/allocations`) für mehrere Exportformate nutzen via `?format=xlsx|pdf`. Die Datenbankabfrage wird einmal ausgeführt, danach teilt ein `if (format === "xlsx")` Block die Verarbeitung auf. **XLS-Generierung server-seitig:** `xlsx` Package (bereits im Projekt für Import vorhanden) — `XLSX.utils.json_to_sheet(data)` + `XLSX.utils.book_new()` + `XLSX.write(wb, { type: "buffer", bookType: "xlsx" })` gibt einen `Buffer` zurück. **MIME-Type XLS:** `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` + `filename="*.xlsx"`. **UI:** `` — `download` Attribut erzwingt Download statt Browser-Öffnung. **Wiederverwendung:** Da `xlsx` bereits für den Import installiert ist, kein neues Package nötig. Import als `import * as XLSX from "xlsx"`. --- ### 2026-03-06 | Next.js | "use client" in @react-pdf/renderer Komponente bricht renderToBuffer **Problem:** `AllocationReport.tsx` hatte `"use client"` am Anfang. Wenn diese Komponente in einem API Route Handler (Server Context / RSC) importiert und mit `renderToBuffer` gerendert wird, wickelt Next.js sie in einen `__nextjs-internal-proxy.mjs`-Wrapper. `renderToBuffer` bekommt nicht das echte React Component sondern den Proxy → 500 Internal Server Error. **Fehlersymptom:** `at eval (...AllocationReport.tsx/__nextjs-internal-proxy.mjs:1:1)` im Stack Trace. **Lösung:** `"use client"` aus `AllocationReport.tsx` entfernen. `@react-pdf/renderer` (`Document`, `Page`, `Text`, `View`) ist eine rein server-seitige Bibliothek — sie verwendet keine Browser-APIs, kein DOM, kein `window`. `renderToBuffer` läuft im Node.js-Prozess. Server Components sind hier korrekt. **Regel:** Komponenten für `@react-pdf/renderer` sind immer **Server Components**. `"use client"` dort ist falsch und bricht `renderToBuffer` in Route Handlers. **Merkhilfe:** `"use client"` nur dort, wo echte Browser-APIs (`useState`, `useEffect`, DOM-Events, `window`, `localStorage`) gebraucht werden. Reine Darstellungskomponenten für PDF/Email/Export sind Server Components. --- ### 2026-03-06 | Seed | Seed löscht Daten ohne sie neu anzulegen → stille Datenverluste **Problem:** `seed.ts` führt `prisma.vacation.deleteMany({})` im Cleanup-Block aus, aber erstellt danach keine Vacation-Einträge. Jedes Re-Seeding (z.B. nach Passwort-Hash-Migration) löscht alle Vacations dauerhaft. Die App zeigt danach leere Listen, ohne Fehlermeldung — "No vacations found" ist korrekt aber irreführend. **Symptom:** Vacation-Tab zeigt "No vacations found" obwohl die API 200 zurückgibt. **Lösung:** Vacation-Seed-Daten in `seed.ts` hinzugefügt (10 Einträge: ANNUAL, SICK, PUBLIC_HOLIDAY, OTHER; Status APPROVED + PENDING). Seed gibt jetzt `Vacations: N created` aus. **Regel:** Jedes Model das im `deleteMany`-Cleanup-Block steht, **muss** auch im Seed-Daten-Block befüllt werden. Nach jedem Seed-Lauf: alle Models auf erwartete Counts prüfen. **Für künftige Projekte:** Nach `db:seed` immer spot-check: `prisma.vacation.count()`, `prisma.allocation.count()` etc. — nicht nur "Seed complete!" als Erfolg werten. --- ### 2026-03-06 | Next-Auth | useSession ohne SessionProvider → Runtime Error **Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt. **Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug. **Lösung (zweistufig):** 1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App. 2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall). **Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout. **Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist. --- ### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster **Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User. **Lösung:** Zweigeteiltes System: 1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys. 2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen. 3. **`resolvePermissions(role, overrides)`** — gibt `Set` zurück, wendet grants/denials auf die Rolle-Defaults an. 4. **`requirePermission(ctx, key)`** — wirft `TRPCError FORBIDDEN` wenn Permission fehlt. 5. **`controllerProcedure`** — neues Procedure-Level für ADMIN+MANAGER+CONTROLLER (lesend + Export). **Neue Dateien:** `packages/shared/src/types/permissions.ts` **Geändert:** `packages/api/src/trpc.ts`, `packages/api/src/router/user.ts` (+ 3 neue Procedures), 6 Router mit Permission-Checks, `AppShell.tsx`, `usePermissions.ts` Hook, 4 UI-Komponenten. **Parallelisierungserfolg:** 4 Wellen, je 2-3 parallele Agenten. Welle 1 (Shared+Prisma) → Welle 2 (Middleware+AppShell) → Welle 3 (Router+AdminPage+UI) → Welle 4 (Users-Page). Kein Konflikt, da saubere Datei-Grenzen. **Pre-existing Bug gefunden:** `packages/shared/src/schemas/project.schema.ts` Zeile 5: `crypto.randomUUID()` ist nicht im `lib: ["ES2022"]` ohne DOM — hat nil Auswirkung auf Laufzeit (Node 18+ hat `crypto` global), aber `tsc --noEmit` auf shared direkt schlägt fehl. Fixbar mit `import { randomUUID } from "node:crypto"`. --- ### 2026-03-06 | Architektur | permissionOverrides als JSON statt eigenem Modell **Entscheidung:** Permission-Overrides als `Json?` auf User-Modell statt separatem `Permission`/`RolePermission`-Datenbankmodell. **Begründung:** Kein db:push mit Tabellenmigrationen, keine Join-Queries, direkt serialisierbar in JWT/Session. Für teams < 100 User ausreichend. **Vorteil:** `Prisma.DbNull` setzt JSON-Feld auf NULL (kein `undefined` übergeben). **Trade-off:** Keine DB-level Constraints auf Permission-Keys — Validierung liegt komplett in der API-Middleware. **Für künftige Projekte:** JSON-Overrides bis ~100 User ideal; bei größeren Systemen separate Tabellen für auditierbare History. --- ## Offene Fragen - [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub) - [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects) - [x] P7.2 Touch-Support → umgesetzt - [x] P7.3 Undo/Redo → umgesetzt - [ ] GitHub MCP braucht einen `GITHUB_TOKEN` in `~/.claude.json` – noch nicht gesetzt. --- ### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik **Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung. **Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet). **Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar. **Tiebreaker:** In `staffing.ts getSuggestions` sort-Tiebreaker: wenn zwei Resourcen ≤2 Punkte auseinander, bevorzuge höheren valueScore. **Tests:** 8 neue Unit-Tests in `value-scorer.test.ts` (edge cases: empty skills, maxLcrCents=0, clamp 0-100). --- ### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten **Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz. **Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand. **Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`). **Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen). ### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State **Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user. **Lösung:** - **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst. - **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_` localStorage (getrennt von `colvis_` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set). - **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort. - **`DraggableTableRow`** neue Komponente: `` mit erstem `
` als Drag-Handle (⠿). `dragRef` (shared ref) hält die aktuell gezogene Row-ID. `onDrop(draggedId)` gibt die gezogene ID weiter (nicht die Ziel-ID) — Pattern: `onDrop={(draggedId) => reorder(draggedId, currentRow.id)}`. - **Per-User-Isolation:** Alle Prefs (sort + rowOrder) landen im `User.columnPreferences` JSONB keyed by User-ID — vollständige Isolation zwischen Benutzern. - **Blueprints ausgelassen:** Card-Grid-Layout, keine Tabelle → sorting/drag nicht anwendbar. - **exactOptionalPropertyTypes-Falle:** `writeLocal(view, { sort: sort ?? undefined })` ist ungültig. Fix: Funktionssignatur auf `{ sort?: SavedSort | null; rowOrder?: string[] | null }` ändern, dann `{ sort: sort ?? null }` übergeben. `null` wird dann per `if (next.sort == null) delete next.sort` bereinigt. - **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst. ### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization **Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid. **Lösung:** - **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values. - **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting. - **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them. - **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog. - **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe. - **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation. **TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.