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
@@ -3,12 +3,14 @@ import type { CalendarEvent } from '@/lib/calendar-api';
import {
addDays,
buildCalendarDays,
buildDayBadges,
computeFetchWindow,
dateKey,
formatEventDate,
formatEventTime,
formatMonthLabel,
gridStartFor,
groupDayBySource,
groupEventsByDate,
resolveCalendarConfig,
selectUpcomingEvents,
@@ -149,4 +151,99 @@ describe('calendar-month', () => {
expect(start.getHours()).toBe(0);
expect(addDays(new Date(2026, 6, 30), 2).getDate()).toBe(1);
});
it('Test 8: groupDayBySource gruppiert nach sourceId in Reihenfolge des ersten Auftretens', () => {
const day = new Date(2026, 6, 20, 9, 0);
const hourLater = new Date(2026, 6, 20, 10, 0);
// (a) drei Termine, alle s1 mit derselben Farbe
const sameSource = [
ev('e1', day, hourLater, { sourceId: 's1', color: '#c44040' }),
ev('e2', day, hourLater, { sourceId: 's1', color: '#c44040' }),
ev('e3', day, hourLater, { sourceId: 's1', color: '#c44040' }),
];
expect(groupDayBySource(sameSource)).toEqual([{ sourceId: 's1', color: '#c44040', count: 3 }]);
// (b) Reihenfolge s2, s1, s2 wird NICHT umsortiert
const mixedOrder = [
ev('e1', day, hourLater, { sourceId: 's2' }),
ev('e2', day, hourLater, { sourceId: 's1' }),
ev('e3', day, hourLater, { sourceId: 's2' }),
];
const groupedMixed = groupDayBySource(mixedOrder);
expect(groupedMixed.map((g) => g.sourceId)).toEqual(['s2', 's1']);
expect(groupedMixed[0].count).toBe(2);
expect(groupedMixed[1].count).toBe(1);
// (c) Termine ohne color -> color der Gruppe ist undefined
const noColor = [ev('e1', day, hourLater, { sourceId: 's1', color: undefined })];
expect(groupDayBySource(noColor)[0].color).toBeUndefined();
// (d) zwei Kalender mit derselben Farbe bleiben zwei Gruppen (Gruppierung nach sourceId)
const sameColorDifferentSource = [
ev('e1', day, hourLater, { sourceId: 's1', color: '#123456' }),
ev('e2', day, hourLater, { sourceId: 's2', color: '#123456' }),
];
expect(groupDayBySource(sameColorDifferentSource)).toHaveLength(2);
// (e) Farbe der Gruppe ist die Farbe des ERSTEN Termins der Gruppe
const laterColorChange = [
ev('e1', day, hourLater, { sourceId: 's1', color: '#111111' }),
ev('e2', day, hourLater, { sourceId: 's1', color: '#222222' }),
];
expect(groupDayBySource(laterColorChange)).toEqual([
{ sourceId: 's1', color: '#111111', count: 2 },
]);
// (f) leeres Array
expect(groupDayBySource([])).toEqual([]);
});
it('Test 9: buildDayBadges liefert je Kalender einen Eintrag, ab dem vierten einen grauen Restkreis', () => {
const day = new Date(2026, 6, 20);
const single = [ev('e1', day, day, { sourceId: 's1', color: '#111111' })];
expect(buildDayBadges(single)).toEqual([
{ key: 's1', color: '#111111', count: 1, rest: false },
]);
const two = [
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
ev('e2', day, day, { sourceId: 's2', color: '#222222' }),
];
expect(buildDayBadges(two)).toEqual([
{ key: 's1', color: '#111111', count: 1, rest: false },
{ key: 's2', color: '#222222', count: 1, rest: false },
]);
const three = [
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
ev('e2', day, day, { sourceId: 's2', color: '#222222' }),
ev('e3', day, day, { sourceId: 's3', color: '#333333' }),
];
const threeResult = buildDayBadges(three);
expect(threeResult).toHaveLength(3);
expect(threeResult.every((b) => b.rest === false)).toBe(true);
const four = [
ev('e1', day, day, { sourceId: 's1', color: '#111111' }),
ev('e2a', day, day, { sourceId: 's2', color: '#222222' }),
ev('e2b', day, day, { sourceId: 's2', color: '#222222' }),
ev('e3', day, day, { sourceId: 's3', color: '#333333' }),
ev('e4a', day, day, { sourceId: 's4', color: '#444444' }),
ev('e4b', day, day, { sourceId: 's4', color: '#444444' }),
ev('e4c', day, day, { sourceId: 's4', color: '#444444' }),
];
expect(buildDayBadges(four)).toEqual([
{ key: 's1', color: '#111111', count: 1, rest: false },
{ key: 's2', color: '#222222', count: 2, rest: false },
{ key: '__rest__', color: undefined, count: 4, rest: true },
]);
expect(buildDayBadges(four, 2)).toEqual([
{ key: 's1', color: '#111111', count: 1, rest: false },
{ key: '__rest__', color: undefined, count: 6, rest: true },
]);
expect(buildDayBadges([])).toEqual([]);
});
});
@@ -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,