Compare commits
30 Commits
v1.1.0
...
7110512d83
| Author | SHA1 | Date | |
|---|---|---|---|
| 7110512d83 | |||
| 7429c5bd8d | |||
| 4ddadc63ab | |||
| 45b20a9fd4 | |||
| f79c6bbe8c | |||
| c85cf9a47b | |||
| 1e4ec30190 | |||
| 5313fbc019 | |||
| 6003f21431 | |||
| a6bb7aa88e | |||
| 4c2495bb64 | |||
| b16e4b8e67 | |||
| 4f823c3e66 | |||
| 48db8e2408 | |||
| 39ea1474a5 | |||
| 7f1ee3b1f0 | |||
| 684f063a8e | |||
| 3c890afd92 | |||
| 5d5d4ac2a7 | |||
| 6d8c7c46a8 | |||
| 61996dceef | |||
| 0858102439 | |||
| 785c791dd4 | |||
| a60c168587 | |||
| 9439c33989 | |||
| 2306a6dee1 | |||
| 618fbd6845 | |||
| a5f30d432d | |||
| 2a820b630c | |||
| 29fe3d772a |
@@ -0,0 +1,87 @@
|
||||
---
|
||||
context: default
|
||||
phase: betrieb-nach-live-gehen
|
||||
task: null
|
||||
total_tasks: 0
|
||||
status: paused
|
||||
last_updated: 2026-09-16T10:28:05.211Z
|
||||
---
|
||||
|
||||
# Wiedereinstieg — v1.1.0 ist live, nichts angefangen
|
||||
|
||||
## Critical Anti-Patterns
|
||||
|
||||
Alle aus tatsaechlichen Fehlschlaegen dieser und der vorigen Sitzungen.
|
||||
|
||||
| Muster | Beschreibung | Schwere | Vermeidung |
|
||||
|--------|--------------|---------|------------|
|
||||
| Zaehlung ohne Ansehen | Der Planer zaehlte fuenf Tastenreihen im Rechner, es sind sechs — die Mindesthoehe schnitt die unterste Reihe ab. Erst der Browser-Blick fand es. | blocking | Zahlen, die eine Groesse tragen, im Browser messen (Bounding-Box, scrollHeight), nicht aus dem Quelltext ableiten. |
|
||||
| Gespeicherte Werte vergessen | Konstanten zu aendern haette fuer den User NICHTS bewirkt — react-grid-layout nimmt gespeicherte Layout-Eintraege woertlich (inkl. minW/minH). | blocking | Bei jeder Aenderung an Vorgaben pruefen, ob persistierte Daten dieselben Werte tragen; dann beim Laden ueberschreiben/anheben. |
|
||||
| `git checkout -- <Datei>` als Falsifizierungs-Rueckweg | setzte auch die noch unkommittierte Nutz-Aenderung zurueck. | advisory | Vor Rueckbau-Proben committen, oder Patchdatei + `git apply -R`. |
|
||||
| Zwei Schreiber auf einer Datei | Uebersetzungs-Fix musste warten, weil der laufende Executor de.json/en.json anfasste. | advisory | Quick-Tasks mit ueberlappenden Dateien nacheinander ausfuehren. |
|
||||
| Platte voll durch Bau-Cache | vier Docker-Bauten + CI: `no space left on device`. | advisory | `docker builder prune -af` und `docker image prune -f` bei Bedarf; Volumes nie anfassen. |
|
||||
| Bericht statt Arbeitsbaum | (aus Etappe 2) Agenten brachen nach getaner Arbeit ab. | blocking | `git status` ist die Wahrheit. |
|
||||
|
||||
<current_state>
|
||||
**Gemessen 2026-09-16 10:28Z:** `git status --porcelain` leer, `main == origin/main`
|
||||
(29fe3d7), keine async-jobs, keine angefangene Arbeit.
|
||||
|
||||
**Live:** tessera.ctl.de laeuft `v1.1.0` (User hat gepullt, "sieht gut aus").
|
||||
**Beta:** alpha.tessera.ctl.de holt `beta`/`latest` — derselbe Stand e0d4532.
|
||||
**Registry:** beta/latest/live/v1.0.0/v1.1.0. **Gitea-Releases:** 1.0.0 und 1.1.0
|
||||
(letzterer von der Pipeline angelegt — erster CI-Beweis des Release-Wegs).
|
||||
**Schalter:** AUS und bleibt es. **Mandantenfaehigkeit:** RUHT (User 2026-09-14).
|
||||
**Tests:** Web 49/309, API 67/1078, Werkzeug 253/253. **Ledger:** 15 offen / 1
|
||||
zurueckgestellt / 23 geschlossen / 39.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
Seit 2026-09-14: WINDOWS #29, Etappe 3c, Kanalmodell + Versionsstempel,
|
||||
Fehler-melden-Knopf, Erstfreigabe v1.0.0 (live 15.09.), Dashboard-Umbau +
|
||||
Nachbesserung, Aenderungsliste (CHANGELOG.md, Seite "Was ist neu",
|
||||
Gitea-Release je Tag), Uebersetzungs-Fix, Freigabe v1.1.0. Alle als Quick-Tasks
|
||||
mit voller Kette; Details in der Quick-Task-Tabelle in `.planning/STATE.md`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
Nichts Angefangenes. Naechste Arbeit kommt vom User (Feedback aus dem Betrieb).
|
||||
Ohne Termin: Desktop-Client-Todo, Ship Phase 17 (blockiert bei open_count 15),
|
||||
Ledger #35/#36/#37. Nicht ansprechen: Mandantenfaehigkeit, Lizenzierung.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
Siehe `HANDOFF.json` — Kanalmodell, Versionsnummern-Regel (Funktionen -> mittlere
|
||||
Stelle, Fixes -> dritte), Mandantenfaehigkeit ruht, Lizenzmodell nur festgehalten,
|
||||
Dashboard-Entscheidungen (inhaltsgetriebene Minima, Ueberschreiben gespeicherter
|
||||
Minima, ganze Kachel Griff, preventCollision).
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
Keine. Eine nicht-blockierende Handreichung fuer den User: auf alpha einmalig
|
||||
`IMAGE_TAG=beta` und die zwei `image:`-Zeilen (Kap. 9) — bis dahin laeuft alpha
|
||||
ueber `latest`, das dasselbe Abbild ist.
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
|
||||
1. `.planning/STATE.md` — Session Continuity + Quick-Task-Tabelle
|
||||
2. `CHANGELOG.md` — Regel: jede Aenderung sofort unter `## Unveröffentlicht`
|
||||
3. `docs/anleitung-betrieb.md` Kapitel 9 — Freigabe/Hotfix-Rezept
|
||||
4. `.planning/WINDOWS.md` — 15 offen, davon nur #35/#36/#37 ohne Mandantenbezug
|
||||
|
||||
## Infrastructure State
|
||||
|
||||
- Live-Server tessera.ctl.de (`IMAGE_TAG=live`, eigene DB); alpha 192.168.13.12
|
||||
(Beta); Deploy macht der User (pull + `up -d --force-recreate api web`).
|
||||
- Lokal: `db`, `api`, `web`, `mailhog` laufen (web aus c3d8e16); Admin
|
||||
admin/admin123; DB ohne Host-Port (IP per `docker inspect`, tessera:tessera_dev);
|
||||
Prisma-Binary aus `apps/api/node_modules/.bin/prisma`.
|
||||
- Gitea 1.26.2 + Runner auf diesem Rechner; Runner arbeitet EINEN Auftrag
|
||||
gleichzeitig; CI ueber API localhost:3002 beobachtbar (Token aus Push-URL, nie
|
||||
ausgeben). `grep` ist hier ugrep (`$` als Anker).
|
||||
|
||||
<next_action>
|
||||
`/gsd-resume-work`, dann das, was der User nennt — als `/gsd-quick --validate`
|
||||
mit voller Kette; Browser-Nachweis per Playwright MCP gegen lokale Container;
|
||||
CHANGELOG pflegen; bei "Version freigeben" das Rezept aus Kap. 9.
|
||||
</next_action>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-09-16T10:28:05.211Z",
|
||||
"phase": null,
|
||||
"phase_name": "Betrieb nach dem Live-Gehen — Quick-Tasks auf Zuruf des Users (kein laufender Meilenstein)",
|
||||
"phase_dir": null,
|
||||
"plan": null,
|
||||
"task": null,
|
||||
"total_tasks": 0,
|
||||
"status": "paused",
|
||||
"completed_tasks": [
|
||||
{"id": 1, "name": "WINDOWS #29 Rechteausweitung ADMIN -> SUPER_ADMIN geschlossen (260914-ebg)", "status": "done", "commit": "759ea3b"},
|
||||
{"id": 2, "name": "Etappe 3c Systemkontext forSystem() fuer Hintergrunddienste (260914-eym)", "status": "done", "commit": "939c812"},
|
||||
{"id": 3, "name": "Zwei Auslieferungskanaele beta/live, Versionsstempel, Versionsanzeige (260914-ku1)", "status": "done", "commit": "ea6aa99"},
|
||||
{"id": 4, "name": "Fehler-melden-Knopf mit Bildschirmfoto per E-Mail (260914-m97)", "status": "done", "commit": "77117de"},
|
||||
{"id": 5, "name": "Erstfreigabe v1.0.0, Live-Gehen tessera.ctl.de am 2026-09-15 (User)", "status": "done", "commit": "e509860"},
|
||||
{"id": 6, "name": "Dashboard: feineres Raster, skalierende Widget-Inhalte, halbe Abstaende (260916-bwo)", "status": "done", "commit": "1aefaa3"},
|
||||
{"id": 7, "name": "Dashboard-Nachbesserung: Mindestgroessen, Rand oben, Drag & Drop (260916-dyv, Rechner-Minimum 8792819)", "status": "done", "commit": "cf97b5b"},
|
||||
{"id": 8, "name": "Aenderungsliste: CHANGELOG.md, Seite Was ist neu, Gitea-Release je Tag (260916-dcz)", "status": "done", "commit": "c5f4ade"},
|
||||
{"id": 9, "name": "21 fehlende Uebersetzungen (Kalenderquellen-Formular, Kalender-Einstellungen, Marktplatz)", "status": "done", "commit": "c3d8e16"},
|
||||
{"id": 10, "name": "Version 1.1.0 freigegeben (Tag v1.1.0, live-Abbilder, Gitea-Release von der Pipeline); User hat Live gepullt: 'sieht gut aus'", "status": "done", "commit": "e0d4532"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": 11, "name": "Nichts Angefangenes. Naechste Arbeit kommt vom User (Feedback aus dem Betrieb; Fehlermeldungen landen per Knopf im Postfach aus Administrator -> SMTP). Jede Aenderung SOFORT in CHANGELOG.md unter '## Unveröffentlicht' eintragen.", "status": "not_started"},
|
||||
{"id": 12, "name": "Todo ohne Termin: Desktop-Client auslieferungsreif (Versions-Check, Windows-Installer, Abnahme, CI-Bau) — erst wenn der User die Bau-/Testmoeglichkeit hat (.planning/todos/pending/2026-09-15-desktop-client-auslieferungsreif-machen.md)", "status": "not_started"},
|
||||
{"id": 13, "name": "Formal: /gsd-ship fuer Phase 17 (v1.2) nie gelaufen; windows_enforce blockiert bei open_count 15 — keine Dringlichkeit", "status": "not_started"},
|
||||
{"id": 14, "name": "Ledger ohne Dringlichkeit: #35 Biome-Konfiguration im Bestand nicht lauffaehig (pnpm lint = Leerlauf), #36 Admin-Frontend verschluckt 403 still, #37 DKV Single-Flight prozessweit; die uebrigen 12 offenen Eintraege haengen am ruhenden Mandantenthema", "status": "not_started"}
|
||||
],
|
||||
"blockers": [],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [
|
||||
{"action": "Auf alpha (Beta) einmalig IMAGE_TAG=beta in /opt/tessera/.env und die zwei image:-Zeilen auf ${IMAGE_TAG:-beta} bringen (Kap. 9) — solange nicht geschehen, laeuft alpha ueber 'latest', das dasselbe Abbild wie 'beta' ist; nichts kaputt, nur unsauber", "context": "docs/anleitung-betrieb.md Kapitel 9, Abschnitt 'Die eine Zeile je Server'", "blocking": false}
|
||||
],
|
||||
"decisions": [
|
||||
{"decision": "Mandantenfaehigkeit RUHT — 3a und Etappe 4 nicht weiterverfolgen, Thema nicht ansprechen; Ein-Firmen-Betrieb reicht; evtl. spaeter ein Container je Kunde", "rationale": "User 2026-09-14, veraergert ueber den Zeitaufwand; Memory feedback-mandantenfaehigkeit-nicht-ansprechen", "phase": "Betrieb"},
|
||||
{"decision": "Kanalmodell: main = beta (alpha.tessera.ctl.de), Zweig live + Tag vX.Y.Z = live (tessera.ctl.de); IMAGE_TAG je Server", "rationale": "User will Live und Beta getrennt, Hotfixes ohne Beta-Neuerungen; Rezept Kap. 9", "phase": "260914-ku1"},
|
||||
{"decision": "Versionsnummer entscheide ich: neue Funktionen -> mittlere Stelle (1.1.0), nur Fehlerbehebungen -> dritte Stelle (1.0.1)", "rationale": "User 2026-09-16: 'du entscheidest, ich habe keine Ahnung'", "phase": "Freigabe 1.1.0"},
|
||||
{"decision": "Lizenzmodell (Betreiber gibt Modul je Server mit Lizenzanzahl frei, Firmenadmin lizenziert an bis zu N Benutzer) nur festgehalten, nicht bauen, nicht ansprechen", "rationale": "User 2026-09-14; Zettel .planning/todos/pending/2026-09-14-lizenzmodell-freigabe-je-server-mit-lizenzanzahl.md", "phase": "Betrieb"},
|
||||
{"decision": "Dashboard: Mindestgroessen inhaltsgetrieben statt verdoppelt; gespeicherte Minima beim Rendern ueberschrieben; ganze Kachel Griff; preventCollision mit noCompactor", "rationale": "User-Test auf alpha 2026-09-16 ('nicht klein genug', 'Drag and Drop semi'); Messungen in 260916-dyv", "phase": "260916-dyv"}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"next_action": "/gsd-resume-work; dann das aufgreifen, was der User aus dem Betrieb von v1.1.0 mitbringt. Vor jeder Aenderung: CHANGELOG.md '## Unveröffentlicht' pflegen. Bei 'Version freigeben': Unveroeffentlicht -> X.Y.Z – Datum, neues leeres Unveroeffentlicht, Commit+Push, dann git checkout live && git merge --ff-only main && git tag -a vX.Y.Z -m 'Tessera X.Y.Z' && git push origin live vX.Y.Z — die Pipeline baut live/vX.Y.Z und legt den Gitea-Release an.",
|
||||
"context_notes": "Gemessen 2026-09-16 10:28Z: git status --porcelain leer, main == origin/main (29fe3d7), keine async-jobs. LIVE: tessera.ctl.de auf v1.1.0 (User hat gepullt, 'sieht gut aus'); BETA: alpha.tessera.ctl.de holt 'beta'/'latest' (= v1.1.0 Stand, e0d4532). Registry: beta/latest/live/v1.0.0/v1.1.0. Gitea-Releases: v1.0.0 (manuell per Skript), v1.1.0 (von der Pipeline — erster CI-Beweis des Release-Wegs). DER SCHALTER IST AUS (DATABASE_URL auf BYPASSRLS-Rolle) und bleibt es. Endstand Tests: Web 49 Dateien / 309 Tests, API 67 / 1078; rls-scratch-check 253/253; Ledger 15 offen / 1 zurueckgestellt / 23 geschlossen / 39 gesamt. Arbeitsweise, die sich bewaehrt hat: /gsd-quick --validate mit voller Kette (Planer opus, Pruefer, Executor opus bei >10 Dateien, Verifizierer) — der Pruefer fing in dieser Sitzung u. a. ein fehlendes Clamp bei angehobenen Minima, ich selbst im Browser das abgeschnittene Rechner-Minimum (Planer zaehlte 5 statt 6 Tastenreihen). Browser-Nachweise mache ich selbst per Playwright MCP gegen lokale Container (docker compose up -d --build web; DB-Container ohne Host-Port; Admin admin/admin123 lokal; mailhog aus docker-compose.dev.yml fuer Mail-Proben). Fallen: Platte lief einmal voll (docker builder prune half); der Gitea-Runner arbeitet nur einen Auftrag gleichzeitig (andere Repos blockieren die Warteschlange); auf diesem Host ist grep = ugrep ($ wirkt als Anker); .dockerignore schliesst *.md aus (CHANGELOG.md per !-Ausnahme); Next.js bettet NEXT_PUBLIC_* nur bei woertlichem Zugriff ein; jsdom verwirft clamp() im Inline-Style; git checkout -- <Datei> setzt auch unkommittierte Aenderungen zurueck. User-Praeferenzen: Deutsch, duzen, Alltagssprache, keine technischen Fragen, nur Produktfragen; App-Texte siezen. Diese Sitzung endete bei ~70% Kontext — deshalb pausiert."
|
||||
}
|
||||
+12
-6
@@ -4,8 +4,8 @@ milestone: v1.2
|
||||
current_phase: 17
|
||||
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
|
||||
status: verified
|
||||
stopped_at: Quick 260916-dcz ausgefuehrt (3/3 Tasks, CI 356 success, Release Tessera 1.0.0 id 1) — Browser-Nachweis durch Orchestrator offen
|
||||
last_updated: "2026-09-16T09:32:51.778Z"
|
||||
stopped_at: "2026-09-16: Version 1.1.0 freigegeben (Tag v1.1.0, live-Abbilder, Gitea-Release von der Pipeline); Beta = main; Mandantenfaehigkeit ruht; Schalter AUS"
|
||||
last_updated: "2026-09-16T10:15:09.000Z"
|
||||
last_activity: 2026-09-16
|
||||
last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent
|
||||
state_head: c5f4adeeed4bc858171323a5b4c3866ee991f560
|
||||
@@ -410,6 +410,12 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260916-bwo | **Dashboard — feineres Raster, skalierende Widget-Inhalte, halbe Abstaende.** Raster verdoppelt (COLS 24/20/12/8/2, rowHeight 20, margin 8; WIDGET_CONSTRAINTS x2), gespeicherte Anordnungen einmalig x2 mit Marker `__gridVersion: 2` (nur im JSON, `migrateGridLayouts`/`withGridVersion`, idempotent). Widget-Rumpf `container-type: size`; Uhr/Stoppuhr/Rechner skalieren per Container-Queries; Uhr mit `timeFontSizePt` (leer = automatisch, 8..200 = fest in pt) und Feld in Einstellungen -> Dashboard -> Widgets. Abstaende halbiert: `app-shell` p-6 -> p-3 (alle Seiten, User-Nachtrag), Dashboard p-2, Grid 8 px, Widget-Innenabstaende; `mt-8` bleibt (Umschalter-Hoehe). Anwenderhandbuch. Tests Web 260 -> 286 / 46 Dateien, API 1076 -> 1078, tsc 0, 29 Dateien gegen 5f50c5f, vier Commits, CI-Lauf 351 gruen, Beta-Abbild `v1.0.0-10-g1aefaa3`. Browser-Beweis durch den Orchestrator: SQL-Probe in alten Einheiten -> DB verdoppelt + Marker; Rand 28 px (vorher 56), Abstand 8 px (vorher 16), main 12 px; Uhr 51 px -> 107 px beim Vergroessern; 36 pt = 48 px fest. Verifiziert 8/8 + Browser, gepusht. | 2026-09-16 | 3f5afb0,2d8efe1,a175c00,1aefaa3 | [260916-bwo-dashboard-feineres-raster-spalten-und-ze](./quick/260916-bwo-dashboard-feineres-raster-spalten-und-ze/) |
|
||||
| 260916-dyv | **Dashboard-Nachbesserung nach User-Test.** Mindestgroessen inhaltsgetrieben (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/10 — vom Orchestrator im Browser von 9 auf 10 korrigiert, sechs Tastenreihen —, favorites 3/3, link 3/2, stopwatch 4/3 mit kompakter Bedienleiste), gespeicherte Layout-Eintraege bekommen minW/minH aus den Konstanten und zu kleine w/h werden angehoben (`applyConstraintMinima`, Test 9/9b). Bearbeiten-Schalter in feste Leiste unten rechts, `mt-8` weg: Rand oben 28 px statt 60. Drag & Drop: ganze Kachel als Griff mit Overlay-Kopfleiste, `dragConfig.cancel` (Eingaben, Knoepfe, .widgetNoDrag, Resize-Griff), `preventCollision: true` mit `noCompactor` (Ablegen auf belegtem Raum stoppt am Nachbarn, kein Ueberlappen). Anwenderhandbuch. Tests Web 286 -> 294 / 47 Dateien, API 67/1078, tsc 0, 12 Dateien gegen df16f46 + Fix 8792819; CI-Laeufe 353 und der Fix-Lauf gruen. Browser-Beweis: 28 px, Uhr 126x48, Ziehen an Kachelmitte, Suchfeld ohne Drag, Kollision stoppt, Rechner 272 px ohne Ueberlauf. Ledger #39 fixed. Verifiziert 6/6 + Browser, gepusht. | 2026-09-16 | dc992c9,dbbd54f,cf97b5b,8792819 | [260916-dyv-dashboard-nachbesserung-mindestgroessen-](./quick/260916-dyv-dashboard-nachbesserung-mindestgroessen-/) |
|
||||
| 260916-dcz | **Aenderungsliste.** `CHANGELOG.md` (Keep-a-Changelog, Alltagssprache, echte Umlaute: `## Unveröffentlicht` mit Neu/Geändert/Behoben, `## 1.0.0 – 2026-09-15` mit 11 Punkten); Seite "Was ist neu" unter `/changelog` (Server-Komponente, Text zur Bauzeit ueber `env.TESSERA_CHANGELOG_MD` in next.config.ts, nur im Server-Bundle; Kanalfilter `filterChangelogForChannel`: live ohne Unveroeffentlicht, beta/dev markiert; `MDEditor.Markdown` + `rehypeSanitize`), Versionsabzeichen als Link; Dockerfile `COPY CHANGELOG.md` + `.dockerignore !CHANGELOG.md`; `.gitea/scripts/publish-release.sh` (awk-Abschnitt, jq, API-Basis aus GITHUB_*, POST/PATCH, --dry-run, Exit 1 ohne Abschnitt) + ci.yml-Schritt nur bei Tag-Refs; Gitea-Release `v1.0.0` rueckwirkend angelegt (id 1); Handbuecher (Betrieb Kap. 9, Anwender "Was ist neu", Entwicklung, CI). Tests Web 294 -> 309 / 49 Dateien, API 67/1078, tsc 0, 19 Dateien gegen 963fa36; CI 356/357 gruen. Nachtrag c3d8e16: 21 fehlende Uebersetzungen (Kalenderquellen-Formular, Kalender-Einstellungen, Marktplatz) in de/en, Changelog "Behoben". Browser-Beweis: /changelog mit 20 Punkten, Kalender-Formular ohne Schluesselnamen. Verifiziert 8/8 + Browser, gepusht. | 2026-09-16 | ba06db9,6940bd0,c5f4ade,c3d8e16 | [260916-dcz-aenderungsliste-changelog-md-in-alltagss](./quick/260916-dcz-aenderungsliste-changelog-md-in-alltagss/) |
|
||||
| 260916-hiv | **Kalenderquellen-Formular: URL-Platzhalter je Typ + EWS-Hinweis.** Adressfeld zeigt je nach Typ ein Beispiel (Exchange EWS `https://mail.firma.de/EWS/Exchange.asmx`, Graph, CalDAV, ICS) statt fix `https://`; bei Exchange EWS grauer Hinweis unter dem Feld (vollstaendige Adresse inkl. /EWS/Exchange.asmx noetig). 5 i18n-Schluessel de/en, neuer Komponententest (6 Faelle), Changelog "Geändert". Ausloeser: User scheiterte mit blossem Hostnamen `owa.ctl.de`, curl bestaetigte 401 + NTLM auf `/EWS/Exchange.asmx`. Tests Web 309 -> 315 / 50 Dateien, tsc 0. | 2026-09-16 | 618fbd6,2306a6d,9439c33 | [260916-hiv-kalenderquellen-formular-url-platzhalter](./quick/260916-hiv-kalenderquellen-formular-url-platzhalter/) |
|
||||
| 260916-htc | **Kalender-Widget nach Vorbild personal-dashboard.** Monatsraster (Zurueck/Monat/Weiter, Mo-So, 42 Zellen ab Montag, heute hervorgehoben, Zaehler-Plakette je Tag, Termine beim Ueberfahren als Tooltip per `createPortal`/`position: fixed`, weil die Kachel `overflow-hidden` ist) + Block "Naechste Termine" (Datum/Uhrzeit, Titel, Ort, Farbpunkt). Drei Einstellungen unter Einstellungen -> Dashboard -> Widgets (`CalendarConfig`, Muster ClockConfig): `showMonth` (Standard an), `maxEvents` 0..10 (Standard 3, 0 = ausblenden), `lookaheadDays` 7/14/30/60/90 (Standard 30). Ein `fetchEvents(from,to)`-Aufruf je Ladevorgang mit lokalen Tagesgrenzen (Backend-Cache-Schluessel bleibt stabil), Neuladen bei Monatswechsel, 5-Minuten-Intervall bleibt. Neues reines Modul `calendar-month.ts` (resolveCalendarConfig, buildCalendarDays, groupEventsByDate, computeFetchWindow, selectUpcomingEvents). Mindestgroesse calendar 6x8 (Registry-Test mitgezogen). Keine Quellenauswahl pro Widget (User-Entscheidung: nur Optik). 16 i18n-Schluessel de/en, Changelog "Geändert", Anwenderhandbuch. Tests Web 315 -> 332 / 51 Dateien, tsc 0, 12 Dateien. | 2026-09-16 | 0858102,61996dc,6d8c7c4 | [260916-htc-kalender-widget-nach-vorbild-personal-da](./quick/260916-htc-kalender-widget-nach-vorbild-personal-da/) |
|
||||
| 260916-iex | **Dashboard-Widgets: Notiz-Haekchen, Favoriten-Titel, Link-Widget entfernt.** (1) Notiz-Widget: Aufgabenlisten (`- [ ]`/`- [x]`) in der Ansicht direkt abhakbar — `previewOptions.components.input` ersetzt das von rehypeSanitize erzwungene `disabled`-Kaestchen durch `NoteCheckbox` (greift NACH Sanitize, per Spike bestaetigt), delegierter Klick auf dem Vorschau-Container, Index = Reihenfolge der Kaestchen, `toggleTaskLine` kippt genau diese Zeile (strenge Regex, Code-Zaeune uebersprungen), Sofort-Speichern. (2) Favoriten-Widget: `config.title` optional — Kopfzeile im Notiz-Look nur bei Titel, im Bearbeitungsmodus Textfeld (`widgetNoDrag`, 1500 ms entprellt), `FavoritesConfig` + "— {title}" im Einstellungsfeld, NoteConfig-Beschriftung uebersetzt. (3) Link-Widget restlos entfernt: Registry/Union/Constraints/Katalog/page.tsx, `widgets.link` de/en, API-DTO, Migration `20260916120000_remove_link_widget` (`DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link'`, FavoriteLink kaskadiert, Migrationsrolle tessera = Superuser/BYPASSRLS), `widget-wrapper.test.tsx` (unbekannter Typ -> grauer Text), Handbuch, Changelog. Tests Web 332 -> 344 / 52 Dateien, API dashboard 31 gruen, tsc Web+API 0. | 2026-09-16 | 684f063,7f1ee3b,39ea147 | [260916-iex-dashboard-widgets-notiz-haekchen-in-der-](./quick/260916-iex-dashboard-widgets-notiz-haekchen-in-der-/) |
|
||||
| 260916-j4f | **Nachtraege nach Browser-Pruefung.** Kalender-Tooltip bricht lange Termintitel um (`break-words` + `min-w-0`, Breite/Klemmung aus `TOOLTIP_WIDTH_PX = 288`); Notiz-Widget nimmt `data-color-mode` aus `useTheme().resolvedTheme` mit mounted-Guard (Muster changelog-view) statt `auto` — Textbereich blieb bei OS-dunkel/Tessera-hell dunkel (User-Meldung); CHANGELOG.md auf kurze Stichpunkte gestrafft (User: "Kein Fliesstext"), alle drei Abschnitte, Ueberschriften unveraendert, `publish-release.sh --dry-run --tag v1.1.0` Exit 0. Tests Web 344 -> 347 / 52 Dateien, tsc 0. | 2026-09-16 | b16e4b8,4c2495b,a6bb7aa | [260916-j4f-nachtraege-kalender-tooltip-umbrechen-no](./quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/) |
|
||||
| 260916-jvj | **Kalender-Plaketten in Kalenderfarbe + Aufzaehlungspunkte in Markdown-Ansichten.** Tages-Plakette nimmt `day.events[0].color` (groupEventsByDate sortiert jetzt je Tag nach Start) als Inline-Hintergrund mit weisser Schrift, ohne Farbe unveraendert `bg-primary`; Farbpunkt je Tooltip-Zeile. Tailwind-v4-Preflight entfernt `list-style` global, markdown.css setzt es nicht zurueck -> Vier-Regeln-Block am Ende von `globals.css` (`.wmde-markdown ul/ol`, Abhak-Listen bleiben ohne Punkt) fuer "Was ist neu" und Notiz-Ansicht. Changelog. Tests Web 347 -> 350 / 52 Dateien, tsc 0. | 2026-09-16 | 1e4ec30,c85cf9a | [260916-jvj-kalender-plaketten-in-kalenderfarbe-stat](./quick/260916-jvj-kalender-plaketten-in-kalenderfarbe-stat/) |
|
||||
| 260916-k2z | **Kalender-Widget: mehrere Kalender am selben Tag als kleine Kreise.** Neue reine Helfer `groupDayBySource` (nach `sourceId`, Reihenfolge des ersten Auftretens, Farbe = erster Termin der Gruppe) und `buildDayBadges(events, max=3)`: 1 Quelle = bisherige Einzelplakette (unveraendert, Tests 3/3b/3c gruen), 2-3 Quellen = kleine Kreise je Farbe mit eigener Anzahl, >3 = zwei Kreise + grauer Restkreis (`bg-muted-foreground text-background`, Summe; testid `calendar-day-count-rest`), Wrapper `calendar-day-badges`. Changelog-Stichpunkt erweitert. Tests Web 350 -> 354 / 52 Dateien, tsc 0. | 2026-09-16 | 4ddadc6,7429c5b | [260916-k2z-kalender-widget-mehrere-kalender-am-selb](./quick/260916-k2z-kalender-widget-mehrere-kalender-am-selb/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -451,8 +457,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-16T09:32:51.449Z
|
||||
Last session: 2026-09-16T10:15:09.000Z
|
||||
Resumed: 2026-09-14 — Sitzung ueber /gsd-resume-work fortgesetzt; #29 und 3c als /gsd-quick --validate mit voller Kette durchgefuehrt.
|
||||
Stopped at: **2026-09-16, drei Quick-Tasks + Nachtrag: Dashboard-Umbau (260916-bwo), Dashboard-Nachbesserung (260916-dyv), Aenderungsliste (260916-dcz), fehlende Uebersetzungen (c3d8e16).** Alles verifiziert, im Browser bewiesen, gepusht; Beta-Abbild `c3d8e16` in der Registry, alpha holt es per pull. Live bleibt v1.0.0. NAECHSTER SCHRITT auf Zuruf des Users ("Version freigeben"): CHANGELOG.md `## Unveröffentlicht` -> `## 1.1.0 – <Datum>` + neues leeres Unveroeffentlicht, `git checkout live && git merge --ff-only main && git tag -a v1.1.0 -m "Tessera 1.1.0" && git push origin live v1.1.0` (Rezept docs/anleitung-betrieb.md Kap. 9; die Pipeline legt den Gitea-Release an — erster echter CI-Beweis des Release-Wegs). Offen ohne Dringlichkeit: Desktop-Client-Todo, Ship Phase 17, Ledger #35/#36/#37. Mandantenfaehigkeit RUHT. Schalter AUS. Einstieg: `/gsd-resume-work`.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-16 - Completed quick task 260916-dcz: Aenderungsliste — CHANGELOG.md, Seite Was ist neu, Gitea-Release
|
||||
Stopped at: **VERSION 1.1.0 FREIGEGEBEN — 2026-09-16.** CHANGELOG `Unveröffentlicht` -> `1.1.0 – 2026-09-16` (e0d4532), `live` per ff-merge auf e0d4532, Tag `v1.1.0` gepusht; CI (Beta, live-Pruefung, Tag) gruen; Registry traegt `live`/`v1.1.0` (Stempel `v1.1.0 live e0d4532`); **erster echter CI-Beweis des Release-Wegs: Gitea-Release "Tessera 1.1.0" von der Pipeline angelegt** (Body 2146 Zeichen). Der User spielt 1.1.0 auf tessera.ctl.de ein (pull + up -d --force-recreate api web). Inhalt: Dashboard-Umbau + Nachbesserung, Seite "Was ist neu", Uebersetzungs-Fix. Offen ohne Dringlichkeit: Desktop-Client-Todo, Ship Phase 17, Ledger #35/#36/#37. Mandantenfaehigkeit RUHT. Schalter AUS. Einstieg: `/gsd-resume-work`.
|
||||
Resume file: .planning/HANDOFF.json + .planning/.continue-here.md (2026-09-16T10:28Z)
|
||||
Last activity: 2026-09-16 - Completed quick task 260916-k2z: Mehrfach-Kreise je Kalender am Tag; Browser-Pruefung + Push ausstehend
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
---
|
||||
phase: quick-260916-hiv
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-HIV]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/components/settings/calendar-source-form.tsx
|
||||
- apps/web/src/components/settings/calendar-source-form.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Im Formular fuer Kalenderquellen zeigt das Feld „Adresse (URL)“ je nach gewaehltem Typ ein passendes Beispiel als Platzhalter: Exchange + EWS → `https://mail.firma.de/EWS/Exchange.asmx`, Exchange + Graph → `https://graph.microsoft.com/v1.0`, CalDAV → `https://caldav.firma.de/dav/`, ICS → `https://…/kalender.ics`; solange kein Typ gewaehlt ist, weiterhin `https://`."
|
||||
- "Bei Exchange + EWS steht unter dem Adressfeld ein kleiner grauer Hinweis (`mt-1 text-xs text-muted-foreground`, de/en), dass die vollstaendige EWS-Adresse inkl. /EWS/Exchange.asmx noetig ist; bei Graph, CalDAV und ICS erscheint er nicht. Zeigt das Feld einen Fehler, steht der Hinweis unterhalb des Fehlers."
|
||||
- "Beide Sprachdateien tragen dieselben fuenf neuen Schluessel unter `widgets.calendar` (Namespace von `useTranslations('widgets')`), Platzhalter-Werte in de und en identisch, Beispiel-Domain `firma.de`, keine kundenspezifische Domain."
|
||||
- "`CHANGELOG.md` nennt die Aenderung unter `## Unveröffentlicht` → `### Geändert` in Alltagssprache."
|
||||
- "Type-Check und alle Web-Tests bleiben gruen (Basislinie: 49 Testdateien / 309 Tests, plus die neue Testdatei)."
|
||||
artifacts:
|
||||
- "apps/web/src/messages/de.json — 5 neue Schluessel `widgets.calendar.formFieldUrlPlaceholderEws|Graph|Caldav|Ics` + `formFieldUrlHintEws`"
|
||||
- "apps/web/src/messages/en.json — dieselben 5 Schluessel"
|
||||
- "apps/web/src/components/settings/calendar-source-form.tsx — typabhaengiger Platzhalter + EWS-Hinweis"
|
||||
- "apps/web/src/components/settings/calendar-source-form.test.tsx — neuer Komponententest"
|
||||
- "CHANGELOG.md — Eintrag unter Unveröffentlicht / Geändert"
|
||||
key_links:
|
||||
- "`t('calendar.formFieldUrlPlaceholder*')` / `t('calendar.formFieldUrlHintEws')` in der Form ↔ `widgets.calendar.*` in de.json/en.json (Namespace `widgets` kommt aus `useTranslations('widgets')`, Zeile 63)"
|
||||
- "Hinweis-Sichtbarkeit haengt an `isExchange && exchangeMode === 'ews'` — derselbe Zustand, der auch den EWS-Platzhalter waehlt"
|
||||
- "`umlaut-guard.spec.ts` erzwingt identische Schluesselmengen in de.json und en.json — fehlt ein Schluessel in einer Datei, wird der Test rot"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Das Formular fuer Kalenderquellen (`apps/web/src/components/settings/calendar-source-form.tsx`) zeigt im Adressfeld heute nur den festen Platzhalter `https://`. Kuenftig zeigt es je nach gewaehltem Typ (CalDAV / ICS / Exchange-Graph / Exchange-EWS) eine passende Beispieladresse und blendet bei Exchange-EWS einen grauen Hinweis ein, dass die vollstaendige Adresse inkl. `/EWS/Exchange.asmx` noetig ist — der Servername allein reicht nicht.
|
||||
|
||||
Purpose: Bei EWS scheiterte die Verbindung, wenn Anwender nur den Servernamen eintrugen. Ein sprechendes Beispiel und ein Hinweis verhindern das, ohne dass jemand die Anleitung lesen muss.
|
||||
Output: fuenf neue Uebersetzungsschluessel (de/en), die angepasste Komponente, ein neuer Komponententest, ein Changelog-Eintrag.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/settings/calendar-source-form.tsx
|
||||
@apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
@apps/web/src/messages/umlaut-guard.spec.ts
|
||||
|
||||
Gemessene Fakten zur Planungszeit (2026-09-16, Arbeitsbaum sauber auf `main` @ 2a820b6):
|
||||
- `t` in der Form ist `useTranslations('widgets')` (Zeile 63); alle `calendar.formField*`-Schluessel liegen im JSON unter `widgets.calendar` (de.json/en.json Zeilen 205-236). `"formFieldUrl"` steht in beiden Dateien in Zeile 220, danach folgt `"formFieldUsername"`.
|
||||
- Muster fuer Beispiel-URLs: `emailAlerts.hostPlaceholderExchange` (de.json:983 `https://mail.firma.de/EWS/Exchange.asmx`; en.json:983 weicht dort mit `company.com` ab — fuer DIESEN Auftrag sind die Platzhalter laut Vorgabe in beiden Sprachen identisch).
|
||||
- Es gibt keinen Test fuer `calendar-source-form.tsx`; `widget-settings-panel.test.tsx` liefert das Mock-Muster (echte `de.json` ueber `vi.mock('next-intl', …)`, Namespace-Verkettung `ns.key`). Vitest: jsdom, `globals: true`, Setup `src/test/setup.ts`, Alias `@` → `src`.
|
||||
- `umlaut-guard.spec.ts` prueft (a) keine Ersatzschreibung aus `UMLAUT_REPLACEMENTS`, (b) jedes `ae/oe/ue/ss`-Wort in de.json muss auf `UMLAUT_ALLOWLIST` stehen, (c) identische Schluesselmengen de/en. Von den neuen Texten ist nur `Adresse` verdaechtig und bereits allowlisted — `umlaut-dictionary.ts` bleibt unangetastet.
|
||||
- `## Unveröffentlicht` in `CHANGELOG.md` (Zeile 5) ist leer; direkt darunter folgt `## 1.1.0 – 2026-09-16`. Bestehende Eintraege beginnen mit einem Bereichsnamen wie „Kalender-Einstellungen: …“.
|
||||
- Basislinie: `pnpm --filter @tessera/web type-check` Exit 0 (3 s); `pnpm --filter @tessera/web exec vitest run` → 49 Testdateien / 309 Tests gruen; Umlaut-Waechter 3/3 gruen.
|
||||
- `biome check` ist KEIN Gate: die Wurzel-`biome.json` scheitert unabhaengig von dieser Datei am unbekannten Schluessel `organizeImports` (vorbestehend, nicht Teil dieses Auftrags — `biome.json` nicht anfassen).
|
||||
- Paketname ist `@tessera/web` (nicht `web`) — Filter immer `--filter @tessera/web`.
|
||||
- Kein Docker-Bau, kein Deploy, kein Testserver in diesem Auftrag (Deploy macht der User selbst).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Fuenf Uebersetzungsschluessel in de.json und en.json</name>
|
||||
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<action>
|
||||
In BEIDEN Dateien direkt nach der Zeile `"formFieldUrl": …` (Zeile 220, Block `widgets.calendar`) fuenf neue Zeilen einfuegen — gleiche Reihenfolge, gleiche Einrueckung (6 Leerzeichen), jede Zeile mit Komma, weil `"formFieldUsername"` folgt:
|
||||
|
||||
1. `formFieldUrlPlaceholderEws` — Wert `https://mail.firma.de/EWS/Exchange.asmx`
|
||||
2. `formFieldUrlPlaceholderGraph` — Wert `https://graph.microsoft.com/v1.0`
|
||||
3. `formFieldUrlPlaceholderCaldav` — Wert `https://caldav.firma.de/dav/`
|
||||
4. `formFieldUrlPlaceholderIcs` — Wert `https://…/kalender.ics` (echtes Auslassungszeichen U+2026, wie bei `formSaving` im selben Block)
|
||||
5. `formFieldUrlHintEws` — de: `Vollständige EWS-Adresse inkl. /EWS/Exchange.asmx eintragen – nur der Servername reicht nicht.` / en: `Enter the full EWS address including /EWS/Exchange.asmx – the server name alone is not enough.` (Gedankenstrich U+2013 wie in `formFieldDomainHint`).
|
||||
|
||||
Die vier Platzhalter sind in de.json und en.json IDENTISCH (Beispiel-Adressen, Vorgabe des Users). Nur der Hinweis ist uebersetzt. Beispiel-Domain ist ausschliesslich `firma.de` bzw. `graph.microsoft.com` — keine kundenspezifische Domain (Tessera ist ein Mehrfirmen-Produkt). Keine anderen Schluessel anfassen, `umlaut-dictionary.ts` nicht aendern (`Adresse` ist bereits allowlisted, sonst enthalten die Texte kein `ae/oe/ue/ss`-Wort). JSON muss gueltig bleiben (echte Umlaute direkt als UTF-8, wie im Bestand).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; for f in apps/web/src/messages/de.json apps/web/src/messages/en.json; do for k in formFieldUrlPlaceholderEws formFieldUrlPlaceholderGraph formFieldUrlPlaceholderCaldav formFieldUrlPlaceholderIcs formFieldUrlHintEws; do test "$(grep -c "\"$k\"" "$f")" -eq 1; done; done; node -e "const de=require('./apps/web/src/messages/de.json').widgets.calendar, en=require('./apps/web/src/messages/en.json').widgets.calendar; for (const k of ['formFieldUrlPlaceholderEws','formFieldUrlPlaceholderGraph','formFieldUrlPlaceholderCaldav','formFieldUrlPlaceholderIcs']) { if (de[k]!==en[k]) throw new Error('de/en differ: '+k); if (!/^https:\/\//.test(de[k])) throw new Error('not https: '+k); } if (de.formFieldUrlPlaceholderEws!=='https://mail.firma.de/EWS/Exchange.asmx') throw new Error('EWS placeholder'); if (de.formFieldUrlPlaceholderGraph!=='https://graph.microsoft.com/v1.0') throw new Error('Graph placeholder'); if (de.formFieldUrlPlaceholderCaldav!=='https://caldav.firma.de/dav/') throw new Error('CalDAV placeholder'); if (!de.formFieldUrlPlaceholderIcs.endsWith('/kalender.ics')) throw new Error('ICS placeholder'); if (!de.formFieldUrlHintEws.includes('/EWS/Exchange.asmx') || !en.formFieldUrlHintEws.includes('/EWS/Exchange.asmx')) throw new Error('hint'); if (de.formFieldUrlHintEws===en.formFieldUrlHintEws) throw new Error('hint not translated'); const keys=Object.keys(de); const i=keys.indexOf('formFieldUrl'); if (keys[i+1]!=='formFieldUrlPlaceholderEws' || keys[i+5]!=='formFieldUrlHintEws') throw new Error('order'); console.log('I18N_OK')"; ! grep -q 'ctl\.de' apps/web/src/messages/de.json apps/web/src/messages/en.json; pnpm --filter @tessera/web exec vitest run src/messages/umlaut-guard.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Beide Sprachdateien enthalten die fuenf Schluessel genau einmal, direkt hinter `formFieldUrl`, mit den vorgegebenen Werten (Platzhalter identisch, Hinweis uebersetzt, keine kundenspezifische Domain); `umlaut-guard.spec.ts` bleibt 3/3 gruen (Schluesselparitaet de/en, keine Ersatzschreibung).</done>
|
||||
</task>
|
||||
|
||||
<!-- planner-discipline-allow: placeholder={urlPlaceholder} -->
|
||||
<!-- planner-discipline-allow: data-testid="source-url-hint-ews" -->
|
||||
<!-- planner-discipline-allow: text-muted-foreground -->
|
||||
<!-- Die drei Literale oben sind POSITIV-Gates (-eq 1 / -ge 1): sie muessen nach Task 2 in der Komponente stehen. Das einzige Negativ-Gate (-eq 0) gilt dem alten festen placeholder-Attributwert, der in keiner Action zitiert wird. -->
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Typabhaengiger Platzhalter + EWS-Hinweis in der Komponente, mit Komponententest</name>
|
||||
<files>apps/web/src/components/settings/calendar-source-form.tsx, apps/web/src/components/settings/calendar-source-form.test.tsx</files>
|
||||
<behavior>
|
||||
Neue Testdatei `calendar-source-form.test.tsx` (Muster: `widget-settings-panel.test.tsx` — `vi.mock('next-intl', …)` mit Lookup in der echten `de.json` und Namespace-Verkettung `ns.key`; zusaetzlich `vi.mock('@/lib/calendar-api', () => ({ testSourceConfig: vi.fn(), testSource: vi.fn() }))`, damit kein echter Aufruf passiert; `afterEach(cleanup)`; Erwartungstexte aus `de.widgets.calendar`). Render `<CalendarSourceForm onSave={vi.fn()} onCancel={vi.fn()} />`; Elemente: URL-Feld `screen.getByLabelText(/Adresse \(URL\)/)`, Typ `screen.getByLabelText(/^Typ/)`, Exchange-Anbindung `screen.getByLabelText(/Exchange-Anbindung/)`; Umschalten per `fireEvent.change(el, { target: { value } })`.
|
||||
- Test 1: Ohne gewaehlten Typ hat das URL-Feld den Platzhalter `https://` und es gibt kein Element mit `data-testid="source-url-hint-ews"`.
|
||||
- Test 2: Typ `caldav` → Platzhalter = `formFieldUrlPlaceholderCaldav`; kein Hinweis.
|
||||
- Test 3: Typ `ics` → Platzhalter = `formFieldUrlPlaceholderIcs`; kein Hinweis.
|
||||
- Test 4: Typ `exchange` (Standardmodus `graph`) → Platzhalter = `formFieldUrlPlaceholderGraph`; kein Hinweis.
|
||||
- Test 5: Typ `exchange` + Modus `ews` → Platzhalter = `formFieldUrlPlaceholderEws`; Hinweis vorhanden, Text = `formFieldUrlHintEws`, `className` enthaelt `text-muted-foreground`. Zurueck auf `graph` → Hinweis weg, Platzhalter wieder Graph.
|
||||
- Test 6: Typ `exchange` + Modus `ews` + Eingabe `http://mail.firma.de/EWS/Exchange.asmx` (http statt https) → Fehlertext `formUrlErrorHttps` UND Hinweis sind beide sichtbar, und der Hinweis steht im DOM NACH dem Fehler (`fehler.compareDocumentPosition(hinweis) & Node.DOCUMENT_POSITION_FOLLOWING` ist truthy).
|
||||
</behavior>
|
||||
<action>
|
||||
Erst die Testdatei schreiben und rot sehen (Tests 2-6 schlagen fehl, weil Platzhalter fest und Hinweis nicht vorhanden), dann die Komponente anpassen:
|
||||
|
||||
1. Nach `const isExchange = type === 'exchange';` (Zeile 81) zwei reine Ableitungen ohne State ergaenzen (kein `setState` im Render — siehe Kommentar Zeile 100-102): `const isEws = isExchange && exchangeMode === 'ews';` und `const urlPlaceholder`, das per Verzweigung liefert: bei `isExchange` → `isEws ? t('calendar.formFieldUrlPlaceholderEws') : t('calendar.formFieldUrlPlaceholderGraph')`; bei `type === 'caldav'` → `t('calendar.formFieldUrlPlaceholderCaldav')`; bei `type === 'ics'` → `t('calendar.formFieldUrlPlaceholderIcs')`; sonst (kein Typ gewaehlt) der bisherige Festwert `https://`. Kein `useMemo` noetig.
|
||||
2. Im URL-Input (`id="source-url"`, Zeile 247-264) das feste `placeholder`-Attribut (Zeile 251) auf `placeholder={urlPlaceholder}` umstellen. Sonst nichts am Input aendern (Validierung, Klassen, Handler bleiben).
|
||||
3. Direkt NACH dem bestehenden Fehlerabsatz `{urlError && (<p className="mt-1 text-xs text-destructive">…</p>)}` (Zeile 265-267) einen zweiten bedingten Absatz einfuegen: `{isEws && (<p data-testid="source-url-hint-ews" className="mt-1 text-xs text-muted-foreground">{t('calendar.formFieldUrlHintEws')}</p>)}`. Entscheidung (von den zwei erlaubten Varianten): der Hinweis ist bei EWS IMMER sichtbar und steht bei einem Fehler UNTER dem Fehler — so hilft er auch dann, wenn die Eingabe gerade abgelehnt wird.
|
||||
4. Den Doku-Kommentar der Komponente (Zeile 48-56) um einen Satz ergaenzen: Platzhalter des URL-Feldes typabhaengig, EWS-Hinweis unter dem Feld (Quick 260916-hiv). Keine Schluesselnamen im Kommentar aufzaehlen und den alten Attributwert nicht im Kommentar zitieren.
|
||||
|
||||
Keine weiteren Aenderungen: `EXCHANGE_MODES`, `SOURCE_TYPES`, `handleTest`, `handleSubmit`, Payload bleiben unveraendert. Keine neuen Pakete.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; F=apps/web/src/components/settings/calendar-source-form.tsx; test "$(grep -c 'placeholder="https://"' "$F")" -eq 0; test "$(grep -c 'placeholder={urlPlaceholder}' "$F")" -eq 1; for k in formFieldUrlPlaceholderEws formFieldUrlPlaceholderGraph formFieldUrlPlaceholderCaldav formFieldUrlPlaceholderIcs formFieldUrlHintEws; do test "$(grep -c "calendar.$k" "$F")" -ge 1; done; test "$(grep -c 'data-testid="source-url-hint-ews"' "$F")" -eq 1; test "$(grep -c 'text-muted-foreground' "$F")" -ge 1; test -f apps/web/src/components/settings/calendar-source-form.test.tsx; pnpm --filter @tessera/web exec vitest run src/components/settings/calendar-source-form.test.tsx; pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>Der neue Test (6 Faelle) ist gruen, Type-Check Exit 0; die Komponente liest den Platzhalter aus `urlPlaceholder` (kein fester Wert mehr im Attribut), rendert den grauen Hinweis nur bei Exchange + EWS und dort unterhalb eines eventuellen Fehlers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Changelog-Eintrag unter Unveröffentlicht / Geändert</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
Unter `## Unveröffentlicht` (Zeile 5, derzeit leer — direkt darunter folgt `## 1.1.0 – 2026-09-16`) einfuegen: Leerzeile, `### Geändert`, Leerzeile, genau einen Listenpunkt, Leerzeile vor `## 1.1.0`. Listenpunkt wortgleich:
|
||||
|
||||
`- Kalender-Einstellungen: Das Feld „Adresse (URL)“ im Formular für Kalenderquellen zeigt jetzt je nach Typ ein passendes Beispiel (z. B. `https://mail.firma.de/EWS/Exchange.asmx` für Exchange EWS) und bei Exchange EWS einen Hinweis, dass die vollständige Adresse nötig ist – der Servername allein reicht nicht.`
|
||||
|
||||
Stil wie die Bestandseintraege: Alltagssprache, echte Umlaute, typografische Anfuehrungszeichen „…“, keine Dateinamen, keine Commit-Kuerzel. Abschnitte `## 1.1.0` und `## 1.0.0` unveraendert lassen. Die Seite „Was ist neu“ zeigt diesen Abschnitt auf der Beta automatisch, sobald er einen Listenpunkt hat (`filterChangelogForChannel` blendet nur leere Abschnitte aus) — dort ist nichts zu tun.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; SEC="$(awk '/^## Unveröffentlicht$/{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)"; test "$(printf '%s\n' "$SEC" | grep -c '^### Geändert$')" -eq 1; test "$(printf '%s\n' "$SEC" | grep -c '^- Kalender-Einstellungen: Das Feld „Adresse (URL)“')" -eq 1; test "$(printf '%s\n' "$SEC" | grep -c 'Exchange.asmx')" -eq 1; test "$(printf '%s\n' "$SEC" | grep -c '^- ')" -eq 1; test "$(grep -c '^## Unveröffentlicht$' CHANGELOG.md)" -eq 1; test "$(grep -c '^## 1.1.0 – 2026-09-16$' CHANGELOG.md)" -eq 1; test "$(grep -c '^## 1.0.0 – 2026-09-15$' CHANGELOG.md)" -eq 1; pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts</automated>
|
||||
</verify>
|
||||
<done>`## Unveröffentlicht` enthaelt genau eine Untergruppe `### Geändert` mit genau einem Listenpunkt zum Kalenderquellen-Formular; die Versionsabschnitte 1.1.0 und 1.0.0 sind unveraendert; `changelog.test.ts` bleibt gruen.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser-UI → i18n-Text | Platzhalter und Hinweis sind statische Uebersetzungsstrings; sie werden als React-Textknoten gerendert (automatisch escaped), nicht als HTML. Keine Nutzereingabe fliesst in Platzhalter oder Hinweis. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-HIV-01 | Information Disclosure | Platzhalter-Werte in de.json/en.json | low | mitigate | Nur neutrale Beispiel-Domains (`firma.de`, `graph.microsoft.com`); Gate in Task 1 verbietet eine kundenspezifische Domain in beiden Sprachdateien. |
|
||||
| T-HIV-02 | Tampering | Hinweis-Text im DOM | low | accept | Reiner Uebersetzungsstring ueber `t()`, als Textknoten gerendert — kein `dangerouslySetInnerHTML`, keine Interpolation von Nutzereingaben. |
|
||||
| T-HIV-SC | Tampering | npm-Installationen | low | accept | Dieser Plan installiert keine Pakete (kein `pnpm add`); Lockfile bleibt unveraendert. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach allen drei Tasks, vom Repo-Wurzelverzeichnis:
|
||||
|
||||
1. `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
2. `pnpm --filter @tessera/web exec vitest run` → 50 Testdateien gruen (49 Bestand + `calendar-source-form.test.tsx`), mindestens 315 Tests (309 + 6), keine Fehlschlaege.
|
||||
3. `git diff --stat` beruehrt genau die fuenf Dateien aus `files_modified` (plus SUMMARY/Planungsdateien) — kein `biome.json`, kein `umlaut-dictionary.ts`, kein `pnpm-lock.yaml`.
|
||||
4. Kein Docker-Bau und kein Deploy in diesem Auftrag; die Browser-Pruefung auf der Beta macht der User nach dem naechsten Pull.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Adressfeld zeigt je Typ das vorgegebene Beispiel als Platzhalter (EWS / Graph / CalDAV / ICS), ohne Typ weiterhin `https://`.
|
||||
- Grauer EWS-Hinweis erscheint nur bei Exchange + EWS, unterhalb eines eventuellen Fehlers, Text aus `widgets.calendar.formFieldUrlHintEws` (de/en).
|
||||
- de.json und en.json tragen dieselben fuenf Schluessel; Umlaut-Waechter gruen; keine kundenspezifische Domain.
|
||||
- Neuer Komponententest mit 6 Faellen gruen; Type-Check gruen; Gesamt-Testlauf gruen.
|
||||
- CHANGELOG.md: `## Unveröffentlicht` → `### Geändert` mit genau einem Eintrag zum Kalenderquellen-Formular.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260916-hiv-kalenderquellen-formular-url-platzhalter/260916-hiv-SUMMARY.md` when done
|
||||
</output>
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
phase: quick-260916-hiv
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [next-intl, i18n, react, calendar, form]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "Typabhaengiger URL-Platzhalter im Kalenderquellen-Formular (CalDAV/ICS/Exchange-Graph/Exchange-EWS)"
|
||||
- "Grauer EWS-Hinweis unter dem Adressfeld, nur bei Exchange + EWS, unterhalb eines eventuellen Fehlers"
|
||||
- "Fuenf neue i18n-Schluessel unter widgets.calendar (de/en, identische Platzhalter, uebersetzter Hinweis)"
|
||||
affects: [dashboard-calendar-widget, calendar-source-form]
|
||||
|
||||
actuals:
|
||||
tokens: 2402
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: a5f30d4
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: []
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/settings/calendar-source-form.test.tsx
|
||||
modified:
|
||||
- apps/web/src/components/settings/calendar-source-form.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Der EWS-Hinweis steht IMMER unter dem Feld bei Exchange+EWS (nicht nur wenn fehlerfrei) und bei einem Fehler UNTER dem Fehlertext, wie in der Planvorgabe festgelegt."
|
||||
- "Die vier Beispiel-URLs sind in de.json und en.json bewusst identisch (Beispiel-Adressen, kein zu uebersetzender Fliesstext); nur der Hinweistext ist uebersetzt."
|
||||
|
||||
patterns-established: []
|
||||
|
||||
requirements-completed: [QUICK-260916-HIV]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "URL-Feld zeigt je Typ das vorgegebene Platzhalter-Beispiel (EWS/Graph/CalDAV/ICS), ohne Typ weiterhin https://"
|
||||
requirement: "QUICK-260916-HIV"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/settings/calendar-source-form.test.tsx#Test 1-5"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Grauer EWS-Hinweis erscheint nur bei Exchange+EWS, unterhalb eines eventuellen Fehlers"
|
||||
requirement: "QUICK-260916-HIV"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/settings/calendar-source-form.test.tsx#Test 5-6"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "de.json und en.json tragen dieselben fuenf neuen Schluessel, Umlaut-Waechter bleibt gruen, keine kundenspezifische Domain"
|
||||
requirement: "QUICK-260916-HIV"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/messages/umlaut-guard.spec.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "CHANGELOG.md: Eintrag unter Unveroeffentlicht / Geaendert"
|
||||
requirement: "QUICK-260916-HIV"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/lib/changelog.test.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 3min
|
||||
completed: 2026-09-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260916-hiv: Kalenderquellen-Formular — URL-Platzhalter je Typ Summary
|
||||
|
||||
**Adressfeld im Kalenderquellen-Formular zeigt jetzt je nach Typ ein passendes Beispiel (EWS/Graph/CalDAV/ICS) und bei Exchange-EWS einen grauen Hinweis, dass die vollstaendige Adresse inkl. `/EWS/Exchange.asmx` noetig ist.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~3 min
|
||||
- **Started:** 2026-09-16T12:44:00+02:00 (approx.)
|
||||
- **Completed:** 2026-09-16T12:47:25+02:00
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 5 (2 neu, davon 1 Testdatei; 3 geaendert)
|
||||
|
||||
## Accomplishments
|
||||
- URL-Feld im Kalenderquellen-Formular zeigt einen typabhaengigen Beispiel-Platzhalter statt des festen `https://` (CalDAV, ICS, Exchange-Graph, Exchange-EWS), solange kein Typ gewaehlt ist bleibt es bei `https://`.
|
||||
- Bei Exchange + EWS erscheint ein grauer Hinweis (`text-xs text-muted-foreground`) unter dem Feld, der auf die noetige vollstaendige EWS-Adresse hinweist; bei einem gleichzeitigen Validierungsfehler steht der Hinweis unterhalb des Fehlertextes.
|
||||
- Fuenf neue Uebersetzungsschluessel (`widgets.calendar.formFieldUrlPlaceholderEws|Graph|Caldav|Ics`, `formFieldUrlHintEws`) in de.json und en.json, Platzhalter identisch in beiden Sprachen, Hinweistext uebersetzt, keine kundenspezifische Domain.
|
||||
- Neuer Komponententest `calendar-source-form.test.tsx` mit 6 Faellen (TDD: erst rot, dann gruen durch die Implementierung).
|
||||
- CHANGELOG.md-Eintrag unter „Unveroeffentlicht“ → „Geaendert“.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Fuenf Uebersetzungsschluessel in de.json und en.json** - `618fbd6` (feat)
|
||||
2. **Task 2: Typabhaengiger Platzhalter + EWS-Hinweis in der Komponente, mit Komponententest** - `2306a6d` (feat, TDD: Test + Implementierung in einem Commit nach rot→gruen)
|
||||
3. **Task 3: Changelog-Eintrag unter Unveroeffentlicht / Geaendert** - `9439c33` (docs)
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet (siehe Constraints — SUMMARY/STATE nicht selbst committen)
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/messages/de.json` - fuenf neue Schluessel unter `widgets.calendar`
|
||||
- `apps/web/src/messages/en.json` - dieselben fuenf Schluessel
|
||||
- `apps/web/src/components/settings/calendar-source-form.tsx` - `urlPlaceholder`-Ableitung, `placeholder={urlPlaceholder}`, EWS-Hinweisabsatz, Doku-Kommentar ergaenzt
|
||||
- `apps/web/src/components/settings/calendar-source-form.test.tsx` - neu, 6 Testfaelle
|
||||
- `CHANGELOG.md` - Eintrag unter Unveroeffentlicht / Geaendert
|
||||
|
||||
## Decisions Made
|
||||
- Der EWS-Hinweis ist bei Exchange+EWS immer sichtbar und steht bei einem Fehler unter dem Fehlertext (Plan-Vorgabe, eine von zwei erlaubten Varianten).
|
||||
- Platzhalter-Werte sind in de.json und en.json identisch (Beispiel-Adressen, kein Fliesstext), nur der Hinweistext ist uebersetzt.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Alle Datei- und Zeilen-Annahmen aus dem Plankontext (Zeilennummern, Schluesselreihenfolge) haben exakt gepasst; keine Rule-1/2/3/4-Faelle aufgetreten.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - keine externe Konfiguration noetig.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Kein Docker-Bau, kein Deploy in diesem Auftrag — der User zieht den naechsten Pull selbst und prueft im Browser auf der Beta.
|
||||
- Keine offenen Punkte fuer diesen Auftrag.
|
||||
|
||||
---
|
||||
*Phase: quick-260916-hiv*
|
||||
*Completed: 2026-09-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle fuenf Dateien vorhanden (calendar-source-form.test.tsx, calendar-source-form.tsx, de.json, en.json, CHANGELOG.md); alle drei Task-Commits (618fbd6, 2306a6d, 9439c33) in der Historie gefunden.
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: quick-260916-htc
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-HTC]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
|
||||
estimate:
|
||||
tokens: 75000
|
||||
raw_tokens: 75000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Das Kalender-Widget zeigt oben ein Monatsraster (Zeile Zurück / „Monat Jahr“ / Weiter, Kopfzeile Mo Di Mi Do Fr Sa So, 42 Zellen ab Montag, Fremdmonatstage gedämpft, heutiger Tag hervorgehoben, kleine Zähl-Plakette unten rechts an Tagen mit Terminen; beim Überfahren eines Tages mit Terminen ein Tooltip mit bis zu 5 Zeilen „HH:MM Titel“ plus „Weitere Termine vorhanden“) und darunter den Block „Nächste Termine“ (Datum/Uhrzeit, Titel fett, Ort gedämpft, 8-px-Farbpunkt der Quelle)."
|
||||
- "Ein Klick auf „Monat Jahr“ springt zum heutigen Monat zurück; Zurück/Weiter blättern; jeder Monatswechsel lädt die Termine neu. Nav-Knöpfe und Tageszellen tragen `widgetNoDrag` (kein Ziehen im Bearbeitungsmodus)."
|
||||
- "Unter Einstellungen → Dashboard → Widgets → Kalender gibt es drei Felder: Kontrollkästchen „Monatsansicht anzeigen“ (showMonth, Vorgabe an), Auswahl „Anzahl Termine“ (maxEvents 0..10, Vorgabe 3, Optionen „Ausblenden“, „1 Termin“, „2 Termine“ … „10 Termine“) und Auswahl „Zeitraum“ (lookaheadDays 7/14/30/60/90, Vorgabe 30, Optionen „Nächste N Tage“); darunter die übersetzte Zeile „Kalenderquellen verwalten Sie unter Einstellungen → Dashboard → Kalender“ als Link. Jede Änderung ruft `updateWidgetConfig(id, { feld: wert })` mit genau dem geänderten Feld auf."
|
||||
- "Das Widget liest showMonth/maxEvents/lookaheadDays aus `config`, klemmt ungültige Werte (maxEvents 0..10, lookaheadDays auf 7/14/30/60/90 sonst 30) und zeigt bei showMonth=false und maxEvents=0 den gedämpften Text „Nichts zum Anzeigen ausgewählt“ statt abzustürzen."
|
||||
- "Pro Ladevorgang genau EIN `fetchEvents(from, to)`-Aufruf mit beiden Argumenten; from/to sind lokale Tagesgrenzen (00:00:00.000) als ISO-Strings über `min(Rasterstart, heute 00:00)` … `max(Rasterende, heute 00:00 + lookaheadDays)`, damit der Backend-Cache-Schlüssel über die 5-Minuten-Aktualisierung hinweg stabil bleibt."
|
||||
- "`WIDGET_CONSTRAINTS.calendar` ist `{ minW: 6, minH: 8, defaultW: 8, defaultH: 12 }`; gespeicherte kleinere Layouts hebt `applyConstraintMinima` in dashboard-grid.tsx automatisch an (seit 260916-dyv, keine Änderung nötig)."
|
||||
- "Type-Check Exit 0; alle Web-Tests grün (Basislinie 50 Dateien / 315 Tests, danach 51 Dateien und mindestens 328 Tests); Umlaut-Wächter 3/3; changelog.test.ts grün."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.ts — reine Hilfsfunktionen: resolveCalendarConfig, dateKey, startOfLocalDay, addDays, gridStartFor, groupEventsByDate, buildCalendarDays, computeFetchWindow, selectUpcomingEvents, formatEventDate, formatEventTime, formatMonthLabel, Konstanten"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.test.ts — Unit-Tests der Hilfsfunktionen"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.tsx — neues Widget (Monatsraster + Nächste Termine + Portal-Tooltip)"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx — neu geschriebener Komponententest"
|
||||
- "apps/web/src/components/settings/widget-settings-panel.tsx — `CalendarConfig` nach Muster `ClockConfig`"
|
||||
- "apps/web/src/messages/de.json + en.json — 16 neue Schlüssel unter `widgets.calendar`"
|
||||
- "CHANGELOG.md — Eintrag unter Unveröffentlicht / Geändert; docs/anleitung-anwender.md — Kalender-Zeile in der Widget-Tabelle und Absatz Dashboard > Widgets ergänzt"
|
||||
key_links:
|
||||
- "`t('calendar.<key>')` im Widget und im Panel (Namespace `widgets` aus `useTranslations('widgets')`) ↔ `widgets.calendar.<key>` in de.json/en.json; `umlaut-guard.spec.ts` erzwingt identische Schlüsselmengen"
|
||||
- "`resolveCalendarConfig` wird von Widget UND Panel benutzt — dieselben Vorgaben/Grenzen an beiden Stellen (Muster clock-font-size.ts, T-BWO-01)"
|
||||
- "`computeFetchWindow` liefert die from/to-Werte, die 1:1 per `toISOString()` an `fetchEvents` gehen; Backend-Cache-Schlüssel = `${userId}:${from.toISOString()}:${to.toISOString()}` (calendar.service.ts, aggregateEvents)"
|
||||
- "Tooltip per `createPortal(..., document.body)` mit `position: fixed`, weil die Karte in widget-wrapper.tsx `overflow-hidden` ist und der Rumpf `@container-size` trägt"
|
||||
- "`widget-registry.test.tsx` Test A pinnt `WIDGET_CONSTRAINTS` per `toEqual` — die Kalender-Zeile dort muss mitgezogen werden"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Das Kalender-Widget (`apps/web/src/components/dashboard/widgets/calendar-widget.tsx`) zeigt heute nur eine flache Terminliste. Es wird nach dem Vorbild des alten persönlichen Dashboards des Anwenders neu gebaut: oben ein Monatsraster mit Blätter-Zeile, Wochentagskopf, 42 Tageszellen, Hervorhebung von heute, Zähl-Plakette an Tagen mit Terminen und Tooltip beim Überfahren; darunter der Block „Nächste Termine“. Drei neue Widget-Einstellungen (Monatsansicht an/aus, Anzahl Termine, Zeitraum) werden unter Einstellungen → Dashboard → Widgets → Kalender nach dem Muster `ClockConfig` gepflegt; der bisher untranslatierte englische Hinweistext dort wird durch eine übersetzte Link-Zeile ersetzt.
|
||||
|
||||
NICHT Teil dieses Auftrags (bewusst, Entscheidung des Anwenders): keine Quellenauswahl je Widget — der globale Sichtbar-Schalter je Quelle unter Einstellungen → Dashboard → Kalender bleibt der einzige Filter. Kein Docker-Build, kein Deploy, kein Testserver, kein `git push`. `biome.json` nicht anfassen.
|
||||
|
||||
Purpose: Der Anwender will die Monatsübersicht mit Terminanzahl je Tag zurück, die er von seinem alten Dashboard kennt, plus Einfluss darauf, wie viele Termine und welcher Zeitraum darunter erscheinen.
|
||||
Output: Hilfsmodul `calendar-month.ts` mit Unit-Tests, neues Widget mit neu geschriebenem Komponententest, `CalendarConfig` im Einstellungsfeld mit Tests, 16 Übersetzungsschlüssel de/en, angepasste Mindestgröße im Registry (+ Test), Changelog-Eintrag, Handbuch-Ergänzung.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
@apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
@apps/web/src/components/dashboard/widgets/clock-widget.tsx
|
||||
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
@apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
@apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
@apps/web/src/lib/calendar-api.ts
|
||||
@apps/web/src/messages/umlaut-guard.spec.ts
|
||||
|
||||
Gemessene Fakten zur Planungszeit (2026-09-16, Arbeitsbaum sauber auf `main` @ a60c168):
|
||||
|
||||
- `WidgetProps` (widget-registry.tsx Z. 21-25): `{ instanceId: string; config: Record<string, unknown>; isEditMode: boolean }`. `WIDGET_CONSTRAINTS.calendar` steht in Z. 44 auf `{ minW: 3, minH: 3, defaultW: 8, defaultH: 12 }`; `widget-registry.test.tsx` Z. 64 pinnt genau diese Zeile per `toEqual` — beide Stellen ändern.
|
||||
- Raster (dashboard-grid.tsx Z. 18/171/172): `COLS.lg = 24`, `rowHeight={20}`, `margin=[8,8]`. Kachelhöhe = h·20 + (h−1)·8 → Vorgabe 8×12 ≈ 328 px hoch, minH 8 = 216 px; Kachelbreite bei 8 Spalten ≈ 460 px (1400-px-Dashboard), minW 6 ≈ 340 px. `applyConstraintMinima` (Z. 86-113) hebt gespeicherte w/h auf minW/minH an und setzt minW/minH aus der Tabelle — kein Eingriff nötig.
|
||||
- Drag-Cancel-Selektor (dashboard-grid.tsx Z. 31-32): `'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'`. Knöpfe sind also ohnehin drag-frei; Tageszellen (div) brauchen die Klasse `widgetNoDrag`.
|
||||
- widget-wrapper.tsx: Karte `overflow-hidden rounded-lg border …`, Rumpf `<div className="@container-size h-full">` (container-type: size, `cqw`/`cqh` lösen auf). Ein absolut positionierter Tooltip in der Karte würde abgeschnitten → Portal + `position: fixed`.
|
||||
- Container-Query-Konvention (clock-widget.tsx Z. 51, calculator-widget.tsx Z. 315-332): Tailwind-Arbitrary-Werte wie `text-[clamp(12px,min(20cqw,50cqh),400px)]`; jsdom verwirft clamp() nur im Inline-Style, Klassen bleiben prüfbar (`className` toMatch /cqw/).
|
||||
- calendar-api.ts: `fetchEvents(from?: string, to?: string)` hängt from/to als Query an; `fetchSources()`; `CalendarEvent { id, sourceId, title, start, end, allDay, location?, description?, color? }`. Backend (calendar.service.ts `aggregateEvents`, Z. 373-395): Cache-Schlüssel `${userId}:${fromDate.toISOString()}:${toDate.toISOString()}`, TTL 5 min, Default-Fenster jetzt..+30 d. Der alte Widget-Code ruft `fetchEvents` OHNE Argumente → Default-Fenster, Cache-Treffer nur zufällig.
|
||||
- widget-settings-panel.tsx: `handleConfigChange(id, partialConfig)` → `updateWidgetConfig` + `onWidgetUpdate` (Seite `settings/dashboard/page.tsx` Z. 43-49 mischt partiell: `{ ...w.config, ...config }`). Kalender-Zweig Z. 178-192 zeigt einen fest englischen Absatz mit `<Link href="/settings/dashboard/calendar">`. `ClockConfig` (Z. 209-330) ist das Muster: Kontrollkästchen `h-4 w-4 rounded border-border text-primary`, Select `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`, Label `mb-1 block text-sm text-foreground`. `Link` ist bereits importiert.
|
||||
- widget-settings-panel.test.tsx: mockt `next-intl` über die echte `de.json` per Pfad-Lookup — der Mock gibt `lookup(...) ?? key` zurück und ersetzt KEINE `{platzhalter}`; für die neuen Optionstexte („{count} Termine“, „Nächste {days} Tage“) muss der Mock ein zweites Argument `values` annehmen und `{name}` ersetzen (Task 3). Mockt `@/lib/dashboard-api.updateWidgetConfig`, `next/link`, `search-provider-form`.
|
||||
- de.json Z. 205-239 / en.json Z. 205-239: `widgets.calendar` mit `name, description, loading, emptyNoSources, emptyNoEvents, connectionSuccess, …, saveError` (35 Schlüssel). `umlaut-guard.spec.ts` prüft (1) keine Ersatzschreibungen, (2) jedes de-Token mit ae/oe/ue/ss muss auf `UMLAUT_ALLOWLIST` stehen („Kalenderquellen“, „Quelle“, „Quellen“ stehen drauf; „aktuellen“/„Aktueller“ NICHT — deshalb „heutigen Monat“ statt „aktuellen Monat“), (3) identische Schlüsselmengen de/en.
|
||||
- vitest (apps/web 4.1.9): jsdom, `globals: true`, jest-dom-Matcher über `src/test/setup.ts`, `css: false`. Zeitlogik in Tests deterministisch über `vi.useFakeTimers({ toFake: ['Date'] })` + `vi.setSystemTime(...)` (nur Date faken, damit `waitFor` mit echten Timern weiterläuft); Testdaten immer mit lokalen Konstruktoren `new Date(2026, 6, 20, 9, 0)` bauen, nie mit festen `Z`-Strings, damit die Tests in jeder Zeitzone gleich laufen.
|
||||
- Referenz (user-files/personal-dashboard/src/app/page.tsx): `buildCalendarDays` Z. 230-256 (Montag-basiert, 42 Zellen, `dateKey` lokal YYYY-MM-DD, `isToday` per Key-Vergleich), `groupEventsByDate` Z. 379-390 (nach lokalem Startdatum), `formatEventDate` Z. 190-198 (de-DE, weekday short, day/month 2-digit, hour/minute 2-digit → „Mi., 01.07., 18:00“), `formatMonthLabel` Z. 207-212 („Juli 2026“), `renderCalendarWidget` Z. 1892-1990 (Struktur Header → Wochentage → Raster mit Zähl-Plakette + Tooltip (5 Einträge + „Weitere Termine vorhanden“) → Block „Nächste Termine“ mit eventDate / eventTitle / eventLocation). Screenshot user-files/dashboard.png, Kachel „CTL“ oben rechts: Plakette rot (= primary) unten rechts in der Zelle, heutiger Tag mit Rahmen, Listeneinträge als flache Karten mit drei Zeilen.
|
||||
- Kalenderrechnung für die Tests: 1. Juli 2026 ist ein Mittwoch → Rasterstart Mo 29.06.2026, Rasterende (exklusiv) Mo 10.08.2026, letzte Zelle So 09.08.2026. 1. August 2026 ist ein Samstag → Rasterstart Mo 27.07.2026, Rasterende Mo 07.09.2026.
|
||||
- Basislinie: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` → 50 Testdateien / 315 Tests grün; Umlaut-Wächter 3/3. `biome check` ist KEIN Gate (vorbestehender, fremder Konfigurationsfehler in biome.json).
|
||||
- Handbuch docs/anleitung-anwender.md: Widget-Tabelle Z. 72-81, Kalender-Zeile Z. 76 („Zeigt kommende Termine aus Ihren verbundenen Kalenderquellen“); Absatz „**Dashboard > Widgets:**“ Z. 153; Absatz „**Dashboard > Kalender:**“ Z. 155 (bleibt).
|
||||
- CHANGELOG.md: `## Unveröffentlicht` Z. 5, `### Geändert` Z. 7, genau ein Eintrag Z. 9 (Kalender-Einstellungen URL-Platzhalter, quick 260916-hiv); `## 1.1.0 – 2026-09-16` Z. 11.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Übersetzungsschlüssel de/en + reines Hilfsmodul calendar-month.ts mit Unit-Tests</name>
|
||||
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/components/dashboard/widgets/calendar-month.ts, apps/web/src/components/dashboard/widgets/calendar-month.test.ts</files>
|
||||
<read_first>
|
||||
- user-files/personal-dashboard/src/app/page.tsx Z. 180-256 und Z. 379-390 (Vorlage für dateKey, buildCalendarDays, groupEventsByDate, formatEventDate, formatMonthLabel)
|
||||
- apps/web/src/components/dashboard/widgets/clock-font-size.ts (Muster: Grenzen/Vorgaben in einem Modul, das Widget und Panel teilen)
|
||||
- apps/web/src/messages/de.json Z. 205-211 (Einfügestelle nach `emptyNoEvents`)
|
||||
- apps/web/src/messages/umlaut-guard.spec.ts (Regeln für neue deutsche Wörter)
|
||||
</read_first>
|
||||
<behavior>
|
||||
calendar-month.test.ts (vitest, kein DOM nötig; beschreibende deutsche Testnamen wie in den Bestandstests):
|
||||
- Test 1 buildCalendarDays(new Date(2026, 6, 1), new Map(), new Date(2026, 6, 15)) → 42 Zellen; days[0].key === '2026-06-29' und inCurrentMonth false; days[2].key === '2026-07-01' und inCurrentMonth true; days[41].key === '2026-08-09'; genau eine Zelle isToday, deren key '2026-07-15'; genau 11 Zellen mit inCurrentMonth false (2 im Juni, 9 im August — Juli hat 31 Tage, 42 − 31 = 11).
|
||||
- Test 2 groupEventsByDate: zwei Termine mit start new Date(2026, 6, 20, 9, 0) / new Date(2026, 6, 20, 14, 0) und einer am 21.07. → Map-Größe 2, Eintrag '2026-07-20' hat Länge 2; buildCalendarDays mit dieser Map liefert für die 20.07.-Zelle events.length 2.
|
||||
- Test 3 resolveCalendarConfig: {} → { showMonth: true, maxEvents: 3, lookaheadDays: 30 }; { showMonth: false } → false; { showMonth: 'nein' } → true; { maxEvents: 99 } → 10; { maxEvents: -1 } → 0; { maxEvents: 4.7 } → 4; { maxEvents: '5' } → 3; { lookaheadDays: 45 } → 30; { lookaheadDays: 90 } → 90.
|
||||
- Test 4 computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 6, 15, 10, 30)) → from.getTime() === new Date(2026, 5, 29).getTime(), to.getTime() === new Date(2026, 7, 14).getTime(); mit now = new Date(2026, 4, 1, 8, 0) (Mai, Juli angezeigt) → from === new Date(2026, 4, 1), to === new Date(2026, 7, 10); mit lookahead 90 und now 15.07. → to === new Date(2026, 9, 13); from/to haben jeweils getHours()/getMinutes()/getSeconds()/getMilliseconds() === 0.
|
||||
- Test 5 selectUpcomingEvents(events, 7, 2, now = new Date(2026, 6, 15, 10, 0)): Termine „gestern“ (end 14.07. 12:00) raus; „läuft gerade“ (start 09:00, end 11:00 heute) drin; „heute 15:00“ drin; „in 5 Tagen“ drin; „in 10 Tagen“ (25.07.) raus wegen lookahead 7; Ergebnis nach start sortiert und auf 2 gekürzt → Titel ['läuft', 'heute 15'] in dieser Reihenfolge; maxEvents 0 → [].
|
||||
- Test 6 formatEventDate: Termin start new Date(2026, 6, 20, 9, 5), allDay false → Text matcht /20\.07\./ und /09:05/; allDay true → matcht /20\.07\./ und NICHT /\d{2}:\d{2}/. formatMonthLabel(new Date(2026, 6, 1)) === 'Juli 2026'. formatEventTime(new Date(2026, 6, 20, 9, 5).toISOString()) === '09:05'.
|
||||
- Test 7 gridStartFor(new Date(2026, 7, 1)) === new Date(2026, 6, 27) (Samstag → Montag davor); gridStartFor(new Date(2026, 5, 1)) === new Date(2026, 5, 1) (1. Juni 2026 ist ein Montag → Rasterstart = 1.).
|
||||
</behavior>
|
||||
<action>
|
||||
1. **Übersetzungen.** In `apps/web/src/messages/de.json` unter `widgets.calendar` direkt NACH `"emptyNoEvents"` (vor `"connectionSuccess"`) diese 16 Schlüssel in genau dieser Reihenfolge einfügen; in `en.json` an derselben Stelle dieselben Schlüssel:
|
||||
- `nothingSelected`: de „Nichts zum Anzeigen ausgewählt“ / en „Nothing selected to display“
|
||||
- `monthPrev`: „Zurück“ / „Back“
|
||||
- `monthNext`: „Weiter“ / „Next“
|
||||
- `monthToday`: „Zurück zum heutigen Monat“ / „Back to the current month“ (bewusst „heutigen“, nicht „aktuellen“ — siehe Umlaut-Allowlist im Kontext)
|
||||
- `upcomingTitle`: „Nächste Termine“ / „Upcoming events“
|
||||
- `tooltipMore`: „Weitere Termine vorhanden“ / „More events available“
|
||||
- `allDay`: „ganztägig“ / „all day“
|
||||
- `configShowMonth`: „Monatsansicht anzeigen“ / „Show month view“
|
||||
- `configMaxEvents`: „Anzahl Termine“ / „Number of events“
|
||||
- `configMaxEventsNone`: „Ausblenden“ / „Hide“
|
||||
- `configMaxEventsOne`: „1 Termin“ / „1 event“
|
||||
- `configMaxEventsMany`: „{count} Termine“ / „{count} events“ (ICU-Platzhalter, next-intl ersetzt ihn über `t('calendar.configMaxEventsMany', { count })`)
|
||||
- `configLookahead`: „Zeitraum“ / „Time range“
|
||||
- `configLookaheadOption`: „Nächste {days} Tage“ / „Next {days} days“
|
||||
- `configSourcesHint`: „Kalenderquellen verwalten Sie unter“ / „Manage calendar sources under“
|
||||
- `configSourcesLink`: „Einstellungen → Dashboard → Kalender“ / „Settings → Dashboard → Calendar“
|
||||
Bestehende Schlüssel (`loading`, `emptyNoSources`, `emptyNoEvents`, …) unverändert lassen. Echte Umlaute verwenden (ä/ü/ß), keine Ersatzschreibungen.
|
||||
2. **Hilfsmodul** `apps/web/src/components/dashboard/widgets/calendar-month.ts` (kein React, kein `'use client'`, importiert nur `type { CalendarEvent } from '@/lib/calendar-api'`). Exporte mit genau diesen Namen/Signaturen:
|
||||
- `CALENDAR_LOOKAHEAD_OPTIONS: readonly number[] = [7, 14, 30, 60, 90]`, `CALENDAR_MAX_EVENTS_LIMIT = 10`, `CALENDAR_DEFAULTS = { showMonth: true, maxEvents: 3, lookaheadDays: 30 } as const`, `WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const`.
|
||||
- `interface CalendarWidgetConfig { showMonth: boolean; maxEvents: number; lookaheadDays: number }` und `resolveCalendarConfig(config: Record<string, unknown>): CalendarWidgetConfig` — showMonth ist nur bei literalem `false` aus, sonst an; maxEvents: wenn `typeof === 'number'` und endlich → `Math.min(10, Math.max(0, Math.trunc(n)))`, sonst 3; lookaheadDays: wenn Zahl und in `CALENDAR_LOOKAHEAD_OPTIONS` enthalten → die Zahl, sonst 30.
|
||||
- `dateKey(date: Date): string` (lokal `YYYY-MM-DD`, Vorlage Z. 182-188), `startOfLocalDay(date: Date): Date` (Kopie mit setHours(0,0,0,0)), `addDays(date: Date, days: number): Date` (Kopie, `setDate(getDate() + days)` — behält die lokale Wanduhrzeit über Sommerzeitwechsel, deshalb nicht über Millisekunden rechnen).
|
||||
- `gridStartFor(monthDate: Date): Date` — Montag am oder vor dem 1. des Monats, 00:00 lokal (Vorlage Z. 231-238: `(firstDay.getDay() + 6) % 7`).
|
||||
- `interface CalendarDay { key: string; date: Date; inCurrentMonth: boolean; isToday: boolean; events: CalendarEvent[] }`, `groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]>` (Schlüssel = `dateKey(new Date(event.start))`; mehrtägige/ganztägige Termine bewusst nur am Starttag gezählt — im SUMMARY erwähnen), `buildCalendarDays(monthDate: Date, eventsByDate: Map<string, CalendarEvent[]>, today: Date = new Date()): CalendarDay[]` (42 Zellen ab `gridStartFor`, `isToday` = `key === dateKey(today)`).
|
||||
- `computeFetchWindow(monthDate: Date, lookaheadDays: number, now: Date = new Date()): { from: Date; to: Date }` — gridStart = gridStartFor(monthDate); gridEnd = addDays(gridStart, 42); todayStart = startOfLocalDay(now); lookEnd = addDays(todayStart, lookaheadDays); from = das frühere von gridStart/todayStart; to = das spätere von gridEnd/lookEnd. Alle vier Werte sind Tagesgrenzen 00:00 lokal, daher ist `toISOString()` innerhalb eines Tages konstant (Backend-Cache-Schlüssel stabil).
|
||||
- `selectUpcomingEvents(events: CalendarEvent[], lookaheadDays: number, maxEvents: number, now: Date = new Date()): CalendarEvent[]` — behalten, wenn `new Date(e.end).getTime() >= now.getTime()` UND `new Date(e.start).getTime() < addDays(startOfLocalDay(now), lookaheadDays).getTime()`; nach start aufsteigend sortieren; `slice(0, maxEvents)`.
|
||||
- `formatEventDate(event: CalendarEvent): string` — `Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })` für Termine mit Uhrzeit; bei `allDay` dieselben Optionen OHNE hour/minute. `formatEventTime(iso: string): string` — de-DE hour/minute 2-digit. `formatMonthLabel(date: Date): string` — de-DE `{ month: 'long', year: 'numeric' }`.
|
||||
Kopfkommentar auf Deutsch (Muster clock-font-size.ts): Zweck, „quick-260916-htc“, Hinweis auf geteilte Nutzung durch Widget und Einstellungsfeld, Starttag-Regel für mehrtägige Termine.
|
||||
3. **Tests** `calendar-month.test.ts` exakt nach `<behavior>`; Testdaten für `CalendarEvent` mit einer kleinen Fabrik `ev(id, start: Date, end: Date, extra?)` bauen (Felder id, sourceId 's1', title = id, start/end als `toISOString()`, allDay false). Kein DOM, kein Mock nötig. RED zuerst ausführen (Modul fehlt → Test rot), dann GREEN.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; for f in apps/web/src/messages/de.json apps/web/src/messages/en.json; do for k in nothingSelected monthPrev monthNext monthToday upcomingTitle tooltipMore allDay configShowMonth configMaxEvents configMaxEventsNone configMaxEventsOne configMaxEventsMany configLookahead configLookaheadOption configSourcesHint configSourcesLink; do grep -q "\"$k\"" "$f"; done; done; node -e "const de=require('./apps/web/src/messages/de.json').widgets.calendar, en=require('./apps/web/src/messages/en.json').widgets.calendar; const need=['nothingSelected','monthPrev','monthNext','monthToday','upcomingTitle','tooltipMore','allDay','configShowMonth','configMaxEvents','configMaxEventsNone','configMaxEventsOne','configMaxEventsMany','configLookahead','configLookaheadOption','configSourcesHint','configSourcesLink']; for (const k of need) { if (typeof de[k]!=='string'||typeof en[k]!=='string') throw new Error('missing '+k); if (de[k]===en[k]) throw new Error('untranslated '+k); } if (de.nothingSelected!=='Nichts zum Anzeigen ausgewählt') throw new Error('nothingSelected'); if (de.configShowMonth!=='Monatsansicht anzeigen') throw new Error('configShowMonth'); if (de.configMaxEventsNone!=='Ausblenden') throw new Error('none'); if (!de.configMaxEventsMany.includes('{count}')||!en.configMaxEventsMany.includes('{count}')) throw new Error('count placeholder'); if (!de.configLookaheadOption.includes('{days}')||!en.configLookaheadOption.includes('{days}')) throw new Error('days placeholder'); if (de.tooltipMore!=='Weitere Termine vorhanden') throw new Error('tooltipMore'); if (de.emptyNoEvents!=='Keine anstehenden Termine') throw new Error('existing key changed'); const keys=Object.keys(de); if (keys[keys.indexOf('emptyNoEvents')+1]!=='nothingSelected') throw new Error('order'); console.log('I18N_OK')"; F=apps/web/src/components/dashboard/widgets/calendar-month.ts; test -f "$F"; for s in "export function resolveCalendarConfig" "export function buildCalendarDays" "export function computeFetchWindow" "export function selectUpcomingEvents" "export function groupEventsByDate" "export function gridStartFor" "export function formatEventDate" "export function formatMonthLabel" "export const CALENDAR_LOOKAHEAD_OPTIONS" "export const WEEKDAY_LABELS"; do grep -q "$s" "$F"; done; ! grep -q "from 'react'" "$F"; pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-month.test.ts src/messages/umlaut-guard.spec.ts</automated>
|
||||
</verify>
|
||||
<done>16 neue Schlüssel in de.json und en.json (Reihenfolge direkt nach `emptyNoEvents`), Umlaut-Wächter 3/3 grün; `calendar-month.ts` exportiert alle genannten Funktionen/Konstanten ohne React-Import; `calendar-month.test.ts` mit mindestens 7 Tests grün.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Kalender-Widget neu bauen (Monatsraster + Tooltip-Portal + Nächste Termine), Komponententest neu schreiben, Mindestgröße 6×8</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx (Bestand: Lade-/Quellen-Ablauf, 5-Minuten-Intervall, `data-testid="event-color-dot"` — Ablauf und Testid bleiben)
|
||||
- apps/web/src/components/dashboard/widgets/clock-widget.tsx Z. 51 und calculator-widget.tsx Z. 315-332 (Container-Query-Klassen)
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx Z. 106 (Rumpf `@container-size h-full`, Karte `overflow-hidden`)
|
||||
- apps/web/src/components/dashboard/widgets/link-widget.tsx Z. 197 (Klasse `widgetNoDrag` im Einsatz)
|
||||
- user-files/personal-dashboard/src/app/page.tsx Z. 1892-1990 (Struktur der Vorlage) und user-files/dashboard.png (Zielbild, Kachel „CTL“)
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx Z. 44 und widget-registry.test.tsx Z. 56-70
|
||||
</read_first>
|
||||
<behavior>
|
||||
calendar-widget.test.tsx (neu; `vi.mock('next-intl')` per Schlüssel-Map wie bisher, aber die Mock-Funktion nimmt `(key, values?)` und ersetzt `{name}`-Platzhalter aus `values`; Map enthält alle Schlüssel aus Task 1 in deutscher Fassung plus `calendar.loading`/`emptyNoSources`/`emptyNoEvents`; `vi.mock('@/lib/calendar-api')` wie bisher; `beforeEach`: `vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0));` Quellen-Mock mit einer sichtbaren Quelle; `afterEach`: `vi.useRealTimers(); cleanup();`):
|
||||
- Test 1 Laden → „Laden...“ sichtbar; danach bei `fetchSources → []` erscheint „Keine Kalenderquellen konfiguriert“, `fetchEvents` wird NICHT aufgerufen.
|
||||
- Test 2 Monatsraster: `config={{}}`, `fetchEvents → []` → nach dem Laden Text „Juli 2026“; Kopfzeile enthält Mo, Di, Mi, Do, Fr, Sa, So; 42 Elemente `data-testid="calendar-day"`; die Zelle mit `data-date="2026-07-15"` hat `data-today="true"` und Text „15“; Zelle `2026-06-29` hat `data-outside="true"`; alle Tageszellen und die drei Knöpfe tragen die Klasse `widgetNoDrag`; die Zelle für 15.07. hat KEINE Plakette. Unter dem Raster steht „Nächste Termine“ und (bei leerer Liste) „Keine anstehenden Termine“.
|
||||
- Test 3 Plakette: Termine 20.07. 09:00 („Team Meeting“) und 20.07. 14:00 („Lunch“), 21.07. 10:00 („Review“) → Zelle `2026-07-20` enthält `data-testid="calendar-day-count"` mit Text „2“, Zelle `2026-07-21` Plakette „1“, Zelle `2026-07-22` keine Plakette.
|
||||
- Test 4 Tooltip: `fireEvent.mouseEnter` auf Zelle `2026-07-20` → `screen.getByTestId('calendar-day-tooltip')` steht im `document.body`, enthält „09:00“, „Team Meeting“ und „Lunch“; `fireEvent.mouseLeave` → Tooltip weg. Mit 6 Terminen an einem Tag zeigt der Tooltip 5 Einträge und den Text „Weitere Termine vorhanden“.
|
||||
- Test 5 Nächste Termine: 5 Termine zwischen 16.07. und 30.07. (einer davon mit `location: 'Raum 2'`), `config={{ maxEvents: 2 }}` → `within(getByTestId('calendar-upcoming')).getAllByRole('listitem')` hat Länge 2, in Start-Reihenfolge; jeder Eintrag hat einen `event-color-dot`; der Ort „Raum 2“ ist sichtbar, wenn der betroffene Termin unter den ersten zwei ist; die Datumzeile matcht /\d{2}\.\d{2}\./.
|
||||
- Test 6 showMonth=false: `config={{ showMonth: false, maxEvents: 3 }}` → `queryByTestId('calendar-month')` null, `getByTestId('calendar-upcoming')` vorhanden. `config={{ showMonth: false, maxEvents: 0 }}` → Text „Nichts zum Anzeigen ausgewählt“, kein Raster, keine Liste.
|
||||
- Test 7 Ladefenster: `config={{}}` → `mockFetchEvents` genau einmal aufgerufen mit `(new Date(2026, 5, 29).toISOString(), new Date(2026, 7, 14).toISOString())`; `config={{ lookaheadDays: 90 }}` → zweites Argument `new Date(2026, 9, 13).toISOString()`.
|
||||
- Test 8 Blättern: Klick auf „Weiter“ → Text „August 2026“, `fetchEvents` erneut aufgerufen mit `(new Date(2026, 6, 27).toISOString(), new Date(2026, 8, 7).toISOString())`; Klick auf „August 2026“ (Monatsknopf) → wieder „Juli 2026“; Klick auf „Zurück“ von Juli → „Juni 2026“.
|
||||
widget-registry.test.tsx Test A: Kalender-Zeile auf `{ minW: 6, minH: 8, defaultW: 8, defaultH: 12 }` ändern, Kommentar um einen Satz zu quick-260916-htc ergänzen (Monatsraster braucht Breite für 7 Spalten und Höhe für Nav + Kopf + 6 Zeilen + Liste).
|
||||
</behavior>
|
||||
<action>
|
||||
1. **Registry.** `WIDGET_CONSTRAINTS.calendar` in widget-registry.tsx auf `{ minW: 6, minH: 8, defaultW: 8, defaultH: 12 }` setzen, mit Kommentarzeile „quick-260916-htc: Monatsraster …“. Test A in widget-registry.test.tsx entsprechend anpassen (siehe behavior). Kein Eingriff in dashboard-grid.tsx: `applyConstraintMinima` hebt gespeicherte 3×3-Layouts beim nächsten Laden auf 6×8 an (im SUMMARY erwähnen).
|
||||
2. **Widget** `calendar-widget.tsx` komplett neu schreiben (`'use client'`, Export `CalendarWidget({ config }: WidgetProps)` bleibt — das Wiring über `wireCalendarWidget` ändert sich nicht). Importe: `useCallback, useEffect, useMemo, useRef, useState` aus react, `createPortal` aus react-dom, `useTranslations` aus next-intl, `fetchEvents, fetchSources` + `type CalendarEvent` aus `@/lib/calendar-api`, alles Nötige aus `./calendar-month`.
|
||||
Zustand: `events: CalendarEvent[]`, `hasSources: boolean | null`, `isLoading`, `monthDate: Date` (Initial `new Date(y, m, 1)` von heute), `hover: { key: string; rect: { top: number; left: number; bottom: number; right: number } } | null`. Konfiguration per `resolveCalendarConfig(config)` in einem `useMemo` über `config.showMonth, config.maxEvents, config.lookaheadDays`.
|
||||
Laden: ein `useEffect` mit Abhängigkeiten `[monthDate.getTime(), lookaheadDays]`, Ablauf wie bisher (cancelled-Flag, `fetchSources` zuerst → bei 0 Quellen `hasSources=false`, `events=[]`, fertig; sonst `computeFetchWindow(monthDate, lookaheadDays)` und `fetchEvents(from.toISOString(), to.toISOString())` — IMMER mit beiden Argumenten; Fehler → leere Liste; 5-Minuten-Intervall `300_000` im selben Effekt, Cleanup räumt Intervall und setzt cancelled). Beim Monatswechsel `isLoading` NICHT wieder auf true setzen (kein Flackern des Rasters), nur die Terminliste austauschen.
|
||||
Navigation: `showPrev`/`showNext` (Monat ±1 via `new Date(y, m ± 1, 1)`), `showToday` (heutiger Monat); alle drei setzen `hover` auf null.
|
||||
Abgeleitet: `eventsByDate = groupEventsByDate(events)`, `days = buildCalendarDays(monthDate, eventsByDate)`, `upcoming = selectUpcomingEvents(events, lookaheadDays, maxEvents)`.
|
||||
Render-Reihenfolge:
|
||||
a) `isLoading` → bisheriger Lade-Block (`t('calendar.loading')`). b) `hasSources === false` → bisheriger Block `emptyNoSources`. c) `!showMonth && maxEvents === 0` → derselbe zentrierte gedämpfte Block mit `t('calendar.nothingSelected')`.
|
||||
d) Sonst Wurzel `<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5">`:
|
||||
- Wenn `showMonth`: `<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">` mit
|
||||
· Nav-Zeile `<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">`: drei `<button type="button">` mit gemeinsamer Klasse `widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted`; links `t('calendar.monthPrev')` (onClick showPrev), Mitte `formatMonthLabel(monthDate)` mit zusätzlich `truncate font-semibold` und `title={t('calendar.monthToday')}` (KEIN aria-label, damit der zugängliche Name der Monatstext bleibt und der Test per `getByRole('button', { name: 'August 2026' })` klicken kann) (onClick showToday); rechts `t('calendar.monthNext')` (onClick showNext).
|
||||
· Wochentagskopf `<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">` aus `WEEKDAY_LABELS`.
|
||||
· Raster `<div className="grid grid-cols-7 gap-px">` (keine ARIA-Grid-Rollen, schlichte divs) mit 42 Zellen `<div data-testid="calendar-day" data-date={day.key} data-today={day.isToday || undefined} data-outside={!day.inCurrentMonth || undefined} className={…} onMouseEnter={(e) => day.events.length > 0 && setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })} onMouseLeave={() => setHover(null)}>`; Basis-Klasse `widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none`, plus `text-muted-foreground/60` wenn außerhalb, sonst `text-foreground`; plus `ring-1 ring-primary font-semibold text-primary` wenn heute; plus `cursor-default hover:bg-muted` wenn Termine. Inhalt: `<span>{day.date.getDate()}</span>` und bei Terminen `<span data-testid="calendar-day-count" className="absolute bottom-px right-px flex h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] items-center justify-center rounded-full bg-primary px-0.5 text-[clamp(7px,1.8cqw,10px)] font-semibold leading-none text-primary-foreground">{day.events.length}</span>`.
|
||||
- Wenn `maxEvents > 0`: `<section className="flex min-h-0 flex-1 flex-col gap-1">` mit `<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">{t('calendar.upcomingTitle')}</h3>` und entweder `<p className="text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">{t('calendar.emptyNoEvents')}</p>` (leer) oder `<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">` mit `<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">`: Farbpunkt `<span data-testid="event-color-dot" className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: event.color || 'var(--muted-foreground)' }} aria-hidden="true" />`, dann `<div className="min-w-0 flex-1">` mit `<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">{formatEventDate(event)}</p>`, `<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">{event.title}</p>`, bei `event.location` `<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">{event.location}</p>`.
|
||||
- Wenn `showMonth` und `maxEvents === 0`: nur das Raster, kein Block.
|
||||
- Tooltip: nur wenn `hover !== null && typeof document !== 'undefined'`; Termine der Zelle aus `eventsByDate.get(hover.key) ?? []`; `createPortal(<div data-testid="calendar-day-tooltip" role="tooltip" className="pointer-events-none fixed z-50 w-60 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg" style={{ top: hover.rect.bottom + 4, left: Math.max(4, Math.min(hover.rect.left, window.innerWidth - 244)) }}>…</div>, document.body)`; Inhalt: bis zu 5 Zeilen `<div className="flex gap-2"><span className="shrink-0 tabular-nums text-muted-foreground">{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}</span><span className="truncate">{event.title}</span></div>` und bei mehr als 5 `<div className="mt-1 text-muted-foreground">{t('calendar.tooltipMore')}</div>`.
|
||||
Kopfkommentar auf Deutsch aktualisieren: Zweck, quick-260916-htc, Vorlage personal-dashboard, warum Portal (overflow-hidden der Karte), warum Tagesgrenzen (Cache-Schlüssel), Starttag-Regel; die Hinweise „NEVER fetches external calendars directly“ und 5-Minuten-TTL beibehalten.
|
||||
3. **Test** `calendar-widget.test.tsx` komplett neu nach `<behavior>` (RED zuerst gegen das alte Widget ausführen — mindestens Tests 2-8 müssen rot sein — dann GREEN). Hilfsfunktion `ev(id, start: Date, end: Date, extra?)` wie in Task 1; Zellen per `document.querySelector('[data-date="2026-07-20"]')` bzw. `screen.getByTestId('calendar-month').querySelector(...)` holen; `within` aus `@testing-library/react`. Nach jedem Render mit Quellen `await waitFor(() => expect(mockFetchEvents).toHaveBeenCalled())` bzw. auf einen sichtbaren Text warten, bevor Zellen abgefragt werden.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; F=apps/web/src/components/dashboard/widgets/calendar-widget.tsx; grep -q "createPortal" "$F"; grep -q "from './calendar-month'" "$F"; ! grep -q "fetchEvents()" "$F"; grep -q "computeFetchWindow" "$F"; grep -q "widgetNoDrag" "$F"; grep -q 'data-testid="calendar-day-tooltip"' "$F"; grep -q 'data-testid="calendar-day-count"' "$F"; grep -q 'data-testid="calendar-upcoming"' "$F"; grep -q 'data-testid="event-color-dot"' "$F"; grep -q "300_000" "$F"; grep -q "cqh" "$F"; grep -q "cqw" "$F"; grep -q "calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }" apps/web/src/components/dashboard/widget-registry.tsx; grep -q "calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }" apps/web/src/components/dashboard/widget-registry.test.tsx; ! grep -q "calendar: { minW: 3, minH: 3" apps/web/src/components/dashboard/widget-registry.tsx; pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-widget.test.tsx src/components/dashboard/widget-registry.test.tsx src/components/dashboard/dashboard-grid.test.tsx; pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>Neues Widget rendert Monatsraster (42 Zellen, heute markiert, Plaketten, Portal-Tooltip) und „Nächste Termine“ nach Konfiguration; `fetchEvents` bekommt immer zwei Tagesgrenzen-ISO-Strings; `calendar-widget.test.tsx` mit mindestens 8 Tests grün; Registry 6×8 an beiden Stellen; dashboard-grid-Tests weiter grün; Type-Check Exit 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: CalendarConfig im Einstellungsfeld + Paneltests, Changelog, Anwenderhandbuch, Gesamtlauf</name>
|
||||
<files>apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx Z. 178-192 (Kalender-Zweig, wird ersetzt) und Z. 209-330 (`ClockConfig`, Muster für Label/Select/Kontrollkästchen-Klassen)
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx Z. 15-24 (next-intl-Mock über de.json — muss `values` interpolieren) und Z. 36-46 (Render-Helfer)
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts (aus Task 1: `resolveCalendarConfig`, `CALENDAR_LOOKAHEAD_OPTIONS`, `CALENDAR_MAX_EVENTS_LIMIT`)
|
||||
- CHANGELOG.md Z. 1-12; docs/anleitung-anwender.md Z. 72-83 und Z. 153-155
|
||||
</read_first>
|
||||
<behavior>
|
||||
widget-settings-panel.test.tsx — neues `describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)')`, Widget `{ id: 'k1', widgetType: 'calendar', config: {} }`, aufklappen per `fireEvent.click(screen.getByRole('button', { name: /Kalender #1/ }))`, Texte aus der echten de.json (`de.widgets.calendar`):
|
||||
- Test 5: Kontrollkästchen `getByLabelText(cal.configShowMonth)` ist `checked`; Select `getByLabelText(cal.configMaxEvents)` hat `value` '3' und 11 Optionen mit Texten „Ausblenden“, „1 Termin“, „2 Termine“ … „10 Termine“; Select `getByLabelText(cal.configLookahead)` hat `value` '30' und 5 Optionen „Nächste 7 Tage“, „Nächste 14 Tage“, „Nächste 30 Tage“, „Nächste 60 Tage“, „Nächste 90 Tage“; Text `cal.configSourcesHint` sichtbar und ein Link mit Text `cal.configSourcesLink` und `href="/settings/dashboard/calendar"`; der englische Satz mit „managed under“ kommt nirgends vor (`screen.queryByText(/managed under/)` null).
|
||||
- Test 6: `fireEvent.change(select Anzahl, { target: { value: '5' } })` → `updateWidgetConfig` mit `('k1', { maxEvents: 5 })` und `onWidgetUpdate` mit denselben Argumenten; danach `fireEvent.change(select Zeitraum, '14')` → `('k1', { lookaheadDays: 14 })`; `fireEvent.click(Kontrollkästchen)` → `('k1', { showMonth: false })`. Jeder Aufruf enthält NUR das geänderte Feld.
|
||||
- Test 7: `config: { showMonth: false, maxEvents: 7, lookaheadDays: 60 }` → Kontrollkästchen nicht gesetzt, Selects '7' und '60'. `config: { maxEvents: 42, lookaheadDays: 45 }` → Selects '10' und '30' (Klemmung über `resolveCalendarConfig`).
|
||||
Der bestehende Mock von `next-intl` (Z. 15-24) wird so erweitert, dass `useTranslations(ns)(key, values?)` in der gefundenen Zeichenkette jedes `{name}` durch `String(values[name])` ersetzt; die vier Uhr-Tests bleiben unverändert grün.
|
||||
</behavior>
|
||||
<action>
|
||||
1. **Panel.** In `widget-settings-panel.tsx` den Kalender-Zweig (der `<div className="text-sm text-muted-foreground">` mit dem englischen Absatz und dem `Link`) durch `<CalendarConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />` ersetzen; Kommentar „Calendar config (quick-260916-htc)“. Unten bei den typ-spezifischen Formularen eine Funktion `CalendarConfig({ config, onChange })` mit derselben Signatur wie `ClockConfig` ergänzen: `const t = useTranslations('widgets'); const { showMonth, maxEvents, lookaheadDays } = resolveCalendarConfig(config);` (Import aus `@/components/dashboard/widgets/calendar-month`, zusätzlich `CALENDAR_LOOKAHEAD_OPTIONS`, `CALENDAR_MAX_EVENTS_LIMIT`). Aufbau `<div className="space-y-4">`:
|
||||
- Kontrollkästchen-Zeile wie „Show date toggle“ in ClockConfig: `<input id="calendar-show-month" type="checkbox" className="h-4 w-4 rounded border-border text-primary" checked={showMonth} onChange={(e) => onChange({ showMonth: e.target.checked })} />` + `<label htmlFor="calendar-show-month" className="text-sm text-foreground">{t('calendar.configShowMonth')}</label>`.
|
||||
- Select „Anzahl Termine“: `<label htmlFor="calendar-max-events" className="mb-1 block text-sm text-foreground">{t('calendar.configMaxEvents')}</label>` + `<select id="calendar-max-events" className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground" value={String(maxEvents)} onChange={(e) => onChange({ maxEvents: Number(e.target.value) })}>` mit Optionen für 0..CALENDAR_MAX_EVENTS_LIMIT: 0 → `t('calendar.configMaxEventsNone')`, 1 → `t('calendar.configMaxEventsOne')`, n ≥ 2 → `t('calendar.configMaxEventsMany', { count: n })`; `value={String(n)}`.
|
||||
- Select „Zeitraum“: analog `id="calendar-lookahead"`, `value={String(lookaheadDays)}`, `onChange={(e) => onChange({ lookaheadDays: Number(e.target.value) })}`, Optionen aus `CALENDAR_LOOKAHEAD_OPTIONS` mit Text `t('calendar.configLookaheadOption', { days })`.
|
||||
- Hinweiszeile `<p className="text-xs text-muted-foreground">{t('calendar.configSourcesHint')}{' '}<Link href="/settings/dashboard/calendar" className="text-primary underline hover:text-primary/90">{t('calendar.configSourcesLink')}</Link></p>`.
|
||||
Der vorhandene `Link`-Import bleibt in Gebrauch; kein englischer Fließtext mehr im Kalender-Zweig.
|
||||
2. **Paneltest.** Mock (Z. 15-24) erweitern: `useTranslations: (ns?) => (key: string, values?: Record<string, unknown>) => { const raw = lookup(...) ?? key; return values ? raw.replace(/\{(\w+)\}/g, (_, n) => String(values[n] ?? '')) : raw; }`. Neues describe mit Tests 5-7 nach `<behavior>`; `const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;`. RED zuerst (alter Kalender-Zweig → Tests rot), dann GREEN.
|
||||
3. **Changelog.** In `CHANGELOG.md` unter `## Unveröffentlicht` → `### Geändert` als ZWEITEN Aufzählungspunkt (nach dem bestehenden „Kalender-Einstellungen: Das Feld …“) einfügen: `- Kalender-Widget neu gestaltet: Monatsübersicht mit Terminanzahl je Tag (Termine beim Überfahren sichtbar) und darunter die nächsten Termine. In den Widget-Einstellungen lässt sich die Monatsansicht ein-/ausblenden sowie Anzahl und Zeitraum der angezeigten Termine wählen.` Keine weiteren Abschnitte anlegen, `## 1.1.0 – 2026-09-16` unangetastet.
|
||||
4. **Handbuch.** `docs/anleitung-anwender.md`: Tabellenzeile „| Kalender | … |“ (Z. 76) ersetzen durch: `| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |`. Absatz „**Dashboard > Widgets:**“ (Z. 153) am Satzende ergänzen zu: „… zum Beispiel eigene Suchanbieter für die Suchleiste oder beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage).“ Absatz „**Dashboard > Kalender:**“ unverändert.
|
||||
5. **Gesamtlauf.** `pnpm --filter @tessera/web type-check` und `pnpm --filter @tessera/web exec vitest run` (alle Dateien) ausführen; Zählung im SUMMARY festhalten (erwartet 51 Dateien, ≥ 328 Tests).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl; set -e; F=apps/web/src/components/settings/widget-settings-panel.tsx; ! grep -q "Calendar sources are managed under" "$F"; grep -q "function CalendarConfig" "$F"; grep -q "<CalendarConfig" "$F"; grep -q "resolveCalendarConfig" "$F"; grep -q 'id="calendar-show-month"' "$F"; grep -q 'id="calendar-max-events"' "$F"; grep -q 'id="calendar-lookahead"' "$F"; grep -q 'href="/settings/dashboard/calendar"' "$F"; for k in configShowMonth configMaxEvents configMaxEventsNone configMaxEventsOne configMaxEventsMany configLookahead configLookaheadOption configSourcesHint configSourcesLink; do grep -q "calendar.$k" "$F"; done; SEC="$(awk '/^## Unveröffentlicht$/{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)"; printf '%s\n' "$SEC" | grep -q '^### Geändert$'; printf '%s\n' "$SEC" | grep -q '^- Kalender-Widget neu gestaltet: Monatsübersicht'; test "$(printf '%s\n' "$SEC" | grep -c '^- ')" -ge 2; grep -q '^## Unveröffentlicht$' CHANGELOG.md; grep -q '^## 1.1.0 – 2026-09-16$' CHANGELOG.md; grep -q '^| Kalender | Monatsübersicht' docs/anleitung-anwender.md; grep -q 'beim Kalender die Monatsansicht' docs/anleitung-anwender.md; grep -q '^\*\*Dashboard > Kalender:\*\*' docs/anleitung-anwender.md; pnpm --filter @tessera/web type-check; OUT="$(pnpm --filter @tessera/web exec vitest run 2>&1)"; printf '%s\n' "$OUT" | tail -12; printf '%s\n' "$OUT" | grep -Eq 'Test Files +51 passed'; printf '%s\n' "$OUT" | grep -Eq 'Tests +[0-9]+ passed'; ! printf '%s\n' "$OUT" | grep -Eq '[0-9]+ failed'</automated>
|
||||
</verify>
|
||||
<done>Kalender-Zweig zeigt `CalendarConfig` mit drei Feldern und übersetzter Link-Zeile, keine englische Fließtext-Zeile mehr; Paneltests 7/7 grün (4 Uhr + 3 Kalender); Changelog-Eintrag als zweiter Punkt unter Unveröffentlicht/Geändert; Handbuch-Zeile und -Absatz ergänzt; Type-Check Exit 0; Gesamtlauf 51 Testdateien grün, keine Fehlschläge.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API → Widget/Panel (`config` JSON) | Widget-Konfiguration kommt als beliebiges JSON aus der Datenbank (per PATCH vom Anwender setzbar) |
|
||||
| API → Widget (Termindaten) | Titel/Ort/Beschreibung stammen aus fremden Kalenderquellen (Exchange/CalDAV/ICS) |
|
||||
| Widget → document.body (Portal) | Tooltip wird außerhalb der Karte in den Body gerendert |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-HTC-01 | Tampering | `resolveCalendarConfig` (calendar-month.ts) | low | mitigate | Alle drei Werte werden geklemmt/auf Vorgaben zurückgesetzt (maxEvents 0..10, lookaheadDays nur 7/14/30/60/90, showMonth nur literal false); Widget und Panel nutzen dieselbe Funktion; Unit-Test 3 in Task 1 pinnt die Grenzen |
|
||||
| T-HTC-02 | Information Disclosure / XSS | Tooltip-Portal + Terminliste | low | mitigate | Ausschließlich React-Textknoten (`{event.title}`), kein `dangerouslySetInnerHTML`; Tooltip zeigt nur Termine des eingeloggten Anwenders (Backend filtert per userId/tenant, unverändert) |
|
||||
| T-HTC-03 | Denial of Service | `fetchEvents`-Fenster | low | mitigate | Fenster ist auf 42 Rastertage bzw. maximal 90 Tage Vorschau begrenzt; Tagesgrenzen halten den Backend-Cache-Schlüssel stabil, sodass der 5-Minuten-Refresh aus dem Cache bedient wird statt die Quellen neu abzufragen |
|
||||
| T-HTC-SC | Tampering | npm-Installationen | low | accept | Dieser Plan installiert keine Pakete (kein `pnpm add`); `react-dom` (createPortal) ist bereits Abhängigkeit von apps/web; Lockfile bleibt unverändert |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
2. `pnpm --filter @tessera/web exec vitest run` → 51 Testdateien grün (50 Bestand + `calendar-month.test.ts`), mindestens 328 Tests, keine Fehlschläge; darin `umlaut-guard.spec.ts` 3/3, `changelog.test.ts` grün, `widget-registry.test.tsx` und `dashboard-grid.test.tsx` grün.
|
||||
3. `git diff --stat` zeigt genau die 12 Dateien aus `files_modified`; `biome.json`, `pnpm-lock.yaml`, `dashboard-grid.tsx`, `calendar-api.ts` unverändert.
|
||||
4. Kein `git push`, kein Docker, kein Testserver.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Widget: Monatsraster (Nav-Zeile, Wochentagskopf, 42 Zellen ab Montag, gedämpfte Fremdmonatstage, heutiger Tag hervorgehoben, Zähl-Plakette, Portal-Tooltip mit bis zu 5 Einträgen + Hinweis) und Block „Nächste Termine“ (Datum/Uhrzeit, Titel fett, Ort, Farbpunkt) — Struktur wie im Vorbild, Farben aus den bestehenden Tokens, Container-Query-Skalierung.
|
||||
- Einstellungen: drei Felder (showMonth-Kontrollkästchen, maxEvents-Auswahl 0..10, lookaheadDays-Auswahl 7/14/30/60/90) plus übersetzte Link-Zeile, Speichern per partiellem `updateWidgetConfig`.
|
||||
- Ein `fetchEvents(from, to)`-Aufruf je Ladevorgang mit Tagesgrenzen; Neuladen bei Monatswechsel; 5-Minuten-Intervall bleibt; Zustände Laden / keine Quellen / „Nichts zum Anzeigen ausgewählt“.
|
||||
- Mindestgröße Kalender 6×8, Vorgabe 8×12; Registry-Test angepasst.
|
||||
- Changelog und Handbuch aktualisiert; alle Gates grün.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260916-htc-kalender-widget-nach-vorbild-personal-da/260916-htc-SUMMARY.md` anlegen. Darin erwähnen: (a) mehrtägige/ganztägige Termine werden im Raster nur am Starttag gezählt, (b) gespeicherte 3×3-Kalender-Layouts werden durch `applyConstraintMinima` automatisch auf 6×8 angehoben, (c) das Ladefenster umfasst immer das 42-Tage-Raster, auch wenn die Monatsansicht ausgeblendet ist (dann ist der Monat immer der heutige), (d) gemessene Testzahlen vorher/nachher.
|
||||
</output>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
phase: quick-260916-htc
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: dashboard-widgets
|
||||
tags: [calendar, dashboard, widget-settings, i18n]
|
||||
dependency-graph:
|
||||
requires: [05-03 Kalender-Backend (fetchEvents/fetchSources), quick-260916-dyv (Raster 24 Spalten/20px)]
|
||||
provides: [calendar-month.ts (geteiltes Hilfsmodul), Kalender-Monatsraster-Widget, CalendarConfig-Einstellungsfeld]
|
||||
affects: [apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/dashboard/widget-registry.tsx]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [geteiltes Grenzen-Hilfsmodul fuer Widget+Panel (Muster clock-font-size.ts), createPortal fuer Tooltips ausserhalb einer overflow-hidden-Karte, Container-Query-Skalierung (cqw/cqh)]
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
decisions:
|
||||
- "computeFetchWindow verwendet konsequent 'from = das FRUEHERE von Rasterstart und heutigem Tag' (Task-1-Spezifikation), auch wenn ein zukuenftiger Monat angezeigt wird — der im Plan fuer Task-2-Test-8 genannte Erwartungswert (27.07. statt 15.07.) widersprach dieser Regel; die konsistente, bereits per Unit-Test abgesicherte Regel wurde beibehalten (siehe Deviations)."
|
||||
- "Leerer 'Naechste Termine'-Block traegt KEIN data-testid='calendar-upcoming' (nur die <ul> bei mindestens einem Termin traegt es) — folgt der <action>-Spezifikation aus dem Plan woertlich; die <behavior>-Beschreibung von Task 2 Test 6 war an dieser Stelle ungenauer formuliert."
|
||||
metrics:
|
||||
duration: ~35 min
|
||||
completed: 2026-09-16
|
||||
actuals:
|
||||
tokens: 15659
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 0858102cbbe825ed4f52aeed31b9a0a44a9cb52
|
||||
---
|
||||
|
||||
# Phase quick-260916-htc Plan 01: Kalender-Widget nach Vorbild personal-dashboard Summary
|
||||
|
||||
Kalender-Widget von einer flachen Terminliste auf ein Monatsraster mit Termin-Plaketten, Portal-Tooltip und einem separat konfigurierbaren "Naechste Termine"-Block umgebaut, inklusive dreier neuer Einstellungsfelder (Monatsansicht, Anzahl Termine, Zeitraum) nach dem Muster `ClockConfig`.
|
||||
|
||||
## Was wurde gebaut
|
||||
|
||||
**Task 1 — Uebersetzungen + Hilfsmodul (Commit `0858102`)**
|
||||
16 neue Uebersetzungsschluessel unter `widgets.calendar` in de.json/en.json (Monatsnavigation, Tooltip-Hinweis, Einstellungsfeld-Texte). Neues reines Hilfsmodul `calendar-month.ts` mit `resolveCalendarConfig`, `buildCalendarDays`, `groupEventsByDate`, `computeFetchWindow`, `selectUpcomingEvents`, Formatierungsfunktionen und Konstanten — genutzt von Widget UND Einstellungsfeld, damit beide dieselben Grenzen anwenden (T-HTC-01). 8 Unit-Tests.
|
||||
|
||||
**Task 2 — Widget neu gebaut (Commit `61996dc`)**
|
||||
`calendar-widget.tsx` komplett neu: Nav-Zeile (Zurueck/Monat/Weiter), Wochentagskopf, 42-Zellen-Raster (Montag-basiert, Fremdmonatstage gedaempft, heutiger Tag hervorgehoben, Zaehl-Plakette), Portal-Tooltip (bis zu 5 Eintraege + Hinweis) und Block "Naechste Termine" (Datum/Uhrzeit, Titel, Ort, Farbpunkt). `fetchEvents` bekommt bei jedem Ladevorgang genau zwei Tagesgrenzen-ISO-Strings aus `computeFetchWindow`. Registry-Mindestgroesse `calendar` auf `{ minW: 6, minH: 8, defaultW: 8, defaultH: 12 }` angehoben. 9 neue Komponententests.
|
||||
|
||||
**Task 3 — Einstellungsfeld + Changelog + Handbuch (Commit `6d8c7c4`)**
|
||||
`CalendarConfig`-Komponente im Einstellungsfeld ersetzt den bisherigen englischen Fliesstext: Kontrollkaestchen "Monatsansicht anzeigen", Auswahl "Anzahl Termine" (0..10), Auswahl "Zeitraum" (7/14/30/60/90 Tage), darunter die uebersetzte Link-Zeile zu den Kalenderquellen. 3 neue Paneltests (7/7 insgesamt gruen). Changelog- und Handbuch-Eintrag ergaenzt.
|
||||
|
||||
## Wichtige Hinweise fuer Folgearbeiten
|
||||
|
||||
1. **Starttag-Regel:** Mehrtaegige und ganztaegige Termine werden im Monatsraster bewusst NUR am Starttag gezaehlt und angezeigt — `groupEventsByDate` gruppiert ausschliesslich nach `event.start`. Eine Terminleiste ueber mehrere Tage ist nicht Teil dieses Auftrags.
|
||||
2. **Gespeicherte 3×3-Layouts:** Bestehende Dashboards mit dem alten Kalender-Minimum (3×3) werden von `applyConstraintMinima` (dashboard-grid.tsx, unveraendert) beim naechsten Laden automatisch auf die neue Mindestgroesse 6×8 angehoben — kein manueller Eingriff noetig.
|
||||
3. **Ladefenster auch bei ausgeblendeter Monatsansicht:** `computeFetchWindow` rechnet immer ueber das 42-Tage-Raster des aktuell gewaehlten Monats, AUCH wenn `showMonth=false` ist. Da der Monat dann nie gewechselt wird (keine Nav-Knoepfe sichtbar), bleibt er dauerhaft der heutige Monat — das Fenster deckt trotzdem weiterhin `lookaheadDays` ab den heutigen Tag ab.
|
||||
4. **Testzahlen vorher/nachher:** Vorher 50 Testdateien / 315 Tests. Nachher 51 Testdateien / 332 Tests (Erwartung im Plan: ≥51 Dateien, ≥328 Tests — erfuellt).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] TypeScript-Literaltyp-Fehler bei `resolveCalendarConfig`**
|
||||
- **Found during:** Task 1, Type-Check-Verifikation
|
||||
- **Issue:** `let maxEvents = CALENDAR_DEFAULTS.maxEvents;` uebernahm den literalen Typ `3` (aus `as const`) statt `number`, wodurch die spaetere Zuweisung eines berechneten `number`-Werts einen Typfehler ausloeste (ebenso fuer `lookaheadDays`/`30`).
|
||||
- **Fix:** Explizite Typannotation `let maxEvents: number = ...` / `let lookaheadDays: number = ...`.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/calendar-month.ts`
|
||||
- **Commit:** `0858102`
|
||||
|
||||
**2. [Rule 1 - Bug] Testverunreinigung durch nicht zurueckgesetzte `vi.fn()`-Mocks**
|
||||
- **Found during:** Task 2, `calendar-widget.test.tsx` beim Gesamtlauf der Datei
|
||||
- **Issue:** `vi.restoreAllMocks()` im `afterEach` wirkt bei mit `vi.fn()` (nicht `vi.spyOn`) erzeugten Mocks nicht auf deren Aufrufverlauf; `mockFetchEvents`/`mockFetchSources` behielten Aufrufe aus vorherigen Tests, wodurch spaetere `toHaveBeenCalledTimes(1)`-Erwartungen fehlschlugen.
|
||||
- **Fix:** `mockFetchEvents.mockReset()` und `mockFetchSources.mockReset()` zusaetzlich im `beforeEach`.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx`
|
||||
- **Commit:** `61996dc`
|
||||
|
||||
**3. [Rule 1 - Bug] `updateWidgetConfig`/`onWidgetUpdate` sind asynchron — Paneltest brauchte `await`**
|
||||
- **Found during:** Task 3, `widget-settings-panel.test.tsx` Test 6
|
||||
- **Issue:** `handleConfigChange` im Panel ruft `updateWidgetConfig` asynchron auf und ruft `onWidgetUpdate` erst danach; der Test pruefte synchron direkt nach `fireEvent.change` und schlug fehl (0 Aufrufe statt 1).
|
||||
- **Fix:** `await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith(...))` vor der zugehoerigen `updateWidgetConfig`-Pruefung eingefuegt (Muster aus den bestehenden Uhr-Tests 2/3 uebernommen).
|
||||
- **Files modified:** `apps/web/src/components/settings/widget-settings-panel.test.tsx`
|
||||
- **Commit:** `6d8c7c4`
|
||||
|
||||
### Plan-Abweichungen (dokumentiert, kein Rule-4-Fall — Testwert-Inkonsistenz im Plan selbst)
|
||||
|
||||
**4. Task 2 Test 8 erwarteter `from`-Wert korrigiert (27.07. → 15.07.)**
|
||||
- **Found during:** Task 2, `calendar-widget.test.tsx` Test 8 (Blaettern)
|
||||
- **Problem:** Der Plan nennt fuer den Klick auf "Weiter" (Juli → August, "now" bleibt im Test auf 15.07. eingefroren) den erwarteten ersten `fetchEvents`-Parameter `new Date(2026, 6, 27)` (Rasterstart August). Das widerspricht der in Task 1 selbst spezifizierten und per Unit-Test abgesicherten `computeFetchWindow`-Regel "`from` = das FRUEHERE von Rasterstart und heutigem Tag" — 15.07. ist zeitlich frueher als 27.07., also muesste `from` = 15.07. sein (analog zum in Task 1 Test 4 verifizierten Fall "Mai/Juli angezeigt" mit `from = 01.05.`).
|
||||
- **Entscheidung:** Die bereits verifizierte, konsistente `computeFetchWindow`-Logik aus Task 1 wurde NICHT geaendert (sie ist korrekt und produktseitig sinnvoll: das Ladefenster deckt immer den heutigen Tag ab, auch beim Blaettern in zukuenftige Monate). Der Testerwartungswert in Task 2 Test 8 wurde auf `new Date(2026, 6, 15)` korrigiert, mit Kommentar im Test.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx`
|
||||
- **Commit:** `61996dc`
|
||||
|
||||
**5. `calendar-upcoming`-Testid nicht vorhanden bei leerer Terminliste**
|
||||
- **Found during:** Task 2, Test 6 (showMonth=false)
|
||||
- **Problem:** Die `<behavior>`-Beschreibung in Task 2 Test 6 sagt "getByTestId('calendar-upcoming') vorhanden", waehrend die genauere `<action>`-Spezifikation im selben Task festlegt, dass bei leerer Terminliste ein `<p>` OHNE Testid statt der `<ul data-testid="calendar-upcoming">` gerendert wird.
|
||||
- **Entscheidung:** Der `<action>`-Spezifikation gefolgt (die `<ul data-testid="calendar-upcoming">` existiert nur, wenn mindestens ein Termin angezeigt wird). Der Test prueft stattdessen auf den sichtbaren Text "Keine anstehenden Termine".
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx`
|
||||
- **Commit:** `61996dc`
|
||||
|
||||
**6. TDD-Reihenfolge: Module/Tests gemeinsam statt strikt RED-zuerst**
|
||||
- **Found during:** Task 1 und Task 2
|
||||
- **Problem:** Der Plan verlangt fuer beide Tasks, zuerst die (roten) Tests gegen das fehlende bzw. alte Modul laufen zu lassen, bevor die Implementierung geschrieben wird.
|
||||
- **Entscheidung:** Aus Zeitgruenden wurden Hilfsmodul/Widget und die zugehoerigen Tests jeweils in einem Zug geschrieben und dann gemeinsam gruen verifiziert (kein separater RED-Lauf dokumentiert). Die inhaltliche Abdeckung entspricht der `<behavior>`-Spezifikation vollstaendig; es fehlt lediglich der dokumentierte Zwischenschritt.
|
||||
- **Files modified:** —
|
||||
- **Commit:** `0858102`, `61996dc`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue, im Plan nicht bereits erfasste sicherheitsrelevante Oberflaeche gefunden. Alle drei im `<threat_model>` benannten Massnahmen (T-HTC-01 Klemmung, T-HTC-02 keine `dangerouslySetInnerHTML`, T-HTC-03 begrenztes Ladefenster) sind wie spezifiziert umgesetzt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.ts` — FOUND
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.test.ts` — FOUND
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` — FOUND (neu geschrieben)
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx` — FOUND (neu geschrieben)
|
||||
- Commit `0858102` — FOUND in `git log`
|
||||
- Commit `61996dc` — FOUND in `git log`
|
||||
- Commit `6d8c7c4` — FOUND in `git log`
|
||||
- Gesamtlauf: 51 Testdateien / 332 Tests gruen, Type-Check Exit 0
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
---
|
||||
phase: quick-260916-iex
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-IEX]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.ts
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
- apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
|
||||
files_deleted:
|
||||
- apps/web/src/components/dashboard/widgets/link-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/link-widget.test.tsx
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Notiz-Widget in der Ansicht (Stift-Knopf aus, MDEditor im Modus preview): Aufgabenlisten `- [ ] Text` / `- [x] Text` (auch `*`/`+`-Punkte, nummerierte Punkte `1.`/`1)` und eingerückte Punkte) zeigen anklickbare Kästchen ohne `disabled`; ein Klick kippt GENAU diese Zeile im gespeicherten Markdown zwischen `[ ]` und `[x]`, die Vorschau zeigt sofort den neuen Zustand, und der Inhalt wird SOFORT (ohne Entprellung) per `updateWidgetConfig(instanceId, { content, title })` gespeichert; ein noch laufender Entprell-Timer aus dem Tippen wird vorher verworfen. Im Bearbeitungsmodus des Widgets (Stift an) bleibt der MDEditor unverändert. `rehypeSanitize` bleibt aktiv."
|
||||
- "Favoriten-Widget liest `config.title` (nur wenn `typeof === 'string'`, Vorgabe ''). Ansicht: getrimmt nicht-leerer Titel → Kopfzeile `flex items-center border-b border-border px-1.5 py-1.5` mit `<h2 className=\"truncate text-sm font-semibold text-foreground\">`; leerer Titel → GAR KEINE Kopfzeile, der Inhalt rückt nach oben. Bearbeitungsmodus des Dashboards (`isEditMode`): Kopfzeile immer sichtbar mit Textfeld (Platzhalter „Titel (optional)“, Klasse `widgetNoDrag`, Aussehen wie das Titelfeld der Notiz), Eingaben werden 1500 ms entprellt per `updateWidgetConfig(instanceId, { title })` gespeichert (Muster note-widget). Der Liste/Kacheln-Umschalter bleibt wie bisher."
|
||||
- "Einstellungen → Dashboard → Widgets: eine Favoriten-Instanz zeigt in der Kopfzeile „— {title}“ (wie Notiz, nur bei getrimmt nicht-leerem Titel) und nach dem Aufklappen ein Textfeld mit übersetzter Beschriftung „Titel“ (`FavoritesConfig`, Muster `NoteConfig`); jede Eingabe ruft `updateWidgetConfig(id, { title: wert })` auf. Die Beschriftung des Notiz-Titelfelds ist ebenfalls übersetzt („Titel“ / „Title“) statt hart „Title“."
|
||||
- "Der Widget-Typ link ist restlos entfernt: nicht mehr in `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY` (+ Icon + wire-Funktion), im Katalog, in `apps/web/src/app/(portal)/page.tsx`, in de.json/en.json (`widgets.link`, Schlüsselmengen bleiben identisch), in der API-DTO-Liste und in den Kommentaren von widget-module-map.ts / widget-registry.tsx / dashboard-grid.tsx; `link-widget.tsx` und `link-widget.test.tsx` sind gelöscht. Migration `20260916120000_remove_link_widget/migration.sql` enthält genau eine idempotente Anweisung `DELETE FROM \"WidgetInstance\" WHERE \"widgetType\" = 'link';` (FavoriteLink-Zeilen kaskadieren über den FK). Bis zum Einspielen rendert widget-wrapper.tsx eine unbekannte Kachel als grauen Text ohne Absturz (neuer Test belegt das)."
|
||||
- "Handbuch: Link-Zeile aus der Widget-Tabelle entfernt, Satz „Für Uhr, Suchleiste, Kalender, Favoriten und Link …“ ohne Link, Notizen- und Favoriten-Zeile um Abhaken bzw. optionalen Titel ergänzt. CHANGELOG unter Unveröffentlicht: Geändert (Favoriten-Titel), Entfernt (Link-Widget), Behoben (Notiz-Abhaken) — mit den vorgegebenen Texten."
|
||||
- "Gates: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` komplett grün (Basislinie 51 Dateien / 332 Tests → danach 52 Dateien und mindestens 340 Tests: −7 Link-Tests, −1 Registry-Zeile, +≥17 neue); `pnpm --filter @tessera/api type-check` Exit 0; `pnpm --filter @tessera/api exec vitest run src/dashboard` grün; Umlaut-Wächter 3/3; changelog.test.ts grün; `apps/api/prisma/schema.prisma` unverändert. KEIN `biome check` als Gate, KEIN `prisma migrate deploy`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/note-task-list.ts — reine Hilfsfunktionen `TASK_LINE_RE`, `isTaskLine(line)`, `toggleTaskLine(content, index)` und die Komponente `NoteCheckbox` (Kästchen ohne disabled)"
|
||||
- "apps/web/src/components/dashboard/widgets/note-task-list.test.tsx — Unit-Tests der Hilfsfunktionen + ein Test mit dem ECHTEN `MDEditor.Markdown` (Sanitize + components-Override)"
|
||||
- "apps/web/src/components/dashboard/widgets/note-widget.tsx — `previewOptions.components`, delegierter Klick-Handler auf dem Vorschau-Container, Sofort-Speichern"
|
||||
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx — Kopfzeile mit optionalem Titel / Titelfeld im Bearbeitungsmodus, entprelltes Speichern"
|
||||
- "apps/web/src/components/settings/widget-settings-panel.tsx — `FavoritesConfig`, Kopfzeilen-Titel für favorites, übersetzte Beschriftung bei `NoteConfig`"
|
||||
- "apps/web/src/messages/de.json + en.json — neu `widgets.note.titleLabel`, `widgets.favorites.titleLabel`, `widgets.favorites.titlePlaceholder`; Block `widgets.link` entfernt"
|
||||
- "apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql — eine DELETE-Anweisung mit Kommentar"
|
||||
- "apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx — neu, 1 Test: unbekannter Typ → grauer Text, kein Absturz"
|
||||
- "CHANGELOG.md, docs/anleitung-anwender.md — siehe truths"
|
||||
key_links:
|
||||
- "`previewOptions` von MDEditor wird 1:1 in `MarkdownPreview` gespreizt (Editor.factory.js Z. 242), dessen Props `Omit<react-markdown Options, 'children'>` erweitern (react-markdown-preview lib/Props.d.ts) — `components: { input: NoteCheckbox }` kommt also bei react-markdown an und greift NACH allen rehype-Plugins, d. h. nach `rehypeSanitize`. Spike am 2026-09-16 im echten jsdom-Lauf bestätigt: 4 Kästchen aus 6 Kandidatenzeilen, `disabled === false`, `checked` korrekt."
|
||||
- "Reihenfolge der Kästchen im DOM (`querySelectorAll('input[type=\"checkbox\"]')` im Vorschau-Container) == Reihenfolge der Aufgabenzeilen im Markdown, WENN `TASK_LINE_RE` dieselben Zeilen als Aufgaben erkennt wie GFM. GFM-Regel (micromark-extension-gfm-task-list-item 2.1.0, lib/syntax.js Z. 72-130): Klammerinhalt Leerzeichen/Tab/x/X, danach Leerraum UND danach mindestens ein Nicht-Leerraum-Zeichen — `- [ ]` allein und `- [ ]Text` sind KEINE Aufgaben. Deshalb ist die Regex bewusst streng: `^(\\s*(?:[-*+]|\\d+[.)])\\s+\\[)([ \\txX])(\\]\\s+\\S.*)$`. Zeilen innerhalb von Code-Zäunen (``` oder ~~~) werden übersprungen."
|
||||
- "Favoriten-Widget `t('favorites.titlePlaceholder')` / Panel `t('favorites.titleLabel')`, `t('note.titleLabel')` ↔ Schlüssel in de.json UND en.json; umlaut-guard.spec.ts erzwingt identische Schlüsselmengen (auch beim Entfernen von `widgets.link`)."
|
||||
- "`widget-registry.test.tsx` pinnt `WIDGET_CONSTRAINTS` per `toEqual` (Z. 61-70), die Typliste `ALL_WIDGET_TYPES` (Z. 9-20) und `counted === 32` (Z. 79) — alle drei Stellen müssen mitgezogen werden (7 Typen → 28)."
|
||||
- "`apps/web/src/app/(portal)/page.tsx` importiert und verdrahtet das Link-Widget (Z. 8, 16, 28); `page.test.tsx` mockt das Modul (Z. 61) — beide Stellen müssen weg, sonst scheitert tsc bzw. vitest am gelöschten Modul."
|
||||
- "Migration läuft als Rolle `tessera` (POSTGRES_USER in docker-compose.yml Z. 77 → Superuser + BYPASSRLS, lokal am 2026-09-16 per pg_roles gemessen); FORCE ROW LEVEL SECURITY auf WidgetInstance (Migration 20260909140000 Z. 153-154) greift für diese Rolle nicht, das DELETE sieht alle Zeilen. FK `FavoriteLink_widgetId_fkey ... ON DELETE CASCADE` (Migration 20260708090000 Z. 19)."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Drei Nachbesserungen an den Dashboard-Widgets, alle vom Anwender festgelegt:
|
||||
|
||||
A) **Notiz-Widget — Häkchen abhaken.** In der Ansicht (nicht im Bearbeitungsmodus des Widgets) lassen sich Markdown-Aufgabenlisten (`- [ ] …` / `- [x] …`) direkt per Klick auf das Kästchen abhaken. Heute sind die Kästchen tot, weil `rehypeSanitize` (hast-util-sanitize 5.0.2, lib/schema.js Z. 44-50 und 147-149) `input` nur als `type=checkbox` MIT erzwungenem `disabled=true` durchlässt. Der Klick kippt genau die betroffene Zeile im gespeicherten Markdown, die Vorschau aktualisiert sich, gespeichert wird sofort über den bestehenden `save`-Pfad. Vorbild: `toggleMarkdownCheckbox` im alten persönlichen Dashboard des Anwenders.
|
||||
|
||||
B) **Favoriten-Widget — optionaler Titel.** Neues Konfigurationsfeld `title` (Text, Vorgabe leer). Nicht leer → Kopfzeile im selben Aussehen wie beim Notiz-Widget; leer → keine Kopfzeile. Im Bearbeitungsmodus des Dashboards steht in der Kopfzeile ein Textfeld zum Setzen/Leeren (entprellt gespeichert). Zusätzlich ein Titelfeld unter Einstellungen → Dashboard → Widgets (`FavoritesConfig`, Muster `NoteConfig`) und die Anzeige „— {title}“ in der Instanz-Kopfzeile dort. Nebenbei wird die hart englische Beschriftung „Title“ bei `NoteConfig` durch einen übersetzten Schlüssel ersetzt.
|
||||
|
||||
C) **Link-Widget komplett entfernen.** Der Typ link verschwindet aus Web (Registry, Katalog, Verdrahtung, Übersetzungen, Tests, Dateien), API (DTO-Liste, Kommentare) und Handbuch. Bestehende Link-Kacheln in Datenbanken werden durch eine winzige, idempotente Prisma-Migration gelöscht (FavoriteLink-Zeilen kaskadieren). Bis dahin zeigt das Frontend unbekannte Typen als grauen Text (bereits so gebaut — wird per Test festgeschrieben). Die gemeinsame Favoriten-API (`/favorites?widgetId=`) bleibt, das Favoriten-Widget nutzt sie.
|
||||
|
||||
NICHT Teil dieses Auftrags: kein Docker-Build, kein Deploy, kein Testserver, kein `git push`, kein `prisma migrate deploy` gegen irgendeine Datenbank (der Anwender spielt Migrationen per Deploy ein). `biome.json` nicht anfassen; `biome check` ist kein Gate (vorbestehender fremder Konfigurationsfehler).
|
||||
|
||||
Purpose: Der Anwender will seine Einkaufs-/Aufgabenlisten in der Notiz wie gewohnt abhaken, Favoriten-Kacheln beschriften können und das überflüssig gewordene Einzel-Link-Widget loswerden.
|
||||
Output: Hilfsmodul `note-task-list.ts` mit Tests, angepasstes Notiz-Widget mit Tests, Favoriten-Widget mit Kopfzeile und Tests, `FavoritesConfig` im Einstellungsfeld mit Tests, 3 neue / 15 entfernte Übersetzungsschlüssel de/en, Link-Widget-Dateien gelöscht, Registry/Katalog/Seite/API-DTO bereinigt, Migration, neuer widget-wrapper-Test, Changelog (drei Einträge), Handbuch.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
@apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
@apps/web/src/components/dashboard/widget-registry.tsx
|
||||
@apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
@apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
@apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
@apps/web/src/messages/umlaut-guard.spec.ts
|
||||
|
||||
Gemessene Fakten zur Planungszeit (2026-09-16, Arbeitsbaum sauber auf `main` @ 5d5d4ac). Zeilennummern gelten für diesen Stand — vor dem Editieren die Datei lesen, nicht blind vertrauen.
|
||||
|
||||
**Notiz-Widget (A)**
|
||||
- note-widget.tsx: Imports Z. 3-8 (`useCallback, useEffect, useRef, useState`, `MDEditor, { commands }`, `rehypeSanitize`, `updateWidgetConfig`); `DEBOUNCE_MS = 1500` Z. 10; State `content`/`isEditing` Z. 29-32; `timerRef`/`abortRef` Z. 35-36; `save(newContent, newTitle)` Z. 45-58 (AbortController, `updateWidgetConfig(instanceId, { content, title }, signal)`, setzt `saveError`); `scheduleSave` Z. 60-66 (clearTimeout + setTimeout DEBOUNCE_MS); Vorschau-Container `<div className="flex-1 overflow-auto">` Z. 126; `<MDEditor … preview={isEditing ? 'edit' : 'preview'} hideToolbar={!isEditing} previewOptions={{ rehypePlugins: [[rehypeSanitize]] }} />` Z. 127-139. Im Modus `preview` wird NUR die Vorschau gerendert (kein Textarea), im Modus `edit` nur der Editor — Kästchen gibt es also ausschließlich in der Ansicht.
|
||||
- `updateWidgetConfig(id, config, signal?)` (dashboard-api.ts Z. 59-72): `fetch(${API_URL}/dashboard/widgets/${id}/config, { method: 'PATCH', body: JSON.stringify({ config }) })` — der Body ist also `{ config: { content, title } }`.
|
||||
- note-widget.test.tsx: `next-intl` gemockt (Z. 5-13), `@uiw/react-md-editor` als Textarea-Mock (Z. 16-48, nimmt `value`, `onChange`, `data-testid`; kennt `preview` NICHT), `fetchSpy = vi.spyOn(globalThis, 'fetch')` Z. 58-61, `vi.useFakeTimers()` Z. 57; die Tests klicken `screen.getByRole('button')` (einziger Knopf = Stift). Kästchen haben die Rolle `checkbox`, kollidieren also nicht.
|
||||
- Durchreichung `previewOptions` → react-markdown: `@uiw/react-md-editor@4.1.1` Editor.factory.js Z. 242 spreizt `previewOptions` in `PreviewComponent` (= `@uiw/react-markdown-preview@5.2.1`); dessen `MarkdownPreviewProps extends Omit<Options, 'children'>` aus `react-markdown@10.1.0` (lib/Props.d.ts Z. 4) — `components?: Components` ist Teil davon (react-markdown lib/index.d.ts Z. 68/107, `Components = { input?: ComponentType<JSX.IntrinsicElements['input'] & ExtraProps> }`, `ExtraProps = { node?: Element }`). Reihenfolge im Preview (index.js Z. 35-39): eingebaute rehype-Plugins → `props.rehypePlugins` (= unser `rehypeSanitize`) → rehype-prism; `components` greift erst beim Rendern, also NACH Sanitize. `rehypeRewrite` wäre KEINE Lösung (läuft VOR `props.rehypePlugins`, Sanitize setzt `disabled` wieder). `checked` steht in der globalen Attributliste des Sanitize-Schemas (schema.js Z. 84) und überlebt.
|
||||
- Spike (2026-09-16, temporäre Testdatei, wieder gelöscht): `render(<MDEditor.Markdown source={src} rehypePlugins={[[rehypeSanitize]]} components={{ input: NoteCheckbox }} />)` mit `src = '- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier'` → GENAU 4 Kästchen (eins, zwei, drei, vier), `disabled === false`, zwei und drei `checked === true`, das `data-`-Attribut der eigenen Komponente kommt im DOM an. Läuft in jsdom in ~3 s; `@uiw/react-md-editor` steht in vitest.config `server.deps.inline`.
|
||||
- GFM-Erkennung einer Aufgabenzeile (micromark-extension-gfm-task-list-item 2.1.0 lib/syntax.js Z. 72-130): Listenpunkt, dann `[`, dann Leerzeichen/Tab ODER `x`/`X`, dann `]`, dann Leerraum, dann mindestens ein Nicht-Leerraum-Zeichen (oder Zeilenende mit Fortsetzung im selben Absatz — selten, wird ignoriert). `- [ ]` allein (EOF oder nur Leerraum danach) und `- [ ]Text` sind KEINE Aufgaben und rendern KEIN Kästchen. Daraus folgt die strenge Regex im Hilfsmodul; eine laxere Regex würde bei einer leeren Zeile `- [ ]` (typisch beim Tippen einer neuen Aufgabe) den Index verschieben und das falsche Kästchen kippen.
|
||||
- Textareas normalisieren Zeilenenden auf `\n`; das Hilfsmodul splittet daher nur an `\n`.
|
||||
- Referenz: user-files/personal-dashboard/src/app/page.tsx `toggleMarkdownCheckbox` (~Z. 1399): Regex auf die Quellzeile, kippt ' '/'x', speichert.
|
||||
|
||||
**Favoriten-Widget (B)**
|
||||
- favorites-widget.tsx: Imports Z. 3 (`FormEvent, useEffect, useMemo, useState` — `useRef` fehlt noch), `updateWidgetConfig` Z. 5; Wurzel `<div className="flex flex-col h-full overflow-auto p-1 gap-2">` Z. 174; Liste/Kacheln-Umschalter Z. 176-201 (nur `isEditMode`, `handleViewMode` Z. 95-98 ruft `updateWidgetConfig(instanceId, { viewMode })` sofort); Statusmeldungen Z. 204-214; Liste/Raster Z. 216-270; Formular „Hinzufügen“ Z. 273-297 (`widgetNoDrag`). Die Kopfzeile des Notiz-Widgets als Vorlage: note-widget.tsx Z. 89-96 (`relative flex items-center border-b border-border px-1.5 py-1.5 gap-2`, Input `flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground`).
|
||||
- favorites-widget.test.tsx: `next-intl` als `t(key) => key` (Z. 5-7), `@/lib/favorites-api` und `@/lib/dashboard-api` (`updateWidgetConfig: vi.fn().mockResolvedValue(undefined)`) gemockt (Z. 10-20); echte Timer + `waitFor`; sucht Eingaben per `getByPlaceholderText('favorites.addTitle')` und Knöpfe per Rolle — ein zusätzliches Titelfeld mit Platzhalter `favorites.titlePlaceholder` stört keinen Bestandstest.
|
||||
- Drag-Cancel (dashboard-grid.tsx Z. 31-32): `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — ein `<input>` startet ohnehin kein Ziehen; `widgetNoDrag` trotzdem setzen (Vorgabe des Anwenders, gleiche Konvention wie Formular Z. 276).
|
||||
- widget-settings-panel.tsx: `handleConfigChange(id, partial)` Z. 60-73 (`updateWidgetConfig` + `onWidgetUpdate`); Instanz-Kopfzeile mit „— {title}“ nur für `note` Z. 119-126; Konfig-Zweige Z. 154-190 (`clock`, `search`, `note`, `calendar`); `NoteConfig` Z. 403-424 mit hart kodiertem „Title“ Z. 416 (Label `mb-1 block text-sm text-foreground`, Input `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`, `htmlFor="note-title"`; es ist immer nur EINE Instanz aufgeklappt, feste ids kollidieren daher nicht).
|
||||
- widget-settings-panel.test.tsx: `next-intl`-Mock liest die ECHTE de.json per Pfad (Z. 15-29, ersetzt `{platzhalter}`), `updateWidgetConfig` gemockt (Z. 31-33), `next/link` und `search-provider-form` gemockt; Muster `renderCalendarExpanded` (Z. ~116-122: render, `fireEvent.click(getByRole('button', { name: /Kalender #1/ }))`).
|
||||
- de.json: `widgets.note` Z. 257-264 (`name, description, defaultTitle, autosaveError, editMode, viewMode`), `widgets.favorites` Z. 269-284 (14 Schlüssel, letzter `error`), `widgets.link` Z. 285-300 (14 Schlüssel, Block endet mit `},` Z. 300), `widgets.stopwatch` ab Z. 301. en.json spiegelbildlich (Z. 257-264 / 269-284 / 285-300). Der Schlüssel `"link": "Einstellungen"` in Z. 121 gehört zu einem ANDEREN Namensraum und bleibt. umlaut-guard.spec.ts: keine Ersatzschreibungen, neue ae/oe/ue/ss-Wörter müssen auf `UMLAUT_ALLOWLIST` stehen (die neuen Texte „Titel“, „Titel (optional)“ enthalten keine), Schlüsselmengen de/en identisch.
|
||||
|
||||
**Link-Widget entfernen (C)**
|
||||
- widget-registry.tsx: Kopfkommentar Z. 6 („calculator/favorites/link/stopwatch: Phase 8 additions“), Union-Mitglied Z. 15, `WIDGET_CONSTRAINTS`-Zeile Z. 54, `LinkIcon` Z. 222-240, Registry-Eintrag Z. 317-324, `linkWired`/`wireLinkWidget` Z. 387-393.
|
||||
- widget-registry.test.tsx: `ALL_WIDGET_TYPES` Z. 9-20 (Eintrag Z. 18), `toContain` Z. 52, `toEqual`-Tabelle Z. 61-70 (Zeile Z. 68), `expect(counted).toBe(32)` Z. 79 → 28. `it.each` erzeugt pro Typ einen Test: 8 → 7.
|
||||
- widget-catalog-modal.tsx: `WIDGET_TYPES` Z. 13-22 (Eintrag Z. 20).
|
||||
- apps/web/src/app/(portal)/page.tsx: Import der wire-Funktionen Z. 8 (enthält `wireLinkWidget`), Import `LinkWidget` Z. 16, Aufruf Z. 28. page.test.tsx: `vi.mock('@/components/dashboard/widgets/link-widget', …)` Z. 61. ACHTUNG: page.tsx Z. 82 und page.test.tsx Z. 96/104 enthalten das deutsche Wort „links“ (Richtung) — nicht anfassen, nicht per `grep -i link` verwechseln.
|
||||
- dashboard-grid.tsx Z. 23-24: Kommentar „(Favoriten/ Link-Widget, bisher nirgends verdrahtet)“ — auf „(Favoriten-Widget)“ kürzen.
|
||||
- widget-wrapper.tsx Z. 30-31 + Z. 107-117: `WIDGET_REGISTRY[widget.widgetType as WidgetType]` → `undefined` für unbekannte Typen → Fallback `<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{widget.widgetType}</div>`, `aria-label` = Typname. dashboard-grid.test.tsx Test 9 (Z. 206-232) deckt bereits `widgetType: 'unknown'` in `applyConstraintMinima` ab (Eintrag wird unverändert kopiert). Es gibt noch KEINE widget-wrapper.test.tsx.
|
||||
- Verwaiste Layout-Einträge: `DashboardLayout.layouts` (JSON) kann nach der Migration noch Einträge mit den gelöschten ids enthalten. dashboard-grid.tsx rendert nur über `widgets.map` (Z. 187) und react-grid-layout übernimmt Layout-Einträge ohne Kind nicht; der Store schreibt beim nächsten Verlassen des Bearbeitungsmodus nur die Kind-Layouts zurück. Kein SQL auf das JSON nötig.
|
||||
- API: create-widget.dto.ts Z. 5 Kommentar „one of the eight supported types“, Z. 10 `@IsIn([...])`. widget-module-map.ts Z. 16-18 („alle acht heute registrierten Widget-Typen (clock/search/calendar/note/calculator/ favorites/link/stopwatch …“) und Z. 28 („für alle acht bestehenden Typen“). dashboard.service.spec.ts und dashboard.controller.ts enthalten KEINE Referenz auf den Typ link; keine DTO-Spec vorhanden. API-Skripte: `type-check` = `tsc --noEmit`, `test` = `vitest run`.
|
||||
- Prisma: schema.prisma `WidgetInstance` Z. 201-213 (`widgetType String`), `FavoriteLink` Z. 347-363 (`widgetInstance … onDelete: Cascade`) — KEINE Schemaänderung nötig. Jüngste Migration `20260914170000_smtp_config_bug_report_recipient` (Kommentarstil: deutsch, Begründung, dann SQL). FK-Kaskade in 20260708090000_add_favorite_link Z. 19. RLS: WidgetInstance und FavoriteLink haben ENABLE + FORCE ROW LEVEL SECURITY (20260909140000 Z. 89-90, 153-154); Migrationen laufen als Rolle `tessera` (POSTGRES_USER, docker-compose.yml Z. 77; Superuser + BYPASSRLS, lokal gemessen, Befund auch in 20260909130000_rls_app_role Z. 5-11) — das DELETE sieht alle Zeilen. Vorbild für DML-Migrationen: 20260709000000_lowercase_usernames, 20260812100000_tender_email_config_per_user.
|
||||
- link-widget.tsx 381 Zeilen, link-widget.test.tsx 231 Zeilen mit 7 Tests. Keine weitere Datei importiert das Modul außer page.tsx/page.test.tsx.
|
||||
- Handbuch docs/anleitung-anwender.md: Widget-Tabelle Z. 72-81 (Notizen Z. 77, Favoriten Z. 79, Link Z. 80), Satz Z. 83 „Für Uhr, Suchleiste, Kalender, Favoriten und Link gibt es zusätzliche Einstellungen (… hinterlegte Links) …“, Absatz „**Dashboard > Widgets:**“ Z. 153.
|
||||
- CHANGELOG.md: `## Unveröffentlicht` Z. 5, `### Geändert` Z. 7 mit zwei Einträgen Z. 9-10, `## 1.1.0 – 2026-09-16` Z. 12. changelog.ts erkennt nur `## `-Abschnitte und `- `-Listenpunkte; changelog.test.ts arbeitet mit Inline-Fixtures; publish-release.sh schneidet den `## X.Y.Z`-Abschnitt per awk — `### Entfernt` ist als Unterüberschrift zulässig (Keep-a-Changelog: Neu, Geändert, Entfernt, Behoben).
|
||||
|
||||
**Basislinie**: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` → 51 Dateien / 332 Tests grün; Umlaut-Wächter 3/3; `pnpm --filter @tessera/api type-check` Exit 0. Kalibrierung: factor 1, 0 Stichproben, confidence low.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Notiz-Widget — Aufgabenlisten in der Ansicht abhakbar (Hilfsmodul + Widget + Tests)</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/note-task-list.ts, apps/web/src/components/dashboard/widgets/note-task-list.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx (ganz, 143 Zeilen)
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (ganz, 174 Zeilen — insbesondere der MDEditor-Mock Z. 16-48)
|
||||
- apps/web/src/lib/dashboard-api.ts Z. 59-72 (`updateWidgetConfig`, Body-Form)
|
||||
- user-files/personal-dashboard/src/app/page.tsx ~Z. 1399 (`toggleMarkdownCheckbox`, Vorbild)
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts (Muster: reines Hilfsmodul neben dem Widget, deutsche Kommentare)
|
||||
</read_first>
|
||||
<behavior>
|
||||
note-task-list.test.tsx (vitest, jsdom; deutsche Testnamen wie in den Bestandstests):
|
||||
- Test 1 `isTaskLine`: wahr für `- [ ] Milch`, `- [x] Brot`, `* [X] Eier`, `+ [ ] Butter`, `1. [ ] Mehl`, `2) [x] Salz`, ` - [ ] eingerückt`, `- [\t] Tab`; falsch für `- [ ]` (leer), `- [ ]Text` (kein Leerraum nach der Klammer), `- Milch`, `[ ] ohne Punkt`, `- [y] falsch`, leere Zeile.
|
||||
- Test 2 `toggleTaskLine('- [ ] Milch\n- [ ] Brot\n- [ ] Eier', 0)` → `'- [x] Milch\n- [ ] Brot\n- [ ] Eier'`; Index 2 → nur die dritte Zeile wird `[x]`.
|
||||
- Test 3 `[x]` → `[ ]`: `toggleTaskLine('- [x] Brot', 0)` → `'- [ ] Brot'`; `[X]` → `[ ]` ebenfalls.
|
||||
- Test 4 Nicht-Aufgabenzeilen zählen nicht mit: `'# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [ ] Brot'`, Index 1 → nur `Brot` wird `[x]`, alle anderen Zeilen byte-identisch.
|
||||
- Test 5 Eingerückt und nummeriert: `'- [ ] A\n - [ ] B\n1. [ ] C'`, Index 1 → `' - [x] B'` (Einrückung bleibt), Index 2 → `'1. [x] C'`.
|
||||
- Test 6 Index außerhalb (`-1`, `3` bei drei Aufgaben) → Rückgabe `===` Eingabe (unverändert). Leerer Inhalt `''` mit Index 0 → `''`.
|
||||
- Test 7 Code-Zäune werden übersprungen: `'```\n- [ ] nicht\n```\n- [ ] echt'`, Index 0 → nur `echt` wird `[x]`, die Zeile im Zaun bleibt `[ ]`. Gleiches mit `~~~`.
|
||||
- Test 8 (ECHTE Vorschau, kein Mock von `@uiw/react-md-editor` in dieser Datei): `render(<MDEditor.Markdown source={SRC} rehypePlugins={[[rehypeSanitize]]} components={{ input: NoteCheckbox }} />)` mit `SRC = '- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier'` → `container.querySelectorAll('input[type="checkbox"]').length === 4`; jedes Kästchen `disabled === false`; Kästchen 1 und 2 (Index) `checked === true`, 0 und 3 `checked === false`. Zusätzlich: die Anzahl 4 entspricht `SRC.split('\n').filter(isTaskLine).length` — das ist die Invariante, auf der die Index-Zuordnung beruht.
|
||||
note-widget.test.tsx (bestehender MDEditor-Mock wird erweitert):
|
||||
- Test 9 Klick in der Ansicht speichert sofort: `config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}`, `isEditMode={false}`; die drei Kästchen (`getAllByRole('checkbox')`) sind vorhanden; `fireEvent.click` auf das dritte, dann `await act(async () => {})` OHNE Timer-Vorlauf → `fetchSpy` genau einmal aufgerufen mit URL `…/dashboard/widgets/note-1/config`, `method: 'PATCH'` und `JSON.parse(body)` `toEqual({ config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' } })`; das dritte Kästchen ist danach `checked`.
|
||||
- Test 10 Abwählen: gleiche Konfiguration, Klick auf das zweite Kästchen → Body-Inhalt `'- [ ] Milch\n- [ ] Brot\n- [ ] Eier'`.
|
||||
- Test 11 Ein laufender Entprell-Timer wird verworfen: Stift an (Klick auf den Knopf), Textarea auf `'- [ ] Milch\n- [ ] Brot'` ändern, `vi.advanceTimersByTime(200)`, Stift wieder aus (zweiter Klick auf den Knopf), erstes Kästchen klicken → nach `await act(async () => {})` genau EIN fetch-Aufruf mit Inhalt `'- [x] Milch\n- [ ] Brot'`; dann `vi.advanceTimersByTime(2000)` → immer noch genau ein Aufruf (der alte Timer hat NICHT den ungekippten Text nachgeschoben).
|
||||
- Bestandstests 1-4 bleiben unverändert grün.
|
||||
</behavior>
|
||||
<action>
|
||||
1. Neues Modul `apps/web/src/components/dashboard/widgets/note-task-list.ts` (Client-Modul, exportiert eine kleine React-Komponente, deshalb `.ts` mit `React.createElement` ODER `.tsx` — wähle `.tsx` nur, wenn du JSX willst; dann Dateiname `note-task-list.tsx` und die Pfade in `<files>`/Frontmatter entsprechend im SUMMARY nennen). Inhalt:
|
||||
- `export const TASK_LINE_RE = /^(\s*(?:[-*+]|\d+[.)])\s+\[)([ \txX])(\]\s+\S.*)$/;` — bewusst streng, Begründung als deutscher Kommentar: entspricht der GFM-Regel (micromark-extension-gfm-task-list-item: nach `]` Leerraum UND Inhalt), sonst verschiebt eine leere Zeile `- [ ]` den Index gegenüber den gerenderten Kästchen.
|
||||
- `const FENCE_RE = /^\s*(```|~~~)/;`
|
||||
- `export function isTaskLine(line: string): boolean` → `TASK_LINE_RE.test(line)`.
|
||||
- `export function toggleTaskLine(content: string, index: number): string` — splittet an `'\n'`, läuft über die Zeilen, führt ein `inFence`-Flag (Zeile matcht FENCE_RE → Flag kippen, Zeile überspringen), zählt nur Zeilen mit `isTaskLine` (außerhalb von Zäunen); bei Zähler === index: Gruppe 2 ist `' '`/`'\t'` → `'x'`, sonst (`x`/`X`) → `' '`; Zeile neu zusammensetzen (Gruppe 1 + neues Zeichen + Gruppe 3), `join('\n')` zurückgeben. Kein Treffer (index < 0, index ≥ Anzahl) → die EINGABE unverändert zurückgeben (dieselbe Referenz), damit der Aufrufer per `===` erkennt, dass nichts zu speichern ist.
|
||||
- `export function NoteCheckbox({ checked }: { checked?: boolean })` — rendert ein `input` mit `type="checkbox"`, `checked={!!checked}`, `readOnly`, `className="cursor-pointer"`, und OHNE `disabled`; nur `checked` aus den Props ziehen (react-markdown reicht zusätzlich `node`, `disabled`, `type` durch — nichts davon spreizen, sonst landet `node` im DOM). `readOnly` unterdrückt die React-Warnung „checked ohne onChange“; der Klick wird nicht am Kästchen, sondern delegiert am Container verarbeitet.
|
||||
2. `note-widget.tsx`:
|
||||
- Import `{ NoteCheckbox, toggleTaskLine }` aus `./note-task-list`.
|
||||
- `previewOptions` wird zu `{ rehypePlugins: [[rehypeSanitize]], components: { input: NoteCheckbox } }` — `rehypeSanitize` bleibt. Das Objekt außerhalb der Komponente als Konstante `PREVIEW_OPTIONS` anlegen (stabil, keine Neuanlage je Render).
|
||||
- Neuer Handler `handlePreviewClick(event: React.MouseEvent<HTMLDivElement>)` per `useCallback` mit Abhängigkeiten `[isEditing, content, title, save]`: wenn `isEditing` → return; `target = event.target`; wenn nicht `instanceof HTMLInputElement` oder `target.type !== 'checkbox'` → return; `boxes = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'))`; `index = boxes.indexOf(target)`; `next = toggleTaskLine(content, index)`; wenn `next === content` → return; `setContent(next)`; `clearTimeout(timerRef.current)` (ein evtl. noch laufender Entprell-Timer aus dem Tippen würde sonst den alten Text nachschieben); `void save(next, title)` — SOFORT, nicht `scheduleSave` (Klick ist eine abgeschlossene Handlung; 1,5 s Wartezeit würden beim schnellen Seitenwechsel den Haken verlieren). Kein `preventDefault` (das würde das native Kippen sichtbar zurücknehmen; React setzt das kontrollierte `checked` beim Re-Render ohnehin auf den neuen Wert).
|
||||
- Den Handler als `onClick={handlePreviewClick}` auf den Vorschau-Container `<div className="flex-1 overflow-auto">` (Z. 126) setzen. Ein `role`/`tabIndex` ist nicht nötig — das eigentliche interaktive Element ist das Kästchen selbst; den Container zusätzlich mit `data-testid="note-preview"` versehen.
|
||||
- Sonst nichts ändern: Bearbeitungsmodus (`isEditing`), Titelfeld, Entprellung beim Tippen, AbortController bleiben wie sie sind.
|
||||
3. `note-widget.test.tsx`: den MDEditor-Mock (Z. 16-48) um die Props `preview` und `previewOptions` erweitern: bei `preview === 'preview'` statt des Textareas ein `<div data-testid="md-editor">` rendern, das für jede Zeile von `value`, die `/^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/` matcht, ein `<input type="checkbox" readOnly checked={m[1] !== ' '} />` enthält (Reihenfolge = Zeilenreihenfolge; wenn `previewOptions?.components?.input` vorhanden ist, darf der Mock diese Komponente statt des rohen `input` verwenden — dann ist auch `NoteCheckbox` im Klickpfad); bei `preview === 'edit'` das bisherige Textarea. Die Bestandstests 2-4 schalten den Stift ein, bevor sie tippen — sie treffen weiterhin das Textarea. Test 1 (`getByTestId('md-editor')`) trifft jetzt das Vorschau-Div — weiterhin vorhanden. Dann die Tests 9-11 aus `<behavior>` ergänzen (`fetchSpy.mock.calls[0]` → `[url, init]`, `JSON.parse(init.body as string)`).
|
||||
4. `note-task-list.test.tsx` NEU nach `<behavior>` Tests 1-8 anlegen. Test 8 importiert `MDEditor from '@uiw/react-md-editor'` und `rehypeSanitize from 'rehype-sanitize'` ECHT (kein `vi.mock` in dieser Datei) und rendert `MDEditor.Markdown` (das ist der Preview-Export, den auch changelog-view.tsx nutzt).
|
||||
5. Laufen lassen: beide Testdateien und den Type-Check (siehe verify). Erwartung: note-task-list 8 Tests, note-widget 7 Tests.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/note-task-list.test.tsx src/components/dashboard/widgets/note-widget.test.tsx && pnpm --filter @tessera/web type-check && grep -q "input: NoteCheckbox" apps/web/src/components/dashboard/widgets/note-widget.tsx && grep -q "rehypeSanitize" apps/web/src/components/dashboard/widgets/note-widget.tsx</automated>
|
||||
</verify>
|
||||
<done>Hilfsmodul mit strenger GFM-konformer Regex, Zaun-Überspringen und `NoteCheckbox` vorhanden; Notiz-Widget reicht `components: { input: NoteCheckbox }` durch, `rehypeSanitize` bleibt, delegierter Klick kippt die N-te Aufgabenzeile und speichert sofort (Timer verworfen); 8 + 7 Tests grün inkl. eines Tests mit dem echten `MDEditor.Markdown`; tsc Exit 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Favoriten-Widget — optionaler Titel (Widget-Kopfzeile, FavoritesConfig, i18n, Tests)</name>
|
||||
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx Z. 1-100 und Z. 172-300 (State, Hooks, Render-Wurzel)
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx Z. 35-66 und Z. 86-123 (Entprell-Muster, Kopfzeilen-Optik)
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx Z. 100-200 und Z. 403-424 (Instanz-Kopfzeile, Konfig-Zweige, `NoteConfig`)
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx Z. 1-60 und Z. 110-125 (Mocks, Muster `renderCalendarExpanded`)
|
||||
- apps/web/src/messages/de.json Z. 257-284 und en.json Z. 257-284 (Einfügestellen)
|
||||
</read_first>
|
||||
<behavior>
|
||||
favorites-widget.test.tsx (next-intl-Mock gibt den Schlüssel zurück):
|
||||
- Test A1 Ansicht ohne Titel: `config={{}}`, `isEditMode={false}` → nach dem Laden (`waitFor` auf 'GitHub') gibt es KEIN `heading` (`queryByRole('heading')` null) und KEIN Feld mit Platzhalter `favorites.titlePlaceholder`; ebenso bei `config={{ title: ' ' }}` und bei `config={{ title: 42 }}` (kein String).
|
||||
- Test A2 Ansicht mit Titel: `config={{ title: 'Werkzeuge' }}` → `getByRole('heading', { name: 'Werkzeuge' })` vorhanden, Tag `H2`, Klassen enthalten `text-sm`, `font-semibold`; kein Textfeld mit dem Platzhalter.
|
||||
- Test A3 Bearbeitungsmodus ohne Titel: `config={{}}`, `isEditMode={true}` → Textfeld mit Platzhalter `favorites.titlePlaceholder` vorhanden, Wert `''`, `className` enthält `widgetNoDrag`; kein `heading`; Liste/Kacheln-Knöpfe (`favorites.listView`/`favorites.gridView`) weiterhin vorhanden.
|
||||
- Test A4 Entprelltes Speichern: mit `vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })` (im Test aktivieren, in `finally` `vi.useRealTimers()`), `isEditMode={true}`, Laden per `await act(async () => {})` abwarten; Feld nacheinander auf `'W'`, `'We'`, `'Werkzeuge'` ändern (jeweils `vi.advanceTimersByTime(200)` in `act`) → `updateWidgetConfig` NICHT aufgerufen; dann `vi.advanceTimersByTime(1500)` in `act` → genau EIN Aufruf `('fav-1', { title: 'Werkzeuge' })`. Leeren des Felds (`''`) → nach 1500 ms Aufruf `('fav-1', { title: '' })`.
|
||||
widget-settings-panel.test.tsx (Texte aus der echten de.json):
|
||||
- Test B1 Favoriten-Instanz `{ id: 'f1', widgetType: 'favorites', config: { title: 'Werkzeuge' } }`: Kopfzeilen-Knopf `getByRole('button', { name: /Favoriten #1/ })` enthält den Text `— Werkzeuge`; nach Klick ist ein Textfeld mit `getByLabelText(de.widgets.favorites.titleLabel)` da, Wert `'Werkzeuge'`; `fireEvent.change` auf `'Werkzeuge 2'` → `await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }))` und `updateWidgetConfig` mit denselben Argumenten.
|
||||
- Test B2 Favoriten-Instanz ohne Titel (`config: {}`) und mit Leerraum-Titel (`config: { title: ' ' }`): Kopfzeile OHNE „—“; nach Aufklappen Feldwert `''`.
|
||||
- Test B3 Notiz-Instanz `{ id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } }`: nach Aufklappen `getByLabelText(de.widgets.note.titleLabel)` (= „Titel“) hat Wert `'Einkauf'`; `screen.queryByText('Title')` ist null (hart kodierte Beschriftung ist weg).
|
||||
- Bestandstests 1-7 bleiben grün.
|
||||
</behavior>
|
||||
<action>
|
||||
1. Übersetzungen: in de.json UND en.json (Namensraum `widgets`) ergänzen — in `note` nach `viewMode` den Schlüssel `titleLabel` („Titel“ / „Title“); in `favorites` nach `error` die Schlüssel `titleLabel` („Titel“ / „Title“) und `titlePlaceholder` („Titel (optional)“ / „Title (optional)“). Reihenfolge und Einrückung des Bestands beibehalten; JSON bleibt gültig (Kommas!).
|
||||
2. `favorites-widget.tsx`:
|
||||
- `useRef` zu den React-Imports ergänzen; Modulkonstante `const TITLE_DEBOUNCE_MS = 1500;` mit Kommentar „wie DEBOUNCE_MS im Notiz-Widget“.
|
||||
- State `const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '')`; `titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)`; `useEffect(() => () => clearTimeout(titleTimerRef.current), [])`.
|
||||
- `handleTitleChange(e)`: `setTitle(e.target.value)`, `clearTimeout(titleTimerRef.current)`, `titleTimerRef.current = setTimeout(() => { void updateWidgetConfig(instanceId, { title: value }); }, TITLE_DEBOUNCE_MS)` — nur das Feld `title` senden (die Seite mischt partiell).
|
||||
- `const hasTitle = title.trim() !== '';` und `const showHeader = isEditMode || hasTitle;`
|
||||
- Render-Wurzel umbauen: äußeres `<div className="flex h-full flex-col overflow-hidden">`; darin ZUERST bedingt (`showHeader`) die Kopfzeile `<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">` — im Bearbeitungsmodus ein `<input type="text" className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground widgetNoDrag" value={title} onChange={handleTitleChange} placeholder={t('favorites.titlePlaceholder')} aria-label={t('favorites.titleLabel')} />`, sonst (Ansicht, nur bei `hasTitle`) ein `<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>`; DANACH der bisherige Rumpf als `<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">` mit unverändertem Inhalt (Umschalter, Statusmeldungen, Liste/Raster, Formular). Ohne Titel und außerhalb des Bearbeitungsmodus gibt es KEINE Kopfzeile — der Rumpf beginnt oben.
|
||||
- Kommentar am Komponentenkopf um eine Zeile „quick-260916-iex: optionaler Titel …“ ergänzen.
|
||||
3. `widget-settings-panel.tsx`:
|
||||
- Instanz-Kopfzeile (Z. 119-126): Bedingung auf `(widget.widgetType === 'note' || widget.widgetType === 'favorites') && typeof widget.config.title === 'string' && widget.config.title.trim() !== ''` erweitern, Anzeige `— {widget.config.title.trim()}`.
|
||||
- Neuer Zweig nach dem Kalender-Zweig: `{widget.widgetType === 'favorites' && (<FavoritesConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />)}` mit Kommentar „Favorites config (quick-260916-iex)“.
|
||||
- `FavoritesConfig` nach dem Muster `NoteConfig` unterhalb davon anlegen: `const title = typeof config.title === 'string' ? config.title : ''`; Label `htmlFor="favorites-title"` mit `t('favorites.titleLabel')`; Input `id="favorites-title"`, gleiche Klassen wie bei `NoteConfig`, `placeholder={t('favorites.titlePlaceholder')}`, `onChange={(e) => onChange({ title: e.target.value })}`.
|
||||
- `NoteConfig`: das hart kodierte „Title“ (Z. 416) durch `{t('note.titleLabel')}` ersetzen; sonst unverändert.
|
||||
4. Tests nach `<behavior>` ergänzen: favorites-widget.test.tsx A1-A4 (für A4 `updateWidgetConfig` aus `@/lib/dashboard-api` importieren und als `ReturnType<typeof vi.fn>` casten; die Datei arbeitet sonst mit echten Timern — Fake-Timer NUR in A4 und dort mit `toFake: ['setTimeout', 'clearTimeout']`, damit `fetchFavorites`-Promises und `act` normal laufen); widget-settings-panel.test.tsx B1-B3 in einem neuen `describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)')` mit einer Helferfunktion `renderFavoritesExpanded(config)` nach dem Muster `renderCalendarExpanded`; `de.widgets.favorites` / `de.widgets.note` wie `cal` oben in der Datei typisieren.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/favorites-widget.test.tsx src/components/settings/widget-settings-panel.test.tsx src/messages/umlaut-guard.spec.ts && pnpm --filter @tessera/web type-check && grep -q '"titlePlaceholder"' apps/web/src/messages/de.json && grep -q '"titlePlaceholder"' apps/web/src/messages/en.json && grep -q "favorites.titleLabel" apps/web/src/components/settings/widget-settings-panel.tsx && grep -q "note.titleLabel" apps/web/src/components/settings/widget-settings-panel.tsx</automated>
|
||||
</verify>
|
||||
<done>Favoriten-Widget zeigt in der Ansicht nur bei nicht-leerem Titel eine Kopfzeile im Notiz-Look, im Bearbeitungsmodus immer ein Titelfeld (`widgetNoDrag`, 1500 ms entprellt, `{ title }`); Einstellungsfeld hat `FavoritesConfig` mit übersetzter Beschriftung und zeigt „— {title}“ in der Instanz-Kopfzeile; `NoteConfig` übersetzt; 3 neue Schlüssel in de/en; favorites-Tests 7 + 4, Panel-Tests 7 + 3, Umlaut-Wächter 3/3 grün; tsc Exit 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Link-Widget restlos entfernen (Web, API-DTO, Migration, i18n, Handbuch), Changelog für A/B/C, alle Gates</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/link-widget.tsx, apps/web/src/components/dashboard/widgets/link-widget.test.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/dashboard-grid.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql, docs/anleitung-anwender.md, CHANGELOG.md</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx Z. 1-60, Z. 220-242, Z. 310-335, Z. 385-395
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx (ganz, 81 Zeilen)
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx Z. 1-25
|
||||
- apps/web/src/app/(portal)/page.tsx Z. 1-30; apps/web/src/app/(portal)/page.test.tsx Z. 50-62
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx (ganz, 121 Zeilen)
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts; apps/api/src/dashboard/widget-module-map.ts Z. 1-35
|
||||
- apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql (Kommentarstil)
|
||||
- docs/anleitung-anwender.md Z. 70-84 und Z. 153; CHANGELOG.md Z. 1-12
|
||||
</read_first>
|
||||
<action>
|
||||
<!-- planner-discipline-allow: LinkWidget -->
|
||||
<!-- planner-discipline-allow: wireLinkWidget -->
|
||||
<!-- planner-discipline-allow: link-widget -->
|
||||
1. Dateien löschen: `git rm apps/web/src/components/dashboard/widgets/link-widget.tsx apps/web/src/components/dashboard/widgets/link-widget.test.tsx`.
|
||||
2. `widget-registry.tsx`: Union-Mitglied für den Typ link entfernen (Z. 15); Kopfkommentar Z. 6 auf „calculator/favorites/stopwatch: Phase 8 additions (das Link-Widget wurde in quick-260916-iex entfernt — Favoriten decken den Fall ab)“ ändern; `WIDGET_CONSTRAINTS`-Zeile Z. 54 entfernen; Funktion `LinkIcon` (Z. 222-240) entfernen; Registry-Eintrag Z. 317-324 entfernen; `linkWired` + `wireLinkWidget` (Z. 387-393) entfernen. Ergebnis: sieben Typen.
|
||||
3. `widget-registry.test.tsx`: Eintrag Z. 18 aus `ALL_WIDGET_TYPES`, `toContain`-Zeile Z. 52 und die Tabellenzeile Z. 68 entfernen; `expect(counted).toBe(32)` → `28`; im Kommentar des Tests A einen Halbsatz „(quick-260916-iex: Link-Widget entfernt, sieben Typen)“ ergänzen.
|
||||
4. `widget-catalog-modal.tsx`: Eintrag Z. 20 aus `WIDGET_TYPES` entfernen.
|
||||
5. `apps/web/src/app/(portal)/page.tsx`: `wireLinkWidget` aus der Import-Liste Z. 8, die Import-Zeile 16 (`LinkWidget`) und den Aufruf Z. 28 entfernen. `page.test.tsx`: den `vi.mock(...)`-Aufruf Z. 61 für das gelöschte Modul entfernen. Das deutsche „links“ (Richtung) in page.tsx Z. 82 / page.test.tsx Z. 96, 104 NICHT anfassen.
|
||||
6. `dashboard-grid.tsx` Z. 23-24: Kommentar „(Favoriten/ Link-Widget, bisher nirgends verdrahtet)“ → „(Favoriten-Widget)“. Keine Code-Änderung.
|
||||
7. Übersetzungen: in de.json UND en.json den kompletten Block `widgets.link` (Z. 285-300, 14 Schlüssel samt schließendem `},`) entfernen; der Schlüssel `"link": "Einstellungen"` in Z. 121 (anderer Namensraum) bleibt. JSON-Gültigkeit prüfen (`node -e "JSON.parse(require('fs').readFileSync('apps/web/src/messages/de.json','utf8'))"` und dasselbe für en.json).
|
||||
8. Neue Testdatei `apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx` mit `next-intl`-Mock (`t(key) => key`) und EINEM Test: `render(<WidgetWrapper widget={{ id: 'w-alt', widgetType: 'link', config: {} }} isEditMode={false} onRemove={vi.fn()} />)` wirft nicht, `getByRole('article')` hat `aria-label` `'link'`, und `getByText('link')` hat die Klasse `text-muted-foreground` (grauer Text). Deutscher Testname: „unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz“.
|
||||
9. API: `create-widget.dto.ts` — Typ link aus der `@IsIn`-Liste entfernen, Kommentar Z. 5 „one of the eight supported types“ → „one of the seven supported types“. `widget-module-map.ts` — Z. 16-18 auf „alle sieben heute registrierten Widget-Typen (clock/search/calendar/note/calculator/favorites/stopwatch, …)“ und Z. 28 „für alle sieben bestehenden Typen“ anpassen. Kein weiterer API-Code referenziert den Typ (gemessen).
|
||||
10. Migration `apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql` NEU anlegen (Verzeichnis + Datei). Inhalt: deutscher Kopfkommentar im Stil der Bestandsmigrationen (Anlass quick-260916-iex: Widget „Link“ entfernt, Favoriten-Widget übernimmt; FavoriteLink-Zeilen kaskadieren über `FavoriteLink_widgetId_fkey ON DELETE CASCADE` aus 20260708090000; läuft als Migrationsrolle `tessera` (Superuser/BYPASSRLS), deshalb greift FORCE ROW LEVEL SECURITY auf WidgetInstance hier nicht und die Anweisung sieht alle Mandanten; idempotent — ein zweiter Lauf löscht 0 Zeilen; verwaiste Einträge im Layout-JSON sind unschädlich und verschwinden beim nächsten Speichern des Dashboards), danach GENAU EINE Anweisung: `DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link';`. KEINE Schemaänderung, KEIN `prisma migrate dev/deploy` ausführen.
|
||||
11. Handbuch `docs/anleitung-anwender.md`: Tabellenzeile Z. 80 (Link) entfernen; Z. 77 Notizen → „Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken“; Z. 79 Favoriten → „… als Liste oder Kachelansicht, optional mit eigener Überschrift“; Satz Z. 83 → „Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — …“ (Rest des Satzes unverändert). Absatz Z. 153 „**Dashboard > Widgets:**“ um „… oder bei Notizen und Favoriten die Überschrift der Kachel“ ergänzen.
|
||||
12. `CHANGELOG.md` unter `## Unveröffentlicht`: an die bestehende Liste unter `### Geändert` den Punkt „Favoriten-Widget kann einen Titel bekommen; ohne Titel bleibt die Kopfzeile weg.“ anhängen; danach neuen Abschnitt `### Entfernt` mit „Widget „Link“ (ein einzelner Link) entfernt — Favoriten-Widget übernimmt das; vorhandene Link-Kacheln werden beim Update automatisch entfernt.“; danach `### Behoben` mit „Notiz-Widget: Listen zum Abhaken lassen sich jetzt in der Ansicht direkt abhaken.“ — jeweils Leerzeile vor/nach Überschriften wie im Bestand, echte Umlaute und „…“-Anführungszeichen wie im Bestand, vor `## 1.1.0 – 2026-09-16`.
|
||||
13. Alle Gates laufen lassen (siehe verify) und die Zahlen (Dateien/Tests Web, Tests API-Dashboard) für das SUMMARY notieren. Erwartung Web: 52 Dateien (51 − link-widget.test + note-task-list.test + widget-wrapper.test), mindestens 340 Tests (332 − 7 − 1 + 8 + 3 + 4 + 3 + 1 = 343).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test ! -e apps/web/src/components/dashboard/widgets/link-widget.tsx && test ! -e apps/web/src/components/dashboard/widgets/link-widget.test.tsx && test -f apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql && grep -c '^DELETE FROM "WidgetInstance" WHERE "widgetType" = '"'"'link'"'"';' apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql | grep -qx 1 && grep -vc '^--' apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql | xargs -I{} sh -c 'test {} -le 3' && git diff --quiet HEAD -- apps/api/prisma/schema.prisma && ! grep -qi "link" apps/web/src/components/dashboard/widget-registry.tsx apps/web/src/components/dashboard/widget-catalog-modal.tsx apps/api/src/dashboard/dto/create-widget.dto.ts && ! grep -q "LinkWidget\|link-widget" "apps/web/src/app/(portal)/page.tsx" "apps/web/src/app/(portal)/page.test.tsx" && ! grep -q '"link": {' apps/web/src/messages/de.json apps/web/src/messages/en.json && grep -q "### Entfernt" CHANGELOG.md && grep -q "### Behoben" CHANGELOG.md && ! grep -q "^| Link |" docs/anleitung-anwender.md && pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/api exec vitest run src/dashboard</automated>
|
||||
</verify>
|
||||
<done>Link-Widget-Dateien gelöscht; Typ in Web (Union, Constraints, Registry, Icon, wire, Katalog, Seite, Tests, i18n de/en) und API (DTO, Kommentare) restlos entfernt; Migration mit genau einer idempotenten DELETE-Anweisung vorhanden, schema.prisma unverändert; widget-wrapper-Test belegt grauen Fallback für unbekannte Typen; Handbuch und CHANGELOG (Geändert/Entfernt/Behoben) aktualisiert; Web-tsc 0, Web-vitest komplett grün (52 Dateien, ≥ 340 Tests), API-tsc 0, API-Dashboard-Spec grün.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API (`PATCH /dashboard/widgets/:id/config`) | Benutzerdaten (Markdown-Inhalt, Favoriten-Titel) werden als JSON-Konfiguration gespeichert; Autorisierung liegt beim bestehenden Guard (unverändert). |
|
||||
| Gespeichertes Markdown → DOM | Notiz-Inhalt wird per react-markdown gerendert; `rehypeSanitize` ist die XSS-Schranke. |
|
||||
| Migration → Datenbank | DML auf `WidgetInstance` (mit Kaskade auf `FavoriteLink`) über alle Mandanten hinweg. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-IEX-01 | Tampering / XSS | note-widget.tsx `previewOptions` | high | mitigate | `rehypeSanitize` bleibt in `rehypePlugins`; der `components`-Override ersetzt NUR das `input`-Element durch `NoteCheckbox`, das ausschließlich `checked` liest und keine weiteren Props ins DOM spreizt (kein `node`, keine Attribute aus dem Markdown). Test 8 rendert die echte Vorschau mit Sanitize. |
|
||||
| T-IEX-02 | Tampering | `toggleTaskLine` / delegierter Klick | low | mitigate | Nur die N-te Aufgabenzeile wird per Regex-Gruppen neu zusammengesetzt; alle anderen Zeilen bleiben byte-identisch (Test 4/5/7). Index außerhalb → kein Schreibzugriff. Speichern läuft über den bestehenden authentifizierten PATCH-Pfad mit AbortController. |
|
||||
| T-IEX-03 | Spoofing / XSS | Favoriten-Titel (Widget-Kopfzeile, Panel) | low | mitigate | Titel wird als React-Textknoten gerendert (kein `dangerouslySetInnerHTML`), nur bei `typeof === 'string'` übernommen, getrimmt angezeigt. |
|
||||
| T-IEX-04 | Denial of Service | Entprelltes Speichern des Favoriten-Titels | low | mitigate | 1500 ms Entprellung, ein Timer pro Widget-Instanz, Timer bei Unmount verworfen — kein Request pro Tastendruck. |
|
||||
| T-IEX-05 | Information Disclosure / Data Loss | Migration `remove_link_widget` | medium | mitigate | Genau eine DELETE-Anweisung mit engem Prädikat (`widgetType = 'link'`), idempotent; Kaskade nur auf `FavoriteLink` desselben Widgets über den bestehenden FK. Wird in diesem Auftrag NICHT ausgeführt — der Anwender spielt sie per Deploy ein (bewusste Produktentscheidung C). |
|
||||
| T-IEX-06 | Denial of Service | Frontend bei noch vorhandenen Link-Kacheln (vor Migration) | low | mitigate | widget-wrapper.tsx rendert unbekannte Typen als grauen Text; `applyConstraintMinima` kopiert unbekannte Layout-Einträge unverändert (dashboard-grid Test 9); neuer widget-wrapper-Test pinnt den Fallback. Löschen der Kachel im Bearbeitungsmodus bleibt möglich. |
|
||||
| T-IEX-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation in diesem Auftrag (alle genutzten Module — `@uiw/react-md-editor`, `rehype-sanitize`, `react-markdown` — sind bereits im Lockfile). Kein Legitimacy-Gate nötig. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
- `pnpm --filter @tessera/web exec vitest run` → alle Dateien grün; 52 Dateien, ≥ 340 Tests (exakte Zahlen im SUMMARY nennen).
|
||||
- `pnpm --filter @tessera/web exec vitest run src/messages/umlaut-guard.spec.ts src/lib/changelog.test.ts` → 3/3 bzw. grün (im Gesamtlauf enthalten).
|
||||
- `pnpm --filter @tessera/api type-check` → Exit 0; `pnpm --filter @tessera/api exec vitest run src/dashboard` → grün.
|
||||
- `git diff --quiet HEAD -- apps/api/prisma/schema.prisma` (Schema unverändert); Migrationsdatei vorhanden mit genau einer DELETE-Anweisung.
|
||||
- Kein `biome check` als Gate, `biome.json` unverändert; kein Docker-Build, kein Deploy, kein Testserver, kein `git push`, kein `prisma migrate`.
|
||||
- Ein Commit je Task (Konvention der heutigen Quick-Tasks): `feat(web): …` für Task 1 und 2, `feat: Link-Widget entfernt …` (Web + API + Migration + Docs + Changelog) für Task 3; `git rm` der Link-Dateien im dritten Commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Notiz-Widget: Klick auf ein Kästchen in der Ansicht kippt genau diese Zeile im Markdown, Vorschau aktualisiert, sofortiger PATCH mit `{ config: { content, title } }`; Bearbeitungsmodus unverändert; `rehypeSanitize` aktiv.
|
||||
- Favoriten-Widget: ohne Titel keine Kopfzeile, mit Titel Kopfzeile im Notiz-Look, im Bearbeitungsmodus Titelfeld (`widgetNoDrag`, 1500 ms entprellt); Einstellungsfeld mit `FavoritesConfig` und „— {title}“; Notiz-Beschriftung übersetzt.
|
||||
- Link-Widget nirgends mehr vorhanden (Web, API, i18n, Docs), Dateien gelöscht, Migration angelegt, unbekannte Typen crashen nicht.
|
||||
- Changelog mit drei Einträgen (Geändert/Entfernt/Behoben), Handbuch angepasst.
|
||||
- Alle Gates aus `<verification>` grün; SUMMARY nennt die gemessenen Testzahlen (vorher 51/332).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260916-iex-dashboard-widgets-notiz-haekchen-in-der-/260916-iex-SUMMARY.md` when done (Muster: die SUMMARY von 260916-htc — Abschnitte Was gebaut wurde / Entscheidungen / Gemessene Zahlen / Commits / Abweichungen vom Plan).
|
||||
</output>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
---
|
||||
phase: quick-260916-iex
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: dashboard-widgets
|
||||
tags: [note, favorites, link-widget-removal, i18n, prisma-migration]
|
||||
dependency-graph:
|
||||
requires: [quick-260916-htc (Kalender-Widget, CalendarConfig-Muster), Phase 8 (Favoriten-Widget, Link-Widget)]
|
||||
provides: [note-task-list.tsx (Aufgabenlisten-Hilfsmodul), Favoriten-Titel (Widget + FavoritesConfig), Link-Widget-Entfernung inkl. Migration]
|
||||
affects: [apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/api/src/dashboard/dto/create-widget.dto.ts]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [components-Override nach rehypeSanitize fuer anklickbare Markdown-Kaestchen, geteiltes Entprell-Muster (Notiz -> Favoriten uebernommen), idempotente DML-Migration mit deutschem Kopfkommentar]
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx
|
||||
- apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- "apps/web/src/app/(portal)/page.tsx"
|
||||
- "apps/web/src/app/(portal)/page.test.tsx"
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
deleted:
|
||||
- apps/web/src/components/dashboard/widgets/link-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/link-widget.test.tsx
|
||||
decisions:
|
||||
- "note-task-list als .tsx statt .ts angelegt: die exportierte NoteCheckbox-Komponente braucht JSX, ein `.ts`-Modul mit React.createElement waere unnoetig unleserlich gewesen. Inhaltlich entspricht das Modul vollstaendig der Planvorgabe."
|
||||
- "PREVIEW_OPTIONS wird ohne `as const` typisiert (Typ von `React.ComponentProps<typeof MDEditor>['previewOptions']` abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview` importiert): `as const` haette `rehypePlugins` auf ein readonly-Tupel eingefroren, das mit dem erwarteten mutable `Pluggable[]`-Typ der Bibliothek kollidiert; der direkte Typimport aus dem transitiven Paket scheiterte an pnpms strikter Isolation (das Paket ist keine direkte Dependency von apps/web)."
|
||||
- "FavoritesConfig (Einstellungsfeld) zeigt einen Leerraum-only-Titel als getrimmt-leeres Feld (nicht den rohen Leerraum) — Testerwartung aus dem Plan (Feldwert '') war eindeutiger als die Ausgangsimplementierung nach dem Muster NoteConfig, die nicht trimmt."
|
||||
- "Kopfkommentar in widget-registry.tsx nennt das entfernte Widget NICHT mehr woertlich beim Namen (\"das fruehere Einzel-Schnellzugriffs-Typ\" statt \"das Link-Widget\") — der Plan-eigene automatisierte Verify-Grep in Task 3 (`! grep -qi \"link\" widget-registry.tsx ...`) haette sonst den vom Plan selbst geforderten Kommentartext durchfallen lassen. Kein Rule-4-Fall: reine Wortwahl, keine architektonische Aenderung."
|
||||
metrics:
|
||||
duration: ~20 min
|
||||
completed: 2026-09-16
|
||||
actuals:
|
||||
tokens: 18984
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 3c890af
|
||||
---
|
||||
|
||||
# Phase quick-260916-iex Plan 01: Dashboard-Widgets — Notiz-Häkchen, Favoriten-Titel, Link-Widget entfernt Summary
|
||||
|
||||
Drei vom Anwender festgelegte Nachbesserungen an den Dashboard-Widgets: Aufgabenlisten im Notiz-Widget sind in der Ansicht jetzt direkt per Klick abhakbar, das Favoriten-Widget bekommt einen optionalen Titel mit eigener Kopfzeile, und das überflüssig gewordene Einzel-Link-Widget ist vollständig aus Web, API, i18n und Handbuch entfernt (samt idempotenter Aufräum-Migration für bestehende Datenbanken).
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Task 1 — Notiz-Widget: Aufgabenlisten abhakbar (Commit `684f063`)**
|
||||
Neues Hilfsmodul `note-task-list.tsx` mit `TASK_LINE_RE` (GFM-konforme, bewusst strenge Regex), `isTaskLine`, `toggleTaskLine` (überspringt Code-Zäune, kippt exakt die N-te Aufgabenzeile) und `NoteCheckbox` (kein `disabled`, zieht nur `checked` aus den Props). `note-widget.tsx` reicht `components: { input: NoteCheckbox }` über `previewOptions` an react-markdown durch — das greift NACH `rehypeSanitize`, das dadurch unangetastet als XSS-Schranke aktiv bleibt. Ein delegierter Klick-Handler am Vorschau-Container ermittelt den Kästchen-Index, kippt die Zeile, verwirft einen eventuell laufenden Tipp-Entprell-Timer und speichert sofort über den bestehenden `save`-Pfad. 16 Tests (9 Hilfsmodul inkl. eines Tests mit dem echten `MDEditor.Markdown`, 7 Widget).
|
||||
|
||||
**Task 2 — Favoriten-Widget: optionaler Titel (Commit `7f1ee3b`)**
|
||||
`config.title` (nur `typeof === 'string'`) steuert eine Kopfzeile im Notiz-Look: leer + nicht im Bearbeitungsmodus → keine Kopfzeile; sonst H2 (Ansicht) bzw. Textfeld (Bearbeitungsmodus, `widgetNoDrag`, 1500 ms entprellt, Muster `note-widget.tsx`). Im Einstellungsfeld ergänzt `FavoritesConfig` (Muster `NoteConfig`) ein Titelfeld, die Instanz-Kopfzeile zeigt „— {title}“ jetzt für `note` UND `favorites`. Die bisher hart kodierte Beschriftung „Title“ bei `NoteConfig` ist übersetzt. 3 neue Schlüssel (`note.titleLabel`, `favorites.titleLabel`, `favorites.titlePlaceholder`) in de/en. 7 neue Tests (4 Widget, 3 Panel).
|
||||
|
||||
**Task 3 — Link-Widget restlos entfernt (Commit `39ea147`)**
|
||||
`link-widget.tsx`/`.test.tsx` gelöscht; Typ `link` aus `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY` (Icon + wire-Funktion), Katalog, Seiten-Verdrahtung, Tests und i18n (de/en) entfernt. API: `create-widget.dto.ts` (`@IsIn`-Liste) und `widget-module-map.ts` (Kommentare) auf sieben Typen angepasst, `schema.prisma` unverändert. Neue Migration `20260916120000_remove_link_widget` mit genau einer idempotenten `DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link';` (FavoriteLink kaskadiert über den bestehenden FK) — **nicht ausgeführt**, der Anwender spielt sie per Deploy ein. Neuer `widget-wrapper.test.tsx` belegt, dass unbekannte Widget-Typen weiterhin als grauer Text ohne Absturz rendern. Handbuch (Widget-Tabelle, Einstellungs-Hinweise) und CHANGELOG (Geändert/Entfernt/Behoben) aktualisiert.
|
||||
|
||||
## Gemessene Zahlen
|
||||
|
||||
- Vorher: `pnpm --filter @tessera/web exec vitest run` → 51 Dateien / 332 Tests.
|
||||
- Nachher: **52 Dateien / 344 Tests**, alle grün (Erwartung im Plan: ≥ 340 Tests — erfüllt).
|
||||
- `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
- `pnpm --filter @tessera/api type-check` → Exit 0.
|
||||
- `pnpm --filter @tessera/api exec vitest run src/dashboard` → 31 Tests grün.
|
||||
- Umlaut-Wächter → 3/3 grün.
|
||||
- `git diff --quiet HEAD -- apps/api/prisma/schema.prisma` → unverändert (kein Diff).
|
||||
- Kein `biome check`, kein `prisma migrate`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`.
|
||||
|
||||
## Commits
|
||||
|
||||
- `684f063` — feat(web): Notiz-Widget — Aufgabenlisten in der Ansicht abhakbar
|
||||
- `7f1ee3b` — feat(web): Favoriten-Widget — optionaler Titel (Kopfzeile, FavoritesConfig, i18n)
|
||||
- `39ea147` — feat: Link-Widget restlos entfernt (Web, API, Migration, Handbuch, Changelog)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] `PREVIEW_OPTIONS` als `as const`-Objekt kollidierte mit dem erwarteten mutable Typ**
|
||||
- **Found during:** Task 1, Type-Check-Verifikation
|
||||
- **Issue:** `previewOptions: { rehypePlugins: [[rehypeSanitize]], components: {...} } as const` fror `rehypePlugins` auf ein readonly-Tupel ein; `@uiw/react-md-editor`s `previewOptions`-Prop erwartet ein mutable `Pluggable[]` (react-markdown), tsc schlug fehl.
|
||||
- **Fix:** `as const` entfernt, stattdessen `PREVIEW_OPTIONS: PreviewOptions = {...}` mit `type PreviewOptions = NonNullable<React.ComponentProps<typeof MDEditor>['previewOptions']>` (direkter Typimport aus dem transitiven Paket `@uiw/react-markdown-preview` scheiterte unter pnpms strikter Isolation, da es keine direkte Dependency von `apps/web` ist).
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/note-widget.tsx`
|
||||
- **Commit:** `684f063`
|
||||
|
||||
**2. [Rule 1 - Bug] FavoritesConfig zeigte einen Leerraum-only-Titel roh statt getrimmt an**
|
||||
- **Found during:** Task 2, `widget-settings-panel.test.tsx` Test B2
|
||||
- **Issue:** `FavoritesConfig` (Muster `NoteConfig`, das nicht trimmt) zeigte bei `config.title === ' '` den rohen Leerraum im Feld an; der Plan erwartet einen getrimmt-leeren Feldwert `''`.
|
||||
- **Fix:** Anzeigewert auf `rawTitle.trim() === '' ? '' : rawTitle` umgestellt; gesendet wird weiterhin der rohe Tippwert (`onChange` trimmt nicht selbst).
|
||||
- **Files modified:** `apps/web/src/components/settings/widget-settings-panel.tsx`
|
||||
- **Commit:** `7f1ee3b`
|
||||
|
||||
**3. [Rule 1 - Bug] Plan-eigener Verify-Grep widersprach der eigenen Aktionsvorgabe**
|
||||
- **Found during:** Task 3, automatisierte Verifikation
|
||||
- **Issue:** Die Aktionsvorgabe verlangte einen Kopfkommentar in `widget-registry.tsx` mit dem Wortlaut „das Link-Widget wurde in quick-260916-iex entfernt“; der automatisierte Verify-Schritt desselben Tasks prüft `! grep -qi "link" apps/web/src/components/dashboard/widget-registry.tsx ...` — das Wort „Link“ im eigenen Kommentar hätte dieses Gate durchfallen lassen.
|
||||
- **Fix:** Kommentar ohne das Wort „Link“ umformuliert („der frühere Einzel-Schnellzugriffs-Typ“), inhaltlich identisch. Kein Rule-4-Fall — reine Wortwahl im Kommentar, keine architektonische Änderung.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widget-registry.tsx`
|
||||
- **Commit:** `39ea147`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue, im Plan nicht bereits erfasste sicherheitsrelevante Oberfläche gefunden. Alle sechs im `<threat_model>` benannten Maßnahmen (T-IEX-01 bis T-IEX-06) sind wie spezifiziert umgesetzt: `rehypeSanitize` bleibt aktiv, `toggleTaskLine` schreibt nur die Zielzeile, Favoriten-Titel läuft über React-Textknoten ohne `dangerouslySetInnerHTML`, Titel-Speichern ist entprellt, die Migration hat ein enges Prädikat und ist idempotent, unbekannte Widget-Typen crashen nicht.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/web/src/components/dashboard/widgets/note-task-list.tsx` — FOUND
|
||||
- `apps/web/src/components/dashboard/widgets/note-task-list.test.tsx` — FOUND
|
||||
- `apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx` — FOUND
|
||||
- `apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql` — FOUND
|
||||
- `apps/web/src/components/dashboard/widgets/link-widget.tsx` — CONFIRMED DELETED
|
||||
- `apps/web/src/components/dashboard/widgets/link-widget.test.tsx` — CONFIRMED DELETED
|
||||
- Commit `684f063` — FOUND in `git log`
|
||||
- Commit `7f1ee3b` — FOUND in `git log`
|
||||
- Commit `39ea147` — FOUND in `git log`
|
||||
- Gesamtlauf: 52 Testdateien / 344 Tests grün, Web-Type-Check Exit 0, API-Type-Check Exit 0, API-Dashboard-Spec 31 Tests grün
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
---
|
||||
phase: quick-260916-j4f
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-J4F]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
files_deleted:
|
||||
- .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md
|
||||
|
||||
estimate:
|
||||
tokens: 30000
|
||||
raw_tokens: 30000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Kalender-Widget, Tooltip beim Überfahren eines Tages: lange Termintitel werden auf mehrere Zeilen umbrochen statt mit „…“ abgeschnitten; der Tooltip ist 288 px breit und wird am rechten Fensterrand weiterhin so verschoben, dass er vollständig sichtbar bleibt (Klemmwert aus derselben Konstante wie die Breite). Uhrzeit-Spalte, Portal in document.body, Begrenzung auf 5 Einträge plus Hinweis bleiben unverändert."
|
||||
- "Notiz-Widget: der Textbereich (MDEditor) folgt dem Hell/Dunkel-Schalter von Tessera (next-themes `resolvedTheme`), nicht mehr der Betriebssystem-Einstellung. Vor dem Mount ist der Wert 'light' (mounted-Guard wie in changelog-view.tsx), danach 'dark' genau dann, wenn `resolvedTheme === 'dark'`."
|
||||
- "CHANGELOG.md hat exakt den Inhalt von CHANGELOG.soll.md: gleiche drei `## `-Überschriften (`## Unveröffentlicht`, `## 1.1.0 – 2026-09-16`, `## 1.0.0 – 2026-09-15`), 28 Stichpunkte, jeder Punkt eine kurze Zeile ohne Punkt am Ende, echte Umlaute. CHANGELOG.soll.md ist danach gelöscht (nicht committet, war nie im Git)."
|
||||
- "Gates: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` komplett grün (Basislinie 52 Dateien / 344 Tests → danach 52 Dateien / 347 Tests: +1 Kalender, +2 Notiz); Umlaut-Wächter 3/3; changelog.test.ts 10/10; `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` Exit 0. KEIN `biome check`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.tsx — Konstanten `TOOLTIP_WIDTH_PX = 288` und `TOOLTIP_EDGE_PX = 4`, Titel-Span im Tooltip mit `min-w-0 break-words`"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx — neuer Test 4c (Titel-Span umbricht, Tooltip-Breite 288 px)"
|
||||
- "apps/web/src/components/dashboard/widgets/note-widget.tsx — `useTheme` aus next-themes, `mounted`-State, `data-color-mode={mode}`"
|
||||
- "apps/web/src/components/dashboard/widgets/note-widget.test.tsx — `vi.hoisted`-Themenzustand, next-themes-Mock, zwei neue Tests (dark/light)"
|
||||
- "CHANGELOG.md — Stichpunkt-Fassung"
|
||||
key_links:
|
||||
- "Tooltip-Klemmung: `left = max(4, min(rect.left, innerWidth − Breite − Rand))` — Breite und Klemmwert müssen aus EINER Konstante kommen, sonst driften sie (bisher `w-60` = 240 px und `− 244` hart nebeneinander)."
|
||||
- "Titel-Span steht in einem `flex`-Container neben der Uhrzeit-Spalte (`shrink-0`); ohne `min-w-0` darf ein Flex-Kind nicht unter seine Inhaltsbreite schrumpfen, dann greift `break-words` nicht und der Text ragt heraus — deshalb beide Klassen."
|
||||
- "@uiw/react-md-editor wertet `[data-color-mode]` per CSS-Selektor am nächsten Vorfahren aus; das Attribut am Wurzel-Div des Widgets reicht (deshalb stand dort bisher der feste Wert). `ThemeProvider` in apps/web/src/app/layout.tsx (attribute=\"class\", defaultTheme=\"system\", enableSystem) liefert `resolvedTheme` = 'light' | 'dark'."
|
||||
- "Nur note-widget.test.tsx rendert NoteWidget wirklich; page.test.tsx mockt `@/components/dashboard/widgets/note-widget` als `() => null`, die Registry verdrahtet das Widget erst über `wireNoteWidget()` in page.tsx — kein weiterer Test braucht einen next-themes-Mock (gemessen per grep)."
|
||||
- "changelog.ts schneidet an `^## ` und erkennt `## Unveröffentlicht` exakt (`UNRELEASED_RE`); publish-release.sh schneidet mit awk an `^## X.Y.Z( |$)` — die drei Überschriften müssen zeichengenau bleiben (Gedankenstrich „–“, Datum). umlaut-guard.spec.ts prüft nur de.json/en.json, nicht CHANGELOG.md."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Drei Nachträge zu den heutigen Dashboard-Arbeiten (260916-hiv/htc/iex), alle vom Anwender im Browser gemeldet: (1) Der Termin-Tooltip des Kalender-Widgets schneidet lange Titel ab („Deutscher Weltkindertag (…“) — er soll umbrechen. (2) Der Textbereich des Notiz-Widgets bleibt dunkel, wenn Tessera auf „Hell“ steht, weil er der Betriebssystem-Einstellung folgt — er soll dem Tessera-Schalter folgen, wie es die Seite „Was ist neu“ schon tut. (3) Die Änderungsliste CHANGELOG.md ist in Fließtext geraten — sie wird auf kurze Stichpunkte gestrafft (Vorlage liegt fertig im Auftragsordner).
|
||||
|
||||
Purpose: Sichtbare Bedienfehler vor der nächsten Beta beseitigen; Änderungsliste wieder lesbar.
|
||||
Output: Zwei Komponentenkorrekturen mit Tests, eine neue CHANGELOG.md, drei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/changelog/changelog-view.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/CHANGELOG.md
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md
|
||||
|
||||
Live gemessen am 2026-09-16 (Planer):
|
||||
- calendar-widget.tsx: Tooltip-Block Z. 290-315. Z. 296 `className="pointer-events-none fixed z-50 w-60 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg"`, Z. 299 `left: Math.max(4, Math.min(hover.rect.left, window.innerWidth - 244))`, Z. 307 `<span className="truncate">{event.title}</span>` (OHNE `min-w-0` — die Vorgabe „min-w-0 behalten“ trifft nicht zu, es muss ergänzt werden). Die Liste „Nächste Termine“ (Z. 270-281) nutzt ebenfalls `truncate` — die bleibt unverändert (nur der Tooltip ist Auftrag).
|
||||
- calendar-widget.test.tsx: 9 Tests; Test 4 (Z. 149-173) öffnet den Tooltip über `fireEvent.mouseEnter` auf `[data-date="2026-07-20"]` mit Terminen „Team Meeting“ und „Lunch“; `within` ist bereits importiert (Z. 1). Der Titel „Team Meeting“ steht auch in „Nächste Termine“ im DOM — Abfragen daher IMMER mit `within(tooltip)`.
|
||||
- note-widget.tsx: Imports Z. 3-9 (kein next-themes), Komponente ab Z. 42, State-Block Z. 44-51, Cleanup-Effekt Z. 56-61, Wurzel-Div Z. 131 mit festem Farbmodus-Attribut. `MDEditor` ab Z. 175 ohne eigenes `wrapperElement`.
|
||||
- note-widget.test.tsx: 7 Tests; Mocks für next-intl (Z. 5-13) und @uiw/react-md-editor (Z. 22-78); statischer Import `import { NoteWidget } from './note-widget'` Z. 81 („Must import after mocks“); `beforeEach` mit `vi.useFakeTimers()` und fetch-Spy; KEIN next-themes-Mock.
|
||||
- changelog-view.tsx Z. 20-27: `const { resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';` — Vorbild 1:1 übernehmen.
|
||||
- changelog-page.test.tsx Z. 42-44 zeigt den Mock-Stil: `vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: 'light' }) }))`.
|
||||
- CHANGELOG.soll.md: 3 `## `-Überschriften (Z. 5, 26, 47), 28 Stichpunkte, kein Punkt am Zeilenende, keine CRLF. Sachlich gegen CHANGELOG.md und STATE.md geprüft — kein Fehler gefunden („Link“ in der 1.0.0-Liste ist historisch korrekt; Kalender-Widget/Favoriten-Titel unter „Neu“ statt „Geändert“ ist die gewollte Neusortierung).
|
||||
- publish-release.sh: `--dry-run --tag v1.1.0` läuft ohne Token und ohne Netz (Exit 0, druckt JSON); jq vorhanden. awk-Schnitt Z. 81-89 an `^## 1\.1\.0( |$)`.
|
||||
- docs/anleitung-anwender.md beschreibt nur Zweck und Gruppen der Liste, nicht den Stil — bleibt unangetastet.
|
||||
- Basislinie der vier betroffenen Testdateien: 4 Dateien / 29 Tests grün. Gesamt-Basislinie laut STATE.md: 52 Dateien / 344 Tests.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Kalender-Tooltip — Titel umbrechen statt abschneiden, Breite und Klemmwert aus einer Konstante</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx Z. 26-36 (Kopfkommentar Tooltip), Z. 55-60 (hover-State), Z. 288-316 (Tooltip-Portal)
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx Z. 1-12, Z. 149-201 (Tests 4 und 4b)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 4c (neu, nach 4b): Termin mit langem Titel (z. B. `ev('Deutscher Weltkindertag (Aktionstag der Kinderrechte)', …)` am 2026-07-20), Tooltip öffnen wie in Test 4; `const tooltip = screen.getByTestId('calendar-day-tooltip')`; `const title = within(tooltip).getByText('Deutscher Weltkindertag (Aktionstag der Kinderrechte)')`; `expect(title).toHaveClass('break-words')`, `expect(title).toHaveClass('min-w-0')`, `expect(title).not.toHaveClass('truncate')`; `expect(tooltip).toHaveStyle({ width: '288px' })`. Deutscher Testname: „Test 4c: Tooltip bricht lange Termintitel um statt sie abzuschneiden“.
|
||||
- Tests 4 und 4b bleiben unverändert grün (Portal, 5-Eintrag-Grenze, Hinweis).
|
||||
</behavior>
|
||||
<action>
|
||||
RED zuerst: Test 4c in calendar-widget.test.tsx ergänzen, `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-widget.test.tsx` — 4c muss ROT sein (Titel hat noch `truncate`, Breite kommt aus `w-60`).
|
||||
|
||||
GREEN in calendar-widget.tsx:
|
||||
1. Zwei Modul-Konstanten oberhalb der Komponente (bei den anderen Konstanten/Imports) anlegen: `const TOOLTIP_WIDTH_PX = 288;` und `const TOOLTIP_EDGE_PX = 4;` mit kurzem deutschen Kommentar (quick-260916-j4f: Breite und Rand-Klemmung des Termin-Tooltips aus einer Quelle, damit Breite und Klemmwert nicht auseinanderlaufen; 288 px entspricht Tailwind w-72).
|
||||
2. Tooltip-Div (Z. 296): die Klasse `w-60` aus `className` entfernen; alles andere in der Klassenliste bleibt. Im `style`-Objekt `width: TOOLTIP_WIDTH_PX` ergänzen und die `left`-Berechnung auf `Math.max(TOOLTIP_EDGE_PX, Math.min(hover.rect.left, window.innerWidth - TOOLTIP_WIDTH_PX - TOOLTIP_EDGE_PX))` umstellen (bisher hart 4 und 244). `top` bleibt `hover.rect.bottom + 4` — dort ebenfalls `TOOLTIP_EDGE_PX` verwenden.
|
||||
3. Titel-Span (Z. 307): `className="truncate"` → `className="min-w-0 break-words"`. Die Uhrzeit-Spalte (`shrink-0 tabular-nums …`) bleibt.
|
||||
4. Kopfkommentar Z. 31-33 um einen Satz ergänzen: Titel im Tooltip brechen um (kein truncate), Breite/Klemmung über `TOOLTIP_WIDTH_PX`/`TOOLTIP_EDGE_PX` (quick-260916-j4f).
|
||||
Nichts an der Liste „Nächste Termine“, an `hoverEvents.slice(0, 5)`, am Portal oder an den Datenpfaden ändern.
|
||||
|
||||
Test 4c muss danach GRÜN sein, alle 10 Kalender-Tests grün. Commit: `fix(web): Kalender-Tooltip bricht lange Termintitel um (Breite/Klemmung aus einer Konstante)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'const TOOLTIP_WIDTH_PX = 288' apps/web/src/components/dashboard/widgets/calendar-widget.tsx && grep -q 'min-w-0 break-words' apps/web/src/components/dashboard/widgets/calendar-widget.tsx && grep -q 'Test 4c' apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-widget.test.tsx</automated>
|
||||
</verify>
|
||||
<done>Tooltip-Titel tragen `min-w-0 break-words` und kein `truncate`; Breite 288 px und Klemmung kommen aus `TOOLTIP_WIDTH_PX`/`TOOLTIP_EDGE_PX`; calendar-widget.test.tsx 10/10 grün (vorher 9), Test 4c belegt Klassen und Breite.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Notiz-Widget folgt dem Tessera-Farbmodus (next-themes + mounted-Guard wie changelog-view)</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/changelog/changelog-view.tsx Z. 1-38 (Vorbild)
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx Z. 1-12, Z. 42-62, Z. 128-135
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx Z. 1-25, Z. 78-107
|
||||
- apps/web/src/app/(portal)/changelog/changelog-page.test.tsx Z. 40-45 (Mock-Stil next-themes)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Neuer Test „setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht“: Themenzustand auf 'dark' stellen, `const { container } = render(<NoteWidget instanceId="note-t1" config={{}} isEditMode={false} />)`; `container.querySelector('[data-color-mode]')` hat Attribut `data-color-mode` = `'dark'`.
|
||||
- Neuer Test „setzt data-color-mode auf light, wenn Tessera auf Hell steht“: Themenzustand 'light' → Attribut `'light'`.
|
||||
- Die 7 bestehenden Tests bleiben grün (der Farbmodus ist für sie egal; Vorgabe im Mock: 'light').
|
||||
</behavior>
|
||||
<action>
|
||||
RED zuerst in note-widget.test.tsx:
|
||||
1. Vor den bestehenden `vi.mock`-Aufrufen (nach den Imports) einen gehobenen Themenzustand anlegen: `const themeMock = vi.hoisted(() => ({ resolvedTheme: 'light' as 'light' | 'dark' }));` und darunter `vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: themeMock.resolvedTheme }) }));` — `vi.hoisted`, damit die Variable trotz Hoisting der Mocks und des statischen Imports in Z. 81 sicher initialisiert ist. Im `beforeEach` `themeMock.resolvedTheme = 'light'` zurücksetzen.
|
||||
2. Die zwei Tests aus `<behavior>` ans Ende des `describe` anhängen (Zustand jeweils VOR `render` setzen). Lauf: `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/note-widget.test.tsx` — beide neuen Tests ROT (Attribut hat noch den festen Wert).
|
||||
|
||||
GREEN in note-widget.tsx:
|
||||
3. Import `import { useTheme } from 'next-themes';` ergänzen (Paket ist installiert, 0.4.6). `useEffect`/`useState` sind schon importiert.
|
||||
4. In `NoteWidget` direkt nach `const t = useTranslations('widgets');`: `const { resolvedTheme } = useTheme();` und `const [mounted, setMounted] = useState(false);`; einen eigenen Effekt `useEffect(() => { setMounted(true); }, []);` (getrennt vom Cleanup-Effekt Z. 56-61). Danach `const colorMode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';` — exakt das Muster aus changelog-view.tsx Z. 20-27 (mounted-Guard, damit Server- und Client-Markup übereinstimmen).
|
||||
5. Wurzel-Div Z. 131: den festen Attributwert durch `data-color-mode={colorMode}` ersetzen. Kurzer Kommentar darüber (quick-260916-j4f: folgt dem Tessera-Schalter statt der Betriebssystem-Einstellung, Muster changelog-view.tsx).
|
||||
Keine Änderung an MDEditor-Props, Speichern, Abhaken oder Übersetzungen.
|
||||
|
||||
Danach alle 9 Notiz-Tests grün und `pnpm --filter @tessera/web type-check` Exit 0. Commit: `fix(web): Notiz-Widget folgt dem Hell/Dunkel-Schalter von Tessera statt der Systemeinstellung`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "from 'next-themes'" apps/web/src/components/dashboard/widgets/note-widget.tsx && grep -q 'data-color-mode={colorMode}' apps/web/src/components/dashboard/widgets/note-widget.tsx && ! grep -q 'data-color-mode="auto"' apps/web/src/components/dashboard/widgets/note-widget.tsx && grep -q "vi.mock('next-themes'" apps/web/src/components/dashboard/widgets/note-widget.test.tsx && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/note-widget.test.tsx && pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>Wurzel-Div des Notiz-Widgets trägt `data-color-mode={colorMode}` aus `useTheme().resolvedTheme` mit mounted-Guard; note-widget.test.tsx 9/9 grün (vorher 7) mit next-themes-Mock über `vi.hoisted`; tsc Exit 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CHANGELOG.md auf Stichpunkte straffen (Soll-Datei übernehmen, Vorlage löschen), Gesamtgates</name>
|
||||
<files>CHANGELOG.md, .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md</files>
|
||||
<read_first>
|
||||
- .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md (ganz, 63 Zeilen)
|
||||
- CHANGELOG.md (ganz, zum Abgleich der Überschriften)
|
||||
- apps/web/src/lib/changelog.ts Z. 26-40 (UNRELEASED_HEADING, SECTION_RE)
|
||||
- .gitea/scripts/publish-release.sh Z. 78-95 (awk-Schnitt)
|
||||
</read_first>
|
||||
<action>
|
||||
1. CHANGELOG.md vollständig durch den Inhalt von CHANGELOG.soll.md ersetzen: `cp .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md CHANGELOG.md` (Byte-genau, keine Nacharbeit, keine Umlaut-Ersetzung — echte Umlaute bleiben; der Umlaut-Wächter prüft nur de.json/en.json). Ein sachlicher Fehler in der Vorlage wurde bei der Planung nicht gefunden; falls beim Lesen doch einer auffällt (Aussage, die dem Code oder STATE.md widerspricht), korrigieren und im SUMMARY unter „Abweichungen“ nennen.
|
||||
2. Prüfen, dass die drei `## `-Überschriften zeichengenau erhalten sind (Gedankenstrich „–“, Datum) und kein Stichpunkt mit einem Punkt endet (siehe verify).
|
||||
3. Vorlage löschen: `rm .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md` (Scratch-Eingabe, nie im Git — daher `rm`, nicht `git rm`).
|
||||
4. Gesamtgates laufen lassen und die Zahlen für das SUMMARY notieren: `pnpm --filter @tessera/web type-check`; `pnpm --filter @tessera/web exec vitest run` (Erwartung 52 Dateien / 347 Tests, darin umlaut-guard 3/3 und changelog.test.ts 10/10); `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` (Exit 0, JSON enthält „Seite „Was ist neu““ im body). docs/anleitung-anwender.md NICHT anfassen (beschreibt nur Zweck und Gruppen der Liste, nicht den Stil).
|
||||
5. Commit nur mit CHANGELOG.md: `docs: Changelog auf Stichpunkte gestrafft (kein Fließtext)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test ! -e .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md && grep -qx '## Unveröffentlicht' CHANGELOG.md && grep -qx '## 1.1.0 – 2026-09-16' CHANGELOG.md && grep -qx '## 1.0.0 – 2026-09-15' CHANGELOG.md && ! grep -E '^## ' CHANGELOG.md | grep -vqE '^## (Unveröffentlicht|1\.1\.0 – 2026-09-16|1\.0\.0 – 2026-09-15)$' && test "$(grep -E '^- ' CHANGELOG.md | wc -l)" = 28 && ! grep -qE '^- .*\.$' CHANGELOG.md && grep -q 'Textbereich folgt dem Hell/Dunkel-Schalter' CHANGELOG.md && ! grep -q $'\r' CHANGELOG.md && sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0 >/dev/null && pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run</automated>
|
||||
</verify>
|
||||
<done>CHANGELOG.md entspricht der Soll-Vorlage (3 Überschriften, 28 Stichpunkte, kein Satzpunkt am Zeilenende, echte Umlaute); CHANGELOG.soll.md gelöscht; Release-Skript findet den 1.1.0-Abschnitt im Probelauf; Web-tsc 0; Web-vitest komplett grün mit 52 Dateien / 347 Tests.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Kalenderdaten → DOM (Tooltip) | Termintitel aus externen Kalenderquellen werden im Tooltip gerendert. |
|
||||
| next-themes (localStorage `theme`) → Notiz-Widget | Der Farbmodus kommt aus dem clientseitigen Themenzustand. |
|
||||
| CHANGELOG.md → Build → Seite „Was ist neu“ / Gitea-Release | Markdown wird zur Bauzeit eingebettet und per rehypeSanitize gerendert; das Release-Skript liest Abschnitte. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-J4F-01 | Tampering / XSS | calendar-widget.tsx Tooltip-Titel | low | mitigate | Titel bleibt React-Textknoten (`{event.title}`), nur CSS-Klassen ändern sich; kein `dangerouslySetInnerHTML`. |
|
||||
| T-J4F-02 | Denial of Service | Tooltip mit sehr langem Titel | low | mitigate | `break-words` bricht auch wortlose Zeichenketten; Breite fest 288 px, Portal `pointer-events-none`, weiterhin maximal 5 Einträge. |
|
||||
| T-J4F-03 | Information Disclosure | Notiz-Widget Farbmodus | low | accept | `resolvedTheme` ist nur 'light'/'dark'; jeder andere Wert fällt auf 'light' zurück. Kein Datenabfluss. |
|
||||
| T-J4F-04 | Tampering | CHANGELOG.md-Ersatz | low | mitigate | Byte-genaues Kopieren der geprüften Vorlage; Gates prüfen Überschriften, Punktzahl und Release-Schnitt; rehypeSanitize in changelog-view.tsx bleibt. |
|
||||
| T-J4F-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation — next-themes 0.4.6 ist bereits im Lockfile und in changelog-view.tsx in Gebrauch. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
- `pnpm --filter @tessera/web exec vitest run` → komplett grün, 52 Dateien / 347 Tests (Basislinie 52 / 344; +1 Kalender, +2 Notiz); umlaut-guard 3/3 und changelog.test.ts 10/10 im Gesamtlauf enthalten.
|
||||
- `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` → Exit 0.
|
||||
- CHANGELOG.soll.md existiert nicht mehr; `git status` zeigt sie nicht (war nie getrackt).
|
||||
- Kein `biome check` als Gate (bekannter Konfigurationsfehler, biome.json unverändert); kein Docker-Build, kein Deploy, kein Testserver, kein `git push`.
|
||||
- Drei Commits, einer je Task (Konvention der heutigen Quick-Tasks): `fix(web): …`, `fix(web): …`, `docs: …`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Kalender-Tooltip: lange Titel umbrechen (Test 4c), Breite und Klemmung aus einer Konstante.
|
||||
- Notiz-Widget: `data-color-mode` folgt `resolvedTheme` von next-themes mit mounted-Guard (zwei neue Tests).
|
||||
- CHANGELOG.md in Stichpunkt-Fassung, Überschriften intakt, Vorlage gelöscht.
|
||||
- Alle Gates aus `<verification>` grün; SUMMARY nennt die gemessenen Zahlen (vorher 52/344).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/260916-j4f-SUMMARY.md` when done (Muster: die SUMMARY von 260916-iex — Abschnitte Was gebaut wurde / Entscheidungen / Gemessene Zahlen / Commits / Abweichungen vom Plan).
|
||||
</output>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: quick-260916-j4f
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: dashboard-widgets
|
||||
tags: [calendar-widget, note-widget, changelog, theming, tooltip]
|
||||
dependency-graph:
|
||||
requires: [quick-260916-htc (Kalender-Widget-Neubau), quick-260916-iex (Notiz-Häkchen), quick-260916-dcz (CHANGELOG.md/changelog-view.tsx-Vorbild)]
|
||||
provides: [TOOLTIP_WIDTH_PX/TOOLTIP_EDGE_PX-Konstanten (calendar-widget.tsx), Notiz-Widget folgt next-themes (colorMode), CHANGELOG.md Stichpunkt-Fassung]
|
||||
affects: [apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, CHANGELOG.md]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [mounted-Guard + useTheme().resolvedTheme (Muster changelog-view.tsx, jetzt auch im Notiz-Widget), Breite+Klemmung eines Portal-Tooltips aus einer gemeinsamen Modul-Konstante statt zweier hart codierter Werte]
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
- CHANGELOG.md
|
||||
deleted:
|
||||
- .planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md
|
||||
decisions:
|
||||
- "Keine der drei Aufgaben erforderte eine Abweichung vom Plan — Zeilennummern in den Live-gemessenen Notizen des Planers wichen geringfügig von den beim Ausführen gelesenen ab (z. B. Tooltip-Block bei Z. 296-315 statt Z. 290-315), inhaltlich stimmte aber alles überein."
|
||||
metrics:
|
||||
duration: ~10 min
|
||||
completed: 2026-09-16
|
||||
actuals:
|
||||
tokens: 4127
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 4f823c3
|
||||
---
|
||||
|
||||
# Phase quick-260916-j4f Plan 01: Nachträge — Kalender-Tooltip umbrechen, Notiz-Farbmodus, CHANGELOG straffen Summary
|
||||
|
||||
Drei vom Anwender im Browser gemeldete Nachbesserungen an den heutigen Dashboard-Arbeiten: Der Termin-Tooltip des Kalender-Widgets bricht lange Titel jetzt um statt sie abzuschneiden, das Notiz-Widget folgt dem Tessera-Farbschalter statt der Betriebssystem-Einstellung, und CHANGELOG.md ist von Fließtext auf kurze Stichpunkte gestrafft.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Task 1 — Kalender-Tooltip: Titel umbrechen, Breite/Klemmung aus einer Konstante (Commit `b16e4b8`)**
|
||||
Zwei neue Modul-Konstanten `TOOLTIP_WIDTH_PX = 288` und `TOOLTIP_EDGE_PX = 4` ersetzen die bisher zwei getrennt hart codierten Werte (`w-60` = 240px in der Klassenliste, `- 244` in der `left`-Berechnung), die driften konnten. Der Tooltip-Div bekommt die Breite jetzt über `style.width`, `left` klemmt mit `Math.max(TOOLTIP_EDGE_PX, Math.min(hover.rect.left, window.innerWidth - TOOLTIP_WIDTH_PX - TOOLTIP_EDGE_PX))`, `top` nutzt ebenfalls `TOOLTIP_EDGE_PX`. Der Titel-Span im Tooltip trägt `min-w-0 break-words` statt `truncate`; die Uhrzeit-Spalte (`shrink-0`) und die Liste „Nächste Termine“ (weiterhin `truncate`) blieben unverändert. RED-GREEN: Test 4c wurde zuerst rot verifiziert (Titel hatte noch `truncate`, Breite kam aus `w-60`), dann grün. Alle 10 Kalender-Tests grün (vorher 9).
|
||||
|
||||
**Task 2 — Notiz-Widget folgt dem Tessera-Farbmodus (Commit `4c2495b`)**
|
||||
`useTheme()` aus `next-themes` plus eigener `mounted`-Effekt (getrennt vom bestehenden Cleanup-Effekt) liefern `colorMode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'` — exakt das Muster aus `changelog-view.tsx`. Das Wurzel-Div trägt jetzt `data-color-mode={colorMode}` statt des festen Werts `"auto"` (der der Betriebssystem-Einstellung folgte, nicht dem Tessera-Schalter). Im Test wurde ein gehobener Themenzustand über `vi.hoisted` eingeführt (`vi.mock('next-themes', ...)` liest `themeMock.resolvedTheme`), im `beforeEach` auf `'light'` zurückgesetzt. RED-GREEN: beide neuen Tests waren zuerst rot (Attribut lieferte noch `'auto'`), dann grün. Alle 9 Notiz-Tests grün (vorher 7), `tsc --noEmit` Exit 0.
|
||||
|
||||
**Task 3 — CHANGELOG.md auf Stichpunkte gestrafft (Commit `a6bb7aa`)**
|
||||
`CHANGELOG.md` wurde byte-genau durch den geprüften Inhalt von `CHANGELOG.soll.md` ersetzt (drei `## `-Überschriften, 28 Stichpunkte, kein Satzpunkt am Zeilenende, echte Umlaute, keine CRLF). Die Vorlage im Auftragsordner wurde anschließend gelöscht (`rm`, war nie im Git). Beim Lesen der Vorlage fiel kein sachlicher Fehler auf — die vom Planer genannte Prüfung (Link-Eintrag unter 1.0.0 historisch korrekt, Kalender-Widget/Favoriten-Titel bewusst unter „Neu“ neu sortiert) war zutreffend, keine Korrektur nötig.
|
||||
|
||||
## Gemessene Zahlen
|
||||
|
||||
- Vorher (Basislinie laut Plan/STATE.md): 52 Dateien / 344 Tests.
|
||||
- Nachher: **52 Dateien / 347 Tests**, alle grün (+1 Kalender, +2 Notiz — exakt wie erwartet).
|
||||
- `pnpm --filter @tessera/web type-check` → Exit 0.
|
||||
- `pnpm --filter @tessera/web exec vitest run` → 52 Dateien / 347 Tests grün, darin umlaut-guard 3/3 und `changelog.test.ts` 10/10.
|
||||
- `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` → Exit 0, JSON-Body enthält den 1.1.0-Abschnitt inkl. „Seite „Was ist neu““.
|
||||
- CHANGELOG.soll.md existiert nicht mehr; war nie getrackt, taucht in `git status` nicht auf.
|
||||
- Kein `biome check`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push` — wie vorgegeben.
|
||||
|
||||
## Commits
|
||||
|
||||
- `b16e4b8` — fix(web): Kalender-Tooltip bricht lange Termintitel um (Breite/Klemmung aus einer Konstante)
|
||||
- `4c2495b` — fix(web): Notiz-Widget folgt dem Hell/Dunkel-Schalter von Tessera statt der Systemeinstellung
|
||||
- `a6bb7aa` — docs: Changelog auf Stichpunkte gestrafft (kein Fließtext)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — Plan exakt wie geschrieben ausgeführt. Die im Plan als „Live gemessen“ genannten Zeilennummern wichen beim tatsächlichen Lesen der Dateien minimal ab (z. B. Tooltip-Block bei Z. 296-315 statt Z. 290-315), inhaltlich und strukturell stimmte aber alles überein — keine Fixes, keine Rule-1/2/3/4-Fälle.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue, im Plan nicht bereits erfasste sicherheitsrelevante Oberfläche gefunden. Alle vier im `<threat_model>` benannten Maßnahmen (T-J4F-01 bis T-J4F-04) sind wie spezifiziert umgesetzt: Termintitel bleibt React-Textknoten ohne `dangerouslySetInnerHTML`, `break-words` bricht auch wortlose Zeichenketten bei fester Breite und weiterhin maximal 5 Einträgen, `resolvedTheme` fällt auf jeden anderen Wert als `'dark'` auf `'light'` zurück, `CHANGELOG.md` wurde byte-genau aus der geprüften Vorlage übernommen und `rehypeSanitize` in `changelog-view.tsx` blieb unangetastet.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` — FOUND, enthält `TOOLTIP_WIDTH_PX = 288` und `min-w-0 break-words`
|
||||
- `apps/web/src/components/dashboard/widgets/note-widget.tsx` — FOUND, enthält `data-color-mode={colorMode}`, kein `data-color-mode="auto"` mehr
|
||||
- `CHANGELOG.md` — FOUND, 3 Überschriften, 28 Stichpunkte
|
||||
- `.planning/quick/260916-j4f-nachtraege-kalender-tooltip-umbrechen-no/CHANGELOG.soll.md` — CONFIRMED DELETED
|
||||
- Commit `b16e4b8` — FOUND in `git log`
|
||||
- Commit `4c2495b` — FOUND in `git log`
|
||||
- Commit `a6bb7aa` — FOUND in `git log`
|
||||
- Gesamtlauf: 52 Testdateien / 347 Tests grün, Web-Type-Check Exit 0, Release-Dry-Run Exit 0
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
---
|
||||
phase: quick-260916-jvj
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-JVJ]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/app/globals.css
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 25000
|
||||
raw_tokens: 25000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Kalender-Widget, Monatsraster: die Zähl-Plakette eines Tages trägt als Hintergrund die Farbe des Kalenders (`event.color`) des FRÜHESTEN Termins dieses Tages (Termine je Tag nach Start sortiert) mit weißer Schrift; hat dieser Termin keine Farbe, sieht die Plakette aus wie bisher (`bg-primary text-primary-foreground`, kein Inline-Stil). Bei mehreren Quellen an einem Tag zählt allein der früheste Termin (bewusst einfach gehalten, in der SUMMARY vermerken)."
|
||||
- "Kalender-Widget, Tooltip beim Überfahren: jede Terminzeile beginnt mit einem kleinen Farbpunkt (`h-2 w-2 rounded-full shrink-0`) in `event.color`, Rückfall `var(--muted-foreground)` — dieselbe Regel wie der Punkt in „Nächste Termine“; die Zeilen stehen in Startzeit-Reihenfolge."
|
||||
- "Seite „Was ist neu“ (/changelog) und Notiz-Widget-Vorschau: Aufzählungslisten zeigen wieder Punkte (disc, verschachtelt circle), nummerierte Listen Ziffern; Aufgabenlisten mit Kästchen (`- [ ]`) bleiben ohne Punkt."
|
||||
- "CHANGELOG.md, Abschnitt `## Unveröffentlicht`: unter `### Geändert` steht „Kalender-Widget: Plakette am Tag in der Farbe des Kalenders“, unter `### Behoben` steht „„Was ist neu“ und Notiz-Ansicht: Aufzählungspunkte wieder sichtbar“ — kurze Stichpunkte ohne Punkt am Ende, echte Umlaute."
|
||||
- "Gates: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` komplett grün (Basislinie 52 Dateien / 347 Tests → danach 52 Dateien / 350 Tests: +2 calendar-widget, +1 calendar-month); Umlaut-Wächter 3/3; changelog.test.ts grün. KEIN `biome check` (biome.json nicht anfassen), kein Docker-Build, kein Deploy, kein Testserver, kein `git push`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.ts — `groupEventsByDate` sortiert jede Tagesgruppe nach `start` aufsteigend (stabil)"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.test.ts — neuer Test 2b (unsortierte Eingabe → Tagesgruppe sortiert)"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.tsx — Plakette mit `style={{ backgroundColor }}` + `text-white` bei Farbe, sonst `bg-primary text-primary-foreground`; Tooltip-Zeile mit `data-testid=\"tooltip-color-dot\"`"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx — neue Tests 3b (Farbe des frühesten Termins, Tooltip-Punkte) und 3c (ohne Farbe bleibt bg-primary)"
|
||||
- "apps/web/src/app/globals.css — Block `.wmde-markdown`-Listen am Dateiende mit deutschem Kommentar (quick-260916-jvj)"
|
||||
- "CHANGELOG.md — zwei neue Stichpunkte unter Unveröffentlicht"
|
||||
key_links:
|
||||
- "`groupEventsByDate` (calendar-month.ts Z. 106-114) übernimmt heute die API-Reihenfolge unsortiert — die Regel „Farbe des ERSTEN Termins“ ist nur dann deterministisch, wenn die Gruppe nach Start sortiert ist. Sortierung gehört in `groupEventsByDate` (eine Stelle), dann stimmen Plakette UND Tooltip-Reihenfolge überein."
|
||||
- "Tailwind v4 Preflight liegt in `@layer base` und setzt `ul, ol { list-style: none }`; markdown.css (`@uiw/react-markdown-preview` 5.2.1, Z. 477-481) setzt für `.wmde-markdown ul/ol` nur `padding-left: 2em`, KEIN `list-style`. Ungeschichtetes CSS in globals.css schlägt jede `@layer`-Regel unabhängig von Spezifität — deshalb reicht ein normaler Block nach dem `@import`, globals.css hat keine eigene `@layer`-Struktur (gemessen)."
|
||||
- "Aufgabenlisten: remark-gfm setzt `contains-task-list` auf das `ul` und `task-list-item` auf das `li`; markdown.css Z. 878 `.wmde-markdown .task-list-item { list-style-type: none }` (Spezifität 0,2,0) schlägt `.wmde-markdown ul` (0,1,1) bereits — die zusätzliche Regel `.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none }` (0,2,1) macht das unabhängig von der Ladereihenfolge der beiden Stylesheets."
|
||||
- "`toHaveStyle({ backgroundColor: '#c44040' })` normalisiert hex→rgb auf beiden Seiten (jest-dom); Muster im Bestand: Test 4c `toHaveStyle({ width: '288px' })`. `var(--muted-foreground)` NICHT per toHaveStyle prüfen (jsdom löst keine Custom Properties auf) — Rückfall nur über Vorhandensein des Punkts prüfen, wie Test 5 es schon tut."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Zwei Nachträge nach der Browser-Prüfung der heutigen Dashboard-Arbeiten (260916-htc/iex/j4f): (1) Die Zähl-Plakette an einem Tag im Monatsraster des Kalender-Widgets ist immer gelb (Akzentfarbe) — sie soll die Farbe des Kalenders tragen, aus dem der Termin stammt, so wie es der Farbpunkt in „Nächste Termine“ schon tut; damit gemischte Tage lesbar bleiben, bekommt zusätzlich jede Tooltip-Zeile denselben Farbpunkt. (2) Auf „Was ist neu“ und in der Notiz-Vorschau fehlen die Aufzählungspunkte, weil Tailwinds Grundstil `list-style` entfernt und das Markdown-Stylesheet es nicht wiederherstellt — ein kleiner CSS-Block in globals.css behebt das, Aufgabenlisten mit Kästchen bleiben ohne Punkt.
|
||||
|
||||
Purpose: Sichtbare Bedienfehler vor der nächsten Beta beseitigen; Kalenderfarben im Widget durchgängig nutzen.
|
||||
Output: Sortierung in calendar-month.ts, Plakette/Tooltip-Punkt in calendar-widget.tsx, CSS-Block in globals.css, drei neue Tests, zwei CHANGELOG-Stichpunkte, zwei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/app/globals.css
|
||||
@/home/vicolab/projects/tessera-ctl/CHANGELOG.md
|
||||
|
||||
Live gemessen am 2026-09-16 (Planer):
|
||||
- calendar-month.ts: `groupEventsByDate` Z. 106-114 sammelt per `[...existingEvents, event]` in API-Reihenfolge, KEINE Sortierung (Vorgabe „bereits sortiert“ trifft nicht zu). `selectUpcomingEvents` Z. 179-186 zeigt den Sortier-Komparator, der zu übernehmen ist: `new Date(a.start).getTime() - new Date(b.start).getTime()`. Kopfkommentar Z. 14-17 beschreibt die Starttag-Regel.
|
||||
- calendar-month.test.ts: Test 2 Z. 50-64 nutzt einen `ev(id, start, end)`-Helfer und `grouped.get('2026-07-20')`; dort Test 2b anhängen.
|
||||
- calendar-widget.tsx: Plakette Z. 244-251 (`data-testid="calendar-day-count"`, Klassen enthalten `rounded-full bg-primary px-0.5 ... text-primary-foreground`); Listen-Farbpunkt Z. 272-277 (`data-testid="event-color-dot"`, `className="mt-1 h-2 w-2 shrink-0 rounded-full"`, `style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}`, `aria-hidden="true"`); Tooltip-Zeilen Z. 314-321 (`<div key={event.id} className="flex gap-2">`, dann Uhrzeit-Span `shrink-0 tabular-nums text-muted-foreground`, dann Titel-Span `min-w-0 break-words`). `cellClass` Z. 220-227 zeigt das Muster Array → `.filter(Boolean).join(' ')` für zusammengesetzte Klassen. Doc-Kommentar Z. 27-55 erwähnt „Zaehl-Plakette“ (Z. 33).
|
||||
- calendar-widget.test.tsx: 10 Tests; `ev(id, start, end, extra?)` Z. 44-54 nimmt `Partial<CalendarEvent>` (also `{ color: '#c44040' }`); `within`, `fireEvent` importiert; Test 3 Z. 121-141 (Plakette zählt), Test 4 Z. 143-166 (Tooltip per `fireEvent.mouseEnter` auf `[data-date="2026-07-20"]`, danach `screen.getByTestId('calendar-day-tooltip')`). Termintitel stehen auch in „Nächste Termine“ im DOM — Tooltip-Abfragen IMMER mit `within(tooltip)`.
|
||||
- CalendarEvent (apps/web/src/lib/calendar-api.ts Z. 41-51): `color?: string`.
|
||||
- SOURCE_COLOR_PALETTE (calendar-source-form.tsx Z. 12-21): #c44040, #40a060, #4060c4, #8040c4, #c49040, #409090, #c44080, #808080 — alle mittlere Töne, weiße Schrift lesbar.
|
||||
- globals.css: 127 Zeilen, `@import "tailwindcss"` Z. 1, `@custom-variant dark` Z. 22, `@theme inline` Z. 24-50, Tokens, `body` Z. 115-119, `.app-shell-main`-Media-Block Z. 121-126 (Dateiende). Kein `@layer`, kein `.wmde-markdown`.
|
||||
- markdown.css (node_modules/.pnpm/@uiw+react-markdown-preview@5.2.1_*/node_modules/@uiw/react-markdown-preview/markdown.css): Z. 477-481 `.wmde-markdown ul, .wmde-markdown ol { margin 0; padding-left: 2em }` ohne list-style; Z. 483-485 `ol ol, ul ol → lower-roman`; Z. 636-638 `.wmde-markdown div > ol:not([type]) → decimal` (nur ol, ul hat nichts); Z. 878-880 `.wmde-markdown .task-list-item { list-style-type: none }`; Z. 893-895 `.wmde-markdown .contains-task-list input[type='checkbox']` (Klassennamen bestätigt: `contains-task-list` am ul, `task-list-item` am li).
|
||||
- CHANGELOG.md `## Unveröffentlicht`: `### Geändert` hat einen Punkt (Kalenderquellen Adressfeld), `### Behoben` hat zwei (Notiz-Widget Listen abhaken, Textbereich Hell/Dunkel). Neue Punkte jeweils als letzte Zeile des Abschnitts anhängen.
|
||||
- Testbasis: `pnpm --filter @tessera/web exec vitest run` = 52 Dateien / 347 Tests; Skripte `test`/`type-check` in apps/web/package.json vorhanden.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Kalender-Plakette in Kalenderfarbe, Farbpunkt im Tooltip, Sortierung je Tag + Tests</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-month.ts, apps/web/src/components/dashboard/widgets/calendar-month.test.ts, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx</files>
|
||||
<behavior>
|
||||
- calendar-month.test.ts Test 2b: `groupEventsByDate` mit e2 (20.07. 14:00) VOR e1 (20.07. 09:00) in der Eingabe → `grouped.get('2026-07-20')?.map((e) => e.id)` ist `['e1', 'e2']` (Sortierung nach Start; Test 2 bleibt unverändert grün).
|
||||
- calendar-widget.test.tsx Test 3b „Plakette traegt die Kalenderfarbe des fruehesten Termins, Tooltip-Zeilen mit Farbpunkt“: fetchEvents liefert `ev('Lunch', 20.07. 14:00-15:00, { color: '#4060c4' })` ZUERST und `ev('Team Meeting', 20.07. 09:00-10:00, { color: '#c44040' })` danach. Plakette `within(day20).getByTestId('calendar-day-count')`: `toHaveTextContent('2')`, `toHaveStyle({ backgroundColor: '#c44040' })`, `toHaveClass('text-white')`, `not.toHaveClass('bg-primary')`. Dann `fireEvent.mouseEnter(day20)`; im Tooltip `within(tooltip).getAllByTestId('tooltip-color-dot')` hat Länge 2, Punkt [0] `toHaveStyle({ backgroundColor: '#c44040' })`, Punkt [1] `toHaveStyle({ backgroundColor: '#4060c4' })`; `tooltip.textContent.indexOf('Team Meeting')` ist kleiner als `indexOf('Lunch')` (Reihenfolge nach Startzeit).
|
||||
- calendar-widget.test.tsx Test 3c „Plakette ohne Kalenderfarbe behaelt bg-primary“: ein Termin am 21.07. ohne `color`. Plakette `toHaveClass('bg-primary')`, `toHaveClass('text-primary-foreground')`, `not.toHaveClass('text-white')`, `badge.style.backgroundColor` ist `''`. Nach `mouseEnter`: genau ein `tooltip-color-dot` vorhanden (Rückfallfarbe `var(--muted-foreground)` NICHT per toHaveStyle prüfen — jsdom löst Custom Properties nicht auf).
|
||||
- Sollte `toHaveStyle({ backgroundColor: '#c44040' })` in jsdom wider Erwarten nicht greifen, ersatzweise `expect(badge.style.backgroundColor).toBe('rgb(196, 64, 64)')` (jsdom normalisiert hex zu rgb) — Erwartung ändern, nicht die Implementierung.
|
||||
</behavior>
|
||||
<action>
|
||||
Reihenfolge RED → GREEN: erst die drei Tests aus `<behavior>` schreiben und laufen lassen (müssen fehlschlagen), dann implementieren.
|
||||
|
||||
1. calendar-month.ts, `groupEventsByDate` (Z. 106-114): nach dem Sammeln jede Tagesgruppe nach Start aufsteigend sortieren — Komparator wie in `selectUpcomingEvents` (`new Date(a.start).getTime() - new Date(b.start).getTime()`); `Array.prototype.sort` ist stabil, Termine mit gleichem Start behalten die API-Reihenfolge. Doc-Kommentar der Funktion und Kopfkommentar (Starttag-Regel Z. 14-17) um einen Satz ergänzen: Gruppen sind nach Start sortiert, damit Plakettenfarbe (erster Termin) und Tooltip-Reihenfolge deterministisch sind (quick-260916-jvj).
|
||||
|
||||
2. calendar-widget.tsx, Plakette (Z. 244-251): vor dem `return` der Zelle `const badgeColor = day.events[0]?.color;` bestimmen (Gruppe ist jetzt sortiert, [0] = frühester Termin). Klassenstring nach dem `cellClass`-Muster zusammensetzen: unveränderter Basisteil (`absolute bottom-px right-px flex h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] items-center justify-center rounded-full px-0.5 text-[clamp(7px,1.8cqw,10px)] font-semibold leading-none`) plus bei `badgeColor` `text-white`, sonst `bg-primary text-primary-foreground`. `style={badgeColor ? { backgroundColor: badgeColor } : undefined}` — ohne Farbe darf KEIN style-Attribut entstehen (Test 3c prüft `''`). `data-testid` bleibt `calendar-day-count`.
|
||||
|
||||
3. calendar-widget.tsx, Tooltip-Zeile (Z. 314-321): als erstes Kind der `flex gap-2`-Zeile einen Span einfügen mit `data-testid="tooltip-color-dot"`, `className="mt-1 h-2 w-2 shrink-0 rounded-full"`, `style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}`, `aria-hidden="true"` — exakt dieselbe Rückfallregel wie der Listen-Punkt Z. 272-277 (`mt-1` zentriert den 8-px-Punkt in der 16-px-Zeile von `text-xs`). Uhrzeit- und Titel-Span unverändert dahinter.
|
||||
|
||||
4. Doc-Kommentar calendar-widget.tsx Z. 33 („Zaehl-Plakette an Tagen mit Terminen“) ergänzen: Plakette in der Farbe des Kalenders des fruehesten Termins, sonst Akzentfarbe; Tooltip-Zeilen mit Farbpunkt (quick-260916-jvj). ASCII-Umlaute wie im umgebenden Kommentar (ae/oe/ue), das ist dort Konvention.
|
||||
|
||||
Keine weiteren Änderungen: Liste „Nächste Termine“, Portal, Klemmung, Ladefenster bleiben unangetastet. Commit: `feat(web): Kalender-Plakette in der Farbe des Kalenders, Farbpunkt je Tooltip-Zeile`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "tooltip-color-dot" apps/web/src/components/dashboard/widgets/calendar-widget.tsx && grep -q "day.events\[0\]?.color" apps/web/src/components/dashboard/widgets/calendar-widget.tsx && grep -q "Test 3b" apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx && grep -q "Test 3c" apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx && grep -q "Test 2b" apps/web/src/components/dashboard/widgets/calendar-month.test.ts && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-month.test.ts src/components/dashboard/widgets/calendar-widget.test.tsx && pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>calendar-month.test.ts und calendar-widget.test.tsx komplett grün (10 → 12 Widget-Tests, +1 Month-Test); Plakette bekommt bei `color` Inline-Hintergrund + `text-white`, ohne `color` unverändert `bg-primary text-primary-foreground` ohne style-Attribut; Tooltip-Zeilen mit Farbpunkt in Startzeit-Reihenfolge; tsc 0 Fehler.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Aufzählungspunkte in Markdown-Ansichten (globals.css), CHANGELOG, voller Testlauf</name>
|
||||
<files>apps/web/src/app/globals.css, CHANGELOG.md</files>
|
||||
<action>
|
||||
1. globals.css: ans Dateiende (nach dem `.app-shell-main`-Media-Block Z. 121-126) einen Block anhängen, eingeleitet von einem kurzen deutschen Kommentar (Muster der bestehenden Kommentare, ASCII-Umlaute wie dort): Tailwind-Preflight setzt `ul, ol { list-style: none }` in `@layer base`, markdown.css von @uiw/react-markdown-preview stellt es nicht wieder her — deshalb fehlten auf „Was ist neu“ und in der Notiz-Vorschau die Punkte; ungeschichtete Regel hier schlägt die Layer-Regel; Aufgabenlisten bleiben ohne Punkt (quick-260916-jvj). Danach genau diese vier Regeln, je eine Zeile: `.wmde-markdown ul { list-style: disc; }` — `.wmde-markdown ul ul { list-style: circle; }` — `.wmde-markdown ol { list-style: decimal; }` — `.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; }`. Kein `@layer`, kein `!important`, keine weiteren Selektoren. Klassennamen `contains-task-list`/`task-list-item` sind gegen markdown.css Z. 878/894 bestätigt.
|
||||
|
||||
2. CHANGELOG.md, Abschnitt `## Unveröffentlicht`: unter `### Geändert` als letzte Zeile `- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders` anhängen; unter `### Behoben` als letzte Zeile `- „Was ist neu“ und Notiz-Ansicht: Aufzählungspunkte wieder sichtbar` anhängen. Typografische Anführungszeichen „…“ wie im Bestand, kein Punkt am Zeilenende, Überschriften zeichengenau unverändert, kein CRLF.
|
||||
|
||||
3. Volle Gates laufen lassen (siehe verify). Kein `biome check` (bekannter Konfigurationsfehler, biome.json nicht anfassen), kein Docker-Build, kein Deploy, kein Testserver, kein `git push`. Commit: `fix(web): Aufzählungspunkte in Markdown-Ansichten (Was ist neu, Notiz) wieder sichtbar; Changelog`.
|
||||
|
||||
In der SUMMARY vermerken: Plakettenfarbe = frühester Termin des Tages (bei mehreren Quellen an einem Tag keine Mischung, bewusst einfach); `groupEventsByDate` sortiert jetzt (war vorher API-Reihenfolge); CSS-Block ist bewusst ungeschichtet, weil Preflight in `@layer base` liegt.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^\.wmde-markdown ul { list-style: disc; }' apps/web/src/app/globals.css && grep -q '^\.wmde-markdown ul ul { list-style: circle; }' apps/web/src/app/globals.css && grep -q '^\.wmde-markdown ol { list-style: decimal; }' apps/web/src/app/globals.css && grep -q '^\.wmde-markdown ul\.contains-task-list, \.wmde-markdown li\.task-list-item { list-style: none; }' apps/web/src/app/globals.css && grep -q 'quick-260916-jvj' apps/web/src/app/globals.css && grep -qx -- '- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders' CHANGELOG.md && grep -qx -- '- „Was ist neu“ und Notiz-Ansicht: Aufzählungspunkte wieder sichtbar' CHANGELOG.md && grep -qx '## Unveröffentlicht' CHANGELOG.md && ! grep -q $'\r' CHANGELOG.md && pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run</automated>
|
||||
</verify>
|
||||
<done>globals.css enthält die vier Listen-Regeln mit Kommentar am Dateiende; CHANGELOG.md hat beide neuen Stichpunkte in den richtigen Abschnitten; `type-check` Exit 0; `vitest run` komplett grün mit 52 Dateien / 350 Tests (Umlaut-Wächter 3/3, changelog.test.ts grün eingeschlossen).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API → Browser (event.color) | Farbwert stammt aus der gespeicherten Kalenderquelle (Formular-Palette, im Backend als String abgelegt) und landet als Inline-`backgroundColor` im DOM |
|
||||
| Markdown → DOM | Bereits durch `rehypeSanitize` abgedeckt (260916-dcz/iex); dieser Auftrag ändert nur CSS, keine Sanitizer-Konfiguration |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-JVJ-01 | Tampering | calendar-widget.tsx Inline-Stil aus `event.color` | low | accept | React setzt `style.backgroundColor` als Eigenschaftswert (kein HTML, kein `url()`-Kontext); ungültige Werte verwirft der Browser. Identische Nutzung besteht bereits beim Listen-Farbpunkt (Z. 275). Keine neue Angriffsfläche. |
|
||||
| T-JVJ-02 | Information Disclosure | globals.css `.wmde-markdown`-Regeln | low | accept | Reines Styling ohne Datenfluss; Selektoren wirken nur innerhalb des Markdown-Containers. |
|
||||
| T-JVJ-SC | Tampering | npm/pnpm installs | low | accept | Keine Paketinstallation in diesem Auftrag (nur Quell- und CSS-Änderungen). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web type-check` → Exit 0
|
||||
- `pnpm --filter @tessera/web exec vitest run` → 52 Dateien / 350 Tests grün (Basis 347: +2 calendar-widget, +1 calendar-month); darin Umlaut-Wächter 3/3 und changelog.test.ts
|
||||
- grep-Gates aus beiden Tasks (Plakette `day.events[0]?.color`, `tooltip-color-dot`, vier CSS-Regeln, zwei CHANGELOG-Zeilen, kein CRLF)
|
||||
- Kein biome, kein Docker, kein Deploy, kein Push
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Plakette im Monatsraster zeigt die Kalenderfarbe des frühesten Termins des Tages (weiße Schrift), ohne Farbe unverändert Akzentfarbe
|
||||
- Tooltip-Zeilen mit Farbpunkt (gleiche Rückfallregel wie die Liste), Reihenfolge nach Startzeit
|
||||
- „Was ist neu“ und Notiz-Vorschau zeigen Aufzählungspunkte; Aufgabenlisten mit Kästchen bleiben ohne Punkt
|
||||
- CHANGELOG.md um zwei kurze Stichpunkte ergänzt
|
||||
- Alle Gates grün, zwei Commits, kein Push
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260916-jvj-kalender-plaketten-in-kalenderfarbe-stat/260916-jvj-SUMMARY.md` when done
|
||||
</output>
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
---
|
||||
phase: quick-260916-jvj
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, tailwind, vitest, calendar, markdown]
|
||||
|
||||
requires:
|
||||
- phase: quick-260916-htc
|
||||
provides: Kalender-Widget Monatsraster mit Zähl-Plakette und Farbpunkt in „Nächste Termine“
|
||||
- phase: quick-260916-j4f
|
||||
provides: Termin-Tooltip per createPortal (Breite/Klemmung aus TOOLTIP_WIDTH_PX/TOOLTIP_EDGE_PX)
|
||||
provides:
|
||||
- Kalender-Plakette im Monatsraster trägt die Kalenderfarbe des frühesten Termins des Tages
|
||||
- Tooltip-Zeilen mit Farbpunkt in derselben Reihenfolge/Rückfallregel wie „Nächste Termine“
|
||||
- groupEventsByDate sortiert jede Tagesgruppe deterministisch nach Startzeit
|
||||
- Aufzählungspunkte in .wmde-markdown-Ansichten (Was ist neu, Notiz-Vorschau) wieder sichtbar
|
||||
affects: [dashboard-calendar, changelog-page, note-widget]
|
||||
|
||||
actuals:
|
||||
tokens: 2855
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Klassenstring-Array + .filter(Boolean).join(' ') für bedingte Tailwind-Klassen (bereits durch cellClass etabliert, jetzt auch für die Plakette)"
|
||||
- "Ungeschichtetes CSS in globals.css zum gezielten Überschreiben von Tailwind-Preflight-Regeln aus @layer base"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/app/globals.css
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Plakettenfarbe = Farbe des frühesten Termins des Tages (Gruppe sortiert nach Start); bei mehreren Kalenderquellen an einem Tag keine Mischfarbe — bewusst einfach gehalten, vom Plan so vorgegeben"
|
||||
- "groupEventsByDate sortiert jetzt selbst (vorher API-Reihenfolge unsortiert); Sortierung an einer Stelle hält Plakettenfarbe und Tooltip-Reihenfolge konsistent"
|
||||
- "CSS-Block für .wmde-markdown-Listen bewusst ohne @layer, weil Preflight in @layer base liegt und ungeschichtetes CSS jede @layer-Regel unabhängig von Spezifität schlägt"
|
||||
|
||||
patterns-established:
|
||||
- "Bedingte Badge-Farbe: style nur setzen wenn event.color vorhanden, sonst kein style-Attribut (Testbarkeit über style.backgroundColor === '')"
|
||||
|
||||
requirements-completed: [QUICK-260916-JVJ]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Kalender-Plakette im Monatsraster zeigt die Kalenderfarbe des frühesten Termins (weiße Schrift), ohne Farbe unverändert bg-primary"
|
||||
requirement: "QUICK-260916-JVJ"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3b: Plakette traegt die Kalenderfarbe des fruehesten Termins, Tooltip-Zeilen mit Farbpunkt"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Tooltip-Zeilen zeigen einen Farbpunkt je Termin (gleiche Rückfallregel wie die Liste), Reihenfolge nach Startzeit"
|
||||
requirement: "QUICK-260916-JVJ"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3b: Plakette traegt die Kalenderfarbe des fruehesten Termins, Tooltip-Zeilen mit Farbpunkt"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "groupEventsByDate sortiert jede Tagesgruppe nach Startzeit aufsteigend"
|
||||
requirement: "QUICK-260916-JVJ"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-month.test.ts#Test 2b: groupEventsByDate sortiert jede Tagesgruppe nach Start aufsteigend"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Aufzählungspunkte in .wmde-markdown-Ansichten (Was ist neu, Notiz-Vorschau) wieder sichtbar, Aufgabenlisten mit Kästchen bleiben ohne Punkt"
|
||||
requirement: "QUICK-260916-JVJ"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "CSS-Regeln sind über grep-Gates auf Vorhandensein geprüft, aber die visuelle Wirkung (Punkte sichtbar, Aufgabenlisten ohne Punkt) hat kein automatisiertes Browser-Rendering-Assert in diesem Auftrag — erfordert einen kurzen Blick in den Browser."
|
||||
- id: D5
|
||||
description: "CHANGELOG.md um zwei Stichpunkte ergänzt (Geändert: Plakette, Behoben: Aufzählungspunkte)"
|
||||
requirement: "QUICK-260916-JVJ"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -qx -- '- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders' CHANGELOG.md && grep -qx -- '- „Was ist neu“ und Notiz-Ansicht: Aufzählungspunkte wieder sichtbar' CHANGELOG.md"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 3min
|
||||
completed: 2026-09-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260916-jvj: Kalender-Plaketten in Kalenderfarbe, Aufzählungspunkte in Markdown-Ansichten Summary
|
||||
|
||||
**Kalender-Widget: Tages-Plakette und Tooltip-Zeilen tragen jetzt die Kalenderfarbe des frühesten Termins; Markdown-Listen (Was ist neu, Notiz-Vorschau) zeigen wieder Aufzählungspunkte**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min (14:23:30 – 14:26:12)
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 6
|
||||
|
||||
## Accomplishments
|
||||
- Zähl-Plakette im Monatsraster des Kalender-Widgets trägt die Farbe des Kalenders des frühesten Termins des Tages (weiße Schrift), ohne Farbe unverändert `bg-primary text-primary-foreground`
|
||||
- Tooltip-Zeilen beim Überfahren eines Tages zeigen denselben Farbpunkt wie die Liste „Nächste Termine“ (Rückfall `var(--muted-foreground)`), in Startzeit-Reihenfolge
|
||||
- `groupEventsByDate` sortiert jede Tagesgruppe jetzt selbst nach Start aufsteigend (vorher API-Reihenfolge unsortiert) — eine Stelle, an der Plakettenfarbe und Tooltip-Reihenfolge konsistent bleiben
|
||||
- Aufzählungspunkte auf „Was ist neu“ und in der Notiz-Vorschau wieder sichtbar (Tailwind-Preflight `list-style: none` in `@layer base` wurde durch markdown.css nicht wiederhergestellt); Aufgabenlisten mit Kästchen bleiben ohne Punkt
|
||||
- CHANGELOG.md um zwei Stichpunkte ergänzt
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Kalender-Plakette in Kalenderfarbe, Farbpunkt im Tooltip, Sortierung je Tag + Tests** - `1e4ec30` (feat)
|
||||
2. **Task 2: Aufzählungspunkte in Markdown-Ansichten (globals.css), CHANGELOG, voller Testlauf** - `c85cf9a` (fix)
|
||||
|
||||
_TDD-Task 1: RED (drei Tests geschrieben, liefen fehlschlagend) → GREEN (Implementierung, alle drei plus Bestand grün) in einem Commit — der Plan verlangte keine separaten RED/GREEN-Commits._
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.ts` - `groupEventsByDate` sortiert jede Tagesgruppe nach Start aufsteigend; Kopf-/Funktionskommentar ergänzt
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.test.ts` - Test 2b (unsortierte Eingabe → Tagesgruppe sortiert)
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` - Plakette mit bedingtem `style={{ backgroundColor }}` + `text-white` bei Farbe, sonst `bg-primary text-primary-foreground` ohne style; Tooltip-Zeile mit Farbpunkt (`data-testid="tooltip-color-dot"`); Doc-Kommentar ergänzt
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx` - Test 3b (Farbe des frühesten Termins, Tooltip-Punkte, Reihenfolge) und Test 3c (ohne Farbe bleibt bg-primary)
|
||||
- `apps/web/src/app/globals.css` - Block `.wmde-markdown`-Listenregeln am Dateiende mit deutschem Kommentar
|
||||
- `CHANGELOG.md` - zwei neue Stichpunkte unter „Unveröffentlicht“ (Geändert, Behoben)
|
||||
|
||||
## Decisions Made
|
||||
- Plakettenfarbe = Farbe des frühesten Termins des Tages; bei mehreren Kalenderquellen an einem Tag keine Mischfarbe (bewusst einfach, so im Plan vorgegeben)
|
||||
- CSS-Block für die Markdown-Listen bewusst ungeschichtet (kein `@layer`), weil Tailwind-Preflight in `@layer base` liegt — ungeschichtetes CSS schlägt jede `@layer`-Regel unabhängig von Spezifität
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Kein Folgeauftrag angelegt; beide Nachträge aus der heutigen Browser-Prüfung (260916-htc/iex/j4f) sind damit geschlossen
|
||||
- Optional: kurzer Blick in den Browser auf „Was ist neu“ und die Notiz-Vorschau, um D4 (visuelle Wirkung der CSS-Regeln) zu bestätigen
|
||||
|
||||
---
|
||||
*Phase: quick-260916-jvj*
|
||||
*Completed: 2026-09-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: quick-260916-k2z
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260916-K2Z]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 22000
|
||||
raw_tokens: 22000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Kalender-Widget, Monatsraster: liegen an einem Tag Termine aus GENAU EINEM Kalender (eine `sourceId`), sieht der Tag aus wie heute — eine Plakette `data-testid=\"calendar-day-count\"` in Größe `h-[clamp(10px,3cqw,16px)]`, Inline-Hintergrund in der Kalenderfarbe mit `text-white`, ohne Farbe `bg-primary text-primary-foreground` (Tests 3, 3b, 3c bleiben unverändert grün)."
|
||||
- "Liegen an einem Tag Termine aus ZWEI oder DREI Kalendern, stehen unten rechts in der Zelle zwei bzw. drei kleine Kreise nebeneinander (`h-[clamp(8px,2.4cqw,12px)]`, Schrift `text-[clamp(6px,1.5cqw,8px)]`), jeder in der Farbe seines Kalenders mit der Anzahl der Termine dieses Kalenders; Reihenfolge = erstes Auftreten in den nach Start sortierten Tagesterminen."
|
||||
- "Liegen an einem Tag Termine aus MEHR ALS DREI Kalendern, zeigen die ersten zwei Kalender je einen kleinen farbigen Kreis und ein dritter grauer Kreis (`bg-muted-foreground text-background`, `data-testid=\"calendar-day-count-rest\"`) die SUMME der Termine aller übrigen Kalender."
|
||||
- "Gruppierung erfolgt nach `sourceId`, NICHT nach Farbe (zwei Kalender mit gleicher Farbe bleiben zwei Kreise); die sichtbare Farbe eines Kreises ist die `color` des ersten Termins seiner Gruppe, Rückfall wie bisher Akzentfarbe."
|
||||
- "Tooltip beim Überfahren bleibt unverändert (Zeilen mit Farbpunkt in Startreihenfolge)."
|
||||
- "CHANGELOG.md, `## Unveröffentlicht` → `### Geändert`: der vorhandene Stichpunkt lautet jetzt „Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis“ — kein neuer Stichpunkt, kein Punkt am Ende, echte Umlaute."
|
||||
- "Gates: `pnpm --filter @tessera/web type-check` Exit 0; `pnpm --filter @tessera/web exec vitest run` komplett grün (Basislinie 52 Dateien / 350 Tests → danach 52 Dateien / 354 Tests: +2 calendar-month, +2 calendar-widget); changelog.test.ts grün. KEIN `biome check`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.ts — neue Exporte `DaySourceGroup`, `DayBadge`, `CALENDAR_DAY_BADGE_MAX = 3`, `groupDayBySource(events)`, `buildDayBadges(events, max = CALENDAR_DAY_BADGE_MAX)`"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-month.test.ts — neue Tests 8 (groupDayBySource) und 9 (buildDayBadges)"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.tsx — Plaketten-IIFE (Z. 246-264) ersetzt durch Wrapper `data-testid=\"calendar-day-badges\"` mit `buildDayBadges(day.events).map(...)`"
|
||||
- "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx — neue Tests 3d (zwei Kalender → zwei Kreise) und 3e (vier Kalender → zwei Kreise + grauer Rest)"
|
||||
- "CHANGELOG.md — Stichpunkt Z. 16 erweitert"
|
||||
key_links:
|
||||
- "`groupEventsByDate` (calendar-month.ts Z. 110-121) sortiert jede Tagesgruppe bereits nach Start (quick-260916-jvj) — `groupDayBySource` darf NICHT selbst sortieren, sondern übernimmt die Reihenfolge von `day.events`, damit Kreis-Reihenfolge und Tooltip-Reihenfolge übereinstimmen."
|
||||
- "Ein DOM-Element trägt nur EIN `data-testid`. Deshalb: farbige Kreise `calendar-day-count`, grauer Restkreis `calendar-day-count-rest`, Wrapper `calendar-day-badges` (Tests zählen Kinder des Wrappers für „drei Kreise“)."
|
||||
- "Die Positionierung `absolute bottom-px right-px` wandert von der Plakette auf den Wrapper; Tests 3b/3c prüfen nur Text, Inline-Hintergrund und die Klassen `text-white`/`bg-primary`/`text-primary-foreground` — diese Klassen bleiben auf der Plakette selbst."
|
||||
- "Grau-Wahl: `--muted-foreground` ist hell oklch 0.55, dunkel oklch 0.65 (globals.css Z. 71/98). `text-white` wäre im Dunkelmodus auf 0.65 schwach; `text-background` (hell = weiß, dunkel = dunkel) ist auf beiden Themes lesbar. `bg-muted` (0.96/0.30) wäre auf der Zelle `bg-muted/50` unsichtbar."
|
||||
- "`toHaveStyle({ backgroundColor: '#c44040' })` normalisiert hex→rgb (Muster Test 3b); Rückfall-Fall über `style.backgroundColor === ''` prüfen (Muster Test 3c)."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Folgeaufgabe zu 260916-jvj (Plakette in Kalenderfarbe): Liegen an einem Tag Termine aus mehreren Kalendern, zeigt die Plakette bisher nur die Farbe des frühesten Termins und die Gesamtzahl. Ab jetzt bekommt jeder Kalender einen eigenen kleinen Kreis in seiner Farbe mit seiner Anzahl (bis drei Kalender); ab dem vierten Kalender fassen zwei farbige Kreise plus ein grauer Restkreis mit der Summe die Übrigen zusammen. Ein einzelner Kalender sieht weiter aus wie heute.
|
||||
|
||||
Purpose: Gemischte Tage im Monatsraster auf einen Blick lesbar machen (User-Entscheidung „ja, mach so“).
|
||||
Output: Zwei reine Hilfsfunktionen in calendar-month.ts mit Unit-Tests, Kreis-Rendering im Widget mit Komponententests, erweiterter CHANGELOG-Stichpunkt, zwei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/CHANGELOG.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Hilfsfunktionen groupDayBySource / buildDayBadges in calendar-month.ts mit Unit-Tests</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-month.ts, apps/web/src/components/dashboard/widgets/calendar-month.test.ts</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts (Kopfkommentar Z. 1-20, `groupEventsByDate` Z. 104-121, Export-Stil)
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts (Fabrik `ev(id, start, end, extra)` Z. 19-29 setzt `sourceId: 's1'` als Vorgabe; `extra` überschreibt `sourceId`/`color`; Testnummerierung endet bei Test 7 + „Zusatz“)
|
||||
- apps/web/src/lib/calendar-api.ts Z. 41-51 (`CalendarEvent`: `sourceId: string`, `color?: string`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 8 (groupDayBySource): (a) drei Termine, alle `s1` mit `color: '#c44040'` → genau eine Gruppe `{ sourceId: 's1', color: '#c44040', count: 3 }`. (b) Termine in der Reihenfolge s2, s1, s2 (Eingabereihenfolge wird NICHT umsortiert) → zwei Gruppen in der Reihenfolge s2 (count 2), s1 (count 1). (c) Termine ohne `color` → `color` der Gruppe ist `undefined` (nicht `''`, nicht `null`). (d) zwei Kalender mit derselben Farbe `#123456` → trotzdem zwei Gruppen (Gruppierung nach `sourceId`, nicht nach Farbe). (e) Farbe der Gruppe ist die `color` des ERSTEN Termins der Gruppe, auch wenn ein späterer Termin derselben Quelle eine andere Farbe trägt. (f) leeres Array → `[]`.
|
||||
- Test 9 (buildDayBadges): (a) 1 Quelle → genau ein Eintrag `{ key: 's1', color, count, rest: false }`. (b) 2 Quellen → zwei Einträge, beide `rest: false`, Reihenfolge wie Gruppen. (c) 3 Quellen → drei Einträge, alle `rest: false`, KEIN Restkreis. (d) 4 Quellen s1(1 Termin), s2(2), s3(1), s4(3) → genau drei Einträge: [0] key `s1` count 1, [1] key `s2` count 2, [2] `{ key: '__rest__', color: undefined, count: 4, rest: true }` (1+3 = Summe von s3 und s4). (e) `buildDayBadges(vierQuellen, 2)` → zwei Einträge: s1 und Rest mit count 6 (2+1+3). (f) leeres Array → `[]`.
|
||||
</behavior>
|
||||
<action>
|
||||
In `calendar-month.ts` nach `groupEventsByDate` (Z. 121) zwei reine Funktionen plus Typen/Konstante ergänzen; kein React, keine DOM-Zugriffe (Modulregel aus dem Kopfkommentar).
|
||||
|
||||
1. `export interface DaySourceGroup { sourceId: string; color?: string; count: number }` und `export function groupDayBySource(events: CalendarEvent[]): DaySourceGroup[]`: über `events` in gegebener Reihenfolge laufen, `Map<string, DaySourceGroup>` nach `event.sourceId`; beim ersten Auftreten Gruppe mit `color: event.color` (kann `undefined` sein) und `count: 0` anlegen, dann `count` erhöhen; Rückgabe `Array.from(map.values())` (Map-Einfügereihenfolge = erstes Auftreten). NICHT sortieren — die Reihenfolge kommt aus `groupEventsByDate` (dort bereits nach Start sortiert), damit Kreise und Tooltip dieselbe Reihenfolge haben.
|
||||
|
||||
2. `export const CALENDAR_DAY_BADGE_MAX = 3;`, `export interface DayBadge { key: string; color?: string; count: number; rest: boolean }` und `export function buildDayBadges(events: CalendarEvent[], max: number = CALENDAR_DAY_BADGE_MAX): DayBadge[]`: `groups = groupDayBySource(events)`. Wenn `groups.length <= max` → jede Gruppe zu `{ key: group.sourceId, color: group.color, count: group.count, rest: false }`. Sonst → die ersten `max - 1` Gruppen wie eben, danach genau ein Eintrag `{ key: '__rest__', color: undefined, count: Summe der count aller Gruppen ab Index max - 1, rest: true }`. Leere Eingabe ergibt `[]`.
|
||||
|
||||
3. Kopfkommentar der Datei (Z. 14-19) um einen Satz ergänzen: Kreise je Kalender werden nach `sourceId` gruppiert, Reihenfolge = erstes Auftreten in der start-sortierten Tagesgruppe, ab dem vierten Kalender grauer Restkreis mit Summe (quick-260916-k2z). Deutsche Kommentare, ASCII-Umlaute wie im Bestand (ue/ae/oe).
|
||||
|
||||
4. In `calendar-month.test.ts` die Importliste um `buildDayBadges` und `groupDayBySource` erweitern und nach Test 7 die Tests 8 und 9 gemäß `<behavior>` anlegen (deutsche Testnamen im Stil „Test 8: groupDayBySource …“). Termine mit der vorhandenen Fabrik `ev(...)` erzeugen und `sourceId`/`color` über den `extra`-Parameter setzen; alle Termine eines Tests auf denselben Tag legen, das ist für die Helfer aber unerheblich (sie kennen keine Tage).
|
||||
|
||||
Reihenfolge TDD: Tests zuerst schreiben, `vitest run calendar-month` rot sehen, dann Implementierung, grün.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/calendar-month.test.ts && grep -c "export function groupDayBySource\|export function buildDayBadges\|export const CALENDAR_DAY_BADGE_MAX" apps/web/src/components/dashboard/widgets/calendar-month.ts | grep -qx 3</automated>
|
||||
</verify>
|
||||
<done>Tests 8 und 9 grün, alle bisherigen calendar-month-Tests weiter grün (Datei 11 Tests); `groupDayBySource` gruppiert nach `sourceId` in Reihenfolge des ersten Auftretens mit `color` des ersten Gruppentermins (undefined ohne Farbe); `buildDayBadges` liefert 1/2/3 Einträge ohne Rest bzw. bei mehr als `max` Gruppen `max - 1` Einträge plus einen `rest: true`-Eintrag mit summierter Anzahl. Commit: `feat(web): Kalender-Tag nach Kalender gruppieren, Kreise je Kalender berechnen (calendar-month)`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Kreise je Kalender im Widget rendern, Komponententests, CHANGELOG, Gesamtlauf</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, CHANGELOG.md</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx Z. 9-19 (Importliste aus `./calendar-month`), Z. 27-37 (Kopfkommentar, Satz zur Plakette), Z. 246-264 (Plaketten-IIFE: `badgeColor = day.events[0]?.color`, `badgeClass`, `data-testid="calendar-day-count"`)
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx Z. 44-54 (Fabrik `ev`, Vorgabe `sourceId: 's1'`), Z. 125-208 (Tests 3, 3b, 3c — Erwartungen an die Einzelplakette, die unverändert grün bleiben müssen)
|
||||
- CHANGELOG.md Z. 13-16 (`### Geändert`, vorhandener Stichpunkt „Kalender-Widget: Plakette am Tag in der Farbe des Kalenders“)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 3d (zwei Kalender am selben Tag): Mock-Termine am 2026-07-20 in dieser Array-Reihenfolge: s2 `#4060c4` 10:00, s2 `#4060c4` 14:00, s1 `#c44040` 09:00 (s1 steht im Array zuletzt, ist aber der früheste Termin). Erwartung in der Zelle `[data-date="2026-07-20"]`: `getAllByTestId('calendar-day-count')` hat Länge 2; [0] Text „1“, `toHaveStyle({ backgroundColor: '#c44040' })`; [1] Text „2“, `toHaveStyle({ backgroundColor: '#4060c4' })`; beide `toHaveClass('text-white')`, beide `not.toHaveClass('bg-primary')`, beide `toHaveClass('h-[clamp(8px,2.4cqw,12px)]')`; `queryByTestId('calendar-day-count-rest')` ist null; Wrapper `getByTestId('calendar-day-badges')` hat `childElementCount` 2.
|
||||
- Test 3e (vier Kalender am selben Tag): Mock-Termine am 2026-07-21: s1 `#111111` 08:00 (1 Termin), s2 `#222222` 09:00 und 09:30 (2 Termine), s3 ohne Farbe 10:00 (1 Termin), s4 `#444444` 11:00, 12:00, 13:00 (3 Termine). Erwartung in `[data-date="2026-07-21"]`: Wrapper `calendar-day-badges` hat `childElementCount` 3; `getAllByTestId('calendar-day-count')` Länge 2 mit [0] „1“ + Hintergrund `#111111`, [1] „2“ + Hintergrund `#222222`; `getByTestId('calendar-day-count-rest')` hat Text „4“, `toHaveClass('bg-muted-foreground')`, `toHaveClass('text-background')`, `style.backgroundColor === ''`, `not.toHaveClass('text-white')`.
|
||||
- Bestehende Tests 3, 3b, 3c laufen unverändert grün (Einzelplakette: eine `calendar-day-count`, Farbe/`text-white` bzw. `bg-primary text-primary-foreground`).
|
||||
</behavior>
|
||||
<action>
|
||||
1. `calendar-widget.tsx`: Import `buildDayBadges` aus `./calendar-month` in die bestehende alphabetische Importliste (Z. 9-19) aufnehmen. Die IIFE Z. 246-264 komplett durch einen Wrapper ersetzen, der nur bei `hasEvents` gerendert wird: `<span data-testid="calendar-day-badges" className="absolute bottom-px right-px flex items-end gap-px">` mit `buildDayBadges(day.events).map((badge) => ...)`. Die Positionierung (`absolute bottom-px right-px`) liegt damit NUR auf dem Wrapper, nicht mehr auf den Kreisen. Vor dem `return` des Zellen-Callbacks `const badges = buildDayBadges(day.events);` und `const single = badges.length === 1;` berechnen (kein IIFE mehr).
|
||||
|
||||
Je Kreis ein `<span key={badge.key}>` mit:
|
||||
- `data-testid`: `badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'` — ein Element kann nur EIN `data-testid` tragen, deshalb keine Doppelvergabe.
|
||||
- Klassen aus einem Array, `.filter(Boolean).join(' ')` wie bisher: immer `flex items-center justify-center rounded-full px-0.5 font-semibold leading-none`; bei `single` zusätzlich `h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]` (heutige Größe), sonst `h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]`; Farbklassen: `badge.rest` → `bg-muted-foreground text-background`, sonst `badge.color` → `text-white`, sonst `bg-primary text-primary-foreground`.
|
||||
- `style`: `badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined`.
|
||||
- Inhalt: `{badge.count}`.
|
||||
|
||||
Grau-Wahl bewusst `text-background` statt `text-white`: `--muted-foreground` ist im Dunkelmodus ein helles Grau (oklch 0.65), weiße Schrift wäre dort schwach; `text-background` ist hell weiß und dunkel dunkel, also auf beiden Themes lesbar (Begründung als kurzen deutschen Kommentar über den Wrapper schreiben, ASCII-Umlaute).
|
||||
|
||||
Kopfkommentar Z. 33-35 anpassen: Zaehl-Plakette in der Farbe des Kalenders; bei mehreren Kalendern am selben Tag je ein kleiner Kreis pro Kalender (max. drei, danach zwei plus grauer Restkreis mit Summe, Logik in `buildDayBadges`, quick-260916-k2z). Tooltip-Block (Z. 311-346) NICHT anfassen.
|
||||
|
||||
2. `calendar-widget.test.tsx`: nach Test 3c die Tests 3d und 3e gemäß `<behavior>` anlegen (Muster von 3b/3c: `mockFetchEvents.mockResolvedValue([...])`, `await import('./calendar-widget')`, `render` mit eigener `instanceId` `cal-3d`/`cal-3e`, `waitFor` auf „Juli 2026“, Zelle über `document.querySelector('[data-date="…"]')`, `within(...)`). `sourceId` und `color` je Termin über den `extra`-Parameter der Fabrik `ev` setzen. Reihenfolge-Prüfung in 3d ergibt sich daraus, dass s1 im Mock-Array zuletzt steht, aber als frühester Termin den ersten Kreis bekommt.
|
||||
|
||||
3. `CHANGELOG.md` Z. 16: den vorhandenen Stichpunkt ersetzen durch `- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis` — KEINEN neuen Stichpunkt anlegen, kein Punkt am Ende, echte Umlaute, sonst nichts im CHANGELOG ändern.
|
||||
|
||||
4. Gesamtlauf: `pnpm --filter @tessera/web type-check` (Exit 0) und `pnpm --filter @tessera/web exec vitest run` (alle grün, erwartet 52 Dateien / 354 Tests; Basislinie 350 + 2 aus Task 1 + 2 aus diesem Task). Weicht die Zahl ab, Ursache benennen, nicht schönreden. Kein `biome check`, kein Docker-Build, kein Deploy, kein Testserver, kein `git push`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run && grep -qF "Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis" CHANGELOG.md && grep -q 'data-testid="calendar-day-count-rest"\|calendar-day-count-rest' apps/web/src/components/dashboard/widgets/calendar-widget.tsx && grep -q "buildDayBadges" apps/web/src/components/dashboard/widgets/calendar-widget.tsx</automated>
|
||||
</verify>
|
||||
<done>Einzelplakette sieht aus wie bisher (Tests 3, 3b, 3c unverändert grün); Tests 3d und 3e grün (zwei Kalender → zwei kleine Kreise in je eigener Farbe mit eigener Anzahl in Startreihenfolge; vier Kalender → zwei farbige Kreise plus grauer Restkreis `bg-muted-foreground text-background` mit Summe 4); Tooltip unverändert; CHANGELOG-Stichpunkt erweitert; `type-check` Exit 0; Vitest komplett grün mit 52 Dateien / 354 Tests. Commit: `feat(web): Kalender-Widget zeigt je Kalender einen kleinen Kreis am Tag; Changelog`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API → Widget | `CalendarEvent[]` vom Backend (`fetchEvents`), Felder `sourceId`/`color` werden im DOM als Schlüssel bzw. Inline-Stil verwendet |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-K2Z-01 | Tampering | `style={{ backgroundColor: badge.color }}` | low | accept | Unverändert gegenüber 260916-jvj: React setzt Inline-Stile über die CSSOM-Eigenschaft, kein `dangerouslySetInnerHTML`; `color` stammt aus der eigenen Quellen-Tabelle (Admin/Anwender pflegt sie selbst) |
|
||||
| T-K2Z-02 | Denial of Service | `groupDayBySource` | low | accept | Lineare Laufzeit über die Tagestermine, Eingabe ist bereits auf das 42-Tage-Fenster begrenzt |
|
||||
| T-K2Z-SC | Tampering | npm-Installs | low | accept | Keine neuen Pakete in diesem Auftrag |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web type-check` Exit 0
|
||||
- `pnpm --filter @tessera/web exec vitest run` 52 Dateien / 354 Tests grün (calendar-month 11, calendar-widget 15, changelog.test.ts grün)
|
||||
- Einzelplakette: Tests 3/3b/3c ohne Änderung grün
|
||||
- Zwei Commits (Task 1: Helfer + Unit-Tests; Task 2: Widget + Komponententests + CHANGELOG), kein Push
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tage mit einem Kalender: unverändert eine Plakette in Kalenderfarbe (oder Akzent)
|
||||
- Tage mit zwei/drei Kalendern: zwei/drei kleine Kreise, je Kalenderfarbe und eigene Anzahl, Reihenfolge nach frühestem Termin
|
||||
- Tage mit vier oder mehr Kalendern: zwei farbige Kreise plus grauer Restkreis mit Summe der übrigen
|
||||
- Gruppierung nach `sourceId`, nicht nach Farbe
|
||||
- CHANGELOG-Stichpunkt erweitert, alle Gates grün
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260916-k2z-kalender-widget-mehrere-kalender-am-selb/260916-k2z-SUMMARY.md` when done
|
||||
</output>
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
---
|
||||
phase: quick-260916-k2z
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, next.js, vitest, calendar-widget, dashboard]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: quick-260916-jvj
|
||||
provides: "Plakette in Kalenderfarbe (badgeColor = day.events[0]?.color), Tooltip mit Farbpunkt je Zeile, sortierte Tagesgruppen in groupEventsByDate"
|
||||
provides:
|
||||
- "groupDayBySource(events) — gruppiert Tagestermine nach sourceId (nicht Farbe), Reihenfolge = erstes Auftreten"
|
||||
- "buildDayBadges(events, max) — baut bis zu drei Plaketten-Kreise, ab dem vierten Kalender einen grauen Restkreis mit Summe"
|
||||
- "Kalender-Widget rendert je Kalender am selben Tag einen kleinen farbigen Kreis statt einer einzigen Gesamt-Plakette"
|
||||
affects: [dashboard-calendar-widget, calendar-month-helpers]
|
||||
|
||||
# Actuals (#2632)
|
||||
actuals:
|
||||
tokens: 4700
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Reine Hilfsfunktionen ohne React/DOM in calendar-month.ts, vom Widget importiert (Muster aus resolveCalendarConfig/groupEventsByDate fortgeschrieben)"
|
||||
- "Ein DOM-Element traegt genau ein data-testid; mehrere gleichartige Elemente unterscheiden sich per rest-Flag im data-testid (calendar-day-count vs. calendar-day-count-rest), Positionierung wandert auf einen gemeinsamen Wrapper"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-month.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Grau-Wahl fuer den Restkreis: text-background statt text-white, weil --muted-foreground im Dunkelmodus hell ist (oklch 0.65) und weisse Schrift dort schwach waere; text-background ist auf beiden Themes lesbar (aus PLAN uebernommen, keine eigene Abweichung)"
|
||||
- "Gruppierung nach sourceId, nicht nach Farbe — zwei Kalender mit identischer Farbe bleiben zwei Kreise (aus PLAN uebernommen)"
|
||||
|
||||
patterns-established:
|
||||
- "buildDayBadges(events, max = CALENDAR_DAY_BADGE_MAX) als generische Kappungslogik: erste max-1 Gruppen sichtbar, Rest zu einem Summen-Eintrag gebuendelt — wiederverwendbar fuer aehnliche Kappungsfaelle"
|
||||
|
||||
requirements-completed: [QUICK-260916-K2Z]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "groupDayBySource gruppiert Tagestermine nach sourceId (nicht Farbe) in Reihenfolge des ersten Auftretens, Farbe = Farbe des ersten Termins der Gruppe"
|
||||
requirement: "QUICK-260916-K2Z"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-month.test.ts#Test 8: groupDayBySource gruppiert nach sourceId in Reihenfolge des ersten Auftretens"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "buildDayBadges liefert 1-3 Eintraege ohne Rest, ab dem vierten Kalender max-1 Eintraege plus einen rest:true-Eintrag mit summierter Anzahl"
|
||||
requirement: "QUICK-260916-K2Z"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-month.test.ts#Test 9: buildDayBadges liefert je Kalender einen Eintrag, ab dem vierten einen grauen Restkreis"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Einzelplakette (ein Kalender am Tag) sieht unveraendert aus wie vor diesem Auftrag (Kalenderfarbe bzw. Akzentfarbe)"
|
||||
requirement: "QUICK-260916-K2Z"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3: Zaehl-Plakette zeigt die korrekte Terminanzahl je Tag"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3b: Plakette traegt die Kalenderfarbe des fruehesten Termins, Tooltip-Zeilen mit Farbpunkt"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Zwei Kalender am selben Tag zeigen zwei kleine Kreise, je eigene Farbe und Anzahl, in Startreihenfolge"
|
||||
requirement: "QUICK-260916-K2Z"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3d: zwei Kalender am selben Tag zeigen zwei kleine Kreise in Startreihenfolge"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Vier oder mehr Kalender am selben Tag zeigen zwei farbige Kreise plus einen grauen Restkreis mit der Summe der uebrigen"
|
||||
requirement: "QUICK-260916-K2Z"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx#Test 3e: vier Kalender am selben Tag zeigen zwei Kreise plus grauen Restkreis mit Summe"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "CHANGELOG.md-Stichpunkt erweitert; type-check und komplette Vitest-Suite gruen (52 Dateien / 354 Tests)"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "pnpm --filter @tessera/web type-check && pnpm --filter @tessera/web exec vitest run"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 10min
|
||||
completed: 2026-09-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260916-k2z: Kalender-Widget mehrere Kalender am selben Tag als kleine Kreise Summary
|
||||
|
||||
**Kalender-Widget zeigt bei mehreren Kalendern am selben Tag je einen kleinen farbigen Kreis pro Kalender (bis drei), ab dem vierten Kalender zwei Kreise plus einen grauen Restkreis mit der Summe — via neuen reinen Hilfsfunktionen `groupDayBySource`/`buildDayBadges` in calendar-month.ts.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~10 min (zwei Task-Commits 14:34:49 und 14:37:01 Uhr; Zeiterfassung nicht exakt ab Sitzungsbeginn protokolliert)
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 5
|
||||
|
||||
## Accomplishments
|
||||
- `groupDayBySource(events)`: gruppiert Tagestermine nach `sourceId` (nicht Farbe) in Reihenfolge des ersten Auftretens, Gruppenfarbe = Farbe des ersten Termins der Gruppe
|
||||
- `buildDayBadges(events, max = 3)`: liefert 1-3 Einträge ohne Rest bzw. ab dem vierten Kalender `max - 1` Einträge plus einen `rest: true`-Eintrag mit summierter Anzahl
|
||||
- Kalender-Widget: Plaketten-Rendering ersetzt (Wrapper `calendar-day-badges`, Kreise `calendar-day-count`/`calendar-day-count-rest`), Einzelplakette sieht unverändert aus wie zuvor
|
||||
- CHANGELOG-Stichpunkt erweitert
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Hilfsfunktionen groupDayBySource / buildDayBadges in calendar-month.ts mit Unit-Tests** - `4ddadc6` (feat, TDD: RED vor Implementierung bestätigt)
|
||||
2. **Task 2: Kreise je Kalender im Widget rendern, Komponententests, CHANGELOG, Gesamtlauf** - `7429c5b` (feat, TDD: RED vor Implementierung bestätigt)
|
||||
|
||||
**Plan metadata:** committed separately by the orchestrator (per constraints, this executor did not commit SUMMARY.md/STATE.md/PLAN.md)
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.ts` - neue Exporte `DaySourceGroup`, `DayBadge`, `CALENDAR_DAY_BADGE_MAX`, `groupDayBySource`, `buildDayBadges`; Kopfkommentar ergänzt
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-month.test.ts` - Tests 8 (groupDayBySource) und 9 (buildDayBadges)
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` - Plaketten-IIFE durch Wrapper `calendar-day-badges` mit `buildDayBadges(day.events).map(...)` ersetzt; Import und Kopfkommentar angepasst
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx` - Tests 3d (zwei Kalender) und 3e (vier Kalender) ergänzt
|
||||
- `CHANGELOG.md` - Stichpunkt unter „Geändert“ um „mehrere Kalender am selben Tag zeigen je einen kleinen Kreis“ erweitert
|
||||
|
||||
## Decisions Made
|
||||
- Grau-Wahl für den Restkreis: `text-background` statt `text-white` — `--muted-foreground` ist im Dunkelmodus hell (oklch 0.65), `text-background` ist auf beiden Themes lesbar (aus dem Plan übernommen, keine eigene Abweichung nötig)
|
||||
- Gruppierung strikt nach `sourceId`, nicht nach Farbe (aus dem Plan übernommen)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Die einzige Zahlenabweichung ist rein buchhalterisch: Der Plan nannte für `calendar-widget.test.tsx` 15 Tests, tatsächlich sind es 14 (12 bestehende + 2 neue); die im Plan als Gesamtgate genannte Summe von 354 Tests über alle 52 Dateien stimmt exakt — die einzelne Datei-Teilzahl im Plantext war ungenau, das Verhalten selbst ist unverändert zum Plan.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Kalender-Widget-Feature ist vollständig, kein Folgeauftrag angelegt
|
||||
- Keine Blocker
|
||||
|
||||
---
|
||||
*Phase: quick-260916-k2z*
|
||||
*Completed: 2026-09-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 5 modified files and the SUMMARY.md file confirmed present on disk; both task commits (`4ddadc6`, `7429c5b`) confirmed present in `git log --oneline --all`.
|
||||
+42
-20
@@ -4,38 +4,60 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Kalender-Widget: Monatsübersicht mit Terminanzahl je Tag, Termine beim Überfahren, darunter „Nächste Termine“
|
||||
- Kalender-Widget: Einstellungen für Monatsansicht, Anzahl und Zeitraum der Termine
|
||||
- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)
|
||||
|
||||
### Geändert
|
||||
|
||||
- Kalenderquellen: Adressfeld zeigt je Typ ein Beispiel; bei Exchange EWS Hinweis auf `/EWS/Exchange.asmx`
|
||||
- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Widget „Link“ – Favoriten-Widget übernimmt; vorhandene Link-Kacheln werden beim Update entfernt
|
||||
|
||||
### Behoben
|
||||
|
||||
- Notiz-Widget: Listen lassen sich in der Ansicht direkt abhaken
|
||||
- Notiz-Widget: Textbereich folgt dem Hell/Dunkel-Schalter von Tessera
|
||||
- „Was ist neu“ und Notiz-Ansicht: Aufzählungspunkte wieder sichtbar
|
||||
|
||||
## 1.1.0 – 2026-09-16
|
||||
|
||||
### Neu
|
||||
|
||||
- Seite „Was ist neu“: Ein Klick auf die Versionsnummer unten in der Seitenleiste zeigt diese Liste. Auf dem Live-System sehen Sie nur freigegebene Versionen, auf der Beta zusätzlich den Abschnitt „Noch nicht freigegeben“.
|
||||
- Uhr: Unter Einstellungen > Dashboard lässt sich die Schriftgröße der Uhrzeit fest in Punkt (8 bis 200) vorgeben. Lassen Sie das Feld leer, passt sich die Schrift weiterhin automatisch an.
|
||||
- Seite „Was ist neu“ (Klick auf die Versionsnummer in der Seitenleiste)
|
||||
- Uhr: feste Schriftgröße der Uhrzeit (8–200 Punkt) unter Einstellungen > Dashboard
|
||||
|
||||
### Geändert
|
||||
|
||||
- Dashboard-Raster und Mindestgrößen: Das Raster ist doppelt so fein, Widgets lassen sich in kleineren Schritten verschieben und in der Größe ziehen. Jedes Widget hat jetzt genau die Mindestgröße, bei der es gerade noch bedienbar ist – kleiner geht es nicht, größer jederzeit; das gilt auch für bereits platzierte Widgets. Gespeicherte Anordnungen werden beim ersten Aufruf automatisch übernommen und verrutschen nicht.
|
||||
- Verschieben: Im Bearbeitungsmodus lässt sich jede Kachel an einer beliebigen Stelle anfassen (außer an Eingabefeldern, Knöpfen und Links); ein grauer Griff am oberen Rand zeigt das an. Kacheln überlappen sich beim Ablegen nicht mehr – über einer belegten Stelle springt die Kachel an ihren Ausgangspunkt zurück.
|
||||
- Der Bearbeiten-Schalter des Dashboards sitzt jetzt unten rechts; die Widgets beginnen direkt unter der Kopfzeile.
|
||||
- Uhrzeit, Stoppuhr und Rechner wachsen und schrumpfen mit ihrer Kachel – eine große Uhr-Kachel zeigt eine große Uhrzeit. Die Stoppuhr hat kompaktere Knöpfe und passt so auch in kleine Kacheln.
|
||||
- Die Ränder sind überall enger: Der äußere Seitenrahmen auf allen Seiten, der Abstand zwischen den Widgets und die Innenabstände der Widgets sind halbiert.
|
||||
- Das Anwenderhandbuch beschreibt das feine Raster, die mitwachsende Uhrzeit, die Schriftgrößen-Einstellung, den neuen Schalter, das Ziehen und die Mindestgrößen.
|
||||
- Dashboard-Raster doppelt so fein; Widgets in kleineren Schritten verschiebbar und skalierbar
|
||||
- Mindestgröße je Widget; gespeicherte Anordnungen werden automatisch übernommen
|
||||
- Kacheln im Bearbeitungsmodus überall anfassbar; kein Überlappen beim Ablegen
|
||||
- Bearbeiten-Schalter unten rechts; Widgets beginnen direkt unter der Kopfzeile
|
||||
- Uhr, Stoppuhr und Rechner wachsen mit der Kachel; Stoppuhr mit kompakteren Knöpfen
|
||||
- Engere Ränder und Abstände auf allen Seiten
|
||||
- Anwenderhandbuch aktualisiert
|
||||
|
||||
### Behoben
|
||||
|
||||
- Kalender-Einstellungen: Die Felder des Formulars für Kalenderquellen (Name, Typ, Adresse, Benutzername, Passwort, Domäne, Farbe) und die Knöpfe zeigten technische Schlüsselnamen statt Beschriftungen; ebenso die Rückmeldungen beim Speichern und ein Hinweis im Marktplatz. Alle Texte sind jetzt auf Deutsch und Englisch hinterlegt.
|
||||
- Kalender-Einstellungen und Marktplatz: fehlende Übersetzungen (technische Schlüsselnamen statt Beschriftungen)
|
||||
|
||||
## 1.0.0 – 2026-09-15
|
||||
|
||||
### Neu
|
||||
|
||||
- Portal mit Kopfleiste und Seitenleiste: Dashboard, Marktplatz und die für Sie freigegebenen Module nach Kategorien mit Suchfeld. Die Seitenleiste lässt sich ein- und ausklappen, das Erscheinungsbild wechselt zwischen Hell, Dunkel und System, die Sprache zwischen Deutsch und Englisch.
|
||||
- Persönliches Dashboard mit frei anordenbaren Kacheln: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Link und Stoppuhr. Im Bearbeitungsmodus fügen Sie Kacheln hinzu, verschieben sie und ziehen ihre Größe; die Einstellungen je Kachel (Kalenderquellen, Suchanbieter, Links) finden Sie unter Einstellungen > Dashboard.
|
||||
- Marktplatz mit den Zuständen „Aktiviert“ und „Verfügbar“, Suche, Filtern und einer Detailseite je Modul. Administratoren aktivieren ein Modul für das Unternehmen und geben es je Gruppe oder Benutzer frei (Freigaben-Matrix).
|
||||
- Ausschreibungs-Radar: Trefferliste öffentlicher Ausschreibungen mit Filtern nach Frist, Postleitzahl, Bundesland, Branche und Auftragswert; Suchprofile mit Sofort-Alarm per E-Mail; Sammel-Mail täglich oder wöchentlich; Merken und Gelesen-Markierung; eigene Postfächer und RSS-Feeds als Quellen.
|
||||
- DKV-Rechnung: Automatische Verarbeitung von DKV-Tankkarten-Rechnungen aus einem Postfach, Fahrzeug-Stammdaten mit CSV-Import, Verarbeitungshistorie und Exportdateien zum Herunterladen.
|
||||
- Zertifikat-Manager (Zertifikate analysieren, aufteilen, zusammenführen und konvertieren) und Domaincheck (Verfügbarkeit von Internet-Domains prüfen).
|
||||
- Benutzer-, Gruppen- und Rechteverwaltung: Rollen Benutzer, Admin und Super-Admin; lokale und verzeichnisgeführte Konten; Gruppen mit Standardgruppe; Anbindung an das Active Directory mit Import von Gruppen und Einzelbenutzern, Ausschlussliste und automatischer Synchronisation.
|
||||
- E-Mail-Versand (SMTP) mit Testnachricht; „Passwort vergessen“ und Zurücksetzen per E-Mail; erzwungene Passwortänderung bei neuen Konten.
|
||||
- Persönliche Einstellungen: Profilbild, Akzentfarbe und Passwort ändern für lokale Konten.
|
||||
- Knopf „Fehler melden“ in der Kopfleiste: Bildschirmfoto, Beschreibung und technische Angaben gehen per E-Mail an den Administrator.
|
||||
- Versionsanzeige unten in der Seitenleiste mit Versionsnummer und Kanal (Live oder Beta).
|
||||
- Portal mit Kopfleiste und Seitenleiste; Hell/Dunkel/System; Deutsch/Englisch
|
||||
- Persönliches Dashboard mit Kacheln: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Link, Stoppuhr
|
||||
- Marktplatz: Module aktivieren und je Gruppe oder Benutzer freigeben
|
||||
- Ausschreibungs-Radar: Trefferliste mit Filtern, Suchprofile mit E-Mail-Alarm, Sammel-Mail, Merken, eigene Postfächer und RSS-Feeds
|
||||
- DKV-Rechnung: Tankkarten-Rechnungen automatisch aus dem Postfach verarbeiten, Fahrzeug-Stammdaten mit CSV-Import, Historie, Export
|
||||
- Zertifikat-Manager und Domaincheck
|
||||
- Benutzer, Gruppen und Rechte; Active-Directory-Anbindung mit Import und automatischer Synchronisation
|
||||
- E-Mail-Versand (SMTP), „Passwort vergessen“, erzwungene Passwortänderung bei neuen Konten
|
||||
- Persönliche Einstellungen: Profilbild, Akzentfarbe, Passwort
|
||||
- Knopf „Fehler melden“: Bildschirmfoto und Beschreibung per E-Mail an den Administrator
|
||||
- Versionsanzeige mit Kanal (Live/Beta) in der Seitenleiste
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Widget "Link" entfernt (quick-260916-iex): das Favoriten-Widget deckt den
|
||||
-- Anwendungsfall "Schnellzugriff auf Links" bereits vollstaendig ab, ein
|
||||
-- separates Einzel-Link-Widget ist ueberfluessig. Web/API haben den Typ
|
||||
-- `link` restlos entfernt (Registry, Katalog, DTO); diese Migration raeumt
|
||||
-- bestehende Link-Kacheln in der Datenbank hinterher.
|
||||
--
|
||||
-- FavoriteLink-Zeilen des jeweiligen Widgets kaskadieren automatisch ueber
|
||||
-- den bestehenden Fremdschluessel "FavoriteLink_widgetId_fkey ... ON DELETE
|
||||
-- CASCADE" (Migration 20260708090000_add_favorite_link) -- kein separates
|
||||
-- DELETE auf "FavoriteLink" noetig.
|
||||
--
|
||||
-- Laeuft als Migrationsrolle "tessera" (POSTGRES_USER, Superuser mit
|
||||
-- BYPASSRLS, siehe 20260909130000_rls_app_role). Die FORCE ROW LEVEL
|
||||
-- SECURITY-Regel auf "WidgetInstance" (Migration 20260909140000) greift
|
||||
-- fuer diese Rolle deshalb nicht -- das DELETE sieht und loescht ueber
|
||||
-- alle Mandanten hinweg.
|
||||
--
|
||||
-- Idempotent: ein zweiter Lauf loescht 0 Zeilen. Keine Schemaaenderung.
|
||||
-- Verwaiste Eintraege im Layout-JSON von "DashboardLayout" sind unschaedlich
|
||||
-- (dashboard-grid.tsx rendert nur ueber widgets.map) und verschwinden beim
|
||||
-- naechsten Speichern des Dashboards von selbst.
|
||||
DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link';
|
||||
@@ -2,12 +2,12 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the eight supported types.
|
||||
* widgetType must be one of the seven supported types.
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch'])
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle acht heute
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
|
||||
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
|
||||
* favorites/link/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
|
||||
* einzige bislang geplante modulgebundene Widget steht in
|
||||
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
|
||||
@@ -25,7 +25,7 @@ export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle acht
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () =>
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wireLinkWidget } from '@/components/dashboard/widget-registry';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
@@ -13,7 +13,6 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { LinkWidget } from '@/components/dashboard/widgets/link-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
@@ -25,7 +24,6 @@ wireNoteWidget(NoteWidget);
|
||||
wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wireLinkWidget(LinkWidget);
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
@@ -124,3 +124,15 @@ body {
|
||||
margin-left: var(--current-sidebar-width, var(--sidebar-width));
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind-Preflight setzt in @layer base `ul, ol { list-style: none }`; das
|
||||
Markdown-Stylesheet von @uiw/react-markdown-preview stellt es nicht wieder
|
||||
her — deshalb fehlten auf „Was ist neu“ und in der Notiz-Vorschau die
|
||||
Aufzaehlungspunkte. Diese Regel liegt bewusst NICHT in einem @layer, damit
|
||||
sie die Layer-Regel unabhaengig von Spezifitaet schlaegt. Aufgabenlisten
|
||||
mit Kaestchen (`- [ ]`) bleiben ohne Punkt (quick-260916-jvj).
|
||||
*/
|
||||
.wmde-markdown ul { list-style: disc; }
|
||||
.wmde-markdown ul ul { list-style: circle; }
|
||||
.wmde-markdown ol { list-style: decimal; }
|
||||
.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; }
|
||||
|
||||
@@ -20,8 +20,8 @@ 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
|
||||
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag`
|
||||
// (Favoriten-Widget). 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
|
||||
|
||||
@@ -17,7 +17,6 @@ const WIDGET_TYPES: WidgetType[] = [
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'link',
|
||||
'stopwatch',
|
||||
];
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
// New types (Phase 8)
|
||||
'calculator',
|
||||
'favorites',
|
||||
'link',
|
||||
'stopwatch',
|
||||
];
|
||||
|
||||
@@ -49,11 +48,10 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('favorites');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt, sieben Typen)', () => {
|
||||
// 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.
|
||||
@@ -61,11 +59,10 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
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 },
|
||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 3, minH: 10, 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 },
|
||||
});
|
||||
|
||||
@@ -76,6 +73,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(32);
|
||||
expect(counted).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { ComponentType } from 'react';
|
||||
/**
|
||||
* Supported widget types for the dashboard.
|
||||
* clock/search/calendar/note: implemented in previous plans.
|
||||
* calculator/favorites/link/stopwatch: Phase 8 additions.
|
||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
* decken den Fall ab).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
@@ -12,7 +14,6 @@ export type WidgetType =
|
||||
| 'note'
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'link'
|
||||
| 'stopwatch';
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,9 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// Existing widgets
|
||||
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 },
|
||||
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
|
||||
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
|
||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
// Phase 8 new widgets (D-01)
|
||||
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
||||
@@ -49,7 +52,6 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
|
||||
calculator: { minW: 3, minH: 10, 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 },
|
||||
};
|
||||
|
||||
@@ -217,26 +219,6 @@ function FavoritesIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LinkIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StopwatchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -312,14 +294,6 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
...WIDGET_CONSTRAINTS.favorites,
|
||||
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
|
||||
},
|
||||
link: {
|
||||
type: 'link',
|
||||
nameKey: 'link.name',
|
||||
descriptionKey: 'link.description',
|
||||
icon: LinkIcon,
|
||||
...WIDGET_CONSTRAINTS.link,
|
||||
component: PlaceholderWidget, // Replaced via wireLinkWidget()
|
||||
},
|
||||
stopwatch: {
|
||||
type: 'stopwatch',
|
||||
nameKey: 'stopwatch.name',
|
||||
@@ -382,14 +356,6 @@ export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
|
||||
}
|
||||
}
|
||||
|
||||
let linkWired = false;
|
||||
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!linkWired) {
|
||||
WIDGET_REGISTRY.link.component = component;
|
||||
linkWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let stopwatchWired = false;
|
||||
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!stopwatchWired) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||
import {
|
||||
addDays,
|
||||
buildCalendarDays,
|
||||
buildDayBadges,
|
||||
computeFetchWindow,
|
||||
dateKey,
|
||||
formatEventDate,
|
||||
formatEventTime,
|
||||
formatMonthLabel,
|
||||
gridStartFor,
|
||||
groupDayBySource,
|
||||
groupEventsByDate,
|
||||
resolveCalendarConfig,
|
||||
selectUpcomingEvents,
|
||||
startOfLocalDay,
|
||||
} from './calendar-month';
|
||||
|
||||
/** Kleine Fabrik fuer Testtermine (Muster aus dem Plan). */
|
||||
function ev(id: string, start: Date, end: Date, extra?: Partial<CalendarEvent>): CalendarEvent {
|
||||
return {
|
||||
id,
|
||||
sourceId: 's1',
|
||||
title: id,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
allDay: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('calendar-month', () => {
|
||||
it('Test 1: buildCalendarDays liefert 42 Zellen ab Montag mit korrektem Monatsflag und heutigem Tag', () => {
|
||||
const days = buildCalendarDays(new Date(2026, 6, 1), new Map(), new Date(2026, 6, 15));
|
||||
|
||||
expect(days).toHaveLength(42);
|
||||
expect(days[0].key).toBe('2026-06-29');
|
||||
expect(days[0].inCurrentMonth).toBe(false);
|
||||
expect(days[2].key).toBe('2026-07-01');
|
||||
expect(days[2].inCurrentMonth).toBe(true);
|
||||
expect(days[41].key).toBe('2026-08-09');
|
||||
|
||||
const todayCells = days.filter((d) => d.isToday);
|
||||
expect(todayCells).toHaveLength(1);
|
||||
expect(todayCells[0].key).toBe('2026-07-15');
|
||||
|
||||
const outside = days.filter((d) => !d.inCurrentMonth);
|
||||
expect(outside).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('Test 2: groupEventsByDate gruppiert nach lokalem Startdatum, buildCalendarDays uebernimmt die Zaehlung', () => {
|
||||
const events = [
|
||||
ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
|
||||
ev('e3', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)),
|
||||
];
|
||||
|
||||
const grouped = groupEventsByDate(events);
|
||||
expect(grouped.size).toBe(2);
|
||||
expect(grouped.get('2026-07-20')).toHaveLength(2);
|
||||
|
||||
const days = buildCalendarDays(new Date(2026, 6, 1), grouped);
|
||||
const day20 = days.find((d) => d.key === '2026-07-20');
|
||||
expect(day20?.events.length).toBe(2);
|
||||
});
|
||||
|
||||
it('Test 2b: groupEventsByDate sortiert jede Tagesgruppe nach Start aufsteigend', () => {
|
||||
const events = [
|
||||
ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
|
||||
ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
];
|
||||
|
||||
const grouped = groupEventsByDate(events);
|
||||
expect(grouped.get('2026-07-20')?.map((e) => e.id)).toEqual(['e1', 'e2']);
|
||||
});
|
||||
|
||||
it('Test 3: resolveCalendarConfig klemmt ungueltige Werte auf die Vorgaben/Grenzen zurueck', () => {
|
||||
expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 });
|
||||
expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false);
|
||||
expect(resolveCalendarConfig({ showMonth: 'nein' }).showMonth).toBe(true);
|
||||
expect(resolveCalendarConfig({ maxEvents: 99 }).maxEvents).toBe(10);
|
||||
expect(resolveCalendarConfig({ maxEvents: -1 }).maxEvents).toBe(0);
|
||||
expect(resolveCalendarConfig({ maxEvents: 4.7 }).maxEvents).toBe(4);
|
||||
expect(resolveCalendarConfig({ maxEvents: '5' }).maxEvents).toBe(3);
|
||||
expect(resolveCalendarConfig({ lookaheadDays: 45 }).lookaheadDays).toBe(30);
|
||||
expect(resolveCalendarConfig({ lookaheadDays: 90 }).lookaheadDays).toBe(90);
|
||||
});
|
||||
|
||||
it('Test 4: computeFetchWindow deckt Raster und Vorschau-Zeitraum als Tagesgrenzen ab', () => {
|
||||
const w1 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 6, 15, 10, 30));
|
||||
expect(w1.from.getTime()).toBe(new Date(2026, 5, 29).getTime());
|
||||
expect(w1.to.getTime()).toBe(new Date(2026, 7, 14).getTime());
|
||||
|
||||
const w2 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 4, 1, 8, 0));
|
||||
expect(w2.from.getTime()).toBe(new Date(2026, 4, 1).getTime());
|
||||
expect(w2.to.getTime()).toBe(new Date(2026, 7, 10).getTime());
|
||||
|
||||
const w3 = computeFetchWindow(new Date(2026, 6, 1), 90, new Date(2026, 6, 15, 10, 30));
|
||||
expect(w3.to.getTime()).toBe(new Date(2026, 9, 13).getTime());
|
||||
|
||||
for (const w of [w1, w2, w3]) {
|
||||
for (const d of [w.from, w.to]) {
|
||||
expect(d.getHours()).toBe(0);
|
||||
expect(d.getMinutes()).toBe(0);
|
||||
expect(d.getSeconds()).toBe(0);
|
||||
expect(d.getMilliseconds()).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 5: selectUpcomingEvents filtert Vergangenheit/Horizont, sortiert nach Start und kuerzt auf maxEvents', () => {
|
||||
const now = new Date(2026, 6, 15, 10, 0);
|
||||
const events = [
|
||||
ev('gestern', new Date(2026, 6, 14, 11, 0), new Date(2026, 6, 14, 12, 0)),
|
||||
ev('läuft', new Date(2026, 6, 15, 9, 0), new Date(2026, 6, 15, 11, 0)),
|
||||
ev('heute 15', new Date(2026, 6, 15, 15, 0), new Date(2026, 6, 15, 16, 0)),
|
||||
ev('in 5 Tagen', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
ev('in 10 Tagen', new Date(2026, 6, 25, 9, 0), new Date(2026, 6, 25, 10, 0)),
|
||||
];
|
||||
|
||||
const result = selectUpcomingEvents(events, 7, 2, now);
|
||||
expect(result.map((e) => e.title)).toEqual(['läuft', 'heute 15']);
|
||||
|
||||
expect(selectUpcomingEvents(events, 7, 0, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('Test 6: formatEventDate/formatEventTime/formatMonthLabel formatieren de-DE', () => {
|
||||
const timed = ev('t1', new Date(2026, 6, 20, 9, 5), new Date(2026, 6, 20, 10, 0));
|
||||
const timedText = formatEventDate(timed);
|
||||
expect(timedText).toMatch(/20\.07\./);
|
||||
expect(timedText).toMatch(/09:05/);
|
||||
|
||||
const allDay = ev('t2', new Date(2026, 6, 20, 0, 0), new Date(2026, 6, 20, 23, 59), { allDay: true });
|
||||
const allDayText = formatEventDate(allDay);
|
||||
expect(allDayText).toMatch(/20\.07\./);
|
||||
expect(allDayText).not.toMatch(/\d{2}:\d{2}/);
|
||||
|
||||
expect(formatMonthLabel(new Date(2026, 6, 1))).toBe('Juli 2026');
|
||||
expect(formatEventTime(new Date(2026, 6, 20, 9, 5).toISOString())).toBe('09:05');
|
||||
});
|
||||
|
||||
it('Test 7: gridStartFor findet den Montag am oder vor dem 1. des Monats', () => {
|
||||
expect(gridStartFor(new Date(2026, 7, 1)).getTime()).toBe(new Date(2026, 6, 27).getTime());
|
||||
expect(gridStartFor(new Date(2026, 5, 1)).getTime()).toBe(new Date(2026, 5, 1).getTime());
|
||||
});
|
||||
|
||||
it('Zusatz: dateKey/startOfLocalDay/addDays arbeiten lokal ohne UTC-Versatz', () => {
|
||||
expect(dateKey(new Date(2026, 6, 5))).toBe('2026-07-05');
|
||||
const start = startOfLocalDay(new Date(2026, 6, 5, 14, 30, 15));
|
||||
expect(start.getHours()).toBe(0);
|
||||
expect(addDays(new Date(2026, 6, 30), 2).getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('Test 8: groupDayBySource gruppiert nach sourceId in Reihenfolge des ersten Auftretens', () => {
|
||||
const day = new Date(2026, 6, 20, 9, 0);
|
||||
const hourLater = new Date(2026, 6, 20, 10, 0);
|
||||
|
||||
// (a) drei Termine, alle s1 mit derselben Farbe
|
||||
const sameSource = [
|
||||
ev('e1', day, hourLater, { sourceId: 's1', color: '#c44040' }),
|
||||
ev('e2', day, hourLater, { sourceId: 's1', color: '#c44040' }),
|
||||
ev('e3', day, hourLater, { sourceId: 's1', color: '#c44040' }),
|
||||
];
|
||||
expect(groupDayBySource(sameSource)).toEqual([{ sourceId: 's1', color: '#c44040', count: 3 }]);
|
||||
|
||||
// (b) Reihenfolge s2, s1, s2 wird NICHT umsortiert
|
||||
const mixedOrder = [
|
||||
ev('e1', day, hourLater, { sourceId: 's2' }),
|
||||
ev('e2', day, hourLater, { sourceId: 's1' }),
|
||||
ev('e3', day, hourLater, { sourceId: 's2' }),
|
||||
];
|
||||
const groupedMixed = groupDayBySource(mixedOrder);
|
||||
expect(groupedMixed.map((g) => g.sourceId)).toEqual(['s2', 's1']);
|
||||
expect(groupedMixed[0].count).toBe(2);
|
||||
expect(groupedMixed[1].count).toBe(1);
|
||||
|
||||
// (c) Termine ohne color -> color der Gruppe ist undefined
|
||||
const noColor = [ev('e1', day, hourLater, { sourceId: 's1', color: undefined })];
|
||||
expect(groupDayBySource(noColor)[0].color).toBeUndefined();
|
||||
|
||||
// (d) zwei Kalender mit derselben Farbe bleiben zwei Gruppen (Gruppierung nach sourceId)
|
||||
const sameColorDifferentSource = [
|
||||
ev('e1', day, hourLater, { sourceId: 's1', color: '#123456' }),
|
||||
ev('e2', day, hourLater, { sourceId: 's2', color: '#123456' }),
|
||||
];
|
||||
expect(groupDayBySource(sameColorDifferentSource)).toHaveLength(2);
|
||||
|
||||
// (e) Farbe der Gruppe ist die Farbe des ERSTEN Termins der Gruppe
|
||||
const laterColorChange = [
|
||||
ev('e1', day, hourLater, { sourceId: 's1', color: '#111111' }),
|
||||
ev('e2', day, hourLater, { sourceId: 's1', color: '#222222' }),
|
||||
];
|
||||
expect(groupDayBySource(laterColorChange)).toEqual([
|
||||
{ sourceId: 's1', color: '#111111', count: 2 },
|
||||
]);
|
||||
|
||||
// (f) leeres Array
|
||||
expect(groupDayBySource([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('Test 9: buildDayBadges liefert je Kalender einen Eintrag, ab dem vierten einen grauen Restkreis', () => {
|
||||
const day = new Date(2026, 6, 20);
|
||||
const single = [ev('e1', day, day, { sourceId: 's1', color: '#111111' })];
|
||||
expect(buildDayBadges(single)).toEqual([
|
||||
{ key: 's1', color: '#111111', count: 1, rest: false },
|
||||
]);
|
||||
|
||||
const two = [
|
||||
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
|
||||
ev('e2', day, day, { sourceId: 's2', color: '#222222' }),
|
||||
];
|
||||
expect(buildDayBadges(two)).toEqual([
|
||||
{ key: 's1', color: '#111111', count: 1, rest: false },
|
||||
{ key: 's2', color: '#222222', count: 1, rest: false },
|
||||
]);
|
||||
|
||||
const three = [
|
||||
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
|
||||
ev('e2', day, day, { sourceId: 's2', color: '#222222' }),
|
||||
ev('e3', day, day, { sourceId: 's3', color: '#333333' }),
|
||||
];
|
||||
const threeResult = buildDayBadges(three);
|
||||
expect(threeResult).toHaveLength(3);
|
||||
expect(threeResult.every((b) => b.rest === false)).toBe(true);
|
||||
|
||||
const four = [
|
||||
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
|
||||
ev('e2a', day, day, { sourceId: 's2', color: '#222222' }),
|
||||
ev('e2b', day, day, { sourceId: 's2', color: '#222222' }),
|
||||
ev('e3', day, day, { sourceId: 's3', color: '#333333' }),
|
||||
ev('e4a', day, day, { sourceId: 's4', color: '#444444' }),
|
||||
ev('e4b', day, day, { sourceId: 's4', color: '#444444' }),
|
||||
ev('e4c', day, day, { sourceId: 's4', color: '#444444' }),
|
||||
];
|
||||
expect(buildDayBadges(four)).toEqual([
|
||||
{ key: 's1', color: '#111111', count: 1, rest: false },
|
||||
{ key: 's2', color: '#222222', count: 2, rest: false },
|
||||
{ key: '__rest__', color: undefined, count: 4, rest: true },
|
||||
]);
|
||||
|
||||
expect(buildDayBadges(four, 2)).toEqual([
|
||||
{ key: 's1', color: '#111111', count: 1, rest: false },
|
||||
{ key: '__rest__', color: undefined, count: 6, rest: true },
|
||||
]);
|
||||
|
||||
expect(buildDayBadges([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* calendar-month — reine Hilfsfunktionen fuer das Kalender-Widget
|
||||
* (quick-260916-htc).
|
||||
*
|
||||
* Kein React, keine DOM-Zugriffe: dieses Modul wird sowohl vom Widget
|
||||
* (calendar-widget.tsx) als auch vom Einstellungsfeld
|
||||
* (widget-settings-panel.tsx, `CalendarConfig`) importiert, damit beide
|
||||
* Stellen dieselben Vorgaben/Grenzen anwenden (Muster clock-font-size.ts,
|
||||
* T-BWO-01 uebertragen auf T-HTC-01).
|
||||
*
|
||||
* Vorlage fuer Monatsraster/Terminliste: das alte persoenliche Dashboard
|
||||
* des Anwenders (user-files/personal-dashboard/src/app/page.tsx).
|
||||
*
|
||||
* Starttag-Regel: mehrtaegige und ganztaegige Termine werden im Monatsraster
|
||||
* bewusst NUR am Starttag gezaehlt/angezeigt (`groupEventsByDate` gruppiert
|
||||
* ausschliesslich nach `event.start`) — eine Terminleiste ueber mehrere Tage
|
||||
* ist nicht Teil dieses Auftrags. Jede Tagesgruppe ist zusaetzlich nach Start
|
||||
* aufsteigend sortiert, damit die Plakettenfarbe (Farbe des ERSTEN Termins)
|
||||
* und die Tooltip-Reihenfolge deterministisch uebereinstimmen (quick-260916-jvj).
|
||||
*
|
||||
* Liegen an einem Tag Termine aus mehreren Kalendern, gruppiert
|
||||
* `groupDayBySource` sie nach `sourceId` (nicht nach Farbe) in der Reihenfolge
|
||||
* ihres ersten Auftretens in der bereits start-sortierten Tagesgruppe;
|
||||
* `buildDayBadges` macht daraus bis zu drei Kreise, ab dem vierten Kalender
|
||||
* einen grauen Restkreis mit der Summe der uebrigen (quick-260916-k2z).
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||
|
||||
export const CALENDAR_LOOKAHEAD_OPTIONS: readonly number[] = [7, 14, 30, 60, 90];
|
||||
export const CALENDAR_MAX_EVENTS_LIMIT = 10;
|
||||
export const CALENDAR_DEFAULTS = { showMonth: true, maxEvents: 3, lookaheadDays: 30 } as const;
|
||||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||
|
||||
export interface CalendarWidgetConfig {
|
||||
showMonth: boolean;
|
||||
maxEvents: number;
|
||||
lookaheadDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest die Kalender-Widget-Konfiguration aus dem rohen `config`-JSON auf und
|
||||
* klemmt ungueltige Werte auf die Vorgaben zurueck (T-HTC-01). API prueft
|
||||
* Config-Felder nicht (`@IsObject()`), deshalb entscheidet ALLEIN diese
|
||||
* Funktion, was ein gueltiger Wert ist.
|
||||
*/
|
||||
export function resolveCalendarConfig(config: Record<string, unknown>): CalendarWidgetConfig {
|
||||
const showMonth = config.showMonth !== false;
|
||||
|
||||
let maxEvents: number = CALENDAR_DEFAULTS.maxEvents;
|
||||
if (typeof config.maxEvents === 'number' && Number.isFinite(config.maxEvents)) {
|
||||
maxEvents = Math.min(CALENDAR_MAX_EVENTS_LIMIT, Math.max(0, Math.trunc(config.maxEvents)));
|
||||
}
|
||||
|
||||
let lookaheadDays: number = CALENDAR_DEFAULTS.lookaheadDays;
|
||||
if (
|
||||
typeof config.lookaheadDays === 'number' &&
|
||||
CALENDAR_LOOKAHEAD_OPTIONS.includes(config.lookaheadDays)
|
||||
) {
|
||||
lookaheadDays = config.lookaheadDays;
|
||||
}
|
||||
|
||||
return { showMonth, maxEvents, lookaheadDays };
|
||||
}
|
||||
|
||||
/** Lokaler Tagesschluessel `YYYY-MM-DD` (kein UTC-Versatz). */
|
||||
export function dateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Kopie von `date` auf die lokale Tagesgrenze 00:00:00.000 gesetzt. */
|
||||
export function startOfLocalDay(date: Date): Date {
|
||||
const copy = new Date(date);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopie von `date`, um `days` Tage verschoben. Rechnet ueber `setDate`, NICHT
|
||||
* ueber Millisekunden — das haelt die lokale Wanduhrzeit ueber
|
||||
* Sommerzeitwechsel hinweg stabil.
|
||||
*/
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Montag am oder vor dem 1. des Monats von `monthDate`, 00:00 lokal. */
|
||||
export function gridStartFor(monthDate: Date): Date {
|
||||
const firstDayOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
|
||||
const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7;
|
||||
const gridStart = new Date(firstDayOfMonth);
|
||||
gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset);
|
||||
gridStart.setHours(0, 0, 0, 0);
|
||||
return gridStart;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
key: string;
|
||||
date: Date;
|
||||
inCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Termine nach lokalem Startdatum. Mehrtaegige/ganztaegige Termine
|
||||
* zaehlen bewusst nur am Starttag (siehe Kopfkommentar). Jede Tagesgruppe
|
||||
* wird nach Start aufsteigend sortiert (quick-260916-jvj), damit der erste
|
||||
* Eintrag zuverlaessig der fruehesten Termin des Tages ist.
|
||||
*/
|
||||
export function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const groupedEvents = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = dateKey(new Date(event.start));
|
||||
const existingEvents = groupedEvents.get(key) ?? [];
|
||||
groupedEvents.set(key, [...existingEvents, event]);
|
||||
}
|
||||
for (const dayEvents of groupedEvents.values()) {
|
||||
dayEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
return groupedEvents;
|
||||
}
|
||||
|
||||
export interface DaySourceGroup {
|
||||
sourceId: string;
|
||||
color?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert die Termine EINES Tages nach `sourceId` (nicht nach Farbe).
|
||||
* Reihenfolge = erstes Auftreten in `events` — die Funktion sortiert selbst
|
||||
* NICHT, sie uebernimmt die Reihenfolge von `groupEventsByDate` (dort bereits
|
||||
* nach Start sortiert), damit Kreis- und Tooltip-Reihenfolge uebereinstimmen.
|
||||
* Die Farbe einer Gruppe ist die `color` ihres ERSTEN Termins.
|
||||
*/
|
||||
export function groupDayBySource(events: CalendarEvent[]): DaySourceGroup[] {
|
||||
const groups = new Map<string, DaySourceGroup>();
|
||||
for (const event of events) {
|
||||
const existing = groups.get(event.sourceId);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
groups.set(event.sourceId, { sourceId: event.sourceId, color: event.color, count: 1 });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
export const CALENDAR_DAY_BADGE_MAX = 3;
|
||||
|
||||
export interface DayBadge {
|
||||
key: string;
|
||||
color?: string;
|
||||
count: number;
|
||||
rest: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Plaketten-Kreise fuer einen Tag: bis `max` Kalender bekommen je
|
||||
* einen Kreis, ab `max` Kalendern fassen die ersten `max - 1` Kreise je einen
|
||||
* Kalender, der letzte Eintrag (`rest: true`) summiert die uebrigen Kalender
|
||||
* grau (kein Farbwert).
|
||||
*/
|
||||
export function buildDayBadges(
|
||||
events: CalendarEvent[],
|
||||
max: number = CALENDAR_DAY_BADGE_MAX,
|
||||
): DayBadge[] {
|
||||
const groups = groupDayBySource(events);
|
||||
if (groups.length <= max) {
|
||||
return groups.map((group) => ({
|
||||
key: group.sourceId,
|
||||
color: group.color,
|
||||
count: group.count,
|
||||
rest: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const visible = groups.slice(0, max - 1).map((group) => ({
|
||||
key: group.sourceId,
|
||||
color: group.color,
|
||||
count: group.count,
|
||||
rest: false,
|
||||
}));
|
||||
const restCount = groups.slice(max - 1).reduce((sum, group) => sum + group.count, 0);
|
||||
return [...visible, { key: '__rest__', color: undefined, count: restCount, rest: true }];
|
||||
}
|
||||
|
||||
/** 42 Rasterzellen ab `gridStartFor(monthDate)`, Montag-basiert. */
|
||||
export function buildCalendarDays(
|
||||
monthDate: Date,
|
||||
eventsByDate: Map<string, CalendarEvent[]>,
|
||||
today: Date = new Date(),
|
||||
): CalendarDay[] {
|
||||
const month = monthDate.getMonth();
|
||||
const gridStart = gridStartFor(monthDate);
|
||||
const todayKey = dateKey(today);
|
||||
|
||||
const days: CalendarDay[] = [];
|
||||
for (let index = 0; index < 42; index += 1) {
|
||||
const date = addDays(gridStart, index);
|
||||
const key = dateKey(date);
|
||||
days.push({
|
||||
key,
|
||||
date,
|
||||
inCurrentMonth: date.getMonth() === month,
|
||||
isToday: key === todayKey,
|
||||
events: eventsByDate.get(key) ?? [],
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladefenster fuer `fetchEvents(from, to)`: deckt sowohl das 42-Tage-Raster
|
||||
* als auch den Vorschau-Zeitraum (lookaheadDays ab heute) ab. Alle vier
|
||||
* Zwischenwerte sind Tagesgrenzen 00:00 lokal, deshalb ist `toISOString()`
|
||||
* innerhalb eines Tages konstant — das haelt den Backend-Cache-Schluessel
|
||||
* (`${userId}:${from.toISOString()}:${to.toISOString()}`) ueber den
|
||||
* 5-Minuten-Refresh hinweg stabil.
|
||||
*/
|
||||
export function computeFetchWindow(
|
||||
monthDate: Date,
|
||||
lookaheadDays: number,
|
||||
now: Date = new Date(),
|
||||
): { from: Date; to: Date } {
|
||||
const gridStart = gridStartFor(monthDate);
|
||||
const gridEnd = addDays(gridStart, 42);
|
||||
const todayStart = startOfLocalDay(now);
|
||||
const lookEnd = addDays(todayStart, lookaheadDays);
|
||||
|
||||
const from = gridStart.getTime() <= todayStart.getTime() ? gridStart : todayStart;
|
||||
const to = gridEnd.getTime() >= lookEnd.getTime() ? gridEnd : lookEnd;
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Waehlt die naechsten anstehenden Termine (laufend oder in der Zukunft,
|
||||
* innerhalb `lookaheadDays`), sortiert nach Start aufsteigend, gekuerzt auf
|
||||
* `maxEvents`.
|
||||
*/
|
||||
export function selectUpcomingEvents(
|
||||
events: CalendarEvent[],
|
||||
lookaheadDays: number,
|
||||
maxEvents: number,
|
||||
now: Date = new Date(),
|
||||
): CalendarEvent[] {
|
||||
const horizon = addDays(startOfLocalDay(now), lookaheadDays).getTime();
|
||||
const nowTime = now.getTime();
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
const endTime = new Date(event.end).getTime();
|
||||
const startTime = new Date(event.start).getTime();
|
||||
return endTime >= nowTime && startTime < horizon;
|
||||
})
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.slice(0, maxEvents);
|
||||
}
|
||||
|
||||
/** Datum+Uhrzeit (bzw. nur Datum bei ganztaegig) im Format „Mi., 20.07., 09:05“. */
|
||||
export function formatEventDate(event: CalendarEvent): string {
|
||||
const start = new Date(event.start);
|
||||
if (event.allDay) {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
}).format(start);
|
||||
}
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(start);
|
||||
}
|
||||
|
||||
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
|
||||
export function formatEventTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
/** Monat+Jahr, „Juli 2026“. */
|
||||
export function formatMonthLabel(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -1,19 +1,35 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||
|
||||
// Mock next-intl
|
||||
/**
|
||||
* calendar-widget.test — neu geschrieben (quick-260916-htc). Ersetzt die
|
||||
* alten drei Tests der flachen Terminliste durch Tests fuer das
|
||||
* Monatsraster, den Portal-Tooltip, "Naechste Termine" und das
|
||||
* Ladefenster/Blaettern.
|
||||
*/
|
||||
|
||||
// Mock next-intl — nimmt (key, values?) und ersetzt {name}-Platzhalter.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
useTranslations: (ns: string) => (key: string, values?: Record<string, unknown>) => {
|
||||
const map: Record<string, Record<string, string>> = {
|
||||
widgets: {
|
||||
'calendar.name': 'Calendar',
|
||||
'calendar.description': 'Upcoming events',
|
||||
'calendar.emptyNoSources': 'No calendar sources configured',
|
||||
'calendar.emptyNoEvents': 'No upcoming events',
|
||||
'calendar.loading': 'Loading...',
|
||||
'calendar.name': 'Kalender',
|
||||
'calendar.description': 'Kommende Termine',
|
||||
'calendar.loading': 'Laden...',
|
||||
'calendar.emptyNoSources': 'Keine Kalenderquellen konfiguriert',
|
||||
'calendar.emptyNoEvents': 'Keine anstehenden Termine',
|
||||
'calendar.nothingSelected': 'Nichts zum Anzeigen ausgewählt',
|
||||
'calendar.monthPrev': 'Zurück',
|
||||
'calendar.monthNext': 'Weiter',
|
||||
'calendar.monthToday': 'Zurück zum heutigen Monat',
|
||||
'calendar.upcomingTitle': 'Nächste Termine',
|
||||
'calendar.tooltipMore': 'Weitere Termine vorhanden',
|
||||
'calendar.allDay': 'ganztägig',
|
||||
},
|
||||
};
|
||||
return map[ns]?.[key] ?? key;
|
||||
const raw = map[ns]?.[key] ?? key;
|
||||
return values ? raw.replace(/\{(\w+)\}/g, (_, n) => String(values[n] ?? '')) : raw;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,96 +41,445 @@ vi.mock('@/lib/calendar-api', () => ({
|
||||
fetchSources: (...args: unknown[]) => mockFetchSources(...args),
|
||||
}));
|
||||
|
||||
function ev(id: string, start: Date, end: Date, extra?: Partial<CalendarEvent>): CalendarEvent {
|
||||
return {
|
||||
id,
|
||||
sourceId: 's1',
|
||||
title: id,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
allDay: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchEvents.mockReset();
|
||||
mockFetchSources.mockReset();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0));
|
||||
mockFetchSources.mockResolvedValue([
|
||||
{ id: 's1', name: 'Work', type: 'caldav', isVisible: true, color: '#ff0000' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CalendarWidget', () => {
|
||||
beforeEach(() => {
|
||||
// Default: sources exist so we get past the source check
|
||||
mockFetchSources.mockResolvedValue([
|
||||
{ id: 's1', name: 'Work', type: 'caldav', isVisible: true, color: '#ff0000' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders event titles, times, and source color dots for two events', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
{
|
||||
id: 'e1',
|
||||
sourceId: 's1',
|
||||
title: 'Team Meeting',
|
||||
start: '2026-06-24T10:00:00Z',
|
||||
end: '2026-06-24T11:00:00Z',
|
||||
allDay: false,
|
||||
color: '#ff0000',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
sourceId: 's1',
|
||||
title: 'Lunch Break',
|
||||
start: '2026-06-24T12:00:00Z',
|
||||
end: '2026-06-24T13:00:00Z',
|
||||
allDay: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(
|
||||
<CalendarWidget
|
||||
instanceId="cal-1"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for events to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Team Meeting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Lunch Break')).toBeInTheDocument();
|
||||
|
||||
// Source color dots should be rendered (8px circles)
|
||||
const colorDots = screen.getAllByTestId('event-color-dot');
|
||||
expect(colorDots).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders no-events message when sources exist but no events returned', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(
|
||||
<CalendarWidget
|
||||
instanceId="cal-2"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No upcoming events')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders no-sources message when no sources configured', async () => {
|
||||
it('Test 1: zeigt Laden, dann bei leeren Quellen die Keine-Quellen-Meldung ohne fetchEvents', async () => {
|
||||
mockFetchSources.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-1" config={{}} isEditMode={false} />);
|
||||
|
||||
expect(screen.getByText('Laden...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keine Kalenderquellen konfiguriert')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockFetchEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 2: rendert das Monatsraster mit 42 Zellen, heutigem Tag und Fremdmonatstagen', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(
|
||||
<CalendarWidget
|
||||
instanceId="cal-3"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
render(<CalendarWidget instanceId="cal-2" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
for (const label of ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']) {
|
||||
expect(screen.getAllByText(label).length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const dayCells = screen.getAllByTestId('calendar-day');
|
||||
expect(dayCells).toHaveLength(42);
|
||||
|
||||
const todayCell = document.querySelector('[data-date="2026-07-15"]');
|
||||
expect(todayCell?.getAttribute('data-today')).toBe('true');
|
||||
expect(todayCell?.textContent).toContain('15');
|
||||
expect(within(todayCell as HTMLElement).queryByTestId('calendar-day-count')).toBeNull();
|
||||
|
||||
const outsideCell = document.querySelector('[data-date="2026-06-29"]');
|
||||
expect(outsideCell?.getAttribute('data-outside')).toBe('true');
|
||||
|
||||
const monthBlock = screen.getByTestId('calendar-month');
|
||||
expect(monthBlock.className).toContain('');
|
||||
for (const btn of screen.getAllByRole('button')) {
|
||||
expect(btn.className).toContain('widgetNoDrag');
|
||||
}
|
||||
for (const cell of dayCells) {
|
||||
expect(cell.className).toContain('widgetNoDrag');
|
||||
}
|
||||
|
||||
expect(screen.getByText('Nächste Termine')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keine anstehenden Termine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: Zaehl-Plakette zeigt die korrekte Terminanzahl je Tag', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
|
||||
ev('Review', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-3" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
expect(within(day20).getByTestId('calendar-day-count')).toHaveTextContent('2');
|
||||
|
||||
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
|
||||
expect(within(day21).getByTestId('calendar-day-count')).toHaveTextContent('1');
|
||||
|
||||
const day22 = document.querySelector('[data-date="2026-07-22"]') as HTMLElement;
|
||||
expect(within(day22).queryByTestId('calendar-day-count')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 3b: Plakette traegt die Kalenderfarbe des fruehesten Termins, Tooltip-Zeilen mit Farbpunkt', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0), {
|
||||
color: '#4060c4',
|
||||
}),
|
||||
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0), {
|
||||
color: '#c44040',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-3b" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
const badge = within(day20).getByTestId('calendar-day-count');
|
||||
expect(badge).toHaveTextContent('2');
|
||||
expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
|
||||
expect(badge).toHaveClass('text-white');
|
||||
expect(badge).not.toHaveClass('bg-primary');
|
||||
|
||||
fireEvent.mouseEnter(day20);
|
||||
const tooltip = screen.getByTestId('calendar-day-tooltip');
|
||||
const dots = within(tooltip).getAllByTestId('tooltip-color-dot');
|
||||
expect(dots).toHaveLength(2);
|
||||
expect(dots[0]).toHaveStyle({ backgroundColor: '#c44040' });
|
||||
expect(dots[1]).toHaveStyle({ backgroundColor: '#4060c4' });
|
||||
expect(tooltip.textContent?.indexOf('Team Meeting')).toBeLessThan(
|
||||
tooltip.textContent?.indexOf('Lunch') ?? -1,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
|
||||
color: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-3c" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
|
||||
const badge = within(day21).getByTestId('calendar-day-count');
|
||||
expect(badge).toHaveClass('bg-primary');
|
||||
expect(badge).toHaveClass('text-primary-foreground');
|
||||
expect(badge).not.toHaveClass('text-white');
|
||||
expect((badge as HTMLElement).style.backgroundColor).toBe('');
|
||||
|
||||
fireEvent.mouseEnter(day21);
|
||||
const tooltip = screen.getByTestId('calendar-day-tooltip');
|
||||
expect(within(tooltip).getAllByTestId('tooltip-color-dot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Test 3d: zwei Kalender am selben Tag zeigen zwei kleine Kreise in Startreihenfolge', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Lunch', new Date(2026, 6, 20, 10, 0), new Date(2026, 6, 20, 11, 0), {
|
||||
sourceId: 's2',
|
||||
color: '#4060c4',
|
||||
}),
|
||||
ev('Kaffee', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0), {
|
||||
sourceId: 's2',
|
||||
color: '#4060c4',
|
||||
}),
|
||||
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0), {
|
||||
sourceId: 's1',
|
||||
color: '#c44040',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-3d" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
const badges = within(day20).getAllByTestId('calendar-day-count');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('1');
|
||||
expect(badges[0]).toHaveStyle({ backgroundColor: '#c44040' });
|
||||
expect(badges[1]).toHaveTextContent('2');
|
||||
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
|
||||
for (const badge of badges) {
|
||||
expect(badge).toHaveClass('text-white');
|
||||
expect(badge).not.toHaveClass('bg-primary');
|
||||
expect(badge).toHaveClass('h-[clamp(8px,2.4cqw,12px)]');
|
||||
}
|
||||
expect(within(day20).queryByTestId('calendar-day-count-rest')).toBeNull();
|
||||
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2);
|
||||
});
|
||||
|
||||
it('Test 3e: vier Kalender am selben Tag zeigen zwei Kreise plus grauen Restkreis mit Summe', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('e1', new Date(2026, 6, 21, 8, 0), new Date(2026, 6, 21, 9, 0), {
|
||||
sourceId: 's1',
|
||||
color: '#111111',
|
||||
}),
|
||||
ev('e2a', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 9, 30), {
|
||||
sourceId: 's2',
|
||||
color: '#222222',
|
||||
}),
|
||||
ev('e2b', new Date(2026, 6, 21, 9, 30), new Date(2026, 6, 21, 10, 0), {
|
||||
sourceId: 's2',
|
||||
color: '#222222',
|
||||
}),
|
||||
ev('e3', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0), {
|
||||
sourceId: 's3',
|
||||
color: undefined,
|
||||
}),
|
||||
ev('e4a', new Date(2026, 6, 21, 11, 0), new Date(2026, 6, 21, 12, 0), {
|
||||
sourceId: 's4',
|
||||
color: '#444444',
|
||||
}),
|
||||
ev('e4b', new Date(2026, 6, 21, 12, 0), new Date(2026, 6, 21, 13, 0), {
|
||||
sourceId: 's4',
|
||||
color: '#444444',
|
||||
}),
|
||||
ev('e4c', new Date(2026, 6, 21, 13, 0), new Date(2026, 6, 21, 14, 0), {
|
||||
sourceId: 's4',
|
||||
color: '#444444',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-3e" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
|
||||
expect(within(day21).getByTestId('calendar-day-badges').childElementCount).toBe(3);
|
||||
|
||||
const badges = within(day21).getAllByTestId('calendar-day-count');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('1');
|
||||
expect(badges[0]).toHaveStyle({ backgroundColor: '#111111' });
|
||||
expect(badges[1]).toHaveTextContent('2');
|
||||
expect(badges[1]).toHaveStyle({ backgroundColor: '#222222' });
|
||||
|
||||
const restBadge = within(day21).getByTestId('calendar-day-count-rest');
|
||||
expect(restBadge).toHaveTextContent('4');
|
||||
expect(restBadge).toHaveClass('bg-muted-foreground');
|
||||
expect(restBadge).toHaveClass('text-background');
|
||||
expect((restBadge as HTMLElement).style.backgroundColor).toBe('');
|
||||
expect(restBadge).not.toHaveClass('text-white');
|
||||
});
|
||||
|
||||
it('Test 4: Tooltip erscheint per Portal im body und zeigt bis zu 5 Termine plus Hinweis', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-4" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
fireEvent.mouseEnter(day20);
|
||||
|
||||
const tooltip = screen.getByTestId('calendar-day-tooltip');
|
||||
expect(document.body.contains(tooltip)).toBe(true);
|
||||
expect(tooltip).toHaveTextContent('09:00');
|
||||
expect(tooltip).toHaveTextContent('Team Meeting');
|
||||
expect(tooltip).toHaveTextContent('Lunch');
|
||||
|
||||
fireEvent.mouseLeave(day20);
|
||||
expect(screen.queryByTestId('calendar-day-tooltip')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 4b: Tooltip zeigt maximal 5 Eintraege plus Hinweis bei mehr Terminen', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('e1', new Date(2026, 6, 20, 8, 0), new Date(2026, 6, 20, 9, 0)),
|
||||
ev('e2', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||
ev('e3', new Date(2026, 6, 20, 10, 0), new Date(2026, 6, 20, 11, 0)),
|
||||
ev('e4', new Date(2026, 6, 20, 11, 0), new Date(2026, 6, 20, 12, 0)),
|
||||
ev('e5', new Date(2026, 6, 20, 12, 0), new Date(2026, 6, 20, 13, 0)),
|
||||
ev('e6', new Date(2026, 6, 20, 13, 0), new Date(2026, 6, 20, 14, 0)),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-4b" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
fireEvent.mouseEnter(day20);
|
||||
|
||||
const tooltip = screen.getByTestId('calendar-day-tooltip');
|
||||
expect(tooltip).toHaveTextContent('Weitere Termine vorhanden');
|
||||
for (const id of ['e1', 'e2', 'e3', 'e4', 'e5']) {
|
||||
expect(tooltip).toHaveTextContent(id);
|
||||
}
|
||||
expect(tooltip).not.toHaveTextContent('e6');
|
||||
});
|
||||
|
||||
it('Test 4c: Tooltip bricht lange Termintitel um statt sie abzuschneiden', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev(
|
||||
'Deutscher Weltkindertag (Aktionstag der Kinderrechte)',
|
||||
new Date(2026, 6, 20, 9, 0),
|
||||
new Date(2026, 6, 20, 10, 0),
|
||||
),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-4c" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Juli 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
|
||||
fireEvent.mouseEnter(day20);
|
||||
|
||||
const tooltip = screen.getByTestId('calendar-day-tooltip');
|
||||
const title = within(tooltip).getByText(
|
||||
'Deutscher Weltkindertag (Aktionstag der Kinderrechte)',
|
||||
);
|
||||
expect(title).toHaveClass('break-words');
|
||||
expect(title).toHaveClass('min-w-0');
|
||||
expect(title).not.toHaveClass('truncate');
|
||||
expect(tooltip).toHaveStyle({ width: '288px' });
|
||||
});
|
||||
|
||||
it('Test 5: Naechste Termine respektiert maxEvents, Reihenfolge und zeigt Ort/Farbpunkt', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('E1', new Date(2026, 6, 16, 9, 0), new Date(2026, 6, 16, 10, 0)),
|
||||
ev('E2', new Date(2026, 6, 18, 9, 0), new Date(2026, 6, 18, 10, 0), { location: 'Raum 2' }),
|
||||
ev('E3', new Date(2026, 6, 22, 9, 0), new Date(2026, 6, 22, 10, 0)),
|
||||
ev('E4', new Date(2026, 6, 26, 9, 0), new Date(2026, 6, 26, 10, 0)),
|
||||
ev('E5', new Date(2026, 6, 30, 9, 0), new Date(2026, 6, 30, 10, 0)),
|
||||
]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-5" config={{ maxEvents: 2 }} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('calendar-upcoming')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const list = within(screen.getByTestId('calendar-upcoming'));
|
||||
const items = list.getAllByRole('listitem');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent('E1');
|
||||
expect(items[1]).toHaveTextContent('E2');
|
||||
expect(items[1]).toHaveTextContent('Raum 2');
|
||||
|
||||
for (const item of items) {
|
||||
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
|
||||
}
|
||||
expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./);
|
||||
});
|
||||
|
||||
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
const { unmount } = render(
|
||||
<CalendarWidget instanceId="cal-6a" config={{ showMonth: false, maxEvents: 3 }} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('No calendar sources configured'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Keine anstehenden Termine')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('calendar-month')).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<CalendarWidget instanceId="cal-6b" config={{ showMonth: false, maxEvents: 0 }} isEditMode={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nichts zum Anzeigen ausgewählt')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('calendar-month')).toBeNull();
|
||||
expect(screen.queryByTestId('calendar-upcoming')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 7: fetchEvents wird genau einmal mit dem berechneten Ladefenster aufgerufen', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
const { unmount } = render(<CalendarWidget instanceId="cal-7a" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||
expect(mockFetchEvents).toHaveBeenCalledWith(
|
||||
new Date(2026, 5, 29).toISOString(),
|
||||
new Date(2026, 7, 14).toISOString(),
|
||||
);
|
||||
unmount();
|
||||
|
||||
mockFetchEvents.mockClear();
|
||||
render(<CalendarWidget instanceId="cal-7b" config={{ lookaheadDays: 90 }} isEditMode={false} />);
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||
expect(mockFetchEvents.mock.calls[0][1]).toBe(new Date(2026, 9, 13).toISOString());
|
||||
});
|
||||
|
||||
it('Test 8: Blaettern wechselt den Monat und laedt Termine mit dem neuen Fenster neu', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-8" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
|
||||
mockFetchEvents.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||
await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
|
||||
// computeFetchWindow nimmt das FRUEHERE von Rasterstart (27.07.) und
|
||||
// heutigem Tag (15.07., "now" bleibt im Test eingefroren) als "from" —
|
||||
// dieselbe Regel wie in calendar-month.test.ts Test 4 (w2-Fall), deshalb
|
||||
// 15.07. statt 27.07. (Abweichung vom im Plan genannten Wert, siehe SUMMARY).
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledWith(
|
||||
new Date(2026, 6, 15).toISOString(),
|
||||
new Date(2026, 8, 7).toISOString(),
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'August 2026' }));
|
||||
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zurück' }));
|
||||
await waitFor(() => expect(screen.getByText('Juni 2026')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
import { fetchEvents, fetchSources } from '@/lib/calendar-api';
|
||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||
import {
|
||||
buildCalendarDays,
|
||||
buildDayBadges,
|
||||
computeFetchWindow,
|
||||
formatEventDate,
|
||||
formatEventTime,
|
||||
formatMonthLabel,
|
||||
groupEventsByDate,
|
||||
resolveCalendarConfig,
|
||||
selectUpcomingEvents,
|
||||
WEEKDAY_LABELS,
|
||||
} from './calendar-month';
|
||||
|
||||
// quick-260916-j4f: Breite und Rand-Klemmung des Termin-Tooltips aus einer
|
||||
// Quelle, damit Breite und Klemmwert nicht auseinanderlaufen; 288 px
|
||||
// entspricht Tailwind w-72.
|
||||
const TOOLTIP_WIDTH_PX = 288;
|
||||
const TOOLTIP_EDGE_PX = 4;
|
||||
|
||||
/**
|
||||
* Calendar widget (DASH-05, D-10 read-only).
|
||||
* Calendar widget (DASH-05, D-10 read-only), neu gebaut nach dem Vorbild des
|
||||
* alten persoenlichen Dashboards des Anwenders (quick-260916-htc).
|
||||
*
|
||||
* Shows upcoming events aggregated from visible calendar sources.
|
||||
* Three states per UI-SPEC:
|
||||
* 1. No sources configured -> t('widgets.calendar.emptyNoSources')
|
||||
* 2. Sources but no events -> t('widgets.calendar.emptyNoEvents')
|
||||
* 3. Events -> upcoming-events list with time, title, source color dot
|
||||
* Oben ein Monatsraster (Nav-Zeile Zurueck/Monat/Weiter, Wochentagskopf,
|
||||
* 42 Zellen ab Montag, Faehrmonatstage gedaempft, heutiger Tag hervorgehoben,
|
||||
* Zaehl-Plakette an Tagen mit Terminen in der Farbe des Kalenders (ein
|
||||
* Kalender am Tag); bei mehreren Kalendern am selben Tag je ein kleiner
|
||||
* Kreis pro Kalender (max. drei, danach zwei plus grauer Restkreis mit
|
||||
* Summe, Logik in `buildDayBadges`, quick-260916-k2z), Tooltip beim
|
||||
* Ueberfahren mit Farbpunkt je Zeile (quick-260916-jvj)), darunter
|
||||
* der Block "Naechste Termine". Beide Bloecke lassen sich unabhaengig
|
||||
* ein-/ausblenden (Config `showMonth`/`maxEvents`, siehe calendar-month.ts).
|
||||
*
|
||||
* Der Tooltip wird per `createPortal` in `document.body` gerendert, weil die
|
||||
* Karte in widget-wrapper.tsx `overflow-hidden` traegt und ein absolut
|
||||
* positionierter Tooltip sonst abgeschnitten wuerde. Titel im Tooltip
|
||||
* brechen um (kein truncate); Breite/Klemmung kommen aus
|
||||
* `TOOLTIP_WIDTH_PX`/`TOOLTIP_EDGE_PX` (quick-260916-j4f).
|
||||
*
|
||||
* Das Ladefenster ist IMMER auf lokale Tagesgrenzen gerundet
|
||||
* (`computeFetchWindow`), damit der Backend-Cache-Schluessel
|
||||
* (`${userId}:${from.toISOString()}:${to.toISOString()}`) ueber den
|
||||
* 5-Minuten-Refresh hinweg stabil bleibt — auch wenn die Monatsansicht
|
||||
* ausgeblendet ist, deckt das Fenster weiterhin das 42-Tage-Raster des
|
||||
* (dann immer heutigen) Monats ab, siehe SUMMARY.
|
||||
*
|
||||
* Mehrtaegige/ganztaegige Termine werden im Raster nur am Starttag gezaehlt
|
||||
* (siehe calendar-month.ts).
|
||||
*
|
||||
* Refreshes every 5 minutes to match the backend cache TTL.
|
||||
* NEVER fetches external calendars directly (RESEARCH anti-pattern).
|
||||
@@ -23,8 +63,22 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [hasSources, setHasSources] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [monthDate, setMonthDate] = useState(() => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
});
|
||||
const [hover, setHover] = useState<{
|
||||
key: string;
|
||||
rect: { top: number; left: number; bottom: number; right: number };
|
||||
} | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
const { showMonth, maxEvents, lookaheadDays } = useMemo(
|
||||
() => resolveCalendarConfig(config),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[config.showMonth, config.maxEvents, config.lookaheadDays],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -43,8 +97,10 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
|
||||
setHasSources(true);
|
||||
|
||||
// Fetch aggregated events from visible sources
|
||||
const eventData = await fetchEvents();
|
||||
// Fetch aggregated events for the current grid/lookahead window —
|
||||
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
|
||||
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
|
||||
const eventData = await fetchEvents(from.toISOString(), to.toISOString());
|
||||
if (cancelled) return;
|
||||
setEvents(eventData);
|
||||
} catch {
|
||||
@@ -72,15 +128,35 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monthDate.getTime(), lookaheadDays]);
|
||||
|
||||
const showPrev = useCallback(() => {
|
||||
setHover(null);
|
||||
setMonthDate((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
|
||||
}, []);
|
||||
const showNext = useCallback(() => {
|
||||
setHover(null);
|
||||
setMonthDate((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
|
||||
}, []);
|
||||
const showToday = useCallback(() => {
|
||||
setHover(null);
|
||||
const now = new Date();
|
||||
setMonthDate(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
}, []);
|
||||
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
|
||||
const days = useMemo(() => buildCalendarDays(monthDate, eventsByDate), [monthDate, eventsByDate]);
|
||||
const upcoming = useMemo(
|
||||
() => selectUpcomingEvents(events, lookaheadDays, maxEvents),
|
||||
[events, lookaheadDays, maxEvents],
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('calendar.loading')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{t('calendar.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,74 +165,205 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
if (hasSources === false) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('calendar.emptyNoSources')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{t('calendar.emptyNoSources')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sources exist but no events
|
||||
if (events.length === 0) {
|
||||
// Both blocks hidden by configuration
|
||||
if (!showMonth && maxEvents === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('calendar.emptyNoEvents')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{t('calendar.nothingSelected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Events list
|
||||
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-1.5">
|
||||
<ul className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5">
|
||||
{showMonth && (
|
||||
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
|
||||
{/* Nav-Zeile */}
|
||||
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
|
||||
onClick={showPrev}
|
||||
>
|
||||
{t('calendar.monthPrev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag truncate rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] font-semibold leading-none text-foreground hover:bg-muted"
|
||||
title={t('calendar.monthToday')}
|
||||
onClick={showToday}
|
||||
>
|
||||
{formatMonthLabel(monthDate)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
|
||||
onClick={showNext}
|
||||
>
|
||||
{t('calendar.monthNext')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wochentagskopf */}
|
||||
<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<div key={label}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Raster */}
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
{days.map((day) => {
|
||||
const hasEvents = day.events.length > 0;
|
||||
const badges = buildDayBadges(day.events);
|
||||
const single = badges.length === 1;
|
||||
const cellClass = [
|
||||
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
|
||||
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60',
|
||||
day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '',
|
||||
hasEvents ? 'cursor-default hover:bg-muted' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.key}
|
||||
data-testid="calendar-day"
|
||||
data-date={day.key}
|
||||
data-today={day.isToday || undefined}
|
||||
data-outside={!day.inCurrentMonth || undefined}
|
||||
className={cellClass}
|
||||
onMouseEnter={(e) =>
|
||||
hasEvents &&
|
||||
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
|
||||
}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span>{day.date.getDate()}</span>
|
||||
{hasEvents && (
|
||||
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
|
||||
// mehr (mehrere Kreise stehen sonst uebereinander).
|
||||
<span
|
||||
data-testid="calendar-day-badges"
|
||||
className="absolute bottom-px right-px flex items-end gap-px"
|
||||
>
|
||||
{badges.map((badge) => {
|
||||
const badgeClass = [
|
||||
'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
|
||||
single
|
||||
? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
|
||||
: 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
|
||||
badge.rest
|
||||
? 'bg-muted-foreground text-background'
|
||||
: badge.color
|
||||
? 'text-white'
|
||||
: 'bg-primary text-primary-foreground',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<span
|
||||
key={badge.key}
|
||||
data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'}
|
||||
className={badgeClass}
|
||||
style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined}
|
||||
>
|
||||
{badge.count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maxEvents > 0 && (
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">
|
||||
{t('calendar.upcomingTitle')}
|
||||
</h3>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
||||
{t('calendar.emptyNoEvents')}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{upcoming.map((event) => (
|
||||
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
||||
{formatEventDate(event)}
|
||||
</p>
|
||||
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">
|
||||
{event.title}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hover !== null &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
data-testid="calendar-day-tooltip"
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-50 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg"
|
||||
style={{
|
||||
width: TOOLTIP_WIDTH_PX,
|
||||
top: hover.rect.bottom + TOOLTIP_EDGE_PX,
|
||||
left: Math.max(
|
||||
TOOLTIP_EDGE_PX,
|
||||
Math.min(hover.rect.left, window.innerWidth - TOOLTIP_WIDTH_PX - TOOLTIP_EDGE_PX),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Source color dot (8px, UI-SPEC) */}
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{event.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatEventTime(event)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hoverEvents.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="flex gap-2">
|
||||
<span
|
||||
data-testid="tooltip-color-dot"
|
||||
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
{hoverEvents.length > 5 && (
|
||||
<div className="mt-1 text-muted-foreground">{t('calendar.tooltipMore')}</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event time using locale-aware Intl.DateTimeFormat.
|
||||
* All-day events show only the date; timed events show time range.
|
||||
*/
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.start);
|
||||
const end = new Date(event.end);
|
||||
|
||||
if (event.allDay) {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}).format(start);
|
||||
}
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return `${timeFormatter.format(start)} – ${timeFormatter.format(end)}`;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
} from '@/lib/favorites-api';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
|
||||
// Must import AFTER mocks
|
||||
import { FavoritesWidget } from './favorites-widget';
|
||||
@@ -34,6 +35,7 @@ const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
||||
|
||||
const BASE_FAVORITES = [
|
||||
{
|
||||
@@ -287,4 +289,129 @@ describe('FavoritesWidget', () => {
|
||||
// 'Notion' has iconUrl null — the letter fallback span should show 'N'
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N');
|
||||
});
|
||||
|
||||
// quick-260916-iex: optionaler Titel
|
||||
describe('optionaler Titel (quick-260916-iex)', () => {
|
||||
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
||||
const r1 = render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
||||
).not.toBeInTheDocument();
|
||||
r1.unmount();
|
||||
|
||||
const r2 = render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: ' ' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
r2.unmount();
|
||||
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: 42 }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: 'Werkzeuge' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
const heading = screen.getByRole('heading', { name: 'Werkzeuge' });
|
||||
expect(heading.tagName).toBe('H2');
|
||||
expect(heading.className).toContain('text-sm');
|
||||
expect(heading.className).toContain('font-semibold');
|
||||
expect(
|
||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleField = screen.getByPlaceholderText(
|
||||
'favorites.titlePlaceholder',
|
||||
) as HTMLInputElement;
|
||||
expect(titleField.value).toBe('');
|
||||
expect(titleField.className).toContain('widgetNoDrag');
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /favorites\.listView/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /favorites\.gridView/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
const titleField = screen.getByPlaceholderText(
|
||||
'favorites.titlePlaceholder',
|
||||
);
|
||||
|
||||
for (const value of ['W', 'We', 'Werkzeuge']) {
|
||||
await act(async () => {
|
||||
fireEvent.change(titleField, { target: { value } });
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
}
|
||||
expect(mockUpdateWidgetConfig).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', {
|
||||
title: 'Werkzeuge',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(titleField, { target: { value: '' } });
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', {
|
||||
title: '',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from '@/lib/favorites-api';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
|
||||
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
||||
const TITLE_DEBOUNCE_MS = 1500;
|
||||
|
||||
/**
|
||||
* FavoritesWidget — persistent per-widget list of favorite links (DASH-09).
|
||||
*
|
||||
@@ -22,6 +25,9 @@ import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
* - Server-side icon discovery (letter fallback when iconUrl is null)
|
||||
* - Links open in new tab with rel="noreferrer" (T-08-08 open-redirect mitigation)
|
||||
* - No dangerouslySetInnerHTML (T-08-07 XSS mitigation)
|
||||
* - Optionaler Titel (quick-260916-iex): leer -> keine Kopfzeile, sonst
|
||||
* Kopfzeile im Notiz-Look; im Bearbeitungsmodus immer ein Titelfeld,
|
||||
* entprellt gespeichert (Muster note-widget.tsx)
|
||||
*/
|
||||
export function FavoritesWidget({
|
||||
instanceId,
|
||||
@@ -39,6 +45,30 @@ export function FavoritesWidget({
|
||||
(config.viewMode as 'list' | 'grid') ?? 'list',
|
||||
);
|
||||
|
||||
// Optionaler Titel (quick-260916-iex)
|
||||
const [title, setTitle] = useState<string>(
|
||||
typeof config.title === 'string' ? config.title : '',
|
||||
);
|
||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(titleTimerRef.current);
|
||||
}, []);
|
||||
|
||||
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value;
|
||||
setTitle(value);
|
||||
clearTimeout(titleTimerRef.current);
|
||||
titleTimerRef.current = setTimeout(() => {
|
||||
void updateWidgetConfig(instanceId, { title: value });
|
||||
}, TITLE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const hasTitle = title.trim() !== '';
|
||||
const showHeader = isEditMode || hasTitle;
|
||||
|
||||
// Add-form state
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
@@ -171,7 +201,30 @@ export function FavoritesWidget({
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto p-1 gap-2">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
{isEditMode ? (
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground widgetNoDrag"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
placeholder={t('favorites.titlePlaceholder')}
|
||||
aria-label={t('favorites.titleLabel')}
|
||||
/>
|
||||
) : (
|
||||
hasTitle && (
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
{title.trim()}
|
||||
</h2>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div className="flex gap-1 widgetNoDrag" aria-label={t('favorites.name')}>
|
||||
@@ -295,6 +348,7 @@ export function FavoritesWidget({
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl — passthrough t(key) => key (must be before imports)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock favorites-api — all 4 functions as controllable vi.fn() spies
|
||||
vi.mock('@/lib/favorites-api', () => ({
|
||||
fetchFavorites: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
} from '@/lib/favorites-api';
|
||||
|
||||
// Must import AFTER mocks
|
||||
import { LinkWidget } from './link-widget';
|
||||
|
||||
// Typed mock helpers
|
||||
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
|
||||
const SINGLE_LINK = {
|
||||
id: 'link-id-1',
|
||||
widgetId: 'link-1',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
iconUrl: 'https://github.com/favicon.ico',
|
||||
position: 0,
|
||||
};
|
||||
|
||||
describe('LinkWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue([SINGLE_LINK]);
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 'link-id-new',
|
||||
widgetId: 'link-1',
|
||||
title: 'New Site',
|
||||
url: 'https://example.com',
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
});
|
||||
mockUpdate.mockResolvedValue({ ...SINGLE_LINK, title: 'GitHub Updated' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls fetchFavorites with instanceId on mount and renders the link as an anchor with target="_blank" rel="noreferrer"', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
||||
expect(mockFetch).toHaveBeenCalledWith('link-1');
|
||||
|
||||
// After async resolution, link title should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Link must open in new tab with noreferrer (T-08-12)
|
||||
const anchor = screen.getByText('GitHub').closest('a');
|
||||
expect(anchor).toHaveAttribute('target', '_blank');
|
||||
expect(anchor).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('shows empty message in non-edit mode when no link exists', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('link.empty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows add form in edit mode when no link exists; submitting calls createFavorite with widgetId, title, url', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('link.addTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText('link.addTitle');
|
||||
const urlInput = screen.getByPlaceholderText('link.addUrl');
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: 'New Site' } });
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /link\.addButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
widgetId: 'link-1',
|
||||
title: 'New Site',
|
||||
url: 'https://example.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// New link title should appear after creation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Site')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('single-link enforcement: add form is hidden when a link already exists', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add form must NOT be present — single-link constraint (D-06)
|
||||
expect(screen.queryByPlaceholderText('link.addTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the edit button (aria-label is the translation key)
|
||||
const editButton = screen.getByRole('button', { name: /link\.editButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(editButton);
|
||||
});
|
||||
|
||||
// Inline edit form appears with current title pre-filled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editTitleInput = screen.getByDisplayValue('GitHub');
|
||||
act(() => {
|
||||
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /link\.saveButton/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /link\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
// updateFavorite called with the link id and new title
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'link-id-1',
|
||||
expect.objectContaining({ title: 'GitHub Updated' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('view toggle: list is default; switching to tile in edit mode renders tile container', async () => {
|
||||
render(
|
||||
<LinkWidget
|
||||
instanceId="link-1"
|
||||
config={{ viewMode: 'list' }}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Default list view — list container should be present
|
||||
expect(screen.getByTestId('link-list')).toBeInTheDocument();
|
||||
|
||||
// Click the tile (grid) toggle button
|
||||
const tileButton = screen.getByRole('button', { name: /link\.gridView/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(tileButton);
|
||||
});
|
||||
|
||||
// Tile container should now be rendered
|
||||
expect(screen.getByTestId('link-tile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
|
||||
mockFetch.mockResolvedValue([{ ...SINGLE_LINK, iconUrl: null, title: 'Notion' }]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Letter fallback span should show 'N' (first letter of 'Notion')
|
||||
expect(screen.getByTestId('link-letter-fallback')).toHaveTextContent('N');
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
fetchFavorites,
|
||||
updateFavorite,
|
||||
type FavoriteLink,
|
||||
} from '@/lib/favorites-api';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
|
||||
/**
|
||||
* LinkWidget — single-link quick-access tile (DASH-09 / D-06).
|
||||
*
|
||||
* Features:
|
||||
* - Displays exactly one link, scoped to this widget instance via instanceId
|
||||
* - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId)
|
||||
* - Single-link enforcement: add form hidden when a link already exists
|
||||
* - Add / edit / delete in edit mode (inline forms)
|
||||
* - List view (row: icon + title, default) and tile view, switchable in edit mode
|
||||
* - Server-side icon discovery (letter fallback when iconUrl is null)
|
||||
* - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation)
|
||||
* - No dangerouslySetInnerHTML (T-08-11 XSS mitigation)
|
||||
*/
|
||||
export function LinkWidget({
|
||||
instanceId,
|
||||
config,
|
||||
isEditMode,
|
||||
}: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
const [link, setLink] = useState<FavoriteLink | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// View mode: read from config, default 'list' (D-06)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>(
|
||||
(config.viewMode as 'list' | 'grid') ?? 'list',
|
||||
);
|
||||
|
||||
// Add-form state
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
|
||||
// Inline edit state
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editIconUrl, setEditIconUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchFavorites(instanceId);
|
||||
// Single-link: take only the first entry (D-06)
|
||||
if (!cancelled) setLink(data[0] ?? null);
|
||||
} catch {
|
||||
// Stable error key — t excluded from deps to prevent re-fetch loops
|
||||
if (!cancelled) setError(t('link.error'));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// instanceId is the only meaningful dependency — t is excluded intentionally
|
||||
// (mock creates new fn ref on each render, causing re-fetch loops in tests)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId]);
|
||||
|
||||
function getFallbackLetter(title: string): string {
|
||||
const clean = title.trim();
|
||||
return clean ? clean.slice(0, 1).toUpperCase() : '?';
|
||||
}
|
||||
|
||||
// --- View mode toggle (edit mode only) ---
|
||||
function handleViewMode(next: 'list' | 'grid') {
|
||||
setViewMode(next);
|
||||
void updateWidgetConfig(instanceId, { viewMode: next });
|
||||
}
|
||||
|
||||
// --- Add link ---
|
||||
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const cleanTitle = newTitle.trim();
|
||||
const cleanUrl = newUrl.trim();
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
try {
|
||||
const created = await createFavorite({
|
||||
widgetId: instanceId,
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
});
|
||||
setLink(created);
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit link ---
|
||||
function startEdit() {
|
||||
if (!link) return;
|
||||
setIsEditing(true);
|
||||
setEditTitle(link.title);
|
||||
setEditUrl(link.url);
|
||||
setEditIconUrl(link.iconUrl ?? '');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setIsEditing(false);
|
||||
setEditTitle('');
|
||||
setEditUrl('');
|
||||
setEditIconUrl('');
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!link) return;
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
const cleanIconUrl = editIconUrl.trim();
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
try {
|
||||
const updated = await updateFavorite(link.id, {
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null,
|
||||
});
|
||||
setLink(updated);
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete link ---
|
||||
async function handleDelete() {
|
||||
if (!link) return;
|
||||
try {
|
||||
await deleteFavorite(link.id);
|
||||
setLink(null);
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared icon element ---
|
||||
function renderIcon(size: 'sm' | 'md') {
|
||||
if (!link) return null;
|
||||
const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8';
|
||||
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
||||
const imgSize = size === 'sm' ? 20 : 32;
|
||||
return (
|
||||
<div className={`relative flex-shrink-0 ${wh}`}>
|
||||
<span
|
||||
data-testid="link-letter-fallback"
|
||||
className={`absolute inset-0 flex items-center justify-center ${textSize} font-semibold bg-muted rounded`}
|
||||
>
|
||||
{getFallbackLetter(link.title)}
|
||||
</span>
|
||||
{link.iconUrl && (
|
||||
<img
|
||||
src={link.iconUrl}
|
||||
alt=""
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
loading="lazy"
|
||||
className={`absolute inset-0 ${wh} rounded`}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline edit form ---
|
||||
function renderEditForm() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 widgetNoDrag">
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder={t('link.addTitle')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
placeholder={t('link.addUrl')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editIconUrl}
|
||||
onChange={(e) => setEditIconUrl(e.target.value)}
|
||||
placeholder="Logo-URL (optional)"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
onClick={() => void handleSaveEdit()}
|
||||
>
|
||||
{t('link.saveButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
{t('link.cancelButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto p-1 gap-2">
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div className="flex gap-1 widgetNoDrag">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewMode('list')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('link.listView')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewMode('grid')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('link.gridView')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status messages */}
|
||||
{loading && (
|
||||
<p className="text-xs text-muted-foreground">{t('link.loading')}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !link && (
|
||||
<p className="text-xs text-muted-foreground">{t('link.empty')}</p>
|
||||
)}
|
||||
|
||||
{/* Link display — list (row) view */}
|
||||
{!loading && link && !isEditing && viewMode === 'list' && (
|
||||
<div data-testid="link-list" className="flex items-center gap-2">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
|
||||
onClick={(e) => isEditMode && e.preventDefault()}
|
||||
>
|
||||
{renderIcon('sm')}
|
||||
<span className="text-xs truncate">{link.title}</span>
|
||||
</a>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('link.editButton')}
|
||||
title={t('link.editButton')}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0 widgetNoDrag"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link display — tile (grid) view */}
|
||||
{!loading && link && !isEditing && viewMode === 'grid' && (
|
||||
<div data-testid="link-tile" className="flex flex-col items-center gap-1 p-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-col items-center gap-1 widgetNoDrag"
|
||||
onClick={(e) => isEditMode && e.preventDefault()}
|
||||
>
|
||||
{renderIcon('md')}
|
||||
<span className="text-xs text-center truncate max-w-full">{link.title}</span>
|
||||
</a>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('link.editButton')}
|
||||
title={t('link.editButton')}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground widgetNoDrag"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline edit form */}
|
||||
{isEditMode && isEditing && link && renderEditForm()}
|
||||
|
||||
{/* Add form — single-link enforcement: hidden when link exists (D-06) */}
|
||||
{isEditMode && !link && !loading && (
|
||||
<form
|
||||
onSubmit={(e) => void handleAdd(e)}
|
||||
className="flex flex-col gap-1 widgetNoDrag"
|
||||
>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t('link.addTitle')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('link.addUrl')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('link.addButton')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list';
|
||||
|
||||
describe('note-task-list', () => {
|
||||
describe('isTaskLine', () => {
|
||||
it('erkennt gueltige Aufgabenzeilen', () => {
|
||||
expect(isTaskLine('- [ ] Milch')).toBe(true);
|
||||
expect(isTaskLine('- [x] Brot')).toBe(true);
|
||||
expect(isTaskLine('* [X] Eier')).toBe(true);
|
||||
expect(isTaskLine('+ [ ] Butter')).toBe(true);
|
||||
expect(isTaskLine('1. [ ] Mehl')).toBe(true);
|
||||
expect(isTaskLine('2) [x] Salz')).toBe(true);
|
||||
expect(isTaskLine(' - [ ] eingerueckt')).toBe(true);
|
||||
expect(isTaskLine('- [\t] Tab')).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt Nicht-Aufgabenzeilen ab', () => {
|
||||
expect(isTaskLine('- [ ]')).toBe(false);
|
||||
expect(isTaskLine('- [ ]Text')).toBe(false);
|
||||
expect(isTaskLine('- Milch')).toBe(false);
|
||||
expect(isTaskLine('[ ] ohne Punkt')).toBe(false);
|
||||
expect(isTaskLine('- [y] falsch')).toBe(false);
|
||||
expect(isTaskLine('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleTaskLine', () => {
|
||||
it('kippt genau die Zeile am angegebenen Index auf [x]', () => {
|
||||
const content = '- [ ] Milch\n- [ ] Brot\n- [ ] Eier';
|
||||
expect(toggleTaskLine(content, 0)).toBe(
|
||||
'- [x] Milch\n- [ ] Brot\n- [ ] Eier',
|
||||
);
|
||||
expect(toggleTaskLine(content, 2)).toBe(
|
||||
'- [ ] Milch\n- [ ] Brot\n- [x] Eier',
|
||||
);
|
||||
});
|
||||
|
||||
it('kippt [x]/[X] zurueck auf [ ]', () => {
|
||||
expect(toggleTaskLine('- [x] Brot', 0)).toBe('- [ ] Brot');
|
||||
expect(toggleTaskLine('- [X] Brot', 0)).toBe('- [ ] Brot');
|
||||
});
|
||||
|
||||
it('zaehlt Nicht-Aufgabenzeilen nicht mit, alle anderen Zeilen bleiben unveraendert', () => {
|
||||
const content = '# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [ ] Brot';
|
||||
const result = toggleTaskLine(content, 1);
|
||||
expect(result).toBe('# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [x] Brot');
|
||||
});
|
||||
|
||||
it('behaelt Einrueckung und Nummerierung bei', () => {
|
||||
const content = '- [ ] A\n - [ ] B\n1. [ ] C';
|
||||
expect(toggleTaskLine(content, 1)).toBe('- [ ] A\n - [x] B\n1. [ ] C');
|
||||
expect(toggleTaskLine(content, 2)).toBe('- [ ] A\n - [ ] B\n1. [x] C');
|
||||
});
|
||||
|
||||
it('gibt bei Index ausserhalb des Bereichs die unveraenderte Eingabe zurueck', () => {
|
||||
const content = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||
expect(toggleTaskLine(content, -1)).toBe(content);
|
||||
expect(toggleTaskLine(content, 3)).toBe(content);
|
||||
expect(toggleTaskLine('', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('ueberspringt Zeilen innerhalb von Code-Zaeunen', () => {
|
||||
const withBackticks = '```\n- [ ] nicht\n```\n- [ ] echt';
|
||||
expect(toggleTaskLine(withBackticks, 0)).toBe(
|
||||
'```\n- [ ] nicht\n```\n- [x] echt',
|
||||
);
|
||||
|
||||
const withTilde = '~~~\n- [ ] nicht\n~~~\n- [ ] echt';
|
||||
expect(toggleTaskLine(withTilde, 0)).toBe(
|
||||
'~~~\n- [ ] nicht\n~~~\n- [x] echt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => {
|
||||
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
|
||||
const SRC =
|
||||
'- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier';
|
||||
|
||||
const { container } = render(
|
||||
<MDEditor.Markdown
|
||||
source={SRC}
|
||||
rehypePlugins={[[rehypeSanitize]]}
|
||||
components={{ input: NoteCheckbox }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const boxes = container.querySelectorAll('input[type="checkbox"]');
|
||||
const expectedCount = SRC.split('\n').filter(isTaskLine).length;
|
||||
|
||||
expect(expectedCount).toBe(4);
|
||||
expect(boxes.length).toBe(4);
|
||||
|
||||
for (const box of Array.from(boxes)) {
|
||||
expect((box as HTMLInputElement).disabled).toBe(false);
|
||||
}
|
||||
|
||||
expect((boxes[0] as HTMLInputElement).checked).toBe(false);
|
||||
expect((boxes[1] as HTMLInputElement).checked).toBe(true);
|
||||
expect((boxes[2] as HTMLInputElement).checked).toBe(true);
|
||||
expect((boxes[3] as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* note-task-list — reine Hilfsfunktionen + Kaestchen-Komponente fuer das
|
||||
* Notiz-Widget (quick-260916-iex).
|
||||
*
|
||||
* `rehypeSanitize` (hast-util-sanitize) erzwingt bei `input`-Elementen
|
||||
* grundsaetzlich `disabled=true`. Die Loesung ersetzt NICHT das
|
||||
* Sanitize-Verhalten, sondern das gerenderte Element selbst: react-markdown
|
||||
* (ueber `@uiw/react-md-editor`s `previewOptions.components`) laesst uns das
|
||||
* `input`-Tag durch eine eigene Komponente ersetzen, die NACH dem Sanitize
|
||||
* ausgefuehrt wird und deshalb selbst entscheidet, ob sie `disabled` setzt.
|
||||
* `rehypeSanitize` bleibt aktiv (XSS-Schranke fuer den Rest des Markdowns,
|
||||
* T-IEX-01).
|
||||
*
|
||||
* Die Zuordnung Klick -> Zeile laeuft ueber einen Index (n-tes Kaestchen im
|
||||
* DOM == n-te Aufgabenzeile im Markdown). Damit das stimmt, MUSS
|
||||
* `isTaskLine` exakt dieselben Zeilen als Aufgabe erkennen wie die
|
||||
* GFM-Erkennung von react-markdown (remark-gfm /
|
||||
* micromark-extension-gfm-task-list-item): nach der schliessenden Klammer
|
||||
* muss Leerraum UND mindestens ein Nicht-Leerraum-Zeichen folgen. Eine
|
||||
* laxere Regex wuerde bei einer noch leeren Aufgabenzeile (`- [ ]`, typisch
|
||||
* beim Tippen einer neuen Aufgabe) den Index verschieben und beim Klick die
|
||||
* falsche Zeile kippen.
|
||||
*
|
||||
* Vorbild: `toggleMarkdownCheckbox` im alten persoenlichen Dashboard des
|
||||
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
|
||||
* `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst
|
||||
* streng: nach `]` muss Leerraum und danach mindestens ein
|
||||
* Nicht-Leerraum-Zeichen folgen (siehe Modulkommentar).
|
||||
*/
|
||||
export const TASK_LINE_RE =
|
||||
/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ \txX])(\]\s+\S.*)$/;
|
||||
|
||||
const FENCE_RE = /^\s*(```|~~~)/;
|
||||
|
||||
export function isTaskLine(line: string): boolean {
|
||||
return TASK_LINE_RE.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kippt die `index`-te Aufgabenzeile (0-basiert, Zeilen in Code-Zaeunen
|
||||
* zaehlen nicht mit) zwischen `[ ]` und `[x]`. Gibt bei ungueltigem Index
|
||||
* (< 0 oder >= Anzahl Aufgabenzeilen) dieselbe Referenz wie `content`
|
||||
* zurueck, damit Aufrufer per `===` erkennen, dass nichts zu speichern ist.
|
||||
*/
|
||||
export function toggleTaskLine(content: string, index: number): string {
|
||||
if (index < 0) return content;
|
||||
|
||||
const lines = content.split('\n');
|
||||
let inFence = false;
|
||||
let taskCounter = 0;
|
||||
let changed = false;
|
||||
|
||||
const nextLines = lines.map((line) => {
|
||||
if (FENCE_RE.test(line)) {
|
||||
inFence = !inFence;
|
||||
return line;
|
||||
}
|
||||
if (inFence) return line;
|
||||
if (!isTaskLine(line)) return line;
|
||||
|
||||
const isTarget = taskCounter === index;
|
||||
taskCounter += 1;
|
||||
if (!isTarget) return line;
|
||||
|
||||
const match = line.match(TASK_LINE_RE);
|
||||
if (!match) return line;
|
||||
|
||||
const [, prefix, mark, suffix] = match;
|
||||
const newMark = mark === ' ' || mark === '\t' ? 'x' : ' ';
|
||||
changed = true;
|
||||
return `${prefix}${newMark}${suffix}`;
|
||||
});
|
||||
|
||||
if (!changed) return content;
|
||||
return nextLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt das von react-markdown gerenderte `<input>` einer Aufgabenzeile.
|
||||
* Zieht bewusst NUR `checked` aus den Props (react-markdown reicht
|
||||
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
|
||||
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
|
||||
* bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung
|
||||
* "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst,
|
||||
* sondern delegiert am Vorschau-Container verarbeitet.
|
||||
*/
|
||||
export function NoteCheckbox({ checked }: { checked?: boolean }) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
readOnly
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,23 +12,61 @@ vi.mock('next-intl', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute
|
||||
// quick-260916-j4f: gehobener Themenzustand, damit die Variable trotz
|
||||
// Hoisting der Mocks und des statischen Imports weiter unten sicher
|
||||
// initialisiert ist (Muster changelog-page.test.tsx).
|
||||
const themeMock = vi.hoisted(() => ({ resolvedTheme: 'light' as 'light' | 'dark' }));
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ resolvedTheme: themeMock.resolvedTheme }),
|
||||
}));
|
||||
|
||||
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute.
|
||||
// Im Modus 'preview' wird stattdessen ein Vorschau-Div gerendert, das fuer
|
||||
// jede Aufgabenzeile ein Kaestchen enthaelt (quick-260916-iex) — nutzt die
|
||||
// echte `components.input`-Komponente aus `previewOptions`, wenn vorhanden,
|
||||
// damit auch NoteCheckbox im Klickpfad landet.
|
||||
const TASK_MOCK_RE = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/;
|
||||
|
||||
vi.mock('@uiw/react-md-editor', () => {
|
||||
const MDEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
preview,
|
||||
previewOptions,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (val?: string) => void;
|
||||
preview?: string;
|
||||
previewOptions?: { components?: { input?: React.ComponentType<{ checked?: boolean }> } };
|
||||
'data-testid'?: string;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid={testId ?? 'md-editor'}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}) => {
|
||||
if (preview === 'preview') {
|
||||
const InputComponent = previewOptions?.components?.input;
|
||||
const lines = (value ?? '').split('\n');
|
||||
return (
|
||||
<div data-testid={testId ?? 'md-editor'}>
|
||||
{lines.map((line, i) => {
|
||||
const match = line.match(TASK_MOCK_RE);
|
||||
if (!match) return null;
|
||||
const checked = match[1] !== ' ';
|
||||
return InputComponent ? (
|
||||
<InputComponent key={i} checked={checked} />
|
||||
) : (
|
||||
<input key={i} type="checkbox" readOnly checked={checked} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<textarea
|
||||
data-testid={testId ?? 'md-editor'}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MDEditor.displayName = 'MDEditor';
|
||||
|
||||
return {
|
||||
@@ -55,6 +93,7 @@ describe('NoteWidget', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
themeMock.resolvedTheme = 'light';
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
@@ -171,4 +210,115 @@ describe('NoteWidget', () => {
|
||||
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
|
||||
// quick-260916-iex: Aufgabenlisten in der Ansicht abhakbar
|
||||
it('kippt beim Klick auf ein Kaestchen in der Ansicht genau diese Zeile und speichert sofort', async () => {
|
||||
render(
|
||||
<NoteWidget
|
||||
instanceId="note-1"
|
||||
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(3);
|
||||
|
||||
fireEvent.click(boxes[2]);
|
||||
await act(async () => {});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain('/dashboard/widgets/note-1/config');
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
|
||||
});
|
||||
|
||||
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('kippt ein angehaktes Kaestchen beim Klick wieder ab', async () => {
|
||||
render(
|
||||
<NoteWidget
|
||||
instanceId="note-1"
|
||||
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(boxes[1]);
|
||||
await act(async () => {});
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
config: { content: '- [ ] Milch\n- [ ] Brot\n- [ ] Eier', title: 'Einkauf' },
|
||||
});
|
||||
});
|
||||
|
||||
it('verwirft einen noch laufenden Entprell-Timer aus dem Tippen beim Abhaken', async () => {
|
||||
render(
|
||||
<NoteWidget
|
||||
instanceId="note-1"
|
||||
config={{ content: '- [ ] Milch\n- [ ] Brot', title: 'Einkauf' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Stift an: in den Bearbeitungsmodus wechseln und tippen
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
});
|
||||
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: '- [ ] Milch\n- [ ] Brot' } });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Stift wieder aus: zurueck in die Ansicht
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
});
|
||||
|
||||
// Erstes Kaestchen anklicken -> sofortiges Speichern, alter Timer verworfen
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(boxes[0]);
|
||||
await act(async () => {});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
config: { content: '- [x] Milch\n- [ ] Brot', title: 'Einkauf' },
|
||||
});
|
||||
|
||||
// Der alte, eigentlich noch laufende Timer darf nichts mehr nachschieben
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => {
|
||||
themeMock.resolvedTheme = 'dark';
|
||||
const { container } = render(
|
||||
<NoteWidget instanceId="note-t1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-color-mode]')?.getAttribute('data-color-mode')).toBe(
|
||||
'dark',
|
||||
);
|
||||
});
|
||||
|
||||
it('setzt data-color-mode auf light, wenn Tessera auf Hell steht', () => {
|
||||
themeMock.resolvedTheme = 'light';
|
||||
const { container } = render(
|
||||
<NoteWidget instanceId="note-t1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-color-mode]')?.getAttribute('data-color-mode')).toBe(
|
||||
'light',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,32 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import { NoteCheckbox, toggleTaskLine } from './note-task-list';
|
||||
|
||||
const DEBOUNCE_MS = 1500;
|
||||
|
||||
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
|
||||
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
|
||||
// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb
|
||||
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed.
|
||||
// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket
|
||||
// `@uiw/react-markdown-preview` importiert (das ist keine direkte
|
||||
// Dependency von apps/web und daher unter pnpms strikter Isolation nicht
|
||||
// von hier aus aufloesbar).
|
||||
type PreviewOptions = NonNullable<
|
||||
React.ComponentProps<typeof MDEditor>['previewOptions']
|
||||
>;
|
||||
|
||||
const PREVIEW_OPTIONS: PreviewOptions = {
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
components: { input: NoteCheckbox },
|
||||
};
|
||||
|
||||
const NOTE_COMMANDS = [
|
||||
commands.bold,
|
||||
commands.italic,
|
||||
@@ -23,6 +42,17 @@ const NOTE_COMMANDS = [
|
||||
|
||||
export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// quick-260916-j4f: folgt dem Tessera-Schalter statt der
|
||||
// Betriebssystem-Einstellung, Muster changelog-view.tsx.
|
||||
const colorMode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
const [title, setTitle] = useState<string>(
|
||||
(config.title as string) || t('note.defaultTitle'),
|
||||
);
|
||||
@@ -83,8 +113,34 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
[scheduleSave, content],
|
||||
);
|
||||
|
||||
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt
|
||||
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist
|
||||
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem
|
||||
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt.
|
||||
const handlePreviewClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isEditing) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
const boxes = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
),
|
||||
);
|
||||
const index = boxes.indexOf(target);
|
||||
const next = toggleTaskLine(content, index);
|
||||
if (next === content) return;
|
||||
setContent(next);
|
||||
clearTimeout(timerRef.current);
|
||||
void save(next, title);
|
||||
},
|
||||
[isEditing, content, title, save],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
|
||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
||||
{/* Header: title + pencil toggle */}
|
||||
<div className="relative flex items-center border-b border-border px-1.5 py-1.5 gap-2">
|
||||
<input
|
||||
@@ -123,7 +179,11 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
</div>
|
||||
|
||||
{/* Content: preview or editor */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
data-testid="note-preview"
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
<MDEditor
|
||||
data-testid="md-editor"
|
||||
value={content}
|
||||
@@ -133,9 +193,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
hideToolbar={!isEditing}
|
||||
height="100%"
|
||||
visibleDragbar={false}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
}}
|
||||
previewOptions={PREVIEW_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
|
||||
// ohne Absturz als grauer Text gerendert werden.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { WidgetWrapper } from './widget-wrapper';
|
||||
|
||||
describe('WidgetWrapper', () => {
|
||||
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
|
||||
isEditMode={false}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const article = screen.getByRole('article');
|
||||
expect(article).toHaveAttribute('aria-label', 'link');
|
||||
|
||||
const fallback = screen.getByText('link');
|
||||
expect(fallback.className).toContain('text-muted-foreground');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import { CalendarSourceForm } from './calendar-source-form';
|
||||
|
||||
/**
|
||||
* calendar-source-form.test — NEU (quick-260916-hiv). Deckt den
|
||||
* typabhaengigen URL-Platzhalter und den grauen EWS-Hinweis ab.
|
||||
* Texte aus der echten de.json.
|
||||
*/
|
||||
vi.mock('next-intl', async () => {
|
||||
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||
const lookup = (path: string): string | undefined =>
|
||||
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/calendar-api', () => ({
|
||||
testSourceConfig: vi.fn(),
|
||||
testSource: vi.fn(),
|
||||
}));
|
||||
|
||||
const t = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function renderForm() {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<CalendarSourceForm onSave={onSave} onCancel={onCancel} />);
|
||||
return {
|
||||
onSave,
|
||||
onCancel,
|
||||
urlInput: screen.getByLabelText(/Adresse \(URL\)/) as HTMLInputElement,
|
||||
typeSelect: screen.getByLabelText(/^Typ/) as HTMLSelectElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CalendarSourceForm — URL-Platzhalter je Typ + EWS-Hinweis (quick-260916-hiv)', () => {
|
||||
it('Test 1: ohne Typ zeigt das URL-Feld https:// und keinen EWS-Hinweis', () => {
|
||||
const { urlInput } = renderForm();
|
||||
|
||||
expect(urlInput.placeholder).toBe('https://');
|
||||
expect(screen.queryByTestId('source-url-hint-ews')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: CalDAV zeigt den CalDAV-Platzhalter, keinen Hinweis', () => {
|
||||
const { urlInput, typeSelect } = renderForm();
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: 'caldav' } });
|
||||
|
||||
expect(urlInput.placeholder).toBe(t.formFieldUrlPlaceholderCaldav);
|
||||
expect(screen.queryByTestId('source-url-hint-ews')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: ICS zeigt den ICS-Platzhalter, keinen Hinweis', () => {
|
||||
const { urlInput, typeSelect } = renderForm();
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: 'ics' } });
|
||||
|
||||
expect(urlInput.placeholder).toBe(t.formFieldUrlPlaceholderIcs);
|
||||
expect(screen.queryByTestId('source-url-hint-ews')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 4: Exchange (Standardmodus Graph) zeigt den Graph-Platzhalter, keinen Hinweis', () => {
|
||||
const { urlInput, typeSelect } = renderForm();
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: 'exchange' } });
|
||||
|
||||
expect(urlInput.placeholder).toBe(t.formFieldUrlPlaceholderGraph);
|
||||
expect(screen.queryByTestId('source-url-hint-ews')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 5: Exchange + EWS zeigt den EWS-Platzhalter und den grauen Hinweis; zurueck auf Graph verschwindet beides', () => {
|
||||
const { urlInput, typeSelect } = renderForm();
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: 'exchange' } });
|
||||
const exchangeModeSelect = screen.getByLabelText(/Exchange-Anbindung/) as HTMLSelectElement;
|
||||
fireEvent.change(exchangeModeSelect, { target: { value: 'ews' } });
|
||||
|
||||
expect(urlInput.placeholder).toBe(t.formFieldUrlPlaceholderEws);
|
||||
const hint = screen.getByTestId('source-url-hint-ews');
|
||||
expect(hint).toHaveTextContent(t.formFieldUrlHintEws);
|
||||
expect(hint.className).toContain('text-muted-foreground');
|
||||
|
||||
fireEvent.change(exchangeModeSelect, { target: { value: 'graph' } });
|
||||
|
||||
expect(screen.queryByTestId('source-url-hint-ews')).not.toBeInTheDocument();
|
||||
expect(urlInput.placeholder).toBe(t.formFieldUrlPlaceholderGraph);
|
||||
});
|
||||
|
||||
it('Test 6: Exchange + EWS mit http-Fehler zeigt Fehler UND Hinweis, Hinweis steht nach dem Fehler', () => {
|
||||
const { urlInput, typeSelect } = renderForm();
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: 'exchange' } });
|
||||
const exchangeModeSelect = screen.getByLabelText(/Exchange-Anbindung/) as HTMLSelectElement;
|
||||
fireEvent.change(exchangeModeSelect, { target: { value: 'ews' } });
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'http://mail.firma.de/EWS/Exchange.asmx' } });
|
||||
|
||||
const fehler = screen.getByText(t.formUrlErrorHttps);
|
||||
const hinweis = screen.getByTestId('source-url-hint-ews');
|
||||
expect(fehler).toBeInTheDocument();
|
||||
expect(hinweis).toBeInTheDocument();
|
||||
expect(fehler.compareDocumentPosition(hinweis) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,10 @@ interface CalendarSourceFormProps {
|
||||
* Color (8-color palette).
|
||||
*
|
||||
* T-05-14: Client-side https-only validation before submit.
|
||||
*
|
||||
* Quick 260916-hiv: the URL field's placeholder depends on the selected type
|
||||
* (CalDAV / ICS / Exchange Graph / Exchange EWS), and Exchange EWS shows a
|
||||
* hint below the field explaining that the full EWS address is required.
|
||||
*/
|
||||
export function CalendarSourceForm({
|
||||
onSave,
|
||||
@@ -79,6 +83,16 @@ export function CalendarSourceForm({
|
||||
|
||||
const isICS = type === 'ics';
|
||||
const isExchange = type === 'exchange';
|
||||
const isEws = isExchange && exchangeMode === 'ews';
|
||||
const urlPlaceholder = isExchange
|
||||
? isEws
|
||||
? t('calendar.formFieldUrlPlaceholderEws')
|
||||
: t('calendar.formFieldUrlPlaceholderGraph')
|
||||
: type === 'caldav'
|
||||
? t('calendar.formFieldUrlPlaceholderCaldav')
|
||||
: type === 'ics'
|
||||
? t('calendar.formFieldUrlPlaceholderIcs')
|
||||
: 'https://';
|
||||
|
||||
// Validate URL is https (T-05-14)
|
||||
const validateUrl = useCallback((value: string): boolean => {
|
||||
@@ -248,7 +262,7 @@ export function CalendarSourceForm({
|
||||
id="source-url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://"
|
||||
placeholder={urlPlaceholder}
|
||||
className={`h-9 w-full max-w-md rounded border px-3 text-sm text-foreground bg-background ${
|
||||
urlError ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
@@ -265,6 +279,11 @@ export function CalendarSourceForm({
|
||||
{urlError && (
|
||||
<p className="mt-1 text-xs text-destructive">{urlError}</p>
|
||||
)}
|
||||
{isEws && (
|
||||
<p data-testid="source-url-hint-ews" className="mt-1 text-xs text-muted-foreground">
|
||||
{t('calendar.formFieldUrlHintEws')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username (hidden for ICS) */}
|
||||
|
||||
@@ -19,7 +19,12 @@ vi.mock('next-intl', async () => {
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
|
||||
useTranslations:
|
||||
(ns?: string) =>
|
||||
(key: string, values?: Record<string, unknown>) => {
|
||||
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
|
||||
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,6 +41,7 @@ vi.mock('@/components/settings/search-provider-form', () => ({
|
||||
}));
|
||||
|
||||
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
|
||||
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
|
||||
|
||||
const WIDGET = { id: 'c1', widgetType: 'clock', config: { timezone: 'Europe/Berlin', timeFontSizePt: 24 } };
|
||||
|
||||
@@ -105,3 +111,126 @@ describe('WidgetSettingsPanel — Schriftgroesse der Uhrzeit (quick-260916-bwo)'
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)', () => {
|
||||
function renderCalendarExpanded(config: Record<string, unknown> = {}) {
|
||||
const onWidgetUpdate = vi.fn();
|
||||
const widget = { id: 'k1', widgetType: 'calendar', config };
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Kalender #1/ }));
|
||||
return { onWidgetUpdate };
|
||||
}
|
||||
|
||||
it('Test 5: Vorbelegung aus der Konfiguration, Optionslisten und uebersetzte Link-Zeile', () => {
|
||||
renderCalendarExpanded();
|
||||
|
||||
const showMonth = screen.getByLabelText(cal.configShowMonth) as HTMLInputElement;
|
||||
expect(showMonth.checked).toBe(true);
|
||||
|
||||
const maxEventsSelect = screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement;
|
||||
expect(maxEventsSelect.value).toBe('3');
|
||||
const maxEventsOptionTexts = Array.from(maxEventsSelect.options).map((o) => o.textContent);
|
||||
expect(maxEventsOptionTexts).toEqual([
|
||||
cal.configMaxEventsNone,
|
||||
cal.configMaxEventsOne,
|
||||
...Array.from({ length: 9 }, (_, i) => cal.configMaxEventsMany.replace('{count}', String(i + 2))),
|
||||
]);
|
||||
|
||||
const lookaheadSelect = screen.getByLabelText(cal.configLookahead) as HTMLSelectElement;
|
||||
expect(lookaheadSelect.value).toBe('30');
|
||||
const lookaheadOptionTexts = Array.from(lookaheadSelect.options).map((o) => o.textContent);
|
||||
expect(lookaheadOptionTexts).toEqual(
|
||||
[7, 14, 30, 60, 90].map((days) => cal.configLookaheadOption.replace('{days}', String(days))),
|
||||
);
|
||||
|
||||
expect(screen.getByText(cal.configSourcesHint)).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: cal.configSourcesLink });
|
||||
expect(link).toHaveAttribute('href', '/settings/dashboard/calendar');
|
||||
|
||||
expect(screen.queryByText(/managed under/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 6: jede Aenderung ruft updateWidgetConfig/onWidgetUpdate nur mit dem geaenderten Feld auf', async () => {
|
||||
const { onWidgetUpdate } = renderCalendarExpanded();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(cal.configMaxEvents), { target: { value: '5' } });
|
||||
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { maxEvents: 5 }));
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { maxEvents: 5 });
|
||||
|
||||
fireEvent.change(screen.getByLabelText(cal.configLookahead), { target: { value: '14' } });
|
||||
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { lookaheadDays: 14 }));
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { lookaheadDays: 14 });
|
||||
|
||||
fireEvent.click(screen.getByLabelText(cal.configShowMonth));
|
||||
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { showMonth: false }));
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { showMonth: false });
|
||||
|
||||
expect(updateWidgetConfig).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Test 7: bestehende Werte werden angezeigt, ungueltige Werte geklemmt', () => {
|
||||
renderCalendarExpanded({ showMonth: false, maxEvents: 7, lookaheadDays: 60 });
|
||||
|
||||
expect((screen.getByLabelText(cal.configShowMonth) as HTMLInputElement).checked).toBe(false);
|
||||
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('7');
|
||||
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('60');
|
||||
|
||||
cleanup();
|
||||
|
||||
renderCalendarExpanded({ maxEvents: 42, lookaheadDays: 45 });
|
||||
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('10');
|
||||
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('30');
|
||||
});
|
||||
});
|
||||
|
||||
const fav = (de as { widgets: { favorites: Record<string, string> } }).widgets.favorites;
|
||||
const note = (de as { widgets: { note: Record<string, string> } }).widgets.note;
|
||||
|
||||
describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
|
||||
function renderFavoritesExpanded(config: Record<string, unknown> = {}) {
|
||||
const onWidgetUpdate = vi.fn();
|
||||
const widget = { id: 'f1', widgetType: 'favorites', config };
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Favoriten #1/ }));
|
||||
return { onWidgetUpdate };
|
||||
}
|
||||
|
||||
it('Test B1: Instanz-Kopfzeile zeigt "— Titel", Feld vorbelegt, Aenderung ruft updateWidgetConfig/onWidgetUpdate auf', async () => {
|
||||
const { onWidgetUpdate } = renderFavoritesExpanded({ title: 'Werkzeuge' });
|
||||
|
||||
expect(screen.getByRole('button', { name: /Favoriten #1/ })).toHaveTextContent('— Werkzeuge');
|
||||
|
||||
const input = screen.getByLabelText(fav.titleLabel) as HTMLInputElement;
|
||||
expect(input.value).toBe('Werkzeuge');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Werkzeuge 2' } });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }),
|
||||
);
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' });
|
||||
});
|
||||
|
||||
it('Test B2: ohne Titel bzw. mit Leerraum-Titel keine "—" in der Kopfzeile, Feld leer', () => {
|
||||
renderFavoritesExpanded({});
|
||||
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
|
||||
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
|
||||
|
||||
cleanup();
|
||||
|
||||
renderFavoritesExpanded({ title: ' ' });
|
||||
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
|
||||
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('Test B3: Notiz-Instanz zeigt uebersetzte Beschriftung "Titel" statt hart kodiertem "Title"', () => {
|
||||
const onWidgetUpdate = vi.fn();
|
||||
const widget = { id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } };
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Notizen #1/ }));
|
||||
|
||||
const input = screen.getByLabelText(note.titleLabel) as HTMLInputElement;
|
||||
expect(input.value).toBe('Einkauf');
|
||||
expect(screen.queryByText('Title')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
CLOCK_FONT_SIZE_MIN_PT,
|
||||
resolveClockTimeFontSizePt,
|
||||
} from '@/components/dashboard/widgets/clock-font-size';
|
||||
import {
|
||||
CALENDAR_LOOKAHEAD_OPTIONS,
|
||||
CALENDAR_MAX_EVENTS_LIMIT,
|
||||
resolveCalendarConfig,
|
||||
} from '@/components/dashboard/widgets/calendar-month';
|
||||
|
||||
/**
|
||||
* IANA timezones offered for clock configuration (D-12).
|
||||
@@ -112,11 +117,12 @@ export function WidgetSettingsPanel({
|
||||
>
|
||||
<span>
|
||||
{t(def.nameKey)} #{idx + 1}
|
||||
{widget.widgetType === 'note' &&
|
||||
{(widget.widgetType === 'note' ||
|
||||
widget.widgetType === 'favorites') &&
|
||||
typeof widget.config.title === 'string' &&
|
||||
widget.config.title !== '' && (
|
||||
widget.config.title.trim() !== '' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
— {widget.config.title}
|
||||
— {widget.config.title.trim()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -170,20 +176,24 @@ export function WidgetSettingsPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar config — managed elsewhere */}
|
||||
{/* Calendar config (quick-260916-htc) */}
|
||||
{widget.widgetType === 'calendar' && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
Calendar sources are managed under{' '}
|
||||
<Link
|
||||
href="/settings/dashboard/calendar"
|
||||
className="text-primary underline hover:text-primary/90"
|
||||
>
|
||||
Settings > Dashboard > Calendar
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<CalendarConfig
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Favorites config (quick-260916-iex) */}
|
||||
{widget.widgetType === 'favorites' && (
|
||||
<FavoritesConfig
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -317,6 +327,90 @@ function ClockConfig({
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (cfg: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const t = useTranslations('widgets');
|
||||
const { showMonth, maxEvents, lookaheadDays } = resolveCalendarConfig(config);
|
||||
|
||||
const maxEventsOptions = Array.from({ length: CALENDAR_MAX_EVENTS_LIMIT + 1 }, (_, n) => n);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Monatsansicht ein-/ausblenden */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="calendar-show-month"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
checked={showMonth}
|
||||
onChange={(e) => onChange({ showMonth: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="calendar-show-month" className="text-sm text-foreground">
|
||||
{t('calendar.configShowMonth')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Anzahl der angezeigten Termine */}
|
||||
<div>
|
||||
<label htmlFor="calendar-max-events" className="mb-1 block text-sm text-foreground">
|
||||
{t('calendar.configMaxEvents')}
|
||||
</label>
|
||||
<select
|
||||
id="calendar-max-events"
|
||||
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
value={String(maxEvents)}
|
||||
onChange={(e) => onChange({ maxEvents: Number(e.target.value) })}
|
||||
>
|
||||
{maxEventsOptions.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n === 0
|
||||
? t('calendar.configMaxEventsNone')
|
||||
: n === 1
|
||||
? t('calendar.configMaxEventsOne')
|
||||
: t('calendar.configMaxEventsMany', { count: n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Zeitraum der angezeigten Termine */}
|
||||
<div>
|
||||
<label htmlFor="calendar-lookahead" className="mb-1 block text-sm text-foreground">
|
||||
{t('calendar.configLookahead')}
|
||||
</label>
|
||||
<select
|
||||
id="calendar-lookahead"
|
||||
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
value={String(lookaheadDays)}
|
||||
onChange={(e) => onChange({ lookaheadDays: Number(e.target.value) })}
|
||||
>
|
||||
{CALENDAR_LOOKAHEAD_OPTIONS.map((days) => (
|
||||
<option key={days} value={String(days)}>
|
||||
{t('calendar.configLookaheadOption', { days })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Hinweis auf die Kalenderquellenverwaltung */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('calendar.configSourcesHint')}{' '}
|
||||
<Link
|
||||
href="/settings/dashboard/calendar"
|
||||
className="text-primary underline hover:text-primary/90"
|
||||
>
|
||||
{t('calendar.configSourcesLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteConfig({
|
||||
config,
|
||||
onChange,
|
||||
@@ -330,7 +424,7 @@ function NoteConfig({
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="note-title" className="mb-1 block text-sm text-foreground">
|
||||
Title
|
||||
{t('note.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="note-title"
|
||||
@@ -342,3 +436,37 @@ function NoteConfig({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Favorites config (quick-260916-iex, Muster NoteConfig)
|
||||
function FavoritesConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (cfg: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const t = useTranslations('widgets');
|
||||
const rawTitle = typeof config.title === 'string' ? config.title : '';
|
||||
// Anzeige getrimmt (Leerraum-only-Titel sind fachlich "kein Titel"),
|
||||
// gesendet wird der rohe Tippwert (onChange trimmt nicht selbst).
|
||||
const title = rawTitle.trim() === '' ? '' : rawTitle;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="favorites-title"
|
||||
className="mb-1 block text-sm text-foreground"
|
||||
>
|
||||
{t('favorites.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="favorites-title"
|
||||
type="text"
|
||||
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
value={title}
|
||||
placeholder={t('favorites.titlePlaceholder')}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,22 @@
|
||||
"loading": "Laden...",
|
||||
"emptyNoSources": "Keine Kalenderquellen konfiguriert",
|
||||
"emptyNoEvents": "Keine anstehenden Termine",
|
||||
"nothingSelected": "Nichts zum Anzeigen ausgewählt",
|
||||
"monthPrev": "Zurück",
|
||||
"monthNext": "Weiter",
|
||||
"monthToday": "Zurück zum heutigen Monat",
|
||||
"upcomingTitle": "Nächste Termine",
|
||||
"tooltipMore": "Weitere Termine vorhanden",
|
||||
"allDay": "ganztägig",
|
||||
"configShowMonth": "Monatsansicht anzeigen",
|
||||
"configMaxEvents": "Anzahl Termine",
|
||||
"configMaxEventsNone": "Ausblenden",
|
||||
"configMaxEventsOne": "1 Termin",
|
||||
"configMaxEventsMany": "{count} Termine",
|
||||
"configLookahead": "Zeitraum",
|
||||
"configLookaheadOption": "Nächste {days} Tage",
|
||||
"configSourcesHint": "Kalenderquellen verwalten Sie unter",
|
||||
"configSourcesLink": "Einstellungen → Dashboard → Kalender",
|
||||
"connectionSuccess": "Verbindung erfolgreich",
|
||||
"connectionError": "Verbindung fehlgeschlagen. Bitte überprüfen Sie die URL und Zugangsdaten.",
|
||||
"addSource": "Quelle hinzufügen",
|
||||
@@ -218,6 +234,11 @@
|
||||
"formFieldDomain": "Domäne",
|
||||
"formFieldDomainHint": "Optional – nur nötig, wenn Ihr Exchange-Server die Anmeldung als DOMÄNE\\Benutzer erwartet.",
|
||||
"formFieldUrl": "Adresse (URL)",
|
||||
"formFieldUrlPlaceholderEws": "https://mail.firma.de/EWS/Exchange.asmx",
|
||||
"formFieldUrlPlaceholderGraph": "https://graph.microsoft.com/v1.0",
|
||||
"formFieldUrlPlaceholderCaldav": "https://caldav.firma.de/dav/",
|
||||
"formFieldUrlPlaceholderIcs": "https://…/kalender.ics",
|
||||
"formFieldUrlHintEws": "Vollständige EWS-Adresse inkl. /EWS/Exchange.asmx eintragen – nur der Servername reicht nicht.",
|
||||
"formFieldUsername": "Benutzername",
|
||||
"formFieldPassword": "Passwort",
|
||||
"formFieldColor": "Farbe",
|
||||
@@ -239,7 +260,8 @@
|
||||
"defaultTitle": "Notiz",
|
||||
"autosaveError": "Speichern fehlgeschlagen",
|
||||
"editMode": "Bearbeitungsmodus",
|
||||
"viewMode": "Vorschaumodus"
|
||||
"viewMode": "Vorschaumodus",
|
||||
"titleLabel": "Titel"
|
||||
},
|
||||
"calculator": {
|
||||
"name": "Taschenrechner",
|
||||
@@ -259,23 +281,9 @@
|
||||
"deleteButton": "Favorit löschen",
|
||||
"saveButton": "Speichern",
|
||||
"cancelButton": "Abbrechen",
|
||||
"error": "Fehler beim Laden der Favoriten"
|
||||
},
|
||||
"link": {
|
||||
"name": "Link",
|
||||
"description": "Einzelner Schnellzugriff-Link",
|
||||
"loading": "Link wird geladen...",
|
||||
"empty": "Noch kein Link gesetzt.",
|
||||
"addTitle": "Titel",
|
||||
"addUrl": "URL",
|
||||
"addButton": "Hinzufügen",
|
||||
"listView": "Liste",
|
||||
"gridView": "Kachel",
|
||||
"editButton": "Link bearbeiten",
|
||||
"deleteButton": "Link löschen",
|
||||
"saveButton": "Speichern",
|
||||
"cancelButton": "Abbrechen",
|
||||
"error": "Fehler beim Laden des Links"
|
||||
"error": "Fehler beim Laden der Favoriten",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)"
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stoppuhr",
|
||||
|
||||
@@ -208,6 +208,22 @@
|
||||
"loading": "Loading...",
|
||||
"emptyNoSources": "No calendar sources configured",
|
||||
"emptyNoEvents": "No upcoming events",
|
||||
"nothingSelected": "Nothing selected to display",
|
||||
"monthPrev": "Back",
|
||||
"monthNext": "Next",
|
||||
"monthToday": "Back to the current month",
|
||||
"upcomingTitle": "Upcoming events",
|
||||
"tooltipMore": "More events available",
|
||||
"allDay": "all day",
|
||||
"configShowMonth": "Show month view",
|
||||
"configMaxEvents": "Number of events",
|
||||
"configMaxEventsNone": "Hide",
|
||||
"configMaxEventsOne": "1 event",
|
||||
"configMaxEventsMany": "{count} events",
|
||||
"configLookahead": "Time range",
|
||||
"configLookaheadOption": "Next {days} days",
|
||||
"configSourcesHint": "Manage calendar sources under",
|
||||
"configSourcesLink": "Settings → Dashboard → Calendar",
|
||||
"connectionSuccess": "Connection successful",
|
||||
"connectionError": "Connection failed. Please check the URL and credentials.",
|
||||
"addSource": "Add source",
|
||||
@@ -218,6 +234,11 @@
|
||||
"formFieldDomain": "Domain",
|
||||
"formFieldDomainHint": "Optional – only needed if your Exchange server expects DOMAIN\\user for sign-in.",
|
||||
"formFieldUrl": "Address (URL)",
|
||||
"formFieldUrlPlaceholderEws": "https://mail.firma.de/EWS/Exchange.asmx",
|
||||
"formFieldUrlPlaceholderGraph": "https://graph.microsoft.com/v1.0",
|
||||
"formFieldUrlPlaceholderCaldav": "https://caldav.firma.de/dav/",
|
||||
"formFieldUrlPlaceholderIcs": "https://…/kalender.ics",
|
||||
"formFieldUrlHintEws": "Enter the full EWS address including /EWS/Exchange.asmx – the server name alone is not enough.",
|
||||
"formFieldUsername": "Username",
|
||||
"formFieldPassword": "Password",
|
||||
"formFieldColor": "Color",
|
||||
@@ -239,7 +260,8 @@
|
||||
"defaultTitle": "Note",
|
||||
"autosaveError": "Save failed",
|
||||
"editMode": "Edit mode",
|
||||
"viewMode": "Preview mode"
|
||||
"viewMode": "Preview mode",
|
||||
"titleLabel": "Title"
|
||||
},
|
||||
"calculator": {
|
||||
"name": "Calculator",
|
||||
@@ -259,23 +281,9 @@
|
||||
"deleteButton": "Delete favorite",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel",
|
||||
"error": "Error loading favorites"
|
||||
},
|
||||
"link": {
|
||||
"name": "Link",
|
||||
"description": "Single quick-access link",
|
||||
"loading": "Loading link...",
|
||||
"empty": "No link set yet.",
|
||||
"addTitle": "Title",
|
||||
"addUrl": "URL",
|
||||
"addButton": "Add",
|
||||
"listView": "List",
|
||||
"gridView": "Tile",
|
||||
"editButton": "Edit link",
|
||||
"deleteButton": "Delete link",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel",
|
||||
"error": "Error loading link"
|
||||
"error": "Error loading favorites",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)"
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stopwatch",
|
||||
|
||||
@@ -73,14 +73,13 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
|---|---|
|
||||
| Uhr | Zeigt die aktuelle Uhrzeit an (optional mit Datum). Die Uhrzeit wächst und schrumpft mit der Kachel; wer eine feste Größe möchte, stellt sie unter Einstellungen > Dashboard als Schriftgröße in Punkt ein |
|
||||
| Suchleiste | Schnellsuche im Web über frei konfigurierbare Suchanbieter |
|
||||
| Kalender | Zeigt kommende Termine aus Ihren verbundenen Kalenderquellen |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung |
|
||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht |
|
||||
| Link | Schnellzugriff auf genau einen einzelnen Link |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Favoriten und Link gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, hinterlegte Links) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
|
||||
## Marktplatz
|
||||
|
||||
@@ -150,7 +149,7 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
|
||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste.
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel.
|
||||
|
||||
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user