Files
ftb-project-management/apps/web/lib/version-overview.ts
2026-06-26 13:06:22 +08:00

231 lines
7.5 KiB
TypeScript

import type { Stage } from './stage';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug, BugSeverity } from './bug';
import type { OvertimeRecord } from './overtime';
import { getActualHours as getDevTaskActualHours } from './dev-task';
import { getTestCaseActualHours } from './test-case';
import { getBugActualHours } from './bug';
import { calcActualElapsedHours } from './work-hours';
export interface StageEffortMetric {
actualHours: number;
estimateHours?: number;
aiEstimateHours?: number;
showEstimates?: boolean;
}
export interface PersonalEffortItem {
name: string;
actualHours: number;
overtimeHours: number;
total: number;
}
export interface BugSeverityRankingItem {
assigneeId: string;
critical: number;
major: number;
minor: number;
trivial: number;
total: number;
}
export interface VersionOverviewEffortTotals {
actualHours: number;
overtimeHours: number;
}
function roundHalf(hours: number): number {
return Math.round(hours * 2) / 2;
}
function roundTenth(hours: number): number {
return Math.round(hours * 10) / 10;
}
function roundHundredth(hours: number): number {
return Math.round(hours * 100) / 100;
}
function sumAiEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
const total = items.reduce((sum, item) => {
const value = getValue(item);
return sum + (typeof value === 'number' && value > 0 ? value : 0);
}, 0);
return total > 0 ? roundHundredth(total) : undefined;
}
function sumEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
const total = items.reduce((sum, item) => {
const value = getValue(item);
return sum + (typeof value === 'number' && value > 0 ? value : 0);
}, 0);
return total > 0 ? roundHundredth(total) : undefined;
}
function getPlanActualHours(plan: VersionPlan, now: Date): number {
if (!plan.actualStartAt) return 0;
return calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now.toISOString());
}
function sumActualHours<T>(items: T[], getValue: (item: T) => number): number {
return roundHalf(items.reduce((sum, item) => sum + getValue(item), 0));
}
export function calcStageEffortMetrics(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
now?: Date;
}): Record<Stage, StageEffortMetric> {
const now = input.now ?? new Date();
const researchPlans = input.plans.filter((p) => p.type === 'research');
const productPlans = input.plans.filter((p) => p.type === 'product');
const uiPlans = input.plans.filter((p) => p.type === 'ui');
return {
requirement: {
actualHours: sumActualHours(researchPlans, (plan) => getPlanActualHours(plan, now)),
},
product_design: {
actualHours: sumActualHours(productPlans, (plan) => getPlanActualHours(plan, now)),
},
ui_design: {
actualHours: sumActualHours(uiPlans, (plan) => getPlanActualHours(plan, now)),
},
dev: {
actualHours: sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now)),
estimateHours: sumEstimate(input.devTasks, (task) => task.estimateHours),
aiEstimateHours: sumAiEstimate(input.devTasks, (task) => task.aiEstimateHours),
showEstimates: true,
},
testing: {
actualHours: sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now)),
estimateHours: sumEstimate(input.testCases, (testCase) => testCase.estimateHours),
aiEstimateHours: sumAiEstimate(input.testCases, (testCase) => testCase.aiEstimateHours),
showEstimates: true,
},
bug: {
actualHours: sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now)),
estimateHours: sumEstimate(input.bugs, (bug) => bug.estimateHours),
aiEstimateHours: sumAiEstimate(input.bugs, (bug) => bug.aiEstimateHours),
showEstimates: true,
},
};
}
export function calcVersionOverviewEffortTotals(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords: OvertimeRecord[];
now?: Date;
}): VersionOverviewEffortTotals {
const now = input.now ?? new Date();
const planHours = sumActualHours(input.plans, (plan) => getPlanActualHours(plan, now));
const devHours = sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now));
const testHours = sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now));
const bugHours = sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now));
const actualHours = roundHalf(planHours + devHours + testHours + bugHours);
const overtimeHours = roundTenth(input.overtimeRecords.reduce((sum, record) => sum + record.duration, 0));
return { actualHours, overtimeHours };
}
export function calcPersonalEffortRanking(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords?: OvertimeRecord[];
now?: Date;
}): PersonalEffortItem[] {
const now = input.now ?? new Date();
const personalHours = new Map<string, { actualHours: number; overtimeHours: number }>();
const addActualHours = (name: string | undefined, hours: number) => {
if (!name || hours <= 0) return;
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
prev.actualHours += hours;
personalHours.set(name, prev);
};
const addOvertimeHours = (name: string | undefined, hours: number) => {
if (!name || hours <= 0) return;
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
prev.overtimeHours += hours;
personalHours.set(name, prev);
};
input.plans.forEach((plan) => {
addActualHours(plan.owner, getPlanActualHours(plan, now));
});
input.devTasks.forEach((task) => {
addActualHours(task.assigneeId, getDevTaskActualHours(task, now));
});
input.testCases.forEach((testCase) => {
addActualHours(testCase.assigneeId, getTestCaseActualHours(testCase, now));
});
input.bugs.forEach((bug) => {
addActualHours(bug.assigneeId, getBugActualHours(bug, now));
});
(input.overtimeRecords ?? []).forEach((record) => {
addOvertimeHours(record.person, record.duration);
});
return Array.from(personalHours.entries())
.map(([name, hours]) => {
const actualHours = roundHalf(hours.actualHours);
const overtimeHours = roundTenth(hours.overtimeHours);
return {
name,
actualHours,
overtimeHours,
total: roundTenth(actualHours + overtimeHours),
};
})
.sort((a, b) => b.total - a.total);
}
function createBugSeverityRankingItem(assigneeId: string): BugSeverityRankingItem {
return {
assigneeId,
critical: 0,
major: 0,
minor: 0,
trivial: 0,
total: 0,
};
}
export function calcBugSeverityRanking(bugs: Bug[]): BugSeverityRankingItem[] {
const rows = new Map<string, BugSeverityRankingItem>();
const severityOrder: BugSeverity[] = ['critical', 'major', 'minor', 'trivial'];
bugs.forEach((bug) => {
if (!bug.assigneeId || !severityOrder.includes(bug.severity)) return;
const row = rows.get(bug.assigneeId) ?? createBugSeverityRankingItem(bug.assigneeId);
row[bug.severity] += 1;
row.total += 1;
rows.set(bug.assigneeId, row);
});
return Array.from(rows.values()).sort(
(a, b) =>
b.total - a.total ||
b.critical - a.critical ||
b.major - a.major ||
b.minor - a.minor ||
b.trivial - a.trivial ||
a.assigneeId.localeCompare(b.assigneeId),
);
}