231 lines
7.5 KiB
TypeScript
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),
|
|
);
|
|
}
|