Files

36 KiB
Raw Permalink Blame History

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
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]

// 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. 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.

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 Webseiten
  • MAX_REDIRECTS = 2 — verhindert Redirect-Loops
  • MAX_HTML_CHARS = 200_000 — ca. 200 KB HTML reichen für Icon-Tags im <head>
  • User-Agent: tessera/1.0 (statt personal-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 (RESOLVED)

  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.
    • RESOLVED: /favorites (eigenes Modul, eigener Controller) — saubere Trennung. In Plan 08-03 umgesetzt.
  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.
    • RESOLVED: Kein Next.js API-Proxy nötig — gleiche Fetch-Pattern wie dashboard-api.ts verwenden. CORS durch bestehende NestJS-Config abgedeckt. In Plan 08-03 (favorites-api.ts) umgesetzt.

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-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 <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-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)