chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user