/** * Monthly spread computation for estimate demand lines. * * Distributes total hours across months in a date range. Supports: * - Even distribution (pro-rated for partial months) * - Manual overrides per month * - Rebalancing remaining hours after manual edits */ /** Format: "YYYY-MM" */ type MonthKey = string; export interface MonthlySpreadInput { totalHours: number; startDate: Date; endDate: Date; } export interface MonthlySpreadResult { /** Hours per month, keyed by "YYYY-MM" */ spread: Record; /** Ordered month keys for display */ months: MonthKey[]; } export interface RebalanceSpreadInput { totalHours: number; startDate: Date; endDate: Date; /** Months with manually locked values */ lockedMonths: Record; } /** * Returns ordered month keys between two dates (inclusive). */ export function getEstimateMonthRange(startDate: Date, endDate: Date): MonthKey[] { const months: MonthKey[] = []; const start = new Date(startDate); const end = new Date(endDate); let cursor = new Date(start.getFullYear(), start.getMonth(), 1); const endMonth = new Date(end.getFullYear(), end.getMonth(), 1); while (cursor <= endMonth) { const year = cursor.getFullYear(); const month = String(cursor.getMonth() + 1).padStart(2, "0"); months.push(`${year}-${month}`); cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); } return months; } /** * Counts working days (Mon-Fri) in a date range. */ function countWorkingDays(from: Date, to: Date): number { let count = 0; const cursor = new Date(from); cursor.setHours(0, 0, 0, 0); const end = new Date(to); end.setHours(0, 0, 0, 0); while (cursor <= end) { const day = cursor.getDay(); if (day !== 0 && day !== 6) count++; cursor.setDate(cursor.getDate() + 1); } return count; } function parseMonthKey(monthKey: string): { year: number; month: number } { const parts = monthKey.split("-"); return { year: parseInt(parts[0] ?? "0", 10), month: parseInt(parts[1] ?? "1", 10) - 1, }; } /** * Get working days per month within a date range, pro-rated for partial months. */ function getWorkingDaysPerMonth( startDate: Date, endDate: Date, months: MonthKey[], ): Record { const result: Record = {}; for (const monthKey of months) { const { year, month } = parseMonthKey(monthKey); const monthStart = new Date(year, month, 1); const monthEnd = new Date(year, month + 1, 0); // last day of month const effectiveStart = startDate > monthStart ? startDate : monthStart; const effectiveEnd = endDate < monthEnd ? endDate : monthEnd; result[monthKey] = countWorkingDays(effectiveStart, effectiveEnd); } return result; } /** * Distribute total hours evenly across months, weighted by working days. */ export function computeEvenSpread(input: MonthlySpreadInput): MonthlySpreadResult { const { totalHours, startDate, endDate } = input; const months = getEstimateMonthRange(startDate, endDate); if (months.length === 0) { return { spread: {}, months: [] }; } const workingDays = getWorkingDaysPerMonth(startDate, endDate, months); const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0); const spread: Record = {}; if (totalWorkingDays === 0) { // Fallback: distribute evenly by month count const perMonth = Math.round((totalHours / months.length) * 10) / 10; for (const month of months) { spread[month] = perMonth; } } else { for (const month of months) { const days = workingDays[month] ?? 0; const weight = days / totalWorkingDays; spread[month] = Math.round(totalHours * weight * 10) / 10; } } // Adjust rounding error on last month const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0); const diff = Math.round((totalHours - spreadTotal) * 10) / 10; if (diff !== 0 && months.length > 0) { const lastMonth = months[months.length - 1]!; spread[lastMonth] = Math.round(((spread[lastMonth] ?? 0) + diff) * 10) / 10; } return { spread, months }; } /** * Rebalance: distribute remaining hours (after locked months) across unlocked months. */ export function rebalanceSpread(input: RebalanceSpreadInput): MonthlySpreadResult { const { totalHours, startDate, endDate, lockedMonths } = input; const months = getEstimateMonthRange(startDate, endDate); if (months.length === 0) { return { spread: {}, months: [] }; } const lockedTotal = Object.entries(lockedMonths) .filter(([key]) => months.includes(key)) .reduce((sum, [, hours]) => sum + hours, 0); const remainingHours = Math.max(0, totalHours - lockedTotal); const unlockedMonths = months.filter((m) => !(m in lockedMonths)); const spread: Record = {}; // Copy locked months for (const month of months) { if (month in lockedMonths) { spread[month] = lockedMonths[month] ?? 0; } } if (unlockedMonths.length === 0) { return { spread, months }; } // Distribute remaining hours across unlocked months const workingDays = getWorkingDaysPerMonth(startDate, endDate, unlockedMonths); const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0); if (totalWorkingDays === 0) { const perMonth = Math.round((remainingHours / unlockedMonths.length) * 10) / 10; for (const month of unlockedMonths) { spread[month] = perMonth; } } else { for (const month of unlockedMonths) { const days = workingDays[month] ?? 0; const weight = days / totalWorkingDays; spread[month] = Math.round(remainingHours * weight * 10) / 10; } } // Adjust rounding error on last unlocked month (only when locked total doesn't exceed budget) if (lockedTotal <= totalHours) { const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0); const diff = Math.round((totalHours - spreadTotal) * 10) / 10; if (diff !== 0 && unlockedMonths.length > 0) { const lastUnlocked = unlockedMonths[unlockedMonths.length - 1]!; spread[lastUnlocked] = Math.round(((spread[lastUnlocked] ?? 0) + diff) * 10) / 10; } } return { spread, months }; } /** * Summarize monthly spreads across multiple demand lines. * Returns total hours per month. */ export function summarizeMonthlySpread( spreads: Record[], ): Record { const totals: Record = {}; for (const spread of spreads) { for (const [month, hours] of Object.entries(spread)) { totals[month] = Math.round(((totals[month] ?? 0) + hours) * 10) / 10; } } return totals; }