+```
+
+[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)