From af77e5ecf89660d69cc8b01f7b17be5d32737b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:31:18 +0200 Subject: [PATCH] fix(insights): trend on "all" filter computes meaningful delta (split-half comparison) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/charts/health-chart.tsx | 55 +++++++- .../analytics/__tests__/window-trend.test.ts | 118 ++++++++++++++++++ src/lib/analytics/window-trend.ts | 76 +++++++++++ 3 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/lib/analytics/__tests__/window-trend.test.ts create mode 100644 src/lib/analytics/window-trend.ts diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index e6346a949..c849cb82d 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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]); diff --git a/src/lib/analytics/__tests__/window-trend.test.ts b/src/lib/analytics/__tests__/window-trend.test.ts new file mode 100644 index 000000000..4d63c2065 --- /dev/null +++ b/src/lib/analytics/__tests__/window-trend.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { computeWindowTrend, SPLIT_HALF_THRESHOLD_DAYS } from "../window-trend"; + +/** + * v1.4.16 Fix A8b — chart trend overlay on the "All" filter. + * + * Marc's report: clicking the All range button on a multi-year metric + * displays "+0.0 kg/Woche" because the per-week delta rounds to zero + * once the window is years wide. The split-half delta keeps the trend + * legible by reporting the change between the first-half mean and the + * second-half mean of the visible series — a number that cannot round + * to zero unless the metric truly didn't move. + */ + +describe("computeWindowTrend()", () => { + it("returns null for an empty / single-point series", () => { + expect( + computeWindowTrend({ rawValues: [], trendValues: [], windowDays: 0 }), + ).toBeNull(); + expect( + computeWindowTrend({ + rawValues: [80], + trendValues: [80], + windowDays: 0, + }), + ).toBeNull(); + }); + + it("returns null when raw / trend lengths disagree (programmer error)", () => { + expect( + computeWindowTrend({ + rawValues: [80, 81, 82], + trendValues: [80, 81], + windowDays: 30, + }), + ).toBeNull(); + }); + + it("computes a per-week delta for short windows (< threshold)", () => { + // 7 days, slope of +1 per week → weeklyDelta = +1 + const result = computeWindowTrend({ + rawValues: [80, 80.5, 81], + trendValues: [80, 80.5, 81], + windowDays: 7, + }); + expect(result).not.toBeNull(); + expect(result?.weeklyDelta).toBeCloseTo(1, 3); + // Below the split-half threshold — no second-half computation. + expect(result?.splitHalfDelta).toBeNull(); + }); + + it("on the threshold boundary: split-half kicks in at exactly 90 days", () => { + const result = computeWindowTrend({ + rawValues: [80, 81, 82, 83], + trendValues: [80, 81, 82, 83], + windowDays: SPLIT_HALF_THRESHOLD_DAYS, + }); + expect(result?.splitHalfDelta).not.toBeNull(); + }); + + it("computes a meaningful split-half delta for a long ('All') window", () => { + // ~3 years of synthetic data, +0.3 kg per month average rise. The + // per-week rate is small (~0.07 kg/week) which rounds to 0.1 in + // the 1-decimal formatter — and the test in the chart wrapper is + // about whether ANY signal survives. The split-half delta ≈ +5 kg + // is the kind of number Marc actually wants to see. + const months = 36; + const rawValues = Array.from({ length: months }, (_, i) => 80 + 0.3 * i); + const trendValues = rawValues; // perfectly linear, trend === raw + const windowDays = months * 30; + + const result = computeWindowTrend({ rawValues, trendValues, windowDays }); + expect(result).not.toBeNull(); + expect(result?.splitHalfDelta).not.toBeNull(); + // First half mean ≈ 80 + 0.3*8.5 = 82.55; second half ≈ 80 + 0.3*26.5 = 87.95 + // → delta ≈ 5.4 kg, definitively non-zero. + expect(result?.splitHalfDelta ?? 0).toBeGreaterThan(3); + }); + + it("split-half delta is exactly zero for a flat series even on a long window", () => { + // Confirms we don't fabricate movement where there is none — the + // split-half is the *mean* delta, so identical halves yield 0. + const flat = Array(50).fill(80); + const result = computeWindowTrend({ + rawValues: flat, + trendValues: flat, + windowDays: 365 * 2, + }); + expect(result?.splitHalfDelta).toBe(0); + expect(result?.weeklyDelta).toBe(0); + }); + + it("respects an overridden split-half threshold (param injection)", () => { + // Caller wants split-half from day 30. + const result = computeWindowTrend( + { + rawValues: [80, 81, 82, 83, 84, 85], + trendValues: [80, 81, 82, 83, 84, 85], + windowDays: 30, + }, + 30, + ); + expect(result?.splitHalfDelta).not.toBeNull(); + }); + + it("first-half/second-half split halves of a 6-point series", () => { + // Values: [70, 71, 72, 80, 81, 82] + // First half mean = (70+71+72)/3 = 71 + // Second half mean = (80+81+82)/3 = 81 + // Δ = 10 + const result = computeWindowTrend({ + rawValues: [70, 71, 72, 80, 81, 82], + trendValues: [70, 71, 72, 80, 81, 82], + windowDays: 365, + }); + expect(result?.splitHalfDelta).toBeCloseTo(10, 3); + }); +}); diff --git a/src/lib/analytics/window-trend.ts b/src/lib/analytics/window-trend.ts new file mode 100644 index 000000000..3356ae882 --- /dev/null +++ b/src/lib/analytics/window-trend.ts @@ -0,0 +1,76 @@ +/** + * Window-trend helpers for the health-chart `showTrend` overlay. + * + * Two complementary deltas: + * + * - `weeklyDelta` — first vs last regression-line value, normalised + * to a per-week rate. Stable for short windows + * (≤ 90 days) where the user is asking "what is + * this metric doing this week / month". + * - `splitHalfDelta` — mean of the second half minus mean of the first + * half of the visible series. Stays meaningful + * for long ("All") ranges where the per-week rate + * rounds to ±0 in the 1-decimal formatter. + * + * v1.4.16 Fix A8b — Marc reported the chart trend overlay reads + * "+0.0 kg/Woche" on the All filter, even when his weight clearly + * shifted across years. Root cause: `weeklyDelta = (last − first) / days + * × 7` is mathematically correct but visually rounds to zero whenever + * the slope-per-day is small enough that 7× still falls below the + * formatter's display precision. The split-half delta side-steps the + * rounding entirely because it doesn't divide by the window width. + * + * Pure & deterministic — pinned by unit test. + */ + +export interface WindowTrendInput { + /** Sorted ascending by timestamp. */ + rawValues: number[]; + /** Sorted ascending by timestamp. Must align 1:1 with `rawValues`. */ + trendValues: number[]; + /** Days between the first and last point of the visible series. */ + windowDays: number; +} + +export interface WindowTrendResult { + /** Linear-trend per-week delta. Always present when ≥ 2 points. */ + weeklyDelta: number; + /** + * First-half-mean vs second-half-mean delta of the raw (not trend) + * values. Reported as `null` for short windows or when one of the + * halves is empty (n < 2 split into two halves). + */ + splitHalfDelta: number | null; +} + +/** Default threshold above which the split-half delta is computed. */ +export const SPLIT_HALF_THRESHOLD_DAYS = 90; + +export function computeWindowTrend( + input: WindowTrendInput, + splitHalfThresholdDays = SPLIT_HALF_THRESHOLD_DAYS, +): WindowTrendResult | null { + if (input.rawValues.length < 2) return null; + if (input.trendValues.length !== input.rawValues.length) return null; + if (!Number.isFinite(input.windowDays) || input.windowDays <= 0) return null; + + const firstTrend = input.trendValues[0]; + const lastTrend = input.trendValues[input.trendValues.length - 1]; + const weeklyDelta = ((lastTrend - firstTrend) / input.windowDays) * 7; + + let splitHalfDelta: number | null = null; + if (input.windowDays >= splitHalfThresholdDays) { + const mid = Math.floor(input.rawValues.length / 2); + const firstHalf = input.rawValues.slice(0, mid); + const secondHalf = input.rawValues.slice(mid); + if (firstHalf.length > 0 && secondHalf.length > 0) { + const meanFirst = + firstHalf.reduce((sum, value) => sum + value, 0) / firstHalf.length; + const meanSecond = + secondHalf.reduce((sum, value) => sum + value, 0) / secondHalf.length; + splitHalfDelta = meanSecond - meanFirst; + } + } + + return { weeklyDelta, splitHalfDelta }; +}