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