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

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

290 lines
9.3 KiB
TypeScript

/**
* 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
*/
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
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()) / MILLISECONDS_PER_DAY + 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;
}