chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,160 @@
/**
* Pure scope-to-effort expansion engine.
* Takes scope items and effort rules, produces demand line drafts.
* No DB or IO dependencies.
*/
export type EffortUnitMode = "per_frame" | "per_item" | "flat";
export interface EffortRuleInput {
scopeType: string;
discipline: string;
chapter?: string | null;
unitMode: EffortUnitMode;
hoursPerUnit: number;
sortOrder: number;
}
export interface ScopeItemInput {
name: string;
scopeType: string;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
}
export interface ExpandedDemandLine {
scopeItemName: string;
scopeType: string;
discipline: string;
chapter?: string | null;
hours: number;
unitMode: EffortUnitMode;
unitCount: number;
hoursPerUnit: number;
}
export interface EffortExpansionResult {
lines: ExpandedDemandLine[];
warnings: string[];
unmatchedScopeItems: string[];
}
function getUnitCount(
item: ScopeItemInput,
unitMode: EffortUnitMode,
): number {
switch (unitMode) {
case "per_frame":
return item.frameCount ?? 1;
case "per_item":
return item.itemCount ?? 1;
case "flat":
return 1;
}
}
/**
* Expand scope items into demand line drafts using effort rules.
*
* For each scope item, finds all matching rules by scopeType and generates
* one demand line per matching rule. Hours = unitCount * hoursPerUnit.
*
* Rules are matched case-insensitively on scopeType.
*/
export function expandScopeToEffort(
scopeItems: ScopeItemInput[],
rules: EffortRuleInput[],
): EffortExpansionResult {
const lines: ExpandedDemandLine[] = [];
const warnings: string[] = [];
const unmatchedScopeItems: string[] = [];
if (rules.length === 0) {
warnings.push("No effort rules provided.");
return { lines, warnings, unmatchedScopeItems };
}
// Index rules by normalized scopeType for fast lookup
const rulesByScopeType = new Map<string, EffortRuleInput[]>();
for (const rule of rules) {
const key = rule.scopeType.toLowerCase().trim();
const existing = rulesByScopeType.get(key) ?? [];
existing.push(rule);
rulesByScopeType.set(key, existing);
}
for (const item of scopeItems) {
if (!item.name.trim()) continue;
const normalizedType = item.scopeType.toLowerCase().trim();
const matchingRules = rulesByScopeType.get(normalizedType);
if (!matchingRules || matchingRules.length === 0) {
unmatchedScopeItems.push(item.name);
continue;
}
// Sort by sortOrder for deterministic output
const sortedRules = [...matchingRules].sort((a, b) => a.sortOrder - b.sortOrder);
for (const rule of sortedRules) {
const unitCount = getUnitCount(item, rule.unitMode);
const hours = Math.round(unitCount * rule.hoursPerUnit * 100) / 100;
if (hours <= 0) {
warnings.push(
`Skipped "${rule.discipline}" for "${item.name}": computed hours is 0 (unitCount=${unitCount}, hoursPerUnit=${rule.hoursPerUnit}).`,
);
continue;
}
lines.push({
scopeItemName: item.name,
scopeType: item.scopeType,
discipline: rule.discipline,
...(rule.chapter != null ? { chapter: rule.chapter } : {}),
hours,
unitMode: rule.unitMode,
unitCount,
hoursPerUnit: rule.hoursPerUnit,
});
}
}
if (unmatchedScopeItems.length > 0) {
warnings.push(
`${unmatchedScopeItems.length} scope item(s) had no matching rules: ${unmatchedScopeItems.slice(0, 5).join(", ")}${unmatchedScopeItems.length > 5 ? "..." : ""}.`,
);
}
return { lines, warnings, unmatchedScopeItems };
}
/**
* Aggregate expanded lines by discipline, summing hours.
* Useful for creating one demand line per discipline instead of per scope item.
*/
export function aggregateByDiscipline(
lines: ExpandedDemandLine[],
): Array<{ discipline: string; chapter?: string | null; totalHours: number; lineCount: number }> {
const map = new Map<string, { discipline: string; chapter?: string | null; totalHours: number; lineCount: number }>();
for (const line of lines) {
const key = `${line.discipline}::${line.chapter ?? ""}`;
const existing = map.get(key);
if (existing) {
existing.totalHours += line.hours;
existing.lineCount++;
} else {
map.set(key, {
discipline: line.discipline,
...(line.chapter != null ? { chapter: line.chapter } : {}),
totalHours: line.hours,
lineCount: 1,
});
}
}
return [...map.values()].sort((a, b) => b.totalHours - a.totalHours);
}