import { STAGES, 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 type { VersionStatus } from './version-status'; import { getActualHours as getDevTaskActualHours } from './dev-task'; import { getTestCaseActualHours } from './test-case'; import { getBugActualHours } from './bug'; import { calcActualElapsedHours } from './work-hours'; import { formatDateTime } from './format'; export interface StageEffortMetric { actualHours: number; estimateHours?: number; aiEstimateHours?: number; showEstimates?: boolean; } export interface StageProgressState { percent: number; status: 'idle' | 'active' | 'done'; } export type StageProgressWithEffort = StageProgressState & StageEffortMetric; 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; } export interface VersionTimelineSummary { actualStartIso: string | null; expectedReleaseIso: string | null; actualReleaseIso: string | null; actualEndIso: string | null; isTerminalVersion: boolean; actualHours: number; overdueDays: 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 mergeStageProgressWithEffort( progress: Partial>, effortMetrics: Record, ): Record { return STAGES.reduce((acc, stage) => { const state = progress[stage.key]; acc[stage.key] = { percent: state?.percent ?? 0, status: state?.status ?? 'idle', ...effortMetrics[stage.key], }; return acc; }, {} as Record); } export function getVersionCardDefaultExpanded(status: VersionStatus): boolean { return status === 'developing'; } export function formatVersionOverviewDateTime(value?: string | null): string { if (!value) return '-'; return value.includes('T') ? formatDateTime(value) : value; } export function buildVersionTimelineSummary(input: { status: VersionStatus; startDate?: string | null; expectedReleaseDate?: string | null; releaseDate?: string | null; plans: VersionPlan[]; devTasks: DevTask[]; testCases: TestCase[]; bugs: Bug[]; now?: Date; }): VersionTimelineSummary { const now = input.now ?? new Date(); const startDates: string[] = []; input.plans.forEach((plan) => { if (plan.actualStartAt) { startDates.push(plan.actualStartAt); } else if (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= now) { startDates.push(plan.startTime); } }); input.devTasks.forEach((task) => { if (task.actualStartAt) startDates.push(task.actualStartAt); }); input.testCases.forEach((testCase) => { if (testCase.startedAt) startDates.push(testCase.startedAt); }); const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (input.startDate ?? null); const endDates: string[] = []; input.plans.forEach((plan) => { if (plan.completedAt) endDates.push(plan.completedAt); }); input.devTasks.forEach((task) => { if (task.actualEndAt) endDates.push(task.actualEndAt); }); input.testCases.forEach((testCase) => { if (testCase.completedAt) endDates.push(testCase.completedAt); }); input.bugs.forEach((bug) => { if (bug.closedAt) endDates.push(bug.closedAt); else if (bug.resolvedAt) endDates.push(bug.resolvedAt); else if ((bug.status === 'closed' || bug.status === 'rejected') && bug.updatedAt) endDates.push(bug.updatedAt); }); const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null; const isTerminalVersion = input.status === 'released' || input.status === 'closed'; const actualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString()); let overdueDays = 0; if (input.expectedReleaseDate && input.releaseDate) { const endDate = new Date(input.releaseDate); const deadlineDate = new Date(input.expectedReleaseDate); endDate.setHours(0, 0, 0, 0); deadlineDate.setHours(0, 0, 0, 0); overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24)); } return { actualStartIso, expectedReleaseIso: input.expectedReleaseDate ?? null, actualReleaseIso: input.releaseDate ?? null, actualEndIso, isTerminalVersion, actualHours, overdueDays, }; } 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), ); }