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
+537 -43
View File
@@ -1,10 +1,106 @@
import { analyzeUtilization, findCapacityWindows, rankResources } from "@capakraken/staffing";
import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application";
import { TRPCError } from "@trpc/server";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
type ResourceDailyAvailabilityContext,
} from "../lib/resource-capacity.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function getBaseDayAvailability(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
}
function getEffectiveDayAvailability(
availability: WeekdayAvailability,
date: Date,
context: ResourceDailyAvailabilityContext | undefined,
): number {
const key = DAY_KEYS[date.getUTCDay()];
const baseHours = key ? (availability[key] ?? 0) : 0;
if (baseHours <= 0) {
return 0;
}
const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0;
return Math.max(0, baseHours * (1 - fraction));
}
function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean {
return date >= startDate && date <= endDate;
}
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
if (workingDays <= 0) {
return 0;
}
return round1(totalHours / workingDays);
}
function createLocationLabel(input: {
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}): string {
return [
input.countryCode ?? null,
input.federalState ?? null,
input.metroCityName ?? null,
].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / ");
}
function calculateAllocatedHoursForDay(input: {
bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>;
date: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): { allocatedHours: number; chargeableHours: number } {
const isoDate = toIsoDate(input.date);
const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0));
return input.bookings.reduce(
(acc, booking) => {
if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) {
return acc;
}
const effectiveHours = booking.hoursPerDay * dayFraction;
acc.allocatedHours += effectiveHours;
if (booking.isChargeable) {
acc.chargeableHours += effectiveHours;
}
return acc;
},
{ allocatedHours: 0, chargeableHours: 0 },
);
}
export const staffingRouter = createTRPCRouter({
/**
* Get ranked resource suggestions for a staffing requirement.
@@ -32,31 +128,169 @@ export const staffingRouter = createTRPCRouter({
isActive: true,
...(chapter ? { chapter } : {}),
},
select: {
id: true,
displayName: true,
eid: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
availability: true,
valueScore: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate,
endDate,
resourceIds: resources.map((resource) => resource.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
startDate,
endDate,
);
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
items.push(booking);
bookingsByResourceId.set(booking.resourceId, items);
}
// Compute utilization percent for each resource in the requested period
const enrichedResources = resources.map((resource) => {
const totalAvailableHours =
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, a) => sum + a.hoursPerDay,
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
});
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
});
const allocatedHours = activeBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: startDate,
periodEnd: endDate,
context,
}),
0,
);
const holidayDates = [...(context?.holidayDates ?? new Set<string>())].sort();
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const conflictDays: string[] = [];
const conflictDetails: Array<{
date: string;
baseHours: number;
effectiveHours: number;
allocatedHours: number;
remainingHours: number;
requestedHours: number;
shortageHours: number;
absenceFraction: number;
isHoliday: boolean;
}> = [];
const cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0);
const periodEndAtMidnight = new Date(endDate);
periodEndAtMidnight.setUTCHours(0, 0, 0, 0);
while (cursor <= periodEndAtMidnight) {
const isoDate = toIsoDate(cursor);
const baseHoursForDay = getBaseDayAvailability(availability, cursor);
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
const absenceFraction = Math.min(
1,
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
);
if (availableHoursForDay > 0) {
const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({
bookings: activeBookings,
date: cursor,
context,
});
if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) {
const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay);
conflictDays.push(isoDate);
conflictDetails.push({
date: isoDate,
baseHours: round1(baseHoursForDay),
effectiveHours: round1(availableHoursForDay),
allocatedHours: round1(allocatedHoursForDay),
remainingHours: round1(remainingHoursForDay),
requestedHours: round1(hoursPerDay),
shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)),
absenceFraction: round1(absenceFraction),
isHoliday,
});
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const remainingHours = Math.max(0, totalAvailableHours - allocatedHours);
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
let skills = resource.skills as unknown as SkillRow[];
@@ -73,9 +307,43 @@ export const staffingRouter = createTRPCRouter({
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
hasAvailabilityConflicts: conflictDays.length > 0,
conflictDays,
valueScore: resource.valueScore ?? 0,
transparency: {
location: {
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
label: createLocationLabel({
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name ?? null,
}),
},
capacity: {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay),
baseWorkingDays: round1(baseWorkingDays),
effectiveWorkingDays: round1(effectiveWorkingDays),
baseAvailableHours: round1(baseAvailableHours),
effectiveAvailableHours: round1(totalAvailableHours),
bookedHours: round1(allocatedHours),
remainingHours: round1(remainingHours),
remainingHoursPerDay,
holidayCount: holidayDates.length,
holidayWorkdayCount,
holidayHoursDeduction: round1(holidayHoursDeduction),
absenceDayEquivalent: round1(absenceDayEquivalent),
absenceHoursDeduction: round1(absenceHoursDeduction),
},
conflicts: {
count: conflictDays.length,
conflictDays,
details: conflictDetails,
},
},
};
});
@@ -85,15 +353,95 @@ export const staffingRouter = createTRPCRouter({
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
// Value-score tiebreaker: within 2 points, prefer higher valueScore
return ranked.sort((a, b) => {
return [...ranked].sort((a, b) => {
if (Math.abs(a.score - b.score) <= 2) {
const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0);
const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0);
return bVal - aVal;
}
return 0;
}).map((suggestion, index) => {
const resource = enrichedResources.find((item) => item.id === suggestion.resourceId);
const fallbackBreakdown = "breakdown" in suggestion
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
: undefined;
const scoreBreakdown = suggestion.scoreBreakdown ?? {
skillScore: fallbackBreakdown?.skillScore ?? 0,
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
costScore: fallbackBreakdown?.costScore ?? 0,
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
total: suggestion.score,
};
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
const tieBreakerApplied = baseRank !== index + 1;
return {
...suggestion,
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
eid: suggestion.eid ?? resource?.eid ?? "",
scoreBreakdown,
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
valueScore: resource?.valueScore ?? 0,
location: resource?.transparency.location ?? {
countryCode: null,
countryName: null,
federalState: null,
metroCityName: null,
label: "",
},
capacity: resource?.transparency.capacity ?? {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: 0,
baseWorkingDays: 0,
effectiveWorkingDays: 0,
baseAvailableHours: 0,
effectiveAvailableHours: 0,
bookedHours: 0,
remainingHours: 0,
remainingHoursPerDay: 0,
holidayCount: 0,
holidayWorkdayCount: 0,
holidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
},
conflicts: resource?.transparency.conflicts ?? {
count: 0,
conflictDays: [],
details: [],
},
ranking: {
rank: index + 1,
baseRank,
tieBreakerApplied,
tieBreakerReason: tieBreakerApplied
? "Within 2 score points, higher value score moves the candidate up."
: null,
model: "Composite ranking across skill fit, availability, cost, and utilization.",
components: [
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
],
},
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
};
});
}),
@@ -117,6 +465,11 @@ export const staffingRouter = createTRPCRouter({
displayName: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
@@ -128,24 +481,83 @@ export const staffingRouter = createTRPCRouter({
resourceIds: [resource.id],
});
return analyzeUtilization({
resource: {
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
displayName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
},
allocations: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
analysisStart: input.startDate,
analysisEnd: input.endDate,
});
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
const activeBookings = resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
}));
const overallocatedDays: string[] = [];
const underutilizedDays: string[] = [];
let totalAvailableHours = 0;
let totalChargeableHours = 0;
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay > 0) {
const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({
bookings: activeBookings,
date: cursor,
context,
});
totalAvailableHours += availableHoursForDay;
totalChargeableHours += chargeableHours;
if (allocatedHours > availableHoursForDay) {
overallocatedDays.push(toIsoDate(cursor));
} else if (allocatedHours < availableHoursForDay * 0.5) {
underutilizedDays.push(toIsoDate(cursor));
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const currentChargeability = totalAvailableHours > 0
? (totalChargeableHours / totalAvailableHours) * 100
: 0;
return {
resourceId: resource.id,
resourceName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
chargeabilityGap: resource.chargeabilityTarget - currentChargeability,
allocations: activeBookings
.filter((booking) => ACTIVE_STATUSES.has(booking.status))
.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
projectName: booking.projectName,
isChargeable: booking.isChargeable,
})),
overallocatedDays,
underutilizedDays,
};
}),
/**
@@ -168,6 +580,11 @@ export const staffingRouter = createTRPCRouter({
id: true,
displayName: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
@@ -179,21 +596,98 @@ export const staffingRouter = createTRPCRouter({
resourceIds: [resource.id],
});
return findCapacityWindows(
{
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
displayName: resource.displayName,
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
},
resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<import("@capakraken/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
input.minAvailableHoursPerDay,
);
const context = contexts.get(resource.id);
const windows: Array<{
resourceId: string;
resourceName: string;
startDate: Date;
endDate: Date;
availableHoursPerDay: number;
availableDays: number;
totalAvailableHours: number;
}> = [];
let windowStart: Date | null = null;
let windowAvailableDays = 0;
let windowTotalHours = 0;
let windowMinHours = Number.POSITIVE_INFINITY;
const closeWindow = (closeDate: Date) => {
if (windowStart && windowAvailableDays > 0) {
const previousDay = new Date(closeDate);
previousDay.setUTCDate(previousDay.getUTCDate() - 1);
windows.push({
resourceId: resource.id,
resourceName: resource.displayName,
startDate: new Date(windowStart),
endDate: previousDay,
availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0,
availableDays: windowAvailableDays,
totalAvailableHours: Math.round(windowTotalHours * 10) / 10,
});
}
windowStart = null;
windowAvailableDays = 0;
windowTotalHours = 0;
windowMinHours = Number.POSITIVE_INFINITY;
};
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay <= 0) {
closeWindow(cursor);
cursor.setUTCDate(cursor.getUTCDate() + 1);
continue;
}
const { allocatedHours } = calculateAllocatedHoursForDay({
bookings: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})),
date: cursor,
context,
});
const freeHours = Math.max(0, availableHoursForDay - allocatedHours);
if (freeHours >= input.minAvailableHoursPerDay) {
if (!windowStart) {
windowStart = new Date(cursor);
}
windowAvailableDays += 1;
windowTotalHours += freeHours;
windowMinHours = Math.min(windowMinHours, freeHours);
} else {
closeWindow(cursor);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
closeWindow(new Date(end.getTime() + 86_400_000));
return windows;
}),
});