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) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 14:37:01 +02:00
parent 4ddadc63ab
commit 7429c5bd8d
3 changed files with 139 additions and 23 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert ### Geändert
- Kalenderquellen: Adressfeld zeigt je Typ ein Beispiel; bei Exchange EWS Hinweis auf `/EWS/Exchange.asmx` - 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 ### Entfernt
@@ -207,6 +207,102 @@ describe('CalendarWidget', () => {
expect(within(tooltip).getAllByTestId('tooltip-color-dot')).toHaveLength(1); 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 () => { it('Test 4: Tooltip erscheint per Portal im body und zeigt bis zu 5 Termine plus Hinweis', async () => {
mockFetchEvents.mockResolvedValue([ mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
@@ -8,6 +8,7 @@ import { fetchEvents, fetchSources } from '@/lib/calendar-api';
import type { CalendarEvent } from '@/lib/calendar-api'; import type { CalendarEvent } from '@/lib/calendar-api';
import { import {
buildCalendarDays, buildCalendarDays,
buildDayBadges,
computeFetchWindow, computeFetchWindow,
formatEventDate, formatEventDate,
formatEventTime, formatEventTime,
@@ -30,9 +31,11 @@ const TOOLTIP_EDGE_PX = 4;
* *
* Oben ein Monatsraster (Nav-Zeile Zurueck/Monat/Weiter, Wochentagskopf, * Oben ein Monatsraster (Nav-Zeile Zurueck/Monat/Weiter, Wochentagskopf,
* 42 Zellen ab Montag, Faehrmonatstage gedaempft, heutiger Tag hervorgehoben, * 42 Zellen ab Montag, Faehrmonatstage gedaempft, heutiger Tag hervorgehoben,
* Zaehl-Plakette an Tagen mit Terminen in der Farbe des Kalenders des * Zaehl-Plakette an Tagen mit Terminen in der Farbe des Kalenders (ein
* fruehesten Termins des Tages (sonst Akzentfarbe), Tooltip beim Ueberfahren * Kalender am Tag); bei mehreren Kalendern am selben Tag je ein kleiner
* mit Farbpunkt je Zeile (quick-260916-jvj)), darunter * 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 * der Block "Naechste Termine". Beide Bloecke lassen sich unabhaengig
* ein-/ausblenden (Config `showMonth`/`maxEvents`, siehe calendar-month.ts). * ein-/ausblenden (Config `showMonth`/`maxEvents`, siehe calendar-month.ts).
* *
@@ -219,6 +222,8 @@ export function CalendarWidget({ config }: WidgetProps) {
<div className="grid grid-cols-7 gap-px"> <div className="grid grid-cols-7 gap-px">
{days.map((day) => { {days.map((day) => {
const hasEvents = day.events.length > 0; const hasEvents = day.events.length > 0;
const badges = buildDayBadges(day.events);
const single = badges.length === 1;
const cellClass = [ 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', '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.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60',
@@ -243,25 +248,40 @@ export function CalendarWidget({ config }: WidgetProps) {
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<span>{day.date.getDate()}</span> <span>{day.date.getDate()}</span>
{hasEvents && {hasEvents && (
(() => { // Wrapper traegt die Positionierung, die einzelnen Kreise nicht
const badgeColor = day.events[0]?.color; // mehr (mehrere Kreise stehen sonst uebereinander).
const badgeClass = [ <span
'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', data-testid="calendar-day-badges"
badgeColor ? 'text-white' : 'bg-primary text-primary-foreground', className="absolute bottom-px right-px flex items-end gap-px"
] >
.filter(Boolean) {badges.map((badge) => {
.join(' '); const badgeClass = [
return ( 'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
<span single
data-testid="calendar-day-count" ? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
className={badgeClass} : 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
style={badgeColor ? { backgroundColor: badgeColor } : undefined} badge.rest
> ? 'bg-muted-foreground text-background'
{day.events.length} : badge.color
</span> ? '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>
); );
})} })}