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