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