feat(web): Kalender-Tag nach Kalender gruppieren, Kreise je Kalender berechnen (calendar-month)

- groupDayBySource gruppiert Tagestermine nach sourceId (nicht Farbe), Reihenfolge = erstes Auftreten, Farbe = Farbe des ersten Termins der Gruppe
- buildDayBadges baut daraus bis zu drei Plaketten-Kreise, ab dem vierten Kalender einen grauen Restkreis mit Summe
- Tests 8/9 in calendar-month.test.ts (TDD: RED bestaetigt vor Implementierung)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 14:34:49 +02:00
parent 45b20a9fd4
commit 4ddadc63ab
2 changed files with 168 additions and 0 deletions
@@ -17,6 +17,12 @@
* 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';
@@ -120,6 +126,71 @@ export function groupEventsByDate(events: CalendarEvent[]): Map<string, Calendar
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,