refactor(api): split resource graph availability

This commit is contained in:
2026-03-31 22:01:29 +02:00
parent e0de41488c
commit 459ab6911b
2 changed files with 212 additions and 134 deletions
@@ -0,0 +1,185 @@
import { VacationStatus } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import {
asHolidayResolverDb,
collectHolidayAvailability,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
calculateEffectiveAvailableHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
type ResourceLocation = {
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country?: { code?: string | null; name?: string | null } | null;
metroCity?: { name?: string | null } | null;
};
type ResourceAvailabilityInput = {
db: any;
resource: ResourceLocation & {
id: string;
};
weeklyAvailability: WeekdayAvailability;
monthStart: Date;
monthEnd: Date;
};
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
export async function loadResourceGraphAvailability(input: ResourceAvailabilityInput) {
const { db, resource, weeklyAvailability, monthStart, monthEnd } = input;
const vacations = await db.vacation.findMany({
where: {
resourceId: resource.id,
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: monthStart,
periodEnd: monthEnd,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
const holidayAvailability = collectHolidayAvailability({
vacations,
periodStart: monthStart,
periodEnd: monthEnd,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
});
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
const absenceDateStrings = holidayAvailability.absenceDateStrings;
const absenceDays = holidayAvailability.absenceDays;
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
const publicHolidayCount = resolvedHolidays.length;
const absenceDayEquivalent = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
return sum + (absence.isHalfDay ? 0.5 : 1);
}, 0);
const contexts = await loadResourceDailyAvailabilityContexts(
db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
monthStart,
monthEnd,
);
const availabilityContext = contexts.get(resource.id);
const baseWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
);
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
? effectiveAvailableHours / effectiveWorkingDays
: 0;
const holidayScopeSummary = [
resource.country?.code ?? "—",
resource.federalState ?? "—",
resource.metroCity?.name ?? "—",
].join(" / ");
const holidayExamples = resolvedHolidays.length > 0
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
}, {});
return {
absenceDateStrings,
absenceDayEquivalent,
absenceDays,
absenceHoursDeduction,
availabilityContext,
baseAvailableHours,
baseWorkingDays,
effectiveAvailableHours,
effectiveHoursPerWorkingDay,
effectiveWorkingDays,
halfDayCount,
holidayExamples,
holidayScopeBreakdown,
holidayScopeSummary,
publicHolidayCount,
publicHolidayHoursDeduction,
publicHolidayStrings,
publicHolidayWorkdayCount,
resolvedHolidays,
sickDayCount,
vacationDayCount,
};
}
@@ -7,19 +7,9 @@ import {
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import type { TRPCContext } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import {
asHolidayResolverDb,
collectHolidayAvailability,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
calculateEffectiveAvailableHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { loadResourceGraphAvailability } from "./computation-graph-resource-availability.js";
import { readResourceBudgetGraph } from "./computation-graph-resource-budget.js";
import { buildResourceGraphSnapshot } from "./computation-graph-resource-graph.js";
@@ -28,21 +18,6 @@ type ResourceGraphInput = {
month: string;
};
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
export async function readResourceGraphSnapshot(
ctx: { db: TRPCContext["db"] },
input: ResourceGraphInput,
@@ -106,62 +81,34 @@ export async function readResourceGraphSnapshot(
},
});
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: monthStart,
periodEnd: monthEnd,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
const holidayAvailability = collectHolidayAvailability({
vacations,
periodStart: monthStart,
periodEnd: monthEnd,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
});
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
const absenceDateStrings = holidayAvailability.absenceDateStrings;
const absenceDays = holidayAvailability.absenceDays;
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
const publicHolidayCount = resolvedHolidays.length;
const absenceDayEquivalent = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
return sum + (absence.isHalfDay ? 0.5 : 1);
}, 0);
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
const {
absenceDateStrings,
absenceDayEquivalent,
absenceDays,
absenceHoursDeduction,
baseAvailableHours,
baseWorkingDays,
effectiveAvailableHours,
effectiveHoursPerWorkingDay,
effectiveWorkingDays,
halfDayCount,
holidayExamples,
holidayScopeBreakdown,
holidayScopeSummary,
publicHolidayCount,
publicHolidayHoursDeduction,
publicHolidayStrings,
publicHolidayWorkdayCount,
resolvedHolidays,
sickDayCount,
vacationDayCount,
} = await loadResourceGraphAvailability({
db: ctx.db,
resource,
weeklyAvailability,
monthStart,
monthEnd,
);
const availabilityContext = contexts.get(resource.id);
});
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
@@ -185,60 +132,6 @@ export async function readResourceGraphSnapshot(
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
const baseWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
);
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
? effectiveAvailableHours / effectiveWorkingDays
: 0;
const holidayScopeSummary = [
resource.country?.code ?? "—",
resource.federalState ?? "—",
resource.metroCity?.name ?? "—",
].join(" / ");
const holidayExamples = resolvedHolidays.length > 0
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
}, {});
const slices: AssignmentSlice[] = [];
const assignmentBreakdown: Array<{