36 KiB
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>
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 <link rel="apple-touch-icon">, <link rel="icon">, <link rel="shortcut icon">, OG-Image. Fallback: /favicon.ico. SSRF-Schutz gegen private IPs/localhost. iconUrl in FavoriteLink. Client rendert <img src={iconUrl}> 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) </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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:
// 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<WidgetType, { minW: number; minH: number; defaultW: number; defaultH: number }> = {
// 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<WidgetProps>) {
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)
// 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 (
<div className="flex h-full flex-col ...">
{/* Tailwind-Styling */}
</div>
);
}
[VERIFIED: apps/web/src/components/dashboard/widgets/clock-widget.tsx, note-widget.tsx]
Pattern 3: FavoriteLink Prisma-Schema
// 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
// 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
// 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
// Portiert aus personal-dashboard/src/components/CalculatorWidget.tsx
// CSS-Module → Tailwind; CalculatorWidget() → export function CalculatorWidget({ instanceId, config, isEditMode }: WidgetProps)
function handleKeyboard(event: React.KeyboardEvent<HTMLDivElement>) {
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:
<div tabIndex={0} onKeyDown={handleKeyboard} role="application" aria-label="Taschenrechner">
[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. Niemalsstyles.xyzim Tessera-Projekt. - Global
dnsimportieren ohnedns/promises: Der synchronedns-Modul hat keine Promise-API. Immerimport { 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()ohneredirect: '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.tsxhat ein hardcodiertesWIDGET_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:
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 WebseitenMAX_REDIRECTS = 2— verhindert Redirect-LoopsMAX_HTML_CHARS = 200_000— ca. 200 KB HTML reichen für Icon-Tags im<head>- User-Agent:
tessera/1.0(stattpersonal-dashboard/0.1.0)
Code Examples
Icon-Discovery-Service (NestJS, portiert)
// 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<string> {
// ... (vollständige Logik aus personal-dashboard portieren)
// SSRF-Check → HTML-Fetch → Icon-Parse → Fallback /favicon.ico
}
}
FavoritesController (NestJS-Pattern)
// 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)
// 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<FavoriteLink[]>;
}
// ... create, update, remove analog
[VERIFIED: Muster aus apps/web/src/lib/dashboard-api.ts]
i18n-Keys für neue Widgets
// 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
-
FavoritesController-Route:
/favoritesoder/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.
- Was bekannt: DashboardController ist unter
-
Braucht Next.js eine API-Route als Proxy für
/favorites?- Was bekannt:
dashboard-api.tsruft NestJS direkt viaNEXT_PUBLIC_API_URLauf (mitcredentials: 'include'). Das funktioniert für alle bestehenden Widgets. - Was unklar: Ob CORS-Konfiguration für
/favoritesbereits durch bestehende NestJS-CORS-Config abgedeckt ist. - Empfehlung: Kein Next.js API-Proxy nötig — gleiche Fetch-Pattern wie dashboard-api.ts verwenden.
- Was bekannt:
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 <widget-name> - 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-08apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx— DASH-09apps/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 | <img src={iconUrl}> 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-Patternapps/web/src/components/dashboard/widgets/clock-widget.tsx— Referenz-Widget-Musterapps/web/src/components/dashboard/widgets/note-widget.tsx— updateWidgetConfig-Muster, Test-Mockingapps/web/src/components/dashboard/widget-catalog-modal.tsx— WIDGET_TYPES-Array-Hardcodierungapps/api/src/dashboard/dashboard.controller.ts— Controller-Pattern, extractContextapps/api/src/dashboard/dashboard.service.ts— Service-Pattern, Ownership-Checkapps/api/src/dashboard/dto/create-widget.dto.ts— @IsIn()-Patternapps/api/src/app.module.ts— AppModule-Imports-Arrayapps/api/prisma/schema.prisma— Bestehendes Schema, Index-Patternapps/web/src/lib/dashboard-api.ts— updateWidgetConfig, Fetch-Patternapps/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)