chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,232 @@
import type { PrismaClient } from "@planarchy/db";
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
import { calculateAllocationHours } from "./shared.js";
export interface GetDashboardDemandInput {
startDate: Date;
endDate: Date;
groupBy: "project" | "person" | "chapter";
}
interface ProjectSummary {
id: string;
name: string;
shortCode: string;
staffingReqs: unknown;
}
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 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);
}
export async function getDashboardDemand(
db: PrismaClient,
input: GetDashboardDemandInput,
) {
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 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,
);
}
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) =>
sum +
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);
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,
};
});
}
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 existing = chapterMap.get(chapter) ?? {
allocatedHours: 0,
resourceIds: new Set<string>(),
};
if (assignment.resource?.id) {
existing.resourceIds.add(assignment.resource.id);
}
existing.allocatedHours += 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,
}));
}
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>(),
};
existing.allocatedHours += 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,
}));
}