From 4657e504946e157dd58bdd409b196d3343bd7966 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:46:50 +0200 Subject: [PATCH 1/3] test(08-04): add failing tests for LinkWidget single-link contract (RED) - fetchFavorites called with instanceId on mount - Link renders as anchor with target="_blank" rel="noreferrer" (T-08-12) - Empty + edit mode shows add form; createFavorite called on submit - Single-link enforcement: add form hidden when link exists (D-06) - Edit mode: updateFavorite called with id and new title - View toggle: list default, tile container on gridView click - Letter fallback: iconUrl null shows first uppercase letter --- .../dashboard/widgets/link-widget.test.tsx | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/link-widget.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/link-widget.test.tsx b/apps/web/src/components/dashboard/widgets/link-widget.test.tsx new file mode 100644 index 0000000..59748cc --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/link-widget.test.tsx @@ -0,0 +1,231 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock next-intl — passthrough t(key) => key (must be before imports) +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock favorites-api — all 4 functions as controllable vi.fn() spies +vi.mock('@/lib/favorites-api', () => ({ + fetchFavorites: vi.fn(), + createFavorite: vi.fn(), + updateFavorite: vi.fn(), + deleteFavorite: vi.fn(), +})); + +// Mock dashboard-api for updateWidgetConfig (view mode persistence) +vi.mock('@/lib/dashboard-api', () => ({ + updateWidgetConfig: vi.fn().mockResolvedValue(undefined), +})); + +import { + fetchFavorites, + createFavorite, + updateFavorite, +} from '@/lib/favorites-api'; + +// Must import AFTER mocks +import { LinkWidget } from './link-widget'; + +// Typed mock helpers +const mockFetch = fetchFavorites as ReturnType; +const mockCreate = createFavorite as ReturnType; +const mockUpdate = updateFavorite as ReturnType; + +const SINGLE_LINK = { + id: 'link-id-1', + widgetId: 'link-1', + title: 'GitHub', + url: 'https://github.com', + iconUrl: 'https://github.com/favicon.ico', + position: 0, +}; + +describe('LinkWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue([SINGLE_LINK]); + mockCreate.mockResolvedValue({ + id: 'link-id-new', + widgetId: 'link-1', + title: 'New Site', + url: 'https://example.com', + iconUrl: null, + position: 0, + }); + mockUpdate.mockResolvedValue({ ...SINGLE_LINK, title: 'GitHub Updated' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls fetchFavorites with instanceId on mount and renders the link as an anchor with target="_blank" rel="noreferrer"', async () => { + render( + , + ); + + // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) + expect(mockFetch).toHaveBeenCalledWith('link-1'); + + // After async resolution, link title should be visible + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Link must open in new tab with noreferrer (T-08-12) + const anchor = screen.getByText('GitHub').closest('a'); + expect(anchor).toHaveAttribute('target', '_blank'); + expect(anchor).toHaveAttribute('rel', 'noreferrer'); + }); + + it('shows empty message in non-edit mode when no link exists', async () => { + mockFetch.mockResolvedValue([]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('link.empty')).toBeInTheDocument(); + }); + }); + + it('shows add form in edit mode when no link exists; submitting calls createFavorite with widgetId, title, url', async () => { + mockFetch.mockResolvedValue([]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByPlaceholderText('link.addTitle')).toBeInTheDocument(); + }); + + const titleInput = screen.getByPlaceholderText('link.addTitle'); + const urlInput = screen.getByPlaceholderText('link.addUrl'); + + act(() => { + fireEvent.change(titleInput, { target: { value: 'New Site' } }); + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + }); + + const addButton = screen.getByRole('button', { name: /link\.addButton/i }); + act(() => { + fireEvent.click(addButton); + }); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + widgetId: 'link-1', + title: 'New Site', + url: 'https://example.com', + }), + ); + }); + + // New link title should appear after creation + await waitFor(() => { + expect(screen.getByText('New Site')).toBeInTheDocument(); + }); + }); + + it('single-link enforcement: add form is hidden when a link already exists', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Add form must NOT be present — single-link constraint (D-06) + expect(screen.queryByPlaceholderText('link.addTitle')).not.toBeInTheDocument(); + }); + + it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Click the edit button (aria-label is the translation key) + const editButton = screen.getByRole('button', { name: /link\.editButton/i }); + act(() => { + fireEvent.click(editButton); + }); + + // Inline edit form appears with current title pre-filled + await waitFor(() => { + expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument(); + }); + + const editTitleInput = screen.getByDisplayValue('GitHub'); + act(() => { + fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } }); + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /link\.saveButton/i })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole('button', { name: /link\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + // updateFavorite called with the link id and new title + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + 'link-id-1', + expect.objectContaining({ title: 'GitHub Updated' }), + ); + }); + }); + + it('view toggle: list is default; switching to tile in edit mode renders tile container', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Default list view — list container should be present + expect(screen.getByTestId('link-list')).toBeInTheDocument(); + + // Click the tile (grid) toggle button + const tileButton = screen.getByRole('button', { name: /link\.gridView/i }); + await act(async () => { + fireEvent.click(tileButton); + }); + + // Tile container should now be rendered + expect(screen.getByTestId('link-tile')).toBeInTheDocument(); + }); + + it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { + mockFetch.mockResolvedValue([{ ...SINGLE_LINK, iconUrl: null, title: 'Notion' }]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Notion')).toBeInTheDocument(); + }); + + // Letter fallback span should show 'N' (first letter of 'Notion') + expect(screen.getByTestId('link-letter-fallback')).toHaveTextContent('N'); + }); +}); From e9914f61cb711772c22fa0715944972766af9c5b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:48:58 +0200 Subject: [PATCH 2/3] feat(08-04): LinkWidget implementation + page.tsx wiring (GREEN) - LinkWidget: single-link widget reusing FavoriteLink backend (D-06) - Single-link enforcement: add form hidden when link exists - List (row) and tile (grid) view modes, switchable in edit mode - Inline add/edit/delete forms in edit mode - Letter fallback span + icon with onError hide (T-08-11) - Links with target="_blank" rel="noreferrer" (T-08-12) - i18n keys added to de.json + en.json (link.*) - wireLinkWidget(LinkWidget) added to portal page.tsx - All 7 link-widget tests pass (GREEN); tsc --noEmit clean --- apps/web/src/app/(portal)/page.tsx | 4 +- .../dashboard/widgets/link-widget.tsx | 381 ++++++++++++++++++ apps/web/src/messages/de.json | 14 +- apps/web/src/messages/en.json | 14 +- 4 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/dashboard/widgets/link-widget.tsx diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 14a51cd..1d72c56 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wireLinkWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; @@ -13,6 +13,7 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; +import { LinkWidget } from '@/components/dashboard/widgets/link-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -24,6 +25,7 @@ wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); wireStopwatchWidget(StopwatchWidget); wireFavoritesWidget(FavoritesWidget); +wireLinkWidget(LinkWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/widgets/link-widget.tsx b/apps/web/src/components/dashboard/widgets/link-widget.tsx new file mode 100644 index 0000000..412b64b --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/link-widget.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { updateWidgetConfig } from '@/lib/dashboard-api'; +import { + createFavorite, + deleteFavorite, + fetchFavorites, + updateFavorite, + type FavoriteLink, +} from '@/lib/favorites-api'; +import type { WidgetProps } from '@/components/dashboard/widget-registry'; + +/** + * LinkWidget — single-link quick-access tile (DASH-09 / D-06). + * + * Features: + * - Displays exactly one link, scoped to this widget instance via instanceId + * - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId) + * - Single-link enforcement: add form hidden when a link already exists + * - Add / edit / delete in edit mode (inline forms) + * - List view (row: icon + title, default) and tile view, switchable in edit mode + * - Server-side icon discovery (letter fallback when iconUrl is null) + * - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation) + * - No dangerouslySetInnerHTML (T-08-11 XSS mitigation) + */ +export function LinkWidget({ + instanceId, + config, + isEditMode, +}: WidgetProps) { + const t = useTranslations('widgets'); + + const [link, setLink] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // View mode: read from config, default 'list' (D-06) + const [viewMode, setViewMode] = useState<'list' | 'grid'>( + (config.viewMode as 'list' | 'grid') ?? 'list', + ); + + // Add-form state + const [newTitle, setNewTitle] = useState(''); + const [newUrl, setNewUrl] = useState(''); + + // Inline edit state + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editUrl, setEditUrl] = useState(''); + const [editIconUrl, setEditIconUrl] = useState(''); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const data = await fetchFavorites(instanceId); + // Single-link: take only the first entry (D-06) + if (!cancelled) setLink(data[0] ?? null); + } catch { + // Stable error key — t excluded from deps to prevent re-fetch loops + if (!cancelled) setError('link.error'); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + + return () => { + cancelled = true; + }; + // instanceId is the only meaningful dependency — t is excluded intentionally + // (mock creates new fn ref on each render, causing re-fetch loops in tests) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId]); + + function getFallbackLetter(title: string): string { + const clean = title.trim(); + return clean ? clean.slice(0, 1).toUpperCase() : '?'; + } + + // --- View mode toggle (edit mode only) --- + function handleViewMode(next: 'list' | 'grid') { + setViewMode(next); + void updateWidgetConfig(instanceId, { viewMode: next }); + } + + // --- Add link --- + async function handleAdd(e: FormEvent) { + e.preventDefault(); + const cleanTitle = newTitle.trim(); + const cleanUrl = newUrl.trim(); + if (!cleanTitle || !cleanUrl) return; + + try { + const created = await createFavorite({ + widgetId: instanceId, + title: cleanTitle, + url: cleanUrl, + }); + setLink(created); + setNewTitle(''); + setNewUrl(''); + } catch { + setError('link.error'); + } + } + + // --- Edit link --- + function startEdit() { + if (!link) return; + setIsEditing(true); + setEditTitle(link.title); + setEditUrl(link.url); + setEditIconUrl(link.iconUrl ?? ''); + setError(null); + } + + function cancelEdit() { + setIsEditing(false); + setEditTitle(''); + setEditUrl(''); + setEditIconUrl(''); + } + + async function handleSaveEdit() { + if (!link) return; + const cleanTitle = editTitle.trim(); + const cleanUrl = editUrl.trim(); + const cleanIconUrl = editIconUrl.trim(); + if (!cleanTitle || !cleanUrl) return; + + try { + const updated = await updateFavorite(link.id, { + title: cleanTitle, + url: cleanUrl, + iconUrl: cleanIconUrl || null, + }); + setLink(updated); + cancelEdit(); + } catch { + setError('link.error'); + } + } + + // --- Delete link --- + async function handleDelete() { + if (!link) return; + try { + await deleteFavorite(link.id); + setLink(null); + cancelEdit(); + } catch { + setError('link.error'); + } + } + + // --- Shared icon element --- + function renderIcon(size: 'sm' | 'md') { + if (!link) return null; + const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8'; + const textSize = size === 'sm' ? 'text-xs' : 'text-sm'; + const imgSize = size === 'sm' ? 20 : 32; + return ( +
+ + {getFallbackLetter(link.title)} + + {link.iconUrl && ( + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+ ); + } + + // --- Inline edit form --- + function renderEditForm() { + return ( +
+ setEditTitle(e.target.value)} + placeholder={t('link.addTitle')} + /> + setEditUrl(e.target.value)} + placeholder={t('link.addUrl')} + /> + setEditIconUrl(e.target.value)} + placeholder="Logo-URL (optional)" + /> +
+ + +
+
+ ); + } + + // --- Render --- + return ( +
+ {/* View mode toggle (edit mode only) */} + {isEditMode && ( +
+ + +
+ )} + + {/* Status messages */} + {loading && ( +

{t('link.loading')}

+ )} + {error && ( +

{error}

+ )} + + {/* Empty state */} + {!loading && !link && ( +

{t('link.empty')}

+ )} + + {/* Link display — list (row) view */} + {!loading && link && !isEditing && viewMode === 'list' && ( + + )} + + {/* Link display — tile (grid) view */} + {!loading && link && !isEditing && viewMode === 'grid' && ( + + )} + + {/* Inline edit form */} + {isEditMode && isEditing && link && renderEditForm()} + + {/* Add form — single-link enforcement: hidden when link exists (D-06) */} + {isEditMode && !link && !loading && ( +
void handleAdd(e)} + className="flex flex-col gap-1 widgetNoDrag" + > + setNewTitle(e.target.value)} + placeholder={t('link.addTitle')} + /> + setNewUrl(e.target.value)} + placeholder={t('link.addUrl')} + /> + +
+ )} +
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 374f008..337b8aa 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -215,7 +215,19 @@ }, "link": { "name": "Link", - "description": "Einzelner Schnellzugriff-Link" + "description": "Einzelner Schnellzugriff-Link", + "loading": "Link wird geladen...", + "empty": "Noch kein Link gesetzt.", + "addTitle": "Titel", + "addUrl": "URL", + "addButton": "Hinzufuegen", + "listView": "Liste", + "gridView": "Kachel", + "editButton": "Link bearbeiten", + "deleteButton": "Link loeschen", + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "error": "Fehler beim Laden des Links" }, "stopwatch": { "name": "Stoppuhr", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 7a4d63f..afb9163 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -215,7 +215,19 @@ }, "link": { "name": "Link", - "description": "Single quick-access link" + "description": "Single quick-access link", + "loading": "Loading link...", + "empty": "No link set yet.", + "addTitle": "Title", + "addUrl": "URL", + "addButton": "Add", + "listView": "List", + "gridView": "Tile", + "editButton": "Edit link", + "deleteButton": "Delete link", + "saveButton": "Save", + "cancelButton": "Cancel", + "error": "Error loading link" }, "stopwatch": { "name": "Stopwatch", From dcff93e2aeb9bb042d4e322445909e93a7300bcc Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:51:08 +0200 Subject: [PATCH 3/3] docs(08-04): complete Link widget plan summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3/3 tasks complete (RED test → GREEN implementation → full suite gate) - 88/88 web tests passing (18 test files, +7 link-widget tests) - web + api tsc --noEmit clean - LinkWidget: single-link D-06 via FavoriteLink backend reuse --- .../08-04-SUMMARY.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md diff --git a/.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md new file mode 100644 index 0000000..fcc7189 --- /dev/null +++ b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md @@ -0,0 +1,144 @@ +--- +phase: 08-dashboard-widgets-vollimplementierung +plan: "04" +subsystem: dashboard-widgets +tags: [link, widget, tdd, dashboard, persistence, i18n, icons, single-link] +status: complete + +dependency_graph: + requires: + - 08-01 (wireLinkWidget, WIDGET_CONSTRAINTS.link, i18n keys) + - 08-03 (FavoriteLink backend, favorites-api.ts — shared via widgetId scoping) + provides: + - LinkWidget component (single-link, list/tile view, add/edit/delete, letter fallback) + - wireLinkWidget() wired in page.tsx + - i18n keys: link.* (de.json + en.json) + affects: + - apps/web/src/app/(portal)/page.tsx + +tech_stack: + added: [] + patterns: + - TDD Red/Green cycle (LinkWidget single-link behavioral contract) + - Single-link enforcement via conditional rendering (add form hidden when link!=null) + - Reuse of FavoriteLink backend (widgetId=instanceId, no new backend) + - useEffect dep array excludes t() to prevent re-fetch loops (from Plan 03) + - Letter fallback span always in DOM (img layered on top via absolute positioning) + - target="_blank" rel="noreferrer" anchor pattern (T-08-12) + +key_files: + created: + - apps/web/src/components/dashboard/widgets/link-widget.tsx + - apps/web/src/components/dashboard/widgets/link-widget.test.tsx + modified: + - apps/web/src/app/(portal)/page.tsx (wireLinkWidget added) + - apps/web/src/messages/de.json (link.* keys added) + - apps/web/src/messages/en.json (link.* keys added) + +decisions: + - "Single-link enforcement: add form rendered only when link==null (conditional JSX), not via counter/limit logic — simpler and testable" + - "renderIcon() helper extracted to avoid duplication between list+tile view (same icon logic, different size)" + - "useEffect dep [instanceId] only — same rationale as Plan 03 (t() mock ref causes re-fetch loops)" + - "letter-fallback testid: 'link-letter-fallback' (single link, no id suffix needed unlike favorites)" + - "Worktree fast-forward: git merge main --ff-only before any task work (same pattern as Plan 03)" + +metrics: + duration: "~4 minutes" + completed: "2026-07-01" + tasks_completed: 3 + tests_added: 7 + files_created: 2 + files_modified: 3 +--- + +# Phase 08 Plan 04: Link Widget Summary + +Single-Link-Widget (DASH-09 / D-06) via TDD Red/Green-Zyklus: ein kompaktes Widget das genau einen Link anzeigt, den FavoriteLink-Backend von Plan 03 (widgetId-Scoping) wiederverwendet, und zwischen Zeilen- und Kachelansicht umgeschaltet werden kann. + +## What Was Built + +- **LinkWidget** (`link-widget.tsx`): 'use client', useTranslations('widgets'). Laed beim Mount via fetchFavorites(instanceId) und nimmt den ersten Eintrag als den einzigen Link (Single-Link-Constraint D-06). Kein neues Backend — FavoriteLink-Tabelle und FavoritesModule aus Plan 03 werden via widgetId=instanceId isoliert genutzt. + +- **Single-Link-Enforcement**: Das Hinzufuegen-Formular wird nur gerendert wenn `link == null` (nicht wenn ein Link bereits existiert). Das ist der "D-06: genau einen Link" Contract. + +- **Ansichten (D-06)**: Standard-Listenansicht (`data-testid="link-list"`: Icon + Titel in einer Zeile) und Kachelansicht (`data-testid="link-tile"`: Icon oben, Titel darunter zentriert). Umschaltbar im Edit-Modus via viewMode-Toggle-Buttons, persistent via updateWidgetConfig(). + +- **CRUD im Edit-Modus**: Hinzufuegen-Formular (wenn kein Link vorhanden), Inline-Bearbeiten (Titel, URL, optionale Logo-URL), Loeschen-Button. Alle CRUD-Operationen rufen createFavorite/updateFavorite/deleteFavorite auf. + +- **Icon-Display**: `` mit onError-Hide + `` als Buchstaben-Fallback. Kein dangerouslySetInnerHTML (T-08-11). renderIcon()-Helper fuer list/tile. + +- **Sicherheit**: Links mit `target="_blank" rel="noreferrer"` (T-08-12). Anchor-Klick im Edit-Modus via preventDefault() blockiert. + +- **i18n**: `link.*`-Keys in de.json und en.json hinzugefuegt: loading, empty, addTitle, addUrl, addButton, listView, gridView, editButton, deleteButton, saveButton, cancelButton, error. + +- **page.tsx Wiring**: `wireLinkWidget(LinkWidget)` und entsprechender Import hinzugefuegt. + +## Tasks + +| Task | Description | Status | Commit | +|------|-------------|--------|--------| +| 1 | RED: Failing tests fuer LinkWidget single-link contract | Complete | 4657e50 | +| 2 | GREEN: LinkWidget implementation + i18n keys + page.tsx wiring | Complete | e9914f6 | +| 3 | Full Phase-8 Suite + Type-Check Gate | Complete | (verification only) | + +## Test Results + +- **link-widget.test.tsx**: 7 Tests — fetchFavorites mit instanceId, Anchor target/_blank/rel=noreferrer, empty state, add-Formular, single-link enforcement, inline edit, view toggle (list/tile), letter fallback +- **Full Web Suite**: 88/88 Tests bestehen (18 Test-Dateien; +7 neue gegenueber Plan 03's 81/81) +- **Web TypeScript**: sauber (0 Fehler) +- **API TypeScript**: sauber (0 Fehler) + +## Deviations from Plan + +### Worktree Fast-Forward (auto-fix, Rule 3) + +**Found during:** Pre-task setup + +**Issue:** Das Worktree-Branch `worktree-agent-a2d7feebc2349e393` war bei `88db54f` (Quick-Fix-Commit aus frueherem Task), nicht am erwarteten Basis-Commit `7b42a3a` (Main nach Wave 3). Phase-08 wave-1, wave-2 und wave-3 Artefakte (widget-registry, FavoritesModule, StopwatchWidget) fehlten im Worktree. + +**Fix:** `git merge main --ff-only` — Fast-Forward auf `7b42a3a`. Keine Konflikte. Node-Modules-Symlinks auf main repo danach erstellt. + +**Impact:** Keiner. Alle vorherigen Plan-Artefakte standen vollstaendig zur Verfuegung. + +### Node-Modules-Symlinks (auto-fix, Rule 3) + +**Found during:** Task 1 Verifikation (RED-Phase) + +**Issue:** Worktree hatte keine node_modules (vitest: not found). + +**Fix:** Symlinks von `MAIN_REPO/{,apps/web,apps/api,packages/shared}/node_modules` -> entsprechende Worktree-Verzeichnisse. + +**Impact:** Keiner. + +### dangerouslySetInnerHTML grep-count = 1 (kein Problem) + +Das Vorkommen ist in einem **JSDoc-Kommentar** (Zeile 26) der erklaert WARUM dangerouslySetInnerHTML nicht verwendet wird. Kein tatsaechlicher JSX-Attribut-Einsatz. T-08-11 korrekt mitigiert. + +## Known Stubs + +Keine Stubs. LinkWidget ist vollstaendig implementiert und mit dem FavoriteLink-Backend verbunden. + +## Threat Surface Scan + +Alle Threats aus dem Plan-Threat-Register wurden mitigiert. Keine neuen Sicherheitsoberflaechen eingefuehrt: + +| Threat ID | Status | +|-----------|--------| +| T-08-10 (Spoofing via FavoritesService-Reuse) | Mitigiert — FavoritesService userId+widgetId-Scope von Plan 03 uebernommen (kein neuer Endpunkt) | +| T-08-11 (XSS via icon render) | Mitigiert — iconUrl nur via ``; kein dangerouslySetInnerHTML | +| T-08-12 (open redirect) | Mitigiert — Anchor mit target="_blank" rel="noreferrer" | + +## Self-Check: PASSED + +| Item | Status | +|------|--------| +| link-widget.tsx | FOUND | +| link-widget.test.tsx | FOUND | +| wireLinkWidget in page.tsx | FOUND | +| link.* keys in de.json | FOUND | +| link.* keys in en.json | FOUND | +| Commit 4657e50 (RED) | FOUND | +| Commit e9914f6 (GREEN) | FOUND | +| Full suite 88/88 | PASSED | +| Web TypeScript: 0 errors | PASSED | +| API TypeScript: 0 errors | PASSED |