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
@@ -1,6 +1,12 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
import { calculateAllocationHours } from "./shared.js";
import {
calculateEffectiveAllocationHours,
calculateEffectiveAvailableHours,
loadDailyAvailabilityContexts,
} from "./holiday-capacity.js";
export interface GetDashboardDemandInput {
startDate: Date;
@@ -8,6 +14,35 @@ export interface GetDashboardDemandInput {
groupBy: "project" | "person" | "chapter";
}
export interface DemandCalendarLocationSummary {
countryCode: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
allocatedHours: number;
}
export interface DemandRowDerivation {
periodStart: string;
periodEnd: string;
periodWorkingHoursBase: number;
requiredHours: number | null;
requiredFTEs: number;
fillPct: number | null;
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
calendarLocations: DemandCalendarLocationSummary[];
}
export interface DashboardDemandRow {
id: string;
name: string;
shortCode: string;
allocatedHours: number;
requiredFTEs: number;
resourceCount: number;
derivation?: DemandRowDerivation;
}
interface ProjectSummary {
id: string;
name: string;
@@ -15,6 +50,12 @@ interface ProjectSummary {
staffingReqs: unknown;
}
function hasAvailability<T extends { availability?: unknown }>(
resource: T | null | undefined,
): resource is T & { availability: WeekdayAvailability } {
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
}
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
return normalizedPercentage / 100;
@@ -24,6 +65,22 @@ function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function buildLocationKey(input: {
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): string {
return JSON.stringify({
countryCode: input.countryCode ?? null,
federalState: input.federalState ?? null,
metroCityName: input.metroCityName ?? null,
});
}
function getProjectRequiredFTEs(staffingReqs: unknown): number {
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
return requirements.reduce((sum, requirement) => {
@@ -40,10 +97,84 @@ function getProjectRequiredFTEs(staffingReqs: unknown): number {
}, 0);
}
const FULL_TIME_AVAILABILITY: WeekdayAvailability = {
sunday: 0,
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
};
function summarizeCalendarLocations(
assignments: Array<{
resource?: { id?: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
}>,
resourceInfoById: Map<string, {
id: string;
availability: WeekdayAvailability;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}>,
contexts: Map<string, Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never>,
input: GetDashboardDemandInput,
): DemandCalendarLocationSummary[] {
const locationMap = new Map<string, DemandCalendarLocationSummary & { resourceIds: Set<string> }>();
for (const assignment of assignments) {
const resourceId = assignment.resource?.id ?? undefined;
const resource = resourceId ? resourceInfoById.get(resourceId) : undefined;
if (!resource) {
continue;
}
const hours = calculateEffectiveAllocationHours({
availability: resource.availability,
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context: contexts.get(resource.id),
});
const locationKey = buildLocationKey({
countryCode: resource.countryCode,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const existing = locationMap.get(locationKey) ?? {
countryCode: resource.countryCode ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCityName ?? null,
resourceCount: 0,
allocatedHours: 0,
resourceIds: new Set<string>(),
};
existing.allocatedHours += hours;
existing.resourceIds.add(resource.id);
existing.resourceCount = existing.resourceIds.size;
locationMap.set(locationKey, existing);
}
return [...locationMap.values()]
.map(({ resourceIds: _resourceIds, ...summary }) => ({
...summary,
allocatedHours: Math.round(summary.allocatedHours * 10) / 10,
}))
.sort((left, right) => right.allocatedHours - left.allocatedHours);
}
export async function getDashboardDemand(
db: PrismaClient,
input: GetDashboardDemandInput,
) {
): Promise<DashboardDemandRow[]> {
const { demandRequirements, assignments, projects, readModel } =
await loadDashboardPlanningReadModel(db, {
startDate: input.startDate,
@@ -58,6 +189,27 @@ export async function getDashboardDemand(
);
const normalizedAssignments = readModel.assignments;
const normalizedDemands = readModel.demands;
const resourceProfiles = assignments
.map((assignment) => assignment.resource)
.filter(hasAvailability)
.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,
}));
const resourceInfoById = new Map(
resourceProfiles.map((resource) => [resource.id, resource]),
);
const contexts = await loadDailyAvailabilityContexts(
db,
[...new Map(resourceProfiles.map((resource) => [resource.id, resource])).values()],
input.startDate,
input.endDate,
);
const projectMap = new Map<string, ProjectSummary>(
projects.map((project) => [project.id, project]),
@@ -87,6 +239,13 @@ export async function getDashboardDemand(
);
}
const periodWorkingHoursBase = calculateEffectiveAvailableHours({
availability: FULL_TIME_AVAILABILITY,
periodStart: input.startDate,
periodEnd: input.endDate,
context: undefined,
});
if (input.groupBy === "project") {
const projectIds = new Set<string>([
...projectMap.keys(),
@@ -110,13 +269,28 @@ export async function getDashboardDemand(
);
const allocatedHours = projectAssignments.reduce(
(sum, assignment) =>
sum +
calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
}),
(sum, assignment) => {
const resource = assignment.resource?.id
? resourceInfoById.get(assignment.resource.id)
: undefined;
return sum + (
resource
? calculateEffectiveAllocationHours({
availability: resource.availability,
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context: contexts.get(resource.id),
})
: calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
})
);
},
0,
);
const requiredFTEs =
@@ -139,6 +313,18 @@ export async function getDashboardDemand(
return sum + plannedHeadcount * demandFteFactor;
}, 0)
: getProjectRequiredFTEs(project.staffingReqs);
const requiredHours = requiredFTEs > 0
? Math.round(requiredFTEs * periodWorkingHoursBase * 10) / 10
: null;
const fillPct = requiredHours && requiredHours > 0
? Math.round((allocatedHours / requiredHours) * 100)
: null;
const calendarLocations = summarizeCalendarLocations(
projectAssignments,
resourceInfoById,
contexts,
input,
);
return {
id: project.id,
@@ -149,6 +335,18 @@ export async function getDashboardDemand(
resourceCount: new Set(
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
).size,
derivation: {
periodStart: toIsoDate(input.startDate),
periodEnd: toIsoDate(input.endDate),
periodWorkingHoursBase,
requiredHours,
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
fillPct,
demandSource: projectDemands.length > 0
? "DEMAND_REQUIREMENTS"
: "PROJECT_STAFFING_REQS",
calendarLocations,
},
};
});
}
@@ -161,6 +359,9 @@ export async function getDashboardDemand(
for (const assignment of normalizedAssignments) {
const chapter = assignment.resource?.chapter ?? "Unassigned";
const resource = assignment.resource?.id
? resourceInfoById.get(assignment.resource.id)
: undefined;
const existing = chapterMap.get(chapter) ?? {
allocatedHours: 0,
resourceIds: new Set<string>(),
@@ -170,23 +371,54 @@ export async function getDashboardDemand(
existing.resourceIds.add(assignment.resource.id);
}
existing.allocatedHours += calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
});
existing.allocatedHours += resource
? calculateEffectiveAllocationHours({
availability: resource.availability,
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context: contexts.get(resource.id),
})
: calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
});
chapterMap.set(chapter, existing);
}
return [...chapterMap.entries()].map(([chapter, data]) => ({
id: chapter,
name: chapter,
shortCode: chapter,
allocatedHours: Math.round(data.allocatedHours),
requiredFTEs: 0,
resourceCount: data.resourceIds.size,
}));
return [...chapterMap.entries()].map(([chapter, data]) => {
const chapterAssignments = normalizedAssignments.filter(
(assignment) => (assignment.resource?.chapter ?? "Unassigned") === chapter,
);
return {
id: chapter,
name: chapter,
shortCode: chapter,
allocatedHours: Math.round(data.allocatedHours),
requiredFTEs: 0,
resourceCount: data.resourceIds.size,
derivation: {
periodStart: toIsoDate(input.startDate),
periodEnd: toIsoDate(input.endDate),
periodWorkingHoursBase,
requiredHours: null,
requiredFTEs: 0,
fillPct: null,
demandSource: "NONE",
calendarLocations: summarizeCalendarLocations(
chapterAssignments,
resourceInfoById,
contexts,
input,
),
},
};
});
}
const personMap = new Map<
@@ -210,23 +442,55 @@ export async function getDashboardDemand(
allocatedHours: 0,
projectIds: new Set<string>(),
};
const resource = resourceInfoById.get(assignment.resource.id);
existing.allocatedHours += calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
});
existing.allocatedHours += resource
? calculateEffectiveAllocationHours({
availability: resource.availability,
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context: contexts.get(resource.id),
})
: calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
});
existing.projectIds.add(assignment.projectId);
personMap.set(assignment.resource.id, existing);
}
return [...personMap.entries()].map(([id, data]) => ({
id,
name: data.name,
shortCode: data.chapter ?? "",
allocatedHours: Math.round(data.allocatedHours),
requiredFTEs: 0,
resourceCount: data.projectIds.size,
}));
return [...personMap.entries()].map(([id, data]) => {
const personAssignments = normalizedAssignments.filter(
(assignment) => assignment.resource?.id === id,
);
return {
id,
name: data.name,
shortCode: data.chapter ?? "",
allocatedHours: Math.round(data.allocatedHours),
requiredFTEs: 0,
resourceCount: data.projectIds.size,
derivation: {
periodStart: toIsoDate(input.startDate),
periodEnd: toIsoDate(input.endDate),
periodWorkingHoursBase,
requiredHours: null,
requiredFTEs: 0,
fillPct: null,
demandSource: "NONE",
calendarLocations: summarizeCalendarLocations(
personAssignments,
resourceInfoById,
contexts,
input,
),
},
};
});
}