5 Commits

Author SHA1 Message Date
schalli cf97b5b64b docs(quick-260916-dyv): Anwenderhandbuch — Bearbeiten-Schalter unten rechts, ganze Kachel ziehbar, Mindestgroessen
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 56s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m52s
- Abschnitt Dashboard: Stift-Schalter unten rechts, Haekchen "Aenderungen
  speichern" und "Widget hinzufuegen" daneben
- Ziehen an beliebiger Stelle der Kachel (Eingabefelder, Knoepfe, Links
  ausgenommen), grauer Griff am oberen Rand, Ablegen nur auf freiem Platz
- Mindestgroesse = gerade noch bedienbar; Entfernen-Symbol rechts im Griff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:43:01 +02:00
schalli dbbd54f5a1 feat(quick-260916-dyv): Bearbeiten-Schalter unten rechts (Rand oben 28 px), ganze Kachel als Griff mit Kopfleiste, cancel-Selektor, kein Ueberlappen beim Ablegen
- page.tsx: feste Aktionsleiste `fixed bottom-6 right-6 z-20` mit "Widget
  hinzufuegen" (nur Bearbeitungsmodus) links neben dem Stift/Haekchen; Block
  oben rechts und mt-8-Wrapper entfernt, Grid direkt im Container p-2
  (12 + 8 + 8 = 28 px statt 60 px); page.test.tsx NEU mit 3 Tests
- widget-wrapper.tsx: Karte ist im Bearbeitungsmodus der Griff (cursor-grab),
  20-px-Overlay-Kopfleiste mit Griff-Symbol und Tooltip dragHint, Loesch-Knopf
  in der Kopfleiste mit data-no-drag; Rumpf h-full unveraendert (Hoehenkette)
- dashboard-grid.tsx: WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR
  (input, textarea, select, button, a, [contenteditable], [data-no-drag],
  .widgetNoDrag), threshold 3; FREE_PLACEMENT_COMPACTOR = noCompactor +
  preventCollision: true (freie Platzierung bleibt, Commit c8f3361)
- dashboard-grid.test.tsx: Mock per importOriginal (echter noCompactor),
  Tests 6-8 (dragConfig-Pin, Compactor-Pin, cancel/handle-Semantik im DOM)
- edit-mode-toggle.tsx: schwebend (shadow-lg, inaktiv border + bg-card)
- de.json/en.json: widgets.dragHint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:41:49 +02:00
schalli dc992c9bc6 fix(quick-260916-dyv): Mindestgroessen inhaltsgetrieben (kleinste bedienbare Kachel je Typ), gespeicherte Minima ueberschrieben, Stoppuhr-Bedienleiste kompakt
- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2,
  calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3),
  Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual
- dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes
  gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu
  kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege
  woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag
- dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je
  Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt)
- stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit
  Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:37:55 +02:00
