Files
CapaKraken/LEARNINGS.md
T

35 KiB
Raw Blame History

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<string | null> to track drag source key, then onDrop calls the reorder() function.
  • Virtual scroll for table rows is complex: absolute positioning breaks <table> 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 <Suspense> 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<HTMLDivElement>(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:

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: <a href="...?format=xlsx" download>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 <SessionProvider> 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<PermissionKey> 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

  • Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
  • Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
  • P7.2 Touch-Support → umgesetzt
  • 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_<view> localStorage (getrennt von colvis_<view> 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: <tr> mit erstem <td> 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.