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,186 @@
/**
* Chargeability calculator — pure functions for FTE-weighted chargeability reporting.
*
* Derives forecast chargeability from assignments + SAH.
* No DB imports — all data passed in as arguments.
*/
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ResourceForecastInput {
fte: number;
targetPercentage: number; // from management level group (0-1)
/** Assignments active in the period, with their utilization category code. */
assignments: AssignmentSlice[];
/** SAH for the period (net working hours). */
sah: number;
}
export interface AssignmentSlice {
/** Hours per day for this assignment in the period. */
hoursPerDay: number;
/** Working days this assignment covers within the period. */
workingDays: number;
/** Utilization category code (e.g. "Chg", "BD", "MD&I", "M&O", "PD&R"). */
categoryCode: string;
}
export interface ResourceForecast {
chg: number; // chargeability ratio (0-1)
bd: number; // business development ratio
mdi: number; // MD&I ratio
mo: number; // M&O ratio
pdr: number; // PD&R ratio
absence: number; // absence ratio
unassigned: number; // remaining ratio
}
export interface GroupChargeabilityInput {
fte: number;
chargeability: number; // 0-1
}
export interface GroupTargetInput {
fte: number;
targetPercentage: number; // 0-1
}
// ─── Functions ──────────────────────────────────────────────────────────────
/**
* Derive forecast chargeability breakdown for a single resource in a period.
* Returns ratios (0-1) for each utilization category.
*/
export function deriveResourceForecast(input: ResourceForecastInput): ResourceForecast {
const { assignments, sah } = input;
if (sah <= 0) {
return { chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 };
}
// Sum hours per category
const categoryHours: Record<string, number> = {};
for (const a of assignments) {
const hours = a.hoursPerDay * a.workingDays;
const key = a.categoryCode.toLowerCase();
categoryHours[key] = (categoryHours[key] ?? 0) + hours;
}
const chgHours = categoryHours["chg"] ?? 0;
const bdHours = categoryHours["bd"] ?? 0;
const mdiHours = categoryHours["md&i"] ?? categoryHours["mdi"] ?? 0;
const moHours = categoryHours["m&o"] ?? categoryHours["mo"] ?? 0;
const pdrHours = categoryHours["pd&r"] ?? categoryHours["pdr"] ?? 0;
const absenceHours = categoryHours["absence"] ?? 0;
const totalAssigned = chgHours + bdHours + mdiHours + moHours + pdrHours + absenceHours;
const unassignedHours = Math.max(0, sah - totalAssigned);
return {
chg: Math.min(1, chgHours / sah),
bd: Math.min(1, bdHours / sah),
mdi: Math.min(1, mdiHours / sah),
mo: Math.min(1, moHours / sah),
pdr: Math.min(1, pdrHours / sah),
absence: Math.min(1, absenceHours / sah),
unassigned: Math.min(1, unassignedHours / sah),
};
}
/**
* FTE-weighted chargeability for a group of resources.
* Returns a ratio (0-1). Empty group returns 0.
*/
export function calculateGroupChargeability(resources: GroupChargeabilityInput[]): number {
if (resources.length === 0) return 0;
let weightedSum = 0;
let totalFte = 0;
for (const r of resources) {
weightedSum += r.fte * r.chargeability;
totalFte += r.fte;
}
return totalFte > 0 ? weightedSum / totalFte : 0;
}
/**
* FTE-weighted target for a group of resources.
* Returns a ratio (0-1). Empty group returns 0.
*/
export function calculateGroupTarget(resources: GroupTargetInput[]): number {
if (resources.length === 0) return 0;
let weightedSum = 0;
let totalFte = 0;
for (const r of resources) {
weightedSum += r.fte * r.targetPercentage;
totalFte += r.fte;
}
return totalFte > 0 ? weightedSum / totalFte : 0;
}
/**
* Sum FTE for a group of resources.
*/
export function sumFte(resources: { fte: number }[]): number {
return resources.reduce((sum, r) => sum + r.fte, 0);
}
/**
* Get the start and end dates for a month (UTC).
*/
export function getMonthRange(year: number, month: number): { start: Date; end: Date } {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0)); // last day of month
return { start, end };
}
/**
* Generate a list of month keys (e.g. "2026-01", "2026-02") for a date range.
*/
export function getMonthKeys(startDate: Date, endDate: Date): string[] {
const keys: string[] = [];
const cursor = new Date(startDate);
cursor.setUTCDate(1);
while (cursor <= endDate) {
const y = cursor.getUTCFullYear();
const m = cursor.getUTCMonth() + 1;
keys.push(`${y}-${String(m).padStart(2, "0")}`);
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return keys;
}
/**
* Count working days in a date range that overlap with an assignment period.
* Excludes weekends. Does NOT exclude holidays (caller should adjust SAH for that).
*/
export function countWorkingDaysInOverlap(
periodStart: Date,
periodEnd: Date,
assignmentStart: Date,
assignmentEnd: Date,
): number {
const start = new Date(Math.max(periodStart.getTime(), assignmentStart.getTime()));
const end = new Date(Math.min(periodEnd.getTime(), assignmentEnd.getTime()));
if (start > end) return 0;
let count = 0;
const cursor = new Date(start);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(end);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
const day = cursor.getUTCDay();
if (day !== 0 && day !== 6) count++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return count;
}
@@ -0,0 +1,17 @@
export {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
} from "./calculator.js";
export type {
ResourceForecastInput,
AssignmentSlice,
ResourceForecast,
GroupChargeabilityInput,
GroupTargetInput,
} from "./calculator.js";