import { z } from "zod"; export const DAY_NAMES = [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag", ]; export const STATUS_LABELS: Record = { PENDING: "Beantragt", APPROVED: "Genehmigt", REJECTED: "Abgelehnt", CANCEL_REQUESTED: "Stornierung beantragt", CANCELLED: "Storniert", DRAFT: "Offen", SUBMITTED: "Zur Prüfung", }; export const KIND_LABELS: Record = { VACATION: "Urlaub", TIME_OFF: "Freizeitausgleich", SICKNESS: "Krankheit", }; export const dateSchema = z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .refine((key) => { const d = new Date(`${key}T00:00:00Z`); return ( !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === key && key >= "2000-01-01" && key <= "2100-12-31" ); }, "Bitte ein gültiges Datum zwischen 2000 und 2100 angeben."); export function dateKey(date: Date) { return date.toISOString().slice(0, 10); } export function dateOnly(key: string) { return new Date(`${dateSchema.parse(key)}T00:00:00Z`); } export function todayKey(now = new Date()) { return new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", }).format(now); } export function addDays(key: string, amount: number) { const d = dateOnly(key); d.setUTCDate(d.getUTCDate() + amount); return dateKey(d); } export function weekStart(key: string) { const day = dateOnly(key).getUTCDay(); return addDays(key, -(day === 0 ? 6 : day - 1)); } export function datesBetween(start: string, end: string) { if (end < start) throw new Error("Das Enddatum darf nicht vor dem Beginn liegen."); if ((dateOnly(end).getTime() - dateOnly(start).getTime()) / 86400000 > 370) throw new Error("Bitte höchstens ein Jahr auf einmal auswählen."); const days: string[] = []; for (let d = start; d <= end; d = addDays(d, 1)) days.push(d); return days; } export function formatMinutes(minutes: number) { const abs = Math.abs(Math.round(minutes)); return `${minutes < 0 ? "−" : ""}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, "0")} h`; } export function shortDate(key: string) { return new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", day: "2-digit", month: "2-digit", year: "numeric", }).format(dateOnly(key)); } export function clockMinute(value: string) { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error("Bitte eine gültige Uhrzeit angeben."); const [h, m] = value.split(":").map(Number); return h * 60 + m; } export function minuteClock(value: number) { return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`; } export type Schedule = { employmentStart: Date; employmentEnd: Date | null; trackingStart: Date; workPatterns: { effectiveFrom: Date; dailyMinutes: number[] }[]; dayOverrides: { date: Date; minutes: number }[]; }; export function scheduledMinutes( profile: Schedule, key: string, holidays: Set, ) { if ( key < dateKey(profile.employmentStart) || (profile.employmentEnd && key > dateKey(profile.employmentEnd)) || holidays.has(key) ) return 0; const override = profile.dayOverrides.find((o) => dateKey(o.date) === key); if (override) return override.minutes; const pattern = profile.workPatterns .filter((p) => dateKey(p.effectiveFrom) <= key) .sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime())[0]; const weekday = dateOnly(key).getUTCDay(); return pattern?.dailyMinutes[weekday === 0 ? 6 : weekday - 1] ?? 0; } export type BookingDay = { date: string; vacationUnits: number; creditMinutes: number; }; export function calculateLeaveDays( profile: Schedule, holidays: Set, input: { kind: string; startDate: string; endDate: string; halfDay: boolean; minutes?: number; }, ) { if ( input.startDate < dateKey(profile.employmentStart) || (profile.employmentEnd && input.endDate > dateKey(profile.employmentEnd)) ) throw new Error("Der Zeitraum liegt außerhalb der Beschäftigung."); if (input.halfDay && input.startDate !== input.endDate) throw new Error("Halbe Tage bitte einzeln beantragen."); if (input.kind === "TIME_OFF" && input.startDate !== input.endDate) throw new Error( "Freizeitausgleich bitte für einen einzelnen Tag beantragen.", ); const days: BookingDay[] = []; for (const date of datesBetween(input.startDate, input.endDate)) { const expected = scheduledMinutes(profile, date, holidays); if (!expected) continue; const creditMinutes = input.kind === "TIME_OFF" ? (input.minutes ?? 0) : input.halfDay ? Math.round(expected / 2) : expected; if (creditMinutes <= 0 || creditMinutes > expected) throw new Error( "Freizeitausgleich muss zwischen einer Minute und der Sollzeit dieses Tages liegen.", ); days.push({ date, creditMinutes, vacationUnits: input.kind === "VACATION" ? (input.halfDay ? 1 : 2) : 0, }); } if (!days.length) throw new Error("Im ausgewählten Zeitraum liegen keine Arbeitstage."); return days; } export type DailyTotal = { date: string; expected: number; worked: number; credit: number; timeOff: number; balance: number; }; export function calculateWeek( profile: Schedule, holidays: Set, start: string, entries: { date: Date; startMinute: number; endMinute: number; breakMinutes: number; }[], leaves: { kind: string; status: string; days: { date: Date; creditMinutes: number }[]; }[], ): DailyTotal[] { return datesBetween(start, addDays(start, 6)).map((date) => { const expected = date < dateKey(profile.trackingStart) ? 0 : scheduledMinutes(profile, date, holidays); const worked = entries .filter((e) => dateKey(e.date) === date) .reduce( (sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes, 0, ); let credit = 0, timeOff = 0; for (const leave of leaves) { if (!["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) continue; const minutes = leave.days .filter((d) => dateKey(d.date) === date) .reduce((sum, d) => sum + d.creditMinutes, 0); if (leave.kind === "TIME_OFF") timeOff += minutes; else credit += minutes; } return { date, expected, worked, credit, timeOff, balance: worked + credit - expected, }; }); } export function readSnapshot(value: unknown): DailyTotal[] { const parsed = z .array( z.object({ date: dateSchema, expected: z.number().int(), worked: z.number().int(), credit: z.number().int(), timeOff: z.number().int(), balance: z.number().int(), }), ) .safeParse(value); return parsed.success ? parsed.data : []; } export function getTimeBalance( profile: { openingMinutes: number; trackingStart: Date }, weeks: { status: string; dailySnapshot: unknown }[], adjustments: { date: Date; minutes: number }[], leaves: { kind: string; status: string; days: { date: Date; creditMinutes: number }[]; }[], asOf = todayKey(), ) { const approvedDays = weeks .filter((w) => w.status === "APPROVED") .flatMap((w) => readSnapshot(w.dailySnapshot)); const postedDates = new Set( approvedDays.filter((d) => d.date <= asOf).map((d) => d.date), ); const confirmed = profile.openingMinutes + approvedDays .filter((d) => d.date <= asOf) .reduce((sum, d) => sum + d.balance, 0) + adjustments .filter((a) => dateKey(a.date) <= asOf) .reduce((sum, a) => sum + a.minutes, 0); const reserved = leaves .filter( (l) => l.kind === "TIME_OFF" && ["APPROVED", "CANCEL_REQUESTED"].includes(l.status), ) .flatMap((l) => l.days) .filter( (d) => dateKey(d.date) >= dateKey(profile.trackingStart) && !postedDates.has(dateKey(d.date)), ) .reduce((sum, d) => sum + d.creditMinutes, 0); return { confirmed, reserved, available: confirmed - reserved }; } export function vacationBalance( account: { year: number; entitlementUnits: number; carryUnits: number }, leaves: { kind: string; status: string; days: { date: Date; vacationUnits: number }[]; }[], asOf = todayKey(), ) { let taken = 0, planned = 0, pending = 0; for (const leave of leaves.filter((l) => l.kind === "VACATION")) { for (const day of leave.days.filter( (d) => d.date.getUTCFullYear() === account.year, )) { if (leave.status === "PENDING") pending += day.vacationUnits; if (["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) { if (dateKey(day.date) < asOf) taken += day.vacationUnits; else planned += day.vacationUnits; } } } return { entitlement: account.entitlementUnits + account.carryUnits, taken, planned, pending, available: account.entitlementUnits + account.carryUnits - taken - planned, }; }