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);
});
});
@@ -0,0 +1,222 @@
/**
* calendar-month — reine Hilfsfunktionen fuer das Kalender-Widget
* (quick-260916-htc).
*
* Kein React, keine DOM-Zugriffe: dieses Modul wird sowohl vom Widget
* (calendar-widget.tsx) als auch vom Einstellungsfeld
* (widget-settings-panel.tsx, `CalendarConfig`) importiert, damit beide
* Stellen dieselben Vorgaben/Grenzen anwenden (Muster clock-font-size.ts,
* T-BWO-01 uebertragen auf T-HTC-01).
*
* Vorlage fuer Monatsraster/Terminliste: das alte persoenliche Dashboard
* des Anwenders (user-files/personal-dashboard/src/app/page.tsx).
*
* Starttag-Regel: mehrtaegige und ganztaegige Termine werden im Monatsraster
* bewusst NUR am Starttag gezaehlt/angezeigt (`groupEventsByDate` gruppiert
* ausschliesslich nach `event.start`) — eine Terminleiste ueber mehrere Tage
* ist nicht Teil dieses Auftrags.
*/
import type { CalendarEvent } from '@/lib/calendar-api';
export const CALENDAR_LOOKAHEAD_OPTIONS: readonly number[] = [7, 14, 30, 60, 90];
export const CALENDAR_MAX_EVENTS_LIMIT = 10;
export const CALENDAR_DEFAULTS = { showMonth: true, maxEvents: 3, lookaheadDays: 30 } as const;
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
export interface CalendarWidgetConfig {
showMonth: boolean;
maxEvents: number;
lookaheadDays: number;
}
/**
* Loest die Kalender-Widget-Konfiguration aus dem rohen `config`-JSON auf und
* klemmt ungueltige Werte auf die Vorgaben zurueck (T-HTC-01). API prueft
* Config-Felder nicht (`@IsObject()`), deshalb entscheidet ALLEIN diese
* Funktion, was ein gueltiger Wert ist.
*/
export function resolveCalendarConfig(config: Record<string, unknown>): CalendarWidgetConfig {
const showMonth = config.showMonth !== false;
let maxEvents: number = CALENDAR_DEFAULTS.maxEvents;
if (typeof config.maxEvents === 'number' && Number.isFinite(config.maxEvents)) {
maxEvents = Math.min(CALENDAR_MAX_EVENTS_LIMIT, Math.max(0, Math.trunc(config.maxEvents)));
}
let lookaheadDays: number = CALENDAR_DEFAULTS.lookaheadDays;
if (
typeof config.lookaheadDays === 'number' &&
CALENDAR_LOOKAHEAD_OPTIONS.includes(config.lookaheadDays)
) {
lookaheadDays = config.lookaheadDays;
}
return { showMonth, maxEvents, lookaheadDays };
}
/** Lokaler Tagesschluessel `YYYY-MM-DD` (kein UTC-Versatz). */
export function dateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** Kopie von `date` auf die lokale Tagesgrenze 00:00:00.000 gesetzt. */
export function startOfLocalDay(date: Date): Date {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
/**
* Kopie von `date`, um `days` Tage verschoben. Rechnet ueber `setDate`, NICHT
* ueber Millisekunden — das haelt die lokale Wanduhrzeit ueber
* Sommerzeitwechsel hinweg stabil.
*/
export function addDays(date: Date, days: number): Date {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
/** Montag am oder vor dem 1. des Monats von `monthDate`, 00:00 lokal. */
export function gridStartFor(monthDate: Date): Date {
const firstDayOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7;
const gridStart = new Date(firstDayOfMonth);
gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset);
gridStart.setHours(0, 0, 0, 0);
return gridStart;
}
export interface CalendarDay {
key: string;
date: Date;
inCurrentMonth: boolean;
isToday: boolean;
events: CalendarEvent[];
}
/**
* Gruppiert Termine nach lokalem Startdatum. Mehrtaegige/ganztaegige Termine
* zaehlen bewusst nur am Starttag (siehe Kopfkommentar).
*/
export function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groupedEvents = new Map<string, CalendarEvent[]>();
for (const event of events) {
const key = dateKey(new Date(event.start));
const existingEvents = groupedEvents.get(key) ?? [];
groupedEvents.set(key, [...existingEvents, event]);
}
return groupedEvents;
}
/** 42 Rasterzellen ab `gridStartFor(monthDate)`, Montag-basiert. */
export function buildCalendarDays(
monthDate: Date,
eventsByDate: Map<string, CalendarEvent[]>,
today: Date = new Date(),
): CalendarDay[] {
const month = monthDate.getMonth();
const gridStart = gridStartFor(monthDate);
const todayKey = dateKey(today);
const days: CalendarDay[] = [];
for (let index = 0; index < 42; index += 1) {
const date = addDays(gridStart, index);
const key = dateKey(date);
days.push({
key,
date,
inCurrentMonth: date.getMonth() === month,
isToday: key === todayKey,
events: eventsByDate.get(key) ?? [],
});
}
return days;
}
/**
* Ladefenster fuer `fetchEvents(from, to)`: deckt sowohl das 42-Tage-Raster
* als auch den Vorschau-Zeitraum (lookaheadDays ab heute) ab. Alle vier
* Zwischenwerte sind Tagesgrenzen 00:00 lokal, deshalb ist `toISOString()`
* innerhalb eines Tages konstant — das haelt den Backend-Cache-Schluessel
* (`${userId}:${from.toISOString()}:${to.toISOString()}`) ueber den
* 5-Minuten-Refresh hinweg stabil.
*/
export function computeFetchWindow(
monthDate: Date,
lookaheadDays: number,
now: Date = new Date(),
): { from: Date; to: Date } {
const gridStart = gridStartFor(monthDate);
const gridEnd = addDays(gridStart, 42);
const todayStart = startOfLocalDay(now);
const lookEnd = addDays(todayStart, lookaheadDays);
const from = gridStart.getTime() <= todayStart.getTime() ? gridStart : todayStart;
const to = gridEnd.getTime() >= lookEnd.getTime() ? gridEnd : lookEnd;
return { from, to };
}
/**
* Waehlt die naechsten anstehenden Termine (laufend oder in der Zukunft,
* innerhalb `lookaheadDays`), sortiert nach Start aufsteigend, gekuerzt auf
* `maxEvents`.
*/
export function selectUpcomingEvents(
events: CalendarEvent[],
lookaheadDays: number,
maxEvents: number,
now: Date = new Date(),
): CalendarEvent[] {
const horizon = addDays(startOfLocalDay(now), lookaheadDays).getTime();
const nowTime = now.getTime();
return events
.filter((event) => {
const endTime = new Date(event.end).getTime();
const startTime = new Date(event.start).getTime();
return endTime >= nowTime && startTime < horizon;
})
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, maxEvents);
}
/** Datum+Uhrzeit (bzw. nur Datum bei ganztaegig) im Format „Mi., 20.07., 09:05“. */
export function formatEventDate(event: CalendarEvent): string {
const start = new Date(event.start);
if (event.allDay) {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(start);
}
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(start);
}
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
/** Monat+Jahr, „Juli 2026“. */
export function formatMonthLabel(date: Date): string {
return new Intl.DateTimeFormat('de-DE', {
month: 'long',
year: 'numeric',
}).format(date);
}