From dde7a9a623ba1307b8000f2680aa8d63bd7b9238 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 26 Jun 2026 13:06:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(version):=20=E4=BF=AE=E6=AD=A3=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E8=80=97=E6=97=B6=E4=B8=8E=E6=8E=92=E5=90=8D=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/versions/[id]/page.tsx | 598 ++++++++---------- apps/web/components/version/CapsuleStages.tsx | 64 +- apps/web/lib/bug.ts | 6 +- apps/web/lib/dev-task.ts | 8 +- apps/web/lib/test-case.ts | 10 +- apps/web/lib/version-overview.test.ts | 262 ++++++++ apps/web/lib/version-overview.ts | 230 +++++++ apps/web/lib/work-hours.test.ts | 16 + apps/web/lib/work-hours.ts | 30 + 9 files changed, 857 insertions(+), 367 deletions(-) create mode 100644 apps/web/lib/version-overview.test.ts create mode 100644 apps/web/lib/version-overview.ts create mode 100644 apps/web/lib/work-hours.test.ts diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 0647c99..d188c15 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -2,18 +2,15 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout, Link2, Settings, Plus, X } from 'lucide-react'; +import { Calendar, ChevronLeft, Clock, FileText, Link2, Plus, Settings, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { getVersionDetail } from '@/lib/derive'; -import { STAGES, Role } from '@/lib/stage'; -import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus, calcVersionExecutionStatus, EXECUTION_STATUS_LABEL, EXECUTION_STATUS_COLOR } from '@/lib/version-status'; +import type { Role } from '@/lib/stage'; import { CapsuleStages } from '@/components/version/CapsuleStages'; -import { MemberChips } from '@/components/version/MemberChips'; -import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend'; -import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health'; -import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement'; +import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health'; +import { CHANGE_REASON_LABEL } from '@/lib/requirement'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime'; import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab'; import { PlanTab } from '@/components/version/PlanTab'; @@ -26,22 +23,24 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; -import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task'; +import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; import { hasPermission } from '@/lib/permissions'; -import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; -import { testCaseIntervals } from '@/lib/test-case'; -import { bugIntervals } from '@/lib/bug'; -import { planIntervals } from '@/lib/version-plan'; +import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; import { formatDateTime } from '@/lib/format'; import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; +import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; -const PRIORITY_STYLE: Record = { - P0: 'bg-red-500/10 text-red-600', - P1: 'bg-orange-500/10 text-orange-600', - P2: 'bg-blue-500/10 text-blue-600', - P3: 'bg-zinc-100 text-zinc-600', - P4: 'bg-zinc-100 text-zinc-500', -}; +function formatOverviewDateTime(value?: string | null): string { + if (!value) return '-'; + return value.includes('T') ? formatDateTime(value) : value; +} + +const BUG_SEVERITY_SEGMENTS = [ + { key: 'critical', label: '致命', color: 'bg-red-500' }, + { key: 'major', label: '严重', color: 'bg-orange-500' }, + { key: 'minor', label: '一般', color: 'bg-amber-400' }, + { key: 'trivial', label: '轻微', color: 'bg-zinc-400' }, +] as const; const TABS = [ { key: 'overview', label: '概览', permission: null as string | null }, @@ -111,32 +110,6 @@ export default function VersionDetailPage() { } }, [plans, version, versionId, updateVersion]); - const elapsedDays = useMemo(() => { - if (!version) return 0; - // 从所有阶段中取最早的实际开始时间 - const vPlans = plans.filter((p) => p.versionId === version.id); - const vReqs = requirements.filter((r) => r.versionId === version.id); - const vReqIds = new Set(vReqs.map((r) => r.id)); - const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId)); - const vTCs = testCases.filter((c) => c.versionId === version.id); - - const startDates: string[] = []; - vPlans.forEach((p) => { - if (p.actualStartAt) startDates.push(p.actualStartAt); - else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); - }); - vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); - vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); - - if (startDates.length === 0 && !version.startDate) return 0; - const earliest = startDates.length > 0 ? startDates.sort()[0] : version.startDate!; - const start = new Date(earliest); - start.setHours(0, 0, 0, 0); - const now = new Date(); - now.setHours(0, 0, 0, 0); - return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))); - }, [version, plans, requirements, devTasks, testCases]); - if (!version) { return (
@@ -240,9 +213,38 @@ export default function VersionDetailPage() {
{activeTab === 'overview' ? ( (() => { + const now = new Date(); const versionReqs = requirements.filter((r) => r.versionId === version.id); + const versionReqIds = new Set(versionReqs.map((r) => r.id)); + const versionPlans = plans.filter((p) => p.versionId === version.id); + const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId)); + const versionTCs = testCases.filter((c) => c.versionId === version.id); + const versionBugs = bugs.filter((b) => b.versionId === version.id); const versionOT = records.filter((r) => r.versionId === version.id); - const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10; + const stageEffortMetrics = calcStageEffortMetrics({ + plans: versionPlans, + devTasks: versionDevTasks, + testCases: versionTCs, + bugs: versionBugs, + now, + }); + const effortTotals = calcVersionOverviewEffortTotals({ + plans: versionPlans, + devTasks: versionDevTasks, + testCases: versionTCs, + bugs: versionBugs, + overtimeRecords: versionOT, + now, + }); + const personalRanking = calcPersonalEffortRanking({ + plans: versionPlans, + devTasks: versionDevTasks, + testCases: versionTCs, + bugs: versionBugs, + overtimeRecords: versionOT, + now, + }); + const bugRanking = calcBugSeverityRanking(versionBugs); // 人员加班排名 const personOT: Record = {}; @@ -255,23 +257,6 @@ export default function VersionDetailPage() { const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]); const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1; - // DevTask 真实统计 - const versionDevTasks = devTasks.filter((t) => versionReqs.some((r) => r.id === t.requirementId)); - const devTaskTodo = versionDevTasks.filter((t) => t.status === 'todo').length; - const devTaskInProgress = versionDevTasks.filter((t) => t.status === 'in_progress' || t.status === 'testing').length; - const devTaskBlocked = versionDevTasks.filter((t) => t.isBlocked).length; - const versionBugs = bugs.filter((b) => b.versionId === version.id); - const bugOpenCount = versionBugs.filter((b) => b.status === 'open' || b.status === 'fixing').length; - - // 版本执行态推导 - const versionTCs = testCases.filter((c) => c.versionId === version.id); - const executionStatus = calcVersionExecutionStatus({ - manualStatus: version.status, - devTasks: versionDevTasks, - testCases: versionTCs, - bugs: versionBugs, - }); - return (
{/* 1. 风险详情 - 最上方 */} @@ -297,33 +282,13 @@ export default function VersionDetailPage() {
)} - {/* 2. 统计卡片 */} + {/* 2. 阶段概览 */} {(() => { - const vPlans = plans.filter((p) => p.versionId === version.id); - const pendingResearch = vPlans.filter((p) => p.type === 'research' && p.status !== 'completed').length; - const pendingProduct = vPlans.filter((p) => p.type === 'product' && p.status !== 'completed').length; - return ( -
- - r.reqType === 'change').length} warn={versionReqs.filter((r) => r.reqType === 'change').length > 0} /> - 0} /> - 0} /> - - - 0} /> - -
- ); - })()} + const researchPlans = versionPlans.filter((p) => p.type === 'research'); + const productPlans = versionPlans.filter((p) => p.type === 'product'); + const uiPlans = versionPlans.filter((p) => p.type === 'ui'); - {/* 3. 状态胶囊 */} - {(() => { - const vPlans = plans.filter((p) => p.versionId === version.id); - const researchPlans = vPlans.filter((p) => p.type === 'research'); - const productPlans = vPlans.filter((p) => p.type === 'product'); - const uiPlans = vPlans.filter((p) => p.type === 'ui'); - - const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => { + const calcGroupProgress = (group: typeof versionPlans, type: 'research' | 'product' | 'ui') => { if (group.length === 0) return 0; // 分子=已完成子任务数,分母=所有plan的子任务总数 // completed 的 plan:其子任务全算已完成 @@ -354,120 +319,132 @@ export default function VersionDetailPage() { return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0; }; - const getPlanStatus = (group: typeof vPlans): 'idle' | 'active' | 'done' => { + const getPlanStatus = (group: typeof versionPlans): 'idle' | 'active' | 'done' => { if (group.length === 0) return 'idle'; if (group.every((p) => p.status === 'completed')) return 'done'; if (group.some((p) => p.status === 'in_progress')) return 'active'; return 'idle'; }; - type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done' }; + type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done'; actualHours?: number; aiEstimateHours?: number }; const stageProgress: Partial> = {}; - if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), status: getPlanStatus(researchPlans) }; - if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), status: getPlanStatus(productPlans) }; - if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), status: getPlanStatus(uiPlans) }; + stageProgress['requirement'] = { + percent: calcGroupProgress(researchPlans, 'research'), + status: getPlanStatus(researchPlans), + ...stageEffortMetrics.requirement, + }; + stageProgress['product_design'] = { + percent: calcGroupProgress(productPlans, 'product'), + status: getPlanStatus(productPlans), + ...stageEffortMetrics.product_design, + }; + stageProgress['ui_design'] = { + percent: calcGroupProgress(uiPlans, 'ui'), + status: getPlanStatus(uiPlans), + ...stageEffortMetrics.ui_design, + }; // 开发阶段 - if (versionDevTasks.length > 0) { - const devProgress = calcDevTaskProgress(versionDevTasks); - const allSubmitted = versionDevTasks.every((t) => t.status === 'submitted'); - const hasActive = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing'); - stageProgress['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' }; - } + const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted'); + const hasActiveDevTask = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing'); + stageProgress['dev'] = { + percent: versionDevTasks.length > 0 ? calcDevTaskProgress(versionDevTasks) : 0, + status: versionDevTasks.length === 0 ? 'idle' : allSubmitted ? 'done' : hasActiveDevTask ? 'active' : 'idle', + ...stageEffortMetrics.dev, + }; // 测试阶段 - if (versionTCs.length > 0) { - const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length; - const testPercent = Math.round((executed / versionTCs.length) * 100); - const allPassed = versionTCs.every((c) => c.status === 'passed'); - const hasRunning = versionTCs.some((c) => c.status === 'running'); - stageProgress['testing'] = { percent: testPercent, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' }; - } + const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length; + const testPercent = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0; + const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed'); + const hasRunning = versionTCs.some((c) => c.status === 'running'); + stageProgress['testing'] = { + percent: testPercent, + status: versionTCs.length === 0 ? 'idle' : allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle', + ...stageEffortMetrics.testing, + }; // BUG 阶段:已关闭Bug / 总Bug - if (versionBugs.length > 0) { - const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length; - const bugPercent = Math.round((closedBugs / versionBugs.length) * 100); - const allClosed = versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected'); - stageProgress['bug'] = { percent: bugPercent, status: allClosed ? 'done' : closedBugs > 0 || versionBugs.length > 0 ? 'active' : 'idle' }; - } + const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length; + const bugPercent = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0; + const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected'); + stageProgress['bug'] = { + percent: bugPercent, + status: versionBugs.length === 0 ? 'idle' : allClosed ? 'done' : 'active', + ...stageEffortMetrics.bug, + }; return ; })()} - {/* 4. 项目总耗时 + 参与人员 + 相关链接 */} + {/* 3. 时间与投入概览 + 参与人员 + 相关链接 */}
-
- {(() => { - // 实际开始日期 - const vPlansAll = plans.filter((p) => p.versionId === version.id); - const vReqsAll = requirements.filter((r) => r.versionId === version.id); - const vReqIdsAll = new Set(vReqsAll.map((r) => r.id)); - const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId)); - const vTCsAll = testCases.filter((c) => c.versionId === version.id); - const vBugsAll = bugs.filter((b) => b.versionId === version.id); - + {(() => { const startDates: string[] = []; - vPlansAll.forEach((p) => { + versionPlans.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); - else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); + else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= now) startDates.push(p.startTime); }); - vDTs.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); - vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); - const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null); + versionDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); + versionTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); + const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (version.startDate ?? null); - // 实际截止日期 const endDates: string[] = []; - vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); - vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); - vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); - vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); - const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null; + versionPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); + versionDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); + versionTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); + versionBugs.forEach((b) => { + if (b.closedAt) endDates.push(b.closedAt); + else if (b.resolvedAt) endDates.push(b.resolvedAt); + else if ((b.status === 'closed' || b.status === 'rejected') && b.updatedAt) endDates.push(b.updatedAt); + }); + const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null; - // 逾期天数 + const isTerminalVersion = version.status === 'released' || version.status === 'closed'; const deadline = version.expectedReleaseDate; + const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString()); let overdueDays = 0; - if (deadline && actualEnd) { - overdueDays = Math.floor((new Date(actualEnd.replace(' ', 'T')).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24)); + if (deadline && isTerminalVersion && actualEndIso) { + const endDate = new Date(actualEndIso); + const deadlineDate = new Date(deadline); + endDate.setHours(0, 0, 0, 0); + deadlineDate.setHours(0, 0, 0, 0); + overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24)); } - // 总耗时 = 日历天数 + 加班时长 - const versionOTForTime = records.filter((r) => r.versionId === version.id); - const otHoursTotal = Math.round(versionOTForTime.reduce((sum, r) => sum + r.duration, 0) * 10) / 10; + const metrics = [ + { label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: }, + { label: '预计截止', value: deadline ?? '未设置' }, + { label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' }, + { label: '实际耗时', value: formatActualDuration(versionActualHours), icon: }, + { label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const }, + { label: '加班时长', value: formatActualDuration(effortTotals.overtimeHours), sub: '来自加班记录', tone: 'orange' as const }, + ]; return ( -
-
- - 开始 - {actualStart ?? '未开始'} +
+
+ {metrics.map((item) => ( + + ))}
-
- 预计截止 - {deadline ?? '未设置'} -
- {actualEnd && ( -
- 实际截止 - {actualEnd} + {overdueDays > 0 && ( +
+ 实际截止晚于预计截止 {overdueDays} 天
)} - {overdueDays > 0 && ( - 逾期 {overdueDays} 天 - )} -
- - 已耗时 {elapsedDays} 天 - {otHoursTotal > 0 && ( - (含加班 {otHoursTotal}h - )} -
); })()} -
-
+
参与人员
@@ -494,7 +471,7 @@ export default function VersionDetailPage() {
相关链接
{(() => { - const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl); + const vPlansCompleted = versionPlans.filter((p) => p.status === 'completed' && p.resultUrl); const typeLabel: Record = { research: '调研', product: '产品方案', ui: 'UI设计' }; const grouped = ['research', 'product', 'ui'].map((type) => ({ type, @@ -682,170 +659,116 @@ export default function VersionDetailPage() { ); })()} - {/* 阶段耗时 + 个人耗时排名 */} - {(() => { - const versionPlans = plans.filter((p) => p.versionId === version.id); +
+ {/* 个人耗时排名 */} + {(() => { + const maxHours = personalRanking[0]?.total || 1; - // 各阶段日历天数计算辅助函数 - const calcCalendar = (starts: string[], ends: string[]) => { - const sortedStarts = starts.filter(Boolean).sort(); - const sortedEnds = ends.filter(Boolean).sort().reverse(); - const start = sortedStarts[0] || null; - const end = sortedEnds[0] || null; - const days = start && end ? Math.max(1, Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0; - return { start, end, days }; - }; - - // 调研阶段 - const researchPlans = versionPlans.filter((p) => p.type === 'research'); - const researchCal = calcCalendar( - researchPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!), - researchPlans.filter((p) => p.completedAt).map((p) => p.completedAt!), - ); - - // 产品方案阶段 - const productPlans = versionPlans.filter((p) => p.type === 'product'); - const productCal = calcCalendar( - productPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!), - productPlans.filter((p) => p.completedAt).map((p) => p.completedAt!), - ); - - // UI设计阶段 - const uiPlans = versionPlans.filter((p) => p.type === 'ui'); - const uiCal = calcCalendar( - uiPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!), - uiPlans.filter((p) => p.completedAt).map((p) => p.completedAt!), - ); - - // 开发阶段 - const devCal = calcCalendar( - versionDevTasks.filter((t) => t.actualStartAt).map((t) => t.actualStartAt!), - versionDevTasks.filter((t) => t.actualEndAt).map((t) => t.actualEndAt!), - ); - - // 测试阶段 - const tcCal = calcCalendar( - versionTCs.filter((c) => c.startedAt).map((c) => c.startedAt!), - versionTCs.filter((c) => c.completedAt).map((c) => c.completedAt!), - ); - - const stages = [ - { label: '调研', ...researchCal, color: 'bg-orange-400' }, - { label: '产品方案', ...productCal, color: 'bg-pink-400' }, - { label: 'UI设计', ...uiCal, color: 'bg-indigo-400' }, - { label: '开发', ...devCal, color: 'bg-blue-400' }, - { label: '测试', ...tcCal, color: 'bg-purple-400' }, - ]; - const maxDays = Math.max(...stages.map((s) => s.days), 1); - - // 个人维度:每人耗时汇总 - const personalHours = new Map(); - const addHours = (name: string, key: 'research' | 'product' | 'ui' | 'dev' | 'test', hours: number) => { - const prev = personalHours.get(name) || { research: 0, product: 0, ui: 0, dev: 0, test: 0 }; - prev[key] += hours; - personalHours.set(name, prev); - }; - - // 调研/产品/UI 用 startTime→endTime 计算 - versionPlans.forEach((p) => { - if (!p.owner || !p.actualStartAt) return; - const hours = calcWorkHours(p.actualStartAt, p.completedAt ?? new Date().toISOString()); - addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours); - }); - versionDevTasks.forEach((t) => { - if (!t.actualStartAt || !t.assigneeId) return; - const endIso = t.actualEndAt ?? new Date().toISOString(); - addHours(t.assigneeId, 'dev', calcWorkHours(t.actualStartAt, endIso)); - }); - versionTCs.forEach((c) => { - if (!c.startedAt || !c.assigneeId) return; - addHours(c.assigneeId, 'test', calcWorkHours(c.startedAt, c.completedAt ?? new Date().toISOString())); - }); - - const personalRanking = Array.from(personalHours.entries()) - .map(([name, h]) => ({ name, ...h, total: h.research + h.product + h.ui + h.dev + h.test })) - .sort((a, b) => b.total - a.total); - const maxHours = personalRanking[0]?.total || 1; - const totalHours = personalRanking.reduce((s, p) => s + p.total, 0); - - // 双口径:日历总耗时(合并区间)+ 人力总投入(即 totalHours) - const allIntervals = [ - ...planIntervals(versionPlans), - ...devTaskIntervals(versionDevTasks), - ...testCaseIntervals(versionTCs), - ...bugIntervals(versionBugs), - ]; - const { calendarHours: versionCalendarHours } = calcTwoMetrics(allIntervals); - - return ( -
- {/* 阶段日历耗时 */} + return (
-
阶段耗时(项目维度)
-
- {stages.map((s) => ( -
-
- {s.label} - {s.days > 0 ? `${s.days}天` : '-'} -
-
-
-
-
- {s.start && {s.start.slice(5)}{s.end ? ` → ${s.end.slice(5)}` : ' →'}} -
-
- ))} -
-
- 日历总耗时 - {formatWorkHours(versionCalendarHours)} -
-
- 人力总投入 - {formatWorkHours(totalHours)} -
+
+
个人耗时排名
+
+ 实际 + 加班
-
- - {/* 个人耗时排名 */} -
-
个人耗时排名
{personalRanking.length === 0 ? ( 暂无数据 ) : ( -
- {personalRanking.slice(0, 8).map((item, i) => ( -
-
- {i + 1} - {item.name} - {item.total}h +
+ {personalRanking.slice(0, 8).map((item, i) => { + const totalWidth = (item.total / maxHours) * 100; + const actualWidth = item.total > 0 ? (item.actualHours / item.total) * 100 : 0; + const overtimeWidth = item.total > 0 ? (item.overtimeHours / item.total) * 100 : 0; + + return ( +
+
+ {i + 1} + {item.name} + {formatActualDuration(item.total)} +
+
+
+ {item.actualHours > 0 &&
} + {item.overtimeHours > 0 &&
} +
+
+
+ 实际 {formatActualDuration(item.actualHours)} + 加班 {formatActualDuration(item.overtimeHours)} +
-
- {item.research > 0 &&
} - {item.product > 0 &&
} - {item.ui > 0 &&
} - {item.dev > 0 &&
} - {item.test > 0 &&
} -
-
- ))} -
- 调研 - 产品 - UI - 开发 - 测试 -
+ ); + })}
)}
-
- ); - })()} + ); + })()} + + {/* Bug 数排名 */} + {(() => { + const maxBugCount = bugRanking[0]?.total || 1; + + return ( +
+
+
Bug 数排名
+
+ {BUG_SEVERITY_SEGMENTS.map((segment) => ( + + + {segment.label} + + ))} +
+
+ {bugRanking.length === 0 ? ( + 暂无数据 + ) : ( +
+ {bugRanking.slice(0, 8).map((item, i) => { + const totalWidth = (item.total / maxBugCount) * 100; + + return ( +
+
+ {i + 1} + {item.assigneeId} + {item.total} 个 +
+
+
+ {BUG_SEVERITY_SEGMENTS.map((segment) => { + const count = item[segment.key]; + if (count <= 0) return null; + return ( +
+ ); + })} +
+
+
+ {BUG_SEVERITY_SEGMENTS.map((segment) => ( + {segment.label} {item[segment.key]} + ))} +
+
+ ); + })} +
+ )} +
+ ); + })()} +
{/* 健康趋势 - 暂时不显示 */} {/*
@@ -968,27 +891,24 @@ export default function VersionDetailPage() { ); } -function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) { - const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]'; +function OverviewMetric({ label, value, icon, sub, tone }: { + label: string; + value: string | number; + icon?: JSX.Element; + sub?: string; + tone?: 'accent' | 'orange'; +}) { + const valueColor = tone === 'orange' ? 'text-orange-600' : tone === 'accent' ? 'text-[var(--accent)]' : 'text-[var(--ink)]'; return ( -
-
{value}
-
{label}
-
- ); -} - -function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) { - return ( -
- {icon} - {url ? ( - - {label} - - ) : ( - {label} · 未设置 - )} +
+
+ {icon && {icon}} + {label} +
+
+ {value} +
+ {sub &&
{sub}
}
); } diff --git a/apps/web/components/version/CapsuleStages.tsx b/apps/web/components/version/CapsuleStages.tsx index 7633006..302430c 100644 --- a/apps/web/components/version/CapsuleStages.tsx +++ b/apps/web/components/version/CapsuleStages.tsx @@ -1,43 +1,60 @@ import type { Stage } from '@/lib/stage'; import { STAGES } from '@/lib/stage'; +import { formatActualDuration } from '@/lib/work-hours'; export interface StageProgressItem { percent: number; status: 'idle' | 'active' | 'done'; + actualHours?: number; + estimateHours?: number; + aiEstimateHours?: number; + showEstimates?: boolean; } export function CapsuleStages({ stageProgress }: { stageProgress?: Partial>; }) { return ( -
- {STAGES.map((stage, idx) => { +
+ {STAGES.map((stage) => { const info = stageProgress?.[stage.key]; const status = info?.status || 'idle'; const percent = info?.percent ?? 0; + const actualHours = info?.actualHours ?? 0; + const stageLabel = stage.key === 'dev' ? '开发任务' : stage.label.replace(' ', ''); + const statusLabel = status === 'done' ? '已完成' : status === 'active' ? '进行中' : '未开始'; + const tone = + status === 'done' + ? 'border-emerald-200 bg-emerald-50/40' + : status === 'active' + ? 'border-blue-200 bg-blue-50/40' + : 'border-[var(--line)] bg-[var(--bg-card)]'; + const progressClass = status === 'done' ? 'bg-emerald-500' : status === 'active' ? 'bg-blue-500' : 'bg-zinc-300'; return (
-
- - {stage.label} +
+
+
{stageLabel}
+
{statusLabel}
+
+ + {percent}% - {status !== 'idle' && ( - - {percent}% - - )}
-
- {status === 'done' &&
} - {status === 'active' && ( -
-
-
- )} +
+
+
+
+
+ 实际耗时 + {formatActualDuration(actualHours)} +
+ {info?.showEstimates && } + {info?.showEstimates && }
); @@ -45,3 +62,14 @@ export function CapsuleStages({ stageProgress }: {
); } + +function EstimateRow({ label, hours, accent }: { label: string; hours?: number; accent?: boolean }) { + return ( +
+ {label} + + {hours !== undefined ? formatActualDuration(hours) : '-'} + +
+ ); +} diff --git a/apps/web/lib/bug.ts b/apps/web/lib/bug.ts index b663a0e..d1fddee 100644 --- a/apps/web/lib/bug.ts +++ b/apps/web/lib/bug.ts @@ -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 { diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts index 604b8e6..9f3dfe2 100644 --- a/apps/web/lib/dev-task.ts +++ b/apps/web/lib/dev-task.ts @@ -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; } diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts index 6a93d46..4fedd1d 100644 --- a/apps/web/lib/test-case.ts +++ b/apps/web/lib/test-case.ts @@ -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; } diff --git a/apps/web/lib/version-overview.test.ts b/apps/web/lib/version-overview.test.ts new file mode 100644 index 0000000..75ce65d --- /dev/null +++ b/apps/web/lib/version-overview.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }, + ]); +}); diff --git a/apps/web/lib/version-overview.ts b/apps/web/lib/version-overview.ts new file mode 100644 index 0000000..4009f3a --- /dev/null +++ b/apps/web/lib/version-overview.ts @@ -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(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), + ); +} diff --git a/apps/web/lib/work-hours.test.ts b/apps/web/lib/work-hours.test.ts new file mode 100644 index 0000000..5e69446 --- /dev/null +++ b/apps/web/lib/work-hours.test.ts @@ -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天)'); +}); diff --git a/apps/web/lib/work-hours.ts b/apps/web/lib/work-hours.ts index 4a34261..3891ef1 100644 --- a/apps/web/lib/work-hours.ts +++ b/apps/web/lib/work-hours.ts @@ -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天" */