fix(version): 修正概览耗时与排名统计
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { Priority } from './derive';
|
||||
import { calcWorkHours, type TimeInterval } from './work-hours';
|
||||
import { calcActualElapsedHours, type TimeInterval } from './work-hours';
|
||||
|
||||
export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
|
||||
export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
|
||||
@@ -32,6 +32,8 @@ export interface Bug {
|
||||
resolvedAt?: string;
|
||||
closedAt?: string;
|
||||
resolution?: string;
|
||||
estimateHours?: number;
|
||||
aiEstimateHours?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -93,7 +95,7 @@ export function getBugActualHours(bug: Bug, now: Date = new Date()): number {
|
||||
const start = bug.createdAt;
|
||||
if (!start) return 0;
|
||||
const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString());
|
||||
return calcWorkHours(start, end);
|
||||
return calcActualElapsedHours(start, end);
|
||||
}
|
||||
|
||||
export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Priority } from './derive';
|
||||
import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
|
||||
import { calcActualElapsedHours, calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
|
||||
|
||||
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted';
|
||||
|
||||
@@ -114,8 +114,8 @@ function roundEffortHours(hours: number): number {
|
||||
|
||||
export function getActualHours(task: DevTask, now: Date = new Date()): number {
|
||||
if (!task.actualStartAt) return 0;
|
||||
const end = task.actualEndAt ?? now.toISOString();
|
||||
return calcWorkHours(task.actualStartAt, end);
|
||||
const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());
|
||||
return calcActualElapsedHours(task.actualStartAt, end);
|
||||
}
|
||||
|
||||
export interface AggregatedHours {
|
||||
@@ -167,7 +167,7 @@ export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): Time
|
||||
const nowIso = now.toISOString();
|
||||
for (const t of tasks) {
|
||||
if (!t.actualStartAt) continue;
|
||||
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso });
|
||||
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? (t.status === 'submitted' ? t.updatedAt : nowIso) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Priority } from './derive';
|
||||
import type { Reference } from './dev-task';
|
||||
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
|
||||
import { calcWorkHours, type TimeInterval } from './work-hours';
|
||||
import { calcActualElapsedHours, type TimeInterval } from './work-hours';
|
||||
import { aggregateWorkEffort } from './work-effort-engine';
|
||||
|
||||
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
|
||||
@@ -138,8 +138,9 @@ export function getTestCaseEstimateHours(tc: TestCase): number {
|
||||
|
||||
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||||
if (!tc.startedAt) return 0;
|
||||
const end = tc.completedAt ?? now.toISOString();
|
||||
return calcWorkHours(tc.startedAt, end);
|
||||
const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked';
|
||||
const end = tc.completedAt ?? (isTerminal ? tc.updatedAt : now.toISOString());
|
||||
return calcActualElapsedHours(tc.startedAt, end);
|
||||
}
|
||||
|
||||
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
|
||||
@@ -166,7 +167,8 @@ export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): Ti
|
||||
const nowIso = now.toISOString();
|
||||
for (const c of cases) {
|
||||
if (!c.startedAt) continue;
|
||||
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso });
|
||||
const isTerminal = c.status === 'passed' || c.status === 'failed' || c.status === 'blocked';
|
||||
out.push({ start: c.startedAt, end: c.completedAt ?? (isTerminal ? c.updatedAt : nowIso) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
262
apps/web/lib/version-overview.test.ts
Normal file
262
apps/web/lib/version-overview.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
calcBugSeverityRanking,
|
||||
calcPersonalEffortRanking,
|
||||
calcStageEffortMetrics,
|
||||
calcVersionOverviewEffortTotals,
|
||||
} from './version-overview';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { Bug } from './bug';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
|
||||
function plan(patch: Partial<VersionPlan>): VersionPlan {
|
||||
return {
|
||||
id: patch.id || 'plan-1',
|
||||
versionId: patch.versionId || 'v-1',
|
||||
type: patch.type || 'research',
|
||||
title: patch.title || '计划',
|
||||
owner: patch.owner || 'Alice',
|
||||
startTime: patch.startTime || '2026-06-22T09:00:00',
|
||||
endTime: patch.endTime || '2026-06-22T18:00:00',
|
||||
status: patch.status || 'completed',
|
||||
actualStartAt: patch.actualStartAt,
|
||||
completedAt: patch.completedAt,
|
||||
createdAt: patch.createdAt || '2026-06-22T08:00:00',
|
||||
addedBy: patch.addedBy || 'Alice',
|
||||
};
|
||||
}
|
||||
|
||||
function devTask(patch: Partial<DevTask>): DevTask {
|
||||
return {
|
||||
id: patch.id || 'dev-1',
|
||||
taskNo: patch.taskNo || 'DEV-001',
|
||||
requirementId: patch.requirementId || 'req-1',
|
||||
title: patch.title || '开发任务',
|
||||
categoryId: patch.categoryId || 'cat-1',
|
||||
assigneeId: patch.assigneeId || 'Alice',
|
||||
priority: patch.priority || 'P2',
|
||||
expectedStartAt: patch.expectedStartAt || '2026-06-22T09:00:00',
|
||||
expectedEndAt: patch.expectedEndAt || '2026-06-22T18:00:00',
|
||||
estimateHours: patch.estimateHours,
|
||||
aiEstimateHours: patch.aiEstimateHours,
|
||||
actualStartAt: patch.actualStartAt,
|
||||
actualEndAt: patch.actualEndAt,
|
||||
status: patch.status || 'submitted',
|
||||
isBlocked: patch.isBlocked ?? false,
|
||||
createdBy: patch.createdBy || 'Alice',
|
||||
createdAt: patch.createdAt || '2026-06-22T08:00:00',
|
||||
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(patch: Partial<TestCase>): TestCase {
|
||||
return {
|
||||
id: patch.id || 'tc-1',
|
||||
caseNo: patch.caseNo || 'TC-001',
|
||||
versionId: patch.versionId || 'v-1',
|
||||
title: patch.title || '测试用例',
|
||||
categoryId: patch.categoryId || 'cat-1',
|
||||
priority: patch.priority || 'P2',
|
||||
assigneeId: patch.assigneeId || 'Bob',
|
||||
status: patch.status || 'passed',
|
||||
estimateHours: patch.estimateHours,
|
||||
aiEstimateHours: patch.aiEstimateHours,
|
||||
startedAt: patch.startedAt,
|
||||
completedAt: patch.completedAt,
|
||||
createdBy: patch.createdBy || 'Bob',
|
||||
createdAt: patch.createdAt || '2026-06-22T08:00:00',
|
||||
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
function bug(patch: Partial<Bug>): Bug {
|
||||
return {
|
||||
id: patch.id || 'bug-1',
|
||||
bugNo: patch.bugNo || 'BUG-001',
|
||||
versionId: patch.versionId || 'v-1',
|
||||
testCaseId: patch.testCaseId || 'tc-1',
|
||||
title: patch.title || 'Bug',
|
||||
description: patch.description || '描述',
|
||||
severity: patch.severity || 'major',
|
||||
priority: patch.priority || 'P1',
|
||||
reportedBy: patch.reportedBy || 'QA',
|
||||
assigneeId: patch.assigneeId || 'Bob',
|
||||
status: patch.status || 'closed',
|
||||
createdAt: patch.createdAt || '2026-06-22T10:00:00',
|
||||
updatedAt: patch.updatedAt || '2026-06-22T12:00:00',
|
||||
closedAt: patch.closedAt,
|
||||
resolvedAt: patch.resolvedAt,
|
||||
};
|
||||
}
|
||||
|
||||
test('calcStageEffortMetrics returns actual hours and AI estimates per stage', () => {
|
||||
const metrics = calcStageEffortMetrics({
|
||||
plans: [
|
||||
plan({ type: 'research', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' }),
|
||||
plan({ type: 'product', actualStartAt: '2026-06-23T09:00:00', completedAt: '2026-06-23T12:00:00' }),
|
||||
],
|
||||
devTasks: [
|
||||
devTask({
|
||||
actualStartAt: '2026-06-24T09:00:00',
|
||||
actualEndAt: '2026-06-24T14:00:00',
|
||||
estimateHours: 4.5,
|
||||
aiEstimateHours: 3.25,
|
||||
}),
|
||||
],
|
||||
testCases: [
|
||||
testCase({
|
||||
startedAt: '2026-06-25T09:00:00',
|
||||
completedAt: '2026-06-25T11:00:00',
|
||||
estimateHours: 2.25,
|
||||
aiEstimateHours: 1.5,
|
||||
}),
|
||||
],
|
||||
bugs: [
|
||||
bug({ createdAt: '2026-06-26T10:00:00', closedAt: '2026-06-26T10:10:00' }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(metrics.requirement.actualHours, 9);
|
||||
assert.equal(metrics.product_design.actualHours, 3);
|
||||
assert.equal(metrics.dev.actualHours, 5);
|
||||
assert.equal(metrics.dev.estimateHours, 4.5);
|
||||
assert.equal(metrics.dev.aiEstimateHours, 3.25);
|
||||
assert.equal(metrics.testing.actualHours, 2);
|
||||
assert.equal(metrics.testing.estimateHours, 2.25);
|
||||
assert.equal(metrics.testing.aiEstimateHours, 1.5);
|
||||
assert.equal(metrics.bug.actualHours, 0.5);
|
||||
});
|
||||
|
||||
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
|
||||
const metrics = calcStageEffortMetrics({
|
||||
plans: [],
|
||||
devTasks: [],
|
||||
testCases: [
|
||||
testCase({
|
||||
status: 'passed',
|
||||
startedAt: '2026-06-25T09:00:00',
|
||||
completedAt: undefined,
|
||||
updatedAt: '2026-06-25T09:20:00',
|
||||
}),
|
||||
],
|
||||
bugs: [],
|
||||
now: new Date('2026-06-26T18:00:00'),
|
||||
});
|
||||
|
||||
assert.equal(metrics.testing.actualHours, 0.5);
|
||||
});
|
||||
|
||||
test('calcVersionOverviewEffortTotals sums actual hours and overtime records separately', () => {
|
||||
const overtimeRecords: OvertimeRecord[] = [
|
||||
{
|
||||
id: 'ot-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'v-1',
|
||||
person: 'Alice',
|
||||
startTime: '2026-06-22T19:00:00',
|
||||
endTime: '2026-06-22T20:15:00',
|
||||
duration: 1.25,
|
||||
reasonId: 'reason-3',
|
||||
createdAt: '2026-06-22T20:20:00',
|
||||
},
|
||||
{
|
||||
id: 'ot-2',
|
||||
projectId: 'project-1',
|
||||
versionId: 'v-1',
|
||||
person: 'Bob',
|
||||
startTime: '2026-06-22T20:00:00',
|
||||
endTime: '2026-06-22T22:03:00',
|
||||
duration: 2.05,
|
||||
reasonId: 'reason-4',
|
||||
createdAt: '2026-06-22T22:10:00',
|
||||
},
|
||||
];
|
||||
|
||||
const totals = calcVersionOverviewEffortTotals({
|
||||
plans: [plan({ actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
|
||||
devTasks: [devTask({ actualStartAt: '2026-06-22T10:00:00', actualEndAt: '2026-06-22T12:00:00' })],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
overtimeRecords,
|
||||
});
|
||||
|
||||
assert.equal(totals.actualHours, 11);
|
||||
assert.equal(totals.overtimeHours, 3.3);
|
||||
});
|
||||
|
||||
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
|
||||
const overtimeRecords: OvertimeRecord[] = [
|
||||
{
|
||||
id: 'ot-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'v-1',
|
||||
person: 'Alice',
|
||||
startTime: '2026-06-23T19:00:00',
|
||||
endTime: '2026-06-23T20:30:00',
|
||||
duration: 1.5,
|
||||
reasonId: 'reason-3',
|
||||
createdAt: '2026-06-23T20:35:00',
|
||||
},
|
||||
{
|
||||
id: 'ot-2',
|
||||
projectId: 'project-1',
|
||||
versionId: 'v-1',
|
||||
person: 'Bob',
|
||||
startTime: '2026-06-25T19:00:00',
|
||||
endTime: '2026-06-25T21:00:00',
|
||||
duration: 2,
|
||||
reasonId: 'reason-4',
|
||||
createdAt: '2026-06-25T21:10:00',
|
||||
},
|
||||
];
|
||||
const ranking = calcPersonalEffortRanking({
|
||||
plans: [plan({ owner: 'Alice', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
|
||||
devTasks: [devTask({ assigneeId: 'Alice', actualStartAt: '2026-06-23T09:00:00', actualEndAt: '2026-06-23T11:00:00' })],
|
||||
testCases: [testCase({ assigneeId: 'Bob', startedAt: '2026-06-24T09:00:00', completedAt: '2026-06-24T11:00:00' })],
|
||||
bugs: [bug({ assigneeId: 'Bob', createdAt: '2026-06-25T10:00:00', closedAt: '2026-06-25T12:00:00' })],
|
||||
overtimeRecords,
|
||||
});
|
||||
|
||||
assert.equal(ranking[0].name, 'Alice');
|
||||
assert.equal(ranking[0].actualHours, 11);
|
||||
assert.equal(ranking[0].overtimeHours, 1.5);
|
||||
assert.equal(ranking[0].total, 12.5);
|
||||
assert.equal(ranking[1].name, 'Bob');
|
||||
assert.equal(ranking[1].actualHours, 4);
|
||||
assert.equal(ranking[1].overtimeHours, 2);
|
||||
assert.equal(ranking[1].total, 6);
|
||||
});
|
||||
|
||||
test('calcBugSeverityRanking groups bugs by assignee and severity', () => {
|
||||
const ranking = calcBugSeverityRanking([
|
||||
bug({ id: 'bug-1', assigneeId: 'Alice', severity: 'critical' }),
|
||||
bug({ id: 'bug-2', assigneeId: 'Alice', severity: 'major' }),
|
||||
bug({ id: 'bug-3', assigneeId: 'Alice', severity: 'minor' }),
|
||||
bug({ id: 'bug-4', assigneeId: 'Bob', severity: 'critical' }),
|
||||
bug({ id: 'bug-5', assigneeId: 'Bob', severity: 'trivial' }),
|
||||
bug({ id: 'bug-6', assigneeId: 'Alice', severity: 'trivial' }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(ranking, [
|
||||
{
|
||||
assigneeId: 'Alice',
|
||||
critical: 1,
|
||||
major: 1,
|
||||
minor: 1,
|
||||
trivial: 1,
|
||||
total: 4,
|
||||
},
|
||||
{
|
||||
assigneeId: 'Bob',
|
||||
critical: 1,
|
||||
major: 0,
|
||||
minor: 0,
|
||||
trivial: 1,
|
||||
total: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
230
apps/web/lib/version-overview.ts
Normal file
230
apps/web/lib/version-overview.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
16
apps/web/lib/work-hours.test.ts
Normal file
16
apps/web/lib/work-hours.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { calcActualElapsedHours, formatActualDuration } from './work-hours';
|
||||
|
||||
test('calcActualElapsedHours uses real elapsed time with a half-hour minimum', () => {
|
||||
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T09:10:00'), 0.5);
|
||||
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T10:15:00'), 1.5);
|
||||
assert.equal(calcActualElapsedHours('2026-06-22T10:00:00', '2026-06-22T09:00:00'), 0);
|
||||
});
|
||||
|
||||
test('formatActualDuration converts hours to natural days', () => {
|
||||
assert.equal(formatActualDuration(60.5), '60.5h(2.5天)');
|
||||
assert.equal(formatActualDuration(0.5), '0.5h(0.02天)');
|
||||
assert.equal(formatActualDuration(0), '0h(0天)');
|
||||
});
|
||||
@@ -94,6 +94,36 @@ export function formatWorkHoursShort(hours: number): string {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算真实经过时长,而不是工作时段内时长。
|
||||
*
|
||||
* 用于“实际耗时”口径:按开始/结束时间戳直接相减,精度 0.5h;
|
||||
* 只要有正向耗时,最低按 0.5h 计,避免 30 分钟内工作被显示为 0。
|
||||
*/
|
||||
export function calcActualElapsedHours(startISO?: string | null, endISO?: string | null): number {
|
||||
if (!startISO || !endISO) return 0;
|
||||
const start = new Date(startISO).getTime();
|
||||
const end = new Date(endISO).getTime();
|
||||
if (isNaN(start) || isNaN(end) || end <= start) return 0;
|
||||
const hours = (end - start) / MS_PER_HOUR;
|
||||
return Math.max(0.5, Math.round(hours * 2) / 2);
|
||||
}
|
||||
|
||||
function formatNaturalDays(hours: number): string {
|
||||
if (!isFinite(hours) || hours <= 0) return '0天';
|
||||
const days = hours / 24;
|
||||
if (days < 0.1) return `${Number(days.toFixed(2))}天`;
|
||||
return `${Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '')}天`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际耗时显示,天数按自然日 24h 换算。
|
||||
*/
|
||||
export function formatActualDuration(hours: number): string {
|
||||
if (!isFinite(hours) || hours <= 0) return '0h(0天)';
|
||||
return `${hours}h(${formatNaturalDays(hours)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅天数版:"Y天"
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user