From 168c748835adb98dc5529e6f83553c9301c9436f Mon Sep 17 00:00:00 2001 From: Script Generator Date: Thu, 25 Jun 2026 13:59:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E6=97=B6):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E5=B7=A5=E6=97=B6=E6=B1=87=E6=80=BB=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/work-effort-engine.test.ts | 31 ++++++++++++++++++ apps/web/lib/work-effort-engine.ts | 42 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 apps/web/lib/work-effort-engine.test.ts create mode 100644 apps/web/lib/work-effort-engine.ts diff --git a/apps/web/lib/work-effort-engine.test.ts b/apps/web/lib/work-effort-engine.test.ts new file mode 100644 index 0000000..8d6c4a1 --- /dev/null +++ b/apps/web/lib/work-effort-engine.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine'; + +test('roundHalfHour rounds to nearest half hour', () => { + assert.equal(roundHalfHour(0.24), 0); + assert.equal(roundHalfHour(0.25), 0.5); + assert.equal(roundHalfHour(1.24), 1); + assert.equal(roundHalfHour(1.25), 1.5); +}); + +test('aggregateWorkEffort calculates weighted progress by estimate', () => { + const result = aggregateWorkEffort([ + { estimateHours: 1, actualHours: 0, progress: 0 }, + { estimateHours: 3, actualHours: 1.25, progress: 100 }, + ]); + + assert.equal(result.estimateHours, 4); + assert.equal(result.actualHours, 1.5); + assert.equal(result.progress, 75); +}); + +test('aggregateWorkEffort falls back to item average when estimates are zero', () => { + const result = aggregateWorkEffort([ + { estimateHours: 0, actualHours: 0, progress: 50 }, + { estimateHours: 0, actualHours: 0, progress: 100 }, + ]); + + assert.equal(result.progress, 75); +}); diff --git a/apps/web/lib/work-effort-engine.ts b/apps/web/lib/work-effort-engine.ts new file mode 100644 index 0000000..08e1e24 --- /dev/null +++ b/apps/web/lib/work-effort-engine.ts @@ -0,0 +1,42 @@ +export interface WorkEffortItem { + estimateHours: number; + actualHours: number; + progress: number; +} + +export interface WorkEffortSummary { + estimateHours: number; + actualHours: number; + progress: number; +} + +export function roundHalfHour(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 0; + return Math.round(hours * 2) / 2; +} + +export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary { + if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 }; + + const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0)); + const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0)); + + if (estimateHours <= 0) { + return { + estimateHours, + actualHours, + progress: Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length), + }; + } + + const weightedProgress = items.reduce((sum, item) => { + const estimate = Math.max(0, item.estimateHours || 0); + return sum + estimate * item.progress; + }, 0); + + return { + estimateHours, + actualHours, + progress: Math.round(weightedProgress / estimateHours), + }; +}