From 13e720c2c3efea48f513578d8b34449438708a44 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 13:51:58 +0800 Subject: [PATCH] refactor: add weighted mileage completion metric --- src/modules/mileage/StatisticsView.tsx | 9 +++++++- src/shared/analytics/metrics.test.ts | 30 ++++++++++++++++++++++++++ src/shared/analytics/metrics.ts | 18 ++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/shared/analytics/metrics.test.ts create mode 100644 src/shared/analytics/metrics.ts diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index cbd3b27..fd8fa9c 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -12,6 +12,7 @@ import { import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types'; import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api'; import Blur from '../../components/Blur'; +import { weightedCompletionRate } from '../../shared/analytics/metrics'; function getDefaultDate(): string { const now = new Date(); @@ -83,6 +84,12 @@ export default function StatisticsView() { const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0; const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0); const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0); + const selectedYearCompletionRate = weightedCompletionRate( + targets.flatMap(target => { + const assessment = getTargetAssessment(target, assessmentYearMap[target.id]); + return assessment ? [{ completed: assessment.completed, target: assessment.target }] : []; + }), + ); const latestTrend = trendData[trendData.length - 1]; const previousTrend = trendData[trendData.length - 2]; const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0; @@ -541,7 +548,7 @@ export default function StatisticsView() { | 车辆 {targets.reduce((sum, t) => sum + t.vehicleCount, 0)} | - 所选年度完成率 {targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'} % + 所选年度完成率 {selectedYearCompletionRate.toFixed(1)} %
diff --git a/src/shared/analytics/metrics.test.ts b/src/shared/analytics/metrics.test.ts new file mode 100644 index 0000000..dd6ab58 --- /dev/null +++ b/src/shared/analytics/metrics.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { weightedCompletionRate } from './metrics.js'; + +test('weights completion by target mileage', () => { + const result = weightedCompletionRate([ + { completed: 100, target: 100 }, + { completed: 0, target: 900 }, + ]); + + assert.equal(result, 10); +}); + +test('allows completion above 100 percent', () => { + const result = weightedCompletionRate([ + { completed: 120, target: 100 }, + ]); + + assert.equal(result, 120); +}); + +test('ignores invalid and zero targets', () => { + const result = weightedCompletionRate([ + { completed: 50, target: 0 }, + { completed: Number.NaN, target: 100 }, + { completed: 30, target: 60 }, + ]); + + assert.equal(result, 50); +}); diff --git a/src/shared/analytics/metrics.ts b/src/shared/analytics/metrics.ts new file mode 100644 index 0000000..230af0d --- /dev/null +++ b/src/shared/analytics/metrics.ts @@ -0,0 +1,18 @@ +export interface ProgressMeasure { + completed: number; + target: number; +} + +export function weightedCompletionRate(measures: ProgressMeasure[]): number { + let completed = 0; + let target = 0; + + for (const measure of measures) { + if (!Number.isFinite(measure.target) || measure.target <= 0) continue; + if (!Number.isFinite(measure.completed)) continue; + completed += Math.max(0, measure.completed); + target += measure.target; + } + + return target > 0 ? completed / target * 100 : 0; +}