mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(insights): trend on "all" filter computes meaningful delta (split-half comparison)
Marc reported clicking "All" on a chart's range buttons displayed "+0.0 kg/Woche" even when his weight clearly drifted across years. Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays * 7` is mathematically correct but the per-week rate becomes vanishingly small for multi-year windows (~0.07 kg/week for 10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)` formatter rounds it to "0.0". Fix: extract a pure `computeWindowTrend()` helper at `src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we additionally surface a split-half delta — the mean of the second half of the visible series minus the mean of the first half. That number cannot round to zero unless the metric truly didn't move, and it's exactly what Marc wants to see when he asks "what changed across all my data?". For short windows the per-week rate stays the only display because it's still the most actionable metric there. The chart trend line surfaces both: "WEIGHT: +0.1 kg/week (0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a multi-year account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
93e712dc60
commit
af77e5ecf8
3 files changed
+244
-5
No files matched your search
@@ -26,6 +26,10 @@ import {
|
||||
pickBucket,
|
||||
type ChartBucketType,
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import {
|
||||
computeWindowTrend,
|
||||
SPLIT_HALF_THRESHOLD_DAYS,
|
||||
} from "@/lib/analytics/window-trend";
|
||||
|
||||
const TIME_RANGES_KEYS = [
|
||||
{
|
||||
@@ -575,23 +579,64 @@ export function HealthChart({
|
||||
const days = (last.timestamp - first.timestamp) / 86400000;
|
||||
if (!Number.isFinite(days) || days <= 0) return null;
|
||||
|
||||
const firstTrend = first[`${type}_trend`] as number;
|
||||
const lastTrend = last[`${type}_trend`] as number;
|
||||
const weeklyDelta = ((lastTrend - firstTrend) / days) * 7;
|
||||
const base = first[type] as number;
|
||||
const rawValues = series.map((point) => point[type] as number);
|
||||
const trendValues = series.map(
|
||||
(point) => point[`${type}_trend`] as number,
|
||||
);
|
||||
|
||||
// v1.4.16 Fix A8b — delegate to the pure helper so the long-
|
||||
// window split-half delta gets a unit-test that doesn't depend
|
||||
// on Recharts. See `src/lib/analytics/window-trend.ts`.
|
||||
const computed = computeWindowTrend({
|
||||
rawValues,
|
||||
trendValues,
|
||||
windowDays: days,
|
||||
});
|
||||
if (!computed) return null;
|
||||
|
||||
const { weeklyDelta, splitHalfDelta } = computed;
|
||||
const base = rawValues[0];
|
||||
const weeklyPct =
|
||||
Math.abs(base) > Number.EPSILON
|
||||
? (weeklyDelta / Math.abs(base)) * 100
|
||||
: null;
|
||||
const unitSuffix = unit ? ` ${unit}` : "";
|
||||
|
||||
// v1.4.16 Fix A8b: when the visible window is long (e.g. "All"
|
||||
// on a multi-year account), the per-week delta becomes vanishingly
|
||||
// small and rounds to ±0.0 in the formatter — Marc's complaint
|
||||
// "Wenn ich alle anklicke, dann wird einfach Null Veränderung
|
||||
// angezeigt" was exactly this: a slope of ~0.5 kg/year prints
|
||||
// as "+0.0 kg/week" and the user reads it as "no change". For
|
||||
// windows ≥ SPLIT_HALF_THRESHOLD_DAYS we additionally surface
|
||||
// the first-half vs. second-half mean delta — a single,
|
||||
// meaningful number that cannot round to zero unless the metric
|
||||
// truly didn't move.
|
||||
let totalDeltaSegment = "";
|
||||
if (days >= SPLIT_HALF_THRESHOLD_DAYS && splitHalfDelta !== null) {
|
||||
const meanFirst =
|
||||
rawValues
|
||||
.slice(0, Math.floor(rawValues.length / 2))
|
||||
.reduce((sum, value) => sum + value, 0) /
|
||||
Math.max(1, Math.floor(rawValues.length / 2));
|
||||
const totalDeltaPct =
|
||||
Math.abs(meanFirst) > Number.EPSILON
|
||||
? (splitHalfDelta / Math.abs(meanFirst)) * 100
|
||||
: null;
|
||||
totalDeltaSegment = ` · ${t(
|
||||
"charts.totalDelta",
|
||||
)} ${formatSigned(splitHalfDelta)}${unitSuffix}${
|
||||
totalDeltaPct != null ? ` (${formatSigned(totalDeltaPct)} %)` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
return `${getTypeLabel(type, valueMode, t)}: ${formatSigned(
|
||||
weeklyDelta,
|
||||
)}${unitSuffix}${t("charts.perWeek")}${
|
||||
weeklyPct != null
|
||||
? ` (${formatSigned(weeklyPct)} %${t("charts.perWeek")})`
|
||||
: ""
|
||||
}`;
|
||||
}${totalDeltaSegment}`;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
|
||||
|
||||
Reference in new issue
Block a user