Files
tessera-ctl/.planning/quick/260916-iex-dashboard-widgets-notiz-haekchen-in-der-/260916-iex-PLAN.md
T

318 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: quick-260916-iex
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260916-IEX]
files_modified:
- apps/web/src/components/dashboard/widgets/note-task-list.ts
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/widget-settings-panel.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- 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/dashboard-grid.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/widget-module-map.ts
- apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql
- docs/anleitung-anwender.md
- CHANGELOG.md
files_deleted:
- apps/web/src/components/dashboard/widgets/link-widget.tsx
- apps/web/src/components/dashboard/widgets/link-widget.test.tsx
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "Notiz-Widget in der Ansicht (Stift-Knopf aus, MDEditor im Modus preview): Aufgabenlisten `- [ ] Text` / `- [x] Text` (auch `*`/`+`-Punkte, nummerierte Punkte `1.`/`1)` und eingerückte Punkte) zeigen anklickbare Kästchen ohne `disabled`; ein Klick kippt GENAU diese Zeile im gespeicherten Markdown zwischen `[ ]` und `[x]`, die Vorschau zeigt sofort den neuen Zustand, und der Inhalt wird SOFORT (ohne Entprellung) per `updateWidgetConfig(instanceId, { content, title })` gespeichert; ein noch laufender Entprell-Timer aus dem Tippen wird vorher verworfen. Im Bearbeitungsmodus des Widgets (Stift an) bleibt der MDEditor unverändert. `rehypeSanitize` bleibt aktiv."
- "Favoriten-Widget liest `config.title` (nur wenn `typeof === 'string'`, Vorgabe ''). Ansicht: getrimmt nicht-leerer Titel → Kopfzeile `flex items-center border-b border-border px-1.5 py-1.5` mit `<h2 className=\"truncate text-sm font-semibold text-foreground\">`; leerer Titel → GAR KEINE Kopfzeile, der Inhalt rückt nach oben. Bearbeitungsmodus des Dashboards (`isEditMode`): Kopfzeile immer sichtbar mit Textfeld (Platzhalter „Titel (optional)“, Klasse `widgetNoDrag`, Aussehen wie das Titelfeld der Notiz), Eingaben werden 1500 ms entprellt per `updateWidgetConfig(instanceId, { title })` gespeichert (Muster note-widget). Der Liste/Kacheln-Umschalter bleibt wie bisher."
- "Einstellungen → Dashboard → Widgets: eine Favoriten-Instanz zeigt in der Kopfzeile „— {title}“ (wie Notiz, nur bei getrimmt nicht-leerem Titel) und nach dem Aufklappen ein Textfeld mit übersetzter Beschriftung „Titel“ (`FavoritesConfig`, Muster `NoteConfig`); jede Eingabe ruft `updateWidgetConfig(id, { title: wert })` auf. Die Beschriftung des Notiz-Titelfelds ist ebenfalls übersetzt („Titel“ / „Title“) statt hart „Title“."
- "Der Widget-Typ link ist restlos entfernt: nicht mehr in `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY` (+ Icon + wire-Funktion), im Katalog, in `apps/web/src/app/(portal)/page.tsx`, in de.json/en.json (`widgets.link`, Schlüsselmengen bleiben identisch), in der API-DTO-Liste und in den Kommentaren von widget-module-map.ts / widget-registry.tsx / dashboard-grid.tsx; `link-widget.tsx` und `link-widget.test.tsx` sind gelöscht. Migration `20260916120000_remove_link_widget/migration.sql` enthält genau eine idempotente Anweisung `DELETE FROM \"WidgetInstance\" WHERE \"widgetType\" = 'link';` (FavoriteLink-Zeilen kaskadieren über den FK). Bis zum Einspielen rendert widget-wrapper.tsx eine unbekannte Kachel als grauen Text ohne Absturz (neuer Test belegt das)."
- "Handbuch: Link-Zeile aus der Widget-Tabelle entfernt, Satz „Für Uhr, Suchleiste, Kalender, Favoriten und Link …“ ohne Link, Notizen- und Favoriten-Zeile um Abhaken bzw. optionalen Titel ergänzt. CHANGELOG unter Unveröffentlicht: Geändert (Favoriten-Titel), Entfernt (Link-Widget), Behoben (Notiz-Abhaken) — mit den vorgegebenen Texten."
- "Gates: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` komplett grün (Basislinie 51 Dateien / 332 Tests → danach 52 Dateien und mindestens 340 Tests: −7 Link-Tests, −1 Registry-Zeile, +≥17 neue); `pnpm --filter @tessera/api type-check` Exit 0; `pnpm --filter @tessera/api exec vitest run src/dashboard` grün; Umlaut-Wächter 3/3; changelog.test.ts grün; `apps/api/prisma/schema.prisma` unverändert. KEIN `biome check` als Gate, KEIN `prisma migrate deploy`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`."
artifacts:
- "apps/web/src/components/dashboard/widgets/note-task-list.ts — reine Hilfsfunktionen `TASK_LINE_RE`, `isTaskLine(line)`, `toggleTaskLine(content, index)` und die Komponente `NoteCheckbox` (Kästchen ohne disabled)"
- "apps/web/src/components/dashboard/widgets/note-task-list.test.tsx — Unit-Tests der Hilfsfunktionen + ein Test mit dem ECHTEN `MDEditor.Markdown` (Sanitize + components-Override)"
- "apps/web/src/components/dashboard/widgets/note-widget.tsx — `previewOptions.components`, delegierter Klick-Handler auf dem Vorschau-Container, Sofort-Speichern"
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx — Kopfzeile mit optionalem Titel / Titelfeld im Bearbeitungsmodus, entprelltes Speichern"
- "apps/web/src/components/settings/widget-settings-panel.tsx — `FavoritesConfig`, Kopfzeilen-Titel für favorites, übersetzte Beschriftung bei `NoteConfig`"
- "apps/web/src/messages/de.json + en.json — neu `widgets.note.titleLabel`, `widgets.favorites.titleLabel`, `widgets.favorites.titlePlaceholder`; Block `widgets.link` entfernt"
- "apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql — eine DELETE-Anweisung mit Kommentar"
- "apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx — neu, 1 Test: unbekannter Typ → grauer Text, kein Absturz"
- "CHANGELOG.md, docs/anleitung-anwender.md — siehe truths"
key_links:
- "`previewOptions` von MDEditor wird 1:1 in `MarkdownPreview` gespreizt (Editor.factory.js Z. 242), dessen Props `Omit<react-markdown Options, 'children'>` erweitern (react-markdown-preview lib/Props.d.ts) — `components: { input: NoteCheckbox }` kommt also bei react-markdown an und greift NACH allen rehype-Plugins, d. h. nach `rehypeSanitize`. Spike am 2026-09-16 im echten jsdom-Lauf bestätigt: 4 Kästchen aus 6 Kandidatenzeilen, `disabled === false`, `checked` korrekt."
- "Reihenfolge der Kästchen im DOM (`querySelectorAll('input[type=\"checkbox\"]')` im Vorschau-Container) == Reihenfolge der Aufgabenzeilen im Markdown, WENN `TASK_LINE_RE` dieselben Zeilen als Aufgaben erkennt wie GFM. GFM-Regel (micromark-extension-gfm-task-list-item 2.1.0, lib/syntax.js Z. 72-130): Klammerinhalt Leerzeichen/Tab/x/X, danach Leerraum UND danach mindestens ein Nicht-Leerraum-Zeichen — `- [ ]` allein und `- [ ]Text` sind KEINE Aufgaben. Deshalb ist die Regex bewusst streng: `^(\\s*(?:[-*+]|\\d+[.)])\\s+\\[)([ \\txX])(\\]\\s+\\S.*)$`. Zeilen innerhalb von Code-Zäunen (``` oder ~~~) werden übersprungen."
- "Favoriten-Widget `t('favorites.titlePlaceholder')` / Panel `t('favorites.titleLabel')`, `t('note.titleLabel')` ↔ Schlüssel in de.json UND en.json; umlaut-guard.spec.ts erzwingt identische Schlüsselmengen (auch beim Entfernen von `widgets.link`)."
- "`widget-registry.test.tsx` pinnt `WIDGET_CONSTRAINTS` per `toEqual` (Z. 61-70), die Typliste `ALL_WIDGET_TYPES` (Z. 9-20) und `counted === 32` (Z. 79) — alle drei Stellen müssen mitgezogen werden (7 Typen → 28)."
- "`apps/web/src/app/(portal)/page.tsx` importiert und verdrahtet das Link-Widget (Z. 8, 16, 28); `page.test.tsx` mockt das Modul (Z. 61) — beide Stellen müssen weg, sonst scheitert tsc bzw. vitest am gelöschten Modul."
- "Migration läuft als Rolle `tessera` (POSTGRES_USER in docker-compose.yml Z. 77 → Superuser + BYPASSRLS, lokal am 2026-09-16 per pg_roles gemessen); FORCE ROW LEVEL SECURITY auf WidgetInstance (Migration 20260909140000 Z. 153-154) greift für diese Rolle nicht, das DELETE sieht alle Zeilen. FK `FavoriteLink_widgetId_fkey ... ON DELETE CASCADE` (Migration 20260708090000 Z. 19)."
---
<objective>
Drei Nachbesserungen an den Dashboard-Widgets, alle vom Anwender festgelegt:
A) **Notiz-Widget — Häkchen abhaken.** In der Ansicht (nicht im Bearbeitungsmodus des Widgets) lassen sich Markdown-Aufgabenlisten (`- [ ] …` / `- [x] …`) direkt per Klick auf das Kästchen abhaken. Heute sind die Kästchen tot, weil `rehypeSanitize` (hast-util-sanitize 5.0.2, lib/schema.js Z. 44-50 und 147-149) `input` nur als `type=checkbox` MIT erzwungenem `disabled=true` durchlässt. Der Klick kippt genau die betroffene Zeile im gespeicherten Markdown, die Vorschau aktualisiert sich, gespeichert wird sofort über den bestehenden `save`-Pfad. Vorbild: `toggleMarkdownCheckbox` im alten persönlichen Dashboard des Anwenders.
B) **Favoriten-Widget — optionaler Titel.** Neues Konfigurationsfeld `title` (Text, Vorgabe leer). Nicht leer → Kopfzeile im selben Aussehen wie beim Notiz-Widget; leer → keine Kopfzeile. Im Bearbeitungsmodus des Dashboards steht in der Kopfzeile ein Textfeld zum Setzen/Leeren (entprellt gespeichert). Zusätzlich ein Titelfeld unter Einstellungen → Dashboard → Widgets (`FavoritesConfig`, Muster `NoteConfig`) und die Anzeige „— {title}“ in der Instanz-Kopfzeile dort. Nebenbei wird die hart englische Beschriftung „Title“ bei `NoteConfig` durch einen übersetzten Schlüssel ersetzt.
C) **Link-Widget komplett entfernen.** Der Typ link verschwindet aus Web (Registry, Katalog, Verdrahtung, Übersetzungen, Tests, Dateien), API (DTO-Liste, Kommentare) und Handbuch. Bestehende Link-Kacheln in Datenbanken werden durch eine winzige, idempotente Prisma-Migration gelöscht (FavoriteLink-Zeilen kaskadieren). Bis dahin zeigt das Frontend unbekannte Typen als grauen Text (bereits so gebaut — wird per Test festgeschrieben). Die gemeinsame Favoriten-API (`/favorites?widgetId=`) bleibt, das Favoriten-Widget nutzt sie.
NICHT Teil dieses Auftrags: kein Docker-Build, kein Deploy, kein Testserver, kein `git push`, kein `prisma migrate deploy` gegen irgendeine Datenbank (der Anwender spielt Migrationen per Deploy ein). `biome.json` nicht anfassen; `biome check` ist kein Gate (vorbestehender fremder Konfigurationsfehler).
Purpose: Der Anwender will seine Einkaufs-/Aufgabenlisten in der Notiz wie gewohnt abhaken, Favoriten-Kacheln beschriften können und das überflüssig gewordene Einzel-Link-Widget loswerden.
Output: Hilfsmodul `note-task-list.ts` mit Tests, angepasstes Notiz-Widget mit Tests, Favoriten-Widget mit Kopfzeile und Tests, `FavoritesConfig` im Einstellungsfeld mit Tests, 3 neue / 15 entfernte Übersetzungsschlüssel de/en, Link-Widget-Dateien gelöscht, Registry/Katalog/Seite/API-DTO bereinigt, Migration, neuer widget-wrapper-Test, Changelog (drei Einträge), Handbuch.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@apps/web/src/components/dashboard/widgets/note-widget.tsx
@apps/web/src/components/dashboard/widgets/note-widget.test.tsx
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
@apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
@apps/web/src/components/dashboard/widget-registry.tsx
@apps/web/src/components/dashboard/widget-registry.test.tsx
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@apps/web/src/components/settings/widget-settings-panel.tsx
@apps/web/src/components/settings/widget-settings-panel.test.tsx
@apps/web/src/messages/umlaut-guard.spec.ts
Gemessene Fakten zur Planungszeit (2026-09-16, Arbeitsbaum sauber auf `main` @ 5d5d4ac). Zeilennummern gelten für diesen Stand — vor dem Editieren die Datei lesen, nicht blind vertrauen.
**Notiz-Widget (A)**
- note-widget.tsx: Imports Z. 3-8 (`useCallback, useEffect, useRef, useState`, `MDEditor, { commands }`, `rehypeSanitize`, `updateWidgetConfig`); `DEBOUNCE_MS = 1500` Z. 10; State `content`/`isEditing` Z. 29-32; `timerRef`/`abortRef` Z. 35-36; `save(newContent, newTitle)` Z. 45-58 (AbortController, `updateWidgetConfig(instanceId, { content, title }, signal)`, setzt `saveError`); `scheduleSave` Z. 60-66 (clearTimeout + setTimeout DEBOUNCE_MS); Vorschau-Container `<div className="flex-1 overflow-auto">` Z. 126; `<MDEditor … preview={isEditing ? 'edit' : 'preview'} hideToolbar={!isEditing} previewOptions={{ rehypePlugins: [[rehypeSanitize]] }} />` Z. 127-139. Im Modus `preview` wird NUR die Vorschau gerendert (kein Textarea), im Modus `edit` nur der Editor — Kästchen gibt es also ausschließlich in der Ansicht.
- `updateWidgetConfig(id, config, signal?)` (dashboard-api.ts Z. 59-72): `fetch(${API_URL}/dashboard/widgets/${id}/config, { method: 'PATCH', body: JSON.stringify({ config }) })` — der Body ist also `{ config: { content, title } }`.
- note-widget.test.tsx: `next-intl` gemockt (Z. 5-13), `@uiw/react-md-editor` als Textarea-Mock (Z. 16-48, nimmt `value`, `onChange`, `data-testid`; kennt `preview` NICHT), `fetchSpy = vi.spyOn(globalThis, 'fetch')` Z. 58-61, `vi.useFakeTimers()` Z. 57; die Tests klicken `screen.getByRole('button')` (einziger Knopf = Stift). Kästchen haben die Rolle `checkbox`, kollidieren also nicht.
- Durchreichung `previewOptions` → react-markdown: `@uiw/react-md-editor@4.1.1` Editor.factory.js Z. 242 spreizt `previewOptions` in `PreviewComponent` (= `@uiw/react-markdown-preview@5.2.1`); dessen `MarkdownPreviewProps extends Omit<Options, 'children'>` aus `react-markdown@10.1.0` (lib/Props.d.ts Z. 4) — `components?: Components` ist Teil davon (react-markdown lib/index.d.ts Z. 68/107, `Components = { input?: ComponentType<JSX.IntrinsicElements['input'] & ExtraProps> }`, `ExtraProps = { node?: Element }`). Reihenfolge im Preview (index.js Z. 35-39): eingebaute rehype-Plugins → `props.rehypePlugins` (= unser `rehypeSanitize`) → rehype-prism; `components` greift erst beim Rendern, also NACH Sanitize. `rehypeRewrite` wäre KEINE Lösung (läuft VOR `props.rehypePlugins`, Sanitize setzt `disabled` wieder). `checked` steht in der globalen Attributliste des Sanitize-Schemas (schema.js Z. 84) und überlebt.
- Spike (2026-09-16, temporäre Testdatei, wieder gelöscht): `render(<MDEditor.Markdown source={src} rehypePlugins={[[rehypeSanitize]]} components={{ input: NoteCheckbox }} />)` mit `src = '- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier'` → GENAU 4 Kästchen (eins, zwei, drei, vier), `disabled === false`, zwei und drei `checked === true`, das `data-`-Attribut der eigenen Komponente kommt im DOM an. Läuft in jsdom in ~3 s; `@uiw/react-md-editor` steht in vitest.config `server.deps.inline`.
- GFM-Erkennung einer Aufgabenzeile (micromark-extension-gfm-task-list-item 2.1.0 lib/syntax.js Z. 72-130): Listenpunkt, dann `[`, dann Leerzeichen/Tab ODER `x`/`X`, dann `]`, dann Leerraum, dann mindestens ein Nicht-Leerraum-Zeichen (oder Zeilenende mit Fortsetzung im selben Absatz — selten, wird ignoriert). `- [ ]` allein (EOF oder nur Leerraum danach) und `- [ ]Text` sind KEINE Aufgaben und rendern KEIN Kästchen. Daraus folgt die strenge Regex im Hilfsmodul; eine laxere Regex würde bei einer leeren Zeile `- [ ]` (typisch beim Tippen einer neuen Aufgabe) den Index verschieben und das falsche Kästchen kippen.
- Textareas normalisieren Zeilenenden auf `\n`; das Hilfsmodul splittet daher nur an `\n`.
- Referenz: user-files/personal-dashboard/src/app/page.tsx `toggleMarkdownCheckbox` (~Z. 1399): Regex auf die Quellzeile, kippt ' '/'x', speichert.
**Favoriten-Widget (B)**
- favorites-widget.tsx: Imports Z. 3 (`FormEvent, useEffect, useMemo, useState` — `useRef` fehlt noch), `updateWidgetConfig` Z. 5; Wurzel `<div className="flex flex-col h-full overflow-auto p-1 gap-2">` Z. 174; Liste/Kacheln-Umschalter Z. 176-201 (nur `isEditMode`, `handleViewMode` Z. 95-98 ruft `updateWidgetConfig(instanceId, { viewMode })` sofort); Statusmeldungen Z. 204-214; Liste/Raster Z. 216-270; Formular „Hinzufügen“ Z. 273-297 (`widgetNoDrag`). Die Kopfzeile des Notiz-Widgets als Vorlage: note-widget.tsx Z. 89-96 (`relative flex items-center border-b border-border px-1.5 py-1.5 gap-2`, Input `flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground`).
- favorites-widget.test.tsx: `next-intl` als `t(key) => key` (Z. 5-7), `@/lib/favorites-api` und `@/lib/dashboard-api` (`updateWidgetConfig: vi.fn().mockResolvedValue(undefined)`) gemockt (Z. 10-20); echte Timer + `waitFor`; sucht Eingaben per `getByPlaceholderText('favorites.addTitle')` und Knöpfe per Rolle — ein zusätzliches Titelfeld mit Platzhalter `favorites.titlePlaceholder` stört keinen Bestandstest.
- Drag-Cancel (dashboard-grid.tsx Z. 31-32): `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — ein `<input>` startet ohnehin kein Ziehen; `widgetNoDrag` trotzdem setzen (Vorgabe des Anwenders, gleiche Konvention wie Formular Z. 276).
- widget-settings-panel.tsx: `handleConfigChange(id, partial)` Z. 60-73 (`updateWidgetConfig` + `onWidgetUpdate`); Instanz-Kopfzeile mit „— {title}“ nur für `note` Z. 119-126; Konfig-Zweige Z. 154-190 (`clock`, `search`, `note`, `calendar`); `NoteConfig` Z. 403-424 mit hart kodiertem „Title“ Z. 416 (Label `mb-1 block text-sm text-foreground`, Input `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`, `htmlFor="note-title"`; es ist immer nur EINE Instanz aufgeklappt, feste ids kollidieren daher nicht).
- widget-settings-panel.test.tsx: `next-intl`-Mock liest die ECHTE de.json per Pfad (Z. 15-29, ersetzt `{platzhalter}`), `updateWidgetConfig` gemockt (Z. 31-33), `next/link` und `search-provider-form` gemockt; Muster `renderCalendarExpanded` (Z. ~116-122: render, `fireEvent.click(getByRole('button', { name: /Kalender #1/ }))`).
- de.json: `widgets.note` Z. 257-264 (`name, description, defaultTitle, autosaveError, editMode, viewMode`), `widgets.favorites` Z. 269-284 (14 Schlüssel, letzter `error`), `widgets.link` Z. 285-300 (14 Schlüssel, Block endet mit `},` Z. 300), `widgets.stopwatch` ab Z. 301. en.json spiegelbildlich (Z. 257-264 / 269-284 / 285-300). Der Schlüssel `"link": "Einstellungen"` in Z. 121 gehört zu einem ANDEREN Namensraum und bleibt. umlaut-guard.spec.ts: keine Ersatzschreibungen, neue ae/oe/ue/ss-Wörter müssen auf `UMLAUT_ALLOWLIST` stehen (die neuen Texte „Titel“, „Titel (optional)“ enthalten keine), Schlüsselmengen de/en identisch.
**Link-Widget entfernen (C)**
- widget-registry.tsx: Kopfkommentar Z. 6 („calculator/favorites/link/stopwatch: Phase 8 additions“), Union-Mitglied Z. 15, `WIDGET_CONSTRAINTS`-Zeile Z. 54, `LinkIcon` Z. 222-240, Registry-Eintrag Z. 317-324, `linkWired`/`wireLinkWidget` Z. 387-393.
- widget-registry.test.tsx: `ALL_WIDGET_TYPES` Z. 9-20 (Eintrag Z. 18), `toContain` Z. 52, `toEqual`-Tabelle Z. 61-70 (Zeile Z. 68), `expect(counted).toBe(32)` Z. 79 → 28. `it.each` erzeugt pro Typ einen Test: 8 → 7.
- widget-catalog-modal.tsx: `WIDGET_TYPES` Z. 13-22 (Eintrag Z. 20).
- apps/web/src/app/(portal)/page.tsx: Import der wire-Funktionen Z. 8 (enthält `wireLinkWidget`), Import `LinkWidget` Z. 16, Aufruf Z. 28. page.test.tsx: `vi.mock('@/components/dashboard/widgets/link-widget', …)` Z. 61. ACHTUNG: page.tsx Z. 82 und page.test.tsx Z. 96/104 enthalten das deutsche Wort „links“ (Richtung) — nicht anfassen, nicht per `grep -i link` verwechseln.
- dashboard-grid.tsx Z. 23-24: Kommentar „(Favoriten/ Link-Widget, bisher nirgends verdrahtet)“ — auf „(Favoriten-Widget)“ kürzen.
- widget-wrapper.tsx Z. 30-31 + Z. 107-117: `WIDGET_REGISTRY[widget.widgetType as WidgetType]` → `undefined` für unbekannte Typen → Fallback `<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{widget.widgetType}</div>`, `aria-label` = Typname. dashboard-grid.test.tsx Test 9 (Z. 206-232) deckt bereits `widgetType: 'unknown'` in `applyConstraintMinima` ab (Eintrag wird unverändert kopiert). Es gibt noch KEINE widget-wrapper.test.tsx.
- Verwaiste Layout-Einträge: `DashboardLayout.layouts` (JSON) kann nach der Migration noch Einträge mit den gelöschten ids enthalten. dashboard-grid.tsx rendert nur über `widgets.map` (Z. 187) und react-grid-layout übernimmt Layout-Einträge ohne Kind nicht; der Store schreibt beim nächsten Verlassen des Bearbeitungsmodus nur die Kind-Layouts zurück. Kein SQL auf das JSON nötig.
- API: create-widget.dto.ts Z. 5 Kommentar „one of the eight supported types“, Z. 10 `@IsIn([...])`. widget-module-map.ts Z. 16-18 („alle acht heute registrierten Widget-Typen (clock/search/calendar/note/calculator/ favorites/link/stopwatch …“) und Z. 28 („für alle acht bestehenden Typen“). dashboard.service.spec.ts und dashboard.controller.ts enthalten KEINE Referenz auf den Typ link; keine DTO-Spec vorhanden. API-Skripte: `type-check` = `tsc --noEmit`, `test` = `vitest run`.
- Prisma: schema.prisma `WidgetInstance` Z. 201-213 (`widgetType String`), `FavoriteLink` Z. 347-363 (`widgetInstance … onDelete: Cascade`) — KEINE Schemaänderung nötig. Jüngste Migration `20260914170000_smtp_config_bug_report_recipient` (Kommentarstil: deutsch, Begründung, dann SQL). FK-Kaskade in 20260708090000_add_favorite_link Z. 19. RLS: WidgetInstance und FavoriteLink haben ENABLE + FORCE ROW LEVEL SECURITY (20260909140000 Z. 89-90, 153-154); Migrationen laufen als Rolle `tessera` (POSTGRES_USER, docker-compose.yml Z. 77; Superuser + BYPASSRLS, lokal gemessen, Befund auch in 20260909130000_rls_app_role Z. 5-11) — das DELETE sieht alle Zeilen. Vorbild für DML-Migrationen: 20260709000000_lowercase_usernames, 20260812100000_tender_email_config_per_user.
- link-widget.tsx 381 Zeilen, link-widget.test.tsx 231 Zeilen mit 7 Tests. Keine weitere Datei importiert das Modul außer page.tsx/page.test.tsx.
- Handbuch docs/anleitung-anwender.md: Widget-Tabelle Z. 72-81 (Notizen Z. 77, Favoriten Z. 79, Link Z. 80), Satz Z. 83 „Für Uhr, Suchleiste, Kalender, Favoriten und Link gibt es zusätzliche Einstellungen (… hinterlegte Links) …“, Absatz „**Dashboard > Widgets:**“ Z. 153.
- CHANGELOG.md: `## Unveröffentlicht` Z. 5, `### Geändert` Z. 7 mit zwei Einträgen Z. 9-10, `## 1.1.0 – 2026-09-16` Z. 12. changelog.ts erkennt nur `## `-Abschnitte und `- `-Listenpunkte; changelog.test.ts arbeitet mit Inline-Fixtures; publish-release.sh schneidet den `## X.Y.Z`-Abschnitt per awk — `### Entfernt` ist als Unterüberschrift zulässig (Keep-a-Changelog: Neu, Geändert, Entfernt, Behoben).
**Basislinie**: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` → 51 Dateien / 332 Tests grün; Umlaut-Wächter 3/3; `pnpm --filter @tessera/api type-check` Exit 0. Kalibrierung: factor 1, 0 Stichproben, confidence low.
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Notiz-Widget — Aufgabenlisten in der Ansicht abhakbar (Hilfsmodul + Widget + Tests)</name>
<files>apps/web/src/components/dashboard/widgets/note-task-list.ts, apps/web/src/components/dashboard/widgets/note-task-list.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx</files>
<read_first>
- apps/web/src/components/dashboard/widgets/note-widget.tsx (ganz, 143 Zeilen)
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (ganz, 174 Zeilen — insbesondere der MDEditor-Mock Z. 16-48)
- apps/web/src/lib/dashboard-api.ts Z. 59-72 (`updateWidgetConfig`, Body-Form)
- user-files/personal-dashboard/src/app/page.tsx ~Z. 1399 (`toggleMarkdownCheckbox`, Vorbild)
- apps/web/src/components/dashboard/widgets/calendar-month.ts (Muster: reines Hilfsmodul neben dem Widget, deutsche Kommentare)
</read_first>
<behavior>
note-task-list.test.tsx (vitest, jsdom; deutsche Testnamen wie in den Bestandstests):
- Test 1 `isTaskLine`: wahr für `- [ ] Milch`, `- [x] Brot`, `* [X] Eier`, `+ [ ] Butter`, `1. [ ] Mehl`, `2) [x] Salz`, ` - [ ] eingerückt`, `- [\t] Tab`; falsch für `- [ ]` (leer), `- [ ]Text` (kein Leerraum nach der Klammer), `- Milch`, `[ ] ohne Punkt`, `- [y] falsch`, leere Zeile.
- Test 2 `toggleTaskLine('- [ ] Milch\n- [ ] Brot\n- [ ] Eier', 0)` → `'- [x] Milch\n- [ ] Brot\n- [ ] Eier'`; Index 2 → nur die dritte Zeile wird `[x]`.
- Test 3 `[x]` → `[ ]`: `toggleTaskLine('- [x] Brot', 0)` → `'- [ ] Brot'`; `[X]` → `[ ]` ebenfalls.
- Test 4 Nicht-Aufgabenzeilen zählen nicht mit: `'# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [ ] Brot'`, Index 1 → nur `Brot` wird `[x]`, alle anderen Zeilen byte-identisch.
- Test 5 Eingerückt und nummeriert: `'- [ ] A\n - [ ] B\n1. [ ] C'`, Index 1 → `' - [x] B'` (Einrückung bleibt), Index 2 → `'1. [x] C'`.
- Test 6 Index außerhalb (`-1`, `3` bei drei Aufgaben) → Rückgabe `===` Eingabe (unverändert). Leerer Inhalt `''` mit Index 0 → `''`.
- Test 7 Code-Zäune werden übersprungen: `'```\n- [ ] nicht\n```\n- [ ] echt'`, Index 0 → nur `echt` wird `[x]`, die Zeile im Zaun bleibt `[ ]`. Gleiches mit `~~~`.
- Test 8 (ECHTE Vorschau, kein Mock von `@uiw/react-md-editor` in dieser Datei): `render(<MDEditor.Markdown source={SRC} rehypePlugins={[[rehypeSanitize]]} components={{ input: NoteCheckbox }} />)` mit `SRC = '- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier'` → `container.querySelectorAll('input[type="checkbox"]').length === 4`; jedes Kästchen `disabled === false`; Kästchen 1 und 2 (Index) `checked === true`, 0 und 3 `checked === false`. Zusätzlich: die Anzahl 4 entspricht `SRC.split('\n').filter(isTaskLine).length` — das ist die Invariante, auf der die Index-Zuordnung beruht.
note-widget.test.tsx (bestehender MDEditor-Mock wird erweitert):
- Test 9 Klick in der Ansicht speichert sofort: `config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}`, `isEditMode={false}`; die drei Kästchen (`getAllByRole('checkbox')`) sind vorhanden; `fireEvent.click` auf das dritte, dann `await act(async () => {})` OHNE Timer-Vorlauf → `fetchSpy` genau einmal aufgerufen mit URL `…/dashboard/widgets/note-1/config`, `method: 'PATCH'` und `JSON.parse(body)` `toEqual({ config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' } })`; das dritte Kästchen ist danach `checked`.
- Test 10 Abwählen: gleiche Konfiguration, Klick auf das zweite Kästchen → Body-Inhalt `'- [ ] Milch\n- [ ] Brot\n- [ ] Eier'`.
- Test 11 Ein laufender Entprell-Timer wird verworfen: Stift an (Klick auf den Knopf), Textarea auf `'- [ ] Milch\n- [ ] Brot'` ändern, `vi.advanceTimersByTime(200)`, Stift wieder aus (zweiter Klick auf den Knopf), erstes Kästchen klicken → nach `await act(async () => {})` genau EIN fetch-Aufruf mit Inhalt `'- [x] Milch\n- [ ] Brot'`; dann `vi.advanceTimersByTime(2000)` → immer noch genau ein Aufruf (der alte Timer hat NICHT den ungekippten Text nachgeschoben).
- Bestandstests 1-4 bleiben unverändert grün.
</behavior>
<action>
1. Neues Modul `apps/web/src/components/dashboard/widgets/note-task-list.ts` (Client-Modul, exportiert eine kleine React-Komponente, deshalb `.ts` mit `React.createElement` ODER `.tsx` — wähle `.tsx` nur, wenn du JSX willst; dann Dateiname `note-task-list.tsx` und die Pfade in `<files>`/Frontmatter entsprechend im SUMMARY nennen). Inhalt:
- `export const TASK_LINE_RE = /^(\s*(?:[-*+]|\d+[.)])\s+\[)([ \txX])(\]\s+\S.*)$/;` — bewusst streng, Begründung als deutscher Kommentar: entspricht der GFM-Regel (micromark-extension-gfm-task-list-item: nach `]` Leerraum UND Inhalt), sonst verschiebt eine leere Zeile `- [ ]` den Index gegenüber den gerenderten Kästchen.
- `const FENCE_RE = /^\s*(```|~~~)/;`
- `export function isTaskLine(line: string): boolean` → `TASK_LINE_RE.test(line)`.
- `export function toggleTaskLine(content: string, index: number): string` — splittet an `'\n'`, läuft über die Zeilen, führt ein `inFence`-Flag (Zeile matcht FENCE_RE → Flag kippen, Zeile überspringen), zählt nur Zeilen mit `isTaskLine` (außerhalb von Zäunen); bei Zähler === index: Gruppe 2 ist `' '`/`'\t'` → `'x'`, sonst (`x`/`X`) → `' '`; Zeile neu zusammensetzen (Gruppe 1 + neues Zeichen + Gruppe 3), `join('\n')` zurückgeben. Kein Treffer (index < 0, index ≥ Anzahl) → die EINGABE unverändert zurückgeben (dieselbe Referenz), damit der Aufrufer per `===` erkennt, dass nichts zu speichern ist.
- `export function NoteCheckbox({ checked }: { checked?: boolean })` — rendert ein `input` mit `type="checkbox"`, `checked={!!checked}`, `readOnly`, `className="cursor-pointer"`, und OHNE `disabled`; nur `checked` aus den Props ziehen (react-markdown reicht zusätzlich `node`, `disabled`, `type` durch — nichts davon spreizen, sonst landet `node` im DOM). `readOnly` unterdrückt die React-Warnung „checked ohne onChange“; der Klick wird nicht am Kästchen, sondern delegiert am Container verarbeitet.
2. `note-widget.tsx`:
- Import `{ NoteCheckbox, toggleTaskLine }` aus `./note-task-list`.
- `previewOptions` wird zu `{ rehypePlugins: [[rehypeSanitize]], components: { input: NoteCheckbox } }` — `rehypeSanitize` bleibt. Das Objekt außerhalb der Komponente als Konstante `PREVIEW_OPTIONS` anlegen (stabil, keine Neuanlage je Render).
- Neuer Handler `handlePreviewClick(event: React.MouseEvent<HTMLDivElement>)` per `useCallback` mit Abhängigkeiten `[isEditing, content, title, save]`: wenn `isEditing` → return; `target = event.target`; wenn nicht `instanceof HTMLInputElement` oder `target.type !== 'checkbox'` → return; `boxes = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'))`; `index = boxes.indexOf(target)`; `next = toggleTaskLine(content, index)`; wenn `next === content` → return; `setContent(next)`; `clearTimeout(timerRef.current)` (ein evtl. noch laufender Entprell-Timer aus dem Tippen würde sonst den alten Text nachschieben); `void save(next, title)` — SOFORT, nicht `scheduleSave` (Klick ist eine abgeschlossene Handlung; 1,5 s Wartezeit würden beim schnellen Seitenwechsel den Haken verlieren). Kein `preventDefault` (das würde das native Kippen sichtbar zurücknehmen; React setzt das kontrollierte `checked` beim Re-Render ohnehin auf den neuen Wert).
- Den Handler als `onClick={handlePreviewClick}` auf den Vorschau-Container `<div className="flex-1 overflow-auto">` (Z. 126) setzen. Ein `role`/`tabIndex` ist nicht nötig — das eigentliche interaktive Element ist das Kästchen selbst; den Container zusätzlich mit `data-testid="note-preview"` versehen.
- Sonst nichts ändern: Bearbeitungsmodus (`isEditing`), Titelfeld, Entprellung beim Tippen, AbortController bleiben wie sie sind.
3. `note-widget.test.tsx`: den MDEditor-Mock (Z. 16-48) um die Props `preview` und `previewOptions` erweitern: bei `preview === 'preview'` statt des Textareas ein `<div data-testid="md-editor">` rendern, das für jede Zeile von `value`, die `/^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/` matcht, ein `<input type="checkbox" readOnly checked={m[1] !== ' '} />` enthält (Reihenfolge = Zeilenreihenfolge; wenn `previewOptions?.components?.input` vorhanden ist, darf der Mock diese Komponente statt des rohen `input` verwenden — dann ist auch `NoteCheckbox` im Klickpfad); bei `preview === 'edit'` das bisherige Textarea. Die Bestandstests 2-4 schalten den Stift ein, bevor sie tippen — sie treffen weiterhin das Textarea. Test 1 (`getByTestId('md-editor')`) trifft jetzt das Vorschau-Div — weiterhin vorhanden. Dann die Tests 9-11 aus `<behavior>` ergänzen (`fetchSpy.mock.calls[0]` → `[url, init]`, `JSON.parse(init.body as string)`).
4. `note-task-list.test.tsx` NEU nach `<behavior>` Tests 1-8 anlegen. Test 8 importiert `MDEditor from '@uiw/react-md-editor'` und `rehypeSanitize from 'rehype-sanitize'` ECHT (kein `vi.mock` in dieser Datei) und rendert `MDEditor.Markdown` (das ist der Preview-Export, den auch changelog-view.tsx nutzt).
5. Laufen lassen: beide Testdateien und den Type-Check (siehe verify). Erwartung: note-task-list 8 Tests, note-widget 7 Tests.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/note-task-list.test.tsx src/components/dashboard/widgets/note-widget.test.tsx && pnpm --filter @tessera/web type-check && grep -q "input: NoteCheckbox" apps/web/src/components/dashboard/widgets/note-widget.tsx && grep -q "rehypeSanitize" apps/web/src/components/dashboard/widgets/note-widget.tsx</automated>
</verify>
<done>Hilfsmodul mit strenger GFM-konformer Regex, Zaun-Überspringen und `NoteCheckbox` vorhanden; Notiz-Widget reicht `components: { input: NoteCheckbox }` durch, `rehypeSanitize` bleibt, delegierter Klick kippt die N-te Aufgabenzeile und speichert sofort (Timer verworfen); 8 + 7 Tests grün inkl. eines Tests mit dem echten `MDEditor.Markdown`; tsc Exit 0.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Favoriten-Widget — optionaler Titel (Widget-Kopfzeile, FavoritesConfig, i18n, Tests)</name>
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx</files>
<read_first>
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx Z. 1-100 und Z. 172-300 (State, Hooks, Render-Wurzel)
- apps/web/src/components/dashboard/widgets/note-widget.tsx Z. 35-66 und Z. 86-123 (Entprell-Muster, Kopfzeilen-Optik)
- apps/web/src/components/settings/widget-settings-panel.tsx Z. 100-200 und Z. 403-424 (Instanz-Kopfzeile, Konfig-Zweige, `NoteConfig`)
- apps/web/src/components/settings/widget-settings-panel.test.tsx Z. 1-60 und Z. 110-125 (Mocks, Muster `renderCalendarExpanded`)
- apps/web/src/messages/de.json Z. 257-284 und en.json Z. 257-284 (Einfügestellen)
</read_first>
<behavior>
favorites-widget.test.tsx (next-intl-Mock gibt den Schlüssel zurück):
- Test A1 Ansicht ohne Titel: `config={{}}`, `isEditMode={false}` → nach dem Laden (`waitFor` auf 'GitHub') gibt es KEIN `heading` (`queryByRole('heading')` null) und KEIN Feld mit Platzhalter `favorites.titlePlaceholder`; ebenso bei `config={{ title: ' ' }}` und bei `config={{ title: 42 }}` (kein String).
- Test A2 Ansicht mit Titel: `config={{ title: 'Werkzeuge' }}` → `getByRole('heading', { name: 'Werkzeuge' })` vorhanden, Tag `H2`, Klassen enthalten `text-sm`, `font-semibold`; kein Textfeld mit dem Platzhalter.
- Test A3 Bearbeitungsmodus ohne Titel: `config={{}}`, `isEditMode={true}` → Textfeld mit Platzhalter `favorites.titlePlaceholder` vorhanden, Wert `''`, `className` enthält `widgetNoDrag`; kein `heading`; Liste/Kacheln-Knöpfe (`favorites.listView`/`favorites.gridView`) weiterhin vorhanden.
- Test A4 Entprelltes Speichern: mit `vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })` (im Test aktivieren, in `finally` `vi.useRealTimers()`), `isEditMode={true}`, Laden per `await act(async () => {})` abwarten; Feld nacheinander auf `'W'`, `'We'`, `'Werkzeuge'` ändern (jeweils `vi.advanceTimersByTime(200)` in `act`) → `updateWidgetConfig` NICHT aufgerufen; dann `vi.advanceTimersByTime(1500)` in `act` → genau EIN Aufruf `('fav-1', { title: 'Werkzeuge' })`. Leeren des Felds (`''`) → nach 1500 ms Aufruf `('fav-1', { title: '' })`.
widget-settings-panel.test.tsx (Texte aus der echten de.json):
- Test B1 Favoriten-Instanz `{ id: 'f1', widgetType: 'favorites', config: { title: 'Werkzeuge' } }`: Kopfzeilen-Knopf `getByRole('button', { name: /Favoriten #1/ })` enthält den Text `— Werkzeuge`; nach Klick ist ein Textfeld mit `getByLabelText(de.widgets.favorites.titleLabel)` da, Wert `'Werkzeuge'`; `fireEvent.change` auf `'Werkzeuge 2'` → `await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }))` und `updateWidgetConfig` mit denselben Argumenten.
- Test B2 Favoriten-Instanz ohne Titel (`config: {}`) und mit Leerraum-Titel (`config: { title: ' ' }`): Kopfzeile OHNE „—“; nach Aufklappen Feldwert `''`.
- Test B3 Notiz-Instanz `{ id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } }`: nach Aufklappen `getByLabelText(de.widgets.note.titleLabel)` (= „Titel“) hat Wert `'Einkauf'`; `screen.queryByText('Title')` ist null (hart kodierte Beschriftung ist weg).
- Bestandstests 1-7 bleiben grün.
</behavior>
<action>
1. Übersetzungen: in de.json UND en.json (Namensraum `widgets`) ergänzen — in `note` nach `viewMode` den Schlüssel `titleLabel` („Titel“ / „Title“); in `favorites` nach `error` die Schlüssel `titleLabel` („Titel“ / „Title“) und `titlePlaceholder` („Titel (optional)“ / „Title (optional)“). Reihenfolge und Einrückung des Bestands beibehalten; JSON bleibt gültig (Kommas!).
2. `favorites-widget.tsx`:
- `useRef` zu den React-Imports ergänzen; Modulkonstante `const TITLE_DEBOUNCE_MS = 1500;` mit Kommentar „wie DEBOUNCE_MS im Notiz-Widget“.
- State `const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '')`; `titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)`; `useEffect(() => () => clearTimeout(titleTimerRef.current), [])`.
- `handleTitleChange(e)`: `setTitle(e.target.value)`, `clearTimeout(titleTimerRef.current)`, `titleTimerRef.current = setTimeout(() => { void updateWidgetConfig(instanceId, { title: value }); }, TITLE_DEBOUNCE_MS)` — nur das Feld `title` senden (die Seite mischt partiell).
- `const hasTitle = title.trim() !== '';` und `const showHeader = isEditMode || hasTitle;`
- Render-Wurzel umbauen: äußeres `<div className="flex h-full flex-col overflow-hidden">`; darin ZUERST bedingt (`showHeader`) die Kopfzeile `<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">` — im Bearbeitungsmodus ein `<input type="text" className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground widgetNoDrag" value={title} onChange={handleTitleChange} placeholder={t('favorites.titlePlaceholder')} aria-label={t('favorites.titleLabel')} />`, sonst (Ansicht, nur bei `hasTitle`) ein `<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>`; DANACH der bisherige Rumpf als `<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">` mit unverändertem Inhalt (Umschalter, Statusmeldungen, Liste/Raster, Formular). Ohne Titel und außerhalb des Bearbeitungsmodus gibt es KEINE Kopfzeile — der Rumpf beginnt oben.
- Kommentar am Komponentenkopf um eine Zeile „quick-260916-iex: optionaler Titel …“ ergänzen.
3. `widget-settings-panel.tsx`:
- Instanz-Kopfzeile (Z. 119-126): Bedingung auf `(widget.widgetType === 'note' || widget.widgetType === 'favorites') && typeof widget.config.title === 'string' && widget.config.title.trim() !== ''` erweitern, Anzeige `— {widget.config.title.trim()}`.
- Neuer Zweig nach dem Kalender-Zweig: `{widget.widgetType === 'favorites' && (<FavoritesConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />)}` mit Kommentar „Favorites config (quick-260916-iex)“.
- `FavoritesConfig` nach dem Muster `NoteConfig` unterhalb davon anlegen: `const title = typeof config.title === 'string' ? config.title : ''`; Label `htmlFor="favorites-title"` mit `t('favorites.titleLabel')`; Input `id="favorites-title"`, gleiche Klassen wie bei `NoteConfig`, `placeholder={t('favorites.titlePlaceholder')}`, `onChange={(e) => onChange({ title: e.target.value })}`.
- `NoteConfig`: das hart kodierte „Title“ (Z. 416) durch `{t('note.titleLabel')}` ersetzen; sonst unverändert.
4. Tests nach `<behavior>` ergänzen: favorites-widget.test.tsx A1-A4 (für A4 `updateWidgetConfig` aus `@/lib/dashboard-api` importieren und als `ReturnType<typeof vi.fn>` casten; die Datei arbeitet sonst mit echten Timern — Fake-Timer NUR in A4 und dort mit `toFake: ['setTimeout', 'clearTimeout']`, damit `fetchFavorites`-Promises und `act` normal laufen); widget-settings-panel.test.tsx B1-B3 in einem neuen `describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)')` mit einer Helferfunktion `renderFavoritesExpanded(config)` nach dem Muster `renderCalendarExpanded`; `de.widgets.favorites` / `de.widgets.note` wie `cal` oben in der Datei typisieren.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/favorites-widget.test.tsx src/components/settings/widget-settings-panel.test.tsx src/messages/umlaut-guard.spec.ts && pnpm --filter @tessera/web type-check && grep -q '"titlePlaceholder"' apps/web/src/messages/de.json && grep -q '"titlePlaceholder"' apps/web/src/messages/en.json && grep -q "favorites.titleLabel" apps/web/src/components/settings/widget-settings-panel.tsx && grep -q "note.titleLabel" apps/web/src/components/settings/widget-settings-panel.tsx</automated>
</verify>
<done>Favoriten-Widget zeigt in der Ansicht nur bei nicht-leerem Titel eine Kopfzeile im Notiz-Look, im Bearbeitungsmodus immer ein Titelfeld (`widgetNoDrag`, 1500 ms entprellt, `{ title }`); Einstellungsfeld hat `FavoritesConfig` mit übersetzter Beschriftung und zeigt „— {title}“ in der Instanz-Kopfzeile; `NoteConfig` übersetzt; 3 neue Schlüssel in de/en; favorites-Tests 7 + 4, Panel-Tests 7 + 3, Umlaut-Wächter 3/3 grün; tsc Exit 0.</done>
</task>
<task type="auto">
<name>Task 3: Link-Widget restlos entfernen (Web, API-DTO, Migration, i18n, Handbuch), Changelog für A/B/C, alle Gates</name>
<files>apps/web/src/components/dashboard/widgets/link-widget.tsx, apps/web/src/components/dashboard/widgets/link-widget.test.tsx, 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/dashboard-grid.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql, docs/anleitung-anwender.md, CHANGELOG.md</files>
<read_first>
- apps/web/src/components/dashboard/widget-registry.tsx Z. 1-60, Z. 220-242, Z. 310-335, Z. 385-395
- apps/web/src/components/dashboard/widget-registry.test.tsx (ganz, 81 Zeilen)
- apps/web/src/components/dashboard/widget-catalog-modal.tsx Z. 1-25
- apps/web/src/app/(portal)/page.tsx Z. 1-30; apps/web/src/app/(portal)/page.test.tsx Z. 50-62
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx (ganz, 121 Zeilen)
- apps/api/src/dashboard/dto/create-widget.dto.ts; apps/api/src/dashboard/widget-module-map.ts Z. 1-35
- apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql (Kommentarstil)
- docs/anleitung-anwender.md Z. 70-84 und Z. 153; CHANGELOG.md Z. 1-12
</read_first>
<action>
<!-- planner-discipline-allow: LinkWidget -->
<!-- planner-discipline-allow: wireLinkWidget -->
<!-- planner-discipline-allow: link-widget -->
1. Dateien löschen: `git rm apps/web/src/components/dashboard/widgets/link-widget.tsx apps/web/src/components/dashboard/widgets/link-widget.test.tsx`.
2. `widget-registry.tsx`: Union-Mitglied für den Typ link entfernen (Z. 15); Kopfkommentar Z. 6 auf „calculator/favorites/stopwatch: Phase 8 additions (das Link-Widget wurde in quick-260916-iex entfernt — Favoriten decken den Fall ab)“ ändern; `WIDGET_CONSTRAINTS`-Zeile Z. 54 entfernen; Funktion `LinkIcon` (Z. 222-240) entfernen; Registry-Eintrag Z. 317-324 entfernen; `linkWired` + `wireLinkWidget` (Z. 387-393) entfernen. Ergebnis: sieben Typen.
3. `widget-registry.test.tsx`: Eintrag Z. 18 aus `ALL_WIDGET_TYPES`, `toContain`-Zeile Z. 52 und die Tabellenzeile Z. 68 entfernen; `expect(counted).toBe(32)` → `28`; im Kommentar des Tests A einen Halbsatz „(quick-260916-iex: Link-Widget entfernt, sieben Typen)“ ergänzen.
4. `widget-catalog-modal.tsx`: Eintrag Z. 20 aus `WIDGET_TYPES` entfernen.
5. `apps/web/src/app/(portal)/page.tsx`: `wireLinkWidget` aus der Import-Liste Z. 8, die Import-Zeile 16 (`LinkWidget`) und den Aufruf Z. 28 entfernen. `page.test.tsx`: den `vi.mock(...)`-Aufruf Z. 61 für das gelöschte Modul entfernen. Das deutsche „links“ (Richtung) in page.tsx Z. 82 / page.test.tsx Z. 96, 104 NICHT anfassen.
6. `dashboard-grid.tsx` Z. 23-24: Kommentar „(Favoriten/ Link-Widget, bisher nirgends verdrahtet)“ → „(Favoriten-Widget)“. Keine Code-Änderung.
7. Übersetzungen: in de.json UND en.json den kompletten Block `widgets.link` (Z. 285-300, 14 Schlüssel samt schließendem `},`) entfernen; der Schlüssel `"link": "Einstellungen"` in Z. 121 (anderer Namensraum) bleibt. JSON-Gültigkeit prüfen (`node -e "JSON.parse(require('fs').readFileSync('apps/web/src/messages/de.json','utf8'))"` und dasselbe für en.json).
8. Neue Testdatei `apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx` mit `next-intl`-Mock (`t(key) => key`) und EINEM Test: `render(<WidgetWrapper widget={{ id: 'w-alt', widgetType: 'link', config: {} }} isEditMode={false} onRemove={vi.fn()} />)` wirft nicht, `getByRole('article')` hat `aria-label` `'link'`, und `getByText('link')` hat die Klasse `text-muted-foreground` (grauer Text). Deutscher Testname: „unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz“.
9. API: `create-widget.dto.ts` — Typ link aus der `@IsIn`-Liste entfernen, Kommentar Z. 5 „one of the eight supported types“ → „one of the seven supported types“. `widget-module-map.ts` — Z. 16-18 auf „alle sieben heute registrierten Widget-Typen (clock/search/calendar/note/calculator/favorites/stopwatch, …)“ und Z. 28 „für alle sieben bestehenden Typen“ anpassen. Kein weiterer API-Code referenziert den Typ (gemessen).
10. Migration `apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql` NEU anlegen (Verzeichnis + Datei). Inhalt: deutscher Kopfkommentar im Stil der Bestandsmigrationen (Anlass quick-260916-iex: Widget „Link“ entfernt, Favoriten-Widget übernimmt; FavoriteLink-Zeilen kaskadieren über `FavoriteLink_widgetId_fkey ON DELETE CASCADE` aus 20260708090000; läuft als Migrationsrolle `tessera` (Superuser/BYPASSRLS), deshalb greift FORCE ROW LEVEL SECURITY auf WidgetInstance hier nicht und die Anweisung sieht alle Mandanten; idempotent — ein zweiter Lauf löscht 0 Zeilen; verwaiste Einträge im Layout-JSON sind unschädlich und verschwinden beim nächsten Speichern des Dashboards), danach GENAU EINE Anweisung: `DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link';`. KEINE Schemaänderung, KEIN `prisma migrate dev/deploy` ausführen.
11. Handbuch `docs/anleitung-anwender.md`: Tabellenzeile Z. 80 (Link) entfernen; Z. 77 Notizen → „Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken“; Z. 79 Favoriten → „… als Liste oder Kachelansicht, optional mit eigener Überschrift“; Satz Z. 83 → „Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — …“ (Rest des Satzes unverändert). Absatz Z. 153 „**Dashboard > Widgets:**“ um „… oder bei Notizen und Favoriten die Überschrift der Kachel“ ergänzen.
12. `CHANGELOG.md` unter `## Unveröffentlicht`: an die bestehende Liste unter `### Geändert` den Punkt „Favoriten-Widget kann einen Titel bekommen; ohne Titel bleibt die Kopfzeile weg.“ anhängen; danach neuen Abschnitt `### Entfernt` mit „Widget „Link“ (ein einzelner Link) entfernt — Favoriten-Widget übernimmt das; vorhandene Link-Kacheln werden beim Update automatisch entfernt.“; danach `### Behoben` mit „Notiz-Widget: Listen zum Abhaken lassen sich jetzt in der Ansicht direkt abhaken.“ — jeweils Leerzeile vor/nach Überschriften wie im Bestand, echte Umlaute und „…“-Anführungszeichen wie im Bestand, vor `## 1.1.0 – 2026-09-16`.
13. Alle Gates laufen lassen (siehe verify) und die Zahlen (Dateien/Tests Web, Tests API-Dashboard) für das SUMMARY notieren. Erwartung Web: 52 Dateien (51 − link-widget.test + note-task-list.test + widget-wrapper.test), mindestens 340 Tests (332 − 7 − 1 + 8 + 3 + 4 + 3 + 1 = 343).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test ! -e apps/web/src/components/dashboard/widgets/link-widget.tsx && test ! -e apps/web/src/components/dashboard/widgets/link-widget.test.tsx && test -f apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql && grep -c '^DELETE FROM "WidgetInstance" WHERE "widgetType" = '"'"'link'"'"';' apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql | grep -qx 1 && grep -vc '^--' apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql | xargs -I{} sh -c 'test {} -le 3' && git diff --quiet HEAD -- apps/api/prisma/schema.prisma && ! grep -qi "link" apps/web/src/components/dashboard/widget-registry.tsx apps/web/src/components/dashboard/widget-catalog-modal.tsx apps/api/src/dashboard/dto/create-widget.dto.ts && ! grep -q "LinkWidget\|link-widget" "apps/web/src/app/(portal)/page.tsx" "apps/web/src/app/(portal)/page.test.tsx" && ! grep -q '"link": {' apps/web/src/messages/de.json apps/web/src/messages/en.json && grep -q "### Entfernt" CHANGELOG.md && grep -q "### Behoben" CHANGELOG.md && ! grep -q "^| Link |" docs/anleitung-anwender.md && pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/api exec vitest run src/dashboard</automated>
</verify>
<done>Link-Widget-Dateien gelöscht; Typ in Web (Union, Constraints, Registry, Icon, wire, Katalog, Seite, Tests, i18n de/en) und API (DTO, Kommentare) restlos entfernt; Migration mit genau einer idempotenten DELETE-Anweisung vorhanden, schema.prisma unverändert; widget-wrapper-Test belegt grauen Fallback für unbekannte Typen; Handbuch und CHANGELOG (Geändert/Entfernt/Behoben) aktualisiert; Web-tsc 0, Web-vitest komplett grün (52 Dateien, ≥ 340 Tests), API-tsc 0, API-Dashboard-Spec grün.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API (`PATCH /dashboard/widgets/:id/config`) | Benutzerdaten (Markdown-Inhalt, Favoriten-Titel) werden als JSON-Konfiguration gespeichert; Autorisierung liegt beim bestehenden Guard (unverändert). |
| Gespeichertes Markdown → DOM | Notiz-Inhalt wird per react-markdown gerendert; `rehypeSanitize` ist die XSS-Schranke. |
| Migration → Datenbank | DML auf `WidgetInstance` (mit Kaskade auf `FavoriteLink`) über alle Mandanten hinweg. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-IEX-01 | Tampering / XSS | note-widget.tsx `previewOptions` | high | mitigate | `rehypeSanitize` bleibt in `rehypePlugins`; der `components`-Override ersetzt NUR das `input`-Element durch `NoteCheckbox`, das ausschließlich `checked` liest und keine weiteren Props ins DOM spreizt (kein `node`, keine Attribute aus dem Markdown). Test 8 rendert die echte Vorschau mit Sanitize. |
| T-IEX-02 | Tampering | `toggleTaskLine` / delegierter Klick | low | mitigate | Nur die N-te Aufgabenzeile wird per Regex-Gruppen neu zusammengesetzt; alle anderen Zeilen bleiben byte-identisch (Test 4/5/7). Index außerhalb → kein Schreibzugriff. Speichern läuft über den bestehenden authentifizierten PATCH-Pfad mit AbortController. |
| T-IEX-03 | Spoofing / XSS | Favoriten-Titel (Widget-Kopfzeile, Panel) | low | mitigate | Titel wird als React-Textknoten gerendert (kein `dangerouslySetInnerHTML`), nur bei `typeof === 'string'` übernommen, getrimmt angezeigt. |
| T-IEX-04 | Denial of Service | Entprelltes Speichern des Favoriten-Titels | low | mitigate | 1500 ms Entprellung, ein Timer pro Widget-Instanz, Timer bei Unmount verworfen — kein Request pro Tastendruck. |
| T-IEX-05 | Information Disclosure / Data Loss | Migration `remove_link_widget` | medium | mitigate | Genau eine DELETE-Anweisung mit engem Prädikat (`widgetType = 'link'`), idempotent; Kaskade nur auf `FavoriteLink` desselben Widgets über den bestehenden FK. Wird in diesem Auftrag NICHT ausgeführt — der Anwender spielt sie per Deploy ein (bewusste Produktentscheidung C). |
| T-IEX-06 | Denial of Service | Frontend bei noch vorhandenen Link-Kacheln (vor Migration) | low | mitigate | widget-wrapper.tsx rendert unbekannte Typen als grauen Text; `applyConstraintMinima` kopiert unbekannte Layout-Einträge unverändert (dashboard-grid Test 9); neuer widget-wrapper-Test pinnt den Fallback. Löschen der Kachel im Bearbeitungsmodus bleibt möglich. |
| T-IEX-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation in diesem Auftrag (alle genutzten Module — `@uiw/react-md-editor`, `rehype-sanitize`, `react-markdown` — sind bereits im Lockfile). Kein Legitimacy-Gate nötig. |
</threat_model>
<verification>
- `pnpm --filter @tessera/web type-check` → Exit 0.
- `pnpm --filter @tessera/web exec vitest run` → alle Dateien grün; 52 Dateien, ≥ 340 Tests (exakte Zahlen im SUMMARY nennen).
- `pnpm --filter @tessera/web exec vitest run src/messages/umlaut-guard.spec.ts src/lib/changelog.test.ts` → 3/3 bzw. grün (im Gesamtlauf enthalten).
- `pnpm --filter @tessera/api type-check` → Exit 0; `pnpm --filter @tessera/api exec vitest run src/dashboard` → grün.
- `git diff --quiet HEAD -- apps/api/prisma/schema.prisma` (Schema unverändert); Migrationsdatei vorhanden mit genau einer DELETE-Anweisung.
- Kein `biome check` als Gate, `biome.json` unverändert; kein Docker-Build, kein Deploy, kein Testserver, kein `git push`, kein `prisma migrate`.
- Ein Commit je Task (Konvention der heutigen Quick-Tasks): `feat(web): …` für Task 1 und 2, `feat: Link-Widget entfernt …` (Web + API + Migration + Docs + Changelog) für Task 3; `git rm` der Link-Dateien im dritten Commit.
</verification>
<success_criteria>
- Notiz-Widget: Klick auf ein Kästchen in der Ansicht kippt genau diese Zeile im Markdown, Vorschau aktualisiert, sofortiger PATCH mit `{ config: { content, title } }`; Bearbeitungsmodus unverändert; `rehypeSanitize` aktiv.
- Favoriten-Widget: ohne Titel keine Kopfzeile, mit Titel Kopfzeile im Notiz-Look, im Bearbeitungsmodus Titelfeld (`widgetNoDrag`, 1500 ms entprellt); Einstellungsfeld mit `FavoritesConfig` und „— {title}“; Notiz-Beschriftung übersetzt.
- Link-Widget nirgends mehr vorhanden (Web, API, i18n, Docs), Dateien gelöscht, Migration angelegt, unbekannte Typen crashen nicht.
- Changelog mit drei Einträgen (Geändert/Entfernt/Behoben), Handbuch angepasst.
- Alle Gates aus `<verification>` grün; SUMMARY nennt die gemessenen Testzahlen (vorher 51/332).
</success_criteria>
<output>
Create `.planning/quick/260916-iex-dashboard-widgets-notiz-haekchen-in-der-/260916-iex-SUMMARY.md` when done (Muster: die SUMMARY von 260916-htc — Abschnitte Was gebaut wurde / Entscheidungen / Gemessene Zahlen / Commits / Abweichungen vom Plan).
</output>