From 4ddadc63ab635e2f9e499179b3269d17202cd85a Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 14:34:49 +0200 Subject: [PATCH] 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) --- .../dashboard/widgets/calendar-month.test.ts | 97 +++++++++++++++++++ .../dashboard/widgets/calendar-month.ts | 71 ++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.test.ts b/apps/web/src/components/dashboard/widgets/calendar-month.test.ts index 1978338..d25c3b8 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-month.test.ts +++ b/apps/web/src/components/dashboard/widgets/calendar-month.test.ts @@ -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([]); + }); }); diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.ts b/apps/web/src/components/dashboard/widgets/calendar-month.ts index 0dfe2e4..7d46865 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-month.ts +++ b/apps/web/src/components/dashboard/widgets/calendar-month.ts @@ -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(); + 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,