docs(08): create phase plan (4 widget vertical slices)

This commit is contained in:
2026-07-01 09:03:54 +02:00
parent dc7b291d28
commit 29636bd2b1
5 changed files with 873 additions and 1 deletions
@@ -0,0 +1,217 @@
---
phase: 08-dashboard-widgets-vollimplementierung
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
- apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/api/src/dashboard/dto/create-widget.dto.ts
autonomous: true
requirements: [DASH-08, DASH-11]
must_haves:
truths:
- "User can open the widget catalog and see Calculator, Favoriten, Link and Stoppuhr as addable widget types"
- "User can add a Calculator widget and perform +, -, x, / with mouse and keyboard"
- "Every WidgetType has minW, minH, defaultW, defaultH constraint fields"
artifacts:
- "apps/web/src/components/dashboard/widgets/calculator-widget.tsx"
- "apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx"
- "apps/web/src/components/dashboard/widget-registry.test.tsx"
key_links:
- "widget-registry.tsx WidgetType union includes calculator/favorites/link/stopwatch"
- "create-widget.dto.ts @IsIn accepts the four new widget types"
- "page.tsx calls wireCalculatorWidget(CalculatorWidget)"
---
<objective>
Deliver the shared widget-registry foundation for all Phase-8 widget types (DASH-11) and the first working vertical slice: a Calculator widget (DASH-08).
This plan extends the registry once for all four new types (calculator, favorites, link, stopwatch) — adding their unified constraint-field structure, catalog entries, wiring hooks, DTO validation, and i18n keys — so subsequent plans only add their component file and one wiring line. It then delivers the fully functional Calculator widget.
Purpose: Establish the registration plumbing every later widget slice depends on, and ship the simplest end-to-end widget first.
Output: A user can add and use a Calculator widget from the dashboard catalog; all widget types carry the same constraint-field structure.
</objective>
<phase_goal>
**As a** portal user, **I want to** add a calculator widget to my dashboard and do quick arithmetic with keyboard support, **so that** I do not have to leave the dashboard for simple calculations.
</phase_goal>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
New symbols/files introduced by this plan (exclude from source-drift verification):
- WidgetType union members: `'calculator'`, `'favorites'`, `'link'`, `'stopwatch'`
- `WIDGET_CONSTRAINTS` entries: `calculator`, `favorites`, `link`, `stopwatch`
- `WIDGET_REGISTRY` entries: `calculator`, `favorites`, `link`, `stopwatch`
- Wiring functions: `wireCalculatorWidget`, `wireFavoritesWidget`, `wireLinkWidget`, `wireStopwatchWidget` (exported from widget-registry.tsx)
- Icon components in widget-registry.tsx: `CalculatorIcon`, `FavoritesIcon`, `LinkIcon`, `StopwatchIcon`
- Component: `CalculatorWidget` (apps/web/src/components/dashboard/widgets/calculator-widget.tsx)
- Test files: `calculator-widget.test.tsx`, `widget-registry.test.tsx`
- i18n keys under `widgets`: `calculator.*`, `favorites.*`, `link.*`, `stopwatch.*`
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Failing tests for Calculator widget and registry constraint structure (RED)</name>
<files>
apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx
apps/web/src/components/dashboard/widget-registry.test.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (test/mocking pattern: next-intl mock, vitest, @testing-library/react)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps consumption)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetType, WIDGET_CONSTRAINTS structure)
- apps/web/vitest.config.ts (test runner config)
</read_first>
<behavior>
calculator-widget.test.tsx (mock next-intl like note-widget.test.tsx; import CalculatorWidget after mocks):
- Renders with initial display "0".
- Clicking 7, +, 3, = shows "10".
- Clicking 8, x, 2, = shows "16".
- Division by zero (5, /, 0, =) shows the error label "Fehler".
- Keyboard: dispatching keydown "5", "*", "6", "Enter" on the calculator container shows "30".
- Comma/decimal: 1, comma, 5, +, 1, comma, 5, = shows "3".
widget-registry.test.tsx:
- For every value in the WidgetType union, WIDGET_CONSTRAINTS[type] has numeric minW, minH, defaultW, defaultH (DASH-11 structure check).
- WIDGET_CONSTRAINTS contains keys calculator, favorites, link, stopwatch (plus existing clock, search, calendar, note).
</behavior>
<action>
Create both test files. Mock next-intl with a passthrough translator returning the key (or a small map) exactly as note-widget.test.tsx does. In calculator-widget.test.tsx use @testing-library/react render + userEvent/fireEvent; query the display by its aria-label "Anzeige" (role output). For keyboard tests, fireEvent.keyDown on the container element (role="application", aria-label "Taschenrechner"). Import CalculatorWidget from './calculator-widget' AFTER the mocks. In widget-registry.test.tsx import WIDGET_CONSTRAINTS and iterate over the four expected keys plus the existing four; assert each has the four numeric fields. These tests MUST fail now (calculator-widget.tsx and the new constraint keys do not exist yet).
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run calculator-widget widget-registry 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- calculator-widget.test.tsx exists and imports from './calculator-widget'
- widget-registry.test.tsx exists and asserts four numeric constraint fields per WidgetType
- Running the two test files fails (RED) because the implementation and constraint keys do not yet exist
</acceptance_criteria>
<done>Both test files exist and fail for the right reason (missing implementation), establishing the RED baseline.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Registry foundation for all four types + Calculator implementation (GREEN)</name>
<files>
apps/web/src/components/dashboard/widget-registry.tsx
apps/web/src/components/dashboard/widget-catalog-modal.tsx
apps/web/src/components/dashboard/widgets/calculator-widget.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/api/src/dashboard/dto/create-widget.dto.ts
</files>
<read_first>
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetType union, WIDGET_CONSTRAINTS, WIDGET_REGISTRY, wireClockWidget/wireNoteWidget pattern, inline SVG icon pattern)
- apps/web/src/components/dashboard/widget-catalog-modal.tsx (hardcoded WIDGET_TYPES array)
- apps/web/src/app/(portal)/page.tsx (wireXWidget calls and imports)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps + Tailwind pattern)
- /home/vicolab/Schreibtisch/personal-dashboard/src/components/CalculatorWidget.tsx (full calculator logic to port: parseDisplay, formatNumber, calculate, inputDigit, inputDecimal, chooseOperator, applyEquals, backspace, clearAll, clearEntry, toggleSign, applyUnary, memory functions, handleKeyboard)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Grid-Constraints table, Pattern 1, Pattern 6, i18n keys section, Anti-Patterns)
</read_first>
<action>
Extend widget-registry.tsx: add `'calculator' | 'favorites' | 'link' | 'stopwatch'` to the WidgetType union. Add WIDGET_CONSTRAINTS entries with the per-widget values from D-01 (research Grid-Constraints table): 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. Add four inline SVG icon components (CalculatorIcon, FavoritesIcon, LinkIcon, StopwatchIcon) following the existing ClockIcon SVG pattern (24x24, stroke currentColor). Add four WIDGET_REGISTRY entries (nameKey/descriptionKey pointing at the new i18n keys, icon, spread of WIDGET_CONSTRAINTS.<type>, component: PlaceholderWidget). Add four exported wiring functions wireCalculatorWidget, wireFavoritesWidget, wireLinkWidget, wireStopwatchWidget following the wireClockWidget guard pattern (module-level boolean flag, assign WIDGET_REGISTRY.<type>.component). Do NOT change existing clock/search/calendar/note constraint values.
Update widget-catalog-modal.tsx: extend the hardcoded WIDGET_TYPES array to include 'calculator', 'favorites', 'link', 'stopwatch' after the existing four (Pitfall 6).
Update create-widget.dto.ts: change the @IsIn list to accept all eight types: clock, search, calendar, note, calculator, favorites, link, stopwatch (Pitfall 7) so POST /dashboard/widgets does not reject the new types with 400.
Create calculator-widget.tsx as `export function CalculatorWidget({ config, isEditMode }: WidgetProps)` (import type WidgetProps from '../widget-registry'; 'use client'). Port the arithmetic and keyboard logic verbatim from the personal-dashboard reference (keep parseDisplay/formatNumber/calculate and all handlers). Replace all CSS-module classes (styles.*) with Tailwind utility classes matching the Tessera widget look (flex h-full flex-col, muted/foreground/primary tokens); do NOT import any .module.css. Keep the container attributes tabIndex={0} role="application" aria-label from t('calculator.name'), onKeyDown={handleKeyboard}, and keep event.preventDefault() + event.stopPropagation() in every handled key branch (Pitfall 1 — prevents react-grid-layout interference). The display element must expose aria-label "Anzeige". Use useTranslations('widgets') for the container aria-label; digit/operator button glyphs stay literal. The error sentinel returned by formatNumber stays "Fehler".
Wire the calculator into the dashboard: in page.tsx import CalculatorWidget and call wireCalculatorWidget(CalculatorWidget) alongside the existing wire calls.
Add i18n keys under the "widgets" namespace in both de.json and en.json for calculator, favorites, link, stopwatch using the key set from the research i18n section (calculator.name/description; favorites.name/description/loading/empty/addTitle/addUrl/addButton/listView/gridView/editButton/deleteButton/saveButton/cancelButton/error; link.name/description; stopwatch.name/description/start/stop/reset/lap). German values from research; English values as faithful translations.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run calculator-widget widget-registry</automated>
</verify>
<acceptance_criteria>
- calculator-widget.test.tsx and widget-registry.test.tsx pass (GREEN)
- grep -c "calculator" apps/web/src/components/dashboard/widget-catalog-modal.tsx returns >= 1
- create-widget.dto.ts contains 'stopwatch' inside the @IsIn array
- widget-registry.tsx exports wireCalculatorWidget, wireFavoritesWidget, wireLinkWidget, wireStopwatchWidget
- calculator-widget.tsx contains no import of a .module.css file (grep -c "module.css" apps/web/src/components/dashboard/widgets/calculator-widget.tsx returns 0)
- page.tsx contains wireCalculatorWidget(CalculatorWidget)
- Both de.json and en.json contain a "calculator" object under "widgets"
</acceptance_criteria>
<done>Adding a Calculator widget from the catalog renders a working calculator with mouse and keyboard input; all four new types are registered with unified constraint fields; the API accepts the new widget types.</done>
</task>
<task type="auto">
<name>Task 3: Full suite + type-check gate</name>
<files>
apps/web/src/components/dashboard/widget-registry.tsx
</files>
<read_first>
- apps/web/package.json (test + type-check scripts)
- apps/api/package.json (build/type-check scripts)
</read_first>
<action>
Run the full web test suite and TypeScript type-check for both web and api to confirm the registry/DTO changes did not break existing widgets (Clock, Search, Calendar, Note) or the API build. Fix any type errors introduced by the WidgetType union widening (e.g. exhaustive Record typing in WIDGET_CONSTRAINTS/WIDGET_REGISTRY). Do not add new features here.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Full @tessera/web vitest suite exits 0
- tsc --noEmit passes for @tessera/web
- tsc --noEmit passes for @tessera/api
</acceptance_criteria>
<done>Existing widgets remain functional and both packages type-check cleanly after the registry and DTO changes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (POST /dashboard/widgets) | Untrusted widgetType string crosses into the API |
| keyboard → grid | Calculator key events could bubble into react-grid-layout |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-01 | Tampering | create-widget.dto.ts | low | mitigate | @IsIn allow-list restricted to the eight known widget types; unknown types rejected with 400 |
| T-08-02 | Denial of Service | Calculator keyboard handler | low | mitigate | event.stopPropagation() in handled branches prevents grid drag/scroll interference (Pitfall 1) |
</threat_model>
<verification>
- pnpm --filter @tessera/web test --run passes (all widget suites including new calculator + registry)
- tsc --noEmit clean for web and api
- Manual smoke (optional): edit mode → add Calculator → 7 + 3 = 10; keyboard entry works
</verification>
<success_criteria>
- Calculator widget is addable from the catalog and performs correct arithmetic via mouse and keyboard (DASH-08)
- All WidgetType values carry minW/minH/defaultW/defaultH fields (DASH-11 structure per D-01)
- Registry wiring hooks for favorites/link/stopwatch exist for later plans to consume
</success_criteria>
<output>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,184 @@
---
phase: 08-dashboard-widgets-vollimplementierung
plan: 02
type: execute
wave: 2
depends_on: [08-01]
files_modified:
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
- apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
autonomous: true
requirements: [DASH-10]
must_haves:
truths:
- "User can start, stop/pause and reset a Stopwatch widget"
- "User can record lap times while the stopwatch runs"
- "A running stopwatch continues correctly after a page reload (reconstructed from persisted startedAt)"
artifacts:
- "apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx"
- "apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx"
key_links:
- "stopwatch-widget.tsx persists state via updateWidgetConfig(instanceId, ...)"
- "page.tsx calls wireStopwatchWidget(StopwatchWidget)"
---
<objective>
Deliver the Stopwatch widget vertical slice (DASH-10): start, stop/pause, reset, and lap recording, with state persisted into WidgetInstance.config so a running stopwatch survives a page reload.
Purpose: Add a persistent timer widget without any new backend endpoint — reuses the existing PATCH /dashboard/widgets/:id/config path (D-07).
Output: A user can add a Stopwatch widget, run it, record laps, reset it, and reload the page without losing elapsed time.
</objective>
<phase_goal>
**As a** portal user, **I want to** run a stopwatch with lap times on my dashboard that keeps counting after a reload, **so that** I can time activities without a separate tool and without losing progress.
</phase_goal>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
- Component: `StopwatchWidget` (apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx)
- Test file: `stopwatch-widget.test.tsx`
- StopwatchConfig shape stored in WidgetInstance.config: `{ state: 'running' | 'paused' | 'stopped', startedAt: string | null, elapsed: number, laps: number[] }`
- Wiring line in page.tsx: `wireStopwatchWidget(StopwatchWidget)` (consumes the hook created in Plan 01)
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Failing tests for Stopwatch behavior and reload reconstruction (RED)</name>
<files>
apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (fake timers, fetch spy, next-intl mock patterns)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps, setInterval pattern)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 5 stopwatch persistence, Pitfall 2 reload drift)
</read_first>
<behavior>
Mock next-intl (passthrough). Mock updateWidgetConfig from '@/lib/dashboard-api' with vi.fn resolving. Use vi.useFakeTimers.
- Initial render with config {} shows 00:00 (elapsed 0) and a Start control.
- Clicking Start then advancing timers by 3000ms shows elapsed ~00:03; updateWidgetConfig was called with state 'running' and a startedAt ISO string.
- Clicking Stop/Pause while running freezes the display and persists state 'paused' with accumulated elapsed (number of ms).
- Clicking Reset returns display to 00:00 and persists state 'stopped', elapsed 0, laps [].
- Clicking Lap while running appends the current elapsed to laps and renders at least one lap entry.
- Reload reconstruction: rendering with config { state: 'running', startedAt: <ISO 5s ago>, elapsed: 2000, laps: [] } shows elapsed of roughly 7000ms (2000 + 5000) — proving Date.now() - startedAt + elapsed math (Pitfall 2).
</behavior>
<action>
Create stopwatch-widget.test.tsx. Mock '@/lib/dashboard-api' so updateWidgetConfig is a spy. Set a fixed system time via vi.setSystemTime for deterministic reconstruction; compute the "5s ago" startedAt from that fixed now. Query controls by their accessible name via the mocked t() keys (stopwatch.start/stop/reset/lap). Assert display text via a stable test id data-testid="stopwatch-display". Import StopwatchWidget after mocks. Tests MUST fail now (component does not exist).
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run stopwatch-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- stopwatch-widget.test.tsx exists and imports from './stopwatch-widget'
- Covers start, stop/pause, reset, lap, and reload reconstruction
- Test run fails (RED) due to missing implementation
</acceptance_criteria>
<done>The stopwatch test file exists and fails for the right reason, defining the timer contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Stopwatch implementation with config persistence + wiring (GREEN)</name>
<files>
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
apps/web/src/app/(portal)/page.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (setInterval + WidgetProps pattern)
- apps/web/src/components/dashboard/widgets/note-widget.tsx (updateWidgetConfig usage, cleanup on unmount)
- apps/web/src/lib/dashboard-api.ts (updateWidgetConfig signature: (id, config, signal?))
- apps/web/src/app/(portal)/page.tsx (existing wireXWidget calls; wireStopwatchWidget already defined in Plan 01)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 5, Pitfall 2, Lap-Timer decision)
</read_first>
<action>
Create stopwatch-widget.tsx as `export function StopwatchWidget({ instanceId, config }: WidgetProps)` ('use client'; import type WidgetProps from '../widget-registry'; useTranslations('widgets')). Read initial state from config with the StopwatchConfig shape { state, startedAt, elapsed, laps }, defaulting to state 'stopped', startedAt null, elapsed 0, laps []. When state is 'running', compute live elapsed as Date.now() - new Date(startedAt).getTime() + (config.elapsed ?? 0) and tick via setInterval(…, 100) updating a display; clear the interval on unmount and when not running (Pitfall 2). Render the elapsed with a data-testid="stopwatch-display" formatted mm:ss (or hh:mm:ss when >= 1h) plus centiseconds optional.
Controls (labels via t('stopwatch.start'|'stop'|'reset'|'lap')):
- Start: if not running, set startedAt = new Date().toISOString(), keep accumulated elapsed, state 'running', persist via updateWidgetConfig(instanceId, {...}).
- Stop/Pause: freeze — compute accumulated elapsed, set state 'paused', startedAt null, persist.
- Reset: state 'stopped', startedAt null, elapsed 0, laps [], persist.
- Lap: while running, append current live elapsed (ms) to laps (newest first per research), persist; render laps as a scrollable list.
Use Tailwind only (flex h-full flex-col, muted/foreground/primary tokens). Wrap control buttons so their click handlers call stopPropagation to avoid grid-drag interference. Persist errors may be swallowed like note-widget (best-effort); do not block the UI.
Wire the widget: in page.tsx import StopwatchWidget and call wireStopwatchWidget(StopwatchWidget) next to the existing wire calls.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run stopwatch-widget</automated>
</verify>
<acceptance_criteria>
- stopwatch-widget.test.tsx passes (GREEN)
- stopwatch-widget.tsx calls updateWidgetConfig with a startedAt ISO string on Start
- Reload reconstruction test passes (elapsed derived from startedAt + stored elapsed)
- page.tsx contains wireStopwatchWidget(StopwatchWidget)
- grep -c "module.css" apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx returns 0
</acceptance_criteria>
<done>A user can start/stop/reset/lap the stopwatch; state persists to WidgetInstance.config and a running timer reconstructs correctly after reload.</done>
</task>
<task type="auto">
<name>Task 3: Suite + type-check gate</name>
<files>
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
</files>
<read_first>
- apps/web/package.json (scripts)
</read_first>
<action>
Run the full web test suite and web type-check to confirm the stopwatch slice did not regress other widgets. Fix any type errors. No new features.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Full @tessera/web vitest suite exits 0
- tsc --noEmit passes for @tessera/web
</acceptance_criteria>
<done>Stopwatch slice is complete with a green suite and clean type-check.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (PATCH /dashboard/widgets/:id/config) | Stopwatch config crosses into the API; ownership already enforced by DashboardService |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-03 | Tampering | stopwatch config persistence | low | mitigate | Reuses DashboardService.updateWidgetConfig ownership check (WHERE userId); no new endpoint added |
| T-08-04 | Denial of Service | setInterval tick | low | mitigate | Single interval cleared on unmount and when not running; no unbounded timers |
</threat_model>
<verification>
- pnpm --filter @tessera/web test --run passes (stopwatch suite + all others)
- tsc --noEmit clean for web
- Manual smoke (optional): start stopwatch, reload page, timer keeps counting; lap + reset work
</verification>
<success_criteria>
- Stopwatch widget supports start, stop/pause, reset and lap recording (DASH-10)
- Running stopwatch reconstructs elapsed time after reload from persisted startedAt/elapsed
</success_criteria>
<output>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,249 @@
---
phase: 08-dashboard-widgets-vollimplementierung
plan: 03
type: execute
wave: 3
depends_on: [08-02]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/src/favorites/favorites.module.ts
- apps/api/src/favorites/favorites.controller.ts
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/favorites/dto/create-favorite.dto.ts
- apps/api/src/favorites/dto/update-favorite.dto.ts
- apps/api/src/app.module.ts
- apps/web/src/lib/favorites-api.ts
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
autonomous: true
requirements: [DASH-09]
must_haves:
truths:
- "User can add, edit and delete favorite links directly in the Favorites widget in edit mode"
- "Favorite links persist across sessions in a FavoriteLink DB table scoped by userId and widgetId"
- "Adding a favorite triggers server-side icon discovery with SSRF protection; the icon is stored and rendered with a letter fallback"
- "User can switch the Favorites widget between list and grid view"
artifacts:
- "apps/api/src/favorites/favorites.service.ts"
- "apps/api/src/favorites/icon-discovery.service.ts"
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx"
- "FavoriteLink model in apps/api/prisma/schema.prisma"
key_links:
- "FavoritesController GET/POST/PATCH/DELETE /favorites scoped by userId + widgetId"
- "favorites-widget.tsx calls favorites-api.ts against NEXT_PUBLIC_API_URL with credentials include"
- "page.tsx calls wireFavoritesWidget(FavoritesWidget)"
---
<objective>
Deliver the Favorites widget vertical slice with backend persistence (DASH-09, D-02..D-05): a new FavoriteLink table, a NestJS FavoritesModule (CRUD + SSRF-protected icon discovery), a frontend API client, and the FavoritesWidget with inline add/edit/delete and list/grid views.
Purpose: Give users a persistent, per-widget favorites list with automatic favicon discovery, isolated per user and per widget instance.
Output: A user can add favorite links in a Favorites widget, see discovered icons (with letter fallback), edit/delete them, toggle list/grid view, and have everything persist across sessions.
</objective>
<phase_goal>
**As a** portal user, **I want to** manage a list of favorite links inside a dashboard widget with automatic site icons, **so that** I have persistent quick access to the sites I use most.
</phase_goal>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- Prisma model `FavoriteLink` (id, userId, tenantId, widgetId, title, url, iconUrl?, position, createdAt, updatedAt) with indexes on userId, tenantId, widgetId
- NestJS `FavoritesModule`, `FavoritesController`, `FavoritesService`, `IconDiscoveryService`
- DTOs `CreateFavoriteDto`, `UpdateFavoriteDto`
- `FavoritesModule` added to app.module.ts imports
- Frontend client `apps/web/src/lib/favorites-api.ts` (fetchFavorites, createFavorite, updateFavorite, deleteFavorite) and `FavoriteLink` type
- Component `FavoritesWidget` (apps/web/src/components/dashboard/widgets/favorites-widget.tsx) + test
- Wiring line in page.tsx: `wireFavoritesWidget(FavoritesWidget)` (consumes the hook created in Plan 01)
- REST routes: GET /favorites?widgetId=, POST /favorites, PATCH /favorites/:id, DELETE /favorites/:id
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Failing FavoritesWidget test (RED)</name>
<files>
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (fetch spy + next-intl mock)
- /home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx (UI behavior to adapt: load, add, edit, delete, list/grid toggle, letter fallback)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Favorites architecture, Pitfall 3 widgetId scope)
</read_first>
<behavior>
Mock next-intl (passthrough) and mock the favorites-api module ('@/lib/favorites-api') with vi.fn spies (fetchFavorites, createFavorite, updateFavorite, deleteFavorite). Import FavoritesWidget after mocks.
- On mount with instanceId "fav-1", fetchFavorites is called with "fav-1" (widgetId scope, Pitfall 3) and the returned links render (title text visible).
- Empty state: when fetchFavorites resolves [], the empty message (t('favorites.empty')) renders.
- Add (isEditMode true): filling title + url and submitting calls createFavorite with { widgetId: 'fav-1', title, url }; the new link appears.
- Edit: clicking edit on a link, changing the title, saving calls updateFavorite with the id and new title.
- Delete: clicking delete calls deleteFavorite with the id and removes the row.
- View toggle (isEditMode true): switching to grid renders the grid container variant; list is the default (D-03).
- Letter fallback: a link with iconUrl null renders the first uppercase letter of its title.
</behavior>
<action>
Create favorites-widget.test.tsx. Mock '@/lib/favorites-api' so all four functions are spies with controllable resolved values. Render FavoritesWidget with props instanceId, config, isEditMode. Query by accessible names from the mocked t() keys and by visible title text. Assert spy call arguments for widgetId scoping and CRUD payloads. Tests MUST fail now (component + api client do not exist).
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run favorites-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- favorites-widget.test.tsx exists and imports from './favorites-widget'
- Asserts fetchFavorites called with the instanceId (widgetId scope)
- Covers add/edit/delete, empty state, list/grid toggle, letter fallback
- Test run fails (RED) due to missing implementation
</acceptance_criteria>
<done>Favorites widget test exists and fails for the right reason, defining the CRUD + view contract.</done>
</task>
<task type="auto">
<name>Task 2: FavoriteLink schema + FavoritesModule (CRUD + SSRF icon discovery)</name>
<files>
apps/api/prisma/schema.prisma
apps/api/src/favorites/favorites.module.ts
apps/api/src/favorites/favorites.controller.ts
apps/api/src/favorites/favorites.service.ts
apps/api/src/favorites/icon-discovery.service.ts
apps/api/src/favorites/dto/create-favorite.dto.ts
apps/api/src/favorites/dto/update-favorite.dto.ts
apps/api/src/app.module.ts
</files>
<read_first>
- apps/api/prisma/schema.prisma (WidgetInstance/SearchProvider model + index conventions)
- apps/api/src/dashboard/dashboard.controller.ts (extractContext pattern: userId/tenantId from req.user / req.tenantId; ForbiddenException)
- apps/api/src/dashboard/dashboard.service.ts (ownership check pattern: findUnique then userId compare, NotFoundException)
- apps/api/src/dashboard/dashboard.module.ts (module shape)
- apps/api/src/dashboard/dto/create-widget.dto.ts (class-validator DTO pattern)
- apps/api/src/app.module.ts (imports array; PrismaModule is global)
- /home/vicolab/Schreibtisch/personal-dashboard/src/lib/favorite-icons.ts (full SSRF-protected icon discovery to port: isPrivateIpv4, isPrivateIpv6, isPrivateIpAddress, isBlockedHostname, isPublicHttpUrl, parseAttributes, toAbsoluteUrl, extractIconFromHtml, fetchHtml, discoverFavoriteIconUrl; constants HTML_FETCH_TIMEOUT_MS 4000, MAX_REDIRECTS 2, MAX_HTML_CHARS 200000, FALLBACK_ICON_PATH /favicon.ico)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 3 schema, Pattern 4 module, Pattern 5, Security Domain, Pitfalls 3/4/5)
</read_first>
<action>
Add the FavoriteLink model to schema.prisma exactly per D-02 / research Pattern 3: fields id (uuid pk), userId, tenantId, widgetId, title, url, iconUrl (nullable), position (Int default 0), createdAt (default now), updatedAt (@updatedAt); add @@index on userId, tenantId, widgetId. After editing the schema run `pnpm --filter @tessera/api exec prisma db push` then `pnpm --filter @tessera/api exec prisma generate` (Pitfall 4 — generate is required after db push).
Create icon-discovery.service.ts as an @Injectable() class IconDiscoveryService with an async method discoverFavoriteIconUrl(pageUrl: string): Promise<string>. Port the reference logic verbatim (DNS lookup → private-IP/blocked-hostname SSRF check → fetch with redirect 'manual' and manual redirect following up to MAX_REDIRECTS → HTML parse for apple-touch-icon / icon / shortcut icon / image_src / og:image → fallback origin /favicon.ico). Keep the SSRF checks and timeout intact; change only the User-Agent header value to "tessera/1.0". Never follow redirects automatically; re-run the public-URL check on each redirect target (Pitfall 5).
Create create-favorite.dto.ts: widgetId @IsUUID, title @IsString @IsNotEmpty, url @IsUrl, iconUrl @IsOptional @IsString, position @IsOptional @IsInt. Create update-favorite.dto.ts: all optional — title? @IsString, url? @IsUrl, iconUrl? @IsOptional (allow null), position? @IsInt.
Create favorites.service.ts (@Injectable, constructor injects PrismaService and IconDiscoveryService). Methods, every query scoped WHERE userId AND (for list) widgetId (V4 access control, Pitfall 3):
- list(userId, widgetId): findMany where userId + widgetId, orderBy position asc.
- create(userId, tenantId, dto): if dto.iconUrl absent, call iconDiscovery.discoverFavoriteIconUrl(dto.url) and store the result; persist with userId, tenantId, widgetId, title, url, iconUrl, position (default 0 or provided).
- update(id, userId, dto): findUnique; if missing or userId mismatch throw NotFoundException; update allowed fields (merge title/url/iconUrl/position).
- remove(id, userId): findUnique; ownership check; delete.
Create favorites.controller.ts @Controller('favorites') with the same private extractContext(req) helper as DashboardController. Routes: @Get() list(@Query('widgetId') widgetId) → service.list(userId, widgetId); @Post() create(@Body dto) → service.create(userId, tenantId, dto); @Patch(':id') update; @Delete(':id') remove. All rely on the global JwtAuthGuard + TenantGuard already applied app-wide.
Create favorites.module.ts (@Module controllers [FavoritesController], providers [FavoritesService, IconDiscoveryService]). Register FavoritesModule in app.module.ts imports (PrismaModule is global — no re-import).
</action>
<verify>
<automated>pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- schema.prisma contains "model FavoriteLink" with fields widgetId, iconUrl, position and @@index([widgetId])
- prisma generate succeeds and PrismaClient exposes favoriteLink (tsc references compile)
- icon-discovery.service.ts contains User-Agent "tessera/1.0" and uses redirect: 'manual'
- icon-discovery.service.ts retains the private-IP / blocked-hostname SSRF checks (grep -c "isPrivateIpAddress" apps/api/src/favorites/icon-discovery.service.ts returns >= 1)
- favorites.service.ts scopes every query by userId (no query without a userId filter)
- app.module.ts imports FavoritesModule
- tsc --noEmit passes for @tessera/api
</acceptance_criteria>
<done>The backend persists FavoriteLink rows per user+widget, discovers icons server-side with SSRF protection, and exposes scoped CRUD routes under /favorites.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: FavoritesWidget frontend + API client + wiring (GREEN)</name>
<files>
apps/web/src/lib/favorites-api.ts
apps/web/src/components/dashboard/widgets/favorites-widget.tsx
apps/web/src/app/(portal)/page.tsx
</files>
<read_first>
- apps/web/src/lib/dashboard-api.ts (fetch pattern: NEXT_PUBLIC_API_URL, credentials 'include', JSON headers)
- apps/web/src/components/dashboard/widgets/note-widget.tsx (updateWidgetConfig usage for persisting viewMode)
- /home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx (UI logic to adapt: sortedFavorites, add/edit/delete forms, list vs grid, icon + letter fallback, target _blank rel noreferrer)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetProps; wireFavoritesWidget defined in Plan 01)
- apps/web/src/app/(portal)/page.tsx (wire calls)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Favorites-API-Client example, i18n keys, Security XSS/open-redirect mitigations)
</read_first>
<action>
Create favorites-api.ts analogous to dashboard-api.ts: export a FavoriteLink type { id, widgetId, title, url, iconUrl: string | null, position } and functions fetchFavorites(widgetId): Promise<FavoriteLink[]> (GET /favorites?widgetId=…), createFavorite(payload: { widgetId, title, url, iconUrl? }): Promise<FavoriteLink> (POST), updateFavorite(id, payload: Partial<{title,url,iconUrl,position}>): Promise<FavoriteLink> (PATCH), deleteFavorite(id): Promise<void> (DELETE). All use `${API_URL}/favorites…` with credentials 'include' and JSON headers; API_URL from process.env.NEXT_PUBLIC_API_URL with the same localhost fallback as dashboard-api.ts.
Create favorites-widget.tsx as `export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps)` ('use client'; useTranslations('widgets')). Adapt the reference UI to Tailwind (no CSS modules, no favoriteX class names). Behavior:
- On mount and when instanceId changes, call fetchFavorites(instanceId) and store the list; show loading (t('favorites.loading')) then empty (t('favorites.empty')) when none.
- Sort by position asc then title (localeCompare).
- viewMode: read from config.viewMode ('list' default per D-03, or 'grid'); render list layout (icon + title stacked rows) or grid layout (tiles) with clean formatting (no clipped text). Show the list/grid toggle only in edit mode; persist the choice via updateWidgetConfig(instanceId, { viewMode }).
- Add/edit/delete only in edit mode (D-04): a '+' add form (title, url, optional icon URL) calling createFavorite; inline edit form per row calling updateFavorite; delete button calling deleteFavorite. Update local state on success; show t('favorites.error') on failure.
- Each favorite renders as an anchor with target="_blank" rel="noreferrer" (open-redirect mitigation) wrapping an icon: <img src={iconUrl}> with onError hiding the image, plus a letter-fallback span (first uppercase letter of title). Never use dangerouslySetInnerHTML (XSS mitigation).
- Add className "widgetNoDrag" (or stopPropagation) on interactive controls so clicks/drag inside the widget do not trigger grid drag.
Wire the widget: in page.tsx import FavoritesWidget and call wireFavoritesWidget(FavoritesWidget) next to the existing wire calls.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run favorites-widget && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- favorites-widget.test.tsx passes (GREEN)
- favorites-api.ts uses credentials 'include' and hits `${API_URL}/favorites`
- favorites-widget.tsx opens links with rel="noreferrer" and target="_blank"
- favorites-widget.tsx contains no dangerouslySetInnerHTML (grep -c "dangerouslySetInnerHTML" apps/web/src/components/dashboard/widgets/favorites-widget.tsx returns 0)
- grep -c "module.css" apps/web/src/components/dashboard/widgets/favorites-widget.tsx returns 0
- page.tsx contains wireFavoritesWidget(FavoritesWidget)
- tsc --noEmit passes for @tessera/web
</acceptance_criteria>
<done>A user can add/edit/delete favorites in the widget, switch list/grid view, and see discovered icons with letter fallback; data persists via the FavoritesModule.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (/favorites) | Untrusted title/url/widgetId cross into the API |
| API → external web (icon discovery) | Server fetches an attacker-controllable URL — SSRF surface |
| stored iconUrl → browser | Server-supplied URL rendered in an <img> tag |
| favorite url → new tab | User-clicked link opens externally |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-05 | Elevation of Privilege | IconDiscoveryService (SSRF) | high | mitigate | Port full SSRF guard: block localhost/.local/0.0.0.0, RFC1918/CGNAT/link-local/IPv6 private ranges, DNS-resolve and re-check every redirect target; redirect 'manual'; 4000ms timeout; 200k HTML cap |
| T-08-06 | Spoofing | FavoritesService queries | high | mitigate | Every query scoped WHERE userId (+ widgetId for list); NotFoundException on ownership mismatch (Pitfall 3) |
| T-08-07 | Tampering (XSS) | favorites-widget.tsx icon render | medium | mitigate | Render iconUrl only via <img src>; no dangerouslySetInnerHTML |
| T-08-08 | Spoofing (open redirect) | favorite link anchor | low | mitigate | Links open with target="_blank" rel="noreferrer"; no server-side redirect |
| T-08-09 | Denial of Service | icon discovery HTML fetch | medium | mitigate | MAX_HTML_CHARS 200000 truncation + 4000ms AbortController timeout |
| T-08-SC | Tampering | npm/pip/cargo installs | high | accept | No new packages this phase (RESEARCH Package Legitimacy Audit: none); Node built-ins only |
</threat_model>
<verification>
- pnpm --filter @tessera/web test --run passes (favorites suite + all others)
- pnpm --filter @tessera/api exec tsc --noEmit clean; prisma generate succeeds
- Manual smoke (optional): add a favorite for a public URL → icon discovered; add localhost URL → falls back to /favicon.ico (no internal fetch); reload → favorites persist
</verification>
<success_criteria>
- Favorites persist in the FavoriteLink table scoped by userId + widgetId (DASH-09)
- Inline add/edit/delete works in edit mode; list and grid views both render cleanly (D-03/D-04)
- Icon discovery runs server-side with SSRF protection; iconUrl rendered with letter fallback (D-05)
</success_criteria>
<output>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,186 @@
---
phase: 08-dashboard-widgets-vollimplementierung
plan: 04
type: execute
wave: 4
depends_on: [08-03]
files_modified:
- apps/web/src/components/dashboard/widgets/link-widget.tsx
- apps/web/src/components/dashboard/widgets/link-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
autonomous: true
requirements: [DASH-09]
must_haves:
truths:
- "User can add a Link widget that displays exactly one link with a discovered icon"
- "User can set/edit the single link in edit mode; it persists via the shared FavoriteLink backend keyed by widgetId"
- "User can switch the Link widget between row (list) and tile view"
artifacts:
- "apps/web/src/components/dashboard/widgets/link-widget.tsx"
- "apps/web/src/components/dashboard/widgets/link-widget.test.tsx"
key_links:
- "link-widget.tsx reuses favorites-api.ts against /favorites with widgetId = instanceId"
- "page.tsx calls wireLinkWidget(LinkWidget)"
---
<objective>
Deliver the single-Link widget vertical slice (DASH-09 / D-06): a separate widget type that shows exactly one link, sharing the FavoriteLink backend from Plan 03 (widgetId scoping keeps each Link widget's entry isolated).
Purpose: Provide a compact one-link quick-access tile reusing the favorites infrastructure — no new backend.
Output: A user can add a Link widget, set its single link (icon auto-discovered), edit it, and toggle between row and tile presentation; the link persists across sessions.
</objective>
<phase_goal>
**As a** portal user, **I want to** pin a single important link as its own compact dashboard tile, **so that** I can open my most-used destination in one click.
</phase_goal>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-03-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- Component `LinkWidget` (apps/web/src/components/dashboard/widgets/link-widget.tsx) + test
- Wiring line in page.tsx: `wireLinkWidget(LinkWidget)` (consumes the hook created in Plan 01)
- No backend changes — reuses FavoritesModule + favorites-api.ts from Plan 03 (widgetId = instanceId)
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Failing LinkWidget test (RED)</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.test.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock of '@/lib/favorites-api', next-intl mock — reuse the same approach)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md (D-06 single-link widget)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (link constraints 2x2, i18n link keys)
</read_first>
<behavior>
Mock next-intl (passthrough) and '@/lib/favorites-api' spies. Import LinkWidget after mocks.
- On mount with instanceId "link-1", fetchFavorites is called with "link-1"; the first (only) link renders as an anchor with target="_blank" rel="noreferrer".
- Empty + edit mode: with no link and isEditMode true, an add form is shown; submitting title + url calls createFavorite with { widgetId: 'link-1', title, url }.
- Edit mode with an existing link: editing calls updateFavorite with the id; there is no way to add a second link (single-link constraint — add form hidden once one exists).
- View toggle (edit mode): switching between row and tile renders the corresponding variant; row is default.
- Non-edit mode: renders the link only, no form/controls.
- Icon fallback: link with iconUrl null shows the first uppercase letter of the title.
</behavior>
<action>
Create link-widget.test.tsx mirroring favorites-widget.test.tsx setup. Assert single-link enforcement (add form absent when a link exists). Query controls by mocked t() keys (link.*, reuse favorites.* form labels where applicable). Tests MUST fail now (component missing).
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run link-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- link-widget.test.tsx exists and imports from './link-widget'
- Asserts fetchFavorites called with instanceId and single-link enforcement
- Test run fails (RED) due to missing implementation
</acceptance_criteria>
<done>Link widget test exists and fails for the right reason, defining the single-link contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: LinkWidget implementation + wiring (GREEN)</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.tsx
apps/web/src/app/(portal)/page.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx (icon render, letter fallback, list/tile styling, edit forms, widgetNoDrag pattern — reuse structure)
- apps/web/src/lib/favorites-api.ts (fetchFavorites/createFavorite/updateFavorite/deleteFavorite, FavoriteLink type)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetProps; wireLinkWidget defined in Plan 01)
- apps/web/src/app/(portal)/page.tsx (wire calls)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (D-06, XSS/open-redirect mitigations)
</read_first>
<action>
Create link-widget.tsx as `export function LinkWidget({ instanceId, config, isEditMode }: WidgetProps)` ('use client'; useTranslations('widgets')). Reuse the favorites patterns but constrained to a single entry:
- On mount call fetchFavorites(instanceId); take the first element as the current link (there should be at most one for this widgetId).
- viewMode from config.viewMode: 'list' (row: icon + title in one line) or 'grid' (tile). Default 'list' (D-06). Show the toggle only in edit mode; persist via updateWidgetConfig(instanceId, { viewMode }).
- When no link exists and isEditMode: show an add form (title, url, optional icon URL) calling createFavorite({ widgetId: instanceId, ... }); after success, hide the add form (single-link enforcement — never render the add form while a link exists).
- When a link exists and isEditMode: show inline edit (updateFavorite) and delete (deleteFavorite) controls.
- Render the link as an anchor target="_blank" rel="noreferrer" with <img src={iconUrl}> (onError hides image) + letter fallback span. No dangerouslySetInnerHTML. Tailwind only. Interactive controls carry widgetNoDrag / stopPropagation.
Wire the widget: in page.tsx import LinkWidget and call wireLinkWidget(LinkWidget) next to the existing wire calls.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run link-widget && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- link-widget.test.tsx passes (GREEN)
- link-widget.tsx opens the link with target="_blank" rel="noreferrer"
- link-widget.tsx contains no dangerouslySetInnerHTML and no .module.css import (both greps return 0)
- page.tsx contains wireLinkWidget(LinkWidget)
- tsc --noEmit passes for @tessera/web
</acceptance_criteria>
<done>A user can add/edit a single link in a Link widget with an auto-discovered icon; it persists via the shared favorites backend and supports row/tile views.</done>
</task>
<task type="auto">
<name>Task 3: Full phase suite + type-check gate</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.tsx
</files>
<read_first>
- apps/web/package.json (scripts)
- apps/api/package.json (scripts)
</read_first>
<action>
Run the complete web test suite plus web and api type-checks to confirm all four Phase-8 widgets (Calculator, Stopwatch, Favorites, Link) and the existing widgets are green together. Fix any residual type errors. This is the phase gate before verification.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Full @tessera/web vitest suite exits 0 (all widget suites)
- tsc --noEmit passes for @tessera/web and @tessera/api
</acceptance_criteria>
<done>All Phase-8 widgets pass together with clean type-checks — phase ready for /gsd-verify-work.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (/favorites) | Link widget reuses the favorites endpoints; same untrusted input surface |
| stored iconUrl → browser | Server-supplied URL rendered in an <img> tag |
| link url → new tab | User-clicked link opens externally |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-10 | Spoofing | Link widget favorites reuse | high | mitigate | Reuses FavoritesService userId + widgetId scoping from Plan 03 (T-08-06); no new endpoint |
| T-08-11 | Tampering (XSS) | link-widget.tsx icon render | medium | mitigate | Render iconUrl only via <img src>; no dangerouslySetInnerHTML |
| T-08-12 | Spoofing (open redirect) | link anchor | low | mitigate | Anchor opens with target="_blank" rel="noreferrer" |
</threat_model>
<verification>
- pnpm --filter @tessera/web test --run passes (link suite + all Phase-8 widgets)
- tsc --noEmit clean for web and api
- Manual smoke (optional): add Link widget → set one link → icon discovered; add form disappears; reload persists; row/tile toggle works
</verification>
<success_criteria>
- Link widget displays exactly one link with discovered icon and letter fallback (DASH-09 / D-06)
- Single link is settable/editable in edit mode and persists via the shared FavoriteLink backend keyed by widgetId
- Row and tile views both render cleanly
</success_criteria>
<output>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md` when done
</output>