/** * 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; // weekKey "2026-W12" -> hours } export interface WeeklyPhasingResult { weeks: WeekDefinition[]; weeklyHours: Record; // 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(); // 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 = {}; 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, 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, 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, ): Record { const monthly: Record = {}; 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 }>, ): Record> { const result: Record> = {}; 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; }