feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+83 -17
View File
@@ -9,19 +9,19 @@ import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/**
* Count calendar days between two dates (inclusive).
* Half-day vacations count as 0.5.
*/
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
if (isHalfDay) return 0.5;
const ms = endDate.getTime() - startDate.getTime();
return Math.round(ms / 86_400_000) + 1;
}
type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
return entitlement;
}
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
@@ -69,14 +77,57 @@ async function syncEntitlement(
resourceId: string,
year: number,
defaultDays: number,
) {
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
@@ -86,13 +137,22 @@ async function syncEntitlement(
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
const days = countVacationChargeableDays({
vacation: v,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
(sum, v) => sum + countCalendarDaysInPeriod(
v,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**