Files
tessera-ctl/.planning/quick/260916-htc-kalender-widget-nach-vorbild-personal-da/260916-htc-PLAN.md
T

51 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260916-htc 01 execute 1
true
QUICK-260916-HTC
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
tokens raw_tokens tasks confidence
75000 75000 3 low
truths artifacts key_links
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.
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
`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
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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.
Task 1: Übersetzungsschlüssel de/en + reines Hilfsmodul calendar-month.ts mit Unit-Tests 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 - 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) 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.). 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): 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` (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, 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 ``; 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. 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 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. Task 2: Kalender-Widget neu bauen (Monatsraster + Tooltip-Portal + Nächste Termine), Komponententest neu schreiben, Mindestgröße 6×8 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/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 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). 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 `
`: - Wenn `showMonth`: `
` mit · Nav-Zeile `
`: drei `` 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 `
` aus `WEEKDAY_LABELS`. · Raster `
` (keine ARIA-Grid-Rollen, schlichte divs) mit 42 Zellen `
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: `{day.date.getDate()}` und bei Terminen `{day.events.length}`. - Wenn `maxEvents > 0`: `
` mit `

{t('calendar.upcomingTitle')}

` und entweder `

{t('calendar.emptyNoEvents')}

` (leer) oder `
    ` mit `
  • `: Farbpunkt `