feat(web): Uebersetzungsschluessel + Hilfsmodul calendar-month.ts fuer Kalender-Widget

- 16 neue Uebersetzungsschluessel unter widgets.calendar in de.json/en.json (Monatsnavigation, Naechste-Termine-Block, Einstellungsfelder)
- Neues Hilfsmodul calendar-month.ts: resolveCalendarConfig, buildCalendarDays, groupEventsByDate, computeFetchWindow, selectUpcomingEvents, Formatierungsfunktionen (quick-260916-htc)
- 8 Unit-Tests fuer das Hilfsmodul, Umlaut-Waechter weiterhin 3/3 gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:06:15 +02:00
parent 785c791dd4
commit 0858102439
4 changed files with 396 additions and 0 deletions
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/calendar-api';
import {
addDays,
buildCalendarDays,
computeFetchWindow,
dateKey,
formatEventDate,
formatEventTime,
formatMonthLabel,
gridStartFor,
groupEventsByDate,
resolveCalendarConfig,
selectUpcomingEvents,
startOfLocalDay,
} from './calendar-month';
/** Kleine Fabrik fuer Testtermine (Muster aus dem Plan). */
function ev(id: string, start: Date, end: Date, extra?: Partial<CalendarEvent>): CalendarEvent {
return {
id,
sourceId: 's1',
title: id,
start: start.toISOString(),
end: end.toISOString(),
allDay: false,
...extra,
};
}
describe('calendar-month', () => {
it('Test 1: buildCalendarDays liefert 42 Zellen ab Montag mit korrektem Monatsflag und heutigem Tag', () => {
const days = buildCalendarDays(new Date(2026, 6, 1), new Map(), new Date(2026, 6, 15));
expect(days).toHaveLength(42);
expect(days[0].key).toBe('2026-06-29');
expect(days[0].inCurrentMonth).toBe(false);
expect(days[2].key).toBe('2026-07-01');
expect(days[2].inCurrentMonth).toBe(true);
expect(days[41].key).toBe('2026-08-09');
const todayCells = days.filter((d) => d.isToday);
expect(todayCells).toHaveLength(1);
expect(todayCells[0].key).toBe('2026-07-15');
const outside = days.filter((d) => !d.inCurrentMonth);
expect(outside).toHaveLength(11);
});
it('Test 2: groupEventsByDate gruppiert nach lokalem Startdatum, buildCalendarDays uebernimmt die Zaehlung', () => {
const events = [
ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
ev('e3', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)),
];
const grouped = groupEventsByDate(events);
expect(grouped.size).toBe(2);
expect(grouped.get('2026-07-20')).toHaveLength(2);
const days = buildCalendarDays(new Date(2026, 6, 1), grouped);
const day20 = days.find((d) => d.key === '2026-07-20');
expect(day20?.events.length).toBe(2);
});
it('Test 3: resolveCalendarConfig klemmt ungueltige Werte auf die Vorgaben/Grenzen zurueck', () => {
expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 });
expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false);
expect(resolveCalendarConfig({ showMonth: 'nein' }).showMonth).toBe(true);
expect(resolveCalendarConfig({ maxEvents: 99 }).maxEvents).toBe(10);
expect(resolveCalendarConfig({ maxEvents: -1 }).maxEvents).toBe(0);
expect(resolveCalendarConfig({ maxEvents: 4.7 }).maxEvents).toBe(4);
expect(resolveCalendarConfig({ maxEvents: '5' }).maxEvents).toBe(3);
expect(resolveCalendarConfig({ lookaheadDays: 45 }).lookaheadDays).toBe(30);
expect(resolveCalendarConfig({ lookaheadDays: 90 }).lookaheadDays).toBe(90);
});
it('Test 4: computeFetchWindow deckt Raster und Vorschau-Zeitraum als Tagesgrenzen ab', () => {
const w1 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 6, 15, 10, 30));
expect(w1.from.getTime()).toBe(new Date(2026, 5, 29).getTime());
expect(w1.to.getTime()).toBe(new Date(2026, 7, 14).getTime());
const w2 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 4, 1, 8, 0));
expect(w2.from.getTime()).toBe(new Date(2026, 4, 1).getTime());
expect(w2.to.getTime()).toBe(new Date(2026, 7, 10).getTime());
const w3 = computeFetchWindow(new Date(2026, 6, 1), 90, new Date(2026, 6, 15, 10, 30));
expect(w3.to.getTime()).toBe(new Date(2026, 9, 13).getTime());
for (const w of [w1, w2, w3]) {
for (const d of [w.from, w.to]) {
expect(d.getHours()).toBe(0);
expect(d.getMinutes()).toBe(0);
expect(d.getSeconds()).toBe(0);
expect(d.getMilliseconds()).toBe(0);
}
}
});
it('Test 5: selectUpcomingEvents filtert Vergangenheit/Horizont, sortiert nach Start und kuerzt auf maxEvents', () => {
const now = new Date(2026, 6, 15, 10, 0);
const events = [
ev('gestern', new Date(2026, 6, 14, 11, 0), new Date(2026, 6, 14, 12, 0)),
ev('läuft', new Date(2026, 6, 15, 9, 0), new Date(2026, 6, 15, 11, 0)),
ev('heute 15', new Date(2026, 6, 15, 15, 0), new Date(2026, 6, 15, 16, 0)),
ev('in 5 Tagen', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
ev('in 10 Tagen', new Date(2026, 6, 25, 9, 0), new Date(2026, 6, 25, 10, 0)),
];
const result = selectUpcomingEvents(events, 7, 2, now);
expect(result.map((e) => e.title)).toEqual(['läuft', 'heute 15']);
expect(selectUpcomingEvents(events, 7, 0, now)).toEqual([]);
});
it('Test 6: formatEventDate/formatEventTime/formatMonthLabel formatieren de-DE', () => {
const timed = ev('t1', new Date(2026, 6, 20, 9, 5), new Date(2026, 6, 20, 10, 0));
const timedText = formatEventDate(timed);
expect(timedText).toMatch(/20\.07\./);
expect(timedText).toMatch(/09:05/);
const allDay = ev('t2', new Date(2026, 6, 20, 0, 0), new Date(2026, 6, 20, 23, 59), { allDay: true });
const allDayText = formatEventDate(allDay);
expect(allDayText).toMatch(/20\.07\./);
expect(allDayText).not.toMatch(/\d{2}:\d{2}/);
expect(formatMonthLabel(new Date(2026, 6, 1))).toBe('Juli 2026');
expect(formatEventTime(new Date(2026, 6, 20, 9, 5).toISOString())).toBe('09:05');
});
it('Test 7: gridStartFor findet den Montag am oder vor dem 1. des Monats', () => {
expect(gridStartFor(new Date(2026, 7, 1)).getTime()).toBe(new Date(2026, 6, 27).getTime());
expect(gridStartFor(new Date(2026, 5, 1)).getTime()).toBe(new Date(2026, 5, 1).getTime());
});
it('Zusatz: dateKey/startOfLocalDay/addDays arbeiten lokal ohne UTC-Versatz', () => {
expect(dateKey(new Date(2026, 6, 5))).toBe('2026-07-05');
const start = startOfLocalDay(new Date(2026, 6, 5, 14, 30, 15));
expect(start.getHours()).toBe(0);
expect(addDays(new Date(2026, 6, 30), 2).getDate()).toBe(1);
});
});