diff --git a/.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md new file mode 100644 index 0000000..bc5a807 --- /dev/null +++ b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md @@ -0,0 +1,740 @@ +# Phase 8: Dashboard Widgets Vollimplementierung - Research + +**Researched:** 2026-07-01 +**Domain:** React Widget-Komponenten, NestJS-Modulerweiterung, Prisma-Schema, SSRF-Schutz +**Confidence:** HIGH (eigene Codebase direkt gelesen; Referenzimplementierungen vollständig verfügbar) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**D-01 (DASH-11):** Kein universeller Einheitswert. Jedes Widget bekommt Constraints, die es bei Mindestgröße erkennbar und nutzbar halten. Claude legt konkrete Werte fest. + +**D-02 (Favorites Backend):** Neue Prisma-Tabelle `FavoriteLink` mit Feldern: `id`, `userId`, `tenantId`, `widgetId`, `title`, `url`, `iconUrl`, `position`. Eigene Tabelle — sauber abfragbar und sortierbar. + +**D-03 (Favorites Ansicht):** Standard-Ansicht: Liste (Icon + Titel untereinander). Grid-Ansicht (Kacheln) umschaltbar. Bei Grid-Ansicht saubere Formatierung. + +**D-04 (Favorites CRUD):** Hinzufügen/Bearbeiten/Löschen direkt im Widget im Dashboard-Edit-Modus ('+'-Button + Inline-Formular). Kein separater Einstellungen-Tab. + +**D-05 (Icon-Discovery):** Server-seitig beim Anlegen. NestJS fetcht Seite, parst ``, ``, ``, OG-Image. Fallback: `/favicon.ico`. SSRF-Schutz gegen private IPs/localhost. `iconUrl` in `FavoriteLink`. Client rendert `` mit Buchstaben-Fallback. + +**D-06 (Link-Widget):** Separater WidgetType `link`. Zeigt genau einen Link. Ansicht umschaltbar: Liste oder Kachel. Teilt FavoriteLink-Tabelle mit FavoritesWidget (widgetId als Zuordnung). + +**D-07 (Stopwatch-Persistenz):** Startzeit (Unix-Timestamp/ISO-String) + Zustand (running/paused/elapsed) in `WidgetInstance.config`. Kein separater API-Endpunkt — `PATCH /dashboard/widgets/:id/config`. + +**D-08 (Stopwatch-Funktionen):** Start, Stop/Pause, Reset. Rundenzeiten optional — Claude entscheidet. + +**D-09 (Calculator):** Grundrechenarten (+, −, ×, ÷), Tastatureingabe. Kein Persist. Logik aus personal-dashboard portieren, CSS-Module durch Tailwind ersetzen. + +### Claude's Discretion +- Konkrete minW/minH/defaultW/defaultH-Werte für neue Widgets +- Lap-Timer in Stoppuhr: im ersten Schritt oder später +- Reihenfolge der Kacheln im Favoriten-Grid +- Keyboard-Shortcuts im Calculator-Widget (aus personal-dashboard übernehmen) +- SSRF-Timeout und max-redirect-Werte (aus personal-dashboard: 4000ms / 2 redirects) + +### Deferred Ideas (OUT OF SCOPE) +- Stoppuhr-Anzeige im Browser-Seitentitel + Taskleiste +- DomainCheck-Widget +- Lap-Timer (falls nicht in Phase 8 umgesetzt: separater Task späterer Phase) + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| DASH-08 | Taschenrechner-Widget (Grundrechenarten, Tastatureingabe) | Calculator-Logik aus personal-dashboard vollständig portierbar; keine neuen Pakete; Tailwind-Styling nach Tessera-Muster | +| DASH-09 | Favoriten-Widget mit Backend-Persistenz (Links mit Titel, URL, Icon) | FavoriteLink-Prisma-Modell definiert; NestJS FavoritesModule mit Controller/Service/DTOs; Icon-Discovery aus personal-dashboard portierbar | +| DASH-10 | Stoppuhr-Widget (Start/Stop/Reset, Rundenzeiten) | Zustand in WidgetInstance.config; bestehende updateWidgetConfig()-Funktion direkt verwendbar; kein neuer API-Endpunkt | +| DASH-11 | Alle Widgets haben einheitliche Grid-Constraints (gleiche Feldstruktur, sinnvolle Werte) | WIDGET_CONSTRAINTS-Record in widget-registry.tsx erweitern; D-01 klärt: gleiche Feldstruktur, individuelle Werte | + + +--- + +## Summary + +Phase 8 implementiert vier neue Dashboard-Widgets (Calculator, Favorites, Link, Stopwatch) und normalisiert die Grid-Constraints aller Widgets. Die Codebase ist vollständig gelesen und die Referenzimplementierungen aus dem personal-dashboard sind verfügbar. + +**Keine neuen npm-Pakete erforderlich.** Alle benötigten Funktionen sind in der bestehenden Stack vorhanden: React 19 + Tailwind für die Frontend-Widgets, NestJS 11 + Prisma 6 + class-validator für das Backend, Node.js 24-Built-ins (`dns/promises`, `net`, globales `fetch`) für die SSRF-geschützte Icon-Discovery. Dies eliminiert Installations- und Kompatibilitätsrisiken. + +Die größte Komplexität liegt beim Favorites-Widget: Es braucht ein neues NestJS-Modul (`FavoritesModule`), ein neues Prisma-Modell (`FavoriteLink`), serverseitiges Icon-Discovery mit SSRF-Schutz und eine zweistufige Widget-Ansicht (Liste/Grid). Der Code für alle diese Teile existiert bereits im personal-dashboard und muss nur portiert werden. + +**Primary recommendation:** Sequenziell vorgehen: zuerst FavoriteLink-Schema + Prisma-Migration, dann FavoritesModule, dann die vier Widget-Komponenten (Calculator zuerst — keine Abhängigkeiten), dann widget-registry/catalog/i18n-Updates als letzter Schritt. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Calculator-Logik | Browser / Client | — | Rein clientseitig; kein Persist; State in React useState | +| Stoppuhr-Logik | Browser / Client | API / Backend | Timer läuft im Browser; Startzeit wird via PATCH an Backend persistiert | +| Favoriten-CRUD UI | Browser / Client | API / Backend | UI im Widget; Daten von NestJS API | +| Icon-Discovery | API / Backend | — | Serverseitig (SSRF-Schutz); Client darf keine externen URLs direkt fetchen | +| FavoriteLink-Persistenz | Database / Storage | — | PostgreSQL via Prisma; userId+tenantId-Scope | +| Widget-Registry / Constraints | Frontend Server (SSR) | Browser / Client | widget-registry.tsx wird beim Bundling eingebunden; Typen + Constraints wirken build-time | +| i18n-Strings | Frontend Server (SSR) | — | next-intl Server Component native; Keys in messages/de.json + messages/en.json | + +--- + +## Standard Stack + +### Core (bereits installiert — keine neuen Pakete) + +| Library | Version | Purpose | Relevanz für Phase 8 | +|---------|---------|---------|----------------------| +| React | 19.x | UI-Komponenten | Alle vier Widget-Komponenten | +| Next.js | 15.3.x | Framework | App Router, 'use client' Widgets | +| Tailwind CSS | 4.x | Styling | CSS-Module aus personal-dashboard durch Tailwind ersetzen | +| next-intl | 4.13.x | i18n | useTranslations() für alle Widget-Strings | +| NestJS | 11.x | API-Framework | Neues FavoritesModule | +| Prisma | 6.x | ORM | Neues FavoriteLink-Modell; db push | +| class-validator | 0.15.x | DTO-Validierung | DTOs für FavoriteLink-CRUD | + +**Hinweis zur Prisma-Version:** Das Projekt nutzt Prisma 6.x (package.json: `^6.0.0`), nicht 7.x wie in der CLAUDE.md-Empfehlung. [VERIFIED: apps/api/package.json] + +### Node.js Built-ins für Icon-Discovery (kein npm install nötig) + +| Modul | Zweck | Node.js-Version | +|-------|-------|-----------------| +| `dns/promises` (lookup) | Hostname → IP-Auflösung für SSRF-Check | Node 18+ (Node 24 installiert) | +| `net` (isIP) | IPv4/IPv6-Erkennung | Built-in | +| `fetch` (global) | HTTP-Fetch für HTML-Parsing | Node 18+ native | + +[VERIFIED: node --version = v24.16.0] + +### Keine neuen Pakete — Package Legitimacy Audit entfällt + +Alle benötigten Funktionen sind in bestehenden Paketen oder Node.js-Built-ins verfügbar. + +--- + +## Package Legitimacy Audit + +**Keine neuen externen Pakete für diese Phase.** [VERIFIED: Analyse aller Anforderungen — reine Portierung + bestehender Stack] + +| Package | Registry | Verdict | Disposition | +|---------|----------|---------|-------------| +| (keine neuen Pakete) | — | — | — | + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser (React) + ├── CalculatorWidget → useState (in-memory, kein API-Call) + ├── StopwatchWidget → useState + updateWidgetConfig() + │ PATCH /dashboard/widgets/:id/config + ├── FavoritesWidget (liste/grid)→ fetch() → GET /favorites?widgetId=X + │ + Inline-Formular (editMode) POST/PATCH/DELETE /favorites/:id + └── LinkWidget (1 Eintrag) → fetch() → GET /favorites?widgetId=X (limit 1) + +NestJS API + ├── DashboardController (bestehend) + │ PATCH /dashboard/widgets/:id/config → StopwatchWidget-Persist + └── FavoritesController (neu) + GET /favorites?widgetId= → FavoritesService.list() + POST /favorites → FavoritesService.create() + IconDiscoveryService + PATCH /favorites/:id → FavoritesService.update() + DELETE /favorites/:id → FavoritesService.remove() + ↓ + IconDiscoveryService (portiert aus personal-dashboard) + ↓ DNS-Lookup → IP-SSRF-Check → fetch(url, {redirect:'manual'}) → HTML-Parse + ↓ Fallback: /favicon.ico + +PostgreSQL (Prisma) + ├── WidgetInstance.config (JSON) ← StopwatchWidget state + └── FavoriteLink ← FavoritesWidget + LinkWidget Daten +``` + +### Recommended Project Structure (neue Dateien) + +``` +apps/ +├── api/ +│ ├── prisma/ +│ │ └── schema.prisma UPDATE: FavoriteLink-Modell hinzufügen +│ └── src/ +│ ├── app.module.ts UPDATE: FavoritesModule importieren +│ └── favorites/ NEU +│ ├── favorites.module.ts +│ ├── favorites.controller.ts +│ ├── favorites.service.ts +│ ├── icon-discovery.service.ts (portiert aus personal-dashboard) +│ └── dto/ +│ ├── create-favorite.dto.ts +│ └── update-favorite.dto.ts +└── web/ + ├── src/ + │ ├── components/dashboard/ + │ │ ├── widget-registry.tsx UPDATE: 4 neue Typen + Constraints + wireXWidget() + │ │ ├── widget-catalog-modal.tsx UPDATE: WIDGET_TYPES Array + │ │ └── widgets/ + │ │ ├── calculator-widget.tsx NEU + │ │ ├── calculator-widget.test.tsx NEU + │ │ ├── favorites-widget.tsx NEU + │ │ ├── favorites-widget.test.tsx NEU + │ │ ├── link-widget.tsx NEU + │ │ ├── link-widget.test.tsx NEU + │ │ ├── stopwatch-widget.tsx NEU + │ │ └── stopwatch-widget.test.tsx NEU + │ ├── lib/ + │ │ └── favorites-api.ts NEU (analog zu dashboard-api.ts) + │ └── messages/ + │ ├── de.json UPDATE: neue Widget-Keys + │ └── en.json UPDATE: neue Widget-Keys + └── src/app/api/favorites/ OPTIONAL: Next.js API-Route als Proxy (falls nötig) +``` + +### Pattern 1: Neues Widget registrieren (wireXWidget-Pattern) + +Jedes neue Widget muss in `widget-registry.tsx` exakt nach diesem Muster registriert werden: + +```typescript +// Source: apps/web/src/components/dashboard/widget-registry.tsx (bestehend) + +// 1. WidgetType-Union erweitern +export type WidgetType = 'clock' | 'search' | 'calendar' | 'note' + | 'calculator' | 'favorites' | 'link' | 'stopwatch'; + +// 2. WIDGET_CONSTRAINTS eintragen +export const WIDGET_CONSTRAINTS: Record = { + // bestehend: + clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 }, + search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 }, + calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 }, + note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 }, + // neu (Claude-Discretion D-01): + calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 }, + favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 }, + link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 }, + stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 }, +}; + +// 3. PlaceholderWidget im WIDGET_REGISTRY eintragen +// 4. wireXWidget()-Funktion hinzufügen (analog zu wireClockWidget) +let calculatorWired = false; +export function wireCalculatorWidget(component: ComponentType) { + if (!calculatorWired) { + WIDGET_REGISTRY.calculator.component = component; + calculatorWired = true; + } +} +// ... analog für favorites, link, stopwatch +``` + +[VERIFIED: apps/web/src/components/dashboard/widget-registry.tsx] + +### Pattern 2: Widget-Komponente (Tessera-Standard) + +```typescript +// Source: apps/web/src/components/dashboard/widgets/clock-widget.tsx (bestehend) +'use client'; + +import type { WidgetProps } from '../widget-registry'; +import { useTranslations } from 'next-intl'; + +export function CalculatorWidget({ instanceId, config, isEditMode }: WidgetProps) { + // Alle Styles via Tailwind (keine CSS-Module) + // useTranslations('widgets') für alle UI-Strings + // updateWidgetConfig(instanceId, {...}) für Persistenz (Stopwatch) + return ( +
+ {/* Tailwind-Styling */} +
+ ); +} +``` + +[VERIFIED: apps/web/src/components/dashboard/widgets/clock-widget.tsx, note-widget.tsx] + +### Pattern 3: FavoriteLink Prisma-Schema + +```prisma +// Zu ergänzen in apps/api/prisma/schema.prisma +model FavoriteLink { + id String @id @default(uuid()) + userId String + tenantId String + widgetId String // WidgetInstance.id — Zuordnung zum konkreten Widget + title String + url String + iconUrl String? + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([tenantId]) + @@index([widgetId]) +} +``` + +[ASSUMED: Feldstruktur aus D-02 und personal-dashboard FavoriteLink-Typ ableitet; Prisma-Syntax verifiziert an bestehendem Schema] + +### Pattern 4: NestJS FavoritesModule + +```typescript +// apps/api/src/favorites/favorites.module.ts +import { Module } from '@nestjs/common'; +import { FavoritesController } from './favorites.controller'; +import { FavoritesService } from './favorites.service'; +import { IconDiscoveryService } from './icon-discovery.service'; + +@Module({ + controllers: [FavoritesController], + providers: [FavoritesService, IconDiscoveryService], +}) +export class FavoritesModule {} + +// In app.module.ts: FavoritesModule zu imports: [...] hinzufügen +// PrismaService ist global via PrismaModule — kein Re-Import nötig +``` + +[VERIFIED: apps/api/src/app.module.ts — PrismaModule in imports, global; MEDIUM confidence via context7-Digest] + +### Pattern 5: Stoppuhr-Persistenz in WidgetInstance.config + +```typescript +// Gespeichertes Config-Objekt in WidgetInstance.config (Json): +type StopwatchConfig = { + state: 'running' | 'paused' | 'stopped'; + startedAt: string | null; // ISO-8601-String wenn running + elapsed: number; // akkumulierte Zeit in ms (wenn paused/stopped) +}; + +// Beim Laden aus config: +const elapsed = config.state === 'running' && config.startedAt + ? (Date.now() - new Date(config.startedAt as string).getTime()) + (config.elapsed as number ?? 0) + : (config.elapsed as number ?? 0); + +// Beim Start: +await updateWidgetConfig(instanceId, { + state: 'running', + startedAt: new Date().toISOString(), + elapsed: currentElapsed, +}); +``` + +[VERIFIED: updateWidgetConfig() in apps/web/src/lib/dashboard-api.ts — Merge-Semantik im Service bestätigt] + +### Pattern 6: Calculator-Tastaturhandler + +```typescript +// Portiert aus personal-dashboard/src/components/CalculatorWidget.tsx +// CSS-Module → Tailwind; CalculatorWidget() → export function CalculatorWidget({ instanceId, config, isEditMode }: WidgetProps) + +function handleKeyboard(event: React.KeyboardEvent) { + const key = event.key; + const code = event.code; + let handled = true; + + if (/^[0-9]$/.test(key)) { inputDigit(key); } + else if (key === ',' || key === '.' || code === 'NumpadDecimal') { inputDecimal(); } + else if (key === '+' || code === 'NumpadAdd') { chooseOperator('add'); } + else if (key === '-' || code === 'NumpadSubtract') { chooseOperator('subtract'); } + else if (key === '*' || code === 'NumpadMultiply') { chooseOperator('multiply'); } + else if (key === '/' || code === 'NumpadDivide') { chooseOperator('divide'); } + else if (key === 'Enter' || key === '=' || code === 'NumpadEnter') { applyEquals(); } + else if (key === 'Backspace') { backspace(); } + else if (key === 'Escape') { clearAll(); } + else if (key === 'Delete') { clearEntry(); } + else if (key === '%') { applyUnary('percent'); } + else if (key === 'F9') { toggleSign(); } + else { handled = false; } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); // Verhindert react-grid-layout Interferenz + } +} + +// Auf Container-div: +
+``` + +[VERIFIED: /home/vicolab/Schreibtisch/personal-dashboard/src/components/CalculatorWidget.tsx — 434 Zeilen, vollständige Logik] + +### Anti-Patterns to Avoid + +- **CSS-Module verwenden:** Tessera nutzt ausschließlich Tailwind. Das personal-dashboard verwendet `CalculatorWidget.module.css` — diese Klassen müssen in Tailwind-Klassen übersetzt werden. Niemals `styles.xyz` im Tessera-Projekt. +- **Global `dns` importieren ohne `dns/promises`:** Der synchrone `dns`-Modul hat keine Promise-API. Immer `import { lookup } from 'dns/promises'` verwenden. +- **Icon-URL direkt vom Client fetchen:** Die SSRF-Schutz-Logik muss serverseitig in NestJS laufen. Der Client darf niemals direkt externe URLs für Icon-Discovery fetchen. +- **`fetch()` ohne `redirect: 'manual'`:** Automatisches Redirect-Folgen umgeht den SSRF-Check für Redirect-Ziele. Immer manuell weiterleiten und jedes Redirect-Ziel prüfen. +- **FavoriteLink ohne userId-Scope zurückgeben:** Ownership-Check (`WHERE userId = :userId`) ist Pflicht in jedem FavoritesService-Query. +- **WidgetType in CreateWidgetDto nicht aktualisieren:** `@IsIn(['clock', 'search', 'calendar', 'note'])` muss die 4 neuen Typen enthalten — sonst wirft die API 400 beim Widget-Erstellen. +- **WIDGET_TYPES-Array im Catalog-Modal nicht aktualisieren:** `widget-catalog-modal.tsx` hat ein hardcodiertes `WIDGET_TYPES`-Array — es muss ebenfalls erweitert werden. + +--- + +## Don't Hand-Roll + +| Problem | Nicht selbst bauen | Verwenden | Warum | +|---------|-------------------|-----------|-------| +| Arithmetik-Logik | Eigene Rechenfunktionen | `parseDisplay`, `formatNumber`, `calculate` aus personal-dashboard | Bewährt, edge-case-behandelt (NaN, -0, 1e15-Grenze, Dezimalkomma) | +| Icon-Discovery | Eigenen HTML-Parser | `extractIconFromHtml` + `fetchHtml` aus personal-dashboard | SSRF-Schutz, Redirect-Handling, Timeout, 200k-HTML-Limit | +| SSRF-IP-Check | Eigene Regex | `isPrivateIpv4` + `isPrivateIpv6` + `isBlockedHostname` aus personal-dashboard | RFC1918 komplett, CGNAT, IPv6-Sonderfälle | +| Widget-Config-Update | Neuen API-Endpunkt | `updateWidgetConfig()` in `dashboard-api.ts` | Bereits implementiert, PATCH /dashboard/widgets/:id/config | +| Favorites-Sortierung | Drag-Library | Native HTML5 Drag & Drop (wie in personal-dashboard) | Ausreichend für Liste; kein neues Paket nötig | + +--- + +## Common Pitfalls + +### Pitfall 1: Tastaturevents + react-grid-layout Konflikt +**What goes wrong:** Der Calculator fängt `/`-Taste ab (Divide) — react-grid-layout oder der Browser könnte ebenfalls darauf reagieren. Ohne `stopPropagation()` "bockt" die Seite oder navigiert. +**Root cause:** react-grid-layout ist ein DOM-Eventlistener; Keydown-Events bubblen nach oben. +**How to avoid:** `event.stopPropagation()` in allen handled-Fällen im `handleKeyboard`-Handler. Der Container braucht `tabIndex={0}` für Fokus. +**Warning signs:** Division-Taste öffnet browser-interne Suche; Seite scrollt beim Drücken von Pfeiltasten. + +### Pitfall 2: Stoppuhr-Drift nach Seiten-Reload +**What goes wrong:** Wenn die Stoppuhr läuft und die Seite neu geladen wird, muss der gespeicherte `startedAt`-Timestamp korrekt interpretiert werden. Bei falscher Zeitzonenbehandlung läuft die Uhr falsch. +**Root cause:** `new Date(startedAt)` interpretiert ISO-8601 korrekt in UTC. `Date.now()` gibt ebenfalls UTC-Millisekunden. Die Differenz ist korrekt — solange `startedAt` als ISO-String (`new Date().toISOString()`) gespeichert wird. +**How to avoid:** Immer `new Date().toISOString()` für startedAt; `Date.now() - new Date(startedAt).getTime()` für Elapsed. +**Warning signs:** Stoppuhr springt nach Reload auf falschen Wert. + +### Pitfall 3: FavoriteLink ohne widgetId-Scope +**What goes wrong:** Wenn `GET /favorites` keine `widgetId`-Filterung hat, sieht ein FavoritesWidget die Favoriten eines anderen FavoritesWidgets desselben Nutzers. +**Root cause:** Ein User kann mehrere FavoritesWidget-Instanzen auf dem Dashboard haben. +**How to avoid:** `WHERE widgetId = :widgetId` in jedem FavoritesService-Query. `widgetId` aus Query-Parameter validieren (class-validator `@IsUUID()`). +**Warning signs:** Alle Widgets zeigen dieselben Favoriten. + +### Pitfall 4: Prisma-Client nach Schema-Change veraltet +**What goes wrong:** Nach `prisma db push` muss der Prisma-Client neu generiert werden (`prisma generate`). Ohne dies kennt TypeScript das neue `FavoriteLink`-Modell nicht. +**Root cause:** `prisma db push` pusht Schema zur DB, aber generiert den Client nicht automatisch (im Gegensatz zu `prisma migrate dev`). +**How to avoid:** Nach `prisma db push` immer `npx prisma generate` ausführen (oder beides via `prisma db push && prisma generate`). +**Warning signs:** TypeScript-Fehler `Property 'favoriteLink' does not exist on type 'PrismaClient'`. + +### Pitfall 5: SSRF-Bypass via DNS-Rebinding +**What goes wrong:** Ein Angreifer registriert eine Domain, die zuerst auf eine öffentliche IP zeigt (SSRF-Check besteht), dann auf `192.168.x.x` rebindet. +**Root cause:** DNS-TTL-Ablauf zwischen Check und Fetch. +**How to avoid:** Die bestehende SSRF-Logik aus personal-dashboard macht den DNS-Lookup und den HTTP-Fetch in derselben Event-Loop-Iteration mit einem kurzen Timeout. Das reduziert das Fenster erheblich. Keine vollständige Immunität, aber ausreichend für diesen Use Case. +**Warning signs:** Kein direktes Warning-Zeichen — Review des SSRF-Codes beim Portieren. + +### Pitfall 6: widget-catalog-modal.tsx WIDGET_TYPES Array vergessen +**What goes wrong:** Neue Widgets erscheinen nicht im Katalog, obwohl sie in der Registry registriert sind. +**Root cause:** `WIDGET_TYPES` in `widget-catalog-modal.tsx` ist ein hardcodiertes Array — kein dynamisches `Object.keys(WIDGET_REGISTRY)`. +**How to avoid:** Bei jedem neuen Widget auch `WIDGET_TYPES` in `widget-catalog-modal.tsx` erweitern. + +### Pitfall 7: CreateWidgetDto @IsIn() nicht aktualisiert +**What goes wrong:** POST /dashboard/widgets mit `widgetType: 'calculator'` gibt 400 zurück. +**Root cause:** `@IsIn(['clock', 'search', 'calendar', 'note'])` in `create-widget.dto.ts` kennt die neuen Typen nicht. +**How to avoid:** `@IsIn([...alle WidgetTypes...])` aktualisieren, sobald neue Typen in `WidgetType`-Union aufgenommen werden. + +--- + +## Grid-Constraints Entscheidung (Claude's Discretion — D-01) + +Begründung der Werte basierend auf Widget-Inhalt: + +| Widget | minW | minH | defaultW | defaultH | Begründung | +|--------|------|------|----------|----------|------------| +| clock (bestehend) | 2 | 2 | 2 | 2 | Einfache Zeitanzeige | +| search (bestehend) | 3 | 2 | 6 | 2 | Breite Suchleiste | +| calendar (bestehend) | 3 | 3 | 4 | 6 | Terminliste braucht Höhe | +| note (bestehend) | 2 | 3 | 3 | 4 | Textfeld braucht Mindesthöhe | +| **calculator (neu)** | **2** | **4** | **3** | **5** | 4×5 Tastenfeld + Display + Memory-Reihe; bei minH:4 sind alle Tasten bedienbar | +| **favorites (neu)** | **2** | **3** | **3** | **5** | Mindestens 2-3 Links sichtbar; defaultH:5 für komfortable Liste | +| **link (neu)** | **2** | **2** | **2** | **2** | Einzelner Link: Icon + Titel, eine Zeile reicht | +| **stopwatch (neu)** | **2** | **2** | **3** | **3** | Display + 3 Buttons; defaultW:3 für bessere Lesbarkeit | + +--- + +## Stoppuhr: Lap-Timer Entscheidung (Claude's Discretion — D-08) + +**Entscheidung: Lap-Timer in Phase 8 implementieren** — die Komplexität ist überschaubar (zusätzliches Array `laps: number[]` im Config, Button "Runde"). Wenn im ersten Plan zu aufwändig, wird es als optionaler Task am Ende des Plans eingeplant. + +Config-Extension für Laps: +```typescript +type StopwatchConfig = { + state: 'running' | 'paused' | 'stopped'; + startedAt: string | null; + elapsed: number; + laps: number[]; // Lap-Zeiten in ms, neueste zuerst +}; +``` + +--- + +## Stopwatch Icon-Discovery: SSRF-Konfiguration (Claude's Discretion — D-05) + +Werte aus personal-dashboard direkt übernehmen: +- `HTML_FETCH_TIMEOUT_MS = 4000` — ausreichend für normale Webseiten +- `MAX_REDIRECTS = 2` — verhindert Redirect-Loops +- `MAX_HTML_CHARS = 200_000` — ca. 200 KB HTML reichen für Icon-Tags im `` +- User-Agent: `tessera/1.0` (statt `personal-dashboard/0.1.0`) + +--- + +## Code Examples + +### Icon-Discovery-Service (NestJS, portiert) + +```typescript +// apps/api/src/favorites/icon-discovery.service.ts +// Portiert aus personal-dashboard/src/lib/favorite-icons.ts +// Änderungen: 'personal-dashboard/0.1.0' → 'tessera/1.0' im User-Agent +import { Injectable } from '@nestjs/common'; +import { lookup } from 'dns/promises'; +import { isIP } from 'net'; + +@Injectable() +export class IconDiscoveryService { + async discoverFavoriteIconUrl(pageUrl: string): Promise { + // ... (vollständige Logik aus personal-dashboard portieren) + // SSRF-Check → HTML-Fetch → Icon-Parse → Fallback /favicon.ico + } +} +``` + +### FavoritesController (NestJS-Pattern) + +```typescript +// apps/api/src/favorites/favorites.controller.ts +@Controller('favorites') +export class FavoritesController { + constructor(private readonly favoritesService: FavoritesService) {} + + private extractContext(req: Request) { + // Gleiche extractContext()-Logik wie DashboardController + } + + @Get() + async list(@Req() req: Request, @Query('widgetId') widgetId: string) { + const { userId } = this.extractContext(req); + return this.favoritesService.list(userId, widgetId); + } + + @Post() + async create(@Req() req: Request, @Body() dto: CreateFavoriteDto) { ... } + + @Patch(':id') + async update(@Param('id') id: string, @Req() req: Request, @Body() dto: UpdateFavoriteDto) { ... } + + @Delete(':id') + async remove(@Param('id') id: string, @Req() req: Request) { ... } +} +``` + +[VERIFIED: Muster aus apps/api/src/dashboard/dashboard.controller.ts] + +### Favorites-API-Client (Frontend) + +```typescript +// apps/web/src/lib/favorites-api.ts (analog zu dashboard-api.ts) +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export async function fetchFavorites(widgetId: string) { + const res = await fetch(`${API_URL}/favorites?widgetId=${encodeURIComponent(widgetId)}`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch favorites'); + return res.json() as Promise; +} +// ... create, update, remove analog +``` + +[VERIFIED: Muster aus apps/web/src/lib/dashboard-api.ts] + +### i18n-Keys für neue Widgets + +```json +// In messages/de.json, Abschnitt "widgets", zu ergänzen: +"calculator": { + "name": "Taschenrechner", + "description": "Grundrechenarten mit Tastatureingabe" +}, +"favorites": { + "name": "Favoriten", + "description": "Schnellzugriff auf Links", + "loading": "Favoriten werden geladen...", + "empty": "Noch keine Favoriten.", + "addTitle": "Titel", + "addUrl": "URL", + "addButton": "Hinzufügen", + "listView": "Liste", + "gridView": "Kacheln", + "editButton": "Favorit bearbeiten", + "deleteButton": "Favorit loeschen", + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "error": "Fehler beim Laden der Favoriten" +}, +"link": { + "name": "Link", + "description": "Einzelner Schnellzugriff-Link" +}, +"stopwatch": { + "name": "Stoppuhr", + "description": "Zeitmessung mit Rundenzeiten", + "start": "Start", + "stop": "Stop", + "reset": "Reset", + "lap": "Runde" +} +``` + +[ASSUMED: Key-Namen nach Tessera-Konvention (bestehende Keys als Vorlage); en.json analog] + +--- + +## State of the Art + +| Alter Ansatz | Aktueller Ansatz | Geaendert | Impact | +|--------------|------------------|-----------|--------| +| CSS-Module (personal-dashboard) | Tailwind (Tessera) | Phase 5 | Alle Widget-Klassen → Tailwind-Utility-Klassen | +| fetch() mit redirect:'follow' | redirect:'manual' + manuelles Folgen | SSRF-Erkenntnis | Kein SSRF-Bypass via Redirects | +| Prisma migrate dev | prisma db push | Projekt-Konvention | Schneller für iterative Entwicklung ohne Migration-History | + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | FavoriteLink-Prisma-Feldnamen (id, userId, tenantId, widgetId, title, url, iconUrl, position) entsprechen D-02 aus CONTEXT | Schema-Pattern | Geringes Risiko: D-02 nennt exakt diese Felder | +| A2 | `prisma db push` ist die bevorzugte Methode (kein `migrate dev`) | Pattern 4 | Risiko: Falls Migrations-History erwünscht ist. Bestehendes Projekt nutzt db push (Jun 29, 2026 beobachtet) | +| A3 | i18n-Key-Namen folgen dem bestehenden Muster (z.B. `calculator.name`, `favorites.loading`) | i18n-Sektion | Gering: Falsche Key-Namen führen zu fehlenden Strings, leicht korrigierbar | +| A4 | FavoritesController unter `/favorites` (nicht `/dashboard/favorites`) | Architecture | Wenn der User `/dashboard/favorites` erwartet, müssen Routen angepasst werden | +| A5 | Lap-Timer wird in Phase 8 umgesetzt (Claude's Discretion D-08) | Stopwatch-Entscheidung | Kann als optionaler Task eingeplant werden | + +--- + +## Open Questions + +1. **FavoritesController-Route: `/favorites` oder `/dashboard/favorites`?** + - Was bekannt: DashboardController ist unter `/dashboard`; SearchProviders ebenfalls. + - Was unklar: Ob Favorites konzeptuell zum Dashboard-Modul gehören oder ein eigenes Top-Level-Modul sind. + - Empfehlung: `/favorites` (eigenes Modul, eigener Controller) — saubere Trennung. Falls Konsistenz gewünscht, `/dashboard/favorites`. + +2. **Braucht Next.js eine API-Route als Proxy für `/favorites`?** + - Was bekannt: `dashboard-api.ts` ruft NestJS direkt via `NEXT_PUBLIC_API_URL` auf (mit `credentials: 'include'`). Das funktioniert für alle bestehenden Widgets. + - Was unklar: Ob CORS-Konfiguration für `/favorites` bereits durch bestehende NestJS-CORS-Config abgedeckt ist. + - Empfehlung: Kein Next.js API-Proxy nötig — gleiche Fetch-Pattern wie dashboard-api.ts verwenden. + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js `dns/promises` | IconDiscoveryService SSRF | ✓ | Node 24.16.0 | — | +| Node.js `net` (isIP) | IconDiscoveryService SSRF | ✓ | Built-in | — | +| Global `fetch` | IconDiscoveryService HTTP | ✓ | Node 24.16.0 | — | +| PostgreSQL | FavoriteLink-Tabelle | ✓ | 16.x (Docker) | — | +| Prisma Client | FavoritesService | ✓ | 6.x | — | + +[VERIFIED: node --version = v24.16.0 auf Entwicklungsrechner] + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest 4.1.9 + @testing-library/react 16.3.2 | +| Config file | `apps/web/vitest.config.ts` | +| Quick run command | `pnpm --filter @tessera/web test` | +| Full suite command | `pnpm --filter @tessera/web test --run` | + +[VERIFIED: apps/web/vitest.config.ts, apps/web/package.json scripts] + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| DASH-08 | Calculator zeigt Rechenergebnis | unit | `pnpm --filter @tessera/web test calculator-widget` | ❌ Wave 0 | +| DASH-08 | Tastatureingabe löst Ziffern/Operatoren aus | unit | `pnpm --filter @tessera/web test calculator-widget` | ❌ Wave 0 | +| DASH-09 | Favorites-Widget lädt Links aus API | unit (fetch mock) | `pnpm --filter @tessera/web test favorites-widget` | ❌ Wave 0 | +| DASH-09 | Favorit hinzufügen/bearbeiten/löschen | unit (fetch mock) | `pnpm --filter @tessera/web test favorites-widget` | ❌ Wave 0 | +| DASH-10 | Stoppuhr: Start/Stop/Reset | unit | `pnpm --filter @tessera/web test stopwatch-widget` | ❌ Wave 0 | +| DASH-10 | Stoppuhr rekonstruiert nach Reload | unit (fake timers) | `pnpm --filter @tessera/web test stopwatch-widget` | ❌ Wave 0 | +| DASH-11 | Alle Widgets haben 4 Constraint-Felder | unit | `pnpm --filter @tessera/web test widget-registry` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `pnpm --filter @tessera/web test --run ` +- **Per wave merge:** `pnpm --filter @tessera/web test --run` +- **Phase gate:** Vollständige Suite grün vor `/gsd-verify-work` + +### Wave 0 Gaps +- [ ] `apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx` — DASH-08 +- [ ] `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — DASH-09 +- [ ] `apps/web/src/components/dashboard/widgets/link-widget.test.tsx` — DASH-09 (Link) +- [ ] `apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx` — DASH-10 + +--- + +## Security Domain + +> security_enforcement: true (aus config.json); ASVS Level 1 + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | nein | Bestehende JwtAuthGuard global | +| V3 Session Management | nein | Keine neuen Sessions | +| V4 Access Control | ja | FavoritesService: WHERE userId = :userId für alle Queries | +| V5 Input Validation | ja | class-validator DTOs (IsString, IsUrl, IsUUID); URL-Validierung in IconDiscovery | +| V6 Cryptography | nein | Keine Kryptographie | + +### Known Threat Patterns + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| SSRF via Icon-Discovery | Elevation of Privilege | `isPrivateIpAddress` + `isBlockedHostname` + DNS-Lookup-Check aus personal-dashboard | +| Unauthorized Favorites Access | Spoofing | WHERE userId = :userId in jedem DB-Query; NotFoundException wenn nicht gefunden | +| XSS via iconUrl | Tampering | `` ist sicher; kein `dangerouslySetInnerHTML`; iconUrl kommt vom Server | +| Open Redirect via URL-Feld | Spoofing | Links öffnen mit `target="_blank" rel="noreferrer"`; kein Server-Side-Redirect | +| Oversized HTML Fetch | DoS | MAX_HTML_CHARS = 200_000 (Text-Truncation); Timeout 4000ms | + +--- + +## Sources + +### Primary (HIGH confidence — direkte Codebase-Verifikation) +- `apps/web/src/components/dashboard/widget-registry.tsx` — WidgetType, WIDGET_CONSTRAINTS, wireXWidget-Pattern +- `apps/web/src/components/dashboard/widgets/clock-widget.tsx` — Referenz-Widget-Muster +- `apps/web/src/components/dashboard/widgets/note-widget.tsx` — updateWidgetConfig-Muster, Test-Mocking +- `apps/web/src/components/dashboard/widget-catalog-modal.tsx` — WIDGET_TYPES-Array-Hardcodierung +- `apps/api/src/dashboard/dashboard.controller.ts` — Controller-Pattern, extractContext +- `apps/api/src/dashboard/dashboard.service.ts` — Service-Pattern, Ownership-Check +- `apps/api/src/dashboard/dto/create-widget.dto.ts` — @IsIn()-Pattern +- `apps/api/src/app.module.ts` — AppModule-Imports-Array +- `apps/api/prisma/schema.prisma` — Bestehendes Schema, Index-Pattern +- `apps/web/src/lib/dashboard-api.ts` — updateWidgetConfig, Fetch-Pattern +- `apps/web/vitest.config.ts` — Test-Framework-Konfiguration + +### Referenz-Implementierungen (HIGH confidence — direkt gelesen) +- `/home/vicolab/Schreibtisch/personal-dashboard/src/components/CalculatorWidget.tsx` — 434 Zeilen vollständige Calculator-Logik +- `/home/vicolab/Schreibtisch/personal-dashboard/src/lib/favorite-icons.ts` — 281 Zeilen SSRF-geschützte Icon-Discovery +- `/home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx` — 482 Zeilen Favorites-UI-Logik + +### Secondary (MEDIUM confidence — Research-Digest) +- NestJS-Modul-Pattern (context7-Digest) — FavoritesModule-Registrierung +- Prisma db push Pattern (context7-Digest) — Schema-Sync ohne Migration + +--- + +## Metadata + +**Confidence breakdown:** +- Standard Stack: HIGH — direkte package.json-Verifikation, keine neuen Pakete +- Architecture: HIGH — vollständige Codebase-Lektüre, klare Integrationspunkte +- Widget-Constraints: MEDIUM — Claude-Discretion basierend auf Widget-Inhalt, erste Schätzung +- Pitfalls: HIGH — aus Codebase-Analyse und Referenz-Implementierungen abgeleitet +- Icon-Discovery: HIGH — vollständige Referenzimplementierung gelesen + +**Research date:** 2026-07-01 +**Valid until:** 2026-07-31 (stabiler Stack; keine Breaking Changes erwartet)