schalli ec1b0ce582 docs(quick-260916-dyv): Plan — Dashboard-Nachbesserung: Mindestgroessen inhaltsgetrieben, Schalter unten rechts (Rand 28 px), ganze Kachel als Griff, kein Ueberlappen
Drei Aufgaben, 12 Dateien ausserhalb .planning. Gemessene Abweichungen vom
Auftrag: react-grid-layout 2.2.3 nimmt gespeicherte Layout-Eintraege woertlich
inklusive minW/minH und ignoriert data-grid (zweiter Grund fuer "nicht klein
genug" — Ueberschreibung beim Rendern noetig); die alten 12-Spalten-Minima sind
im feinen Raster nur fuer Uhr/Kalender brauchbar, Suche braucht 6 Spalten,
Rechner 9 Zeilen (heute bei 8 unten abgeschnitten), Stoppuhr nur mit kompakter
Bedienleiste; noCompactor ohne preventCollision springt bei Kollision und laesst
Ueberlappungen stehen (auch beim Vergroessern); cancel gewinnt in react-draggable
4.7.0 auch innerhalb des Griffs; widgetNoDrag ist ein toter Marker aus Phase 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:29:11 +02:00
schalli df16f4682a docs(quick-260916-dcz): Plan — CHANGELOG.md in Alltagssprache, Seite "Was ist neu" mit Kanalfilter, Gitea-Release je Freigabe-Tag, Handbuecher
Drei Aufgaben, 19 Dateien ausserhalb .planning. Gemessene Abweichungen vom
Auftrag: .dockerignore schliesst Wurzel-*.md aus (Ausnahme !CHANGELOG.md noetig);
localhost:3002 ist aus dem CI-Job-Container nicht erreichbar (API-Basis aus
GITHUB_SERVER_URL); vier Module statt zwei in den Handbuechern. Bauweg per
env.TESSERA_CHANGELOG_MD in next.config.ts (webpack + Turbopack, Server-Bundle-only).
COVERAGE.md: Gitea-Release-API (POST/GET-by-tag/PATCH integriert, Rest begruendet aus).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:03:06 +02:00
15 changed files with 1145 additions and 99 deletions
@@ -0,0 +1,315 @@
---
phase: quick-260916-dcz
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260916-DCZ]
files_modified:
- CHANGELOG.md
- .dockerignore
- apps/web/Dockerfile
- apps/web/next.config.ts
- apps/web/src/lib/changelog.ts
- apps/web/src/lib/changelog.test.ts
- apps/web/src/app/(portal)/changelog/page.tsx
- apps/web/src/app/(portal)/changelog/changelog-page.test.tsx
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/layout/app-version-badge.tsx
- apps/web/src/components/layout/app-version-badge.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- .gitea/scripts/publish-release.sh
- .gitea/workflows/ci.yml
- docs/anleitung-betrieb.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- docs/ci-cd-setup.md
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "`CHANGELOG.md` liegt im Wurzelverzeichnis, deutsch mit echten Umlauten, Alltagssprache in Sie-Form, Form nach Keep a Changelog: H1, kurzer Vorspann, dann `## Unveröffentlicht` (Untergruppen `### Neu` / `### Geändert` mit den Punkten des Dashboard-Umbaus 260916-bwo UND der Seite „Was ist neu“ dieses Auftrags), darunter `## 1.0.0 – 2026-09-15` mit 6-12 Punkten des Live-Standes aus den Handbuechern (Portal, Dashboard-Widgets, Marktplatz/Freigaben, Ausschreibungs-Radar, DKV-Rechnung, Zertifikat-Manager + Domaincheck, Benutzer/Gruppen/AD, SMTP + Passwort zuruecksetzen, persoenliche Einstellungen, Fehler melden, Versionsanzeige). Keine Dateinamen, keine Commit-Kuerzel, keine unerklaerten Fachbegriffe."
- "Ein angemeldeter Anwender klickt unten in der Seitenleiste auf die Versionszeile (jetzt ein Link mit `aria-label` „Was ist neu“, `href=\"/changelog\"`, Tooltip unveraendert) und sieht unter `/changelog` im Portal-Layout die Seite „Was ist neu“ mit dem Inhalt von `CHANGELOG.md` als gerendertem Markdown (`MDEditor.Markdown` aus dem bereits installierten `@uiw/react-md-editor` 4.1.1 mit `rehype-sanitize`, kein neues Paket). Ohne Anmeldung leitet die bestehende Middleware auf `/login` um (`/changelog` ist keine oeffentliche Route)."
- "Kanalregel als reine Funktion `filterChangelogForChannel(markdown, channel, options?)` in `apps/web/src/lib/changelog.ts`: auf `live` fehlt der Abschnitt „Unveröffentlicht“ vollstaendig; auf `beta` und `dev` bleibt er und traegt die Ueberschrift „Noch nicht freigegeben (Beta)“ (uebersetzt), die Seite zeigt dazu einen Hinweis; ein leerer Abschnitt (ohne Listenpunkt) wird auf allen Kanaelen ausgeblendet; H1 und Vorspann vor der ersten `## `-Ueberschrift entfallen (die Seite hat ihren eigenen Titel). Falsifizierung (a): der live-Test wird rot, sobald die Funktion den Abschnitt nicht mehr entfernt."
- "Der Changelog-Text kommt zur BAUZEIT ins Bundle: `apps/web/next.config.ts` liest `../../CHANGELOG.md` per `fs.readFileSync` und legt den Text als `env.TESSERA_CHANGELOG_MD` ab (Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` in webpack UND Turbopack ueber denselben Define-Mechanismus — gemessen in `next/dist/lib/static-env.js` `getNextConfigEnv` + `serializeDefineEnv`). Fehlt die Datei, bricht der Build mit klarer deutscher Meldung ab. Im Docker-Bau liegt die Datei im Kontext (`.dockerignore` schliesst `*.md` an der Wurzel aus — gemessen: `COPY README.md` scheitert mit `not found` — daher die Ausnahme `!CHANGELOG.md`) und wird mit EINER COPY-Zeile in die builder-Stufe kopiert. Falsifizierung (c): im lokal gebauten Web-Abbild enthaelt `/app/apps/web/.next/server` den Datums-Marker der 1.0.0-Ueberschrift, `/app/apps/web/.next/static` NICHT (der Text liegt nur im Server-Bundle, nicht in oeffentlich abrufbaren Chunks)."
- "`.gitea/scripts/publish-release.sh` (POSIX sh, `set -eu`, `jq` + `curl` — beides im Runner-Abbild `gitea/runner-images:ubuntu-latest` vorhanden: jq 1.6, und auf dem Host: jq 1.7) entscheidet wie `publish-images.sh` anhand `GITHUB_REF` (`refs/tags/v*`, sonst „nichts zu tun“, Exit 0), akzeptiert `--tag vX.Y.Z` und `--dry-run`, schneidet den Abschnitt `## X.Y.Z` (bis zur naechsten `## `-Ueberschrift, ohne die eigene Ueberschrift, ohne Leerzeilen am Rand) per awk aus `CHANGELOG.md`, baut das JSON ausschliesslich mit `jq --arg`, legt den Release per `POST .../releases` an (`tag_name`, `name` = `Tessera X.Y.Z`, `body` = Abschnitt) und aktualisiert per `PATCH .../releases/{id}`, wenn `GET .../releases/tags/{tag}` bereits 200 liefert (idempotent, Text folgt CHANGELOG.md). Falsifizierung (b): `--dry-run --tag v9.9.9` (kein Abschnitt) endet mit Exit 1 und der Meldung, dass CHANGELOG.md keinen Abschnitt fuer 9.9.9 hat — es entsteht nie ein leerer Release. Das Token kommt nur aus `GITEA_TOKEN` (Umgebung), wird nie ausgegeben und nicht als Kommandozeilenargument uebergeben (Header aus Datei). API-Basis: `GITEA_API`, sonst `GITHUB_API_URL`, sonst `GITHUB_SERVER_URL/api/v1`, sonst `http://localhost:3002/api/v1` — im CI-Job-Container ist `localhost:3002` NICHT erreichbar (gemessen: 000), `https://git.vicolab.de` schon (200)."
- "`.gitea/workflows/ci.yml`: der Job `publish` hat nach dem Abbild-Schritt einen Schritt, der `sh .gitea/scripts/publish-release.sh` mit `GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}` in `env` aufruft (kein Echo, kein Argument). Das Token traegt gemessen `write:repository` (Scopes der Nutzer-Tokens `cc-full`/`Claude-Code`, per Basic-Auth gelesen) — das deckt Releases ab. Rueckwirkend existiert nach dem echten lokalen Lauf `--tag v1.0.0` (Token aus der Push-URL, nie ausgeben) der Release `Tessera 1.0.0` zum bestehenden Tag `v1.0.0` (Commit e509860; vorher gemessen: null Releases im Repo); ein zweiter Lauf geht den PATCH-Weg (Exit 0, weiterhin genau ein Release)."
- "Handbuecher: `docs/anleitung-betrieb.md` Kapitel 9 „Eine Version freigeben“ nennt VOR dem Tag den Schritt „CHANGELOG.md: Unveröffentlicht in X.Y.Z – Datum umbenennen, neues leeres Unveröffentlicht anlegen, auf main pushen“, den automatischen Gitea-Release durch die Pipeline (und was passiert, wenn der Abschnitt fehlt) und die Seite „Was ist neu“ als vierten Weg unter „Woran Sie erkennen, welche Version läuft“; der Absatz „Erstfreigabe v1.0.0“ steht in der Vergangenheit (erfolgt: Tag 2026-09-14, live seit 2026-09-15). `docs/anleitung-anwender.md` hat einen Abschnitt „Was ist neu“ (Klick auf die Version unten links; auf Live nur Freigegebenes) samt Inhaltsverzeichnis-Eintrag. `docs/anleitung-entwicklung.md` traegt unter „Konventionen und Fallstricke“ die Regel „jede Änderung sofort in CHANGELOG.md unter Unveröffentlicht“. `docs/ci-cd-setup.md` (ASCII-Umschrift wie im Bestand) nennt den vierten Schritt des Jobs `publish`, die Token-Berechtigung `repository: write` und den Release je Tag."
- "Baseline am Ende: Web `Test Files 48 passed (48)` / `Tests 301 passed (301)` (Planungszeit 46/286 plus 10 in `changelog.test.ts`, 3 in `changelog-page.test.tsx`, 2 in `app-version-badge.test.tsx`), API unveraendert `67 passed (67)` / `1078 passed (1078)`, `tsc --noEmit` in web, api und shared Exit 0, `pnpm install --frozen-lockfile` Exit 0 (keine neuen Pakete), `git diff --stat 7a6f42e -- . ':!.planning'` nennt genau `19 files changed`; `.env*`, Compose-Dateien, Prisma-Schema, `pnpm-lock.yaml`, `package.json` beider Apps, `umlaut-dictionary.ts` unangetastet. Nach `git push` endet der CI-Lauf zum gepushten Commit mit `conclusion == success` (der Release-Schritt meldet auf `main` „nichts zu tun“)."
artifacts:
- "CHANGELOG.md — H1 `# Änderungen an Tessera`, Vorspann (2-3 Saetze), `## Unveröffentlicht` mit `### Neu` und `### Geändert`, `## 1.0.0 – 2026-09-15` mit `### Neu`"
- ".dockerignore — Zeile `!CHANGELOG.md` direkt nach `*.md`"
- "apps/web/Dockerfile — in der builder-Stufe genau eine neue Zeile `COPY CHANGELOG.md ./` (zwischen `COPY tsconfig.base.json ./` und `ENV NEXT_PUBLIC_API_URL`), Kommentar mit Verweis auf next.config.ts"
- "apps/web/next.config.ts — `readChangelog()` (node:fs/node:path, `path.resolve(__dirname, '../../CHANGELOG.md')`, klare Fehlermeldung) und `env: { TESSERA_CHANGELOG_MD: readChangelog() }`"
- "apps/web/src/lib/changelog.ts — `export type ChangelogChannel = AppChannel`; `export interface FilteredChangelog { markdown: string; hasUnreleased: boolean }`; `export function filterChangelogForChannel(markdown: string, channel: AppChannel, options?: { unreleasedHeading?: string }): FilteredChangelog`; `export const UNRELEASED_HEADING = 'Unveröffentlicht'`; `export const changelogMarkdown: string = process.env.TESSERA_CHANGELOG_MD ?? ''` (voller Literalname)"
- "apps/web/src/lib/changelog.test.ts — NEU, 10 Tests laut <behavior>"
- "apps/web/src/app/(portal)/changelog/page.tsx — async Server-Komponente (kein 'use client'), `getTranslations('changelog')`, Kanal aus `appVersion.channel`, rendert `<h1>`, Intro, optionalen Hinweis (`data-testid=\"changelog-unreleased-hint\"`), `<ChangelogView markdown=…/>` oder Leer-Text (`data-testid=\"changelog-empty\"`)"
- "apps/web/src/app/(portal)/changelog/changelog-page.test.tsx — NEU, 3 Tests laut <behavior>"
- "apps/web/src/components/changelog/changelog-view.tsx — 'use client', `ChangelogView({ markdown })` mit `MDEditor.Markdown` (`source`, `rehypePlugins={[[rehypeSanitize]]}`, `wrapperElement={{ 'data-color-mode': mode }}` aus `useTheme().resolvedTheme` nach Mount, `data-testid=\"changelog-markdown\"` am Wrapper)"
- "apps/web/src/components/layout/app-version-badge.tsx — `span` wird `Link` (next/link) auf `/changelog` mit `aria-label={t('whatsNew')}`, `title` wie bisher, `data-testid=\"app-version\"`, Klassen wie bisher plus `hover:text-foreground hover:underline`"
- "apps/web/src/components/layout/app-version-badge.test.tsx — `next/link`-Mock (Props durchreichen) und 2 neue Tests (href, aria-label)"
- "apps/web/src/messages/de.json + en.json — `sidebar.whatsNew` und neuer Namensraum `changelog` mit `title`, `intro`, `unreleasedHeading`, `unreleasedHint`, `empty`"
- ".gitea/scripts/publish-release.sh — NEU, ausfuehrbar (chmod +x), Kopfkommentar wie publish-images.sh"
- ".gitea/workflows/ci.yml — vierter Schritt im Job `publish`"
- "docs/anleitung-betrieb.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, docs/ci-cd-setup.md — Abschnitte wie in den truths"
key_links:
- "Bauzeit-Einbettung: `env` in next.config.ts wird von `getNextConfigEnv` zu `process.env.TESSERA_CHANGELOG_MD` fuer client/edge/nodejs-Varianten definiert und per `serializeDefineEnv` JSON-stringifiziert (mehrzeilige Werte sicher). Deshalb (1) muss `changelog.ts` den vollen Literalnamen verwenden (kein Destructuring, kein `process.env[name]` — dieselbe Regel wie in app-version.ts), (2) darf `changelog.ts` NUR von der Server-Seite (`page.tsx`) importiert werden, damit der Text nicht in `.next/static` landet, (3) haengt die Docker-Falsifizierung an der COPY-Zeile PLUS der `.dockerignore`-Ausnahme — eine ohne die andere laesst den Build mit `not found` bzw. mit der eigenen Fehlermeldung scheitern."
- "Option (b) `?raw`/`asset/source` scheidet aus: `apps/web` startet mit `next dev --turbopack`, der `webpack`-Block in next.config.ts greift dort nicht, und eine Turbopack-`rules`-Regel braeuchte einen Loader (`raw-loader`) — verbotenes neues Paket. Option (a) Laufzeit-`fs.readFileSync` in einer Server-Komponente scheidet aus, weil jede Seite dynamisch ist (`i18n/request.ts` liest Cookies) und die Datei dann per `outputFileTracingIncludes` ausserhalb des Projektordners in die runner-Stufe muesste — zwei Mechanismen statt einem. Option (c) generierte Datei braeuchte Skript-Ketten vor build/dev/tsc/vitest."
- "Seite -> Abzeichen: `sidebar.test.tsx` mockt `@/components/layout/app-version-badge` komplett — der Link-Umbau erfordert dort keine Aenderung; `app-version-badge.test.tsx` rendert die echte Komponente und braucht deshalb einen `next/link`-Mock wie in sidebar.test.tsx (Props inkl. `aria-label`, `title`, `data-testid` durchreichen)."
- "Auth: `apps/web/src/middleware.ts` schuetzt jede Route ausser `/login`, `/reset-password`, `/_next/*`, `/api*` — `/changelog` ist damit ohne weiteres Zutun nur angemeldet erreichbar. Statische Chunks unter `/_next/static` sind NICHT geschuetzt — deshalb Server-Bundle-only (siehe oben)."
- "Release-Skript -> Gitea: Aus dem Job-Container ist Gitea nur ueber `https://git.vicolab.de` erreichbar (per-Job-Netz `GITEA-ACTIONS-TASK-…-network`, Runner-Instanz-URL = git.vicolab.de); `docker login localhost:3002` funktioniert heute nur, weil der Docker-DAEMON (Host) die Registry anspricht, nicht der Job-Container. Das Skript darf also im CI nie auf localhost:3002 zurueckfallen — es loest `GITHUB_API_URL`/`GITHUB_SERVER_URL` auf und gibt die gewaehlte API-Basis (ohne Token) als erste Zeile aus; der CI-Lauf auf `main` beweist die Aufloesung im Log, der Release-Weg selbst wird lokal gegen localhost:3002 mit `--tag v1.0.0` bewiesen."
- "Abschnitt-Schnitt: awk-Muster `^## X\\.Y\\.Z( |$)` bis zur naechsten `^## `; zur Planungszeit auf einem Muster-Changelog bewiesen (1.0.0 und 0.9.0 korrekt, `1.0` und `2.0.0` leer -> Exit 1). `## Unveröffentlicht` kann nie getroffen werden, weil der Tag `^v[0-9]+\\.[0-9]+\\.[0-9]+$` erfuellen muss."
- "Umlaut-Waechter: `umlaut-guard.spec.ts` prueft jede neue de.json-Zeichenkette gegen `SUSPECT_RE=/(ae|oe|ue|ss)/i` und die Allowlist. Die vorgegebenen Texte enthalten kein solches Token ausser bereits gelisteten Woertern (`aktuelle`) — daher bleibt `umlaut-dictionary.ts` unangetastet (Gate). Woerter wie „Neuerungen“ (ue), „Fassung“ (ss), „dass“ (ss) sind zu vermeiden."
---
<objective>
Aenderungsliste fuer Anwender und Betrieb: (1) `CHANGELOG.md` im Wurzelverzeichnis in Alltagssprache (rueckwirkend 1.0.0, dazu „Unveröffentlicht“ mit dem Dashboard-Umbau und dieser Seite); (2) Seite „Was ist neu“ unter `/changelog`, erreichbar per Klick auf die Versionszeile in der Seitenleiste, mit Kanalfilter (Live sieht nur Freigegebenes) — Text zur Bauzeit ins Bundle, kein neues Paket; (3) Gitea-Release je Freigabe-Tag durch die Pipeline (Skript mit `--dry-run`, idempotent, rueckwirkend `v1.0.0`); (4) Handbuecher (Betrieb Kapitel 9, Anwender, Entwicklung, CI-Setup).
Purpose: Auf dem Live-Server soll jederzeit einsehbar sein, was sich geaendert hat — fuer Anwender in der Oberflaeche, fuer den Betrieb im Gitea-Release, fuer die Entwicklung als Pflichtschritt je Aenderung.
Output: 19 Dateien (13 Code/Tests/Bau, 2 CI, 4 Handbuecher), drei Commits mit Scope `quick-260916-dcz` (feat / ci / docs), gepusht, CI-Lauf beobachtet, Release `v1.0.0` in Gitea vorhanden.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/quick/260916-bwo-dashboard-feineres-raster-spalten-und-ze/260916-bwo-SUMMARY.md
@.planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-PLAN.md
@apps/web/src/components/layout/app-version-badge.tsx
@apps/web/src/components/layout/app-version-badge.test.tsx
@apps/web/src/lib/app-version.ts
@apps/web/src/components/layout/sidebar.test.tsx
@apps/web/src/components/modules/module-access-gate.tsx
@apps/web/src/components/modules/module-access-gate.test.tsx
@apps/web/src/components/dashboard/widgets/note-widget.tsx
@apps/web/src/components/dashboard/widgets/note-widget.test.tsx
@apps/web/src/middleware.ts
@apps/web/next.config.ts
@apps/web/Dockerfile
@.dockerignore
@.gitea/workflows/ci.yml
@.gitea/scripts/publish-images.sh
@apps/web/src/messages/umlaut-guard.spec.ts
@docs/anleitung-betrieb.md
@docs/anleitung-anwender.md
@docs/anleitung-entwicklung.md
@docs/ci-cd-setup.md
</context>
<planning_measurements>
Gemessen am 2026-09-16 an HEAD `7a6f42e` (Arbeitsbaum sauber, main == origin/main). Abweichungen vom Auftragstext sind mit „DELTA“ markiert.
- Baseline: Web `46 files / 286 tests`, API `67 / 1078`, `tsc --noEmit` web/api/shared Exit 0 (frisch nachgemessen, stimmt mit dem Auftrag ueberein).
- Tag `v1.0.0` ist ein annotierter Tag (Objekt 4d36942) auf Commit `e509860`, Tagger-Datum 2026-09-14; live seit 2026-09-15 laut STATE.md. Changelog-Datum bleibt wie beauftragt `2026-09-15` (Tag der Inbetriebnahme).
- Gitea 1.26.2; `GET /repos/schalli/tessera-ctl/releases` -> leere Liste (kein Release vorhanden). Token aus der Push-URL (Form `user:token@localhost:3002`) antwortet auf `/api/v1/user` mit 200; die Nutzer-Tokens `cc-full` und `Claude-Code` tragen u. a. `write:repository` und `write:package` — `secrets.REGISTRY_TOKEN` ist eines davon (docker login mit `-u schalli`), reicht also fuer Releases. Repo-Rechte: admin/push/pull true, `has_releases: true`.
- DELTA (wichtig): `.dockerignore` enthaelt `*.md` — gemessen mit `COPY README.md` in einem Test-Dockerfile: `"/README.md": not found`. Eine COPY-Zeile allein reicht NICHT; `.dockerignore` braucht die Ausnahme `!CHANGELOG.md` (zweite Bau-Datei, im N enthalten).
- DELTA: `localhost:3002` ist aus einem Container mit dem Runner-Abbild NICHT erreichbar (curl -> 000), `https://git.vicolab.de/api/v1/version` schon (200). Job-Container laufen in einem per-Job-Netz (`GITEA-ACTIONS-TASK-…-network`), Runner-Instanz-URL `https://git.vicolab.de`. Das Release-Skript darf im CI nicht auf localhost:3002 zeigen.
- Werkzeuge: Runner-Abbild `gitea/runner-images:ubuntu-latest` hat `jq` 1.6, `curl`, `python3`, `git`; Host hat `jq` 1.7, `curl`, `python3`. Entscheidung: `jq`.
- `@uiw/react-md-editor` 4.1.1 exportiert `MDEditor.Markdown` (statisch am Default-Export, Typ `MarkdownPreviewProps`: `source`, `rehypePlugins`, `wrapperElement` mit `data-color-mode: 'light'|'dark'`, `className`, `style`). `@uiw/react-markdown-preview` ist NUR transitiv vorhanden (pnpm-Isolation) — nicht direkt importieren. Das Notiz-Widget nutzt `MDEditor` mit `preview='preview'` und `previewOptions.rehypePlugins=[[rehypeSanitize]]`; sein Test mockt `@uiw/react-md-editor` als Modul mit `default` + `commands`.
- Next 15.5.19: `next.config.ts` wird per SWC nach CommonJS transpiliert und mit Dateiname `<cwd>/next.config.compiled.js` geladen -> `__dirname` = `apps/web`. `env`-Werte laufen ueber `getNextConfigEnv` (Schluessel `process.env.<KEY>`, verboten nur `NODE_*`, `__*`, `NEXT_RUNTIME`) und `serializeDefineEnv` (JSON.stringify) fuer webpack und Turbopack gleichermassen. `node:fs`, `node:path`, `__dirname` typechecken in `apps/web` (Scratch-Datei, tsc Exit 0).
- Jede Seite ist dynamisch (`i18n/request.ts` -> `cookies()`), deshalb keine Prerender-Abkuerzung; `middleware.ts` schuetzt alle Routen ausser `/login`, `/reset-password`, `/_next/*`, `/api*`.
- Bestehende Server-Komponente mit `getTranslations` + Test-Muster (Funktion awaiten, Ergebnis rendern, `next-intl/server` gemockt): `module-access-gate.tsx` / `.test.tsx`. Bestehende Server-Seiten ohne 'use client': `(portal)/settings/page.tsx`, `(portal)/modules/[category]/[moduleSlug]/page.tsx`.
- `sidebar.test.tsx` mockt das Abzeichen als Modul — der Link-Umbau beruehrt sidebar.tsx/-test nicht. `app-version-badge.test.tsx` mockt heute `next-intl` und `@/lib/app-version`, aber nicht `next/link`.
- `umlaut-guard.spec.ts`: Allowlist enthaelt u. a. `aktuelle`, `neue`, `muss`, `Adresse`, `Passwort` — NICHT `Neuerungen`, `Fassung`, `dass`. Texte unten sind so gewaehlt, dass `umlaut-dictionary.ts` unangetastet bleibt.
- DELTA: Die Handbuecher nennen VIER Module (Ausschreibungs-Radar, DKV-Rechnung, Zertifikat-Manager, Domaincheck), nicht zwei; „DKV Fleet“ heisst fuer Anwender „DKV-Rechnung“. Das Anwenderhandbuch beschreibt in „Aufbau der Oberfläche“ heute keine Versionszeile.
- Commits seit `v1.0.0`: nur Doku-Commits und 260916-bwo (5 Commits) — „Unveröffentlicht“ = bwo-Punkte + diese Seite.
- Lokaler `docker build` des Web-Abbilds: Kontext enthaelt `apps/desktop/src-tauri/target` (3,8 GB, nicht in .dockerignore) — BuildKit ueberfuehrt inkrementell; ku1 hat lokal 99-129 s je Bau gemessen. Einplanen: 2-4 Minuten.
- Planer-Beitraege: `schema-gate` — keine Prisma-/Schema-Datei im Umfang, kein Push-Task. `api-coverage` — Gitea-Release-API ist eine externe API: Matrix in `COVERAGE.md` neben diesem Plan (POST/GET-by-tag/PATCH INTEGRATE; list/latest/delete/assets/draft OPT-OUT mit Grund). `assumption-delta scan` -> `{"skipped":true,"reason":"phase_unresolved"}` (Quick-Task ohne ROADMAP-Abschnitt); inhaltlich keine Singular->Plural-Verschiebung (Kanaele existieren seit ku1) -> `no-change`. `estimate-calibration`: factor 1, sample_count 0, confidence low.
</planning_measurements>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: CHANGELOG.md, Bauzeit-Einbettung, Kanalfilter mit Tests, Seite „Was ist neu“, Abzeichen-Link, i18n</name>
<files>CHANGELOG.md, .dockerignore, apps/web/Dockerfile, apps/web/next.config.ts, apps/web/src/lib/changelog.ts, apps/web/src/lib/changelog.test.ts, apps/web/src/app/(portal)/changelog/page.tsx, apps/web/src/app/(portal)/changelog/changelog-page.test.tsx, apps/web/src/components/changelog/changelog-view.tsx, apps/web/src/components/layout/app-version-badge.tsx, apps/web/src/components/layout/app-version-badge.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- apps/web/src/lib/app-version.ts (Literalname-Regel fuer process.env, `AppChannel`)
- apps/web/src/lib/app-version.test.ts (Muster vi.stubEnv + vi.resetModules + dynamischer Import)
- apps/web/src/components/layout/app-version-badge.tsx + .test.tsx
- apps/web/src/components/layout/sidebar.test.tsx (Zeilen 1-20: `next/link`-Mock)
- apps/web/src/components/modules/module-access-gate.tsx + .test.tsx (Server-Komponente mit getTranslations, Testmuster)
- apps/web/src/components/dashboard/widgets/note-widget.tsx (MDEditor + rehypeSanitize) und note-widget.test.tsx (Modul-Mock von @uiw/react-md-editor)
- apps/web/src/components/layout/app-shell.tsx (mounted-Guard) und apps/web/src/app/(portal)/change-password/page.tsx (Seitenrahmen `mx-auto max-w-… py-8 px-4`, `<h1 className="text-2xl font-bold …">`)
- apps/web/next.config.ts, apps/web/Dockerfile, .dockerignore
- apps/web/src/messages/umlaut-guard.spec.ts (Regeln), de.json/en.json Namensraum `sidebar`
- docs/anleitung-anwender.md (Abschnitte Aufbau der Oberflaeche, Dashboard, Marktplatz, Module, Persoenliche Einstellungen, Fehler melden) und docs/anleitung-administration.md (Kapitel 1-6) — Quelle der 1.0.0-Punkte, nichts raten
- .planning/quick/260916-bwo-dashboard-feineres-raster-spalten-und-ze/260916-bwo-SUMMARY.md Abschnitt „Fuer den Changelog“ (5 Punkte)
</read_first>
<behavior>
changelog.test.ts (10 Tests, Vorlage: Muster-Changelog als Konstante mit H1, Vorspann, `## Unveröffentlicht` (`### Geändert` mit zwei Punkten), `## 1.0.0 – 2026-09-15` (`### Neu` mit zwei Punkten), `## 0.9.0 – 2026-09-01`):
- Test 1 (live): Ergebnis enthaelt weder `## Unveröffentlicht` noch die Punkte darunter, aber `## 1.0.0 – 2026-09-15`, `## 0.9.0 – 2026-09-01` und deren Punkte in dieser Reihenfolge; `hasUnreleased === false`. (Falsifizierung a: wird rot, sobald die Funktion den Abschnitt nicht entfernt.)
- Test 2 (beta): Ergebnis enthaelt `## Noch nicht freigegeben (Beta)` statt `## Unveröffentlicht`, die Punkte darunter bleiben, `hasUnreleased === true`.
- Test 3 (dev): wie Test 2.
- Test 4 (eigenes Label): `options.unreleasedHeading = 'Not yet released (beta)'` -> Ueberschrift `## Not yet released (beta)`.
- Test 5 (leerer Abschnitt, beta): Unveröffentlicht nur mit `### Neu` ohne Listenpunkt -> Abschnitt fehlt im Ergebnis, `hasUnreleased === false`, 1.0.0 bleibt.
- Test 6 (kein Abschnitt): Markdown ohne Unveröffentlicht -> `hasUnreleased === false`, Versionsabschnitte unveraendert.
- Test 7 (Hierarchie): H1-Zeile und Vorspann fehlen; das Ergebnis beginnt (nach trim) mit `## `; `### `-Ueberschriften bleiben.
- Test 8 (CRLF): Eingabe mit `\r\n` -> live entfernt den Abschnitt ebenfalls, Ergebnis enthaelt kein `\r`.
- Test 9 (Einbettung): `vi.stubEnv('TESSERA_CHANGELOG_MD', '# T\n\n## 1.0.0 – 2026-01-01\n\n- x')` + `vi.resetModules()` + dynamischer Import -> `changelogMarkdown` ist exakt dieser Wert.
- Test 10 (ohne Variable): `vi.stubEnv('TESSERA_CHANGELOG_MD', '')` bzw. unset -> `changelogMarkdown === ''`.
changelog-page.test.tsx (3 Tests; Mocks: `next-intl/server` getTranslations mit Handtabelle fuer `title`, `intro`, `unreleasedHeading`, `unreleasedHint`, `empty`; `@/lib/app-version` mit veraenderbarem `appVersion.channel`; `@/lib/changelog` per `vi.doMock` mit `importOriginal` und eigenem `changelogMarkdown`; `@uiw/react-md-editor` als Modul mit `default: { Markdown: ({ source }) => <pre data-testid="md">{source}</pre> }`; `next-themes` `useTheme: () => ({ resolvedTheme: 'light' })`; Seite wie module-access-gate.test awaiten und rendern):
- Test 1 (live): H1 „Was ist neu“ sichtbar, `data-testid="changelog-unreleased-hint"` fehlt, `md`-Text enthaelt `1.0.0`, aber weder `Unveröffentlicht` noch `Noch nicht freigegeben`.
- Test 2 (beta): Hinweis vorhanden (Text aus Tabelle), `md`-Text enthaelt `Noch nicht freigegeben (Beta)`.
- Test 3 (leer): `changelogMarkdown = ''` -> `data-testid="changelog-empty"` mit Leer-Text sichtbar, kein `md`-Element.
app-version-badge.test.tsx (+2, plus `next/link`-Mock, der `href`, `className`, `title`, `aria-label`, `data-testid` durchreicht; `whatsNew: 'Was ist neu'` in der next-intl-Tabelle):
- Test 5: `screen.getByTestId('app-version')` hat `href="/changelog"` (Tag `a`).
- Test 6: `aria-label` ist `Was ist neu`; Tests 1-4 bleiben gruen (Text, title mit/ohne API, dev ohne title).
</behavior>
<action>
Schritt A — RED: die drei Testdateien gemaess `<behavior>` anlegen bzw. ergaenzen; `pnpm -C apps/web exec vitest run src/lib/changelog.test.ts "src/app/(portal)/changelog" src/components/layout/app-version-badge.test.tsx` muss rot sein (Modul fehlt / kein Link), Ausgabe fuer das SUMMARY notieren.
Schritt B — `CHANGELOG.md` (Wurzel, UTF-8, echte Umlaute, Sie-Form, Alltagssprache; keine Dateinamen, keine Commit-Kuerzel, keine unerklaerten Fachbegriffe). Aufbau: `# Änderungen an Tessera`; Vorspann (2-3 Saetze: was die Liste ist, neueste Version oben, „Unveröffentlicht“ = nur in der Beta enthalten); `## Unveröffentlicht` mit `### Neu` (Punkt 1: die Seite „Was ist neu“ — ein Klick auf die Versionsnummer unten in der Seitenleiste zeigt diese Liste; auf Live nur Freigegebenes. Punkt 2: die Uhr-Schriftgroesse in Punkt aus bwo-Punkt 3) und `### Geändert` (bwo-Punkte 1, 2, 4, 5 sprachlich geglaettet, echte Umlaute); dann `## 1.0.0 – 2026-09-15` (Gedankenstrich U+2013, keine eckigen Klammern) mit `### Neu` und 11 Punkten, JEDER aus den Handbuechern belegt: (1) Portal mit Kopfleiste und Seitenleiste — Dashboard, Marktplatz, freigegebene Module nach Kategorien mit Suchfeld, Seitenleiste ein-/ausklappbar, hell/dunkel/System, Deutsch/Englisch; (2) persoenliches Dashboard mit frei anordenbaren Kacheln: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Link, Stoppuhr; Bearbeitungsmodus (hinzufuegen, verschieben, Groesse ziehen), Einstellungen je Kachel unter Einstellungen > Dashboard (Kalenderquellen, Suchanbieter, Links); (3) Marktplatz mit Status Aktiviert/Verfuegbar, Suche, Filter, Detailseite; Aktivierung durch Administratoren, Freigabe je Gruppe oder Benutzer (Freigaben-Matrix); (4) Ausschreibungs-Radar: Trefferliste oeffentlicher Ausschreibungen, Filter (Frist, Postleitzahl, Bundesland, Branche, Wert), Suchprofile mit Sofort-Alarm per E-Mail, Sammel-Mail taeglich/woechentlich, Merken/Gelesen, eigene Postfaecher und RSS-Feeds als Quellen; (5) DKV-Rechnung: automatische Verarbeitung von DKV-Tankkarten-Rechnungen aus einem Postfach, Fahrzeug-Stammdaten mit CSV-Import, Verarbeitungshistorie, Exportdateien; (6) Zertifikat-Manager (analysieren, aufteilen, zusammenfuehren, konvertieren) und Domaincheck (Verfuegbarkeit von Internet-Domains); (7) Benutzer-, Gruppen- und Rechteverwaltung: Rollen Benutzer/Admin/Super-Admin, lokale und verzeichnisgefuehrte Konten, Gruppen mit Standardgruppe, Active-Directory-Anbindung mit Import von Gruppen und Einzelbenutzern, Ausschlussliste, automatische Synchronisation; (8) E-Mail-Versand (SMTP) mit Testnachricht, Passwort vergessen/zuruecksetzen per E-Mail, erzwungene Passwortaenderung bei neuen Konten; (9) persoenliche Einstellungen: Profilbild, Akzentfarbe, Passwort aendern fuer lokale Konten; (10) Knopf „Fehler melden“ mit Bildschirmfoto, Beschreibung und technischen Angaben per E-Mail an den Administrator; (11) Versionsanzeige unten in der Seitenleiste (Version und Kanal Live/Beta). Umlaute in dieser Aufzaehlung sind ASCII-Umschrift des Plans — in der Datei echte Umlaute.
Schritt C — Bauweg. `.dockerignore`: direkt unter `*.md` die Zeile `!CHANGELOG.md` mit Kommentarzeile (Grund: Wurzel-Markdown ist ausgeschlossen, diese eine Datei braucht der Web-Bau). `apps/web/Dockerfile`: in der builder-Stufe nach `COPY tsconfig.base.json ./` genau eine Zeile `COPY CHANGELOG.md ./` plus Kommentar (quick-260916-dcz: next.config.ts liest die Datei zur Bauzeit; Ausnahme in .dockerignore). `apps/web/next.config.ts`: `import { readFileSync } from 'node:fs'`, `import path from 'node:path'`; Funktion `readChangelog(): string`, die `path.resolve(__dirname, '../../CHANGELOG.md')` liest und bei Fehler eine Error mit deutscher Meldung wirft (Dateipfad nennen, Hinweis auf .dockerignore-Ausnahme und COPY-Zeile); in `nextConfig` den Schluessel `env: { TESSERA_CHANGELOG_MD: readChangelog() }` ergaenzen; Kopfkommentar (deutsch, ASCII, 3-5 Zeilen): Bauzeit-Einbettung, gilt fuer webpack und Turbopack, Server-Bundle-only durch Importdisziplin (nur page.tsx importiert lib/changelog).
Schritt D — `apps/web/src/lib/changelog.ts` (Kopfkommentar deutsch ASCII): `UNRELEASED_HEADING = 'Unveröffentlicht'`; `changelogMarkdown = process.env.TESSERA_CHANGELOG_MD ?? ''` mit vollem Literalnamen und Kommentar zur Literalname-Regel; `filterChangelogForChannel(markdown, channel, options?)`: Zeilenenden auf `\n` normalisieren; Zeilen ab der ersten `## `-Ueberschrift behalten (H1 und Vorspann verwerfen); Abschnitte an `^## `-Grenzen bilden; den Abschnitt mit Ueberschrift `^## Unveröffentlicht\s*$` suchen; „leer“ = kein Listenpunkt `^\s*[-*] ` im Abschnitt; leer ODER channel === 'live' -> Abschnitt verwerfen, `hasUnreleased=false`; sonst Ueberschriftszeile durch `## ${options?.unreleasedHeading ?? 'Noch nicht freigegeben (Beta)'}` ersetzen, `hasUnreleased=true`; Rueckgabe `{ markdown: abschnitte.join('\n').trim() + '\n', hasUnreleased }`. Keine Abhaengigkeit von React/Next im Modul (rein testbar).
Schritt E — Seite. `apps/web/src/components/changelog/changelog-view.tsx`: 'use client'; `import MDEditor from '@uiw/react-md-editor'`, `import rehypeSanitize from 'rehype-sanitize'`, `useTheme` aus `next-themes`; `mounted`-Guard wie AppShell; `mode: 'light'|'dark'` = `resolvedTheme === 'dark' ? 'dark' : 'light'` (vor Mount 'light'); Wrapper `<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4">` mit `<MDEditor.Markdown source={markdown} rehypePlugins={[[rehypeSanitize]]} wrapperElement={{ 'data-color-mode': mode }} style={{ background: 'transparent' }} />`. `apps/web/src/app/(portal)/changelog/page.tsx`: async Server-Komponente ohne 'use client' (Vorbild module-access-gate.tsx); `const t = await getTranslations('changelog')`; `const { markdown, hasUnreleased } = filterChangelogForChannel(changelogMarkdown, appVersion.channel, { unreleasedHeading: t('unreleasedHeading') })`; Rahmen `<div className="mx-auto max-w-3xl py-8 px-4">`, `<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>`, `<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>`; wenn `hasUnreleased`: Hinweisbox (gelbes Muster aus change-password/page.tsx) mit `data-testid="changelog-unreleased-hint"` und `t('unreleasedHint')`; wenn `markdown.trim()` leer: `<p data-testid="changelog-empty" className="text-sm text-muted-foreground">{t('empty')}</p>`, sonst `<ChangelogView markdown={markdown} />`. Kopfkommentar: Kanalregel (Live ohne Unveröffentlicht), Quelle Bauzeit-Variable, Auth durch Middleware.
Schritt F — Abzeichen. `app-version-badge.tsx`: `import Link from 'next/link'`; das `span` wird `<Link href="/changelog" data-testid="app-version" aria-label={t('whatsNew')} title={title} className="block truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline">`; Inhalt und Tooltip-Logik unveraendert; Kopfkommentar um den Satz ergaenzen, dass die Zeile seit quick-260916-dcz zur Seite „Was ist neu“ fuehrt.
Schritt G — i18n (beide Dateien, Schluesselparitaet). de.json: `sidebar.whatsNew` = „Was ist neu“; neuer Top-Level-Namensraum `changelog` (nach `bugReport` einordnen): `title` „Was ist neu“, `intro` „Alle Änderungen an Tessera, sortiert nach Version – die aktuelle Version steht oben.“, `unreleasedHeading` „Noch nicht freigegeben (Beta)“, `unreleasedHint` „Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.“, `empty` „Noch keine Einträge vorhanden.“ en.json: `sidebar.whatsNew` „What's new“; `changelog`: `title` „What's new“, `intro` „All changes to Tessera, sorted by version – the current version is at the top.“, `unreleasedHeading` „Not yet released (beta)“, `unreleasedHint` „The items under “Not yet released” are already part of this beta but have not been released as a version yet.“, `empty` „No entries yet.“ (Sollte der ICU-Parser am Apostroph in „What's“ anstossen, „What is new“ verwenden.) Diese Texte brauchen keinen neuen Eintrag in `umlaut-dictionary.ts` — die Datei bleibt unangetastet.
Schritt H — GREEN: Zielsuite gruen, dann die gesamte Web-Suite (46+2 Dateien) und `tsc`. Danach lokal `pnpm -C apps/web exec next build` einmal laufen lassen (webpack-Build, ca. 1-2 Minuten) und pruefen, dass `apps/web/.next/server` den Datums-Marker enthaelt und `apps/web/.next/static` nicht (Vorstufe zur Docker-Falsifizierung in Task 2). Commit `feat(quick-260916-dcz): CHANGELOG.md, Seite "Was ist neu" mit Kanalfilter, Versionszeile als Link, Bauzeit-Einbettung`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)" ; test -f CHANGELOG.md; echo CL_EXISTS=$? ; grep -c "^## Unveröffentlicht$" CHANGELOG.md ; grep -c "^## 1.0.0 – 2026-09-15$" CHANGELOG.md ; grep -c "^### " CHANGELOG.md ; awk '/^## 1\.0\.0/{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | grep -c "^- " ; grep -c "^!CHANGELOG.md$" .dockerignore ; grep -c "^COPY CHANGELOG.md ./$" apps/web/Dockerfile ; grep -c "TESSERA_CHANGELOG_MD" apps/web/next.config.ts ; grep -c "process.env.TESSERA_CHANGELOG_MD" apps/web/src/lib/changelog.ts ; grep -c "export function filterChangelogForChannel" apps/web/src/lib/changelog.ts ; grep -rl "@/lib/changelog'" apps/web/src --include=*.tsx --include=*.ts | grep -v test | grep -vc "changelog/page.tsx" ; grep -c "MDEditor.Markdown" apps/web/src/components/changelog/changelog-view.tsx ; grep -c "rehypeSanitize" apps/web/src/components/changelog/changelog-view.tsx ; head -1 "apps/web/src/app/(portal)/changelog/page.tsx" | grep -c "use client" ; grep -c 'href="/changelog"' apps/web/src/components/layout/app-version-badge.tsx ; grep -c "aria-label={t('whatsNew')}" apps/web/src/components/layout/app-version-badge.tsx ; grep -c '"whatsNew"' apps/web/src/messages/de.json ; grep -c '"unreleasedHint"' apps/web/src/messages/en.json ; D2=$(git diff --stat 7a6f42e -- apps/web/src/messages/umlaut-dictionary.ts apps/web/package.json pnpm-lock.yaml); echo D2_EXIT=$? ; test -z "$D2"; echo UNTOUCHED=$? ; pnpm -C apps/web exec tsc --noEmit >/dev/null 2>&1; echo TSC_web=$?</automated>
<fails_when>Die Vitest-Zeilen weichen von `48 passed (48)` / `301 passed (301)` ab; CL_EXISTS ist nicht 0; einer der greps auf CHANGELOG.md liefert nicht genau 1 (Unveröffentlicht, 1.0.0-Ueberschrift) bzw. weniger als 3 (`### `) bzw. weniger als 6 oder mehr als 12 (Punkte unter 1.0.0); `.dockerignore`- oder `Dockerfile`-grep ist nicht 1; ein Code-grep, der >= 1 sein muss, liefert 0; der Import-Zaehler von `@/lib/changelog` ausserhalb von page.tsx ist nicht 0 (Text wuerde ins Client-Bundle wandern); page.tsx beginnt mit `use client` (Zaehler 1 statt 0); UNTOUCHED ist 1; TSC_web ist nicht 0.</fails_when>
</verify>
<done>Web-Suite 48/301 gruen, tsc 0; CHANGELOG.md mit Unveröffentlicht (bwo + diese Seite) und 1.0.0 (6-12 Punkte, aus den Handbuechern) vorhanden; Bauweg (dockerignore-Ausnahme, COPY, env in next.config.ts) steht; `/changelog` rendert als Server-Seite den kanalgefilterten Changelog ueber `MDEditor.Markdown` + rehype-sanitize; Versionszeile ist ein Link mit aria-label; de/en-Schluessel vollstaendig, Umlaut-Woerterbuch unangetastet; ein Commit `feat(quick-260916-dcz)`.</done>
</task>
<task type="auto">
<name>Task 2: Release-Skript mit --dry-run, CI-Schritt, Docker-Falsifizierung, rueckwirkender Release v1.0.0</name>
<files>.gitea/scripts/publish-release.sh, .gitea/workflows/ci.yml</files>
<read_first>
- .gitea/scripts/publish-images.sh (Kopfkommentar-Stil, Entscheidung anhand GITHUB_REF, `--print-plan`, `set -eu`, kein Secret)
- .gitea/workflows/ci.yml (Job `publish`, `${{ secrets.REGISTRY_TOKEN }}` nur im Login-Schritt)
- apps/web/Dockerfile (runner-Stufe: `/app/apps/web/.next/server` aus standalone, `/app/apps/web/.next/static` separat kopiert)
- .planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-PLAN.md (Task 2: lokaler Docker-Beweis, Task 3: Token aus pushurl, nie ausgeben)
- COVERAGE.md neben diesem Plan (welche Release-Endpunkte genutzt werden)
</read_first>
<precondition>Gitea antwortet lokal: `curl -s --max-time 5 http://localhost:3002/api/v1/version` liefert `{"version":"1.26.2"}`; `command -v jq` und `command -v docker` liefern Pfade; `git remote get-url --push origin` enthaelt `localhost:3002` (Token-Quelle fuer den echten Lauf — nie ausgeben).</precondition>
<action>
Schritt A — `.gitea/scripts/publish-release.sh` (POSIX sh, `chmod +x`, `set -eu`, Kopfkommentar deutsch ASCII im Stil von publish-images.sh: Zweck, Entscheidung anhand GITHUB_REF, Aufrufformen, Umgebungsvariablen, dass das Token nie ausgegeben wird). Verhalten:
1. Argumente in einer `while`-Schleife: `--dry-run` (Schalter), `--tag <vX.Y.Z>` (Wert), sonst Fehler mit Hilfetext Exit 2.
2. Tag: aus `--tag`, sonst aus `GITHUB_REF` (`refs/tags/v*` -> Rest), sonst Meldung „Kein Freigabe-Tag (nur refs/tags/v*): nichts zu tun.“ und Exit 0. Tag muss `^v[0-9]+\.[0-9]+\.[0-9]+$` erfuellen (grep -E), sonst Exit 1 mit Meldung. `VERSION=${TAG#v}`.
3. API-Basis: `GITEA_API`, sonst `GITHUB_API_URL`, sonst `${GITHUB_SERVER_URL}/api/v1`, sonst `http://localhost:3002/api/v1`; Repo: `GITEA_REPO`, sonst `GITHUB_REPOSITORY`, sonst `schalli/tessera-ctl`. Erste Ausgabezeile: `Gitea-API: <basis> Repo: <repo> Tag: <tag>` (ohne Token).
4. `CHANGELOG="${CHANGELOG_FILE:-CHANGELOG.md}"`; fehlt die Datei: Exit 1 mit Meldung. Abschnitt schneiden (zur Planungszeit bewiesenes Muster): `awk -v ver="$VERSION" 'BEGIN{esc=ver; gsub(/\./,"\\.",esc); pat="^## " esc "( |$)"} $0 ~ pat {f=1; next} /^## / {if(f) exit} f {print}'`, danach fuehrende/abschliessende Leerzeilen entfernen (zweiter awk, der die letzte nicht-leere Zeile merkt, plus `sed '1{/^$/d}'`). Ist das Ergebnis leer: nach stderr „CHANGELOG.md hat keinen Abschnitt fuer Version <VERSION> (erwartet eine Zeile '## <VERSION> – <Datum>'). Kein Release ohne Text.“ und Exit 1 (Falsifizierung b).
5. JSON ausschliesslich per `jq -n --arg tag "$TAG" --arg name "Tessera $VERSION" --arg body "$BODY" '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}'` (kein manuelles Quoting). Fehlt `jq`: Exit 1 mit Meldung.
6. `--dry-run`: JSON und die Zielpfade (`POST <basis>/repos/<repo>/releases` bzw. `PATCH …/releases/<id>`) ausgeben, Exit 0, kein Netzaufruf, kein Token noetig.
7. Echter Lauf: `GITEA_TOKEN` muss gesetzt sein (sonst Exit 1 mit Meldung, Wert nie ausgeben). Header in eine temporaere Datei (`umask 077`, `mktemp`, `trap` zum Loeschen) schreiben und mit `curl -sS --header @"$HDR"` verwenden — das Token erscheint so weder in Argumenten noch in der Prozessliste. `GET <basis>/repos/<repo>/releases/tags/<tag>` mit `-o "$RESP" -w '%{http_code}'`: 200 -> `ID=$(jq -r .id "$RESP")`, `PATCH …/releases/$ID` mit `{name, body}` (jq wie oben ohne tag_name), Erwartung 200 -> Ausgabe „Release <tag> aktualisiert (id <ID>)“; 404 -> `POST …/releases`, Erwartung 201 -> „Release <tag> angelegt (id …)“; jeder andere Code -> Code und Antwort-Body (der Body enthaelt nie das Token) nach stderr, Exit 1. `Content-Type: application/json`, `--data @"$JSONFILE"` (JSON in Datei, nicht als Argument).
8. Das Skript kennt kein `set -x` und kein Echo einer Variablen mit dem Token.
<!-- planner-discipline-allow: set -x -->
Schritt B — `.gitea/workflows/ci.yml`: im Job `publish` nach dem Schritt „Versionsstempel berechnen, Abbilder bauen und veroeffentlichen“ einen Schritt `- name: Gitea-Release zum Freigabe-Tag anlegen (nur bei Tags v*)` mit `env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}` und `run: sh .gitea/scripts/publish-release.sh`. Kopfkommentar der Datei um eine Zeile ergaenzen (Tag v* -> zusaetzlich Gitea-Release aus CHANGELOG.md). Keine weitere Aenderung. `node -e "require('js-yaml')"` steht nicht sicher zur Verfuegung — YAML-Pruefung ueber `python3 -c 'import yaml'` nur, wenn PyYAML vorhanden ist, sonst genuegt die strukturelle grep-Pruefung unten.
Schritt C — Lokale Beweise (Ausgaben ins SUMMARY):
1. `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.0.0` -> Exit 0, JSON mit `"name": "Tessera 1.0.0"` und Body, der mit `### Neu` beginnt.
2. `sh .gitea/scripts/publish-release.sh --dry-run --tag v9.9.9` -> Exit 1, Meldung nennt 9.9.9 (Falsifizierung b).
3. `GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-release.sh --dry-run` -> Exit 0, „nichts zu tun“.
4. `GITHUB_REF=refs/tags/v1.0.0 GITHUB_SERVER_URL=https://git.vicolab.de sh .gitea/scripts/publish-release.sh --dry-run` -> erste Zeile nennt `https://git.vicolab.de/api/v1`.
5. Docker-Falsifizierung (c): `docker build -t tessera-web-dcz-test --build-arg APP_VERSION=v9.9.9-test --build-arg APP_CHANNEL=live -f apps/web/Dockerfile .` (2-4 Minuten; als Hintergrundbefehl starten, falls die Vordergrundzeit knapp ist). Danach `docker run --rm --entrypoint sh tessera-web-dcz-test -c 'grep -rl "2026-09-15" /app/apps/web/.next/server | wc -l; grep -rl "2026-09-15" /app/apps/web/.next/static | wc -l'` -> erste Zahl >= 1, zweite genau 0.
<!-- planner-discipline-allow: 2026-09-15 -->
Zusaetzlich Gegenprobe der COPY/dockerignore-Kopplung: `git stash`-frei pruefen, indem ein zweiter Bau mit `--build-arg` NICHT noetig ist — stattdessen im SUMMARY die gemessene Kontext-Meldung aus `docker build` (Zeile mit `COPY CHANGELOG.md`) zitieren. Test-Abbild danach `docker rmi tessera-web-dcz-test`.
6. Echter Lauf fuer v1.0.0 (Token NIE ausgeben, nur in Variablen): `PUSHURL=$(git remote get-url --push origin); TOK=${PUSHURL#*://}; TOK=${TOK#*:}; TOK=${TOK%%@*}`; dann `GITEA_API=http://localhost:3002/api/v1 GITEA_TOKEN="$TOK" sh .gitea/scripts/publish-release.sh --tag v1.0.0` -> Exit 0, Zeile „Release v1.0.0 angelegt (id N)“. Pruefung: `curl -s -H "Authorization: token $TOK" -o rel.json http://localhost:3002/api/v1/repos/schalli/tessera-ctl/releases/tags/v1.0.0; jq -c '{id, tag_name, name, draft, prerelease}' rel.json; jq -r .body rel.json` (Datei im Scratchpad) -> `tag_name v1.0.0`, `name "Tessera 1.0.0"`, `draft false`, Body beginnt mit `### Neu` und ist laenger als 100 Zeichen. Zweiter Lauf desselben Skriptaufrufs -> Exit 0, Zeile „aktualisiert (id N)“ mit derselben id; `…/releases` in eine Datei holen und `jq length` darauf -> 1 (idempotent, kein Duplikat).
Commit `ci(quick-260916-dcz): Gitea-Release je Freigabe-Tag aus CHANGELOG.md (publish-release.sh, idempotent, --dry-run), Schritt in ci.yml`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test -x .gitea/scripts/publish-release.sh; echo EXEC=$? ; head -1 .gitea/scripts/publish-release.sh | grep -c '^#!/bin/sh$' ; grep -c "^set -eu$" .gitea/scripts/publish-release.sh ; grep -c "set -x" .gitea/scripts/publish-release.sh ; grep -cE 'echo[^\n]*GITEA_TOKEN' .gitea/scripts/publish-release.sh ; grep -c 'jq -n --arg' .gitea/scripts/publish-release.sh ; grep -c 'header @' .gitea/scripts/publish-release.sh ; grep -c 'releases/tags/' .gitea/scripts/publish-release.sh ; grep -c 'PATCH' .gitea/scripts/publish-release.sh ; sh .gitea/scripts/publish-release.sh --dry-run --tag v1.0.0 >/dev/null 2>&1; echo DRY_OK=$? ; sh .gitea/scripts/publish-release.sh --dry-run --tag v9.9.9 >/dev/null 2>/tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-dry-err.txt; echo DRY_MISSING=$? ; grep -c "9.9.9" /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-dry-err.txt ; GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-release.sh --dry-run | grep -c "nichts zu tun" ; sh .gitea/scripts/publish-release.sh --dry-run --tag v1.0.0 | grep -c '"name": "Tessera 1.0.0"' ; grep -c "publish-release.sh" .gitea/workflows/ci.yml ; grep -c "GITEA_TOKEN: \${{ secrets.REGISTRY_TOKEN }}" .gitea/workflows/ci.yml ; grep -c "secrets.REGISTRY_TOKEN" .gitea/workflows/ci.yml ; PUSHURL=$(git remote get-url --push origin); echo PU_EXIT=$? ; TOK=${PUSHURL#*://}; TOK=${TOK#*:}; TOK=${TOK%%@*}; curl -s -H "Authorization: token $TOK" -o /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rel.json http://localhost:3002/api/v1/repos/schalli/tessera-ctl/releases/tags/v1.0.0; echo REL_EXIT=$? ; jq -r '.tag_name, .name, .draft' /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rel.json ; jq -r '.body' /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rel.json > /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rel-body.txt; wc -c < /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rel-body.txt ; curl -s -H "Authorization: token $TOK" -o /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rels.json http://localhost:3002/api/v1/repos/schalli/tessera-ctl/releases; jq length /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/dcz-rels.json</automated>
<fails_when>EXEC ist nicht 0; PU_EXIT oder REL_EXIT ist nicht 0; Shebang- oder `set -eu`-Zaehler ist nicht 1; der `set -x`-Zaehler oder der Echo-Token-Zaehler ist nicht 0; jq-/header-/tags-/PATCH-greps liefern 0; DRY_OK ist nicht 0; DRY_MISSING ist 0 (ein Tag ohne Abschnitt darf nicht durchgehen) oder die stderr-Datei nennt 9.9.9 nicht; „nichts zu tun“ fehlt fuer refs/heads/main; der Name-grep im dry-run-JSON ist 0; ci.yml nennt das Skript nicht genau einmal, den env-Eintrag nicht genau einmal oder REGISTRY_TOKEN nicht genau zweimal (Login + Release); die drei Release-Zeilen lauten nicht `v1.0.0`, `Tessera 1.0.0`, `false`; die Body-Laenge (wc -c) ist nicht groesser als 100; die Release-Anzahl ist nicht 1.</fails_when>
</verify>
<done>Skript vorhanden und POSIX-sauber; dry-run fuer v1.0.0 liefert JSON, fuer v9.9.9 Exit 1 mit klarer Meldung; main-Ref ist ein No-Op; ci.yml ruft das Skript nach dem Abbild-Schritt mit dem Token aus `env` auf; das Web-Abbild traegt den Changelog nur im Server-Bundle (Docker-Beweis im SUMMARY mit beiden Zahlen); Release `Tessera 1.0.0` existiert in Gitea zum Tag v1.0.0, ein Wiederholungslauf aktualisiert statt dupliziert; ein Commit `ci(quick-260916-dcz)`.</done>
</task>
<task type="auto">
<name>Task 3: Handbuecher (Betrieb Kapitel 9, Anwender, Entwicklung, CI-Setup), Push und Beobachtung des CI-Laufs</name>
<files>docs/anleitung-betrieb.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, docs/ci-cd-setup.md</files>
<read_first>
- docs/anleitung-betrieb.md Kapitel 9 (Zeilen 357-530: „Eine Version freigeben“, „Woran Sie erkennen, welche Version läuft“, Absatz „Erstfreigabe v1.0.0“) — Ton: Alltagssprache, echte Umlaute, Sie-Form
- docs/anleitung-anwender.md (Inhaltsverzeichnis Zeilen 6-22, „Aufbau der Oberfläche“ Zeilen 38-55, Abschnittsfolge vor „Häufige Stolpersteine“)
- docs/anleitung-entwicklung.md „Konventionen und Fallstricke“ (ab Zeile 425) — Ton: technisch, echte Umlaute
- docs/ci-cd-setup.md Abschnitte 3 (Secrets-Tabelle) und 4 (Pipeline-Ueberblick, Etiketten-Tabelle) — ASCII-Umschrift wie im Bestand
- .planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-PLAN.md Task 3 Schritt 4 (CI-Beobachtung ueber die Gitea-API)
</read_first>
<precondition>Gitea antwortet lokal (`curl -s --max-time 5 http://localhost:3002/api/v1/version` -> `{"version":"1.26.2"}`) und `docker ps --format '{{.Names}}' | grep -c '^gitea-runner$'` liefert `1` (sonst Push trotzdem, Beobachtung als offenen Punkt ins SUMMARY).</precondition>
<action>
1. `docs/anleitung-betrieb.md`, Kapitel 9: (a) Unterabschnitt „Eine Version freigeben“ — vor dem Befehlsblock einen nummerierten Vorschritt einfuegen: in `CHANGELOG.md` den Abschnitt „Unveröffentlicht“ in „X.Y.Z – JJJJ-MM-TT“ umbenennen, darueber ein neues leeres „Unveröffentlicht“ anlegen, auf `main` committen und pushen — erst dann `live` zusammenfuehren und taggen; (b) nach dem Absatz zur Pipeline-Dauer einen Absatz: die Pipeline legt beim Tag zusaetzlich einen Release in Gitea an (Name „Tessera X.Y.Z“, Text = der Abschnitt dieser Version aus CHANGELOG.md, zu finden unter Releases im Repository); fehlt der Abschnitt, schlaegt genau dieser letzte Schritt fehl — die Abbilder sind dann trotzdem gebaut, der Release wird nach dem Nachtragen des Abschnitts durch erneutes Ausloesen des Tags-Laufs oder lokal per Skript (`.gitea/scripts/publish-release.sh --tag vX.Y.Z`) nachgeholt; (c) Absatz „Erstfreigabe v1.0.0“ in die Vergangenheit setzen: erfolgt am 2026-09-14 (Tag), live seit 2026-09-15; der Release „Tessera 1.0.0“ wurde nachtraeglich angelegt; (d) unter „Woran Sie erkennen, welche Version läuft“ einen vierten Punkt: Klick auf die Versionszeile unten in der Seitenleiste oeffnet „Was ist neu“ — auf Live nur freigegebene Versionen, auf Beta zusaetzlich „Noch nicht freigegeben (Beta)“. Echte Umlaute, Sie-Form.
2. `docs/anleitung-anwender.md`: (a) in „Aufbau der Oberfläche“, Absatz Seitenleiste, einen Satz ergaenzen: ganz unten steht die Versionsnummer von Tessera; ein Klick darauf oeffnet „Was ist neu“; (b) neuen Abschnitt `## Was ist neu` VOR „Häufige Stolpersteine“ (4-6 Saetze: was die Seite zeigt, Gruppen Neu/Geändert/Behoben, neueste Version oben, Hinweis „Noch nicht freigegeben (Beta)“ nur auf der Beta, auf Live nur Freigegebenes); (c) Inhaltsverzeichnis-Eintrag an passender Stelle. Echte Umlaute, Sie-Form.
3. `docs/anleitung-entwicklung.md`, „Konventionen und Fallstricke“: neuen Fettabsatz „**Änderungsliste (`CHANGELOG.md`):**“ — jede Aenderung sofort unter „Unveröffentlicht“ eintragen (Alltagssprache fuer Anwender, Sie-Form, echte Umlaute, Gruppen Neu/Geändert/Behoben, keine Dateinamen/Commit-Kuerzel); Freigabe = Abschnitt umbenennen + neues leeres Unveröffentlicht; die Seite „Was ist neu“ (`apps/web/src/app/(portal)/changelog/page.tsx`) liest den Text zur Bauzeit aus `env.TESSERA_CHANGELOG_MD` in `next.config.ts` (deshalb `COPY CHANGELOG.md` im Web-Dockerfile und die Ausnahme `!CHANGELOG.md` in `.dockerignore`; nur `page.tsx` darf `@/lib/changelog` importieren, damit der Text nicht in oeffentliche Client-Chunks gelangt); Kanalregel in `filterChangelogForChannel` mit Tests; `.gitea/scripts/publish-release.sh` schneidet beim Tag den Abschnitt fuer den Gitea-Release — ohne Abschnitt bricht der CI-Schritt ab. Echte Umlaute.
4. `docs/ci-cd-setup.md` (ASCII-Umschrift wie im Bestand): Secrets-Tabelle — `REGISTRY_TOKEN` braucht zusaetzlich Schreibrecht auf das Repository (`repository: write`) fuer Releases, wird im Release-Schritt ueber `env` an das Skript gereicht, nie als Argument; Pipeline-Ueberblick — Job `publish` besteht aus vier Schritten (Checkout, Login, publish-images.sh, publish-release.sh); Etiketten-Tabelle — Zeile Tag `vX.Y.Z` ergaenzen um „+ Gitea-Release `Tessera X.Y.Z` mit dem CHANGELOG-Abschnitt“; kurzer Hinweis, dass das Skript die API ueber `GITHUB_API_URL`/`GITHUB_SERVER_URL` (im Job-Container `https://git.vicolab.de`) anspricht und `localhost:3002` dort nicht erreichbar ist.
5. Commit `docs(quick-260916-dcz): Betriebshandbuch Kapitel 9 (Changelog-Schritt, Gitea-Release, Seite Was ist neu), Anwender-, Entwicklungs- und CI-Handbuch`. Dann `git push` (Push-URL zeigt auf localhost:3002; schlichtes `git push` genuegt).
6. Beobachtung des CI-Laufs (Token NIE ausgeben): `PUSHED=$(git rev-parse HEAD); PUSHURL=$(git remote get-url --push origin); TOK=${PUSHURL#*://}; TOK=${TOK#*:}; TOK=${TOK%%@*}`; bis zu 12 Minuten alle 20 s `curl -s -H "Authorization: token $TOK" "http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5"` abfragen (als Hintergrundbefehl, falls `sleep` im Vordergrund blockiert ist), Eintrag mit `head_sha == PUSHED`, auf `status == completed` warten; Erwartung `conclusion == success`. Zusaetzlich versuchen, das Job-Log des Jobs `publish` zu lesen (`…/actions/runs/<id>/jobs`, dann `…/actions/jobs/<job_id>/logs`, falls die Gitea-Version antwortet) und die Zeilen `Gitea-API: https://git.vicolab.de/api/v1 …` und „nichts zu tun“ des Release-Schritts zitieren; antwortet der Log-Endpunkt nicht, im SUMMARY vermerken (der Erfolg des Laufs beweist den Exit 0 des Schritts). Lauf-ID, Dauer (`started_at`/`completed_at`), conclusion ins SUMMARY. Ist `conclusion` nicht `success`: Ursache aus dem Job-Log benennen, Korrektur als eigener `fix(quick-260916-dcz)`-Commit, erneut pushen und beobachten.
7. Wird das SUMMARY erst nach dem Push committet, den Push danach wiederholen (ein weiterer CI-Lauf ist erwartet).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "CHANGELOG.md" docs/anleitung-betrieb.md ; grep -c "Was ist neu" docs/anleitung-betrieb.md ; grep -c "Release" docs/anleitung-betrieb.md ; grep -c "^## Was ist neu" docs/anleitung-anwender.md ; grep -c "Was ist neu" docs/anleitung-anwender.md ; grep -c "Änderungsliste" docs/anleitung-entwicklung.md ; grep -c "TESSERA_CHANGELOG_MD" docs/anleitung-entwicklung.md ; grep -c "publish-release.sh" docs/ci-cd-setup.md ; grep -c "repository: write" docs/ci-cd-setup.md ; grep -c '[äöüÄÖÜß]' docs/ci-cd-setup.md ; pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)" ; pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)" ; for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit >/dev/null 2>&1; echo "TSC_$p=$?"; done ; pnpm install --frozen-lockfile >/dev/null 2>&1; echo FROZEN=$? ; D=$(git diff --stat 7a6f42e -- . ':!.planning'); echo GIT_EXIT=$? ; tail -n1 <<< "$D" ; U=$(git diff --stat 7a6f42e -- '.env*' docker-compose.yml docker-compose.prod.yml docker-compose.dev.yml docker-compose.ci.yml pnpm-lock.yaml apps/web/package.json apps/api/package.json apps/api/prisma apps/api/Dockerfile apps/web/src/messages/umlaut-dictionary.ts); echo U_EXIT=$? ; test -z "$U"; echo U_EMPTY=$? ; S=$(git status -sb); head -n1 <<< "$S"</automated>
<fails_when>Ein grep auf die Handbuecher liefert 0 (betrieb: CHANGELOG.md >= 2, Was ist neu >= 1, Release >= 2; anwender: `## Was ist neu` genau 1, Was ist neu >= 2; entwicklung: Änderungsliste >= 1, TESSERA_CHANGELOG_MD >= 1; ci-cd-setup: publish-release.sh >= 2, repository: write >= 1) oder ci-cd-setup.md enthaelt echte Umlaute (Zaehler nicht 0); Web weicht von 48/301 oder API von 67/1078 ab; ein TSC_*, FROZEN oder GIT_EXIT ist nicht 0; die Summenzeile nennt nicht genau `19 files changed`; U_EMPTY ist 1 (eine unantastbare Datei wurde geaendert); die Statuszeile zeigt `[ahead` oder `[behind`.</fails_when>
</verify>
<done>Vier Handbuecher auf dem gemessenen Stand (Kapitel 9 mit Changelog-Vorschritt, Release-Hinweis, Erstfreigabe in der Vergangenheit, vierter Erkennungsweg; Anwender-Abschnitt „Was ist neu“ mit TOC; Entwicklungsregel; CI-Setup ASCII); Suiten Web 48/301, API 67/1078; tsc 0 dreimal; frozen-lockfile 0; genau 19 Dateien ausserhalb `.planning`; Push erfolgt, CI-Lauf zum HEAD `success` (Lauf-ID, Dauer und Release-Schritt-Zeilen im SUMMARY); Commit `docs(quick-260916-dcz)`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Repository-Datei -> Web-Bundle -> Browser des Anwenders | `CHANGELOG.md` (vertrauenswuerdige, versionierte Quelle) wird zu HTML gerendert; jeder Commit-Autor kann Markdown einbringen |
| Internet -> `/changelog` und `/_next/static/*` | Seite nur mit gueltigem Session-Cookie (Middleware); statische Chunks sind ohne Anmeldung abrufbar |
| Gitea-Repository -> CI-Job-Container -> Gitea-API | Beim Tag-Push haelt der Job das Repo-Token als Secret und ruft die Release-API ueber `https://git.vicolab.de` auf |
| CHANGELOG-Text -> JSON-Body -> Gitea-Release | Freitext (Anfuehrungszeichen, Backslashes, Zeilenumbrueche) wird in JSON und dann in Gitea-Markdown ueberfuehrt |
| Docker-Bau-Kontext -> Abbild | Eine zusaetzliche Wurzeldatei gelangt in den Kontext und in die builder-Stufe |
## STRIDE Threat Register (ASVS Level 1, Blocking-Schwelle `high`)
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-DCZ-01 | Tampering (XSS ueber Markdown) | `changelog-view.tsx` (`MDEditor.Markdown`) | medium | mitigate | `rehypePlugins={[[rehypeSanitize]]}` wie im Notiz-Widget (Gate: grep `rehypeSanitize` in changelog-view.tsx >= 1); Quelle ist eine versionierte Repo-Datei, keine Nutzereingabe; kein `dangerouslySetInnerHTML` |
| T-DCZ-02 | Information Disclosure | Changelog-Text in `/_next/static` (ohne Auth abrufbar) | low | mitigate | Text nur im Server-Bundle: `@/lib/changelog` wird ausschliesslich von `page.tsx` (Server-Komponente) importiert (Gate Task 1: Import-Zaehler ausserhalb page.tsx = 0; Docker-Gate Task 2: `.next/static` enthaelt den Marker nicht) |
| T-DCZ-03 | Elevation / Zugriff | `/changelog` ohne Anmeldung | medium | mitigate | Bestehende `middleware.ts` (alle Routen ausser `/login`, `/reset-password`) — keine neue oeffentliche Route; Seitentest rendert unabhaengig davon, der Schutz liegt eine Schicht darueber und wird nicht geschwaecht (publicRoutes unveraendert, Gate: `git diff` auf middleware.ts leer, da nicht in files_modified) |
| T-DCZ-04 | Information Disclosure (Secret im Log) | `publish-release.sh`, `ci.yml` | high | mitigate | Token nur ueber `env: GITEA_TOKEN` (Gitea maskiert Secrets im Log), im Skript kein Shell-Tracing, kein Echo des Tokens, Header aus Datei (`--header @file`, nicht in argv/Prozessliste), JSON aus Datei; lokal: Token nur in Variablen, nie ausgegeben (Gates Task 2: `set -x`-Zaehler 0, Echo-Token-Zaehler 0, `header @` >= 1) |
| T-DCZ-05 | Tampering (JSON-/Body-Injection) | Release-Body aus CHANGELOG.md | medium | mitigate | JSON ausschliesslich per `jq -n --arg` (korrektes Escaping), keine String-Konkatenation; `--dry-run` zeigt das JSON vorab (Gate: `jq -n --arg` >= 1) |
| T-DCZ-06 | Denial of Service / Fehlbetrieb | Leerer oder falscher Release | low | mitigate | Tag-Regex `^v[0-9]+\.[0-9]+\.[0-9]+$`, Abschnittspflicht (Exit 1 ohne Text), idempotenter PATCH statt Duplikat (Gates Task 2: DRY_MISSING != 0, Release-Anzahl 1) |
| T-DCZ-07 | Spoofing (falsche API-Basis) | Skript im CI-Job-Container | medium | mitigate | API-Basis aus `GITHUB_API_URL`/`GITHUB_SERVER_URL` (https://git.vicolab.de, TLS), Fallback localhost nur lokal; erste Ausgabezeile nennt die Basis; Token geht nur an diese Basis |
| T-DCZ-08 | Repudiation | Welcher Text zu welcher Version gehoert | low | accept | Release-Text stammt aus der versionierten Datei am getaggten Commit; Aenderungen sind ueber Git nachvollziehbar |
| T-DCZ-09 | Information Disclosure (Bau-Kontext) | `.dockerignore`-Ausnahme | low | mitigate | Ausnahme gilt genau fuer `!CHANGELOG.md` (Gate: exakte Zeile), `*.md` bleibt ausgeschlossen, keine `.env`-Aenderung |
| T-DCZ-SC | Tampering (Supply Chain) | npm-Installs | high | mitigate | Keine neuen Pakete: `MDEditor.Markdown` aus dem installierten `@uiw/react-md-editor` 4.1.1, `rehype-sanitize` vorhanden; Gate `pnpm install --frozen-lockfile` Exit 0 und `pnpm-lock.yaml`/`package.json` unangetastet — daher kein Legitimitaets-Checkpoint noetig |
</threat_model>
<verification>
- `pnpm -C apps/web exec vitest run` -> `Test Files 48 passed (48)` / `Tests 301 passed (301)`; `pnpm -C apps/api exec vitest run` -> `67 passed (67)` / `1078 passed (1078)`.
- `tsc --noEmit` in packages/shared, apps/api, apps/web -> Exit 0; `pnpm install --frozen-lockfile` -> Exit 0.
- `git diff --stat 7a6f42e -- . ':!.planning'` -> genau `19 files changed`; `.env*`, Compose, Prisma, Lockfile, package.json, umlaut-dictionary.ts unangetastet.
- Falsifizierung (a): Test 1 in `changelog.test.ts` (live) wird rot, wenn `filterChangelogForChannel` den Abschnitt nicht mehr entfernt (Probe im SUMMARY: Funktion kurzzeitig auf Durchreichen gesetzt -> genau dieser Test rot, danach zurueck).
- Falsifizierung (b): `sh .gitea/scripts/publish-release.sh --dry-run --tag v9.9.9` -> Exit 1, Meldung nennt 9.9.9.
- Falsifizierung (c): lokal gebautes Web-Abbild: `grep -rl "2026-09-15" /app/apps/web/.next/server | wc -l` >= 1 und dasselbe fuer `.next/static` = 0.
- Gitea: `GET /repos/schalli/tessera-ctl/releases/tags/v1.0.0` -> `Tessera 1.0.0`, `draft false`, Body > 100 Zeichen; Release-Anzahl 1.
- CI-Lauf zum gepushten HEAD: `conclusion == success`.
- Browser-Gegenprobe (Playwright MCP, nur wenn ein Dev-Stack laeuft; sonst als offenen Punkt vermerken): Klick auf die Versionszeile fuehrt zu `/changelog`, Seite zeigt „Was ist neu“ mit Hinweis „Noch nicht freigegeben (Beta)“ (lokal Kanal dev).
</verification>
<success_criteria>
- CHANGELOG.md mit `Unveröffentlicht` (Dashboard-Umbau + Seite „Was ist neu“) und `1.0.0 – 2026-09-15` (6-12 Punkte aus den Handbuechern), Alltagssprache, echte Umlaute, Sie-Form.
- Seite `/changelog` (nur angemeldet, Portal-Layout) rendert den Changelog ueber `MDEditor.Markdown` + rehype-sanitize; Live sieht kein `Unveröffentlicht`, Beta/dev sehen „Noch nicht freigegeben (Beta)“ mit Hinweis; Versionszeile ist ein Link mit aria-label „Was ist neu“; de/en vollstaendig.
- Bauweg: `env.TESSERA_CHANGELOG_MD` in next.config.ts, `COPY CHANGELOG.md ./` im Web-Dockerfile, `!CHANGELOG.md` in .dockerignore; Docker-Beweis Server-Bundle ja / statische Chunks nein.
- `publish-release.sh` (dry-run, idempotent, Exit 1 ohne Abschnitt) + ci.yml-Schritt; Release `Tessera 1.0.0` in Gitea.
- Handbuecher aktualisiert; alle Zahlen-Gates erfuellt; gepusht; CI gruen.
</success_criteria>
<output>
Nach Abschluss `.planning/quick/260916-dcz-aenderungsliste-changelog-md-in-alltagss/260916-dcz-SUMMARY.md` anlegen (Deutsch, ASCII): Messwerte aller Gates (Vitest-Zeilen, tsc, 19 files changed), RED-Ausgabe aus Task 1, Docker-Beweis (beide Zahlen, Baudauer), dry-run-Ausgaben, Release-Antwort (id, name, body_len) und PATCH-Wiederholung, CI-Lauf (ID, Dauer, conclusion, Release-Schritt-Zeilen oder Grund, warum das Log nicht lesbar war), Abschnitt „Fuer den Changelog“ entfaellt (die Punkte stehen bereits in CHANGELOG.md unter Unveröffentlicht), offene Punkte (z. B. Browser-Gegenprobe, CI-Beweis des Release-Wegs erst beim naechsten Tag).
</output>
@@ -0,0 +1,15 @@
# API Coverage — Gitea REST API v1, Bereich Releases (quick-260916-dcz)
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
> Gemessen 2026-09-16: Gitea 1.26.2 auf localhost:3002 (intern) bzw. https://git.vicolab.de (aus dem CI-Job-Container erreichbar, localhost:3002 dort NICHT). Token aus der Push-URL und `secrets.REGISTRY_TOKEN` tragen `write:repository` (deckt Releases ab).
| capability | decision | reason |
|---|---|---|
| `GET /repos/{owner}/{repo}/releases/tags/{tag}` (Release zum Tag lesen) | INTEGRATE | Idempotenz-Pruefung vor dem Anlegen |
| `POST /repos/{owner}/{repo}/releases` (Release anlegen) | INTEGRATE | Kernfall beim Tag-Push |
| `PATCH /repos/{owner}/{repo}/releases/{id}` (Release aktualisieren) | INTEGRATE | Wiederholter Lauf zum selben Tag haelt Name/Text mit CHANGELOG.md synchron |
| `GET /repos/{owner}/{repo}/releases` (alle Releases listen) | OPT-OUT | nicht noetig — die Abfrage je Tag genuegt |
| `GET /repos/{owner}/{repo}/releases/latest` | OPT-OUT | nicht noetig — die Oberflaeche zeigt den Changelog aus dem Bundle, nicht aus Gitea |
| `DELETE /repos/{owner}/{repo}/releases/{id}` | OPT-OUT | ausdruecklich ausserhalb des Auftrags — Releases werden nie automatisch geloescht |
| Release-Anhaenge (`.../releases/{id}/assets`) | OPT-OUT | nicht noetig — Auslieferung laeuft ueber die Container-Registry, nicht ueber Anhaenge |
| Draft/Prerelease-Markierungen | OPT-OUT | nicht noetig — jeder Tag `v*` ist eine Freigabe; `draft:false`, `prerelease:false` fest |
@@ -0,0 +1,277 @@
---
phase: quick-260916-dyv
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260916-DYV]
files_modified:
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/dashboard-grid.tsx
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/components/dashboard/edit-mode-toggle.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
- docs/anleitung-anwender.md
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "Jeder Widget-Typ laesst sich auf seine kleinste noch bedienbare Kachel verkleinern — nicht kleiner, nicht groesser. `WIDGET_CONSTRAINTS` traegt genau diese Tabelle (minW/minH/defaultW/defaultH): clock 2/2/4/4, search 6/2/12/4, calendar 3/3/8/12, note 4/4/6/8, calculator 3/9/6/10, favorites 3/3/6/10, link 3/2/4/4, stopwatch 4/3/6/6; die Vorgaben (default) sind unveraendert, die Minima sind inhaltsgetrieben aus dem Innenaufbau gerechnet (Tabelle in `<planning_measurements>`), im Test `widget-registry.test.tsx` mit `toEqual` festgenagelt. Der bisherige Test „jede Groesse ist exakt das Doppelte“ ist ersetzt (3 und 9 sind ungerade)."
- "Die Minima wirken auch fuer BESTEHENDE Widgets: react-grid-layout 2.2.3 nimmt fuer ein Widget mit gespeichertem Eintrag den gespeicherten Eintrag WOERTLICH inklusive `minW/minH` und ignoriert `data-grid` (gemessen `chunk-WGL5FSZH.mjs:559-562` `synchronizeLayoutWithChildren`: `existingItem` -> `cloneLayoutItem(existingItem)`). Gespeicherte Anordnungen tragen `minW/minH` (RGL `cloneLayoutItem` beim `onLayoutChange`, von 260916-bwo verdoppelt persistiert). Deshalb ueberschreibt `dashboard-grid.tsx` vor der Uebergabe an `Responsive` in JEDEM Breakpoint `minW/minH` jedes Eintrags aus `WIDGET_CONSTRAINTS` (`useMemo` ueber `layouts` + `widgets`); Test: gespeicherter Uhr-Eintrag mit `minW: 8, minH: 8` kommt als `minW: 2, minH: 2` bei `Responsive` an."
- "Der obere Rand ist halbiert: der Bearbeiten-Umschalter sitzt nicht mehr oben rechts im Dashboard-Container, sondern in einer festen Aktionsleiste unten rechts (`fixed bottom-6 right-6 z-20 flex items-center gap-2`), im Ansichtsmodus nur der Stift (mit Karten-Hintergrund, Rahmen und Schatten, damit er ueber Widgets sichtbar bleibt), im Bearbeitungsmodus „Widget hinzufuegen“ links neben dem Haekchen. Das Grid steht direkt im Container `p-2`, ohne Abstands-Wrapper. Ergebnis: Kopfzeilen-Unterrand bis erstes Widget 12 (main p-3) + 8 (p-2) + 8 (containerPadding) = 28 px statt 60 px (Bounding-Box im Browser: erstes Widget top = Header-Unterrand + 28). Test `page.test.tsx` nagelt die Leiste, die Knopf-Reihenfolge und das Fehlen des alten Wrappers fest."
- "Ziehen ist zuverlaessig: im Bearbeitungsmodus ist die GANZE Kachel der Griff (`widget-drag-handle` an der Karte, `cursor-grab`), eine 20 px hohe Kopfleiste mit Griff-Symbol liegt als Overlay (`absolute inset-x-0 top-0 z-10 h-5`) ueber dem oberen Kachelrand als optischer Hinweis (Tooltip `widgets.dragHint`), der Loesch-Knopf sitzt in dieser Kopfleiste rechts und traegt `data-no-drag`. `dragConfig.cancel` = `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — react-draggable 4.7.0 prueft `cancel` NACH `handle` und vom Ziel aufwaerts bis zum RGL-Element (gemessen `Draggable.js:417` + `matchesSelectorAndParentsTo`), also verhindert ein Eingabefeld INNERHALB des Griffs den Drag-Start; RGL haengt `.react-resizable-handle` selbst voran (`chunk-WGL5FSZH.mjs:526`), der Groessen-Griff funktioniert weiter. `threshold: 3` (RGL-Standard, Klick vs. Ziehen). Die bislang tote Klasse `widgetNoDrag` (Favoriten/Link, 12 Stellen, nirgends verdrahtet) wird durch den Selektor wirksam."
- "Ablegen auf belegter Stelle ueberlappt nie und springt nicht wild: `compactor` = `{ ...noCompactor, preventCollision: true }` (freie Platzierung bleibt — `noCompactor` kam mit Commit c8f3361 ‚prevent auto-compaction on drag' bewusst; Kompaktierung `vertical` wuerde die freie Platzierung aufheben). Gemessen in `chunk-76RTO6EO.mjs:279-328`: OHNE `preventCollision` springt das gezogene Widget auf die Zeile des getroffenen Widgets und das getroffene wird um seine EIGENE Hoehe nach unten geschoben, ohne Kaskade und ohne Aufloesung — Ueberlappungen bleiben, weil `noCompactor.compact` die Identitaet ist; beim Vergroessern in einen Nachbarn (`se`-Griff, `shouldMoveItem=false`, `chunk-WGL5FSZH.mjs:872-885`) entsteht heute stumm eine Ueberlappung. MIT `preventCollision` bleibt das gezogene Widget an seinem Ausgangsort (`l.x = oldX; l.y = oldY`), der Platzhalter wandert nicht in belegten Raum, und Vergroessern stoppt am Nachbarn. Im Test ueber den echten `noCompactor` (`importOriginal`) festgenagelt: `type: null`, `allowOverlap: false`, `preventCollision: true`."
- "Die Stoppuhr kann ueberhaupt kleiner werden: ihre Bedienleiste (Start/Stop/Runde/Reset) ist kompakt (`px-2 py-1 text-xs`, Zeile `gap-1 py-1`, 32 px hoch statt 48). Gerechnet: heute bei Mindestbreite 4 Spalten (191 px, Rumpf 179 px) ueberlaeuft die laufende Stoppuhr (Stop + Runde + Reset mit `px-4 text-sm` ca. 222 px) — der Reset-Knopf ist abgeschnitten; kompakt ca. 151 px passt. Ohne diese Aenderung waere das inhaltsgetriebene Minimum der Stoppuhr 6x4 und damit BREITER als heute — das Gegenteil des Auftrags. Kein anderes Widget wird innen umgebaut (Rechner: hoeheres Minimum 3x9 statt Umbau, wie im Auftrag vorgesehen)."
- "Baseline gehalten und erweitert: Web von `46 / 286` auf `Test Files 47 passed (47)` / `Tests 293 passed (293)` (dashboard-grid +4, page.test.tsx NEU +3, widget-registry 11 -> 11 mit ersetztem Tabellen-Test), API unveraendert `67 / 1078`, `tsc --noEmit` dreimal 0, `pnpm install --frozen-lockfile` 0, `git diff --stat df16f46 -- . ':!.planning'` nennt genau `12 files changed`; Schema, `.env*`, Compose, Lockfile, `package.json`, `dashboard.service.ts`, `dto/`, `globals.css`, `umlaut-dictionary.ts` unangetastet; drei Commits mit Scope `quick-260916-dyv`, `git push` (schiebt auch den lokalen Vorsprung df16f46 mit), CI-Lauf beobachtet."
artifacts:
- "apps/web/src/components/dashboard/widget-registry.tsx — neue Mindestwerte (Tabelle), Vorgaben unveraendert, Kommentar (deutsch, ASCII) ‚inhaltsgetrieben, Rechnung im Plan 260916-dyv'"
- "apps/web/src/components/dashboard/widget-registry.test.tsx — Tabellen-Test ersetzt (exakte 32 Werte per `toEqual`, zusaetzlich min <= default je Typ)"
- "apps/web/src/components/dashboard/dashboard-grid.tsx — exportierte Konstanten `WIDGET_DRAG_HANDLE_SELECTOR`, `WIDGET_DRAG_CANCEL_SELECTOR`, `FREE_PLACEMENT_COMPACTOR`; `effectiveLayouts` (minW/minH aus den Konstanten); `dragConfig` mit `handle`, `cancel`, `threshold: 3`; Kopfkommentare mit den gemessenen RGL-Fundstellen"
- "apps/web/src/components/dashboard/dashboard-grid.test.tsx — Mock per `importOriginal` (echter `noCompactor`), Test 5 angepasst (clock minW/minH 2), +4 Tests (dragConfig-Pin, Compactor-Pin, cancel/handle-Semantik im DOM, minW/minH-Ueberschreibung)"
- "apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx — kompakte Bedienleiste"
- "apps/web/src/components/dashboard/widgets/widget-wrapper.tsx — Karte als Griff im Bearbeitungsmodus, Overlay-Kopfleiste mit Griff-Symbol und Tooltip, Loesch-Knopf in der Kopfleiste mit `data-no-drag`; Rumpf `@container-size h-full` UNVERAENDERT (Hoehenkette bleibt definit, keine Aenderung der Container-Query-Aufloesung)"
- "apps/web/src/components/dashboard/edit-mode-toggle.tsx — schwebender Stil: `shadow-lg`, inaktiv `border border-border bg-card`"
- "apps/web/src/app/(portal)/page.tsx — feste Aktionsleiste unten rechts (Stift/Haekchen + „Widget hinzufuegen“), Grid direkt im Container, alter Block oben rechts und Abstands-Wrapper entfernt"
- "apps/web/src/app/(portal)/page.test.tsx — NEU, 3 Tests (Ansichtsmodus, Bearbeitungsmodus, Umschalten)"
- "apps/web/src/messages/de.json + en.json — `widgets.dragHint` (de: „Ziehen Sie die Kachel, um sie zu verschieben“, en: „Drag the tile to move it“), direkt nach `deleteTooltip`; Umlaut-Waechter gruen ohne Woerterbuch-Aenderung (Wortlaut zur Planungszeit gegen Tokenizer/Muster geprueft: 0 Treffer)"
- "docs/anleitung-anwender.md — Abschnitt Dashboard: Schalter unten rechts, ganze Kachel ziehbar (Eingabefelder/Knoepfe ausgenommen), Kopfleiste als Griff-Hinweis, Ablegen nur auf freiem Platz, Mindestgroesse = gerade noch bedienbar"
key_links:
- "Gespeicherte `minW/minH` schlagen `data-grid`: ohne die Ueberschreibung in `dashboard-grid.tsx` bleiben alle vorhandenen Widgets auf den verdoppelten Minima aus der Datenbank, egal was in `WIDGET_CONSTRAINTS` steht — die Konstante allein aendert fuer den User NICHTS Sichtbares. Deshalb ist Test 9 (Ueberschreibung) der entscheidende Test dieser Aufgabe, nicht der Tabellen-Test."
- "`handle` an der Karte + `cancel` fuer Interaktives: `matchesSelectorAndParentsTo` laeuft vom Ereignisziel aufwaerts bis zum RGL-Element; die Karte ist Kind des RGL-Elements, also matcht jedes Ziel in der Karte den Griff, und `cancel` gewinnt fuer Ziele in Eingabefeldern/Knoepfen/Links. Die Karte selbst darf NICHT auf `cancel` passen (Test 8 prueft `card.closest(cancel) === null`), sonst zieht nichts mehr."
- "`preventCollision` lebt am Compactor-Objekt (`compactor.preventCollision ?? false`, `chunk-WGL5FSZH.mjs:666`), nicht als eigene Prop — deshalb ein eigenes Objekt `{ ...noCompactor, preventCollision: true }` statt `noCompactor`. Der Test importiert den echten `noCompactor` (`importOriginal`), damit `compact` die echte Identitaet ist und nicht ein Mock-Stub."
- "Die Overlay-Kopfleiste ist bewusst `absolute` und NICHT im Fluss: der Rumpf bleibt `h-full`, die Hoehenkette Karte -> Rumpf bleibt definit, `cqh` loest weiter auf (Grund fuer `h-full` in 260916-bwo). Eine Kopfleiste im Fluss haette den Rumpf im Bearbeitungsmodus um 20 px gekuerzt und den Rechner bei Mindestgroesse beschnitten."
- "Feste Aktionsleiste: `fixed` bezieht sich auf das Viewport (kein transformierter Vorfahr — der bisherige „Widget hinzufuegen“-Knopf benutzt `fixed bottom-6 right-6` bereits und funktioniert); `z-20` liegt ueber RGL-Elementen (gezogenes Element z-index 3)."
- "`umlaut-guard.spec.ts` prueft de/en-Schluesselparitaet rekursiv ueber ALLE Namespaces — `dragHint` muss in beiden Dateien stehen."
---
<objective>
Drei Nachbesserungen am Dashboard nach dem Beta-Test des Users: (1) jedes Widget laesst sich wieder auf seine kleinste bedienbare Groesse verkleinern — die in 260916-bwo verdoppelten Minima werden durch inhaltsgetriebene Minima ersetzt UND die in der Datenbank gespeicherten `minW/minH` werden beim Rendern aus den Konstanten ueberschrieben (sonst bleibt alles wie gehabt); (2) der obere Rand wird von 60 px auf 28 px halbiert, indem der Bearbeiten-Umschalter in eine feste Leiste unten rechts wandert; (3) Drag & Drop wird zuverlaessig: ganze Kachel als Griff, `cancel` fuer Eingabefelder/Knoepfe/Links, kein Ueberlappen beim Ablegen (`preventCollision`). Dazu die kompakte Stoppuhr-Bedienleiste (ohne sie kann die Stoppuhr nicht kleiner werden), Handbuch, Tests, Push, CI.
Purpose: Der User sieht auf alpha, dass die Kacheln „nicht mehr klein genug werden“ (verdoppelte Minima plus persistierte Minima), der Rand oben zu gross ist und Ziehen „nur semi“ funktioniert (6-px-Griff, Eingabefelder starten Drags, Kollisionen erzeugen Spruenge und Ueberlappungen). Alles ist reine Frontend-Arbeit ohne Schema.
Output: 12 Dateien (11 Web, 1 Handbuch), drei Commits mit Scope `quick-260916-dyv`, gepusht, CI-Lauf beobachtet. Browser-Nachweis durch den Orchestrator (Playwright MCP, lokale Container nach `docker compose up -d --build web`), siehe `<verification>`.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/quick/260916-bwo-dashboard-feineres-raster-spalten-und-ze/260916-bwo-SUMMARY.md
@apps/web/src/components/dashboard/dashboard-grid.tsx
@apps/web/src/components/dashboard/dashboard-grid.test.tsx
@apps/web/src/components/dashboard/widget-registry.tsx
@apps/web/src/components/dashboard/widget-registry.test.tsx
@apps/web/src/components/dashboard/edit-mode-toggle.tsx
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@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
@apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
@apps/web/src/messages/umlaut-guard.spec.ts
@apps/web/node_modules/react-grid-layout/dist/types-jd8MiKM1.d.ts
@docs/anleitung-anwender.md
<planning_measurements>
Zur Planungszeit (2026-09-16, HEAD `df16f46`, Arbeitsbaum sauber, `main` ist 1 Commit VOR `origin/main` — df16f46 ist ein reiner Akten-Commit, der mit dem Push dieser Aufgabe mitgeht) gemessen:
- **Baseline frisch:** Web `Test Files 46 passed (46)` / `Tests 286 passed (286)`; API `Test Files 67 passed (67)` / `Tests 1078 passed (1078)`; `tsc --noEmit` in `packages/shared`, `apps/api`, `apps/web` je Exit 0. Je Datei: `widget-registry.test.tsx` 11 Tests (1 + `it.each` 8 + 1 + 1), `dashboard-grid.test.tsx` 5, `stopwatch-widget.test.tsx` 8 (keine Klassen-Pins an Knoepfen, nur `cqw/cqh` an der Anzeige), `src/messages` 6. Kein Test fuer `page.tsx` des Dashboards und keiner fuer `edit-mode-toggle.tsx` (grep leer). Vitest findet Tests unter `src/app/(portal)/...` (zehn Beispiele vorhanden, z. B. `admin/groups/groups-page.test.tsx` — Muster fuer next-intl-Mock mit Namensraum). Container: `tessera-ctl-web-1` (3000), `tessera-ctl-api-1` (3001, healthy), `tessera-ctl-db-1` (kein Host-Port), Gitea `1.26.2` auf 3002, `gitea-runner` laeuft; Web-Abbild 47 Minuten alt aus dem Stand 1aefaa3 — vor dem Browser-Nachweis `docker compose up -d --build web` (Erinnerung: `up` allein baut nicht neu). Lokale DB: `DashboardLayout` 0 Zeilen, `WidgetInstance` 0 Zeilen.
- **Raster-Arithmetik (Ist, unveraendert):** rowHeight 20, margin 8, containerPadding = margin. Kachelhoehe(h) = 28h - 8 px. Spaltenbreite = (Containerbreite - 8*(cols-1) - 16)/cols: lg bei 1200 px = 41,67 px (Kachelbreite(w) = 49,67w - 8), beim Orchestrator-Viewport von 260916-bwo (4 Spalten = 264 px -> Spalte 60 px, Container ca. 1640 px) = 60 px; md (996 px, 20 Spalten) 41,4 px; sm (768, 12) 55 px; xs (480, 8) 51 px. Eine Spalte ist also in allen Breakpoints ausser xxs 41-60 px breit, eine Zeile 20 px + 8 px Abstand.
- **Der zweite, im Auftrag nicht genannte Grund fuer „nicht klein genug“:** `synchronizeLayoutWithChildren` (`chunk-WGL5FSZH.mjs:553-582`) nimmt fuer jedes Kind mit Eintrag im `layout` den Eintrag per `cloneLayoutItem` WOERTLICH — `data-grid` wird nur fuer Kinder OHNE Eintrag gelesen. `dashboard-grid.tsx` ueberschreibt `minW/minH` heute nur im `data-grid` (Zeile 100-109), das fuer bestehende Widgets wirkungslos ist. Die gespeicherten Eintraege tragen `minW/minH` (`cloneLayoutItem` in `chunk-76RTO6EO.mjs:204-223` -> `onLayoutChange` -> Store -> `PUT /dashboard/layout`; 260916-bwo hat sie beim Migrieren verdoppelt). Folge: eine Aenderung der Konstanten allein aendert fuer bestehende Widgets nichts. `Responsive` vergleicht `layouts` per `deepEqual` (`chunk-WGL5FSZH.mjs:1354`), ein inhaltsgleiches neues Objekt loest nichts aus; ein geaendertes wird abgeleitet, ohne `onLayoutChange` zu rufen; `GridLayout` meldet beim Mount ohnehin `onLayoutChange`, wenn sein synchronisiertes Layout vom Prop abweicht (Zeile 697-702) — das ist heute schon so (Store `isDirty`, gespeichert wird erst beim Verlassen des Bearbeitungsmodus, `dashboard-store.ts:53-54`), keine Schleife: nach der ersten Meldung tragen die Store-Eintraege dieselben `minW/minH` wie die Ueberschreibung.
- **Kollision mit `noCompactor` (gemessen `chunk-KDANGDDL.mjs:450-454`, `chunk-76RTO6EO.mjs:279-328`, `chunk-WGL5FSZH.mjs:664-666, 776-835, 855-885`):** `noCompactor = { type: null, allowOverlap: false, compact: cloneLayout }`, `preventCollision` fehlt -> `false`. `moveElement` bei Kollision waehrend des Ziehens: `moveElementAwayFromCollision(layout, l, collision, isUserAction=true, null)` -> `fakeItem` an der Position des getroffenen Widgets, `firstCollision` ist immer gesetzt (das gezogene oder das getroffene Widget selbst), `collisionNorth` ist wahr, Zweig `collisionNorth && compactType === null`: `collidesWith.y = itemToMove.y` (das GEZOGENE springt auf die Zeile des getroffenen) und `itemToMove.y += itemToMove.h` (das getroffene rutscht um seine EIGENE Hoehe nach unten), `return [...layout]` — keine Kaskade, keine Nachpruefung; ist das gezogene hoeher als das getroffene, ueberlappen beide weiter; das verschobene kann auf ein drittes fallen. `compact` ist Identitaet, also bleibt jede Ueberlappung. Beim Vergroessern mit dem `se`-Griff ist `shouldMoveItem` false, `moveElement` wird gar nicht gerufen — die Ueberlappung entsteht stumm. MIT `preventCollision: true`: Ziehen `if (hasCollisions && preventCollision) { l.x = oldX; l.y = oldY; l.moved = false; return layout; }` (Platzhalter bleibt am Ursprung, beim Loslassen ueber belegtem Raum bleibt das Widget, wo es war); Vergroessern: `getAllCollisions` am neuen Mass -> Mass bleibt. `Compactor.preventCollision` ist im Typ (`types-jd8MiKM1.d.ts:211`, `readonly preventCollision?: boolean`), `Compactor` wird aus `react-grid-layout` exportiert (`index.d.ts:3`). `noCompactor` kam mit Commit `c8f3361` (2026-07-02, „add noCompactor to prevent auto-compaction on drag“) bewusst — freie Platzierung ist gewollt und bleibt; `verticalCompactor` wuerde Luecken automatisch nach oben schliessen.
- **Griff/cancel (gemessen):** `DragConfig` (`types-jd8MiKM1.d.ts:357-372`): `enabled`, `bounded`, `handle?`, `cancel?`, `threshold` (Standard 3, `chunk-KDANGDDL.mjs:333-337`); `GridLayout` mischt `{ ...defaultDragConfig, ...dragConfigProp }` (`chunk-WGL5FSZH.mjs:633-636`), `dragConfig` ist `Partial<DragConfig>`. `GridItem` reicht `handle` und `cancel: '.react-resizable-handle' + (cancel ? ',' + cancel : '')` an `DraggableCore` aus `react-draggable` 4.7.0 (`chunk-WGL5FSZH.mjs:520-530`). Dort (`build/cjs/Draggable.js:417`): kein Drag, wenn `handle` gesetzt und das Ziel (aufwaerts bis zum Knoten) nicht passt, ODER wenn `cancel` gesetzt und das Ziel (aufwaerts bis zum Knoten) passt — `cancel` gewinnt also auch INNERHALB des Griffs. `threshold` wird in `GridItem` per `Math.hypot` ausgewertet (`chunk-WGL5FSZH.mjs:214-221`). Die Klasse `widgetNoDrag` steht 5x in `favorites-widget.tsx` und 7x in `link-widget.tsx` (Umschaltzeilen, Formulare, Links) und ist NIRGENDS verdrahtet (grep ueber `src`: kein `cancel`, kein `draggableCancel`) — toter Marker aus Phase 8, der durch den Selektor lebendig wird. Kein Test greift `widgetNoDrag`.
- **Innenaufbau bei Mindestgroesse (gerechnet aus den Klassen; der Browser-Nachweis prueft nach):**
| Typ | bwo-Minimum (heute) | neues Minimum | px bei Spalte 41,67 / 60 | Rechnung |
|---|---|---|---|---|
| clock | 4/4 | 2/2 | 91x48 / 128x48 | Zeit `min(20cqw, 50cqh)` = ca. 17-20 px, 8 Zeichen tabular ca. 80 px passen in 83 px; Datum (falls an) 10 px darunter |
| search | 6/4 | 6/2 | 290x48 / 400x48 | Breite: Auswahl 120 + Eingabe >= 80 + Knopf 40 + Abstaende 28 = 268 <= 278 Rumpf (6 Spalten; 3 Spalten = 141 px waeren unbrauchbar, die alte 12-Spalten-Zahl 3 entsprach 281 px); Hoehe: Eingabe `h-8` 32 px in 48 px |
| calendar | 6/6 | 3/3 | 141x76 / 196x76 | eine Terminzeile 48 px + `p-1.5` = 60 <= 76; Leertext (3 Zeilen text-sm) 60 px passt knapp |
| note | 4/6 | 4/4 | 191x104 / 264x104 | Kopfzeile ca. 36 px + Vorschau >= 2 Zeilen (40) = 76 <= 104; im Editiermodus Werkzeugleiste ca. 29 px + 2 Zeilen |
| calculator | 4/8 | 3/9 | 141x244 / 196x244 | Breite: 4 Tasten x >= 30 px + 3x4 + `p-1` 8 = 140 <= 141; Hoehe: Anzeige `min-h-[2.5rem]` 40 + 4 + Speicherzeile `h-7` 28 + 4 + Tastenfeld 5x`min-h-7` 28 + 4x4 = 156, plus `p-1` 8 = 240 <= 244. Bei h=8 (216 px) fehlen 24 px: die unterste Tastenreihe (+/-, 0, Komma, =) ist HEUTE bei Mindestgroesse abgeschnitten (Rumpf `flex-1` mit `min-height: auto` schrumpft nicht unter 156). Kein Umbau des Rechners (Auftrag: hoeheres Minimum begruendet waehlen) |
| favorites | 4/6 | 3/3 | 141x76 / 196x76 | Umschaltzeile Liste/Kacheln (nur Bearbeitungsmodus) ca. 110 px <= 133; drei Listenzeilen (20 px + 4) in 68 px; Kachelraster `grid-cols-3` ca. 40-px-Kacheln |
| link | 4/4 | 3/2 | 141x48 / 196x48 | Listenzeile Symbol 20 + Titel in 40 px Rumpf; Umschaltzeile Liste/Kachel <= 133; Kachelansicht (Symbol 32 + Text 16 + Abstand) braucht 3 Zeilen — der User zieht dafuer eine Zeile hoeher (im SUMMARY nennen) |
| stopwatch | 4/4 | 4/3 | 191x76 / 264x76 | NUR mit kompakter Bedienleiste: laufend Stop+Runde+Reset `px-2 text-xs` ca. 151 px <= 179 (heute `px-4 text-sm` ca. 222 px > 179: Reset abgeschnitten); Zeile 32 px statt 48; Anzeige `min(16cqw, 35cqh)` = 26,6 px bei 76 px Kachel im Rest von 32 px; Rundenliste (`max-h-32`) darunter braucht mehr Hoehe — Nebenfunktion |
Die Vorgaben (defaultW/defaultH) bleiben: clock 4/4, search 12/4, calendar 8/12, note 6/8, calculator 6/10, favorites 6/10, link 4/4, stopwatch 6/6 — neue Widgets erscheinen wie gewohnt. Alle Minima liegen <= Vorgabe (der bestehende `it.each`-Test prueft das).
- **Umschalter-Geometrie (Ist):** `edit-mode-toggle.tsx` Knopf `rounded-md p-2` + 20-px-Symbol = 36x36 px, inaktiv OHNE Hintergrund (`text-muted-foreground hover:bg-muted`), aktiv `bg-primary`. `page.tsx` Zeile 69-91: Container `relative p-2`, Umschalter in einem absolut positionierten Block oben rechts (`z-10`), Grid in einem Abstands-Wrapper (Kommentar erklaert die Kollision mit dem 36-px-Knopf), „Widget hinzufuegen“ `fixed bottom-6 right-6 z-20` nur im Bearbeitungsmodus, Knopf `px-4 py-2 text-sm` = 36 px hoch (gleiche Hoehe wie der Umschalter). Oben heute: 12 + 8 + 32 + 8 = 60 px (gemessen top=120 bei Header 60 in 260916-bwo). Neu: 12 + 8 + 8 = 28 px.
- **Widget-Karte (Ist):** `widget-wrapper.tsx` Karte `relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm`, im Bearbeitungsmodus ein 6 px hoher Griffstreifen IM FLUSS (der Rumpf `h-full` laeuft heute schon 6 px ueber, `overflow-hidden` schneidet) und der Loesch-Knopf `absolute right-1 top-1 z-10 h-6 w-6` ueber dem Streifen; Rumpf `@container-size h-full` plus einer wirkungslosen `pt-0`-Bedingung. RGL-CSS: `.react-grid-item > .react-resizable-handle` liegt als GESCHWISTER der Karte unten rechts (20x20 px), nicht in der Karte — bleibt sichtbar. `bg-muted/50`, `hover:bg-muted/50`, `cursor-grab`, `active:cursor-grabbing`, `shadow-lg` sind im Projekt bereits kompilierte Klassen; `inset-x-0`, `h-5`, `w-5`, `bg-muted/70`, `border-primary/40` sind Standard-Utilities (kein `calc`, kein Arbitrary Value noetig).
- **Store:** `addWidget` legt neue Widgets bei `x: 0, y: maxY` (unter allen vorhandenen) an — keine Ueberlappung beim Hinzufuegen, `preventCollision` stoert nicht. `saveLayout` sendet `withGridVersion(layouts)`; die ueberschriebenen `minW/minH` landen beim naechsten Speichern in der DB (gewollt: RGL liefert sie in `onLayoutChange`).
- **i18n:** `widgets` in de.json/en.json Zeile 176-185, `deleteTooltip` Zeile 181; `umlaut-guard.spec.ts` Tokenizer `/[A-Za-zÄÖÜäöüß]+/g`, `SUSPECT_RE = /(ae|oe|ue|ss)/i`, Allowlist case-sensitiv, de/en-Paritaet rekursiv. Wortlaut „Ziehen Sie die Kachel, um sie zu verschieben“ mit demselben Tokenizer/Muster geprueft: 0 verdaechtige Token -> `umlaut-dictionary.ts` bleibt unangetastet. `dashboard-grid.test.tsx` mockt `useTranslations` mit einer Map (fehlende Schluessel -> Schluesselname).
- **Detektoren/Konfiguration:** `api-coverage` -> `{"detected":false}`; `assumption-delta scan quick-260916-dyv` -> `{"skipped":true,"reason":"phase_unresolved"}` (Quick-Task ohne ROADMAP-Abschnitt; inhaltlich `no-change`); `schema-gate` feuert NICHT (kein `schema.prisma`, keine Migration). `tdd_mode=false` (Tasks 1 und 2 tragen trotzdem `tdd="true"`), `security_enforcement=true`, ASVS 1, Blocking-Schwelle `high`, `human_verify_mode=end-of-phase`, `branching_strategy: none`. Keine neuen Pakete (`estimate-calibration`: Faktor 1, 0 Stichproben, `confidence: low`).
</planning_measurements>
</context>
<!-- planner-discipline-allow: mt-8 -->
<!-- planner-discipline-allow: top-2 -->
<!-- planner-discipline-allow: h-[6px] -->
<!-- planner-discipline-allow: px-4 py-1.5 text-sm -->
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Mindestgroessen inhaltsgetrieben (Tabelle), gespeicherte Minima beim Rendern ueberschreiben, Stoppuhr-Bedienleiste kompakt</name>
<files>apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/dashboard-grid.tsx, apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx</files>
<behavior>
- widget-registry.test.tsx, ersetzt den bwo-Test „jede Groesse ist exakt das Doppelte“ durch „Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert“: `expect(WIDGET_CONSTRAINTS).toEqual({ clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 } })`; dazu die Zaehlung „32 Felder“ wie bisher. Die Pruefung „jeder Wert ist gerade“ entfaellt (3 und 9 sind ungerade); `minW <= defaultW` und `minH <= defaultH` je Typ prueft weiterhin der bestehende `it.each`.
- dashboard-grid.test.tsx Test 5 angepasst: Uhr ohne gespeicherten Eintrag -> `data-grid` `{ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }`.
- dashboard-grid.test.tsx Test 9 (quick-260916-dyv): gespeicherter Eintrag `{ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 8, minH: 8 }` in `lg` und `{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }` in `md` fuer ein Widget vom Typ `clock` -> `captured.props.layouts.lg[0]` ist `{ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 }` und `captured.props.layouts.md[0].minW === 2` (JEDER Breakpoint wird ueberschrieben, x/y/w/h bleiben); ein Eintrag fuer einen unbekannten Typ (`widgetType: 'unknown'`) bleibt unveraendert; die uebergebenen `layouts` enthalten KEINEN Schluessel `__gridVersion` (der Store liefert markerfrei — nur Durchreichung pruefen: `Object.keys(captured.props.layouts)` gleich den Eingabe-Schluesseln).
- Stoppuhr: bestehende 8 Tests bleiben gruen (nur Klassen geaendert).
</behavior>
<action>
RED zuerst: die vier Testaenderungen oben schreiben (Test A ersetzt den bwo-Tabellen-Test woertlich, Test 5 anpassen, Test 9 anhaengen), `pnpm -C apps/web exec vitest run src/components/dashboard/widget-registry.test.tsx src/components/dashboard/dashboard-grid.test.tsx` -> Test A, Test 5 und Test 9 rot, Zahlen der roten Tests notieren (Erwartung `3 failed`). Dann GREEN:
1. `widget-registry.tsx` (`WIDGET_CONSTRAINTS`, Zeile 35-46): Minima auf die Tabelle aus `<planning_measurements>` setzen (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3), `defaultW/defaultH` UNVERAENDERT lassen. Den bwo-Kommentar ersetzen durch einen deutschen ASCII-Kommentar: „quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 + Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 = 240 px -> 9 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH = altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in dashboard-grid.tsx aus dieser Tabelle ueberschrieben.“ Keine weitere Aenderung in dieser Datei (die `WIDGET_REGISTRY`-Eintraege spreaden die Konstanten).
2. `dashboard-grid.tsx`: `useMemo` importieren. Vor dem `return` ein `effectiveLayouts` per `useMemo` ueber `[layouts, widgets]` berechnen: `Map` von Widget-Id auf `widgetType`; fuer jeden Schluessel von `layouts` (nur Array-Werte) jeden Eintrag kopieren und — falls `WIDGET_CONSTRAINTS[typ]` existiert — `minW`/`minH` aus den Konstanten setzen, sonst unveraendert lassen; Rueckgabetyp `Record<string, Array<LayoutItemShape>>` (den bestehenden Prop-Typ um optionale `minW?: number; minH?: number` erweitern). `layouts={effectiveLayouts as ResponsiveLayouts}` an `Responsive` uebergeben und im `data-grid`-Fallback `effectiveLayouts.lg?.find(...)` statt `layouts.lg?.find(...)` verwenden (die explizite `minW/minH`-Zuweisung im `data-grid` bleibt fuer Widgets ohne Eintrag). Kopfkommentar (deutsch, ASCII) mit dem Warum: RGL 2.2.3 `synchronizeLayoutWithChildren` nimmt gespeicherte Eintraege woertlich inklusive minW/minH und liest data-grid nur fuer Kinder ohne Eintrag; gespeicherte Anordnungen tragen die alten (verdoppelten) Minima; die Konstanten sind die einzige Quelle. Den Rest der Datei in diesem Task NICHT anfassen (dragConfig/compactor kommen in Task 2).
3. `stopwatch-widget.tsx` (Zeile 194-234): Bedienleiste kompakt — Zeilen-`div` von `gap-2 py-2` auf `gap-1 py-1`; alle vier Knoepfe (Start, Stop, Runde, Reset) von der bisherigen Groesse (waagerechter Innenabstand Stufe 4, senkrechter 1.5, Schrift `text-sm`) auf `px-2 py-1 text-xs`; Farben, `rounded-md`, `font-medium`, aria-labels und Handler unveraendert. Kommentar (deutsch, ASCII): „quick-260916-dyv: kompakte Bedienleiste, damit die laufende Stoppuhr (Stop + Runde + Reset) in 4 Spalten passt (ca. 151 px statt ca. 222 px) und die Kachel auf 4x3 schrumpfen kann.“ Rundenliste unveraendert.
4. Gruen: `pnpm -C apps/web exec vitest run src/components/dashboard` -> alle gruen; Zeilenzahlen notieren (Erwartung: widget-registry 11, dashboard-grid 6, stopwatch 8). `pnpm -C apps/web exec tsc --noEmit` -> 0.
5. Commit (nur diese 5 Dateien): `fix(quick-260916-dyv): Mindestgroessen inhaltsgetrieben (kleinste bedienbare Kachel je Typ), gespeicherte Minima ueberschrieben, Stoppuhr-Bedienleiste kompakt`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run src/components/dashboard 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "minW: 3, minH: 9" apps/web/src/components/dashboard/widget-registry.tsx ; grep -c "minW: 2, minH: 2, defaultW: 4, defaultH: 4" apps/web/src/components/dashboard/widget-registry.tsx ; grep -c "defaultW: 12, defaultH: 4" apps/web/src/components/dashboard/widget-registry.tsx ; grep -c "effectiveLayouts" apps/web/src/components/dashboard/dashboard-grid.tsx ; grep -c "px-2 py-1 text-xs" apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx ; grep -c "px-4 py-1.5 text-sm" apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx ; pnpm -C apps/web exec tsc --noEmit >/dev/null 2>&1; echo "TSC_web=$?"</automated>
<fails_when>Die Dashboard-Suite ist nicht `Test Files 10 passed (10)` / `Tests 64 passed (64)` (heute 10 Dateien / 63 Tests, plus Test 9); einer der drei Registry-Greps ist nicht 1; `effectiveLayouts` kommt seltener als 3x vor (Definition, `layouts=`, Fallback); die Stoppuhr hat weniger als 4 Knoepfe mit `px-2 py-1 text-xs` oder noch einen mit der alten Groesse (letzter Grep nicht 0); `TSC_web` ist nicht 0.</fails_when>
</verify>
<done>
Dashboard-Suite `Test Files 10 passed (10)` / `Tests 64 passed (64)`; die drei Registry-Greps liefern 1/1/1; `effectiveLayouts` >= 3; Stoppuhr-Greps 4 und 0; `TSC_web=0`; RED-Lauf mit 3 roten Tests im SUMMARY notiert; Commit mit Scope `quick-260916-dyv` vorhanden.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Bearbeiten-Schalter in feste Leiste unten rechts (Rand 28 px), ganze Kachel als Griff mit Overlay-Kopfleiste, cancel-Selektor, kein Ueberlappen beim Ablegen</name>
<files>apps/web/src/components/dashboard/dashboard-grid.tsx, apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/edit-mode-toggle.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</files>
<behavior>
- dashboard-grid.test.tsx Test 6 (dragConfig-Pin): im Bearbeitungsmodus ist `captured.props.dragConfig` gleich `{ enabled: true, handle: '.widget-drag-handle', cancel: 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag', threshold: 3 }` (`toEqual`); im Ansichtsmodus `enabled: false` bei gleichem `handle`/`cancel`/`threshold`; `captured.props.resizeConfig` ist `{ enabled: true }` bzw. `{ enabled: false }`.
- dashboard-grid.test.tsx Test 7 (Compactor-Pin, echter `noCompactor` via `importOriginal`): `captured.props.compactor` erfuellt `toMatchObject({ type: null, allowOverlap: false, preventCollision: true })`, `typeof compactor.compact === 'function'`, und `compactor.compact([{ i: 'a', x: 0, y: 0, w: 2, h: 2 }], 24)` ist per `toEqual` gleich der Eingabe, aber nicht dieselbe Referenz (Identitaet als Kopie — freie Platzierung, keine Kompaktierung).
- dashboard-grid.test.tsx Test 8 (cancel/handle-Semantik im DOM, Bearbeitungsmodus, exportierte Konstanten `WIDGET_DRAG_HANDLE_SELECTOR`/`WIDGET_DRAG_CANCEL_SELECTOR` aus `./dashboard-grid`): die Karte `[data-widget-id="inst-2"]` erfuellt `matches(HANDLE)`; `card.closest(CANCEL)` ist `null`; der Loesch-Knopf (`getByLabelText('Remove widget')`) hat `closest(CANCEL) === button` und das Attribut `data-no-drag`; ein per `document.createElement('input')` in die Karte gehaengtes Eingabefeld hat `closest(CANCEL) === input`; ein angehaengtes `div.widgetNoDrag` ebenso; die Kopfleiste (`getByTitle('Drag the tile to move it')`) liegt in der Karte und hat die Klassen `absolute`, `h-5`; im Ansichtsmodus gibt es weder Kopfleiste noch `.widget-drag-handle`.
- dashboard-grid.test.tsx next-intl-Mock um `dragHint: 'Drag the tile to move it'` erweitert.
- page.test.tsx (NEU, Muster `groups-page.test.tsx`; Mocks: `next-intl` mit Namensraum `widgets` — `editMode: 'Dashboard bearbeiten'`, `saveChanges: 'Änderungen speichern'`, `addWidget: 'Widget hinzufügen'`, `layoutLoadError`; `@/lib/stores/dashboard-store` wie in `dashboard-grid.test.tsx` mit veraenderbarem `mockStore` inkl. `isLoading: false`, `error: null`, `setEditMode: vi.fn()`, `loadDashboard: vi.fn()`; `@/components/dashboard/dashboard-grid` -> `DashboardGrid: (p) => <div data-testid="dashboard-grid" data-edit={String(p.isEditMode)} />`; `@/components/dashboard/widget-catalog-modal` -> `WidgetCatalogModal: () => null`; die acht Widget-Module `@/components/dashboard/widgets/{clock,search,calendar,note,calculator,stopwatch,favorites,link}-widget` -> jeweils die benannte Komponente als `() => null`; `DashboardPage` je Test per `await import('./page')`, `cleanup` in `afterEach`):
- Test 1 (Ansichtsmodus): Knopf mit Name „Dashboard bearbeiten“ vorhanden, sein Elternelement hat die Klassen `fixed`, `bottom-6`, `right-6`, `z-20`; kein Knopf „Widget hinzufügen“; `document.querySelector('.mt-8')` und `document.querySelector('.top-2')` sind `null`; das Elternelement von `getByTestId('dashboard-grid')` hat die Klasse `p-2` (Grid direkt im Container).
- Test 2 (Bearbeitungsmodus, `mockStore.isEditMode = true`): Knopf „Änderungen speichern“ und Knopf „Widget hinzufügen“ haben DASSELBE Elternelement (die feste Leiste), und „Widget hinzufügen“ steht im DOM VOR dem Umschalter (`compareDocumentPosition` & `DOCUMENT_POSITION_FOLLOWING`); `getByTestId('dashboard-grid')` hat `data-edit="true"`.
- Test 3 (Umschalten): im Ansichtsmodus Klick auf „Dashboard bearbeiten“ -> `mockStore.setEditMode` einmal mit `true` gerufen.
</behavior>
<action>
RED zuerst: Tests 6-8 in `dashboard-grid.test.tsx` anhaengen (Mock auf `vi.mock('react-grid-layout', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-grid-layout')>()), Responsive: MockResponsive }))` umstellen, damit `noCompactor` echt ist — bricht das in jsdom, Rueckfall: Objekt-Mock `noCompactor: { type: null, allowOverlap: false, compact: (l) => [...l] }` und Test 7 ohne Referenzvergleich, im SUMMARY begruenden), `page.test.tsx` anlegen; `pnpm -C apps/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx "src/app/(portal)/page.test.tsx"` -> Tests 6, 7, 8 rot, page-Tests 1 und 2 rot (Test 3 kann gruen sein — der Umschalter existiert heute schon), Zahlen notieren. Dann GREEN:
1. `dashboard-grid.tsx`: exportierte Konstanten `WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle'`, `WIDGET_DRAG_CANCEL_SELECTOR = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'` und `FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }` (Typ-Import `Compactor` neben `ResponsiveLayouts`). `dragConfig` = `{ enabled: isEditMode, handle: WIDGET_DRAG_HANDLE_SELECTOR, cancel: WIDGET_DRAG_CANCEL_SELECTOR, threshold: 3 }`; `compactor={FREE_PLACEMENT_COMPACTOR}`. Kommentare (deutsch, ASCII) mit den gemessenen Fundstellen: cancel gewinnt auch innerhalb des Griffs (react-draggable `Draggable.js:417`), RGL haengt `.react-resizable-handle` selbst voran; ohne `preventCollision` springt das gezogene Widget auf die Zeile des getroffenen und das getroffene um seine eigene Hoehe nach unten, Ueberlappungen bleiben, weil `noCompactor.compact` Identitaet ist (`chunk-76RTO6EO.mjs:279-328`); freie Platzierung bleibt (Commit c8f3361). Der `dragConfig`-Kommentar im JSDoc-Kopf („v2 API…“) darf bleiben.
2. `widget-wrapper.tsx`: Karte im Bearbeitungsmodus zusaetzlich mit `widget-drag-handle cursor-grab active:cursor-grabbing border-primary/40` (im Ansichtsmodus `border-primary/20` wie bisher; `className` per Template-String, beide Zweige als Literale). Den 6-px-Griffstreifen im Fluss ersetzen durch eine Overlay-Kopfleiste NUR im Bearbeitungsmodus: `div` mit `absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70`, `title={t('dragHint')}`, `data-testid="widget-drag-head"`, darin ein Griff-Symbol (inline SVG 14x14, sechs Punkte in zwei Reihen: Kreise bei (5,9) (12,9) (19,9) (5,15) (12,15) (19,15), r 1.5, `fill="currentColor"`, `className="text-muted-foreground"`, `aria-hidden`). Den Loesch-Knopf IN diese Kopfleiste setzen: `absolute right-0.5 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground`, Symbol 12x12, `data-no-drag=""`, `aria-label`/`title` `t('deleteTooltip')`, `onClick` mit `stopPropagation` wie bisher. Rumpf: `@container-size h-full` OHNE die wirkungslose `pt-0`-Bedingung (Klasse als reines Literal); den bwo-Kommentar um einen Satz ergaenzen: die Kopfleiste liegt als Overlay ueber dem Rumpf und aendert die Hoehenkette nicht. JSDoc der Komponente anpassen (Karte ist der Griff, Kopfleiste ist Hinweis, cancel-Selektor in dashboard-grid.tsx).
3. `edit-mode-toggle.tsx`: Klassen — Basis `inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors`; aktiv `bg-primary text-primary-foreground hover:opacity-90`; inaktiv `border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground`. JSDoc: „schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv)“. Symbole, aria, title unveraendert.
4. `page.tsx`: den absolut positionierten Block oben rechts samt Kommentar entfernen; den Abstands-Wrapper um `DashboardGrid` samt bwo-Kommentar entfernen (Grid direkt im Container `p-2`; `relative` am Container darf bleiben); den bisherigen bedingten `fixed`-Block durch EINE immer gerenderte Leiste ersetzen: `div` `fixed bottom-6 right-6 z-20 flex items-center gap-2`, darin zuerst `{isEditMode && <button …>{t('addWidget')}</button>}` (Knopf-Klassen unveraendert), dann `<EditModeToggle … />`. Kommentar (deutsch, ASCII): „quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).“ Der Stift ueberdeckt im Ansichtsmodus im schlimmsten Fall 36x36 px eines Widgets in der Ecke — hingenommen (im SUMMARY nennen).
5. `de.json`/`en.json`: unter `widgets` direkt nach `deleteTooltip` den Schluessel `dragHint` einfuegen — de „Ziehen Sie die Kachel, um sie zu verschieben“, en „Drag the tile to move it“. `umlaut-dictionary.ts` NICHT anfassen.
6. Gruen: `pnpm -C apps/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" src/messages` -> alle gruen (Erwartung: dashboard-grid 9, page 3, messages 6). `pnpm -C apps/web exec tsc --noEmit` -> 0. Falsifizierung (b): `cancel` aus dem `dragConfig` entfernen -> Test 6 rot; `preventCollision` aus `FREE_PLACEMENT_COMPACTOR` entfernen -> Test 7 rot; Falsifizierung (c): den Abstands-Wrapper (`div` mit `mt-8`) um das Grid wieder einfuegen -> page-Test 1 rot. Jede einmal ausfuehren, Testnamen notieren, zuruecksetzen (`git diff --stat` danach nur die Arbeitsdateien).
7. Commit (nur diese 8 Dateien): `feat(quick-260916-dyv): Bearbeiten-Schalter unten rechts (Rand oben 28 px), ganze Kachel als Griff mit Kopfleiste, cancel-Selektor, kein Ueberlappen beim Ablegen`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" src/messages 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "preventCollision: true" apps/web/src/components/dashboard/dashboard-grid.tsx ; grep -c "WIDGET_DRAG_CANCEL_SELECTOR" apps/web/src/components/dashboard/dashboard-grid.tsx ; grep -c "threshold: 3" apps/web/src/components/dashboard/dashboard-grid.tsx ; grep -c "mt-8" "apps/web/src/app/(portal)/page.tsx" ; grep -c "top-2" "apps/web/src/app/(portal)/page.tsx" ; grep -c "fixed bottom-6 right-6 z-20 flex items-center gap-2" "apps/web/src/app/(portal)/page.tsx" ; grep -c "h-\[6px\]" apps/web/src/components/dashboard/widgets/widget-wrapper.tsx ; grep -c "data-no-drag" apps/web/src/components/dashboard/widgets/widget-wrapper.tsx ; grep -c "cursor-grab" apps/web/src/components/dashboard/widgets/widget-wrapper.tsx ; grep -c '"dragHint"' apps/web/src/messages/de.json apps/web/src/messages/en.json ; W=$(git diff --stat -- apps/web/src/messages/umlaut-dictionary.ts); echo W_EXIT=$? ; test -z "$W"; echo W_EMPTY=$? ; pnpm -C apps/web exec tsc --noEmit >/dev/null 2>&1; echo "TSC_web=$?"</automated>
<fails_when>Die Teil-Suite ist nicht `Test Files 13 passed (13)` / `Tests 76 passed (76)` (Dashboard 10 Dateien / 64 Tests aus Task 1 plus Tests 6-8 = 67, `page.test.tsx` 1 Datei / 3 Tests, `src/messages` 2 Dateien / 6 Tests); `preventCollision: true`, `WIDGET_DRAG_CANCEL_SELECTOR` (>= 2: Definition und Verwendung) oder `threshold: 3` fehlen; `mt-8` oder `top-2` kommen in page.tsx noch vor (Grep nicht 0); die Leisten-Klasse fehlt; der alte 6-px-Streifen ist noch da; `data-no-drag` oder `cursor-grab` fehlen im Wrapper; `dragHint` fehlt in einer Sprachdatei (Ausgabe nicht `1` je Datei); `umlaut-dictionary.ts` ist geaendert (`W_EMPTY` ist 1) oder `W_EXIT` ist nicht 0; `TSC_web` nicht 0.</fails_when>
</verify>
<done>
Teil-Suite: Dashboard 10 Dateien / 67 Tests plus `page.test.tsx` 1 Datei / 3 Tests plus `src/messages` 2 Dateien / 6 Tests = `Test Files 13 passed (13)` / `Tests 76 passed (76)`; Greps: `preventCollision: true` 1, `WIDGET_DRAG_CANCEL_SELECTOR` >= 2, `threshold: 3` 1, `mt-8` 0, `top-2` 0, Leiste 1, alter Streifen 0, `data-no-drag` >= 1, `cursor-grab` >= 1, `dragHint` je 1, `W_EXIT=0`, `W_EMPTY=0`, `TSC_web=0`. RED-Lauf und Falsifizierungen (b) und (c) mit Testnamen im SUMMARY. Commit mit Scope `quick-260916-dyv` vorhanden.
</done>
</task>
<task type="auto">
<name>Task 3: Anwenderhandbuch, Abschluss-Gates, Push und Beobachtung des CI-Laufs</name>
<files>docs/anleitung-anwender.md</files>
<precondition>Gitea antwortet lokal: `curl -s --max-time 5 http://localhost:3002/api/v1/version` liefert `{"version":"1.26.2"}`, und `docker ps --format '{{.Names}}' | grep -c '^gitea-runner$'` liefert `1` (sonst Push trotzdem, Beobachtung als offenen Punkt ins SUMMARY).</precondition>
<action>
Schritt A — `docs/anleitung-anwender.md`, Abschnitt „Dashboard“ (echte Umlaute, Sie-Form, Ton der Datei), vier Stellen:
1. Zeile 61 „**Widgets hinzufügen und anordnen:** Oben rechts auf dem Dashboard befindet sich der Schalter **„Dashboard bearbeiten"**. Sobald der Bearbeitungsmodus aktiv ist:“ ersetzen durch „**Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist:“ (die Anfuehrungszeichen wie in der Datei: „ und ").
2. Aufzaehlungspunkt „- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben.“ ersetzen durch „- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.“
3. Im Aufzaehlungspunkt zur Größe den Klammertext „(jeder Widget-Typ hat eine Mindestgröße, damit der Inhalt lesbar bleibt)“ ersetzen durch „(jeder Widget-Typ hat eine Mindestgröße, bei der er gerade noch bedienbar bleibt — kleiner geht es nicht, größer jederzeit)“; Rest des Punktes unveraendert.
4. Aufzaehlungspunkt „- Erscheint an jedem Widget ein Symbol zum Entfernen.“ ersetzen durch „- Erscheint an jedem Widget rechts im Griff ein Symbol zum Entfernen.“
Schritt B — Gates, Commit, Push, Beobachtung:
1. `grep -c "Unten rechts auf dem Dashboard" docs/anleitung-anwender.md` -> 1; `grep -c "Oben rechts auf dem Dashboard" docs/anleitung-anwender.md` -> 0; `grep -c "gerade noch bedienbar" docs/anleitung-anwender.md` -> 1; `grep -c "an ihren Ausgangspunkt" docs/anleitung-anwender.md` -> 1.
2. Volle Suiten und tsc erneut: Web `Test Files 47 passed (47)` / `Tests 293 passed (293)`, API `Test Files 67 passed (67)` / `Tests 1078 passed (1078)`, `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; done` dreimal Exit 0; `pnpm install --frozen-lockfile` Exit 0.
3. `D=$(git diff --stat df16f46 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `12 files changed`; Unangetastet-Stichprobe `git diff --stat df16f46 -- '.env*' docker-compose.yml docker-compose.prod.yml docker-compose.dev.yml pnpm-lock.yaml apps/web/package.json apps/api/package.json apps/api/prisma apps/api/src/dashboard apps/web/src/app/globals.css apps/web/src/messages/umlaut-dictionary.ts apps/web/src/lib/stores/dashboard-store.ts apps/web/src/lib/grid-layout-migration.ts` -> leer.
4. Commit: `docs(quick-260916-dyv): Anwenderhandbuch — Bearbeiten-Schalter unten rechts, ganze Kachel ziehbar, Mindestgroessen` (nur diese Datei). Danach `git push` (schlichter Aufruf; die Push-URL zeigt auf localhost:3002; der lokale Vorsprung df16f46 geht mit); `git status -sb | head -n1` ohne `[ahead`.
5. Beobachtung des echten CI-Laufs (Token NIE ausgeben — nur in einer Shell-Variablen; Verfahren wie 260916-bwo Task 3): `PUSHED=$(git rev-parse HEAD); TOK=$(git config --get remote.origin.pushurl | sed -E 's#.*schalli:([^@]+)@.*#\1#')`; bis zu 12 Minuten alle 20 s `curl -s -H "Authorization: token $TOK" "http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5"` abfragen (Hintergrundbefehl, falls `sleep` im Vordergrund blockiert ist), Eintrag mit `head_sha == PUSHED`, auf `status == completed` warten; Erwartung `conclusion == success`, Dauer 4-6 Minuten. Danach Abbild-Probe: `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_COMMIT)'` -> `APP_COMMIT` = kurzer SHA von `PUSHED` (`APP_VERSION` ist das `git describe`-Format, gemessen in 260916-bwo). Lauf-ID, Dauer, Ergebnis ins SUMMARY. Ist `conclusion` nicht `success`: Job-Log ueber `.../actions/runs/<id>/jobs` lesen, Ursache beheben, erneut pushen, erneut beobachten.
6. Wird das SUMMARY erst nach dem Push committet, den Push danach wiederholen (weiterer CI-Lauf erwartet, in Ordnung). SUMMARY-Pflichtinhalte: beide RED-Laeufe und die Falsifizierungen (a) — eine Konstante in `widget-registry.tsx` auf den bwo-Wert setzen -> Test A rot — (b) und (c) mit Testnamen; der Befund „gespeicherte minW/minH schlagen data-grid“ als zweiter Grund fuer ‚nicht klein genug'; die Kollisions-Messung und die Entscheidung `preventCollision` statt Kompaktierung (freie Platzierung bleibt, Commit c8f3361); die Mindestgroessen-Tabelle mit Rechnung je Typ und den bewusst benannten Grenzen (Link-Kachelansicht braucht 3 Zeilen, Rundenliste der Stoppuhr braucht mehr Hoehe, Rechner-Minimum 3x9 ist HOEHER als 4x8, weil der Rechner heute bei 8 Zeilen unten abgeschnitten ist); die Entscheidung „Overlay-Kopfleiste statt Kopfleiste im Fluss“ (Hoehenkette); die Stoppuhr-Bedienleiste als einzige Innen-Aenderung mit Begruendung; die Beobachtung „Stift unten rechts kann im Ansichtsmodus 36x36 px eines Eck-Widgets verdecken“; Abschnitt „Durchsicht Dashboard“ mit Befund je Widget bei Standard- und Mindestgroesse (gerechnet; Browser-Bestaetigung durch den Orchestrator) und einer Liste „bleibt als Todo“ (Rechner-Innenaufbau kompakter, damit weniger als 9 Zeilen reichen; Rundenliste/laufende Stoppuhr bei Minimum; hart englische Texte im Einstellungsformular aus 260916-bwo; Link-Kachelansicht); Abschnitt „Fuer den Changelog“ (Stichpunkte in Alltagssprache, siehe unten); die Anleitung fuer den Browser-Nachweis aus `<verification>`.
Changelog-Stichpunkte (woertlich ins SUMMARY unter „Fuer den Changelog“): „Widgets lassen sich wieder deutlich kleiner ziehen — jedes Widget hat jetzt genau die Mindestgroesse, bei der es gerade noch bedienbar ist; das gilt auch fuer bereits platzierte Widgets.“ / „Der Bearbeiten-Schalter des Dashboards sitzt jetzt unten rechts; dadurch beginnen die Widgets direkt unter der Kopfzeile.“ / „Verschieben ist einfacher: im Bearbeitungsmodus laesst sich jedes Widget an einer beliebigen Stelle anfassen (ausser an Eingabefeldern und Knoepfen), ein grauer Griff am oberen Rand zeigt das an. Widgets ueberlappen sich beim Ablegen nicht mehr — ueber einer belegten Stelle springt das Widget an seinen Ausgangspunkt zurueck.“ / „Die Stoppuhr hat kompaktere Knoepfe und passt so auch in kleine Kacheln.“ / „Das Anwenderhandbuch beschreibt den neuen Schalter, das Ziehen und die Mindestgroessen.“
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "Unten rechts auf dem Dashboard" docs/anleitung-anwender.md ; grep -c "Oben rechts auf dem Dashboard" docs/anleitung-anwender.md ; grep -c "gerade noch bedienbar" docs/anleitung-anwender.md ; grep -c "an ihren Ausgangspunkt" docs/anleitung-anwender.md ; pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)" ; pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)" ; for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit >/dev/null 2>&1; echo "TSC_$p=$?"; done ; pnpm install --frozen-lockfile >/dev/null 2>&1; echo FROZEN=$? ; D=$(git diff --stat df16f46 -- . ':!.planning'); echo GIT_EXIT=$? ; tail -n1 <<< "$D" ; U=$(git diff --stat df16f46 -- '.env*' docker-compose.yml docker-compose.prod.yml docker-compose.dev.yml pnpm-lock.yaml apps/web/package.json apps/api/package.json apps/api/prisma apps/api/src/dashboard apps/web/src/app/globals.css apps/web/src/messages/umlaut-dictionary.ts apps/web/src/lib/stores/dashboard-store.ts apps/web/src/lib/grid-layout-migration.ts); echo U_EXIT=$? ; test -z "$U"; echo U_EMPTY=$? ; S=$(git status -sb); head -n1 <<< "$S"</automated>
<fails_when>Die vier Handbuch-Greps sind nicht 1/0/1/1; eine Suite weicht von Web 47/293 bzw. API 67/1078 ab; ein TSC_* oder FROZEN oder GIT_EXIT ist nicht 0; die Summenzeile nennt nicht genau `12 files changed`; U_EMPTY ist 1 (eine unantastbare Datei wurde geaendert); die Statuszeile zeigt `[ahead` oder `[behind`.</fails_when>
</verify>
<done>
Handbuch-Greps 1/0/1/1; Web `Test Files 47 passed (47)` / `Tests 293 passed (293)`; API `Test Files 67 passed (67)` / `Tests 1078 passed (1078)`; dreimal `TSC_...=0`; `FROZEN=0`; `GIT_EXIT=0` und die Summenzeile nennt `12 files changed`; `U_EXIT=0`, `U_EMPTY=0`; die Status-Zeile enthaelt kein `[ahead`. Das SUMMARY traegt unter „CI-Lauf nach dem Push“ Lauf-ID, `conclusion`, Dauer und die Abbild-Probe — oder, falls Gitea/Runner nicht erreichbar waren, den Grund und den offenen Punkt — sowie alle Pflichtinhalte aus Schritt B.6.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gespeichertes Layout-JSON (`DashboardLayout.layouts`, vom Benutzer ueber `PUT /dashboard/layout` kontrolliert) -> `effectiveLayouts` -> react-grid-layout | Benutzerdaten (x/y/w/h/minW/minH) werden beim Rendern interpretiert; `minW/minH` werden jetzt aus Konstanten ueberschrieben |
| DOM-Ereignisse in der Widget-Kachel -> `cancel`/`handle`-Selektoren -> Drag-Start | Statische CSS-Selektoren entscheiden, ob ein Mausereignis ein Ziehen startet |
| Widget-Inhalte (Favoriten-/Link-Titel, Notiztext) -> Kachel | Unveraendert: kein neuer Pfad von Benutzerdaten in Klassen, Selektoren oder Styles |
## STRIDE Threat Register (ASVS Level 1, Blocking-Schwelle `high`)
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-DYV-01 | Tampering | Gespeicherte `minW/minH` (eigene Anordnung, ungeprueft per `@IsObject()`) | low | mitigate | `effectiveLayouts` ersetzt `minW/minH` jedes Eintrags eines bekannten Typs durch die Konstanten — manipulierte Minima (z. B. `minW: 0` oder `minW: 1e9`) wirken nicht; unbekannte Typen bleiben unveraendert (kein Absturz, Test 9); wirkt ohnehin nur auf das eigene Dashboard |
| T-DYV-02 | Tampering | `cancel`/`handle`-Selektoren | low | accept | Statische Literale im Quelltext (`WIDGET_DRAG_CANCEL_SELECTOR`), keine Benutzerdaten in Selektoren; `Element.matches` mit ungueltigem Selektor wuerde werfen — Test 8 fuehrt `matches`/`closest` mit genau diesem Selektor in jsdom aus |
| T-DYV-03 | Denial of Service | `preventCollision` mit absurden gespeicherten Positionen (`x: 1e9`) | low | accept | react-grid-layout begrenzt ueber `correctBounds`; ein ueberlappend gespeichertes Widget laesst sich weiterhin auf freien Platz ziehen (Kollisionen werden nur am Zielort geprueft); wirkt nur auf das eigene Dashboard |
| T-DYV-04 | Information Disclosure | Tooltip `dragHint`, Kopfleiste | low | accept | Statischer Uebersetzungstext, keine Benutzerdaten |
| T-DYV-05 | Elevation of Privilege | Neue API-Pfade | low | accept | Keine API-Aenderung; `getLayout`/`saveLayout` bleiben `forTenant`-gebunden (Specs 260910-krx/260916-bwo unveraendert gruen) |
| T-DYV-SC | Tampering | npm/pip/cargo-Installationen | low | accept | Keine neuen Pakete, `pnpm-lock.yaml` unveraendert (`pnpm install --frozen-lockfile` als Gate in Task 3) |
</threat_model>
<verification>
Nach Task 3, alles aus `/home/vicolab/projects/tessera-ctl`:
- `pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 47 passed (47)` / `Tests 293 passed (293)`
- `pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 67 passed (67)` / `Tests 1078 passed (1078)`
- `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done` -> dreimal `=0`
- `pnpm install --frozen-lockfile; echo $?` -> `0`
- `D=$(git diff --stat df16f46 -- . ':!.planning'); tail -n1 <<< "$D"` -> `12 files changed`
- Falsifizierungen im SUMMARY benannt: (a) eine Konstante in `widget-registry.tsx` auf den bwo-Wert -> Test A rot; (b) `cancel` aus dem `dragConfig` -> Test 6 rot, `preventCollision` aus dem Compactor -> Test 7 rot; (c) Abstands-Wrapper um das Grid wieder eingefuegt -> page-Test 1 rot. Jede einmal durchfuehren, Testnamen rot notieren, zuruecksetzen.
- Human-Check (end-of-phase, nicht blockierend, durch den Verifizierer/Orchestrator mit Playwright MCP gegen die lokalen Container; VORHER `docker compose up -d --build web` — das Web-Abbild stammt aus 1aefaa3; die API braucht keinen Neubau; Bounding-Boxen per `boundingBox()`, nie per `fetch` aus der Seite):
1. Anmelden (Benutzername `admin`, Kennwort `admin123`). „Dashboard bearbeiten“ (Stift UNTEN RECHTS), nacheinander Uhr, Suchleiste, Rechner, Stoppuhr, Notizen, Kalender, Favoriten, Link hinzufuegen (jedes erscheint unter dem vorigen), Bearbeitungsmodus beenden (Haekchen unten rechts), Seite neu laden.
2. Rand oben: `main.app-shell-main` oben vs. erstes `[data-widget-id]` oben -> 28 px (+/-1; vorher 60). Linker Rand weiter 28 px, Luecke zwischen Nachbarn 8 px (unveraendert).
3. Feste Leiste: Bounding-Box des Stift-Knopfs (aria-label „Dashboard bearbeiten“): Abstand zum rechten Viewport-Rand 24 px, zum unteren 24 px, 36x36 px, mit Karten-Hintergrund und Rahmen; im Bearbeitungsmodus steht „Widget hinzufuegen“ links neben dem Haekchen in derselben Leiste; kein Element mehr oben rechts im Dashboard-Container.
4. Mindestgroessen: im Bearbeitungsmodus je Widget den Groessen-Griff unten rechts weit nach oben links ziehen, bis nichts mehr geht; Bounding-Box notieren und mit der Tabelle in `<planning_measurements>` vergleichen (bei Spaltenbreite `(Containerbreite - 200)/24` px): Uhr 2x2, Suche 6x2, Kalender 3x3, Notizen 4x4, Rechner 3x9, Favoriten 3x3, Link 3x2, Stoppuhr 4x3. Screenshot je Widget bei Minimum. Bedienbarkeit: Uhrzeit lesbar; Suchfeld >= 80 px breit, Knopf sichtbar; Rechner: ALLE fuenf Tastenreihen sichtbar inklusive „=“ (Vergleich: mit dem alten Abbild war die unterste Reihe bei 4x8 abgeschnitten — optional vorher pruefen); Stoppuhr starten -> Stop, Runde und Reset alle sichtbar und klickbar; Notiz: Titel und mindestens zwei Textzeilen; Kalender: Leertext oder eine Terminzeile; Favoriten: Umschaltzeile und Listenzeilen; Link: eine Listenzeile. Faellt ein Widget durch, die gemessene Kachelgroesse und den Grund notieren (dann Konstante + Test A anpassen — kleiner Korrekturlauf, keine Neuplanung).
5. Persistierte Minima: nach einem Speichern `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -At -c 'SELECT layouts->''lg'' FROM "DashboardLayout";'` -> die Eintraege tragen die neuen `minW/minH`. Gegenprobe: per SQL beim Uhr-Eintrag `minW: 8, minH: 8` setzen (jsonb_set), Seite neu laden, Uhr auf 2x2 verkleinern -> geht (die Konstanten schlagen die DB).
6. Ziehen: im Bearbeitungsmodus die Uhr an ihrer MITTE (nicht an der Kopfleiste) 200 px nach rechts ziehen -> sie rastet versetzt ein (Bounding-Box verglichen); Kopfleiste 20 px hoch mit Griff-Punkten und Tooltip „Ziehen Sie die Kachel, um sie zu verschieben“; `mousedown` im Suchfeld + 100 px Bewegung -> Text wird markiert, die Suchleiste bewegt sich NICHT; Klick auf das Loesch-Symbol rechts in der Kopfleiste -> Widget verschwindet, kein Ziehen; Groessen-Griff funktioniert weiter.
7. Ablegen auf belegter Stelle: die Uhr ueber die Suchleiste ziehen und loslassen -> die Uhr steht wieder an ihrem Ausgangsort, die Suchleiste ist nicht verschoben, nichts ueberlappt; die Suchleiste in Richtung Uhr vergroessern -> die Groesse stoppt vor der Uhr. Beobachtetes Verhalten woertlich notieren.
8. Durchsicht: jedes Widget bei Standard- und Mindestgroesse kurz beurteilen (gut / auffaellig / Todo), Liste in die VERIFICATION uebernehmen.
Nach der Probe: Probe-Datensaetze in der lokalen DB entfernen, falls angelegt; Playwright-Artefakte entfernen.
</verification>
<success_criteria>
- Minima: `WIDGET_CONSTRAINTS` traegt die inhaltsgetriebene Tabelle (Test A), `effectiveLayouts` ueberschreibt gespeicherte `minW/minH` in jedem Breakpoint (Test 9), Stoppuhr-Bedienleiste kompakt; Browser: jedes Widget schrumpft auf die Tabelle und bleibt bedienbar.
- Rand: Umschalter in fester Leiste unten rechts (page-Tests 1-3), Grid direkt im Container; Browser: erstes Widget 28 px unter der Kopfzeile.
- Ziehen: Karte ist Griff, Overlay-Kopfleiste mit Symbol und Tooltip, `cancel`-Selektor greift fuer Eingabefelder/Knoepfe/Links/`widgetNoDrag` (Tests 6 und 8), `threshold: 3`; `FREE_PLACEMENT_COMPACTOR` mit `preventCollision: true` (Test 7); Browser: Ziehen an der Mitte, kein Drag aus dem Suchfeld, kein Ueberlappen beim Ablegen, Vergroessern stoppt am Nachbarn.
- Web 47/293, API 67/1078, tsc dreimal 0, Lockfile unveraendert, genau 12 Dateien ausserhalb `.planning`, drei Commits mit Scope `quick-260916-dyv`, gepusht, CI-Lauf `success` beobachtet, Abbild-Probe notiert; Handbuch nennt Schalter unten rechts, ganze Kachel ziehbar, Ablegen nur auf freiem Platz, Mindestgroesse = gerade noch bedienbar; SUMMARY mit Durchsicht-Liste, Todos und Changelog-Stichpunkten.
</success_criteria>
<output>
Create `.planning/quick/260916-dyv-dashboard-nachbesserung-mindestgroessen-/260916-dyv-SUMMARY.md` when done
</output>
+118
View File
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts
// (Stift/Haekchen + "Widget hinzufuegen"), Grid direkt im Container p-2,
// kein Abstands-Wrapper (mt-8) und kein Block oben rechts (top-2) mehr.
// Muster: admin/groups/groups-page.test.tsx (next-intl-Mock mit Namensraum).
const messages: Record<string, Record<string, string>> = {
widgets: {
editMode: 'Dashboard bearbeiten',
saveChanges: 'Änderungen speichern',
addWidget: 'Widget hinzufügen',
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
},
};
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
}));
const mockStore = {
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
isEditMode: false,
isDirty: false,
isLoading: false,
error: null as string | null,
setEditMode: vi.fn(),
updateLayouts: vi.fn(),
addWidget: vi.fn(),
removeWidget: vi.fn(),
loadDashboard: vi.fn(),
saveLayout: vi.fn(),
};
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector?: (state: typeof mockStore) => unknown) => {
if (selector) return selector(mockStore);
return mockStore;
},
}));
vi.mock('@/components/dashboard/dashboard-grid', () => ({
DashboardGrid: (p: { isEditMode: boolean }) => (
<div data-testid="dashboard-grid" data-edit={String(p.isEditMode)} />
),
}));
vi.mock('@/components/dashboard/widget-catalog-modal', () => ({
WidgetCatalogModal: () => null,
}));
vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null }));
vi.mock('@/components/dashboard/widgets/search-widget', () => ({ SearchWidget: () => null }));
vi.mock('@/components/dashboard/widgets/calendar-widget', () => ({ CalendarWidget: () => null }));
vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () => null }));
vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null }));
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
vi.mock('@/components/dashboard/widgets/link-widget', () => ({ LinkWidget: () => null }));
beforeEach(() => {
mockStore.isEditMode = false;
mockStore.isLoading = false;
mockStore.error = null;
mockStore.setEditMode = vi.fn();
mockStore.loadDashboard = vi.fn();
});
afterEach(() => {
cleanup();
});
describe('DashboardPage (quick-260916-dyv)', () => {
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' });
const bar = toggle.parentElement as HTMLElement;
expect(bar.classList.contains('fixed')).toBe(true);
expect(bar.classList.contains('bottom-6')).toBe(true);
expect(bar.classList.contains('right-6')).toBe(true);
expect(bar.classList.contains('z-20')).toBe(true);
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
expect(document.querySelector('.mt-8')).toBeNull();
expect(document.querySelector('.top-2')).toBeNull();
const grid = screen.getByTestId('dashboard-grid');
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
expect(grid.getAttribute('data-edit')).toBe('false');
});
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
mockStore.isEditMode = true;
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' });
const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
expect(add.parentElement).toBe(toggle.parentElement);
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
});
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' }));
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
});
});
+20 -27
View File
@@ -67,32 +67,21 @@ export default function DashboardPage() {
return (
<div className="relative p-2">
{/* Edit mode toggle — top right */}
<div className="absolute right-2 top-2 z-10">
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
{/* Dashboard grid.
quick-260916-bwo: mt-8 bleibt bewusst — der Umschalter ist 36 px hoch
(p-2 plus 20-px-Symbol) und liegt mit top-2 bei 8..44 px; das erste
Widget beginnt mit mt-8 bei 8+32+8 = 48 px (4 px Abstand). Eine
Halbierung auf mt-4 legte es bei 32 px mitten in den Umschalter. */}
<div className="mt-8">
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
</div>
{/* Add widget button (edit mode only) */}
{isEditMode && (
<div className="fixed bottom-6 right-6 z-20">
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && (
<button
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
@@ -113,8 +102,12 @@ export default function DashboardPage() {
</svg>
{t('addWidget')}
</button>
</div>
)}
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Widget catalog modal */}
<WidgetCatalogModal
@@ -16,6 +16,7 @@ vi.mock('next-intl', () => ({
addWidget: 'Add widget',
catalogTitle: 'Add widget',
deleteTooltip: 'Remove widget',
dragHint: 'Drag the tile to move it',
'clock.name': 'Clock',
'clock.description': 'Shows the current time',
'search.name': 'Search',
@@ -38,13 +39,15 @@ vi.mock('next-intl', () => ({
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability.
// quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die
// Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind.
// quick-260916-dyv: Mock per importOriginal, damit `noCompactor` das echte
// Objekt ist (Test 7 prueft compact als Identitaets-Kopie), nur Responsive ersetzt.
const captured = vi.hoisted(() => ({ props: null as Record<string, unknown> | null }));
vi.mock('react-grid-layout', () => ({
vi.mock('react-grid-layout', async (importOriginal) => ({
...(await importOriginal<typeof import('react-grid-layout')>()),
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
captured.props = props;
return <div data-testid="responsive-grid">{props.children}</div>;
},
noCompactor: () => null,
}));
// Mock dashboard-api
@@ -178,7 +181,7 @@ describe('DashboardGrid', () => {
expect(captured.props?.containerPadding).toBeUndefined();
});
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die verdoppelten Vorgaben als data-grid', async () => {
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die Vorgaben und die inhaltsgetriebenen Minima als data-grid', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
@@ -196,6 +199,192 @@ describe('DashboardGrid', () => {
const first = children[0];
expect(isValidElement(first)).toBe(true);
const props = (first as React.ReactElement<Record<string, unknown>>).props;
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 });
// quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert.
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
});
it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
{ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 8, minH: 8 },
{ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 },
],
md: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }],
sm: [],
xs: [],
xxs: [],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'inst-1', widgetType: 'clock', config: {} },
{ id: 'inst-x', widgetType: 'unknown', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(Object.keys(passed)).toEqual(Object.keys(layouts));
expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 });
expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
expect(passed.md[0].minW).toBe(2);
// Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01).
expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 });
expect(passed.sm).toEqual([]);
});
it('quick-260916-dyv Test 9b: gespeicherte Groesse unter dem neuen Minimum wird auf das Minimum angehoben (Rechner h 8 -> 9, w bleibt wenn >= minW)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt.
{ i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 },
// Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt.
{ i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 },
],
md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }],
sm: [],
xs: [],
xxs: [],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'calc-1', widgetType: 'calculator', config: {} },
{ id: 'srch-1', widgetType: 'search', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 9, minW: 3, minH: 9 });
expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 });
// Jeder Breakpoint: md-Rechner 3x7 -> 3x9 (w schon = minW, h angehoben).
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 9, minW: 3, minH: 9 });
// Eingabe unveraendert (keine Mutation des Store-Objekts).
expect(layouts.lg[0].h).toBe(8);
expect(layouts.lg[0].minH).toBe(8);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(captured.props?.dragConfig).toEqual({
enabled: true,
handle: '.widget-drag-handle',
cancel: CANCEL,
threshold: 3,
});
expect(captured.props?.resizeConfig).toEqual({ enabled: true });
cleanup();
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(captured.props?.dragConfig).toEqual({
enabled: false,
handle: '.widget-drag-handle',
cancel: CANCEL,
threshold: 3,
});
expect(captured.props?.resizeConfig).toEqual({ enabled: false });
});
it('quick-260916-dyv Test 7: Compactor-Pin — echter noCompactor plus preventCollision: true, compact ist Identitaets-Kopie (freie Platzierung)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-2', widgetType: 'clock', config: {} }]}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const compactor = captured.props?.compactor as {
type: unknown;
allowOverlap: boolean;
preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
};
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function');
const input = [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }];
const out = compactor.compact(input, 24);
// Gemessen (RGL 2.2.3): noCompactor.compact = cloneLayout -> cloneLayoutItem
// kopiert i/x/y/w/h unveraendert und normalisiert moved/static zu false
// (minW/maxW/... als undefined). Keine Verschiebung, keine Kompaktierung.
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ i: 'a', x: 0, y: 0, w: 2, h: 2, moved: false, static: false });
expect(out).not.toBe(input);
expect(out[0]).not.toBe(input[0]);
expect(input[0]).toEqual({ i: 'a', x: 0, y: 0, w: 2, h: 2 });
});
it('quick-260916-dyv Test 8: cancel/handle-Semantik im DOM — Karte ist Griff, Loesch-Knopf/Eingaben/widgetNoDrag passen auf cancel, Kopfleiste als Overlay nur im Bearbeitungsmodus', async () => {
const mod = await import('./dashboard-grid');
const { DashboardGrid, WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR } = mod;
expect(WIDGET_DRAG_HANDLE_SELECTOR).toBe('.widget-drag-handle');
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
expect(card).not.toBeNull();
expect(card.matches(WIDGET_DRAG_HANDLE_SELECTOR)).toBe(true);
// Die Karte selbst darf NICHT auf cancel passen, sonst zieht nichts mehr.
expect(card.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBeNull();
const button = screen.getByLabelText('Remove widget');
expect(button.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(button);
expect(button.hasAttribute('data-no-drag')).toBe(true);
const input = document.createElement('input');
card.appendChild(input);
expect(input.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(input);
const noDrag = document.createElement('div');
noDrag.className = 'widgetNoDrag';
card.appendChild(noDrag);
expect(noDrag.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(noDrag);
const head = screen.getByTitle('Drag the tile to move it');
expect(card.contains(head)).toBe(true);
expect(head.classList.contains('absolute')).toBe(true);
expect(head.classList.contains('h-5')).toBe(true);
cleanup();
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull();
});
});
@@ -1,8 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
@@ -17,14 +17,99 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` (Favoriten/
// Link-Widget, bisher nirgends verdrahtet). react-draggable 4.7.0 prueft
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
// (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter.
// - `threshold: 3` = RGL-Standard (Klick vs. Ziehen).
export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle';
export const WIDGET_DRAG_CANCEL_SELECTOR =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
// quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt.
// `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt:
// Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen
// auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und
// das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und
// ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die
// Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in
// einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885).
// MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort
// (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn.
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
interface LayoutItemShape {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
}
interface DashboardGridProps {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
layouts: Record<string, Array<LayoutItemShape>>;
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => void;
}
/**
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
*
* Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren`
* (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den
* Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest
* `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen
* minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt
* persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer
* bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes
* Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind
* die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht).
*
* Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit
* h 8, neues minH 9), wird w/h auf das Minimum angehoben: RGL selbst klemmt
* w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`,
* chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`,
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*/
function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>,
widgets: Array<{ id: string; widgetType: string }>,
): Record<string, Array<LayoutItemShape>> {
const typeById = new Map<string, string>();
for (const w of widgets) typeById.set(w.id, w.widgetType);
const result: Record<string, Array<LayoutItemShape>> = {};
for (const key of Object.keys(layouts)) {
const entries = layouts[key];
if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => {
const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry };
return {
...entry,
w: Math.max(entry.w, constraints.minW),
h: Math.max(entry.h, constraints.minH),
minW: constraints.minW,
minH: constraints.minH,
};
});
}
return result;
}
/**
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
* Renders widgets in a draggable/resizable grid when in edit mode.
@@ -53,6 +138,11 @@ export function DashboardGrid({
return () => observer.disconnect();
}, []);
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
// Empty state (D-02)
if (widgets.length === 0) {
return (
@@ -77,19 +167,21 @@ export function DashboardGrid({
width={width}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={layouts as ResponsiveLayouts}
layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20}
margin={[8, 8] as [number, number]}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
handle: WIDGET_DRAG_HANDLE_SELECTOR,
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
threshold: 3,
}}
resizeConfig={{
enabled: isEditMode,
}}
compactor={noCompactor}
compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget) => {
@@ -98,7 +190,7 @@ export function DashboardGrid({
<div
key={widget.id}
data-grid={{
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 4,
@@ -10,6 +10,9 @@ interface EditModeToggleProps {
/**
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
* Active state (edit mode) uses primary background.
* Schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv):
* inaktiv mit Karten-Hintergrund, Rahmen und Schatten, damit der Stift
* ueber Widgets sichtbar bleibt.
*/
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets');
@@ -17,10 +20,10 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
return (
<button
onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 transition-colors ${
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
@@ -53,24 +53,26 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
});
it('quick-260916-bwo: jede Groesse ist exakt das Doppelte der alten 12-Spalten-Werte', () => {
// Raster seit quick-260916-bwo 24 Spalten / 20 px statt 12 / 40 — jeder Wert
// ist das Doppelte, damit Widgets optisch gleich gross bleiben.
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
// 3 und 9 sind ungerade — die bwo-Pruefung "jeder Wert ist gerade" entfaellt.
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
});
let counted = 0;
for (const type of ALL_WIDGET_TYPES) {
for (const field of ['minW', 'minH', 'defaultW', 'defaultH'] as const) {
expect(WIDGET_CONSTRAINTS[type][field] % 2, `${type}.${field} muss gerade sein`).toBe(0);
expect(typeof WIDGET_CONSTRAINTS[type][field]).toBe('number');
counted++;
}
}
@@ -32,17 +32,22 @@ export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
// quick-260916-bwo: Raster verdoppelt (24 Spalten / 20 px) — jeder Wert ist das Doppelte des alten 12-Spalten-Werts, Widgets bleiben optisch gleich gross.
// quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im
// 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
// Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 =
// 240 px -> 9 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH =
// altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in
// dashboard-grid.tsx aus dieser Tabelle ueberschrieben.
// Existing widgets
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01)
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
};
/**
@@ -200,14 +200,17 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
</span>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-2 py-2">
{/* Controls
quick-260916-dyv: kompakte Bedienleiste, damit die laufende Stoppuhr
(Stop + Runde + Reset) in 4 Spalten passt (ca. 151 px statt ca. 222 px)
und die Kachel auf 4x3 schrumpfen kann. */}
<div className="flex items-center justify-center gap-1 py-1">
{!isRunning ? (
<button
type="button"
aria-label={t('stopwatch.start')}
onClick={handleStart}
className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.start')}
</button>
@@ -217,7 +220,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button"
aria-label={t('stopwatch.stop')}
onClick={handleStop}
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.stop')}
</button>
@@ -225,7 +228,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button"
aria-label={t('stopwatch.lap')}
onClick={handleLap}
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.lap')}
</button>
@@ -235,7 +238,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button"
aria-label={t('stopwatch.reset')}
onClick={handleReset}
className="rounded-md border border-border px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{t('stopwatch.reset')}
</button>
@@ -15,7 +15,14 @@ interface WidgetWrapperProps {
/**
* Wrapper card for all dashboard widgets.
* In edit mode: shows drag handle (top) and delete button (top-right).
*
* quick-260916-dyv: Im Bearbeitungsmodus ist die GANZE Karte der Griff
* (`widget-drag-handle`, `cursor-grab`). Eine 20 px hohe Kopfleiste mit
* Griff-Symbol liegt als Overlay ueber dem oberen Kachelrand — nur optischer
* Hinweis (Tooltip `widgets.dragHint`), kein eigener Griff. Der Loesch-Knopf
* sitzt rechts in dieser Kopfleiste und traegt `data-no-drag`; welche Ziele
* KEIN Ziehen starten (Eingabefelder, Knoepfe, Links, [data-no-drag],
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
@@ -23,46 +30,69 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
const cardClassName = isEditMode
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg border border-primary/40 bg-card shadow-sm active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
return (
<div
className="relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm"
className={cardClassName}
data-widget-id={widget.id}
role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: drag handle */}
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
Bewusst `absolute` und NICHT im Fluss: der Rumpf bleibt h-full, die
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && (
<div className="widget-drag-handle flex h-[6px] w-full cursor-grab items-center justify-center bg-muted active:cursor-grabbing">
<div className="h-[2px] w-8 rounded-full bg-muted-foreground/40" />
</div>
)}
{/* Edit mode: delete button */}
{isEditMode && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
<div
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70"
title={t('dragHint')}
data-testid="widget-drag-head"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="currentColor"
className="text-muted-foreground"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
<circle cx="5" cy="9" r="1.5" />
<circle cx="12" cy="9" r="1.5" />
<circle cx="19" cy="9" r="1.5" />
<circle cx="5" cy="15" r="1.5" />
<circle cx="12" cy="15" r="1.5" />
<circle cx="19" cy="15" r="1.5" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-0.5 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
data-no-drag=""
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
{/* Widget body.
@@ -70,8 +100,10 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
damit die Widget-Inhalte per cqw/cqh mit der Kachel skalieren. Braucht eine
definite Hoehe — die kommt ueber h-full aus der Karte, die das RGL-Element
mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte
im Bearbeitungsmodus zusaetzlich den Griff traegt. */}
<div className={`@container-size h-full ${isEditMode ? 'pt-0' : ''}`}>
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="@container-size h-full">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Widget hinzufügen",
"catalogTitle": "Widget hinzufügen",
"deleteTooltip": "Widget entfernen",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern",
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Add widget",
"catalogTitle": "Add widget",
"deleteTooltip": "Remove widget",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard",
"saveChanges": "Save changes",
"layoutLoadError": "Could not load dashboard. Please reload the page.",
+4 -4
View File
@@ -58,11 +58,11 @@ Unten in der Seitenleiste finden Sie die Sprachumschaltung (Deutsch/English) sow
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets**. Ist noch kein Widget platziert, sehen Sie nur das Tessera-Symbol mit dem Hinweis „Keine Widgets aktiv".
**Widgets hinzufügen und anordnen:** Oben rechts auf dem Dashboard befindet sich der Schalter **„Dashboard bearbeiten"**. Sobald der Bearbeitungsmodus aktiv ist:
**Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist:
- Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu.
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben.
- Können Sie Widgets an der Ecke in der Größe ziehen (jeder Widget-Typ hat eine Mindestgröße, damit der Inhalt lesbar bleibt). Position und Größe rasten dabei in feinen Schritten ein, sodass sich auch kleine Anpassungen vornehmen lassen.
- Erscheint an jedem Widget ein Symbol zum Entfernen.
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.
- Können Sie Widgets an der Ecke in der Größe ziehen (jeder Widget-Typ hat eine Mindestgröße, bei der er gerade noch bedienbar bleibt — kleiner geht es nicht, größer jederzeit). Position und Größe rasten dabei in feinen Schritten ein, sodass sich auch kleine Anpassungen vornehmen lassen.
- Erscheint an jedem Widget rechts im Griff ein Symbol zum Entfernen.
Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlassen Sie den Bearbeitungsmodus, ist das Dashboard wieder fest — Verschieben und Größenänderung sind dann gesperrt, damit Sie es im normalen Gebrauch nicht versehentlich verstellen.