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(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(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(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 { 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(); 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(); 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), ); }