Add educator leave management and working time accounts

This commit is contained in:
t.indorf
2026-10-07 15:25:55 +02:00
parent ec3f4f933d
commit 043fed2e46
19 changed files with 5631 additions and 200 deletions
+237
View File
@@ -0,0 +1,237 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
calculateLeaveDays,
calculateWeek,
dateOnly,
dateSchema,
getTimeBalance,
scheduledMinutes,
todayKey,
vacationBalance,
weekStart,
} from "../src/lib/staff/calculations";
const profile = {
employmentStart: dateOnly("2026-01-01"),
employmentEnd: null,
trackingStart: dateOnly("2026-01-01"),
openingMinutes: 120,
workPatterns: [
{
effectiveFrom: dateOnly("2026-01-01"),
dailyMinutes: [360, 360, 0, 360, 0, 0, 0],
},
],
dayOverrides: [],
};
const holidays = new Set(["2026-01-06"]);
const days = (date: string, vacationUnits: number, creditMinutes = 360) => [
{ date: dateOnly(date), vacationUnits, creditMinutes },
];
test("Teilzeiturlaub verbraucht nur feste Arbeitstage, Feiertage werden übersprungen", () => {
const result = calculateLeaveDays(profile, holidays, {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-11",
halfDay: false,
});
assert.deepEqual(
result.map((d) => d.date),
["2026-01-05", "2026-01-08"],
);
assert.equal(
result.reduce((s, d) => s + d.vacationUnits, 0),
4,
);
});
test("Halber Urlaubstag entspricht halbem Tages-Soll", () => {
assert.deepEqual(
calculateLeaveDays(profile, new Set(), {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-05",
halfDay: true,
}),
[{ date: "2026-01-05", vacationUnits: 1, creditMinutes: 180 }],
);
assert.throws(() =>
calculateLeaveDays(profile, holidays, {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-08",
halfDay: true,
}),
);
});
test("Tagesausnahmen und datierte Modelle verändern nur den vorgesehenen Zeitraum", () => {
const schedule = {
...profile,
dayOverrides: [{ date: dateOnly("2026-01-07"), minutes: 240 }],
workPatterns: [
...profile.workPatterns,
{
effectiveFrom: dateOnly("2026-02-01"),
dailyMinutes: [480, 0, 480, 0, 0, 0, 0],
},
],
};
assert.equal(scheduledMinutes(schedule, "2026-01-07", holidays), 240);
assert.equal(scheduledMinutes(schedule, "2026-01-05", holidays), 360);
assert.equal(scheduledMinutes(schedule, "2026-02-02", holidays), 480);
assert.equal(scheduledMinutes(schedule, "2026-01-06", holidays), 0);
assert.equal(
scheduledMinutes(
{ ...schedule, employmentEnd: dateOnly("2026-01-31") },
"2026-02-02",
holidays,
),
0,
);
});
test("Urlaub über Jahreswechsel wird auf die Jahreskonten verteilt", () => {
const leave = [
{
kind: "VACATION",
status: "APPROVED",
days: [...days("2026-12-31", 2), ...days("2027-01-04", 1)],
},
];
assert.equal(
vacationBalance(
{ year: 2026, entitlementUnits: 40, carryUnits: 2 },
leave,
"2026-10-01",
).planned,
2,
);
assert.equal(
vacationBalance(
{ year: 2027, entitlementUnits: 40, carryUnits: 0 },
leave,
"2026-10-01",
).planned,
1,
);
});
test("Offene Stornierung bleibt gebucht, Ablehnung und Stornierung verbrauchen nichts", () => {
const leave = [
{
kind: "VACATION",
status: "CANCEL_REQUESTED",
days: days("2026-01-05", 2),
},
{ kind: "VACATION", status: "PENDING", days: days("2026-01-08", 1) },
...["CANCELLED", "REJECTED"].map((status) => ({
kind: "VACATION",
status,
days: days("2026-01-12", 2),
})),
];
assert.deepEqual(
vacationBalance(
{ year: 2026, entitlementUnits: 40, carryUnits: 4 },
leave,
"2026-01-07",
),
{ entitlement: 44, taken: 2, planned: 0, pending: 1, available: 42 },
);
});
test("Mehrere Arbeitsblöcke abzüglich Pausen, Urlaub und Krankheit ergeben die Wochenbilanz", () => {
const entries = [
{
date: dateOnly("2026-01-05"),
startMinute: 480,
endMinute: 720,
breakMinutes: 30,
},
{
date: dateOnly("2026-01-05"),
startMinute: 780,
endMinute: 960,
breakMinutes: 0,
},
];
const leave = [
{ kind: "SICKNESS", status: "APPROVED", days: days("2026-01-08", 0) },
];
const result = calculateWeek(profile, holidays, "2026-01-05", entries, leave);
assert.equal(result[0].worked, 390);
assert.equal(result[0].balance, 30);
assert.equal(result[1].expected, 0);
assert.equal(result[3].credit, 360);
assert.equal(result[3].balance, 0);
});
test("Nur genehmigte Snapshots zählen; Zukunftskorrekturen zählen erst ab Datum", () => {
const snapshot = [
{
date: "2026-01-05",
expected: 360,
worked: 420,
credit: 0,
timeOff: 0,
balance: 60,
},
];
const result = getTimeBalance(
profile,
[
{ status: "APPROVED", dailySnapshot: snapshot },
{ status: "SUBMITTED", dailySnapshot: snapshot },
],
[{ date: dateOnly("2026-02-01"), minutes: 100 }],
[],
"2026-01-10",
);
assert.equal(result.confirmed, 180);
});
test("Freizeitausgleich wird reserviert und nach Wochenbuchung genau einmal abgezogen", () => {
const leave = [
{ kind: "TIME_OFF", status: "APPROVED", days: days("2026-01-05", 0, 60) },
];
const initial = getTimeBalance(profile, [], [], leave, "2026-01-04");
assert.deepEqual(initial, { confirmed: 120, reserved: 60, available: 60 });
const snapshot = [
{
date: "2026-01-05",
expected: 360,
worked: 300,
credit: 0,
timeOff: 60,
balance: -60,
},
];
assert.deepEqual(
getTimeBalance(
profile,
[{ status: "APPROVED", dailySnapshot: snapshot }],
[],
leave,
"2026-01-06",
),
{ confirmed: 60, reserved: 0, available: 60 },
);
const computed = calculateWeek(
profile,
new Set(),
"2026-01-05",
[
{
date: dateOnly("2026-01-05"),
startMinute: 480,
endMinute: 780,
breakMinutes: 0,
},
],
leave,
);
assert.equal(computed[0].credit, 0);
assert.equal(computed[0].balance, -60);
});
test("Ungültige Kalendertage werden abgewiesen; Tagesdatum wird in Berlin bestimmt", () => {
assert.equal(dateSchema.safeParse("2026-02-30").success, false);
assert.equal(dateSchema.safeParse("2028-02-29").success, true);
assert.equal(todayKey(new Date("2026-01-04T23:30:00Z")), "2026-01-05");
assert.equal(weekStart("2026-01-11"), "2026-01-05");
});