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();
});
});