feat(web): Kalender-Plakette in der Farbe des Kalenders, Farbpunkt je Tooltip-Zeile

- groupEventsByDate sortiert jede Tagesgruppe jetzt nach Start aufsteigend
- Zaehl-Plakette im Monatsraster traegt die Farbe des Kalenders des fruehesten Termins (weisse Schrift), ohne Farbe unveraendert bg-primary
- Tooltip-Zeilen bekommen denselben Farbpunkt wie die Liste "Naechste Termine" (Rueckfall var(--muted-foreground))
- 3 neue Tests: calendar-month Test 2b, calendar-widget Test 3b/3c

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 14:25:21 +02:00
parent 5313fbc019
commit 1e4ec30190
4 changed files with 108 additions and 11 deletions
@@ -63,6 +63,16 @@ describe('calendar-month', () => {
expect(day20?.events.length).toBe(2); 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', () => { it('Test 3: resolveCalendarConfig klemmt ungueltige Werte auf die Vorgaben/Grenzen zurueck', () => {
expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 }); expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 });
expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false); expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false);
@@ -14,7 +14,9 @@
* Starttag-Regel: mehrtaegige und ganztaegige Termine werden im Monatsraster * Starttag-Regel: mehrtaegige und ganztaegige Termine werden im Monatsraster
* bewusst NUR am Starttag gezaehlt/angezeigt (`groupEventsByDate` gruppiert * bewusst NUR am Starttag gezaehlt/angezeigt (`groupEventsByDate` gruppiert
* ausschliesslich nach `event.start`) — eine Terminleiste ueber mehrere Tage * ausschliesslich nach `event.start`) — eine Terminleiste ueber mehrere Tage
* ist nicht Teil dieses Auftrags. * 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).
*/ */
import type { CalendarEvent } from '@/lib/calendar-api'; import type { CalendarEvent } from '@/lib/calendar-api';
@@ -101,7 +103,9 @@ export interface CalendarDay {
/** /**
* Gruppiert Termine nach lokalem Startdatum. Mehrtaegige/ganztaegige Termine * Gruppiert Termine nach lokalem Startdatum. Mehrtaegige/ganztaegige Termine
* zaehlen bewusst nur am Starttag (siehe Kopfkommentar). * 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[]> { export function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groupedEvents = new Map<string, CalendarEvent[]>(); const groupedEvents = new Map<string, CalendarEvent[]>();
@@ -110,6 +114,9 @@ export function groupEventsByDate(events: CalendarEvent[]): Map<string, Calendar
const existingEvents = groupedEvents.get(key) ?? []; const existingEvents = groupedEvents.get(key) ?? [];
groupedEvents.set(key, [...existingEvents, event]); 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; return groupedEvents;
} }
@@ -146,6 +146,67 @@ describe('CalendarWidget', () => {
expect(within(day22).queryByTestId('calendar-day-count')).toBeNull(); 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 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)),
@@ -30,7 +30,9 @@ 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, Tooltip beim Ueberfahren), darunter * 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
* 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).
* *
@@ -241,14 +243,25 @@ export function CalendarWidget({ config }: WidgetProps) {
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<span>{day.date.getDate()}</span> <span>{day.date.getDate()}</span>
{hasEvents && ( {hasEvents &&
<span (() => {
data-testid="calendar-day-count" const badgeColor = day.events[0]?.color;
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" 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',
{day.events.length} badgeColor ? 'text-white' : 'bg-primary text-primary-foreground',
</span> ]
)} .filter(Boolean)
.join(' ');
return (
<span
data-testid="calendar-day-count"
className={badgeClass}
style={badgeColor ? { backgroundColor: badgeColor } : undefined}
>
{day.events.length}
</span>
);
})()}
</div> </div>
); );
})} })}
@@ -313,6 +326,12 @@ export function CalendarWidget({ config }: WidgetProps) {
> >
{hoverEvents.slice(0, 5).map((event) => ( {hoverEvents.slice(0, 5).map((event) => (
<div key={event.id} className="flex gap-2"> <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"> <span className="shrink-0 tabular-nums text-muted-foreground">
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)} {event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
</span> </span>