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,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);
}