import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; dayjs.extend(utc); dayjs.extend(timezone); export const TYPES = ['work', 'vacation', 'sick', 'overtime_compensation', 'holiday', 'other'] as const; export type TimeType = typeof TYPES[number]; export type Period = { started_at: string; ended_at: string | null; }; export type Span = Period & { id?: string; user_id?: string; type: TimeType; description: string; status: string; version?: number; date_from?: string | null; date_to?: string | null; timezone: string; breaks: Period[]; projects: (Period & { project_id: number; notes: string; })[]; }; export class TimeError extends Error { constructor(public statusCode: number, message: string) { super(message); } } export const fail = (message: string, status = 400): never => { throw new TimeError(status, message); }; export function instant(value: unknown): string { if (typeof value !== 'string' || !/T.*(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(Date.parse(value))) fail('Zeitpunkt mit Zeitzone erforderlich.'); dateOnly((value as string).slice(0, 10)); return new Date(value as string).toISOString(); } export function dateOnly(value: unknown): string { if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) fail('Ungültiges Datum.'); return value as string; } export function midnight(date: string, zone = 'Europe/Berlin') { return dayjs.tz(dateOnly(date), zone).toISOString(); } export function nextDay(date: string) { return dayjs.utc(dateOnly(date)).add(1, 'day').format('YYYY-MM-DD'); } export function range(from: string, to: string, zone = 'Europe/Berlin') { dateOnly(from); dateOnly(to); if (from > to || dayjs.utc(to).diff(dayjs.utc(from), 'day') > 3660) fail('Zeitraum muss zwischen 1 und 3661 Tagen liegen.'); return { started_at: midnight(from, zone), ended_at: midnight(nextDay(to), zone) }; } const end = (p: Period) => p.ended_at ? Date.parse(p.ended_at) : Infinity; export const overlaps = (a: Period, b: Period) => Date.parse(a.started_at) < end(b) && Date.parse(b.started_at) < end(a); export const minutes = (p: Period, window: Period) => Math.max(0, Math.min(end(p), end(window)) - Math.max(Date.parse(p.started_at), Date.parse(window.started_at))) / 60000; export function normalizeSpan(input: any): Span { if (!input || !TYPES.includes(input.type)) fail('Ungültige Zeitart.'); const zone = input.timezone || 'Europe/Berlin'; try { new Intl.DateTimeFormat('de', { timeZone: zone }); } catch { fail('Ungültige Zeitzone.'); } const allDay = input.date_from != null || input.date_to != null; const span: Span = { type: input.type, description: String(input.description || '').slice(0, 10000), timezone: zone, started_at: allDay ? midnight(input.date_from, zone) : instant(input.started_at), ended_at: allDay ? midnight(input.date_to, zone) : input.ended_at == null ? null : instant(input.ended_at), date_from: allDay ? dateOnly(input.date_from) : null, date_to: allDay ? dateOnly(input.date_to) : null, status: 'factual', breaks: [], projects: [], }; if (allDay && (span.type === 'work' || span.date_to! <= span.date_from!)) fail('Ungültiger ganztägiger Zeitraum; Ende ist exklusiv.'); if (span.ended_at && end(span) <= Date.parse(span.started_at)) fail('Ende muss nach Beginn liegen.'); if (!Array.isArray(input.breaks || []) || !Array.isArray(input.projects || [])) fail('Ungültige Teilzeiträume.'); span.breaks = (input.breaks || []).map((p: any) => ({ started_at: instant(p.started_at), ended_at: p.ended_at == null ? null : instant(p.ended_at) })); span.projects = (input.projects || []).map((p: any) => { if (!Number.isSafeInteger(Number(p.project_id)) || Number(p.project_id) <= 0) fail('Projekt erforderlich.'); return { started_at: instant(p.started_at), ended_at: instant(p.ended_at), project_id: Number(p.project_id), notes: String(p.notes || '').slice(0, 10000) }; }); if (span.type !== 'work' && (span.breaks.length || span.projects.length)) fail('Pausen und Projekte benötigen eine Anwesenheit.'); for (const parts of [span.breaks, span.projects]) { if (parts.length > 200) fail('Zu viele Teilzeiträume.'); parts.sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at)); for (let i = 0; i < parts.length; i++) { const p = parts[i]; if (end(p) <= Date.parse(p.started_at) || Date.parse(p.started_at) < Date.parse(span.started_at) || end(p) > end(span)) fail('Teilzeitraum muss innerhalb der Anwesenheit liegen.'); if (i && overlaps(parts[i - 1], p)) fail('Teilzeiträume dürfen sich nicht überschneiden.'); } } for (const project of span.projects) if (span.breaks.some(p => overlaps(p, project))) fail('Projektzeit darf keine Pause enthalten.'); return span; } export function transition(span: Span, action: string, reason = '') { if (!span.ended_at) fail('Laufende Einträge zuerst beenden.', 409); if (action === 'submit' && ['factual', 'rejected'].includes(span.status)) return 'submitted'; if (action === 'approve' && span.status === 'submitted') return 'approved'; if (action === 'reject' && span.status === 'submitted' && reason.trim()) return 'rejected'; fail('Ungültiger Statuswechsel oder fehlender Ablehnungsgrund.', 409); } export function evaluate(spans: Span[], profile: any, holidayDates: string[], from: string, to: string, zone = 'Europe/Berlin') { const window = range(from, to, zone); const sum = { timeSpanWorkingMinutes: 0, sumWorkingMinutesSubmitted: 0, sumWorkingMinutesApproved: 0, sumWorkingMinutesRecreationDays: 0, sumRecreationDays: 0, sumWorkingMinutesVacationDays: 0, sumVacationDays: 0, sumWorkingMinutesSickDays: 0, sumSickDays: 0, sumOvertimeCompensationMinutes: 0, sumOtherMinutes: 0, presenceMinutes: 0, breakMinutes: 0, saldoApproved: 0, saldoSubmitted: 0 }; for (const s of spans.filter(s => s.type === 'work' && s.ended_at)) { const gross = minutes(s, window), breaks = s.breaks.reduce((n, p) => n + minutes(p, window), 0), net = gross - breaks; sum.presenceMinutes += gross; sum.breakMinutes += breaks; if (['submitted', 'approved'].includes(s.status)) sum.sumWorkingMinutesSubmitted += net; if (s.status === 'approved') sum.sumWorkingMinutesApproved += net; } const holidays = new Set(holidayDates); for (let date = from; date <= to; date = nextDay(date)) { const day = range(date, date, zone), weekday = dayjs.utc(date).day(); const target = Math.max(0, Number(profile.weekly_regular_working_hours?.[weekday] || 0)) * 60; sum.timeSpanWorkingMinutes += target; // Credits are capped per day; holidays take precedence over overlapping absence credits. let remaining = target; const approved = spans.filter(s => s.type !== 'work' && s.status === 'approved' && s.ended_at && overlaps(s, day)); if ((profile.recreation_days_compensation && holidays.has(date)) || approved.some(s => s.type === 'holiday')) { sum.sumWorkingMinutesRecreationDays += target; sum.sumRecreationDays += target > 0 ? 1 : 0; remaining = 0; } for (const type of ['vacation', 'sick', 'overtime_compensation', 'other']) { for (const s of approved.filter(s => s.type === type)) { const credit = Math.min(remaining, s.date_from ? target : minutes(s, day)); if (type === 'vacation') { sum.sumWorkingMinutesVacationDays += credit; sum.sumVacationDays += target ? credit / target : 0; } if (type === 'sick') { sum.sumWorkingMinutesSickDays += credit; sum.sumSickDays += target ? credit / target : 0; } // Freizeitausgleich consumes the balance through the regular daily target, without a credit. if (type === 'overtime_compensation') sum.sumOvertimeCompensationMinutes += credit; if (type === 'other') sum.sumOtherMinutes += credit; remaining -= credit; } } } const credit = sum.sumWorkingMinutesRecreationDays + sum.sumWorkingMinutesVacationDays + sum.sumWorkingMinutesSickDays; sum.saldoApproved = sum.sumWorkingMinutesApproved + credit - sum.timeSpanWorkingMinutes; sum.saldoSubmitted = sum.sumWorkingMinutesSubmitted + credit - sum.timeSpanWorkingMinutes; return sum; }