Files
Nexus/packages/application/src/use-cases/dashboard/get-demand.ts
T
Hartmut 1df208dbcc feat(timeline): add pulse animation for in-flight drag mutations
Allocation bars that have active optimistic overrides (post-drag,
awaiting server confirmation) now pulse subtly via animate-pulse.
The pending set is derived from the existing optimisticAllocations
map keys, requiring no additional state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:28:46 +02:00

500 lines
16 KiB
TypeScript

import type { PrismaClient } from "@capakraken/db";
import { toIsoDate, 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;
endDate: Date;
groupBy: "project" | "person" | "chapter";
}
export interface DemandCalendarLocationSummary {
countryCode: string | null;
countryName: 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;
shortCode: string;
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;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function buildLocationKey(input: {
countryCode: string | null | undefined;
countryName: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): string {
return JSON.stringify({
countryCode: input.countryCode ?? null,
countryName: input.countryName ?? 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) => {
if (
typeof requirement === "object" &&
requirement !== null &&
"fteCount" in requirement &&
typeof requirement.fteCount === "number"
) {
return sum + requirement.fteCount;
}
return sum;
}, 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;
countryName: 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,
countryName: resource.countryName,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const existing = locationMap.get(locationKey) ?? {
countryCode: resource.countryCode ?? null,
countryName: resource.countryName ?? 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,
endDate: input.endDate,
});
const demandRequirementById = new Map(
demandRequirements.map((demandRequirement) => [
demandRequirement.id,
demandRequirement,
]),
);
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,
countryName: resource.country?.name,
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]),
);
for (const allocation of readModel.allocations) {
if (!allocation.project || projectMap.has(allocation.project.id)) {
continue;
}
projectMap.set(allocation.project.id, {
id: allocation.project.id,
name: allocation.project.name,
shortCode: allocation.project.shortCode,
staffingReqs: allocation.project.staffingReqs,
});
}
const assignmentCountByDemandRequirementId = new Map<string, number>();
for (const assignment of assignments) {
if (!assignment.demandRequirementId) {
continue;
}
assignmentCountByDemandRequirementId.set(
assignment.demandRequirementId,
(assignmentCountByDemandRequirementId.get(assignment.demandRequirementId) ?? 0) + 1,
);
}
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(),
...normalizedAssignments.map((assignment) => assignment.projectId),
...normalizedDemands.map((demand) => demand.projectId),
]);
return [...projectIds].map((projectId) => {
const project = projectMap.get(projectId) ?? {
id: projectId,
name: projectId,
shortCode: projectId,
staffingReqs: [],
};
const projectAssignments = normalizedAssignments.filter(
(assignment) => assignment.projectId === projectId,
);
const projectDemands = normalizedDemands.filter(
(demand) => demand.projectId === projectId,
);
const allocatedHours = projectAssignments.reduce(
(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 =
projectDemands.length > 0
? projectDemands.reduce((sum, demand) => {
const demandFteFactor = getDemandFteFactor(
demand.hoursPerDay,
demand.percentage,
);
const explicitDemand = demandRequirementById.get(demand.id);
if (!explicitDemand) {
return sum + demand.requestedHeadcount * demandFteFactor;
}
const linkedAssignmentCount =
assignmentCountByDemandRequirementId.get(explicitDemand.id) ?? 0;
const plannedHeadcount =
linkedAssignmentCount +
(explicitDemand.status === "COMPLETED" ? 0 : explicitDemand.headcount);
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,
name: project.name,
shortCode: project.shortCode,
allocatedHours: Math.round(allocatedHours),
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
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,
},
};
});
}
if (input.groupBy === "chapter") {
const chapterMap = new Map<
string,
{ allocatedHours: number; resourceIds: Set<string> }
>();
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>(),
};
if (assignment.resource?.id) {
existing.resourceIds.add(assignment.resource.id);
}
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]) => {
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<
string,
{
name: string;
chapter: string | null;
allocatedHours: number;
projectIds: Set<string>;
}
>();
for (const assignment of normalizedAssignments) {
if (!assignment.resource) {
continue;
}
const existing = personMap.get(assignment.resource.id) ?? {
name: assignment.resource.displayName,
chapter: assignment.resource.chapter ?? null,
allocatedHours: 0,
projectIds: new Set<string>(),
};
const resource = resourceInfoById.get(assignment.resource.id);
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]) => {
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,
),
},
};
});
}