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,144 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { detectOverlaps, validateAvailability } from "../allocation/availability-validator.js";
const stdAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
};
describe("validateAvailability", () => {
it("passes when no existing allocations", () => {
const result = validateAvailability(
new Date("2025-01-06"),
new Date("2025-01-10"),
8,
stdAvailability,
[],
);
expect(result.valid).toBe(true);
expect(result.conflicts).toHaveLength(0);
});
it("detects conflict when combined hours exceed availability", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 6,
status: AllocationStatus.CONFIRMED,
}];
const result = validateAvailability(
new Date("2025-01-06"),
new Date("2025-01-10"),
4, // 6+4=10 > 8
stdAvailability,
existing,
);
expect(result.valid).toBe(false);
expect(result.conflicts).toHaveLength(5); // All 5 working days
});
it("passes when combined hours exactly match availability", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
}];
const result = validateAvailability(
new Date("2025-01-06"),
new Date("2025-01-10"),
4, // 4+4=8 ≤ 8
stdAvailability,
existing,
);
expect(result.valid).toBe(true);
});
it("ignores cancelled allocations", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 8,
status: AllocationStatus.CANCELLED,
}];
const result = validateAvailability(
new Date("2025-01-06"),
new Date("2025-01-10"),
8,
stdAvailability,
existing,
);
expect(result.valid).toBe(true);
});
it("skips non-working days in conflict check", () => {
const result = validateAvailability(
new Date("2025-01-04"), // Saturday
new Date("2025-01-05"), // Sunday
8,
stdAvailability,
[],
);
expect(result.valid).toBe(true);
expect(result.conflicts).toHaveLength(0);
});
});
describe("detectOverlaps", () => {
const existingAlloc = {
id: "alloc-1",
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
projectId: "proj-1",
status: AllocationStatus.ACTIVE,
};
it("detects overlapping allocations", () => {
const overlaps = detectOverlaps(
new Date("2025-01-08"),
new Date("2025-01-15"),
[existingAlloc],
);
expect(overlaps).toContain("alloc-1");
});
it("returns empty for non-overlapping date ranges", () => {
const overlaps = detectOverlaps(
new Date("2025-01-13"), // Starts after existing ends
new Date("2025-01-17"),
[existingAlloc],
);
expect(overlaps).toHaveLength(0);
});
it("excludes same-project allocations when excludeProjectId provided", () => {
const overlaps = detectOverlaps(
new Date("2025-01-08"),
new Date("2025-01-15"),
[existingAlloc],
"proj-1", // Exclude this project
);
expect(overlaps).toHaveLength(0);
});
it("ignores cancelled allocations", () => {
const cancelled = { ...existingAlloc, status: AllocationStatus.CANCELLED };
const overlaps = detectOverlaps(
new Date("2025-01-08"),
new Date("2025-01-15"),
[cancelled],
);
expect(overlaps).toHaveLength(0);
});
});
@@ -0,0 +1,74 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { computeBudgetStatus } from "../budget/monitor.js";
const now = new Date("2025-01-06");
const endDate = new Date("2025-03-07");
const mkAlloc = (
hoursPerDay: number,
dailyCostCents: number,
status: AllocationStatus = AllocationStatus.CONFIRMED,
) => ({
status,
dailyCostCents,
hoursPerDay,
startDate: now,
endDate,
});
describe("computeBudgetStatus", () => {
it("returns 0% utilization for no allocations", () => {
const result = computeBudgetStatus(1000000, 100, [], now, endDate);
expect(result.allocatedCents).toBe(0);
expect(result.utilizationPercent).toBe(0);
expect(result.remainingCents).toBe(1000000);
expect(result.warnings).toHaveLength(0);
});
it("calculates correct utilization", () => {
// 1 alloc, 10 working days, 8000 cents/day = 80000 cents
const alloc = { ...mkAlloc(8, 8000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const result = computeBudgetStatus(400000, 100, [alloc], now, endDate);
expect(result.confirmedCents).toBe(80000);
expect(result.utilizationPercent).toBeCloseTo(20, 0);
});
it("emits WARNING at 85% utilization", () => {
// Budget 100000, alloc cost 87000 = 87%
const alloc = { ...mkAlloc(8, 87000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasWarning = result.warnings.some((w) => w.code === "BUDGET_WARNING");
expect(hasWarning).toBe(true);
});
it("emits CRITICAL at 95% utilization", () => {
const alloc = { ...mkAlloc(8, 96000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasCritical = result.warnings.some((w) => w.code === "BUDGET_CRITICAL");
expect(hasCritical).toBe(true);
});
it("emits EXCEEDED when allocations exceed budget", () => {
const alloc = { ...mkAlloc(8, 120000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasExceeded = result.warnings.some((w) => w.code === "BUDGET_EXCEEDED");
expect(hasExceeded).toBe(true);
});
it("applies win probability weighting", () => {
const alloc = { ...mkAlloc(8, 10000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const result = computeBudgetStatus(1000000, 50, [alloc], now, endDate);
// allocated = 10 days × 10000 = 100000, weighted = 50% = 50000
expect(result.winProbabilityWeightedCents).toBe(Math.round(result.allocatedCents * 0.5));
});
it("separates proposed from confirmed costs", () => {
const confirmed = { ...mkAlloc(8, 5000, AllocationStatus.CONFIRMED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
const proposed = { ...mkAlloc(8, 5000, AllocationStatus.PROPOSED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
const result = computeBudgetStatus(1000000, 100, [confirmed, proposed], now, endDate);
expect(result.confirmedCents).toBeGreaterThan(0);
expect(result.proposedCents).toBeGreaterThan(0);
expect(result.confirmedCents).toBe(result.proposedCents);
});
});
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
calculateAllocation,
calculateTotalCost,
countWorkingDays,
isWorkday,
} from "../allocation/calculator.js";
const stdAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
};
describe("isWorkday", () => {
it("returns true for Monday", () => {
expect(isWorkday(new Date("2025-01-06"), stdAvailability)).toBe(true); // Monday
});
it("returns false for Saturday", () => {
expect(isWorkday(new Date("2025-01-04"), stdAvailability)).toBe(false); // Saturday
});
it("returns false for Sunday", () => {
expect(isWorkday(new Date("2025-01-05"), stdAvailability)).toBe(false); // Sunday
});
});
describe("countWorkingDays", () => {
it("counts 5 days in a Mon-Fri week", () => {
const start = new Date("2025-01-06"); // Monday
const end = new Date("2025-01-10"); // Friday
expect(countWorkingDays(start, end, stdAvailability)).toBe(5);
});
it("counts 10 days over two Mon-Fri weeks", () => {
const start = new Date("2025-01-06"); // Monday
const end = new Date("2025-01-17"); // Friday
expect(countWorkingDays(start, end, stdAvailability)).toBe(10);
});
it("counts 0 for a weekend-only range", () => {
const start = new Date("2025-01-04"); // Saturday
const end = new Date("2025-01-05"); // Sunday
expect(countWorkingDays(start, end, stdAvailability)).toBe(0);
});
it("counts same day as 1 if it's a workday", () => {
const day = new Date("2025-01-06"); // Monday
expect(countWorkingDays(day, day, stdAvailability)).toBe(1);
});
});
describe("calculateAllocation", () => {
it("calculates cost for a full week at 8h/day and 100 EUR/h (10000 cents)", () => {
const result = calculateAllocation({
lcrCents: 10000, // 100 EUR/h
hoursPerDay: 8,
startDate: new Date("2025-01-06"), // Monday
endDate: new Date("2025-01-10"), // Friday
availability: stdAvailability,
});
expect(result.workingDays).toBe(5);
expect(result.totalHours).toBe(40);
// 5 days × 8h × 10000 cents/h = 400000 cents = 4000 EUR
expect(result.totalCostCents).toBe(400000);
expect(result.dailyCostCents).toBe(80000);
expect(result.dailyBreakdown).toHaveLength(5);
});
it("skips weekend days in breakdown", () => {
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: new Date("2025-01-04"), // Saturday
endDate: new Date("2025-01-12"), // Sunday
availability: stdAvailability,
});
expect(result.workingDays).toBe(5);
const weekendDays = result.dailyBreakdown.filter((d) => !d.isWorkday);
expect(weekendDays).toHaveLength(4); // 2 weekends
});
it("caps hours at available hours if requested > available", () => {
const limitedAvailability = { ...stdAvailability, monday: 4 };
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8, // Requested more than available
startDate: new Date("2025-01-06"), // Monday only
endDate: new Date("2025-01-06"),
availability: limitedAvailability,
});
expect(result.totalHours).toBe(4); // Capped at availability
expect(result.totalCostCents).toBe(40000); // 4h × 10000 cents
});
it("returns zero for date range with no working days", () => {
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: new Date("2025-01-04"), // Saturday
endDate: new Date("2025-01-05"), // Sunday
availability: stdAvailability,
});
expect(result.workingDays).toBe(0);
expect(result.totalHours).toBe(0);
expect(result.totalCostCents).toBe(0);
});
});
describe("calculateTotalCost", () => {
it("calculates correctly", () => {
// 10000 cents/h × 8h × 5 days = 400000 cents
expect(calculateTotalCost(10000, 8, 5)).toBe(400000);
});
it("returns 0 for 0 working days", () => {
expect(calculateTotalCost(10000, 8, 0)).toBe(0);
});
});
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
} from "../chargeability/calculator.js";
// ─── deriveResourceForecast ──────────────────────────────────────────────────
describe("deriveResourceForecast", () => {
it("returns unassigned=1 when sah=0", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [],
sah: 0,
});
expect(result).toEqual({ chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 });
});
it("fully assigned to chargeable work", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "Chg" }],
sah: 160, // 8h * 20d
});
expect(result.chg).toBeCloseTo(1, 5);
expect(result.unassigned).toBeCloseTo(0, 5);
});
it("50% chargeable, 25% BD, rest unassigned", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" },
{ hoursPerDay: 2, workingDays: 20, categoryCode: "BD" },
],
sah: 160,
});
expect(result.chg).toBeCloseTo(0.5, 5);
expect(result.bd).toBeCloseTo(0.25, 5);
expect(result.unassigned).toBeCloseTo(0.25, 5);
});
it("handles case-insensitive category codes", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [
{ hoursPerDay: 8, workingDays: 10, categoryCode: "chg" },
{ hoursPerDay: 8, workingDays: 10, categoryCode: "CHG" },
],
sah: 160,
});
expect(result.chg).toBeCloseTo(1, 5);
});
it("handles MD&I and M&O codes with ampersand", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [
{ hoursPerDay: 4, workingDays: 20, categoryCode: "MD&I" },
{ hoursPerDay: 4, workingDays: 20, categoryCode: "M&O" },
],
sah: 160,
});
expect(result.mdi).toBeCloseTo(0.5, 5);
expect(result.mo).toBeCloseTo(0.5, 5);
});
it("handles PD&R code", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "PD&R" }],
sah: 160,
});
expect(result.pdr).toBeCloseTo(1, 5);
});
it("handles absence category", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" },
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Absence" },
],
sah: 160,
});
expect(result.chg).toBeCloseTo(0.5, 5);
expect(result.absence).toBeCloseTo(0.5, 5);
expect(result.unassigned).toBeCloseTo(0, 5);
});
it("clamps ratios at 1 when over-assigned", () => {
const result = deriveResourceForecast({
fte: 1,
targetPercentage: 0.8,
assignments: [{ hoursPerDay: 16, workingDays: 20, categoryCode: "Chg" }],
sah: 160,
});
expect(result.chg).toBe(1);
});
it("multiple assignments same category are summed", () => {
const result = deriveResourceForecast({
fte: 0.5,
targetPercentage: 0.8,
assignments: [
{ hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" },
{ hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" },
],
sah: 80,
});
expect(result.chg).toBeCloseTo(1, 5);
});
});
// ─── calculateGroupChargeability ─────────────────────────────────────────────
describe("calculateGroupChargeability", () => {
it("returns 0 for empty group", () => {
expect(calculateGroupChargeability([])).toBe(0);
});
it("single resource returns its chargeability", () => {
expect(calculateGroupChargeability([{ fte: 1, chargeability: 0.75 }])).toBeCloseTo(0.75, 5);
});
it("FTE-weighted average of two resources", () => {
const result = calculateGroupChargeability([
{ fte: 1, chargeability: 0.8 },
{ fte: 0.5, chargeability: 0.6 },
]);
// (1*0.8 + 0.5*0.6) / (1 + 0.5) = 1.1 / 1.5 = 0.7333...
expect(result).toBeCloseTo(0.7333, 3);
});
it("returns 0 when all FTEs are 0", () => {
expect(calculateGroupChargeability([{ fte: 0, chargeability: 0.9 }])).toBe(0);
});
});
// ─── calculateGroupTarget ────────────────────────────────────────────────────
describe("calculateGroupTarget", () => {
it("returns 0 for empty group", () => {
expect(calculateGroupTarget([])).toBe(0);
});
it("FTE-weighted target average", () => {
const result = calculateGroupTarget([
{ fte: 1, targetPercentage: 0.8 },
{ fte: 1, targetPercentage: 0.6 },
]);
expect(result).toBeCloseTo(0.7, 5);
});
});
// ─── sumFte ──────────────────────────────────────────────────────────────────
describe("sumFte", () => {
it("sums FTE values", () => {
expect(sumFte([{ fte: 1 }, { fte: 0.5 }, { fte: 0.75 }])).toBeCloseTo(2.25, 5);
});
it("returns 0 for empty array", () => {
expect(sumFte([])).toBe(0);
});
});
// ─── getMonthRange ───────────────────────────────────────────────────────────
describe("getMonthRange", () => {
it("January 2026", () => {
const { start, end } = getMonthRange(2026, 1);
expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z");
expect(end.toISOString()).toBe("2026-01-31T00:00:00.000Z");
});
it("February 2024 (leap year)", () => {
const { start, end } = getMonthRange(2024, 2);
expect(start.toISOString()).toBe("2024-02-01T00:00:00.000Z");
expect(end.toISOString()).toBe("2024-02-29T00:00:00.000Z");
});
it("February 2025 (non-leap year)", () => {
const { start, end } = getMonthRange(2025, 2);
expect(end.toISOString()).toBe("2025-02-28T00:00:00.000Z");
});
});
// ─── getMonthKeys ────────────────────────────────────────────────────────────
describe("getMonthKeys", () => {
it("generates keys for Q1 2026", () => {
const keys = getMonthKeys(new Date("2026-01-01"), new Date("2026-03-31"));
expect(keys).toEqual(["2026-01", "2026-02", "2026-03"]);
});
it("single month", () => {
const keys = getMonthKeys(new Date("2026-06-15"), new Date("2026-06-20"));
expect(keys).toEqual(["2026-06"]);
});
it("cross-year boundary", () => {
const keys = getMonthKeys(new Date("2025-11-01"), new Date("2026-02-28"));
expect(keys).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]);
});
});
// ─── countWorkingDaysInOverlap ───────────────────────────────────────────────
describe("countWorkingDaysInOverlap", () => {
it("full overlap on a workweek = 5 days", () => {
const mon = new Date("2026-01-05");
const fri = new Date("2026-01-09");
expect(countWorkingDaysInOverlap(mon, fri, mon, fri)).toBe(5);
});
it("no overlap returns 0", () => {
expect(
countWorkingDaysInOverlap(
new Date("2026-01-05"),
new Date("2026-01-09"),
new Date("2026-01-12"),
new Date("2026-01-16"),
),
).toBe(0);
});
it("partial overlap: assignment starts mid-week", () => {
// Period Mon-Fri, assignment Wed-next Mon → overlap Wed-Fri = 3
expect(
countWorkingDaysInOverlap(
new Date("2026-01-05"), // Mon
new Date("2026-01-09"), // Fri
new Date("2026-01-07"), // Wed
new Date("2026-01-12"), // next Mon
),
).toBe(3);
});
it("excludes weekends from overlap", () => {
// Period: Mon-next Fri (10 calendar days, 10 working days)
// Assignment: entire same range
expect(
countWorkingDaysInOverlap(
new Date("2026-01-05"), // Mon
new Date("2026-01-16"), // Fri
new Date("2026-01-05"),
new Date("2026-01-16"),
),
).toBe(10);
});
it("weekend-only overlap returns 0", () => {
expect(
countWorkingDaysInOverlap(
new Date("2026-01-10"), // Sat
new Date("2026-01-11"), // Sun
new Date("2026-01-10"),
new Date("2026-01-11"),
),
).toBe(0);
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
computeAvailableHours,
computeBookedHours,
computeChargeability,
} from "../allocation/chargeability.js";
// Standard MonFri 8h availability
const stdAvail = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
// Week of 2025-01-06 (Mon) to 2025-01-10 (Fri)
const weekStart = new Date("2025-01-06");
const weekEnd = new Date("2025-01-10");
describe("computeAvailableHours", () => {
it("MonFri 8h resource: 1 calendar week = 40 available hours", () => {
expect(computeAvailableHours(stdAvail, weekStart, weekEnd)).toBe(40);
});
it("excludes Saturday and Sunday when not in availability", () => {
// 2025-01-04 (Sat) to 2025-01-05 (Sun)
expect(computeAvailableHours(stdAvail, new Date("2025-01-04"), new Date("2025-01-05"))).toBe(0);
});
it("counts Saturday when enabled in availability", () => {
const avail = { ...stdAvail, saturday: 4 };
// Mon 06 to Sat 11 Jan: 5 weekdays × 8 + 1 Saturday × 4 = 44
expect(computeAvailableHours(avail, new Date("2025-01-06"), new Date("2025-01-11"))).toBe(44);
});
it("single day period (Monday): returns daily hours", () => {
expect(computeAvailableHours(stdAvail, weekStart, weekStart)).toBe(8);
});
});
describe("computeBookedHours", () => {
it("no allocations → bookedHours = 0", () => {
expect(computeBookedHours(stdAvail, [], weekStart, weekEnd)).toBe(0);
});
it("full 8h allocation covering entire period counts all working days", () => {
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
});
it("allocation partially overlapping: only overlap days counted", () => {
// Allocation spans Wed-Sun; period is Mon-Fri → overlap is Wed-Fri (3 days)
const alloc = {
startDate: new Date("2025-01-08"), // Wed
endDate: new Date("2025-01-12"), // Sun
hoursPerDay: 8,
};
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(24); // 3 days × 8
});
it("allocation spanning weekends: weekends excluded from booked hours", () => {
// Mon 06 to Mon 13 (spans a weekend), but weekend has 0 avail
const alloc = {
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-13"),
hoursPerDay: 8,
};
// Period is Mon-Fri week only
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
});
it("Saturday with availability counted when allocation covers it", () => {
const avail = { ...stdAvail, saturday: 4 };
const alloc = {
startDate: new Date("2025-01-06"), // Mon
endDate: new Date("2025-01-11"), // Sat
hoursPerDay: 6,
};
// MonFri: 5 days × 6 = 30, Sat: 1 day × 6 = 6 → total 36
expect(computeBookedHours(avail, [alloc], new Date("2025-01-06"), new Date("2025-01-11"))).toBe(36);
});
it("allocation entirely before period: no hours counted", () => {
const alloc = {
startDate: new Date("2024-12-30"),
endDate: new Date("2025-01-03"),
hoursPerDay: 8,
};
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
});
it("allocation entirely after period: no hours counted", () => {
const alloc = {
startDate: new Date("2025-01-13"),
endDate: new Date("2025-01-17"),
hoursPerDay: 8,
};
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
});
});
describe("computeChargeability", () => {
it("no allocations → chargeability = 0, bookedHours = 0", () => {
const result = computeChargeability(stdAvail, [], weekStart, weekEnd);
expect(result.chargeability).toBe(0);
expect(result.bookedHours).toBe(0);
expect(result.availableHours).toBe(40);
});
it("full 8h allocation covering entire period → chargeability = 100", () => {
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
expect(result.chargeability).toBe(100);
expect(result.bookedHours).toBe(40);
});
it("50% allocation → chargeability = 50", () => {
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 };
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
expect(result.chargeability).toBe(50);
expect(result.bookedHours).toBe(20);
});
it("over-allocated (booked > available): chargeability capped at 100", () => {
// Two allocations totalling 12h/day on a resource with 8h availability
const allocs = [
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 },
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 },
];
const result = computeChargeability(stdAvail, allocs, weekStart, weekEnd);
expect(result.chargeability).toBe(100);
expect(result.bookedHours).toBe(60); // not capped in raw value
});
it("single day period (start === end): correct result", () => {
const alloc = { startDate: weekStart, endDate: weekStart, hoursPerDay: 4 };
const result = computeChargeability(stdAvail, [alloc], weekStart, weekStart);
expect(result.availableHours).toBe(8);
expect(result.bookedHours).toBe(4);
expect(result.chargeability).toBe(50);
});
it("zero available hours (all weekend period): chargeability = 0", () => {
const alloc = {
startDate: new Date("2025-01-04"),
endDate: new Date("2025-01-05"),
hoursPerDay: 8,
};
const result = computeChargeability(stdAvail, [alloc], new Date("2025-01-04"), new Date("2025-01-05"));
expect(result.availableHours).toBe(0);
expect(result.chargeability).toBe(0);
});
});
@@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import {
expandScopeToEffort,
aggregateByDiscipline,
type EffortRuleInput,
type ScopeItemInput,
} from "../estimate/effort-rules.js";
const STANDARD_RULES: EffortRuleInput[] = [
{ scopeType: "SHOT", discipline: "3D Animation", chapter: "Animation", unitMode: "per_frame", hoursPerUnit: 0.5, sortOrder: 0 },
{ scopeType: "SHOT", discipline: "3D Lighting", chapter: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.2, sortOrder: 1 },
{ scopeType: "SHOT", discipline: "Compositing", chapter: "Compositing", unitMode: "per_frame", hoursPerUnit: 0.15, sortOrder: 2 },
{ scopeType: "ASSET", discipline: "3D Modeling", chapter: "Modeling", unitMode: "per_item", hoursPerUnit: 40, sortOrder: 0 },
{ scopeType: "ASSET", discipline: "3D Rigging", chapter: "Rigging", unitMode: "per_item", hoursPerUnit: 24, sortOrder: 1 },
{ scopeType: "ENVIRONMENT", discipline: "3D Environment", chapter: "Environment", unitMode: "flat", hoursPerUnit: 80, sortOrder: 0 },
];
describe("expandScopeToEffort", () => {
it("expands a single shot scope item into multiple discipline lines", () => {
const items: ScopeItemInput[] = [
{ name: "Shot 001", scopeType: "SHOT", frameCount: 120 },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
expect(result.lines).toHaveLength(3);
expect(result.warnings).toHaveLength(0);
expect(result.unmatchedScopeItems).toHaveLength(0);
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
expect(anim.hours).toBe(60); // 120 * 0.5
expect(anim.unitCount).toBe(120);
expect(anim.chapter).toBe("Animation");
const lighting = result.lines.find((l) => l.discipline === "3D Lighting")!;
expect(lighting.hours).toBe(24); // 120 * 0.2
const comp = result.lines.find((l) => l.discipline === "Compositing")!;
expect(comp.hours).toBe(18); // 120 * 0.15
});
it("expands asset scope items using per_item mode", () => {
const items: ScopeItemInput[] = [
{ name: "Hero Character", scopeType: "ASSET", itemCount: 3 },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
expect(result.lines).toHaveLength(2);
const modeling = result.lines.find((l) => l.discipline === "3D Modeling")!;
expect(modeling.hours).toBe(120); // 3 * 40
const rigging = result.lines.find((l) => l.discipline === "3D Rigging")!;
expect(rigging.hours).toBe(72); // 3 * 24
});
it("uses flat hours for environment scope items", () => {
const items: ScopeItemInput[] = [
{ name: "Forest Scene", scopeType: "ENVIRONMENT" },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
expect(result.lines).toHaveLength(1);
expect(result.lines[0]!.hours).toBe(80); // flat
expect(result.lines[0]!.unitCount).toBe(1);
});
it("defaults to 1 when frameCount is null in per_frame mode", () => {
const items: ScopeItemInput[] = [
{ name: "Shot X", scopeType: "SHOT", frameCount: null },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
expect(anim.unitCount).toBe(1);
expect(anim.hours).toBe(0.5); // 1 * 0.5
});
it("handles multiple scope items", () => {
const items: ScopeItemInput[] = [
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
{ name: "Prop A", scopeType: "ASSET", itemCount: 1 },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
// 2 shots * 3 rules + 1 asset * 2 rules = 8 lines
expect(result.lines).toHaveLength(8);
});
it("reports unmatched scope items", () => {
const items: ScopeItemInput[] = [
{ name: "Audio Track", scopeType: "AUDIO" },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
expect(result.lines).toHaveLength(0);
expect(result.unmatchedScopeItems).toContain("Audio Track");
expect(result.warnings.length).toBeGreaterThan(0);
});
it("matches scope type case-insensitively", () => {
const items: ScopeItemInput[] = [
{ name: "Shot 001", scopeType: "shot", frameCount: 50 },
];
const result = expandScopeToEffort(items, STANDARD_RULES);
expect(result.lines).toHaveLength(3);
});
it("warns when no rules provided", () => {
const result = expandScopeToEffort(
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
[],
);
expect(result.lines).toHaveLength(0);
expect(result.warnings).toContain("No effort rules provided.");
});
it("skips lines with zero hours", () => {
const rules: EffortRuleInput[] = [
{ scopeType: "SHOT", discipline: "QC", unitMode: "per_frame", hoursPerUnit: 0, sortOrder: 0 },
];
const result = expandScopeToEffort(
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
rules,
);
expect(result.lines).toHaveLength(0);
expect(result.warnings.some((w) => w.includes("Skipped"))).toBe(true);
});
it("skips empty-name scope items", () => {
const result = expandScopeToEffort(
[{ name: "", scopeType: "SHOT", frameCount: 100 }],
STANDARD_RULES,
);
expect(result.lines).toHaveLength(0);
});
it("rounds hours to 2 decimal places", () => {
const rules: EffortRuleInput[] = [
{ scopeType: "SHOT", discipline: "FX", unitMode: "per_frame", hoursPerUnit: 0.333, sortOrder: 0 },
];
const result = expandScopeToEffort(
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 7 }],
rules,
);
// 7 * 0.333 = 2.331
expect(result.lines[0]!.hours).toBe(2.33);
});
it("preserves sortOrder in output order", () => {
const rules: EffortRuleInput[] = [
{ scopeType: "SHOT", discipline: "Z-Last", unitMode: "flat", hoursPerUnit: 1, sortOrder: 10 },
{ scopeType: "SHOT", discipline: "A-First", unitMode: "flat", hoursPerUnit: 1, sortOrder: 0 },
];
const result = expandScopeToEffort(
[{ name: "Shot 001", scopeType: "SHOT" }],
rules,
);
expect(result.lines[0]!.discipline).toBe("A-First");
expect(result.lines[1]!.discipline).toBe("Z-Last");
});
});
describe("aggregateByDiscipline", () => {
it("sums hours across scope items per discipline", () => {
const items: ScopeItemInput[] = [
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
];
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
const aggregated = aggregateByDiscipline(lines);
const anim = aggregated.find((a) => a.discipline === "3D Animation")!;
expect(anim.totalHours).toBe(150); // (100+200) * 0.5
expect(anim.lineCount).toBe(2);
});
it("sorts by totalHours descending", () => {
const items: ScopeItemInput[] = [
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
];
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
const aggregated = aggregateByDiscipline(lines);
expect(aggregated[0]!.totalHours).toBeGreaterThanOrEqual(aggregated[1]!.totalHours);
});
it("keeps discipline+chapter pairs separate", () => {
const rules: EffortRuleInput[] = [
{ scopeType: "SHOT", discipline: "Animation", chapter: "2D", unitMode: "flat", hoursPerUnit: 10, sortOrder: 0 },
{ scopeType: "SHOT", discipline: "Animation", chapter: "3D", unitMode: "flat", hoursPerUnit: 20, sortOrder: 1 },
];
const { lines } = expandScopeToEffort(
[{ name: "Shot 001", scopeType: "SHOT" }],
rules,
);
const aggregated = aggregateByDiscipline(lines);
expect(aggregated).toHaveLength(2);
const anim2d = aggregated.find((a) => a.chapter === "2D")!;
expect(anim2d.totalHours).toBe(10);
const anim3d = aggregated.find((a) => a.chapter === "3D")!;
expect(anim3d.totalHours).toBe(20);
});
it("handles empty input", () => {
expect(aggregateByDiscipline([])).toHaveLength(0);
});
});
@@ -0,0 +1,173 @@
import * as XLSX from "xlsx";
import {
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import {
serializeEstimateExport,
type EstimateExportSource,
} from "../estimate/export-serializer.js";
const createdAt = new Date("2026-03-13T08:00:00.000Z");
function buildSource(): EstimateExportSource {
return {
estimate: {
id: "est_1",
projectId: "project_1",
name: "CGI Estimate",
opportunityId: "OP-42",
baseCurrency: "EUR",
status: EstimateStatus.APPROVED,
createdAt,
updatedAt: createdAt,
},
project: {
id: "project_1",
shortCode: "CGI-001",
name: "CGI Project",
status: "ACTIVE",
startDate: "2026-03-01T00:00:00.000Z",
endDate: "2026-04-01T00:00:00.000Z",
},
version: {
id: "ver_1",
versionNumber: 1,
label: "Approved",
status: EstimateVersionStatus.APPROVED,
notes: "Ready for export",
lockedAt: createdAt,
projectSnapshot: {},
createdAt,
updatedAt: createdAt,
assumptions: [
{
id: "assumption_1",
category: "commercial",
key: "pricingStructure",
label: "Pricing Structure",
valueType: "string",
value: "fixed-bid",
sortOrder: 0,
notes: null,
createdAt,
updatedAt: createdAt,
},
],
scopeItems: [
{
id: "scope_1",
sequenceNo: 10,
scopeType: "SHOT",
packageCode: "PKG-1",
name: "Shot 010",
description: "Main hero shot",
scene: "Scene 01",
page: null,
location: "Berlin",
assumptionCategory: null,
technicalSpec: { resolution: "4K" },
frameCount: 120,
itemCount: 1,
unitMode: "shot",
internalComments: null,
externalComments: null,
metadata: {},
createdAt,
updatedAt: createdAt,
},
],
demandLines: [
{
id: "line_1",
scopeItemId: "scope_1",
roleId: "role_1",
resourceId: "resource_1",
lineType: "LABOR",
name: "Comp Artist",
chapter: "Compositing",
hours: 40,
days: 5,
fte: 1,
rateSource: "resource",
costRateCents: 5000,
billRateCents: 8000,
currency: "EUR",
costTotalCents: 200000,
priceTotalCents: 320000,
monthlySpread: { "2026-03": 40 },
staffingAttributes: { location: "Berlin" },
metadata: {},
createdAt,
updatedAt: createdAt,
},
],
resourceSnapshots: [
{
id: "snapshot_1",
resourceId: "resource_1",
sourceEid: "E100",
displayName: "Alex Artist",
chapter: "Compositing",
roleId: "role_1",
currency: "EUR",
lcrCents: 5000,
ucrCents: 8000,
fte: 1,
location: "Berlin",
country: "DE",
level: "Senior",
workType: "Remote",
attributes: {},
createdAt,
updatedAt: createdAt,
},
],
metrics: [
{
id: "metric_1",
key: "total_hours",
label: "Total Hours",
metricGroup: "summary",
valueDecimal: 40,
valueCents: null,
currency: null,
metadata: {},
createdAt,
updatedAt: createdAt,
},
],
},
};
}
describe("estimate export serializer", () => {
it("creates a structured JSON export payload", () => {
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.JSON);
expect(payload.encoding).toBe("utf8");
expect(payload.mimeType).toBe("application/json; charset=utf-8");
expect(payload.summary.totalHours).toBe(40);
expect(payload.content).toContain('"estimateId": "est_1"');
expect(payload.previewText).toContain('"schemaVersion": 1');
});
it("creates a multi-sheet xlsx export payload", () => {
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX);
const workbook = XLSX.read(payload.content, { type: "base64" });
expect(payload.encoding).toBe("base64");
expect(payload.sheetNames).toEqual([
"Overview",
"Assumptions",
"Scope",
"DemandLines",
"Resources",
"Metrics",
]);
expect(workbook.SheetNames).toContain("DemandLines");
expect(payload.byteLength).toBeGreaterThan(100);
});
});
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
} from "../estimate/index.js";
describe("summarizeEstimateDemandLines", () => {
it("aggregates hours, totals, and margin", () => {
expect(
summarizeEstimateDemandLines([
{ hours: 10, costTotalCents: 100_00, priceTotalCents: 180_00 },
{ hours: 6.5, costTotalCents: 80_00, priceTotalCents: 120_00 },
]),
).toEqual({
totalHours: 16.5,
totalCostCents: 180_00,
totalPriceCents: 300_00,
marginCents: 120_00,
marginPercent: 40,
});
});
it("returns zero margin percent when no price exists", () => {
expect(
summarizeEstimateDemandLines([
{ hours: 4, costTotalCents: 50_00, priceTotalCents: 0 },
]),
).toEqual({
totalHours: 4,
totalCostCents: 50_00,
totalPriceCents: 0,
marginCents: -50_00,
marginPercent: 0,
});
});
});
describe("normalizeEstimateDemandLine", () => {
it("recomputes linked live rates and totals from the resource snapshot", () => {
expect(
normalizeEstimateDemandLine(
{
resourceId: "res_1",
hours: 12,
rateSource: "RESOURCE",
costRateCents: 4000,
billRateCents: 6500,
currency: "EUR",
costTotalCents: 0,
priceTotalCents: 0,
metadata: {
calculation: {
costRateMode: "resource",
billRateMode: "resource",
totalMode: "computed",
},
},
},
{
resourceSnapshot: {
resourceId: "res_1",
currency: "USD",
lcrCents: 5000,
ucrCents: 8000,
},
defaultCurrency: "EUR",
},
),
).toMatchObject({
costRateCents: 5000,
billRateCents: 8000,
currency: "USD",
costTotalCents: 60000,
priceTotalCents: 96000,
metadata: {
calculation: {
costRateMode: "resource",
billRateMode: "resource",
totalMode: "computed",
liveCostRateCents: 5000,
liveBillRateCents: 8000,
liveCurrency: "USD",
},
},
});
});
it("preserves manual override rates while still updating trace metadata", () => {
expect(
normalizeEstimateDemandLine(
{
resourceId: "res_2",
hours: 10,
rateSource: "RESOURCE",
costRateCents: 7200,
billRateCents: 9500,
currency: "EUR",
costTotalCents: 0,
priceTotalCents: 0,
metadata: {
calculation: {
costRateMode: "manual",
billRateMode: "resource",
totalMode: "computed",
},
},
},
{
resourceSnapshot: {
resourceId: "res_2",
currency: "EUR",
lcrCents: 5000,
ucrCents: 9000,
},
defaultCurrency: "EUR",
},
),
).toMatchObject({
costRateCents: 7200,
billRateCents: 9000,
costTotalCents: 72000,
priceTotalCents: 90000,
metadata: {
calculation: {
costRateMode: "manual",
billRateMode: "resource",
liveCostRateCents: 5000,
liveBillRateCents: 9000,
},
},
});
});
});
@@ -0,0 +1,330 @@
import { describe, it, expect } from "vitest";
import {
findBestMatchingRule,
applyExperienceMultipliers,
applyExperienceMultipliersBatch,
type ExperienceMultiplierRule,
type RateAdjustmentInput,
} from "../estimate/experience-multiplier.js";
// ─── Fixtures ────────────────────────────────────────────────────────────────
const globalRule: ExperienceMultiplierRule = {
costMultiplier: 1.1,
billMultiplier: 1.1,
description: "Global 10% uplift",
};
const chapterRule: ExperienceMultiplierRule = {
chapter: "Animation",
costMultiplier: 1.2,
billMultiplier: 1.3,
};
const locationRule: ExperienceMultiplierRule = {
location: "India",
costMultiplier: 0.6,
billMultiplier: 0.8,
};
const levelRule: ExperienceMultiplierRule = {
level: "Senior",
costMultiplier: 1.5,
billMultiplier: 1.6,
};
const chapterLocationRule: ExperienceMultiplierRule = {
chapter: "Animation",
location: "India",
costMultiplier: 0.7,
billMultiplier: 0.85,
shoringRatio: 0.5,
additionalEffortRatio: 0.2,
};
const chapterLevelRule: ExperienceMultiplierRule = {
chapter: "Animation",
level: "Senior",
costMultiplier: 1.4,
billMultiplier: 1.5,
};
const locationLevelRule: ExperienceMultiplierRule = {
location: "India",
level: "Senior",
costMultiplier: 0.8,
billMultiplier: 0.9,
};
const exactRule: ExperienceMultiplierRule = {
chapter: "Animation",
location: "India",
level: "Senior",
costMultiplier: 0.9,
billMultiplier: 1.0,
shoringRatio: 0.3,
additionalEffortRatio: 0.1,
description: "Exact match rule",
};
const allRules: ExperienceMultiplierRule[] = [
globalRule,
chapterRule,
locationRule,
levelRule,
chapterLocationRule,
chapterLevelRule,
locationLevelRule,
exactRule,
];
// ─── findBestMatchingRule ────────────────────────────────────────────────────
describe("findBestMatchingRule", () => {
it("returns null when no rules are provided", () => {
expect(findBestMatchingRule({ chapter: "X" }, [])).toBeNull();
});
it("returns null when no rule matches", () => {
const rules: ExperienceMultiplierRule[] = [
{ chapter: "Compositing", costMultiplier: 1, billMultiplier: 1 },
];
expect(findBestMatchingRule({ chapter: "Animation" }, rules)).toBeNull();
});
it("matches exact chapter+location+level (specificity 7)", () => {
const result = findBestMatchingRule(
{ chapter: "Animation", location: "India", level: "Senior" },
allRules,
);
expect(result).toBe(exactRule);
});
it("matches chapter+location (specificity 6) when level does not match exact", () => {
const result = findBestMatchingRule(
{ chapter: "Animation", location: "India", level: "Junior" },
allRules,
);
expect(result).toBe(chapterLocationRule);
});
it("matches chapter+level (specificity 5) when location differs", () => {
const result = findBestMatchingRule(
{ chapter: "Animation", location: "Germany", level: "Senior" },
allRules,
);
expect(result).toBe(chapterLevelRule);
});
it("matches chapter only (specificity 4)", () => {
const result = findBestMatchingRule(
{ chapter: "Animation", location: "Germany", level: "Junior" },
allRules,
);
expect(result).toBe(chapterRule);
});
it("matches location+level (specificity 3)", () => {
const result = findBestMatchingRule(
{ chapter: "Compositing", location: "India", level: "Senior" },
allRules,
);
expect(result).toBe(locationLevelRule);
});
it("matches location only (specificity 2)", () => {
const result = findBestMatchingRule(
{ chapter: "Compositing", location: "India", level: "Junior" },
allRules,
);
expect(result).toBe(locationRule);
});
it("matches level only (specificity 1)", () => {
const result = findBestMatchingRule(
{ chapter: "Compositing", location: "Germany", level: "Senior" },
allRules,
);
expect(result).toBe(levelRule);
});
it("falls back to global rule (specificity 0)", () => {
const result = findBestMatchingRule(
{ chapter: "Compositing", location: "Germany", level: "Junior" },
allRules,
);
expect(result).toBe(globalRule);
});
it("is case-insensitive when matching", () => {
const rules: ExperienceMultiplierRule[] = [
{ chapter: "ANIMATION", location: "india", costMultiplier: 1, billMultiplier: 1 },
];
const result = findBestMatchingRule(
{ chapter: "animation", location: "India" },
rules,
);
expect(result).toBe(rules[0]);
});
it("treats null / undefined input fields as wildcard-matchable", () => {
const result = findBestMatchingRule({}, [globalRule]);
expect(result).toBe(globalRule);
});
});
// ─── applyExperienceMultipliers ─────────────────────────────────────────────
describe("applyExperienceMultipliers", () => {
const baseInput: RateAdjustmentInput = {
costRateCents: 10000,
billRateCents: 15000,
hours: 100,
chapter: "Animation",
location: "India",
level: "Senior",
};
it("returns unchanged values when no rules provided", () => {
const result = applyExperienceMultipliers(baseInput, []);
expect(result.adjustedCostRateCents).toBe(10000);
expect(result.adjustedBillRateCents).toBe(15000);
expect(result.adjustedHours).toBe(100);
expect(result.appliedRules).toHaveLength(1);
expect(result.appliedRules[0]).toContain("No rules provided");
});
it("returns unchanged values when no rule matches", () => {
const rules: ExperienceMultiplierRule[] = [
{ chapter: "Compositing", costMultiplier: 2, billMultiplier: 2 },
];
const result = applyExperienceMultipliers(
{ ...baseInput, chapter: "Animation" },
rules,
);
expect(result.adjustedCostRateCents).toBe(10000);
expect(result.adjustedBillRateCents).toBe(15000);
});
it("applies rate multipliers correctly and rounds to integer cents", () => {
const rules: ExperienceMultiplierRule[] = [
{ chapter: "Animation", costMultiplier: 1.15, billMultiplier: 1.33 },
];
const result = applyExperienceMultipliers(
{ costRateCents: 10000, billRateCents: 15000, hours: 80, chapter: "Animation" },
rules,
);
expect(result.adjustedCostRateCents).toBe(11500); // 10000 * 1.15
expect(result.adjustedBillRateCents).toBe(19950); // 15000 * 1.33
expect(result.adjustedHours).toBe(80); // no shoring
});
it("handles rounding edge case (e.g. 3333 * 1.1 = 3666.3 => 3666)", () => {
const rules: ExperienceMultiplierRule[] = [
{ costMultiplier: 1.1, billMultiplier: 1.0 },
];
const result = applyExperienceMultipliers(
{ costRateCents: 3333, billRateCents: 5000, hours: 10 },
rules,
);
expect(result.adjustedCostRateCents).toBe(3666); // Math.round(3666.3)
});
it("applies shoring ratio to hours", () => {
const rules: ExperienceMultiplierRule[] = [
{
costMultiplier: 1.0,
billMultiplier: 1.0,
shoringRatio: 0.5,
additionalEffortRatio: 0,
},
];
const result = applyExperienceMultipliers(
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
rules,
);
// 50 onsite + 50 offshore * (1 + 0) = 100 — no additional effort means hours unchanged
expect(result.adjustedHours).toBe(100);
});
it("applies shoring ratio with additional effort factor", () => {
const rules: ExperienceMultiplierRule[] = [
{
costMultiplier: 1.0,
billMultiplier: 1.0,
shoringRatio: 0.5,
additionalEffortRatio: 0.2,
},
];
const result = applyExperienceMultipliers(
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
rules,
);
// onsite = 100 * 0.5 = 50, offshore = 100 * 0.5 * 1.2 = 60 => total = 110
expect(result.adjustedHours).toBe(110);
});
it("applies both rate multipliers and shoring together", () => {
const result = applyExperienceMultipliers(baseInput, allRules);
// Should match exactRule (chapter=Animation, location=India, level=Senior)
expect(result.adjustedCostRateCents).toBe(Math.round(10000 * 0.9)); // 9000
expect(result.adjustedBillRateCents).toBe(Math.round(15000 * 1.0)); // 15000
// shoring: 0.3, additional: 0.1
// onsite = 100 * 0.7 = 70, offshore = 100 * 0.3 * 1.1 = 33 => 103
expect(result.adjustedHours).toBe(103);
expect(result.appliedRules.length).toBeGreaterThanOrEqual(2);
});
it("includes rule description in audit trail when present", () => {
const result = applyExperienceMultipliers(baseInput, allRules);
const descRule = result.appliedRules.find((r) => r.includes("Exact match rule"));
expect(descRule).toBeDefined();
});
it("reports no-op when multipliers are 1.0 and no shoring", () => {
const rules: ExperienceMultiplierRule[] = [
{ costMultiplier: 1.0, billMultiplier: 1.0 },
];
const result = applyExperienceMultipliers(
{ costRateCents: 5000, billRateCents: 8000, hours: 40 },
rules,
);
expect(result.adjustedCostRateCents).toBe(5000);
expect(result.adjustedBillRateCents).toBe(8000);
expect(result.adjustedHours).toBe(40);
expect(result.appliedRules[0]).toContain("unchanged");
});
});
// ─── applyExperienceMultipliersBatch ────────────────────────────────────────
describe("applyExperienceMultipliersBatch", () => {
it("processes multiple inputs and returns correct summary", () => {
const rules: ExperienceMultiplierRule[] = [
{ chapter: "Animation", costMultiplier: 1.2, billMultiplier: 1.3 },
{ costMultiplier: 1.0, billMultiplier: 1.0 }, // global fallback
];
const inputs: RateAdjustmentInput[] = [
{ costRateCents: 10000, billRateCents: 15000, hours: 50, chapter: "Animation" },
{ costRateCents: 8000, billRateCents: 12000, hours: 30, chapter: "Compositing" },
];
const batch = applyExperienceMultipliersBatch(inputs, rules);
expect(batch.results).toHaveLength(2);
expect(batch.totalOriginalHours).toBe(80);
expect(batch.totalAdjustedHours).toBe(80);
// First line adjusted (cost * 1.2), second not (1.0)
expect(batch.linesAdjusted).toBe(1);
expect(batch.results[0]!.adjustedCostRateCents).toBe(12000);
expect(batch.results[1]!.adjustedCostRateCents).toBe(8000);
});
it("handles empty input array", () => {
const batch = applyExperienceMultipliersBatch([], [globalRule]);
expect(batch.results).toHaveLength(0);
expect(batch.totalOriginalHours).toBe(0);
expect(batch.totalAdjustedHours).toBe(0);
expect(batch.linesAdjusted).toBe(0);
});
});
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
computeEvenSpread,
getEstimateMonthRange,
rebalanceSpread,
summarizeMonthlySpread,
} from "../estimate/monthly-spread.js";
describe("getEstimateMonthRange", () => {
it("returns months between two dates in the same year", () => {
expect(
getEstimateMonthRange(new Date("2026-03-01"), new Date("2026-06-30")),
).toEqual(["2026-03", "2026-04", "2026-05", "2026-06"]);
});
it("returns a single month when start and end are in the same month", () => {
expect(
getEstimateMonthRange(new Date("2026-05-10"), new Date("2026-05-20")),
).toEqual(["2026-05"]);
});
it("spans across years", () => {
expect(
getEstimateMonthRange(new Date("2025-11-01"), new Date("2026-02-28")),
).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]);
});
it("returns empty when endDate < startDate", () => {
expect(
getEstimateMonthRange(new Date("2026-06-01"), new Date("2026-03-01")),
).toEqual([]);
});
});
describe("computeEvenSpread", () => {
it("distributes hours across months weighted by working days", () => {
const result = computeEvenSpread({
totalHours: 100,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-05-31"),
});
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
// Hours should sum to exactly 100
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
expect(Math.round(sum * 10) / 10).toBe(100);
// Each month should have a positive value
for (const month of result.months) {
expect(result.spread[month]).toBeGreaterThan(0);
}
});
it("handles a single month", () => {
const result = computeEvenSpread({
totalHours: 40,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result.months).toEqual(["2026-04"]);
expect(result.spread["2026-04"]).toBe(40);
});
it("handles partial first and last months", () => {
const result = computeEvenSpread({
totalHours: 80,
startDate: new Date("2026-03-15"),
endDate: new Date("2026-04-15"),
});
expect(result.months).toEqual(["2026-03", "2026-04"]);
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
expect(Math.round(sum * 10) / 10).toBe(80);
// March 15-31 has fewer working days than April 1-15
// Both should be positive
expect(result.spread["2026-03"]).toBeGreaterThan(0);
expect(result.spread["2026-04"]).toBeGreaterThan(0);
});
it("returns empty spread for empty date range", () => {
const result = computeEvenSpread({
totalHours: 100,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-03-01"),
});
expect(result.months).toEqual([]);
expect(result.spread).toEqual({});
});
it("handles zero total hours", () => {
const result = computeEvenSpread({
totalHours: 0,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-05-31"),
});
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
for (const month of result.months) {
expect(result.spread[month]).toBe(0);
}
});
});
describe("rebalanceSpread", () => {
it("distributes remaining hours after locked months", () => {
const result = rebalanceSpread({
totalHours: 100,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-05-31"),
lockedMonths: { "2026-03": 50 },
});
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
expect(result.spread["2026-03"]).toBe(50);
// Remaining 50 hours across April + May
const remaining = (result.spread["2026-04"] ?? 0) + (result.spread["2026-05"] ?? 0);
expect(Math.round(remaining * 10) / 10).toBe(50);
});
it("handles all months locked", () => {
const result = rebalanceSpread({
totalHours: 100,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-04-30"),
lockedMonths: { "2026-03": 60, "2026-04": 40 },
});
expect(result.spread["2026-03"]).toBe(60);
expect(result.spread["2026-04"]).toBe(40);
});
it("clamps remaining to zero when locked exceeds total", () => {
const result = rebalanceSpread({
totalHours: 50,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-04-30"),
lockedMonths: { "2026-03": 60 },
});
expect(result.spread["2026-03"]).toBe(60);
// Remaining is max(0, 50-60) = 0
expect(result.spread["2026-04"]).toBe(0);
});
it("ignores locked months outside the date range", () => {
const result = rebalanceSpread({
totalHours: 80,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-04-30"),
lockedMonths: { "2026-01": 30, "2026-03": 40 },
});
// 2026-01 is outside range, so remaining = 80 - 40 = 40
expect(result.spread["2026-03"]).toBe(40);
expect(result.spread["2026-04"]).toBe(40);
});
});
describe("summarizeMonthlySpread", () => {
it("aggregates hours per month across multiple spreads", () => {
const result = summarizeMonthlySpread([
{ "2026-03": 30, "2026-04": 20 },
{ "2026-03": 10, "2026-04": 15, "2026-05": 25 },
]);
expect(result).toEqual({
"2026-03": 40,
"2026-04": 35,
"2026-05": 25,
});
});
it("returns empty for empty input", () => {
expect(summarizeMonthlySpread([])).toEqual({});
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { isRecurringDay, getRecurringHoursForDay } from "../allocation/recurrence.js";
import { RecurrenceFrequency } from "@planarchy/shared";
import type { RecurrencePattern } from "@planarchy/shared";
const monday = new Date("2026-03-09"); // Monday
const tuesday = new Date("2026-03-10"); // Tuesday
const wednesday = new Date("2026-03-11"); // Wednesday
const friday = new Date("2026-03-13"); // Friday
const nextMonday = new Date("2026-03-16"); // Monday (next week)
const twoWeeksMonday = new Date("2026-03-23"); // Monday (two weeks after)
const allocationStart = new Date("2026-03-09"); // Monday
describe("isRecurringDay", () => {
describe("WEEKLY", () => {
const pattern: RecurrencePattern = {
frequency: RecurrenceFrequency.WEEKLY,
weekdays: [1, 3, 5], // Mon, Wed, Fri
};
it("returns true for days matching weekday list", () => {
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
expect(isRecurringDay(wednesday, pattern, allocationStart)).toBe(true);
expect(isRecurringDay(friday, pattern, allocationStart)).toBe(true);
});
it("returns false for days not in weekday list", () => {
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
});
});
describe("BIWEEKLY", () => {
const pattern: RecurrencePattern = {
frequency: RecurrenceFrequency.BIWEEKLY,
weekdays: [1], // Monday only
interval: 2,
};
it("returns true on the allocation start week (week 0)", () => {
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
});
it("returns false on the next week (week 1, odd)", () => {
expect(isRecurringDay(nextMonday, pattern, allocationStart)).toBe(false);
});
it("returns true two weeks after start (week 2, even)", () => {
expect(isRecurringDay(twoWeeksMonday, pattern, allocationStart)).toBe(true);
});
it("returns false for non-matching weekdays even in active weeks", () => {
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
});
});
describe("MONTHLY", () => {
const pattern: RecurrencePattern = {
frequency: RecurrenceFrequency.MONTHLY,
monthDay: 15,
};
it("returns true on the specified day of month", () => {
expect(isRecurringDay(new Date("2026-03-15"), pattern, allocationStart)).toBe(true);
});
it("returns false on other days", () => {
expect(isRecurringDay(new Date("2026-03-14"), pattern, allocationStart)).toBe(false);
});
it("returns false when monthDay is undefined", () => {
const noDay: RecurrencePattern = {
frequency: RecurrenceFrequency.MONTHLY,
};
expect(isRecurringDay(new Date("2026-03-15"), noDay, allocationStart)).toBe(false);
});
});
describe("CUSTOM", () => {
const pattern: RecurrencePattern = {
frequency: RecurrenceFrequency.CUSTOM,
};
it("always returns true", () => {
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(true);
});
});
});
describe("getRecurringHoursForDay", () => {
const weeklyPattern: RecurrencePattern = {
frequency: RecurrenceFrequency.WEEKLY,
weekdays: [1, 3, 5], // Mon, Wed, Fri
};
it("returns default hours on recurring days", () => {
expect(getRecurringHoursForDay(monday, weeklyPattern, 8, allocationStart)).toBe(8);
});
it("returns 0 on non-recurring days", () => {
expect(getRecurringHoursForDay(tuesday, weeklyPattern, 8, allocationStart)).toBe(0);
});
it("uses pattern.hoursPerDay when set", () => {
const patternWithHours: RecurrencePattern = {
...weeklyPattern,
hoursPerDay: 4,
};
expect(getRecurringHoursForDay(monday, patternWithHours, 8, allocationStart)).toBe(4);
});
it("returns 0 before pattern startDate", () => {
const patternWithStart: RecurrencePattern = {
...weeklyPattern,
startDate: new Date("2026-03-16"),
};
expect(getRecurringHoursForDay(monday, patternWithStart, 8, allocationStart)).toBe(0);
});
it("returns 0 after pattern endDate", () => {
const patternWithEnd: RecurrencePattern = {
...weeklyPattern,
endDate: new Date("2026-03-08"),
};
expect(getRecurringHoursForDay(monday, patternWithEnd, 8, allocationStart)).toBe(0);
});
it("returns hours within pattern date bounds", () => {
const bounded: RecurrencePattern = {
...weeklyPattern,
startDate: new Date("2026-03-01"),
endDate: new Date("2026-03-31"),
};
expect(getRecurringHoursForDay(monday, bounded, 8, allocationStart)).toBe(8);
});
});
@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { calculateSAH, getDailyHours } from "../sah/calculator.js";
import type { SpainScheduleRule } from "@planarchy/shared";
const spainRules: SpainScheduleRule = {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
};
describe("getDailyHours", () => {
it("returns base hours when no schedule rules", () => {
expect(getDailyHours(new Date("2026-03-10"), 8)).toBe(8); // Monday
expect(getDailyHours(new Date("2026-03-13"), 9)).toBe(9); // Thursday
});
it("returns Spain friday hours", () => {
// 2026-03-13 is a Friday
expect(getDailyHours(new Date("2026-03-13"), 8, spainRules)).toBe(6.5);
});
it("returns Spain summer hours Mon-Thu", () => {
// 2026-07-06 is a Monday in summer
expect(getDailyHours(new Date("2026-07-06"), 8, spainRules)).toBe(6.5);
});
it("returns Spain regular hours Mon-Thu outside summer", () => {
// 2026-03-09 is a Monday, not summer
expect(getDailyHours(new Date("2026-03-09"), 8, spainRules)).toBe(9);
});
});
describe("calculateSAH", () => {
it("calculates basic SAH for a full-time German employee in one week", () => {
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — 5 working days, no holidays
const result = calculateSAH({
dailyWorkingHours: 8,
fte: 1.0,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
expect(result.calendarDays).toBe(5);
expect(result.weekendDays).toBe(0);
expect(result.grossWorkingDays).toBe(5);
expect(result.netWorkingDays).toBe(5);
expect(result.standardAvailableHours).toBe(40);
});
it("reduces hours by FTE factor", () => {
const result = calculateSAH({
dailyWorkingHours: 8,
fte: 0.5,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
expect(result.standardAvailableHours).toBe(20);
expect(result.effectiveHoursPerDay).toBe(4);
});
it("deducts public holidays", () => {
const result = calculateSAH({
dailyWorkingHours: 8,
fte: 1.0,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: ["2026-03-10"], // Tuesday is holiday
absenceDays: [],
});
expect(result.publicHolidayDays).toBe(1);
expect(result.netWorkingDays).toBe(4);
expect(result.standardAvailableHours).toBe(32);
});
it("deducts absence days", () => {
const result = calculateSAH({
dailyWorkingHours: 8,
fte: 1.0,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: ["2026-03-11", "2026-03-12"], // Wed + Thu
});
expect(result.absenceDays).toBe(2);
expect(result.netWorkingDays).toBe(3);
expect(result.standardAvailableHours).toBe(24);
});
it("handles weekends correctly", () => {
// 2026-03-07 (Sat) to 2026-03-13 (Fri) — includes a full weekend
const result = calculateSAH({
dailyWorkingHours: 8,
fte: 1.0,
periodStart: new Date("2026-03-07"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
expect(result.calendarDays).toBe(7);
expect(result.weekendDays).toBe(2);
expect(result.grossWorkingDays).toBe(5);
expect(result.netWorkingDays).toBe(5);
expect(result.standardAvailableHours).toBe(40);
});
it("uses 9h for India", () => {
const result = calculateSAH({
dailyWorkingHours: 9,
fte: 1.0,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
expect(result.standardAvailableHours).toBe(45);
});
it("handles Spain variable schedule in summer", () => {
// 2026-07-06 (Mon) to 2026-07-10 (Fri) — all summer, includes Friday
const result = calculateSAH({
dailyWorkingHours: 8,
scheduleRules: spainRules,
fte: 1.0,
periodStart: new Date("2026-07-06"),
periodEnd: new Date("2026-07-10"),
publicHolidays: [],
absenceDays: [],
});
// Mon-Fri all summer: 6.5 * 5 = 32.5
expect(result.standardAvailableHours).toBe(32.5);
});
it("handles Spain variable schedule outside summer", () => {
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — non-summer
const result = calculateSAH({
dailyWorkingHours: 8,
scheduleRules: spainRules,
fte: 1.0,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
// Mon-Thu: 9h each = 36, Fri: 6.5h = total 42.5
expect(result.standardAvailableHours).toBe(42.5);
});
it("combines FTE with Spain schedule", () => {
const result = calculateSAH({
dailyWorkingHours: 8,
scheduleRules: spainRules,
fte: 0.5,
periodStart: new Date("2026-03-09"),
periodEnd: new Date("2026-03-13"),
publicHolidays: [],
absenceDays: [],
});
// (9*4 + 6.5*1) * 0.5 = 42.5 * 0.5 = 21.25
expect(result.standardAvailableHours).toBe(21.25);
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { calculateAllocation } from "../allocation/calculator.js";
import { validateAvailability } from "../allocation/availability-validator.js";
// Resource available MonSat (6h Saturday)
const satAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 6,
};
// Week Mon 2025-01-06 to Sat 2025-01-11
const MON = new Date("2025-01-06");
const SAT = new Date("2025-01-11");
describe("calculateAllocation — includeSaturday", () => {
it("excludes Saturday by default when resource has saturday availability", () => {
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: MON,
endDate: SAT,
availability: satAvailability,
// includeSaturday not set → defaults to false
});
expect(result.workingDays).toBe(5); // MonFri only
expect(result.totalHours).toBe(40);
});
it("includes Saturday when includeSaturday is true", () => {
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: MON,
endDate: SAT,
availability: satAvailability,
includeSaturday: true,
});
expect(result.workingDays).toBe(6); // MonSat
// Saturday capped at 6h (resource availability)
expect(result.totalHours).toBe(46); // 5×8 + 6
expect(result.totalCostCents).toBe(460000); // 46h × 10000 cents/h
});
it("includeSaturday: false explicitly still excludes Saturday", () => {
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: MON,
endDate: SAT,
availability: satAvailability,
includeSaturday: false,
});
expect(result.workingDays).toBe(5);
});
it("does not add Saturday if resource has no saturday availability", () => {
const noSatAvailability = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
const result = calculateAllocation({
lcrCents: 10000,
hoursPerDay: 8,
startDate: MON,
endDate: SAT,
availability: noSatAvailability,
includeSaturday: true,
});
expect(result.workingDays).toBe(5); // Saturday still 0 because resource unavailable
});
});
describe("validateAvailability — includeSaturday", () => {
it("skips Saturday conflicts when includeSaturday is false", () => {
// Resource available only on Saturday (MonFri: 0)
const satOnlyAvail = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 8 };
const result = validateAvailability(SAT, SAT, 8, satOnlyAvail, [], false);
// Saturday is excluded, so no conflicts (day treated as non-working)
expect(result.valid).toBe(true);
expect(result.totalConflictDays).toBe(0);
});
it("detects conflict on Saturday when includeSaturday is true", () => {
const result = validateAvailability(
SAT,
SAT,
8, // request 8h
satAvailability, // Saturday: 6h available
[],
true,
);
// 8h requested > 6h available → conflict
expect(result.valid).toBe(false);
expect(result.totalConflictDays).toBe(1);
expect(result.conflicts[0]?.overageHours).toBe(2);
});
it("no conflict on Saturday within availability when includeSaturday true", () => {
const result = validateAvailability(SAT, SAT, 4, satAvailability, [], true);
expect(result.valid).toBe(true);
});
});
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { isVacationDay, getVacationDatesInRange } from "../vacation/utils.js";
import type { VacationRange } from "../vacation/utils.js";
const approved = (start: string, end: string): VacationRange => ({
startDate: new Date(start),
endDate: new Date(end),
status: "APPROVED",
});
const pending = (start: string, end: string): VacationRange => ({
startDate: new Date(start),
endDate: new Date(end),
status: "PENDING",
});
describe("isVacationDay", () => {
it("returns true when date falls within an approved vacation", () => {
const vacations = [approved("2026-03-10", "2026-03-14")];
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(true);
});
it("returns true on the start date of the vacation", () => {
const vacations = [approved("2026-03-10", "2026-03-14")];
expect(isVacationDay(new Date("2026-03-10"), vacations)).toBe(true);
});
it("returns true on the end date of the vacation", () => {
const vacations = [approved("2026-03-10", "2026-03-14")];
expect(isVacationDay(new Date("2026-03-14"), vacations)).toBe(true);
});
it("returns false when date is outside vacation range", () => {
const vacations = [approved("2026-03-10", "2026-03-14")];
expect(isVacationDay(new Date("2026-03-09"), vacations)).toBe(false);
expect(isVacationDay(new Date("2026-03-15"), vacations)).toBe(false);
});
it("ignores non-APPROVED vacations", () => {
const vacations = [pending("2026-03-10", "2026-03-14")];
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
});
it("returns false for empty vacation array", () => {
expect(isVacationDay(new Date("2026-03-12"), [])).toBe(false);
});
it("handles multiple overlapping vacations", () => {
const vacations = [
approved("2026-03-10", "2026-03-12"),
approved("2026-03-11", "2026-03-14"),
];
expect(isVacationDay(new Date("2026-03-11"), vacations)).toBe(true);
expect(isVacationDay(new Date("2026-03-13"), vacations)).toBe(true);
});
it("handles mixed statuses correctly", () => {
const vacations = [
pending("2026-03-10", "2026-03-14"),
approved("2026-03-20", "2026-03-21"),
];
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
expect(isVacationDay(new Date("2026-03-20"), vacations)).toBe(true);
});
});
describe("getVacationDatesInRange", () => {
it("returns approved vacation dates within range", () => {
const vacations = [approved("2026-03-10", "2026-03-12")];
const dates = getVacationDatesInRange(
new Date("2026-03-09"),
new Date("2026-03-13"),
vacations,
);
expect(dates).toHaveLength(3); // 10, 11, 12
expect(dates[0]!.getDate()).toBe(10);
expect(dates[2]!.getDate()).toBe(12);
});
it("clips to the query range", () => {
const vacations = [approved("2026-03-05", "2026-03-15")];
const dates = getVacationDatesInRange(
new Date("2026-03-10"),
new Date("2026-03-12"),
vacations,
);
expect(dates).toHaveLength(3); // 10, 11, 12
});
it("returns empty for no approved vacations", () => {
const vacations = [pending("2026-03-10", "2026-03-14")];
const dates = getVacationDatesInRange(
new Date("2026-03-01"),
new Date("2026-03-31"),
vacations,
);
expect(dates).toEqual([]);
});
it("handles multiple non-overlapping vacations", () => {
const vacations = [
approved("2026-03-10", "2026-03-11"),
approved("2026-03-20", "2026-03-21"),
];
const dates = getVacationDatesInRange(
new Date("2026-03-01"),
new Date("2026-03-31"),
vacations,
);
expect(dates).toHaveLength(4); // 10, 11, 20, 21
});
it("returns empty when range does not intersect vacations", () => {
const vacations = [approved("2026-04-01", "2026-04-05")];
const dates = getVacationDatesInRange(
new Date("2026-03-01"),
new Date("2026-03-31"),
vacations,
);
expect(dates).toEqual([]);
});
});
@@ -0,0 +1,407 @@
import { describe, expect, it } from "vitest";
import {
compareEstimateVersions,
type VersionCompareInput,
} from "../estimate/index.js";
function makeLine(overrides: Partial<VersionCompareInput["demandLines"][number]> & { id: string; name: string }) {
return {
hours: 0,
costRateCents: 0,
billRateCents: 0,
costTotalCents: 0,
priceTotalCents: 0,
lineType: "ROLE",
...overrides,
};
}
function makeVersion(overrides: Partial<VersionCompareInput> = {}): VersionCompareInput {
return {
versionNumber: 1,
demandLines: [],
assumptions: [],
scopeItems: [],
...overrides,
};
}
describe("compareEstimateVersions", () => {
it("returns zeroed diff for identical versions", () => {
const lines = [
makeLine({ id: "l1", name: "Designer", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 }),
];
const assumptions = [{ key: "rate", label: "Day rate", value: 500 }];
const scopeItems = [{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }];
const v = makeVersion({ demandLines: lines, assumptions, scopeItems });
const diff = compareEstimateVersions(v, v);
expect(diff.summary.totalHoursDelta).toBe(0);
expect(diff.summary.totalCostDelta).toBe(0);
expect(diff.summary.totalPriceDelta).toBe(0);
expect(diff.summary.linesAdded).toBe(0);
expect(diff.summary.linesRemoved).toBe(0);
expect(diff.summary.linesChanged).toBe(0);
expect(diff.summary.assumptionsChanged).toBe(0);
expect(diff.summary.scopeItemsAdded).toBe(0);
expect(diff.summary.scopeItemsRemoved).toBe(0);
expect(diff.demandLineDiffs).toHaveLength(1);
expect(diff.demandLineDiffs[0]!.status).toBe("unchanged");
expect(diff.assumptionDiffs).toHaveLength(1);
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
});
it("detects added demand lines", () => {
const a = makeVersion({ demandLines: [] });
const b = makeVersion({
demandLines: [
makeLine({ id: "l1", name: "Animator", hours: 40, costTotalCents: 200_00, priceTotalCents: 320_00 }),
],
});
const diff = compareEstimateVersions(a, b);
expect(diff.summary.linesAdded).toBe(1);
expect(diff.summary.linesRemoved).toBe(0);
expect(diff.summary.totalHoursDelta).toBe(40);
expect(diff.summary.totalCostDelta).toBe(200_00);
expect(diff.summary.totalPriceDelta).toBe(320_00);
expect(diff.demandLineDiffs).toHaveLength(1);
expect(diff.demandLineDiffs[0]).toEqual(
expect.objectContaining({
name: "Animator",
status: "added",
hoursDelta: 40,
costDelta: 200_00,
priceDelta: 320_00,
}),
);
});
it("detects removed demand lines", () => {
const a = makeVersion({
demandLines: [
makeLine({ id: "l1", name: "Lighter", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
],
});
const b = makeVersion({ demandLines: [] });
const diff = compareEstimateVersions(a, b);
expect(diff.summary.linesAdded).toBe(0);
expect(diff.summary.linesRemoved).toBe(1);
expect(diff.summary.totalHoursDelta).toBe(-20);
expect(diff.demandLineDiffs[0]).toEqual(
expect.objectContaining({
name: "Lighter",
status: "removed",
hoursDelta: -20,
}),
);
});
it("detects changed hours and rates", () => {
const a = makeVersion({
demandLines: [
makeLine({ id: "l1", name: "TD", hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 }),
],
});
const b = makeVersion({
demandLines: [
makeLine({ id: "l1", name: "TD", hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 }),
],
});
const diff = compareEstimateVersions(a, b);
expect(diff.summary.linesChanged).toBe(1);
expect(diff.summary.linesAdded).toBe(0);
expect(diff.summary.linesRemoved).toBe(0);
expect(diff.summary.totalHoursDelta).toBe(30);
expect(diff.summary.totalCostDelta).toBe(180_00);
expect(diff.summary.totalPriceDelta).toBe(300_00);
const lineDiff = diff.demandLineDiffs[0]!;
expect(lineDiff.status).toBe("changed");
expect(lineDiff.hoursDelta).toBe(30);
expect(lineDiff.costDelta).toBe(180_00);
expect(lineDiff.priceDelta).toBe(300_00);
expect(lineDiff.a).toEqual({ hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 });
expect(lineDiff.b).toEqual({ hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 });
});
it("fuzzy-matches demand lines by name+lineType when ids differ", () => {
const a = makeVersion({
demandLines: [
makeLine({ id: "old-id", name: "Compositor", lineType: "ROLE", hours: 10, costTotalCents: 50_00, priceTotalCents: 80_00 }),
],
});
const b = makeVersion({
demandLines: [
makeLine({ id: "new-id", name: "Compositor", lineType: "ROLE", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
],
});
const diff = compareEstimateVersions(a, b);
expect(diff.summary.linesAdded).toBe(0);
expect(diff.summary.linesRemoved).toBe(0);
expect(diff.summary.linesChanged).toBe(1);
expect(diff.demandLineDiffs[0]!.hoursDelta).toBe(10);
});
it("detects added and removed assumptions", () => {
const a = makeVersion({
assumptions: [
{ key: "overhead", label: "Overhead %", value: 15 },
{ key: "old_key", label: "Old", value: "x" },
],
});
const b = makeVersion({
assumptions: [
{ key: "overhead", label: "Overhead %", value: 20 },
{ key: "new_key", label: "New", value: "y" },
],
});
const diff = compareEstimateVersions(a, b);
expect(diff.summary.assumptionsChanged).toBe(3);
const overheadDiff = diff.assumptionDiffs.find((d) => d.key === "overhead")!;
expect(overheadDiff.status).toBe("changed");
expect(overheadDiff.aValue).toBe(15);
expect(overheadDiff.bValue).toBe(20);
expect(diff.assumptionDiffs.find((d) => d.key === "old_key")!.status).toBe("removed");
expect(diff.assumptionDiffs.find((d) => d.key === "new_key")!.status).toBe("added");
});
it("detects scope item additions and removals", () => {
const a = makeVersion({
scopeItems: [
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
{ name: "Asset X", sequenceNo: 2, scopeType: "ASSET" },
],
});
const b = makeVersion({
scopeItems: [
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
{ name: "Shot B", sequenceNo: 2, scopeType: "SHOT" },
],
});
const diff = compareEstimateVersions(a, b);
expect(diff.summary.scopeItemsAdded).toBe(1);
expect(diff.summary.scopeItemsRemoved).toBe(1);
});
it("handles empty versions", () => {
const diff = compareEstimateVersions(makeVersion(), makeVersion());
expect(diff.summary.totalHoursDelta).toBe(0);
expect(diff.summary.linesAdded).toBe(0);
expect(diff.demandLineDiffs).toHaveLength(0);
expect(diff.assumptionDiffs).toHaveLength(0);
expect(diff.scopeItemDiffs).toHaveLength(0);
expect(diff.resourceSnapshotDiffs).toHaveLength(0);
expect(diff.chapterSubtotals).toHaveLength(0);
expect(diff.summary.marginPercentA).toBe(0);
expect(diff.summary.marginPercentB).toBe(0);
});
it("compares complex assumption values by deep equality", () => {
const a = makeVersion({
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
});
const b = makeVersion({
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
});
const diff = compareEstimateVersions(a, b);
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
const b2 = makeVersion({
assumptions: [{ key: "config", label: "Config", value: { fps: 30, resolution: "4K" } }],
});
const diff2 = compareEstimateVersions(a, b2);
expect(diff2.assumptionDiffs[0]!.status).toBe("changed");
});
describe("scope item diffs (detailed)", () => {
it("detects changed scope item fields", () => {
const a = makeVersion({
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 120 }],
});
const b = makeVersion({
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 200 }],
});
const diff = compareEstimateVersions(a, b);
expect(diff.scopeItemDiffs).toHaveLength(1);
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("frameCount");
expect(diff.summary.scopeItemsChanged).toBe(1);
});
it("detects description changes in scope items", () => {
const diff = compareEstimateVersions(
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "Old" }] }),
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "New" }] }),
);
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("description");
});
it("marks unchanged scope items", () => {
const scope = { name: "Asset Hero", sequenceNo: 1, scopeType: "ASSET", frameCount: 50 };
const diff = compareEstimateVersions(
makeVersion({ scopeItems: [scope] }),
makeVersion({ scopeItems: [scope] }),
);
expect(diff.scopeItemDiffs[0]!.status).toBe("unchanged");
});
});
describe("resource snapshot diffs", () => {
it("detects added resources", () => {
const diff = compareEstimateVersions(
makeVersion(),
makeVersion({
resourceSnapshots: [{ displayName: "John", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
}),
);
expect(diff.resourceSnapshotDiffs).toHaveLength(1);
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("added");
expect(diff.summary.resourceSnapshotsChanged).toBe(1);
});
it("detects rate changes", () => {
const diff = compareEstimateVersions(
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
}),
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5500, ucrCents: 8500 }],
}),
);
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
expect(diff.resourceSnapshotDiffs[0]!.lcrDelta).toBe(500);
expect(diff.resourceSnapshotDiffs[0]!.ucrDelta).toBe(500);
});
it("detects removed resources", () => {
const diff = compareEstimateVersions(
makeVersion({
resourceSnapshots: [{ displayName: "Max", currency: "EUR", lcrCents: 4000, ucrCents: 7000 }],
}),
makeVersion(),
);
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("removed");
});
it("matches by resourceId for unchanged rates", () => {
const diff = compareEstimateVersions(
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "Old Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
}),
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "New Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
}),
);
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("unchanged");
});
it("detects location changes", () => {
const diff = compareEstimateVersions(
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Munich" }],
}),
makeVersion({
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Berlin" }],
}),
);
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
});
});
describe("chapter subtotals", () => {
it("groups demand lines by chapter and computes deltas", () => {
const diff = compareEstimateVersions(
makeVersion({
demandLines: [
makeLine({ id: "d1", name: "Anim Lead", hours: 80, costTotalCents: 480_00, priceTotalCents: 720_00, chapter: "Animation" }),
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
],
}),
makeVersion({
demandLines: [
makeLine({ id: "d1", name: "Anim Lead", hours: 100, costTotalCents: 600_00, priceTotalCents: 900_00, chapter: "Animation" }),
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
],
}),
);
expect(diff.chapterSubtotals).toHaveLength(2);
const anim = diff.chapterSubtotals.find((c) => c.chapter === "Animation")!;
expect(anim.hoursDelta).toBe(20);
expect(anim.costDelta).toBe(120_00);
});
it("uses (no chapter) fallback", () => {
const diff = compareEstimateVersions(
makeVersion({
demandLines: [makeLine({ id: "d1", name: "Misc", hours: 10, costTotalCents: 30_00, priceTotalCents: 50_00 })],
}),
makeVersion(),
);
expect(diff.chapterSubtotals[0]!.chapter).toBe("(no chapter)");
});
it("sorts by absolute cost delta descending", () => {
const diff = compareEstimateVersions(
makeVersion({
demandLines: [
makeLine({ id: "d1", name: "A", hours: 10, costTotalCents: 100_00, priceTotalCents: 200_00, chapter: "Small" }),
makeLine({ id: "d2", name: "B", hours: 100, costTotalCents: 5000_00, priceTotalCents: 8000_00, chapter: "Big" }),
],
}),
makeVersion({
demandLines: [
makeLine({ id: "d1", name: "A", hours: 12, costTotalCents: 120_00, priceTotalCents: 240_00, chapter: "Small" }),
makeLine({ id: "d2", name: "B", hours: 200, costTotalCents: 10000_00, priceTotalCents: 16000_00, chapter: "Big" }),
],
}),
);
expect(diff.chapterSubtotals[0]!.chapter).toBe("Big");
});
});
describe("margin calculation", () => {
it("computes margin percent and delta", () => {
const diff = compareEstimateVersions(
makeVersion({
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 })],
}),
makeVersion({
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 1000_00 })],
}),
);
// A: (80000-50000)/80000 = 37.5%
expect(diff.summary.marginPercentA).toBeCloseTo(37.5, 1);
// B: (100000-50000)/100000 = 50%
expect(diff.summary.marginPercentB).toBeCloseTo(50, 1);
expect(diff.summary.marginPercentDelta).toBeCloseTo(12.5, 1);
});
it("handles zero price gracefully", () => {
const diff = compareEstimateVersions(makeVersion(), makeVersion());
expect(diff.summary.marginPercentA).toBe(0);
expect(diff.summary.marginPercentB).toBe(0);
});
});
});
@@ -0,0 +1,341 @@
import { describe, expect, it } from "vitest";
import {
generateWeekRange,
distributeHoursToWeeks,
weekKeyFromDate,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "../estimate/weekly-phasing.js";
describe("generateWeekRange", () => {
it("returns a single week for a range within one week", () => {
const weeks = generateWeekRange("2026-03-16", "2026-03-20"); // Mon-Fri of W12
expect(weeks).toHaveLength(1);
expect(weeks[0]!.weekNumber).toBe(12);
expect(weeks[0]!.year).toBe(2026);
expect(weeks[0]!.label).toBe("W12 2026");
});
it("returns multiple weeks for a multi-week range", () => {
const weeks = generateWeekRange("2026-03-16", "2026-04-10");
expect(weeks.length).toBeGreaterThanOrEqual(3);
// Should cover W12 through at least W15
const weekNumbers = weeks.map((w) => w.weekNumber);
expect(weekNumbers).toContain(12);
expect(weekNumbers).toContain(13);
expect(weekNumbers).toContain(14);
});
it("handles month boundary crossing", () => {
const weeks = generateWeekRange("2026-03-28", "2026-04-05");
// This spans end of March into April
expect(weeks.length).toBeGreaterThanOrEqual(1);
// Each week has proper start/end dates
for (const week of weeks) {
expect(week.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(week.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
it("handles year boundary crossing", () => {
const weeks = generateWeekRange("2025-12-22", "2026-01-11");
expect(weeks.length).toBeGreaterThanOrEqual(2);
// Should include weeks in both years (ISO week year may differ from calendar year)
const labels = weeks.map((w) => w.label);
expect(labels.length).toBeGreaterThan(0);
});
it("returns correct ISO week numbers and labels", () => {
const weeks = generateWeekRange("2026-01-05", "2026-01-18");
for (const week of weeks) {
expect(week.label).toMatch(/^W\d{2} \d{4}$/);
expect(week.weekNumber).toBeGreaterThanOrEqual(1);
expect(week.weekNumber).toBeLessThanOrEqual(53);
}
});
it("returns empty array when endDate < startDate", () => {
const weeks = generateWeekRange("2026-06-01", "2026-03-01");
expect(weeks).toEqual([]);
});
it("returns week definitions with Monday start and Sunday end", () => {
const weeks = generateWeekRange("2026-03-16", "2026-03-22");
const week = weeks[0]!;
const start = new Date(week.startDate);
const end = new Date(week.endDate);
expect(start.getUTCDay()).toBe(1); // Monday
expect(end.getUTCDay()).toBe(0); // Sunday
});
});
describe("distributeHoursToWeeks", () => {
it("distributes hours evenly across weeks", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-04-10",
pattern: "even",
});
expect(result.weeks.length).toBeGreaterThan(0);
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
// All weeks should have roughly equal hours
const values = Object.values(result.weeklyHours);
const avg = 100 / values.length;
for (const h of values) {
expect(h).toBeCloseTo(avg, 0);
}
});
it("distributes 100h over 4 weeks = 25h each", () => {
// Find a 4-week range
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-04-12",
pattern: "even",
});
expect(result.weeks).toHaveLength(4);
expect(result.totalDistributedHours).toBe(100);
for (const h of Object.values(result.weeklyHours)) {
expect(h).toBe(25);
}
});
it("handles even distribution with remainder", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-04-05",
pattern: "even",
});
expect(result.weeks).toHaveLength(3);
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
});
it("front loaded: first half weeks have more hours", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-05-10",
pattern: "front_loaded",
});
const values = Object.values(result.weeklyHours);
expect(values.length).toBeGreaterThanOrEqual(4);
// First value should be greater than last value
expect(values[0]!).toBeGreaterThan(values[values.length - 1]!);
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
});
it("back loaded: last half weeks have more hours", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-05-10",
pattern: "back_loaded",
});
const values = Object.values(result.weeklyHours);
expect(values.length).toBeGreaterThanOrEqual(4);
// Last value should be greater than first value
expect(values[values.length - 1]!).toBeGreaterThan(values[0]!);
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
});
it("custom: uses provided hours", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-03-16",
endDate: "2026-04-05",
pattern: "custom",
customWeeklyHours: {
"2026-W12": 40,
"2026-W13": 35,
"2026-W14": 25,
},
});
expect(result.weeklyHours["2026-W12"]).toBe(40);
expect(result.weeklyHours["2026-W13"]).toBe(35);
expect(result.weeklyHours["2026-W14"]).toBe(25);
});
it("returns all zeros for zero hours", () => {
const result = distributeHoursToWeeks({
totalHours: 0,
startDate: "2026-03-16",
endDate: "2026-04-05",
pattern: "even",
});
for (const h of Object.values(result.weeklyHours)) {
expect(h).toBe(0);
}
expect(result.totalDistributedHours).toBe(0);
});
it("handles single week range", () => {
const result = distributeHoursToWeeks({
totalHours: 40,
startDate: "2026-03-16",
endDate: "2026-03-20",
pattern: "front_loaded",
});
expect(result.weeks).toHaveLength(1);
expect(result.totalDistributedHours).toBe(40);
});
it("returns empty result for empty range", () => {
const result = distributeHoursToWeeks({
totalHours: 100,
startDate: "2026-06-01",
endDate: "2026-03-01",
});
expect(result.weeks).toHaveLength(0);
expect(result.totalDistributedHours).toBe(0);
});
it("defaults to even pattern when not specified", () => {
const result = distributeHoursToWeeks({
totalHours: 80,
startDate: "2026-03-16",
endDate: "2026-04-12",
});
expect(result.weeks).toHaveLength(4);
expect(result.totalDistributedHours).toBeCloseTo(80, 1);
// Should be even
for (const h of Object.values(result.weeklyHours)) {
expect(h).toBe(20);
}
});
});
describe("weekKeyFromDate", () => {
it("returns correct week key for a normal date", () => {
// March 18, 2026 is a Wednesday in W12
expect(weekKeyFromDate("2026-03-18")).toBe("2026-W12");
});
it("handles Date object input", () => {
const date = new Date("2026-03-18");
expect(weekKeyFromDate(date)).toBe("2026-W12");
});
it("handles year boundary (Dec 31 may be W01 of next year)", () => {
// Dec 31, 2026 is a Thursday. Let's check ISO week.
const key = weekKeyFromDate("2026-12-31");
// ISO week 53 or W01 of 2027 depending on the year
expect(key).toMatch(/^\d{4}-W\d{2}$/);
});
it("handles Jan 1 which may be in previous year's last week", () => {
// Jan 1, 2027 is a Friday
const key = weekKeyFromDate("2027-01-01");
expect(key).toMatch(/^\d{4}-W\d{2}$/);
});
it("returns W01 for the first Monday of a standard year", () => {
// In 2026, Jan 5 is a Monday, should be W02 (Jan 1 is Thursday, W01 starts Dec 29)
// Actually let's verify: Jan 1 2026 is Thursday → W01 starts Mon Dec 29 2025
// Jan 5 2026 is Monday → W02
expect(weekKeyFromDate("2026-01-05")).toBe("2026-W02");
});
});
describe("aggregateWeeklyToMonthly", () => {
it("converts week keys to month keys and sums hours", () => {
const weeklyHours: Record<string, number> = {
"2026-W12": 40,
"2026-W13": 40,
"2026-W14": 40,
"2026-W15": 40,
};
const monthly = aggregateWeeklyToMonthly(weeklyHours);
// All these weeks are in March-April 2026
const totalMonthly = Object.values(monthly).reduce((sum, h) => sum + h, 0);
expect(totalMonthly).toBeCloseTo(160, 1);
});
it("sums hours within the same month", () => {
// W10 and W11 of 2026 are both in March
const weeklyHours: Record<string, number> = {
"2026-W10": 20,
"2026-W11": 30,
};
const monthly = aggregateWeeklyToMonthly(weeklyHours);
// Both weeks' Thursdays fall in March
expect(monthly["2026-03"]).toBe(50);
});
it("handles cross-month weeks correctly", () => {
// W14 of 2026: Thursday April 2 → April
const weeklyHours: Record<string, number> = {
"2026-W13": 25,
"2026-W14": 25,
};
const monthly = aggregateWeeklyToMonthly(weeklyHours);
const total = Object.values(monthly).reduce((sum, h) => sum + h, 0);
expect(total).toBe(50);
});
it("returns empty for empty input", () => {
expect(aggregateWeeklyToMonthly({})).toEqual({});
});
});
describe("aggregateWeeklyByChapter", () => {
it("groups lines by chapter and sums hours per week", () => {
const lines = [
{ chapter: "Animation", weeklyHours: { "2026-W12": 20, "2026-W13": 15 } },
{ chapter: "Animation", weeklyHours: { "2026-W12": 10, "2026-W13": 5 } },
{ chapter: "Lighting", weeklyHours: { "2026-W12": 8, "2026-W13": 12 } },
];
const result = aggregateWeeklyByChapter(lines);
expect(result["Animation"]!["2026-W12"]).toBe(30);
expect(result["Animation"]!["2026-W13"]).toBe(20);
expect(result["Lighting"]!["2026-W12"]).toBe(8);
expect(result["Lighting"]!["2026-W13"]).toBe(12);
});
it("handles null chapter as (Unassigned)", () => {
const lines = [
{ chapter: null, weeklyHours: { "2026-W12": 10 } },
{ weeklyHours: { "2026-W12": 5 } },
];
const result = aggregateWeeklyByChapter(lines);
expect(result["(Unassigned)"]!["2026-W12"]).toBe(15);
});
it("returns empty for empty input", () => {
expect(aggregateWeeklyByChapter([])).toEqual({});
});
it("handles lines with no overlapping weeks", () => {
const lines = [
{ chapter: "FX", weeklyHours: { "2026-W12": 10 } },
{ chapter: "FX", weeklyHours: { "2026-W15": 20 } },
];
const result = aggregateWeeklyByChapter(lines);
expect(result["FX"]!["2026-W12"]).toBe(10);
expect(result["FX"]!["2026-W15"]).toBe(20);
expect(result["FX"]!["2026-W13"]).toBeUndefined();
});
});
@@ -0,0 +1,112 @@
import type { Allocation, WeekdayAvailability } from "@planarchy/shared";
import { getAvailableHoursForDate } from "./calculator.js";
export interface AvailabilityConflict {
date: Date;
requestedHours: number;
availableHours: number;
existingHours: number;
overageHours: number;
}
export interface AvailabilityValidationResult {
valid: boolean;
conflicts: AvailabilityConflict[];
totalConflictDays: number;
}
/**
* Validates that a new allocation does not exceed resource availability,
* accounting for existing allocations in the same period.
*
* Pure function — no DB access. All data passed as parameters.
*/
export function validateAvailability(
startDate: Date,
endDate: Date,
requestedHoursPerDay: number,
availability: WeekdayAvailability,
existingAllocations: Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
includeSaturday?: boolean,
): AvailabilityValidationResult {
const effectiveAvailability: WeekdayAvailability = includeSaturday
? availability
: { ...availability, saturday: 0 };
const conflicts: AvailabilityConflict[] = [];
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
while (current <= end) {
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
if (availableHours === 0) {
// Non-working day — skip
current.setDate(current.getDate() + 1);
continue;
}
// Sum hours from existing (non-cancelled) allocations on this day
const existingHoursOnDay = existingAllocations
.filter((a) => {
if (!activeStatuses.has(a.status)) return false;
const aStart = new Date(a.startDate);
aStart.setHours(0, 0, 0, 0);
const aEnd = new Date(a.endDate);
aEnd.setHours(0, 0, 0, 0);
return current >= aStart && current <= aEnd;
})
.reduce((sum, a) => sum + a.hoursPerDay, 0);
const totalRequested = existingHoursOnDay + requestedHoursPerDay;
if (totalRequested > availableHours) {
conflicts.push({
date: new Date(current),
requestedHours: requestedHoursPerDay,
availableHours,
existingHours: existingHoursOnDay,
overageHours: totalRequested - availableHours,
});
}
current.setDate(current.getDate() + 1);
}
return {
valid: conflicts.length === 0,
conflicts,
totalConflictDays: conflicts.length,
};
}
/**
* Checks for allocation overlaps — same resource, overlapping date ranges.
*/
export function detectOverlaps(
newStart: Date,
newEnd: Date,
existingAllocations: Pick<Allocation, "id" | "startDate" | "endDate" | "projectId" | "status">[],
excludeProjectId?: string,
): string[] {
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
const overlappingIds: string[] = [];
for (const alloc of existingAllocations) {
if (!activeStatuses.has(alloc.status)) continue;
if (excludeProjectId && alloc.projectId === excludeProjectId) continue;
const aStart = new Date(alloc.startDate);
const aEnd = new Date(alloc.endDate);
// Ranges overlap if: newStart <= aEnd && newEnd >= aStart
if (newStart <= aEnd && newEnd >= aStart) {
overlappingIds.push(alloc.id);
}
}
return overlappingIds;
}
@@ -0,0 +1,166 @@
import type {
AllocationCalculationInput,
AllocationCalculationResult,
DailyBreakdown,
WeekdayAvailability,
} from "@planarchy/shared";
import { getRecurringHoursForDay } from "./recurrence.js";
/** Day-of-week index → availability key */
const DOW_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
/**
* Returns the availability hours for a given date.
* Returns 0 for days not in the availability map (treated as non-working).
*/
export function getAvailableHoursForDate(
date: Date,
availability: WeekdayAvailability,
): number {
const key = DOW_KEYS[date.getDay()];
if (!key) return 0;
return availability[key] ?? 0;
}
/**
* Checks whether a given date is a working day for this resource.
*/
export function isWorkday(date: Date, availability: WeekdayAvailability): boolean {
return getAvailableHoursForDate(date, availability) > 0;
}
/**
* Counts working days between startDate and endDate (inclusive).
*/
export function countWorkingDays(
startDate: Date,
endDate: Date,
availability: WeekdayAvailability,
): number {
let count = 0;
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
while (current <= end) {
if (isWorkday(current, availability)) {
count++;
}
current.setDate(current.getDate() + 1);
}
return count;
}
/**
* Core allocation calculator: given hours/day, LCR, and date range,
* computes total hours, total cost, and daily breakdown.
*
* Monetary values always in integer cents.
*/
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
const { lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday, recurrence, vacationDates } = input;
// When includeSaturday is not explicitly true, zero out saturday availability
const effectiveAvailability: WeekdayAvailability = includeSaturday
? availability
: { ...availability, saturday: 0 };
// Pre-compute vacation date set (YYYY-MM-DD strings for O(1) lookup)
const vacationDateSet = new Set<string>(
(vacationDates ?? []).map((d) => {
const copy = new Date(d);
copy.setHours(0, 0, 0, 0);
return copy.toISOString().split("T")[0]!;
}),
);
const allocationStart = new Date(startDate);
allocationStart.setHours(0, 0, 0, 0);
const breakdown: DailyBreakdown[] = [];
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
let workingDays = 0;
let totalHours = 0;
while (current <= end) {
const dateKey = current.toISOString().split("T")[0]!;
const isVacation = vacationDateSet.has(dateKey);
let effectiveHours: number;
let dayIsWorkday: boolean;
if (isVacation) {
// Vacation always blocks the day
effectiveHours = 0;
dayIsWorkday = false;
} else if (recurrence) {
// Recurrence pattern — may override hoursPerDay or skip the day entirely
const recurHours = getRecurringHoursForDay(current, recurrence, hoursPerDay, allocationStart);
if (recurHours === 0) {
effectiveHours = 0;
dayIsWorkday = false;
} else {
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
dayIsWorkday = availableHours > 0;
effectiveHours = dayIsWorkday ? Math.min(recurHours, availableHours) : 0;
}
} else {
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
dayIsWorkday = availableHours > 0;
effectiveHours = dayIsWorkday ? Math.min(hoursPerDay, availableHours) : 0;
}
// Cost = hours × lcrCents (already in cents-per-hour)
const dayCostCents = Math.round(effectiveHours * lcrCents);
breakdown.push({
date: new Date(current),
isWorkday: dayIsWorkday,
hours: effectiveHours,
costCents: dayCostCents,
});
if (dayIsWorkday) {
workingDays++;
totalHours += effectiveHours;
}
current.setDate(current.getDate() + 1);
}
const totalCostCents = breakdown.reduce((sum, d) => sum + d.costCents, 0);
const dailyCostCents = Math.round(hoursPerDay * lcrCents);
return {
workingDays,
totalHours,
totalCostCents,
dailyCostCents,
dailyBreakdown: breakdown,
};
}
/**
* Calculates total allocation cost for a simple case (without full breakdown).
* Useful for quick budget checks.
*/
export function calculateTotalCost(
lcrCents: number,
hoursPerDay: number,
workingDays: number,
): number {
return Math.round(lcrCents * hoursPerDay * workingDays);
}
@@ -0,0 +1,80 @@
import type { WeekdayAvailability } from "@planarchy/shared";
export interface ChargeabilityAllocation {
startDate: Date;
endDate: Date;
hoursPerDay: number;
}
export interface ChargeabilityResult {
availableHours: number;
bookedHours: number;
chargeability: number; // 0-100, rounded
}
// Maps JS getDay() (0=Sun..6=Sat) to WeekdayAvailability keys
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
];
/** Count working hours a resource has available in [start, end] based on their schedule. */
export function computeAvailableHours(
availability: WeekdayAvailability,
start: Date,
end: Date,
): number {
let hours = 0;
const cur = new Date(start);
cur.setHours(0, 0, 0, 0);
const endNorm = new Date(end);
endNorm.setHours(0, 0, 0, 0);
while (cur <= endNorm) {
const key = DAY_KEYS[cur.getDay()];
hours += key ? (availability[key] ?? 0) : 0;
cur.setDate(cur.getDate() + 1);
}
return hours;
}
/** Count booked hours from allocations overlapping [start, end], working days only. */
export function computeBookedHours(
availability: WeekdayAvailability,
allocations: ChargeabilityAllocation[],
start: Date,
end: Date,
): number {
let hours = 0;
const startNorm = new Date(start); startNorm.setHours(0, 0, 0, 0);
const endNorm = new Date(end); endNorm.setHours(0, 0, 0, 0);
for (const alloc of allocations) {
const aStart = new Date(alloc.startDate); aStart.setHours(0, 0, 0, 0);
const aEnd = new Date(alloc.endDate); aEnd.setHours(0, 0, 0, 0);
const overlapStart = aStart > startNorm ? aStart : startNorm;
const overlapEnd = aEnd < endNorm ? aEnd : endNorm;
if (overlapStart > overlapEnd) continue;
const cur = new Date(overlapStart);
while (cur <= overlapEnd) {
const key = DAY_KEYS[cur.getDay()];
if (key && (availability[key] ?? 0) > 0) {
hours += alloc.hoursPerDay;
}
cur.setDate(cur.getDate() + 1);
}
}
return hours;
}
/** Compute chargeability metrics for a resource over a date range. */
export function computeChargeability(
availability: WeekdayAvailability,
allocations: ChargeabilityAllocation[],
start: Date,
end: Date,
): ChargeabilityResult {
const availableHours = computeAvailableHours(availability, start, end);
const bookedHours = computeBookedHours(availability, allocations, start, end);
const chargeability = availableHours > 0
? Math.min(100, Math.round((bookedHours / availableHours) * 100))
: 0;
return { availableHours, bookedHours, chargeability };
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./calculator.js";
export * from "./availability-validator.js";
export * from "./recurrence.js";
export * from "./chargeability.js";
@@ -0,0 +1,98 @@
import type { RecurrencePattern } from "@planarchy/shared";
import { RecurrenceFrequency } from "@planarchy/shared";
/**
* Returns the ISO week number of a date relative to a base date.
* Used for biweekly parity checks.
*/
function weeksSince(base: Date, date: Date): number {
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const baseMonday = new Date(base);
baseMonday.setHours(0, 0, 0, 0);
// Normalize to start of the week containing base
const baseDow = baseMonday.getDay();
baseMonday.setDate(baseMonday.getDate() - baseDow);
const targetMonday = new Date(date);
targetMonday.setHours(0, 0, 0, 0);
const targetDow = targetMonday.getDay();
targetMonday.setDate(targetMonday.getDate() - targetDow);
return Math.round((targetMonday.getTime() - baseMonday.getTime()) / msPerWeek);
}
/**
* Determines whether a given date falls on a "recurring day" according to the pattern.
*
* @param date The calendar date to check
* @param pattern Recurrence pattern from allocation metadata
* @param allocationStartDate The allocation's own start date (used for biweekly parity)
*/
export function isRecurringDay(
date: Date,
pattern: RecurrencePattern,
allocationStartDate: Date,
): boolean {
const dow = date.getDay(); // 0=Sun, 1=Mon … 6=Sat
switch (pattern.frequency) {
case RecurrenceFrequency.WEEKLY: {
const weekdays = pattern.weekdays ?? [];
return weekdays.includes(dow);
}
case RecurrenceFrequency.BIWEEKLY: {
const weekdays = pattern.weekdays ?? [];
if (!weekdays.includes(dow)) return false;
// Check week parity: week 0 (same as allocationStart) = active,
// odd weeks = skip, even weeks = active.
const interval = pattern.interval ?? 2;
const weeks = weeksSince(allocationStartDate, date);
return weeks % interval === 0;
}
case RecurrenceFrequency.MONTHLY: {
const monthDay = pattern.monthDay;
if (monthDay == null) return false;
return date.getDate() === monthDay;
}
case RecurrenceFrequency.CUSTOM:
// CUSTOM means "always active, but with custom hoursPerDay"
return true;
default:
return true;
}
}
/**
* Returns the effective hours for a day based on the recurrence pattern.
* Returns 0 when the day is not a recurring day.
*
* @param date The calendar date
* @param pattern Recurrence pattern from allocation metadata
* @param defaultHoursPerDay The allocation's base hoursPerDay
* @param allocationStartDate The allocation's own start date
*/
export function getRecurringHoursForDay(
date: Date,
pattern: RecurrencePattern,
defaultHoursPerDay: number,
allocationStartDate: Date,
): number {
// Respect optional start/end overrides on the pattern itself
if (pattern.startDate) {
const ps = new Date(pattern.startDate);
ps.setHours(0, 0, 0, 0);
if (date < ps) return 0;
}
if (pattern.endDate) {
const pe = new Date(pattern.endDate);
pe.setHours(0, 0, 0, 0);
if (date > pe) return 0;
}
if (!isRecurringDay(date, pattern, allocationStartDate)) return 0;
return pattern.hoursPerDay ?? defaultHoursPerDay;
}
@@ -0,0 +1,92 @@
import { FieldType, type BlueprintFieldDefinition } from "@planarchy/shared";
export interface CustomFieldValidationError {
key: string;
message: string;
}
/**
* Validates a `dynamicFields` record against an array of BlueprintFieldDefinitions.
* Returns an array of errors (empty = valid).
*/
export function validateCustomFields(
fieldDefs: BlueprintFieldDefinition[],
dynamicFields: Record<string, unknown>,
): CustomFieldValidationError[] {
const errors: CustomFieldValidationError[] = [];
for (const def of fieldDefs) {
const value = dynamicFields[def.key];
const isEmpty = value === undefined || value === null || value === "";
if (def.required && isEmpty) {
errors.push({ key: def.key, message: `${def.label} is required` });
continue;
}
if (isEmpty) continue;
switch (def.type) {
case FieldType.NUMBER: {
if (typeof value !== "number" && isNaN(Number(value))) {
errors.push({ key: def.key, message: `${def.label} must be a number` });
}
const validation = def.validation;
if (validation) {
const num = Number(value);
if (validation.min !== undefined && num < validation.min) {
errors.push({ key: def.key, message: `${def.label} must be at least ${validation.min}` });
}
if (validation.max !== undefined && num > validation.max) {
errors.push({ key: def.key, message: `${def.label} must be at most ${validation.max}` });
}
}
break;
}
case FieldType.BOOLEAN:
if (value !== true && value !== false && value !== "true" && value !== "false") {
errors.push({ key: def.key, message: `${def.label} must be a boolean` });
}
break;
case FieldType.SELECT:
if (def.options && def.options.length > 0) {
const valid = def.options.some((o) => o.value === value);
if (!valid) {
const allowed = def.options.map((o) => o.label || o.value).join(", ");
errors.push({ key: def.key, message: `${def.label} must be one of: ${allowed}` });
}
}
break;
case FieldType.MULTI_SELECT:
if (Array.isArray(value) && def.options && def.options.length > 0) {
const validSet = new Set(def.options.map((o) => o.value));
const invalid = (value as string[]).filter((v) => !validSet.has(v));
if (invalid.length > 0) {
errors.push({ key: def.key, message: `${def.label} contains invalid values: ${invalid.join(", ")}` });
}
}
break;
case FieldType.URL:
try {
new URL(String(value));
} catch {
errors.push({ key: def.key, message: `${def.label} must be a valid URL` });
}
break;
case FieldType.EMAIL:
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
errors.push({ key: def.key, message: `${def.label} must be a valid email address` });
}
break;
// TEXT, TEXTAREA, DATE — no structural validation beyond required
}
}
return errors;
}
+1
View File
@@ -0,0 +1 @@
export * from "./monitor.js";
+107
View File
@@ -0,0 +1,107 @@
import type { Allocation, BudgetStatus, BudgetWarning } from "@planarchy/shared";
import { BUDGET_WARNING_THRESHOLDS } from "@planarchy/shared";
/**
* Computes budget status for a project given its allocations.
* Pure function — all data passed as parameters.
*/
export function computeBudgetStatus(
budgetCents: number,
winProbability: number,
allocations: Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
projectStartDate: Date,
projectEndDate: Date,
): BudgetStatus {
const activeStatuses = new Set(["CONFIRMED", "ACTIVE"]);
const proposedStatuses = new Set(["PROPOSED"]);
let confirmedCents = 0;
let proposedCents = 0;
for (const alloc of allocations) {
const days = countWorkingDaysInRange(
new Date(alloc.startDate),
new Date(alloc.endDate),
);
const totalCents = alloc.dailyCostCents * days;
if (activeStatuses.has(alloc.status)) {
confirmedCents += totalCents;
} else if (proposedStatuses.has(alloc.status)) {
proposedCents += totalCents;
}
}
const allocatedCents = confirmedCents + proposedCents;
const remainingCents = budgetCents - allocatedCents;
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
const winProbabilityWeightedCents = Math.round(allocatedCents * (winProbability / 100));
const warnings: BudgetWarning[] = [];
if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.CRITICAL) {
warnings.push({
level: "critical",
code: "BUDGET_CRITICAL",
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — critical threshold exceeded`,
thresholdPercent: BUDGET_WARNING_THRESHOLDS.CRITICAL,
currentPercent: utilizationPercent,
});
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.WARNING) {
warnings.push({
level: "warning",
code: "BUDGET_WARNING",
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — approaching limit`,
thresholdPercent: BUDGET_WARNING_THRESHOLDS.WARNING,
currentPercent: utilizationPercent,
});
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.INFO) {
warnings.push({
level: "info",
code: "BUDGET_INFO",
message: `Budget utilization at ${utilizationPercent.toFixed(1)}%`,
thresholdPercent: BUDGET_WARNING_THRESHOLDS.INFO,
currentPercent: utilizationPercent,
});
}
if (allocatedCents > budgetCents) {
warnings.push({
level: "critical",
code: "BUDGET_EXCEEDED",
message: `Budget exceeded by ${((allocatedCents - budgetCents) / 100).toFixed(2)} EUR`,
thresholdPercent: 100,
currentPercent: utilizationPercent,
});
}
return {
budgetCents,
allocatedCents,
confirmedCents,
proposedCents,
remainingCents,
utilizationPercent,
winProbabilityWeightedCents,
warnings,
};
}
/** Simple working day counter (Mon-Fri) for budget calculations */
function countWorkingDaysInRange(startDate: Date, endDate: Date): number {
let count = 0;
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
count++;
}
current.setDate(current.getDate() + 1);
}
return count;
}
@@ -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";
@@ -0,0 +1,160 @@
/**
* Pure scope-to-effort expansion engine.
* Takes scope items and effort rules, produces demand line drafts.
* No DB or IO dependencies.
*/
export type EffortUnitMode = "per_frame" | "per_item" | "flat";
export interface EffortRuleInput {
scopeType: string;
discipline: string;
chapter?: string | null;
unitMode: EffortUnitMode;
hoursPerUnit: number;
sortOrder: number;
}
export interface ScopeItemInput {
name: string;
scopeType: string;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
}
export interface ExpandedDemandLine {
scopeItemName: string;
scopeType: string;
discipline: string;
chapter?: string | null;
hours: number;
unitMode: EffortUnitMode;
unitCount: number;
hoursPerUnit: number;
}
export interface EffortExpansionResult {
lines: ExpandedDemandLine[];
warnings: string[];
unmatchedScopeItems: string[];
}
function getUnitCount(
item: ScopeItemInput,
unitMode: EffortUnitMode,
): number {
switch (unitMode) {
case "per_frame":
return item.frameCount ?? 1;
case "per_item":
return item.itemCount ?? 1;
case "flat":
return 1;
}
}
/**
* Expand scope items into demand line drafts using effort rules.
*
* For each scope item, finds all matching rules by scopeType and generates
* one demand line per matching rule. Hours = unitCount * hoursPerUnit.
*
* Rules are matched case-insensitively on scopeType.
*/
export function expandScopeToEffort(
scopeItems: ScopeItemInput[],
rules: EffortRuleInput[],
): EffortExpansionResult {
const lines: ExpandedDemandLine[] = [];
const warnings: string[] = [];
const unmatchedScopeItems: string[] = [];
if (rules.length === 0) {
warnings.push("No effort rules provided.");
return { lines, warnings, unmatchedScopeItems };
}
// Index rules by normalized scopeType for fast lookup
const rulesByScopeType = new Map<string, EffortRuleInput[]>();
for (const rule of rules) {
const key = rule.scopeType.toLowerCase().trim();
const existing = rulesByScopeType.get(key) ?? [];
existing.push(rule);
rulesByScopeType.set(key, existing);
}
for (const item of scopeItems) {
if (!item.name.trim()) continue;
const normalizedType = item.scopeType.toLowerCase().trim();
const matchingRules = rulesByScopeType.get(normalizedType);
if (!matchingRules || matchingRules.length === 0) {
unmatchedScopeItems.push(item.name);
continue;
}
// Sort by sortOrder for deterministic output
const sortedRules = [...matchingRules].sort((a, b) => a.sortOrder - b.sortOrder);
for (const rule of sortedRules) {
const unitCount = getUnitCount(item, rule.unitMode);
const hours = Math.round(unitCount * rule.hoursPerUnit * 100) / 100;
if (hours <= 0) {
warnings.push(
`Skipped "${rule.discipline}" for "${item.name}": computed hours is 0 (unitCount=${unitCount}, hoursPerUnit=${rule.hoursPerUnit}).`,
);
continue;
}
lines.push({
scopeItemName: item.name,
scopeType: item.scopeType,
discipline: rule.discipline,
...(rule.chapter != null ? { chapter: rule.chapter } : {}),
hours,
unitMode: rule.unitMode,
unitCount,
hoursPerUnit: rule.hoursPerUnit,
});
}
}
if (unmatchedScopeItems.length > 0) {
warnings.push(
`${unmatchedScopeItems.length} scope item(s) had no matching rules: ${unmatchedScopeItems.slice(0, 5).join(", ")}${unmatchedScopeItems.length > 5 ? "..." : ""}.`,
);
}
return { lines, warnings, unmatchedScopeItems };
}
/**
* Aggregate expanded lines by discipline, summing hours.
* Useful for creating one demand line per discipline instead of per scope item.
*/
export function aggregateByDiscipline(
lines: ExpandedDemandLine[],
): Array<{ discipline: string; chapter?: string | null; totalHours: number; lineCount: number }> {
const map = new Map<string, { discipline: string; chapter?: string | null; totalHours: number; lineCount: number }>();
for (const line of lines) {
const key = `${line.discipline}::${line.chapter ?? ""}`;
const existing = map.get(key);
if (existing) {
existing.totalHours += line.hours;
existing.lineCount++;
} else {
map.set(key, {
discipline: line.discipline,
...(line.chapter != null ? { chapter: line.chapter } : {}),
totalHours: line.hours,
lineCount: 1,
});
}
}
return [...map.values()].sort((a, b) => b.totalHours - a.totalHours);
}
@@ -0,0 +1,215 @@
/**
* Pure experience-multiplier engine.
* Applies parametric rate adjustments (cost/bill multipliers) and
* shoring ratios (offshore/nearshore effort factors) to demand lines.
* No DB or IO dependencies.
*/
export interface ExperienceMultiplierRule {
chapter?: string | null;
location?: string | null;
level?: string | null;
costMultiplier: number;
billMultiplier: number;
shoringRatio?: number | null;
additionalEffortRatio?: number | null;
description?: string | null;
}
export interface RateAdjustmentInput {
costRateCents: number;
billRateCents: number;
hours: number;
chapter?: string | null;
location?: string | null;
level?: string | null;
}
export interface RateAdjustmentResult {
adjustedCostRateCents: number;
adjustedBillRateCents: number;
adjustedHours: number;
appliedRules: string[];
}
/**
* Compute a specificity score for a rule given matching dimensions.
* chapter=4, location=2, level=1 — higher means more specific.
*/
function ruleSpecificity(rule: ExperienceMultiplierRule): number {
let score = 0;
if (rule.chapter != null && rule.chapter !== "") score += 4;
if (rule.location != null && rule.location !== "") score += 2;
if (rule.level != null && rule.level !== "") score += 1;
return score;
}
function normalise(value: string | null | undefined): string {
return (value ?? "").toLowerCase().trim();
}
function fieldMatches(
ruleValue: string | null | undefined,
inputValue: string | null | undefined,
): boolean {
const rv = normalise(ruleValue);
if (rv === "") return true; // wildcard — matches everything
return rv === normalise(inputValue);
}
/**
* Find the best matching rule using hierarchical specificity.
*
* Matching priority (most specific first):
* 1. chapter + location + level
* 2. chapter + location
* 3. chapter + level
* 4. chapter only
* 5. location + level
* 6. location only
* 7. level only
* 8. Global fallback (no filters)
*
* When multiple rules share the same specificity, the first one wins.
*/
export function findBestMatchingRule(
input: Pick<RateAdjustmentInput, "chapter" | "location" | "level">,
rules: ExperienceMultiplierRule[],
): ExperienceMultiplierRule | null {
let bestRule: ExperienceMultiplierRule | null = null;
let bestScore = -1;
for (const rule of rules) {
const chapterMatch = fieldMatches(rule.chapter, input.chapter);
const locationMatch = fieldMatches(rule.location, input.location);
const levelMatch = fieldMatches(rule.level, input.level);
if (!chapterMatch || !locationMatch || !levelMatch) continue;
const score = ruleSpecificity(rule);
if (score > bestScore) {
bestScore = score;
bestRule = rule;
}
}
return bestRule;
}
/**
* Apply experience multipliers and shoring ratios to a single rate input.
*
* - Matches the most specific rule by chapter/location/level.
* - Multiplies cost and bill rates (rounds to integer cents).
* - Adjusts hours via shoring ratio and additional effort ratio:
* `adjustedHours = onsiteHours + offshoreHours * (1 + additionalEffortRatio)`
* where `onsiteHours = hours * (1 - shoringRatio)` and `offshoreHours = hours * shoringRatio`.
*/
export function applyExperienceMultipliers(
input: RateAdjustmentInput,
rules: ExperienceMultiplierRule[],
): RateAdjustmentResult {
const result: RateAdjustmentResult = {
adjustedCostRateCents: input.costRateCents,
adjustedBillRateCents: input.billRateCents,
adjustedHours: input.hours,
appliedRules: [],
};
if (rules.length === 0) {
result.appliedRules.push("No rules provided — values unchanged.");
return result;
}
const matched = findBestMatchingRule(input, rules);
if (!matched) {
result.appliedRules.push("No matching rule found — values unchanged.");
return result;
}
// Describe the match for the audit trail
const matchParts: string[] = [];
if (matched.chapter) matchParts.push(`chapter=${matched.chapter}`);
if (matched.location) matchParts.push(`location=${matched.location}`);
if (matched.level) matchParts.push(`level=${matched.level}`);
const matchLabel = matchParts.length > 0 ? matchParts.join(", ") : "global fallback";
// Apply rate multipliers
result.adjustedCostRateCents = Math.round(input.costRateCents * matched.costMultiplier);
result.adjustedBillRateCents = Math.round(input.billRateCents * matched.billMultiplier);
if (matched.costMultiplier !== 1.0 || matched.billMultiplier !== 1.0) {
result.appliedRules.push(
`Rate multipliers applied (${matchLabel}): cost x${matched.costMultiplier}, bill x${matched.billMultiplier}.`,
);
}
// Apply shoring ratio
const shoringRatio = matched.shoringRatio ?? 0;
const additionalEffort = matched.additionalEffortRatio ?? 0;
if (shoringRatio > 0) {
const onsiteHours = input.hours * (1 - shoringRatio);
const offshoreHours = input.hours * shoringRatio * (1 + additionalEffort);
result.adjustedHours = Math.round((onsiteHours + offshoreHours) * 100) / 100;
result.appliedRules.push(
`Shoring applied (${matchLabel}): ${(shoringRatio * 100).toFixed(0)}% shored` +
(additionalEffort > 0
? `, +${(additionalEffort * 100).toFixed(0)}% additional effort on shored portion`
: "") +
` => ${result.adjustedHours}h (from ${input.hours}h).`,
);
}
if (result.appliedRules.length === 0) {
result.appliedRules.push(`Matched rule (${matchLabel}) — multipliers are 1.0, no shoring; values unchanged.`);
}
if (matched.description) {
result.appliedRules.push(`Rule note: ${matched.description}`);
}
return result;
}
/**
* Batch-apply experience multipliers to multiple demand line inputs.
* Returns per-line results and an overall summary.
*/
export function applyExperienceMultipliersBatch(
inputs: RateAdjustmentInput[],
rules: ExperienceMultiplierRule[],
): {
results: RateAdjustmentResult[];
totalOriginalHours: number;
totalAdjustedHours: number;
linesAdjusted: number;
} {
const results = inputs.map((input) => applyExperienceMultipliers(input, rules));
let totalOriginalHours = 0;
let totalAdjustedHours = 0;
let linesAdjusted = 0;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]!;
const result = results[i]!;
totalOriginalHours += input.hours;
totalAdjustedHours += result.adjustedHours;
if (
result.adjustedCostRateCents !== input.costRateCents ||
result.adjustedBillRateCents !== input.billRateCents ||
result.adjustedHours !== input.hours
) {
linesAdjusted++;
}
}
return {
results,
totalOriginalHours: Math.round(totalOriginalHours * 100) / 100,
totalAdjustedHours: Math.round(totalAdjustedHours * 100) / 100,
linesAdjusted,
};
}
@@ -0,0 +1,639 @@
import * as XLSX from "xlsx";
import {
EstimateExportFormat,
type EstimateExportArtifactPayload,
type EstimateExportSummary,
type EstimateStatus,
type EstimateVersionStatus,
} from "@planarchy/shared";
import { summarizeEstimateDemandLines } from "./metrics.js";
type ExportProjectRef = {
id: string;
name: string;
shortCode?: string | null;
status?: string | null;
startDate?: Date | string | null;
endDate?: Date | string | null;
} | null;
type ExportAssumption = {
id: string;
category: string;
key: string;
label: string;
valueType: string;
value: unknown;
sortOrder: number;
notes?: string | null;
createdAt: Date;
updatedAt: Date;
};
type ExportScopeItem = {
id: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
name: string;
description?: string | null;
scene?: string | null;
page?: string | null;
location?: string | null;
assumptionCategory?: string | null;
technicalSpec: unknown;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
internalComments?: string | null;
externalComments?: string | null;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportDemandLine = {
id: string;
scopeItemId?: string | null;
roleId?: string | null;
resourceId?: string | null;
lineType: string;
name: string;
chapter?: string | null;
hours: number;
days?: number | null;
fte?: number | null;
rateSource?: string | null;
costRateCents: number;
billRateCents: number;
currency: string;
costTotalCents: number;
priceTotalCents: number;
monthlySpread: unknown;
staffingAttributes: unknown;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportResourceSnapshot = {
id: string;
resourceId?: string | null;
sourceEid?: string | null;
displayName: string;
chapter?: string | null;
roleId?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
fte?: number | null;
location?: string | null;
country?: string | null;
level?: string | null;
workType?: string | null;
attributes: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportMetric = {
id: string;
key: string;
label: string;
metricGroup?: string | null;
valueDecimal: number;
valueCents?: number | null;
currency?: string | null;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
export interface EstimateExportSource {
estimate: {
id: string;
projectId?: string | null;
name: string;
opportunityId?: string | null;
baseCurrency: string;
status: EstimateStatus | string;
createdAt: Date;
updatedAt: Date;
};
version: {
id: string;
versionNumber: number;
label?: string | null;
status: EstimateVersionStatus | string;
notes?: string | null;
lockedAt?: Date | null;
projectSnapshot: unknown;
createdAt: Date;
updatedAt: Date;
assumptions: ExportAssumption[];
scopeItems: ExportScopeItem[];
demandLines: ExportDemandLine[];
resourceSnapshots: ExportResourceSnapshot[];
metrics: ExportMetric[];
};
project: ExportProjectRef;
}
function serializeDate(value: Date | string | null | undefined) {
if (value instanceof Date) {
return value.toISOString();
}
return value ?? null;
}
function stringifyValue(value: unknown) {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
function toNumericRecord(value: unknown) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {} as Record<string, number>;
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
function escapeDelimitedValue(value: unknown, delimiter: string) {
const rendered = stringifyValue(value);
if (
rendered.includes(delimiter) ||
rendered.includes('"') ||
rendered.includes("\n") ||
rendered.includes("\r")
) {
return `"${rendered.replaceAll('"', '""')}"`;
}
return rendered;
}
function serializeDelimited(
rows: Array<Record<string, unknown>>,
delimiter: string,
) {
if (rows.length === 0) {
return "";
}
const columns = Array.from(
rows.reduce((keys, row) => {
for (const key of Object.keys(row)) {
keys.add(key);
}
return keys;
}, new Set<string>()),
);
const header = columns.map((column) => escapeDelimitedValue(column, delimiter));
const body = rows.map((row) =>
columns.map((column) => escapeDelimitedValue(row[column], delimiter)).join(delimiter),
);
return [header.join(delimiter), ...body].join("\n");
}
function buildSummary(source: EstimateExportSource): EstimateExportSummary {
const summarized = summarizeEstimateDemandLines(source.version.demandLines);
const metricsByKey = new Map(
source.version.metrics.map((metric) => [metric.key, metric]),
);
const totalHours =
metricsByKey.get("total_hours")?.valueDecimal ?? summarized.totalHours;
const totalCostCents =
metricsByKey.get("total_cost")?.valueCents ?? summarized.totalCostCents;
const totalPriceCents =
metricsByKey.get("total_price")?.valueCents ?? summarized.totalPriceCents;
const marginCents =
metricsByKey.get("margin")?.valueCents ?? summarized.marginCents;
const marginPercent =
metricsByKey.get("margin_percent")?.valueDecimal ?? summarized.marginPercent;
return {
estimateId: source.estimate.id,
estimateName: source.estimate.name,
versionId: source.version.id,
versionNumber: source.version.versionNumber,
versionStatus: source.version.status as EstimateVersionStatus,
projectId: source.estimate.projectId ?? source.project?.id ?? null,
projectName: source.project?.name ?? null,
baseCurrency: source.estimate.baseCurrency,
assumptionCount: source.version.assumptions.length,
scopeItemCount: source.version.scopeItems.length,
demandLineCount: source.version.demandLines.length,
resourceSnapshotCount: source.version.resourceSnapshots.length,
totalHours,
totalCostCents,
totalPriceCents,
marginCents,
marginPercent,
};
}
function buildOverviewRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
return [
{ field: "estimate_id", value: summary.estimateId },
{ field: "estimate_name", value: summary.estimateName },
{ field: "estimate_status", value: source.estimate.status },
{ field: "version_id", value: summary.versionId },
{ field: "version_number", value: summary.versionNumber },
{ field: "version_status", value: summary.versionStatus },
{ field: "version_label", value: source.version.label ?? "" },
{ field: "version_notes", value: source.version.notes ?? "" },
{ field: "project_id", value: summary.projectId ?? "" },
{ field: "project_name", value: summary.projectName ?? "" },
{ field: "project_code", value: source.project?.shortCode ?? "" },
{ field: "base_currency", value: summary.baseCurrency },
{ field: "opportunity_id", value: source.estimate.opportunityId ?? "" },
{ field: "locked_at", value: serializeDate(source.version.lockedAt) ?? "" },
{ field: "generated_from_project_start", value: serializeDate(source.project?.startDate) ?? "" },
{ field: "generated_from_project_end", value: serializeDate(source.project?.endDate) ?? "" },
{ field: "assumption_count", value: summary.assumptionCount },
{ field: "scope_item_count", value: summary.scopeItemCount },
{ field: "demand_line_count", value: summary.demandLineCount },
{ field: "resource_snapshot_count", value: summary.resourceSnapshotCount },
{ field: "total_hours", value: summary.totalHours },
{ field: "total_cost_cents", value: summary.totalCostCents },
{ field: "total_price_cents", value: summary.totalPriceCents },
{ field: "margin_cents", value: summary.marginCents },
{ field: "margin_percent", value: summary.marginPercent },
];
}
function buildAssumptionRows(assumptions: ExportAssumption[]) {
return assumptions.map((assumption) => ({
id: assumption.id,
category: assumption.category,
key: assumption.key,
label: assumption.label,
value_type: assumption.valueType,
value: stringifyValue(assumption.value),
notes: assumption.notes ?? "",
sort_order: assumption.sortOrder,
}));
}
function buildScopeRows(scopeItems: ExportScopeItem[]) {
return scopeItems.map((scopeItem) => ({
id: scopeItem.id,
sequence_no: scopeItem.sequenceNo,
scope_type: scopeItem.scopeType,
package_code: scopeItem.packageCode ?? "",
name: scopeItem.name,
description: scopeItem.description ?? "",
scene: scopeItem.scene ?? "",
page: scopeItem.page ?? "",
location: scopeItem.location ?? "",
assumption_category: scopeItem.assumptionCategory ?? "",
frame_count: scopeItem.frameCount ?? "",
item_count: scopeItem.itemCount ?? "",
unit_mode: scopeItem.unitMode ?? "",
technical_spec: stringifyValue(scopeItem.technicalSpec),
internal_comments: scopeItem.internalComments ?? "",
external_comments: scopeItem.externalComments ?? "",
metadata: stringifyValue(scopeItem.metadata),
}));
}
function buildDemandRows(source: EstimateExportSource) {
const scopeItemsById = new Map(
source.version.scopeItems.map((scopeItem) => [scopeItem.id, scopeItem]),
);
return source.version.demandLines.map((line, index) => ({
line_no: index + 1,
line_id: line.id,
scope_item_id: line.scopeItemId ?? "",
scope_item_name:
(line.scopeItemId ? scopeItemsById.get(line.scopeItemId)?.name : null) ?? "",
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
line_type: line.lineType,
name: line.name,
chapter: line.chapter ?? "",
hours: line.hours,
days: line.days ?? "",
fte: line.fte ?? "",
rate_source: line.rateSource ?? "",
cost_rate_cents: line.costRateCents,
bill_rate_cents: line.billRateCents,
currency: line.currency,
cost_total_cents: line.costTotalCents,
price_total_cents: line.priceTotalCents,
monthly_spread: stringifyValue(toNumericRecord(line.monthlySpread)),
staffing_attributes: stringifyValue(line.staffingAttributes),
metadata: stringifyValue(line.metadata),
}));
}
function buildResourceRows(resourceSnapshots: ExportResourceSnapshot[]) {
return resourceSnapshots.map((snapshot) => ({
id: snapshot.id,
resource_id: snapshot.resourceId ?? "",
source_eid: snapshot.sourceEid ?? "",
display_name: snapshot.displayName,
chapter: snapshot.chapter ?? "",
role_id: snapshot.roleId ?? "",
currency: snapshot.currency,
lcr_cents: snapshot.lcrCents,
ucr_cents: snapshot.ucrCents,
fte: snapshot.fte ?? "",
location: snapshot.location ?? "",
country: snapshot.country ?? "",
level: snapshot.level ?? "",
work_type: snapshot.workType ?? "",
attributes: stringifyValue(snapshot.attributes),
}));
}
function buildMetricRows(metrics: ExportMetric[]) {
return metrics.map((metric) => ({
id: metric.id,
key: metric.key,
label: metric.label,
metric_group: metric.metricGroup ?? "",
value_decimal: metric.valueDecimal,
value_cents: metric.valueCents ?? "",
currency: metric.currency ?? "",
metadata: stringifyValue(metric.metadata),
}));
}
function buildSapRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
return source.version.demandLines.map((line, index) => ({
record_type: "ESTIMATE_LINE",
estimate_id: summary.estimateId,
version_number: summary.versionNumber,
project_code: source.project?.shortCode ?? "",
project_name: summary.projectName ?? "",
line_no: index + 1,
line_name: line.name,
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
chapter: line.chapter ?? "",
hours: line.hours,
cost_rate_cents: line.costRateCents,
bill_rate_cents: line.billRateCents,
cost_total_cents: line.costTotalCents,
price_total_cents: line.priceTotalCents,
currency: line.currency,
rate_source: line.rateSource ?? "",
version_status: summary.versionStatus,
}));
}
function buildMmpRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
const monthKeys = Array.from(
new Set(
source.version.demandLines.flatMap((line) =>
Object.keys(toNumericRecord(line.monthlySpread)),
),
),
).sort();
return source.version.demandLines.map((line, index) => {
const baseRow: Record<string, unknown> = {
estimate_id: summary.estimateId,
version_number: summary.versionNumber,
project_id: summary.projectId ?? "",
project_code: source.project?.shortCode ?? "",
line_no: index + 1,
line_name: line.name,
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
total_hours: line.hours,
total_cost_cents: line.costTotalCents,
total_price_cents: line.priceTotalCents,
currency: line.currency,
};
for (const monthKey of monthKeys) {
baseRow[`month_${monthKey}`] =
toNumericRecord(line.monthlySpread)[monthKey] ?? 0;
}
return baseRow;
});
}
function buildJsonDocument(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
estimate: {
...source.estimate,
createdAt: source.estimate.createdAt.toISOString(),
updatedAt: source.estimate.updatedAt.toISOString(),
},
project: source.project
? {
...source.project,
startDate: serializeDate(source.project.startDate),
endDate: serializeDate(source.project.endDate),
}
: null,
version: {
id: source.version.id,
versionNumber: source.version.versionNumber,
label: source.version.label ?? null,
status: source.version.status,
notes: source.version.notes ?? null,
lockedAt: serializeDate(source.version.lockedAt),
projectSnapshot: source.version.projectSnapshot,
createdAt: source.version.createdAt.toISOString(),
updatedAt: source.version.updatedAt.toISOString(),
},
summary,
assumptions: source.version.assumptions.map((assumption) => ({
...assumption,
createdAt: assumption.createdAt.toISOString(),
updatedAt: assumption.updatedAt.toISOString(),
})),
scopeItems: source.version.scopeItems.map((scopeItem) => ({
...scopeItem,
createdAt: scopeItem.createdAt.toISOString(),
updatedAt: scopeItem.updatedAt.toISOString(),
})),
demandLines: source.version.demandLines.map((line) => ({
...line,
createdAt: line.createdAt.toISOString(),
updatedAt: line.updatedAt.toISOString(),
})),
resourceSnapshots: source.version.resourceSnapshots.map((snapshot) => ({
...snapshot,
createdAt: snapshot.createdAt.toISOString(),
updatedAt: snapshot.updatedAt.toISOString(),
})),
metrics: source.version.metrics.map((metric) => ({
...metric,
createdAt: metric.createdAt.toISOString(),
updatedAt: metric.updatedAt.toISOString(),
})),
};
}
function base64ByteLength(content: string) {
const padding = content.endsWith("==") ? 2 : content.endsWith("=") ? 1 : 0;
return Math.floor((content.length * 3) / 4) - padding;
}
function buildTextPayload(
format: EstimateExportFormat,
content: string,
summary: EstimateExportSummary,
options: {
mimeType: string;
fileExtension: string;
rowCount: number;
},
): EstimateExportArtifactPayload {
const lineCount = content.length === 0 ? 0 : content.split("\n").length;
return {
schemaVersion: 1,
format,
mimeType: options.mimeType,
encoding: "utf8",
fileExtension: options.fileExtension,
generatedAt: new Date().toISOString(),
byteLength: new TextEncoder().encode(content).length,
rowCount: options.rowCount,
lineCount,
previewText: content.split("\n").slice(0, 12).join("\n"),
content,
summary,
};
}
function buildXlsxPayload(
source: EstimateExportSource,
summary: EstimateExportSummary,
): EstimateExportArtifactPayload {
const overviewRows = buildOverviewRows(source, summary);
const assumptionRows = buildAssumptionRows(source.version.assumptions);
const scopeRows = buildScopeRows(source.version.scopeItems);
const demandRows = buildDemandRows(source);
const resourceRows = buildResourceRows(source.version.resourceSnapshots);
const metricRows = buildMetricRows(source.version.metrics);
const workbook = XLSX.utils.book_new();
const sheets = [
{ name: "Overview", rows: overviewRows },
{ name: "Assumptions", rows: assumptionRows },
{ name: "Scope", rows: scopeRows },
{ name: "DemandLines", rows: demandRows },
{ name: "Resources", rows: resourceRows },
{ name: "Metrics", rows: metricRows },
] as const;
for (const sheet of sheets) {
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet(sheet.rows),
sheet.name,
);
}
const content = XLSX.write(workbook, {
type: "base64",
bookType: "xlsx",
});
return {
schemaVersion: 1,
format: EstimateExportFormat.XLSX,
mimeType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
encoding: "base64",
fileExtension: "xlsx",
generatedAt: new Date().toISOString(),
byteLength: base64ByteLength(content),
rowCount:
overviewRows.length +
assumptionRows.length +
scopeRows.length +
demandRows.length +
resourceRows.length +
metricRows.length,
lineCount: null,
sheetNames: sheets.map((sheet) => sheet.name),
previewText: `Sheets: ${sheets.map((sheet) => sheet.name).join(", ")}`,
content,
summary,
};
}
export function serializeEstimateExport(
source: EstimateExportSource,
format: EstimateExportFormat,
): EstimateExportArtifactPayload {
const summary = buildSummary(source);
if (format === EstimateExportFormat.JSON) {
const content = JSON.stringify(buildJsonDocument(source, summary), null, 2);
return buildTextPayload(format, content, summary, {
mimeType: "application/json; charset=utf-8",
fileExtension: "json",
rowCount: summary.demandLineCount,
});
}
if (format === EstimateExportFormat.CSV) {
const rows = buildDemandRows(source);
return buildTextPayload(format, serializeDelimited(rows, ","), summary, {
mimeType: "text/csv; charset=utf-8",
fileExtension: "csv",
rowCount: rows.length,
});
}
if (format === EstimateExportFormat.SAP) {
const rows = buildSapRows(source, summary);
return buildTextPayload(format, serializeDelimited(rows, ";"), summary, {
mimeType: "text/plain; charset=utf-8",
fileExtension: "sap",
rowCount: rows.length,
});
}
if (format === EstimateExportFormat.MMP) {
const rows = buildMmpRows(source, summary);
return buildTextPayload(format, serializeDelimited(rows, "|"), summary, {
mimeType: "text/plain; charset=utf-8",
fileExtension: "mmp",
rowCount: rows.length,
});
}
return buildXlsxPayload(source, summary);
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./effort-rules.js";
export * from "./experience-multiplier.js";
export * from "./export-serializer.js";
export * from "./metrics.js";
export * from "./monthly-spread.js";
export * from "./version-compare.js";
export * from "./weekly-phasing.js";
+209
View File
@@ -0,0 +1,209 @@
import type {
EstimateDemandLine,
EstimateDemandLineCalculationMetadata,
EstimateDemandLineRateMode,
EstimateDemandSummary,
} from "@planarchy/shared";
export interface EstimateDemandLineRateSnapshot {
resourceId?: string | null;
currency?: string | null;
lcrCents: number;
ucrCents: number;
}
export interface EstimateDemandLineForCalculation {
resourceId?: string | null | undefined;
hours: number;
rateSource?: string | null | undefined;
costRateCents: number;
billRateCents: number;
currency?: string | null | undefined;
costTotalCents: number;
priceTotalCents: number;
metadata?: Record<string, unknown> | null | undefined;
}
type ParsedDemandLineMetadata = Record<string, unknown> & {
calculation?: Partial<EstimateDemandLineCalculationMetadata>;
};
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
return value === "resource" || value === "manual" ? value : undefined;
}
function parseDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
): ParsedDemandLineMetadata {
const safeMetadata =
typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
? metadata
: {};
const rawCalculation =
typeof safeMetadata.calculation === "object" &&
safeMetadata.calculation !== null &&
!Array.isArray(safeMetadata.calculation)
? (safeMetadata.calculation as Record<string, unknown>)
: undefined;
if (!rawCalculation) {
return safeMetadata as ParsedDemandLineMetadata;
}
const calculation: Partial<EstimateDemandLineCalculationMetadata> = {};
const costRateMode = parseRateMode(rawCalculation.costRateMode);
if (costRateMode) {
calculation.costRateMode = costRateMode;
}
const billRateMode = parseRateMode(rawCalculation.billRateMode);
if (billRateMode) {
calculation.billRateMode = billRateMode;
}
if (rawCalculation.totalMode === "computed") {
calculation.totalMode = "computed";
}
if (typeof rawCalculation.liveCostRateCents === "number") {
calculation.liveCostRateCents = rawCalculation.liveCostRateCents;
}
if (typeof rawCalculation.liveBillRateCents === "number") {
calculation.liveBillRateCents = rawCalculation.liveBillRateCents;
}
if (typeof rawCalculation.liveCurrency === "string") {
calculation.liveCurrency = rawCalculation.liveCurrency;
}
return {
...safeMetadata,
...(Object.keys(calculation).length > 0 ? { calculation } : {}),
};
}
function inferRateMode(
resourceSnapshot: EstimateDemandLineRateSnapshot | null | undefined,
effectiveRateCents: number,
liveRateCents: number | undefined,
explicitMode: EstimateDemandLineRateMode | undefined,
): EstimateDemandLineRateMode {
if (explicitMode) {
return explicitMode;
}
if (!resourceSnapshot || liveRateCents == null) {
return "manual";
}
return effectiveRateCents === liveRateCents ? "resource" : "manual";
}
export function getEstimateDemandLineCalculationMetadata(
line: Pick<
EstimateDemandLineForCalculation,
"resourceId" | "costRateCents" | "billRateCents" | "metadata"
>,
options?: {
resourceSnapshot?: EstimateDemandLineRateSnapshot | null | undefined;
},
): EstimateDemandLineCalculationMetadata {
const parsedMetadata = parseDemandLineMetadata(line.metadata);
const explicitCalculation = parsedMetadata.calculation;
const resourceSnapshot = options?.resourceSnapshot;
const liveCostRateCents = resourceSnapshot?.lcrCents;
const liveBillRateCents = resourceSnapshot?.ucrCents;
return {
costRateMode: inferRateMode(
resourceSnapshot,
line.costRateCents,
liveCostRateCents,
explicitCalculation?.costRateMode,
),
billRateMode: inferRateMode(
resourceSnapshot,
line.billRateCents,
liveBillRateCents,
explicitCalculation?.billRateMode,
),
totalMode: "computed",
liveCostRateCents: liveCostRateCents ?? null,
liveBillRateCents: liveBillRateCents ?? null,
liveCurrency: resourceSnapshot?.currency ?? null,
};
}
export function normalizeEstimateDemandLine<T extends EstimateDemandLineForCalculation>(
line: T,
options?: {
resourceSnapshot?: EstimateDemandLineRateSnapshot | null | undefined;
defaultCurrency?: string;
},
): T {
const resourceSnapshot = options?.resourceSnapshot;
const calculation = getEstimateDemandLineCalculationMetadata(line, {
resourceSnapshot,
});
const effectiveCostRateCents =
calculation.costRateMode === "resource" && resourceSnapshot
? resourceSnapshot.lcrCents
: line.costRateCents;
const effectiveBillRateCents =
calculation.billRateMode === "resource" && resourceSnapshot
? resourceSnapshot.ucrCents
: line.billRateCents;
const currency =
((calculation.costRateMode === "resource" ||
calculation.billRateMode === "resource") &&
resourceSnapshot?.currency
? resourceSnapshot.currency
: line.currency) ||
resourceSnapshot?.currency ||
options?.defaultCurrency ||
"EUR";
const metadata = parseDemandLineMetadata(line.metadata);
return {
...line,
costRateCents: effectiveCostRateCents,
billRateCents: effectiveBillRateCents,
currency,
costTotalCents: Math.round(line.hours * effectiveCostRateCents),
priceTotalCents: Math.round(line.hours * effectiveBillRateCents),
metadata: {
...metadata,
calculation,
},
};
}
export function summarizeEstimateDemandLines(
demandLines: Pick<
EstimateDemandLine,
"hours" | "costTotalCents" | "priceTotalCents"
>[],
): EstimateDemandSummary {
const totalHours = demandLines.reduce((sum, line) => sum + line.hours, 0);
const totalCostCents = demandLines.reduce(
(sum, line) => sum + line.costTotalCents,
0,
);
const totalPriceCents = demandLines.reduce(
(sum, line) => sum + line.priceTotalCents,
0,
);
const marginCents = totalPriceCents - totalCostCents;
const marginPercent =
totalPriceCents > 0 ? Math.round((marginCents / totalPriceCents) * 100) : 0;
return {
totalHours,
totalCostCents,
totalPriceCents,
marginCents,
marginPercent,
};
}
@@ -0,0 +1,225 @@
/**
* Monthly spread computation for estimate demand lines.
*
* Distributes total hours across months in a date range. Supports:
* - Even distribution (pro-rated for partial months)
* - Manual overrides per month
* - Rebalancing remaining hours after manual edits
*/
/** Format: "YYYY-MM" */
type MonthKey = string;
export interface MonthlySpreadInput {
totalHours: number;
startDate: Date;
endDate: Date;
}
export interface MonthlySpreadResult {
/** Hours per month, keyed by "YYYY-MM" */
spread: Record<MonthKey, number>;
/** Ordered month keys for display */
months: MonthKey[];
}
export interface RebalanceSpreadInput {
totalHours: number;
startDate: Date;
endDate: Date;
/** Months with manually locked values */
lockedMonths: Record<MonthKey, number>;
}
/**
* Returns ordered month keys between two dates (inclusive).
*/
export function getEstimateMonthRange(startDate: Date, endDate: Date): MonthKey[] {
const months: MonthKey[] = [];
const start = new Date(startDate);
const end = new Date(endDate);
let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const endMonth = new Date(end.getFullYear(), end.getMonth(), 1);
while (cursor <= endMonth) {
const year = cursor.getFullYear();
const month = String(cursor.getMonth() + 1).padStart(2, "0");
months.push(`${year}-${month}`);
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
}
return months;
}
/**
* Counts working days (Mon-Fri) in a date range.
*/
function countWorkingDays(from: Date, to: Date): number {
let count = 0;
const cursor = new Date(from);
cursor.setHours(0, 0, 0, 0);
const end = new Date(to);
end.setHours(0, 0, 0, 0);
while (cursor <= end) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) count++;
cursor.setDate(cursor.getDate() + 1);
}
return count;
}
function parseMonthKey(monthKey: string): { year: number; month: number } {
const parts = monthKey.split("-");
return {
year: parseInt(parts[0] ?? "0", 10),
month: parseInt(parts[1] ?? "1", 10) - 1,
};
}
/**
* Get working days per month within a date range, pro-rated for partial months.
*/
function getWorkingDaysPerMonth(
startDate: Date,
endDate: Date,
months: MonthKey[],
): Record<MonthKey, number> {
const result: Record<MonthKey, number> = {};
for (const monthKey of months) {
const { year, month } = parseMonthKey(monthKey);
const monthStart = new Date(year, month, 1);
const monthEnd = new Date(year, month + 1, 0); // last day of month
const effectiveStart = startDate > monthStart ? startDate : monthStart;
const effectiveEnd = endDate < monthEnd ? endDate : monthEnd;
result[monthKey] = countWorkingDays(effectiveStart, effectiveEnd);
}
return result;
}
/**
* Distribute total hours evenly across months, weighted by working days.
*/
export function computeEvenSpread(input: MonthlySpreadInput): MonthlySpreadResult {
const { totalHours, startDate, endDate } = input;
const months = getEstimateMonthRange(startDate, endDate);
if (months.length === 0) {
return { spread: {}, months: [] };
}
const workingDays = getWorkingDaysPerMonth(startDate, endDate, months);
const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0);
const spread: Record<MonthKey, number> = {};
if (totalWorkingDays === 0) {
// Fallback: distribute evenly by month count
const perMonth = Math.round((totalHours / months.length) * 10) / 10;
for (const month of months) {
spread[month] = perMonth;
}
} else {
for (const month of months) {
const days = workingDays[month] ?? 0;
const weight = days / totalWorkingDays;
spread[month] = Math.round(totalHours * weight * 10) / 10;
}
}
// Adjust rounding error on last month
const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0);
const diff = Math.round((totalHours - spreadTotal) * 10) / 10;
if (diff !== 0 && months.length > 0) {
const lastMonth = months[months.length - 1]!;
spread[lastMonth] = Math.round(((spread[lastMonth] ?? 0) + diff) * 10) / 10;
}
return { spread, months };
}
/**
* Rebalance: distribute remaining hours (after locked months) across unlocked months.
*/
export function rebalanceSpread(input: RebalanceSpreadInput): MonthlySpreadResult {
const { totalHours, startDate, endDate, lockedMonths } = input;
const months = getEstimateMonthRange(startDate, endDate);
if (months.length === 0) {
return { spread: {}, months: [] };
}
const lockedTotal = Object.entries(lockedMonths)
.filter(([key]) => months.includes(key))
.reduce((sum, [, hours]) => sum + hours, 0);
const remainingHours = Math.max(0, totalHours - lockedTotal);
const unlockedMonths = months.filter((m) => !(m in lockedMonths));
const spread: Record<MonthKey, number> = {};
// Copy locked months
for (const month of months) {
if (month in lockedMonths) {
spread[month] = lockedMonths[month] ?? 0;
}
}
if (unlockedMonths.length === 0) {
return { spread, months };
}
// Distribute remaining hours across unlocked months
const workingDays = getWorkingDaysPerMonth(startDate, endDate, unlockedMonths);
const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0);
if (totalWorkingDays === 0) {
const perMonth = Math.round((remainingHours / unlockedMonths.length) * 10) / 10;
for (const month of unlockedMonths) {
spread[month] = perMonth;
}
} else {
for (const month of unlockedMonths) {
const days = workingDays[month] ?? 0;
const weight = days / totalWorkingDays;
spread[month] = Math.round(remainingHours * weight * 10) / 10;
}
}
// Adjust rounding error on last unlocked month (only when locked total doesn't exceed budget)
if (lockedTotal <= totalHours) {
const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0);
const diff = Math.round((totalHours - spreadTotal) * 10) / 10;
if (diff !== 0 && unlockedMonths.length > 0) {
const lastUnlocked = unlockedMonths[unlockedMonths.length - 1]!;
spread[lastUnlocked] = Math.round(((spread[lastUnlocked] ?? 0) + diff) * 10) / 10;
}
}
return { spread, months };
}
/**
* Summarize monthly spreads across multiple demand lines.
* Returns total hours per month.
*/
export function summarizeMonthlySpread(
spreads: Record<MonthKey, number>[],
): Record<MonthKey, number> {
const totals: Record<MonthKey, number> = {};
for (const spread of spreads) {
for (const [month, hours] of Object.entries(spread)) {
totals[month] = Math.round(((totals[month] ?? 0) + hours) * 10) / 10;
}
}
return totals;
}
@@ -0,0 +1,498 @@
/**
* Pure diff engine for comparing two estimate version snapshots.
* No DB or IO dependencies — suitable for engine package.
*/
export interface VersionCompareInput {
label?: string | null;
versionNumber: number;
demandLines: Array<{
id: string;
name: string;
hours: number;
costRateCents: number;
billRateCents: number;
costTotalCents: number;
priceTotalCents: number;
chapter?: string | null;
lineType: string;
}>;
assumptions: Array<{
key: string;
label: string;
value: unknown;
}>;
scopeItems: Array<{
id?: string;
name: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
description?: string | null;
frameCount?: number | null;
itemCount?: number | null;
}>;
resourceSnapshots?: Array<{
id?: string;
resourceId?: string | null;
displayName: string;
chapter?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
location?: string | null;
level?: string | null;
}>;
}
export type DemandLineDiffStatus = "added" | "removed" | "changed" | "unchanged";
export interface DemandLineDiff {
name: string;
status: DemandLineDiffStatus;
hoursDelta?: number;
costDelta?: number;
priceDelta?: number;
a?: { hours: number; costTotalCents: number; priceTotalCents: number };
b?: { hours: number; costTotalCents: number; priceTotalCents: number };
}
export type AssumptionDiffStatus = "added" | "removed" | "changed" | "unchanged";
export interface AssumptionDiff {
key: string;
label: string;
status: AssumptionDiffStatus;
aValue?: unknown;
bValue?: unknown;
}
export type ScopeItemDiffStatus = "added" | "removed" | "changed" | "unchanged";
export interface ScopeItemDiff {
name: string;
scopeType: string;
status: ScopeItemDiffStatus;
changedFields?: string[] | undefined;
a?: { frameCount?: number | null | undefined; itemCount?: number | null | undefined; description?: string | null | undefined } | undefined;
b?: { frameCount?: number | null | undefined; itemCount?: number | null | undefined; description?: string | null | undefined } | undefined;
}
export type ResourceSnapshotDiffStatus = "added" | "removed" | "changed" | "unchanged";
export interface ResourceSnapshotDiff {
displayName: string;
status: ResourceSnapshotDiffStatus;
lcrDelta?: number | undefined;
ucrDelta?: number | undefined;
a?: { lcrCents: number; ucrCents: number; currency: string; location?: string | null | undefined; level?: string | null | undefined } | undefined;
b?: { lcrCents: number; ucrCents: number; currency: string; location?: string | null | undefined; level?: string | null | undefined } | undefined;
}
export interface ChapterSubtotal {
chapter: string;
hoursA: number;
hoursB: number;
hoursDelta: number;
costA: number;
costB: number;
costDelta: number;
}
export interface VersionDiffSummary {
totalHoursDelta: number;
totalCostDelta: number;
totalPriceDelta: number;
marginPercentA: number;
marginPercentB: number;
marginPercentDelta: number;
linesAdded: number;
linesRemoved: number;
linesChanged: number;
assumptionsChanged: number;
scopeItemsAdded: number;
scopeItemsRemoved: number;
scopeItemsChanged: number;
resourceSnapshotsChanged: number;
}
export interface VersionDiff {
summary: VersionDiffSummary;
demandLineDiffs: DemandLineDiff[];
assumptionDiffs: AssumptionDiff[];
scopeItemDiffs: ScopeItemDiff[];
resourceSnapshotDiffs: ResourceSnapshotDiff[];
chapterSubtotals: ChapterSubtotal[];
}
type DemandLine = VersionCompareInput["demandLines"][number];
type ScopeItem = VersionCompareInput["scopeItems"][number];
type ResourceSnapshot = NonNullable<VersionCompareInput["resourceSnapshots"]>[number];
function scopeDetail(s: ScopeItem): ScopeItemDiff["a"] {
return {
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
};
}
function resDetail(r: ResourceSnapshot): NonNullable<ResourceSnapshotDiff["a"]> {
return {
lcrCents: r.lcrCents,
ucrCents: r.ucrCents,
currency: r.currency,
...(r.location !== undefined ? { location: r.location } : {}),
...(r.level !== undefined ? { level: r.level } : {}),
};
}
function demandLineFuzzyKey(line: DemandLine): string {
return `${line.name}::${line.lineType}`;
}
/**
* Match demand lines from version A to version B.
* First pass: exact id match. Second pass: name+lineType fuzzy match for unmatched lines.
*/
function matchDemandLines(
aLines: DemandLine[],
bLines: DemandLine[],
): {
matched: Array<{ a: DemandLine; b: DemandLine }>;
addedInB: DemandLine[];
removedFromA: DemandLine[];
} {
const matched: Array<{ a: DemandLine; b: DemandLine }> = [];
const unmatchedA = new Map<string, DemandLine>();
const unmatchedB = new Map<string, DemandLine>();
// Index B by id
const bById = new Map<string, DemandLine>();
for (const line of bLines) {
bById.set(line.id, line);
}
// Pass 1: exact id match
const matchedBIds = new Set<string>();
for (const aLine of aLines) {
const bLine = bById.get(aLine.id);
if (bLine) {
matched.push({ a: aLine, b: bLine });
matchedBIds.add(bLine.id);
} else {
unmatchedA.set(aLine.id, aLine);
}
}
for (const bLine of bLines) {
if (!matchedBIds.has(bLine.id)) {
unmatchedB.set(bLine.id, bLine);
}
}
// Pass 2: fuzzy match by name+lineType
const bByFuzzy = new Map<string, DemandLine>();
for (const bLine of unmatchedB.values()) {
const key = demandLineFuzzyKey(bLine);
// Only use fuzzy if unique in unmatched B
if (!bByFuzzy.has(key)) {
bByFuzzy.set(key, bLine);
} else {
// Duplicate fuzzy key — skip fuzzy matching for this key
bByFuzzy.delete(key);
}
}
const fuzzyMatchedBIds = new Set<string>();
const stillUnmatchedA: DemandLine[] = [];
for (const aLine of unmatchedA.values()) {
const key = demandLineFuzzyKey(aLine);
const bLine = bByFuzzy.get(key);
if (bLine && !fuzzyMatchedBIds.has(bLine.id)) {
matched.push({ a: aLine, b: bLine });
fuzzyMatchedBIds.add(bLine.id);
} else {
stillUnmatchedA.push(aLine);
}
}
const addedInB: DemandLine[] = [];
for (const bLine of unmatchedB.values()) {
if (!fuzzyMatchedBIds.has(bLine.id)) {
addedInB.push(bLine);
}
}
return { matched, addedInB, removedFromA: stillUnmatchedA };
}
function valuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null && b == null) return true;
if (typeof a !== typeof b) return false;
if (typeof a === "object" && a !== null && b !== null) {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
/**
* Compare two estimate version snapshots and return a structured diff.
* Pure function — no side effects.
*/
export function compareEstimateVersions(
a: VersionCompareInput,
b: VersionCompareInput,
): VersionDiff {
// --- Demand lines ---
const { matched, addedInB, removedFromA } = matchDemandLines(
a.demandLines,
b.demandLines,
);
const demandLineDiffs: DemandLineDiff[] = [];
for (const { a: aLine, b: bLine } of matched) {
const hoursDelta = bLine.hours - aLine.hours;
const costDelta = bLine.costTotalCents - aLine.costTotalCents;
const priceDelta = bLine.priceTotalCents - aLine.priceTotalCents;
const isChanged = hoursDelta !== 0 || costDelta !== 0 || priceDelta !== 0;
demandLineDiffs.push({
name: bLine.name,
status: isChanged ? "changed" : "unchanged",
...(isChanged ? { hoursDelta, costDelta, priceDelta } : {}),
a: { hours: aLine.hours, costTotalCents: aLine.costTotalCents, priceTotalCents: aLine.priceTotalCents },
b: { hours: bLine.hours, costTotalCents: bLine.costTotalCents, priceTotalCents: bLine.priceTotalCents },
});
}
for (const line of addedInB) {
demandLineDiffs.push({
name: line.name,
status: "added",
hoursDelta: line.hours,
costDelta: line.costTotalCents,
priceDelta: line.priceTotalCents,
b: { hours: line.hours, costTotalCents: line.costTotalCents, priceTotalCents: line.priceTotalCents },
});
}
for (const line of removedFromA) {
demandLineDiffs.push({
name: line.name,
status: "removed",
hoursDelta: -line.hours,
costDelta: -line.costTotalCents,
priceDelta: -line.priceTotalCents,
a: { hours: line.hours, costTotalCents: line.costTotalCents, priceTotalCents: line.priceTotalCents },
});
}
// --- Assumptions ---
const aAssumptions = new Map(a.assumptions.map((x) => [x.key, x]));
const bAssumptions = new Map(b.assumptions.map((x) => [x.key, x]));
const allKeys = new Set([...aAssumptions.keys(), ...bAssumptions.keys()]);
const assumptionDiffs: AssumptionDiff[] = [];
let assumptionsChanged = 0;
for (const key of allKeys) {
const aItem = aAssumptions.get(key);
const bItem = bAssumptions.get(key);
if (aItem && bItem) {
const changed = !valuesEqual(aItem.value, bItem.value);
if (changed) assumptionsChanged++;
assumptionDiffs.push({
key,
label: bItem.label,
status: changed ? "changed" : "unchanged",
aValue: aItem.value,
bValue: bItem.value,
});
} else if (bItem) {
assumptionsChanged++;
assumptionDiffs.push({
key,
label: bItem.label,
status: "added",
bValue: bItem.value,
});
} else if (aItem) {
assumptionsChanged++;
assumptionDiffs.push({
key,
label: aItem.label,
status: "removed",
aValue: aItem.value,
});
}
}
// --- Scope items (detailed diff) ---
const aScopeByKey = new Map(a.scopeItems.map((s) => [`${s.name}::${s.scopeType}`, s]));
const bScopeByKey = new Map(b.scopeItems.map((s) => [`${s.name}::${s.scopeType}`, s]));
const allScopeKeys = new Set([...aScopeByKey.keys(), ...bScopeByKey.keys()]);
const scopeItemDiffs: ScopeItemDiff[] = [];
let scopeItemsAdded = 0;
let scopeItemsRemoved = 0;
let scopeItemsChanged = 0;
for (const key of allScopeKeys) {
const aItem = aScopeByKey.get(key);
const bItem = bScopeByKey.get(key);
if (aItem && bItem) {
const changedFields: string[] = [];
if (aItem.frameCount !== bItem.frameCount) changedFields.push("frameCount");
if (aItem.itemCount !== bItem.itemCount) changedFields.push("itemCount");
if ((aItem.description ?? null) !== (bItem.description ?? null)) changedFields.push("description");
if ((aItem.packageCode ?? null) !== (bItem.packageCode ?? null)) changedFields.push("packageCode");
const status: ScopeItemDiffStatus = changedFields.length > 0 ? "changed" : "unchanged";
if (status === "changed") scopeItemsChanged++;
scopeItemDiffs.push({
name: bItem.name,
scopeType: bItem.scopeType,
status,
...(changedFields.length > 0 ? { changedFields } : {}),
a: scopeDetail(aItem),
b: scopeDetail(bItem),
});
} else if (bItem) {
scopeItemsAdded++;
scopeItemDiffs.push({
name: bItem.name,
scopeType: bItem.scopeType,
status: "added",
b: scopeDetail(bItem),
});
} else if (aItem) {
scopeItemsRemoved++;
scopeItemDiffs.push({
name: aItem.name,
scopeType: aItem.scopeType,
status: "removed",
a: scopeDetail(aItem),
});
}
}
// --- Resource snapshots ---
const aResources = a.resourceSnapshots ?? [];
const bResources = b.resourceSnapshots ?? [];
const aResByKey = new Map(aResources.map((r) => [r.resourceId ?? r.displayName, r]));
const bResByKey = new Map(bResources.map((r) => [r.resourceId ?? r.displayName, r]));
const allResKeys = new Set([...aResByKey.keys(), ...bResByKey.keys()]);
const resourceSnapshotDiffs: ResourceSnapshotDiff[] = [];
let resourceSnapshotsChanged = 0;
for (const key of allResKeys) {
const aRes = aResByKey.get(key);
const bRes = bResByKey.get(key);
if (aRes && bRes) {
const lcrDelta = bRes.lcrCents - aRes.lcrCents;
const ucrDelta = bRes.ucrCents - aRes.ucrCents;
const locationChanged = (aRes.location ?? null) !== (bRes.location ?? null);
const levelChanged = (aRes.level ?? null) !== (bRes.level ?? null);
const isChanged = lcrDelta !== 0 || ucrDelta !== 0 || locationChanged || levelChanged;
if (isChanged) resourceSnapshotsChanged++;
resourceSnapshotDiffs.push({
displayName: bRes.displayName,
status: isChanged ? "changed" : "unchanged",
...(isChanged ? { lcrDelta, ucrDelta } : {}),
a: resDetail(aRes),
b: resDetail(bRes),
});
} else if (bRes) {
resourceSnapshotsChanged++;
resourceSnapshotDiffs.push({
displayName: bRes.displayName,
status: "added",
b: resDetail(bRes),
});
} else if (aRes) {
resourceSnapshotsChanged++;
resourceSnapshotDiffs.push({
displayName: aRes.displayName,
status: "removed",
a: resDetail(aRes),
});
}
}
// --- Chapter subtotals ---
const chapterMap = new Map<string, { hoursA: number; hoursB: number; costA: number; costB: number }>();
for (const line of a.demandLines) {
const ch = line.chapter ?? "(no chapter)";
const entry = chapterMap.get(ch) ?? { hoursA: 0, hoursB: 0, costA: 0, costB: 0 };
entry.hoursA += line.hours;
entry.costA += line.costTotalCents;
chapterMap.set(ch, entry);
}
for (const line of b.demandLines) {
const ch = line.chapter ?? "(no chapter)";
const entry = chapterMap.get(ch) ?? { hoursA: 0, hoursB: 0, costA: 0, costB: 0 };
entry.hoursB += line.hours;
entry.costB += line.costTotalCents;
chapterMap.set(ch, entry);
}
const chapterSubtotals: ChapterSubtotal[] = [...chapterMap.entries()]
.map(([chapter, v]) => ({
chapter,
hoursA: v.hoursA,
hoursB: v.hoursB,
hoursDelta: v.hoursB - v.hoursA,
costA: v.costA,
costB: v.costB,
costDelta: v.costB - v.costA,
}))
.sort((x, y) => Math.abs(y.costDelta) - Math.abs(x.costDelta));
// --- Summary ---
const aTotalHours = a.demandLines.reduce((s, l) => s + l.hours, 0);
const bTotalHours = b.demandLines.reduce((s, l) => s + l.hours, 0);
const aTotalCost = a.demandLines.reduce((s, l) => s + l.costTotalCents, 0);
const bTotalCost = b.demandLines.reduce((s, l) => s + l.costTotalCents, 0);
const aTotalPrice = a.demandLines.reduce((s, l) => s + l.priceTotalCents, 0);
const bTotalPrice = b.demandLines.reduce((s, l) => s + l.priceTotalCents, 0);
const marginPercentA = aTotalPrice > 0 ? ((aTotalPrice - aTotalCost) / aTotalPrice) * 100 : 0;
const marginPercentB = bTotalPrice > 0 ? ((bTotalPrice - bTotalCost) / bTotalPrice) * 100 : 0;
return {
summary: {
totalHoursDelta: bTotalHours - aTotalHours,
totalCostDelta: bTotalCost - aTotalCost,
totalPriceDelta: bTotalPrice - aTotalPrice,
marginPercentA,
marginPercentB,
marginPercentDelta: marginPercentB - marginPercentA,
linesAdded: addedInB.length,
linesRemoved: removedFromA.length,
linesChanged: demandLineDiffs.filter((d) => d.status === "changed").length,
assumptionsChanged,
scopeItemsAdded,
scopeItemsRemoved,
scopeItemsChanged,
resourceSnapshotsChanged,
},
demandLineDiffs,
assumptionDiffs,
scopeItemDiffs,
resourceSnapshotDiffs,
chapterSubtotals,
};
}
@@ -0,0 +1,287 @@
/**
* Weekly phasing computation for estimate demand lines (4Dispo-style).
*
* Distributes total hours across ISO 8601 weeks. Supports:
* - Even distribution
* - Front-loaded (60/40 split)
* - Back-loaded (40/60 split)
* - Custom per-week overrides
* - Aggregation to monthly spread
* - Aggregation by chapter for 4Dispo view
*/
export interface WeekDefinition {
weekNumber: number;
year: number;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
label: string; // e.g. "W12 2026"
}
export interface WeeklyPhasingInput {
totalHours: number;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
pattern?: "even" | "front_loaded" | "back_loaded" | "custom";
customWeeklyHours?: Record<string, number>; // weekKey "2026-W12" -> hours
}
export interface WeeklyPhasingResult {
weeks: WeekDefinition[];
weeklyHours: Record<string, number>; // weekKey -> hours
totalDistributedHours: number;
}
/**
* Returns ISO 8601 week number and year for a given date.
* Week 1 is the week containing January 4th; weeks start on Monday.
*/
function getISOWeekData(date: Date): { year: number; week: number } {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
// Set to nearest Thursday: current date + 4 - current day number (Monday=1, Sunday=7)
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
return { year: d.getUTCFullYear(), week };
}
/**
* Returns the Monday of the ISO week containing the given date.
*/
function getISOWeekMonday(date: Date): Date {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7; // Monday=1, Sunday=7
d.setUTCDate(d.getUTCDate() - (dayNum - 1));
return d;
}
/**
* Converts a date (string or Date) to an ISO week key "YYYY-Www".
*/
export function weekKeyFromDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const { year, week } = getISOWeekData(d);
return `${year}-W${String(week).padStart(2, "0")}`;
}
function formatDateISO(d: Date): string {
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Generate ISO week definitions between two dates (inclusive).
* Each week covers Monday-Sunday. Partial weeks at the boundaries are included.
*/
export function generateWeekRange(startDate: string, endDate: string): WeekDefinition[] {
const start = new Date(startDate);
const end = new Date(endDate);
if (start > end) {
return [];
}
const weeks: WeekDefinition[] = [];
const seen = new Set<string>();
// Start from the Monday of the week containing startDate
let monday = getISOWeekMonday(start);
while (monday.getTime() <= end.getTime()) {
const { year, week } = getISOWeekData(monday);
const key = `${year}-W${String(week).padStart(2, "0")}`;
if (!seen.has(key)) {
seen.add(key);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
weeks.push({
weekNumber: week,
year,
startDate: formatDateISO(monday),
endDate: formatDateISO(sunday),
label: `W${String(week).padStart(2, "0")} ${year}`,
});
}
// Move to next Monday
monday = new Date(monday);
monday.setUTCDate(monday.getUTCDate() + 7);
}
return weeks;
}
/**
* Distribute total hours across weeks according to the specified pattern.
*/
export function distributeHoursToWeeks(input: WeeklyPhasingInput): WeeklyPhasingResult {
const { totalHours, startDate, endDate, pattern = "even", customWeeklyHours } = input;
const weeks = generateWeekRange(startDate, endDate);
if (weeks.length === 0) {
return { weeks: [], weeklyHours: {}, totalDistributedHours: 0 };
}
const weeklyHours: Record<string, number> = {};
if (pattern === "custom" && customWeeklyHours) {
// Use custom values, defaulting missing weeks to 0
for (const week of weeks) {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
weeklyHours[key] = customWeeklyHours[key] ?? 0;
}
} else if (pattern === "even") {
const perWeek = totalHours / weeks.length;
for (const week of weeks) {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
weeklyHours[key] = Math.round(perWeek * 100) / 100;
}
// Fix rounding error on last week
adjustRoundingError(weeklyHours, weeks, totalHours);
} else if (pattern === "front_loaded") {
distributeLoadedPattern(weeklyHours, weeks, totalHours, "front");
} else if (pattern === "back_loaded") {
distributeLoadedPattern(weeklyHours, weeks, totalHours, "back");
}
const totalDistributedHours =
Math.round(Object.values(weeklyHours).reduce((sum, h) => sum + h, 0) * 100) / 100;
return { weeks, weeklyHours, totalDistributedHours };
}
/**
* Distribute hours with a linear ramp (front or back loaded).
* Front: 60% first half, 40% second half with linear decrease
* Back: 40% first half, 60% second half with linear increase
*/
function distributeLoadedPattern(
weeklyHours: Record<string, number>,
weeks: WeekDefinition[],
totalHours: number,
direction: "front" | "back",
): void {
const n = weeks.length;
if (n === 1) {
const key = `${weeks[0]!.year}-W${String(weeks[0]!.weekNumber).padStart(2, "0")}`;
weeklyHours[key] = totalHours;
return;
}
// Create linear ramp weights
// Front: weight decreases from high to low
// Back: weight increases from low to high
const weights: number[] = [];
for (let i = 0; i < n; i++) {
if (direction === "front") {
weights.push(n - i); // n, n-1, ..., 1
} else {
weights.push(i + 1); // 1, 2, ..., n
}
}
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
for (let i = 0; i < n; i++) {
const week = weeks[i]!;
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
weeklyHours[key] = Math.round((totalHours * (weights[i]! / totalWeight)) * 100) / 100;
}
adjustRoundingError(weeklyHours, weeks, totalHours);
}
function adjustRoundingError(
weeklyHours: Record<string, number>,
weeks: WeekDefinition[],
totalHours: number,
): void {
const distributed = Object.values(weeklyHours).reduce((sum, h) => sum + h, 0);
const diff = Math.round((totalHours - distributed) * 100) / 100;
if (diff !== 0 && weeks.length > 0) {
const lastWeek = weeks[weeks.length - 1]!;
const lastKey = `${lastWeek.year}-W${String(lastWeek.weekNumber).padStart(2, "0")}`;
weeklyHours[lastKey] = Math.round(((weeklyHours[lastKey] ?? 0) + diff) * 100) / 100;
}
}
/**
* Convert weekly hours (keyed by "YYYY-Www") to monthly totals (keyed by "YYYY-MM").
*
* Each week's hours are attributed to the month containing the Thursday of that week
* (which matches the ISO week-numbering year's month attribution).
*/
export function aggregateWeeklyToMonthly(
weeklyHours: Record<string, number>,
): Record<string, number> {
const monthly: Record<string, number> = {};
for (const [weekKey, hours] of Object.entries(weeklyHours)) {
const monthKey = weekKeyToMonthKey(weekKey);
monthly[monthKey] = Math.round(((monthly[monthKey] ?? 0) + hours) * 100) / 100;
}
return monthly;
}
/**
* Determine the month key for a week key by finding the Thursday of that week.
*/
function weekKeyToMonthKey(weekKey: string): string {
const match = weekKey.match(/^(\d{4})-W(\d{2})$/);
if (!match) {
throw new Error(`Invalid week key: ${weekKey}`);
}
const year = parseInt(match[1]!, 10);
const week = parseInt(match[2]!, 10);
// Find January 4th of that year (always in ISO week 1)
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4DayOfWeek = jan4.getUTCDay() || 7; // Monday=1
// Monday of week 1
const week1Monday = new Date(jan4);
week1Monday.setUTCDate(jan4.getUTCDate() - (jan4DayOfWeek - 1));
// Monday of the target week
const targetMonday = new Date(week1Monday);
targetMonday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7);
// Thursday of the target week
const thursday = new Date(targetMonday);
thursday.setUTCDate(targetMonday.getUTCDate() + 3);
const monthNum = String(thursday.getUTCMonth() + 1).padStart(2, "0");
return `${thursday.getUTCFullYear()}-${monthNum}`;
}
/**
* Aggregate weekly hours across multiple demand lines, grouped by chapter.
* Returns a map of chapter -> weekKey -> total hours.
* Lines with null/undefined chapter are grouped under "(Unassigned)".
*/
export function aggregateWeeklyByChapter(
lines: Array<{ chapter?: string | null; weeklyHours: Record<string, number> }>,
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const line of lines) {
const chapter = line.chapter ?? "(Unassigned)";
if (!result[chapter]) {
result[chapter] = {};
}
const chapterTotals = result[chapter]!;
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
chapterTotals[weekKey] = Math.round(((chapterTotals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return result;
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./allocation/index.js";
export * from "./blueprint/validator.js";
export * from "./budget/index.js";
export * from "./estimate/index.js";
export * from "./shift/index.js";
export * from "./vacation/utils.js";
export * from "./sah/index.js";
export * from "./chargeability/index.js";
+144
View File
@@ -0,0 +1,144 @@
/**
* Standard Available Hours (SAH) calculator.
*
* SAH = net working time after deducting holidays and absences.
* It is the denominator for chargeability calculations.
*/
import type { SpainScheduleRule } from "@planarchy/shared";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface SAHInput {
/** Base daily working hours for the country (e.g. 8 for DE, 9 for IN). */
dailyWorkingHours: number;
/** Optional variable schedule rules (e.g. Spain). */
scheduleRules?: SpainScheduleRule | null;
/** Resource FTE factor (0.01 1.0). Reduces effective daily hours. */
fte: number;
/** Period start date (inclusive). */
periodStart: Date;
/** Period end date (inclusive). */
periodEnd: Date;
/** Public holiday dates within the period (ISO strings or Dates). */
publicHolidays: (Date | string)[];
/** Absence dates within the period (vacation, illness, other). */
absenceDays: (Date | string)[];
}
export interface SAHResult {
/** Total calendar days in the period. */
calendarDays: number;
/** Weekend days in the period. */
weekendDays: number;
/** Working days (calendar - weekends). */
grossWorkingDays: number;
/** Public holidays falling on working days. */
publicHolidayDays: number;
/** Absence days falling on working days (excluding holidays). */
absenceDays: number;
/** Net working days after holidays and absences. */
netWorkingDays: number;
/** Average effective hours per working day (after FTE scaling). */
effectiveHoursPerDay: number;
/** Total Standard Available Hours for the period. */
standardAvailableHours: number;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function toISODate(d: Date | string): string {
if (typeof d === "string") return d.slice(0, 10);
return d.toISOString().slice(0, 10);
}
function isWeekend(date: Date): boolean {
const day = date.getUTCDay();
return day === 0 || day === 6;
}
/**
* Get daily working hours for a specific date given optional Spain schedule rules.
*/
export function getDailyHours(
date: Date,
baseHours: number,
scheduleRules?: SpainScheduleRule | null,
): number {
if (!scheduleRules || scheduleRules.type !== "spain") {
return baseHours;
}
const dayOfWeek = date.getUTCDay();
// Fridays always use fridayHours
if (dayOfWeek === 5) {
return scheduleRules.fridayHours;
}
// Check if date falls in summer period
const month = date.getUTCMonth() + 1;
const day = date.getUTCDate();
const mmdd = `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (mmdd >= scheduleRules.summerPeriod.from && mmdd <= scheduleRules.summerPeriod.to) {
return scheduleRules.summerHours;
}
return scheduleRules.regularHours;
}
// ─── Calculator ─────────────────────────────────────────────────────────────
export function calculateSAH(input: SAHInput): SAHResult {
const { dailyWorkingHours, scheduleRules, fte, periodStart, periodEnd, publicHolidays, absenceDays } = input;
const holidaySet = new Set(publicHolidays.map(toISODate));
const absenceSet = new Set(absenceDays.map(toISODate));
let calendarDays = 0;
let weekendDays = 0;
let publicHolidayCount = 0;
let absenceCount = 0;
let totalHoursOnWorkingDays = 0;
let netWorkingDays = 0;
const cursor = new Date(periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
calendarDays++;
const iso = cursor.toISOString().slice(0, 10);
if (isWeekend(cursor)) {
weekendDays++;
} else if (holidaySet.has(iso)) {
publicHolidayCount++;
} else if (absenceSet.has(iso)) {
absenceCount++;
} else {
// This is a net working day
const hoursForDay = getDailyHours(cursor, dailyWorkingHours, scheduleRules);
totalHoursOnWorkingDays += hoursForDay * fte;
netWorkingDays++;
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const grossWorkingDays = calendarDays - weekendDays;
const effectiveHoursPerDay = netWorkingDays > 0 ? totalHoursOnWorkingDays / netWorkingDays : dailyWorkingHours * fte;
return {
calendarDays,
weekendDays,
grossWorkingDays,
publicHolidayDays: publicHolidayCount,
absenceDays: absenceCount,
netWorkingDays,
effectiveHoursPerDay: Math.round(effectiveHoursPerDay * 100) / 100,
standardAvailableHours: Math.round(totalHoursOnWorkingDays * 100) / 100,
};
}
+2
View File
@@ -0,0 +1,2 @@
export { calculateSAH, getDailyHours } from "./calculator.js";
export type { SAHInput, SAHResult } from "./calculator.js";
+1
View File
@@ -0,0 +1 @@
export * from "./validator.js";
+206
View File
@@ -0,0 +1,206 @@
import type {
Allocation,
ConflictDetail,
CostImpact,
Resource,
ShiftValidationResult,
ValidationError,
ValidationWarning,
} from "@planarchy/shared";
import { calculateAllocation } from "../allocation/calculator.js";
import { validateAvailability } from "../allocation/availability-validator.js";
import { computeBudgetStatus } from "../budget/monitor.js";
export interface ShiftInput {
project: {
id: string;
budgetCents: number;
winProbability: number;
startDate: Date;
endDate: Date;
};
newStartDate: Date;
newEndDate: Date;
allocations: (Pick<
Allocation,
"id" | "resourceId" | "startDate" | "endDate" | "hoursPerDay" | "percentage" | "role" | "dailyCostCents" | "status"
> & {
resource: Pick<Resource, "id" | "displayName" | "lcrCents" | "availability">;
allAllocationsForResource: Pick<Allocation, "id" | "startDate" | "endDate" | "hoursPerDay" | "status" | "projectId">[];
/** Extracted from allocation metadata before calling validator */
includeSaturday?: boolean;
})[];
}
/**
* Validates a proposed project timeline shift.
*
* Pure function — all data passed in, no DB access.
* Returns a comprehensive validation result with cost impact and conflicts.
*/
export function validateShift(input: ShiftInput): ShiftValidationResult {
const { project, newStartDate, newEndDate, allocations } = input;
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
const conflictDetails: ConflictDetail[] = [];
const durationMs = project.endDate.getTime() - project.startDate.getTime();
const newDurationMs = newEndDate.getTime() - newStartDate.getTime();
// Validate date range
if (newEndDate < newStartDate) {
errors.push({
code: "INVALID_DATE_RANGE",
message: "New end date must be after new start date",
field: "newEndDate",
});
return buildResult(false, errors, warnings, conflictDetails, buildZeroCostImpact(project.budgetCents));
}
// Warn if duration changed significantly
const durationDeltaDays = Math.abs((newDurationMs - durationMs) / (1000 * 60 * 60 * 24));
if (durationDeltaDays > 7) {
warnings.push({
code: "DURATION_CHANGED",
message: `Project duration changed by ${Math.round(durationDeltaDays)} days`,
});
}
// Calculate shifted allocation dates and check availability
let newTotalCostCents = 0;
let currentTotalCostCents = 0;
for (const alloc of allocations) {
const { resource, allAllocationsForResource } = alloc;
const includeSaturday = alloc.includeSaturday ?? false;
// Compute current allocation cost
const currentCalc = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: alloc.hoursPerDay,
startDate: new Date(alloc.startDate),
endDate: new Date(alloc.endDate),
availability: resource.availability,
includeSaturday,
});
currentTotalCostCents += currentCalc.totalCostCents;
// Shift allocation proportionally within the new project window
const shiftedStart = new Date(newStartDate);
const shiftedEnd = new Date(newEndDate);
// Validate availability for shifted period (excluding current project's allocations)
const otherAllocations = allAllocationsForResource.filter(
(a) => a.projectId !== project.id,
);
const availResult = validateAvailability(
shiftedStart,
shiftedEnd,
alloc.hoursPerDay,
resource.availability,
otherAllocations,
includeSaturday,
);
if (!availResult.valid) {
const conflictDays = availResult.conflicts.map(
(c) => c.date.toISOString().split("T")[0] ?? "",
);
conflictDetails.push({
resourceId: resource.id,
resourceName: resource.displayName,
conflictType: "availability",
days: conflictDays,
message: `${resource.displayName} has availability conflicts on ${availResult.totalConflictDays} day(s)`,
});
if (availResult.totalConflictDays > 5) {
errors.push({
code: "AVAILABILITY_CONFLICT",
message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) exceed capacity`,
resourceId: resource.id,
});
} else {
warnings.push({
code: "AVAILABILITY_WARNING",
message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) may have capacity issues`,
resourceId: resource.id,
});
}
}
// Compute new cost for shifted period
const newCalc = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: alloc.hoursPerDay,
startDate: shiftedStart,
endDate: shiftedEnd,
availability: resource.availability,
includeSaturday,
});
newTotalCostCents += newCalc.totalCostCents;
}
// Budget impact check
const currentStatus = computeBudgetStatus(
project.budgetCents,
project.winProbability,
allocations.map((a) => ({
status: a.status,
dailyCostCents: a.dailyCostCents,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
})),
project.startDate,
project.endDate,
);
const wouldExceedBudget = newTotalCostCents > project.budgetCents;
if (wouldExceedBudget) {
errors.push({
code: "BUDGET_EXCEEDED",
message: `Shift would exceed budget by ${((newTotalCostCents - project.budgetCents) / 100).toFixed(2)} EUR`,
});
} else if (newTotalCostCents > project.budgetCents * 0.95) {
warnings.push({
code: "BUDGET_NEAR_LIMIT",
message: `Shift would use ${((newTotalCostCents / project.budgetCents) * 100).toFixed(1)}% of budget`,
});
}
const costImpact: CostImpact = {
currentTotalCents: currentTotalCostCents,
newTotalCents: newTotalCostCents,
deltaCents: newTotalCostCents - currentTotalCostCents,
budgetCents: project.budgetCents,
budgetUtilizationBefore: currentStatus.utilizationPercent,
budgetUtilizationAfter:
project.budgetCents > 0 ? (newTotalCostCents / project.budgetCents) * 100 : 0,
wouldExceedBudget,
};
return buildResult(errors.length === 0, errors, warnings, conflictDetails, costImpact);
}
function buildResult(
valid: boolean,
errors: ValidationError[],
warnings: ValidationWarning[],
conflictDetails: ConflictDetail[],
costImpact: CostImpact,
): ShiftValidationResult {
return { valid, errors, warnings, costImpact, conflictDetails };
}
function buildZeroCostImpact(budgetCents: number): CostImpact {
return {
currentTotalCents: 0,
newTotalCents: 0,
deltaCents: 0,
budgetCents,
budgetUtilizationBefore: 0,
budgetUtilizationAfter: 0,
wouldExceedBudget: false,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Vacation utility functions for the engine layer.
* These are pure functions — no DB imports.
*/
export interface VacationRange {
startDate: Date;
endDate: Date;
status: string;
}
/**
* Returns true if the given date falls within any APPROVED vacation range.
*/
export function isVacationDay(date: Date, vacations: VacationRange[]): boolean {
const t = new Date(date);
t.setHours(0, 0, 0, 0);
const time = t.getTime();
for (const v of vacations) {
if (v.status !== "APPROVED") continue;
const start = new Date(v.startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(v.endDate);
end.setHours(0, 0, 0, 0);
if (time >= start.getTime() && time <= end.getTime()) return true;
}
return false;
}
/**
* Returns all APPROVED vacation dates (as Date objects) that fall within [startDate, endDate].
* Used for demand recalculation display.
*/
export function getVacationDatesInRange(
startDate: Date,
endDate: Date,
vacations: VacationRange[],
): Date[] {
const result: Date[] = [];
const approved = vacations.filter((v) => v.status === "APPROVED");
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
while (current <= end) {
for (const v of approved) {
const vStart = new Date(v.startDate);
vStart.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
if (current >= vStart && current <= vEnd) {
result.push(new Date(current));
break;
}
}
current.setDate(current.getDate() + 1);
}
return result;
}