4ddadc63ab
- 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>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
/**
|
|
* 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. 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';
|
|
|
|
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). Jede Tagesgruppe
|
|
* wird nach Start aufsteigend sortiert (quick-260916-jvj), damit der erste
|
|
* Eintrag zuverlaessig der fruehesten Termin des Tages ist.
|
|
*/
|
|
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]);
|
|
}
|
|
for (const dayEvents of groupedEvents.values()) {
|
|
dayEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
}
|
|
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,
|
|
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);
|
|
}
|