From 7429c5bd8d969eac9749ec8a8ba691516cd9e312 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 14:37:01 +0200 Subject: [PATCH] feat(web): Kalender-Widget zeigt je Kalender einen kleinen Kreis am Tag; Changelog - Plaketten-Wrapper calendar-day-badges rendert buildDayBadges(day.events) statt der alten Einzel-Plakette - Ein Kalender: unveraendert eine Plakette in Kalenderfarbe/Akzentfarbe (Tests 3/3b/3c bleiben gruen) - Zwei/drei Kalender: je ein kleiner Kreis pro Kalender in Startreihenfolge (Test 3d) - Vier oder mehr Kalender: zwei Kreise plus grauer Restkreis bg-muted-foreground/text-background mit Summe (Test 3e) - CHANGELOG-Stichpunkt erweitert (mehrere Kalender am selben Tag) Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- .../widgets/calendar-widget.test.tsx | 96 +++++++++++++++++++ .../dashboard/widgets/calendar-widget.tsx | 64 ++++++++----- 3 files changed, 139 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a22a5..925b4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### 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 +- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis ### Entfernt diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx index 88d6b85..e5450bf 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx @@ -207,6 +207,102 @@ describe('CalendarWidget', () => { 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(); + + 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(); + + 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)), diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx index 85c81f4..58c2e44 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx @@ -8,6 +8,7 @@ import { fetchEvents, fetchSources } from '@/lib/calendar-api'; import type { CalendarEvent } from '@/lib/calendar-api'; import { buildCalendarDays, + buildDayBadges, computeFetchWindow, formatEventDate, formatEventTime, @@ -30,9 +31,11 @@ const TOOLTIP_EDGE_PX = 4; * * 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 des - * fruehesten Termins des Tages (sonst Akzentfarbe), Tooltip beim Ueberfahren - * mit Farbpunkt je Zeile (quick-260916-jvj)), darunter + * 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). * @@ -219,6 +222,8 @@ export function CalendarWidget({ config }: WidgetProps) {
{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', @@ -243,25 +248,40 @@ export function CalendarWidget({ config }: WidgetProps) { onMouseLeave={() => setHover(null)} > {day.date.getDate()} - {hasEvents && - (() => { - const badgeColor = day.events[0]?.color; - const badgeClass = [ - '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', - badgeColor ? 'text-white' : 'bg-primary text-primary-foreground', - ] - .filter(Boolean) - .join(' '); - return ( - - {day.events.length} - - ); - })()} + {hasEvents && ( + // Wrapper traegt die Positionierung, die einzelnen Kreise nicht + // mehr (mehrere Kreise stehen sonst uebereinander). + + {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 ( + + {badge.count} + + ); + })} + + )}
); })}