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