From 9c282ee66d607bdc6628ed6293c069ad022be3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 14:45:09 +0200 Subject: [PATCH] score the weight trend even without a target weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Health Score weight pillar was gated on a derived weight target, and the only target source is the BMI-22 fallback from the profile height. A profile with no height yields a null target, so the pillar came back empty ({value:null, weight:0, source:"none"}) however much weight data the user had logged — the weight trend never factored into the score. Decouple the pillar from requiring a target: when no target is derivable but the series has at least two readings, score the bare weight trend on its own. A flat trend anchors at 75 (maintenance is a solid result), a decline lifts toward 100, and a sustained rise drops toward 25 — the one direction that is broadly adverse independent of any individual goal. The target-aware scoring is unchanged when a height is set. No fabricated target: the trend is scored honestly, never against a pretend goal. The series.length < 2 null guard stays — genuinely too little data still returns null. --- .../__tests__/health-score-fast-path.test.ts | 53 ++++++++++++++++++ .../analytics/__tests__/health-score.test.ts | 30 +++++++++- src/lib/analytics/health-score.ts | 56 ++++++++++++++++++- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 3f5d47a22..e0ff9b316 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -364,6 +364,59 @@ describe("computeUserHealthScoreFastPath", () => { expect(weightCall.where.type).toBe("WEIGHT"); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); + + // Regression pin: a height-less user (no derivable weight target) + // who DOES have weight readings must still get a populated, weighted + // weight pillar. Before this fix the pillar came back + // `{value:null, weight:0, source:"none"}` because the only target + // source was `defaultWeightTargetFromHeight(null)` → null, and the + // pillar was gated on having a target. The trend-only scoring path + // now scores the bare weight trend instead. + it("populates the weight pillar from a trend-only score when height is null", async () => { + const coverage = new Map([["WEIGHT", false]]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + MEASUREMENT_FIND_MANY + // Weight (live) — multiple readings inside the window, gentle decline. + .mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-04T08:00:00.000Z"), + value: 84.0, + source: "WITHINGS", + }, + { + measuredAt: new Date("2026-05-09T08:00:00.000Z"), + value: 83.5, + source: "WITHINGS", + }, + { + measuredAt: new Date("2026-05-14T08:00:00.000Z"), + value: 83.0, + source: "WITHINGS", + }, + ]) + // BP-SYS source attribution. + .mockResolvedValueOnce([]); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-no-height", + bpInTargetPct: null, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + const weight = result?.components.weight; + // The pillar is populated and weighted even with no target. + expect(weight?.value).not.toBeNull(); + expect(weight?.weight).toBeGreaterThan(0); + expect(weight?.source).not.toBe("none"); + // The source reflects the contributing ingest path, not the empty state. + expect(weight?.source).toBe("withings"); + }); }); describe("empty path", () => { diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts index d858bac86..108964927 100644 --- a/src/lib/analytics/__tests__/health-score.test.ts +++ b/src/lib/analytics/__tests__/health-score.test.ts @@ -98,8 +98,34 @@ describe("weightTrendAlignment", () => { expect(result!).toBeLessThan(50); }); - it("returns null when the target is missing", () => { - expect(weightTrendAlignment(weightSeries([80, 81]), null)).toBeNull(); + it("scores the bare trend (non-null) when the target is missing but ≥2 readings exist", () => { + // No target → fall back to trend-only scoring instead of returning + // null, so a height-less user with weight data gets a populated pillar. + const result = weightTrendAlignment(weightSeries([80, 80.5, 81, 81.5]), null); + expect(result).not.toBeNull(); + expect(result!).toBeGreaterThanOrEqual(0); + expect(result!).toBeLessThanOrEqual(100); + }); + + it("anchors a flat trend at 75 when there is no target", () => { + expect(weightTrendAlignment(weightSeries([80, 80, 80, 80]), null)).toBe(75); + }); + + it("rewards a gentle decline (>75) when there is no target", () => { + const result = weightTrendAlignment(weightSeries([84, 83.5, 83, 82.5]), null); + expect(result).not.toBeNull(); + expect(result!).toBeGreaterThan(75); + }); + + it("penalises a sustained rise (<75) when there is no target", () => { + const result = weightTrendAlignment(weightSeries([80, 81, 82, 83, 84]), null); + expect(result).not.toBeNull(); + expect(result!).toBeLessThan(75); + }); + + it("returns null with no target and fewer than two readings", () => { + expect(weightTrendAlignment(weightSeries([80]), null)).toBeNull(); + expect(weightTrendAlignment([], null)).toBeNull(); }); it("returns null with fewer than two readings", () => { diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index 5105c0367..9b0ca94f8 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -186,6 +186,49 @@ export function coefficientOfVariation(values: number[]): number | null { return stdev / Math.abs(mean); } +/** + * Score the recent weight trend on its own, with no target band. + * + * Used when the user has weight readings but no derivable target (their + * profile has no `heightCm`, so `defaultWeightTargetFromHeight` returns + * null). Without a goal we can't say "closing the gap", but a sustained + * upward weight trend is the one direction that is broadly an adverse + * signal independent of any individual target, so we score on that + * asymmetry: + * + * - Flat / stable (slope ≈ 0) → 75 (maintenance is a solid result). + * - Gentle-to-steep decline (slope<0) → 75 → 100 (favourable; saturating). + * - Gentle-to-steep rise (slope>0) → 75 → 25 (unfavourable; saturating). + * + * The 75 anchor keeps a stable-but-noisy series mid-high (it lands at + * 75, not at the 50 "neither" midpoint the target-aware path uses) so a + * user simply maintaining weight isn't penalised for lacking a goal. + * The same `SATURATION = 0.05 kg/day` (≈ 1.5 kg/month) curve as the + * target-aware path keeps the two scoring modes numerically consistent. + * + * Returns null when fewer than two readings are supplied (caller already + * guards this, but the helper stays self-contained for direct tests). + */ +function bareWeightTrendScore( + series: Array<{ date: string; kg: number }>, +): number | null { + if (series.length < 2) return null; + const points = series.map((p) => ({ date: p.date, value: p.kg })); + const slope = linearRegressionSlope(points); + if (slope === null) return null; + if (slope === 0) return 75; // exactly stable → maintenance anchor + + const SATURATION = 0.05; + const normalised = Math.tanh(Math.abs(slope) / SATURATION); + // Decline lifts the anchor toward 100; rise drops it toward 25. The + // 25-point span keeps the unfavourable floor honest (a steep sustained + // gain reads "concerning", not "failing") while the favourable ceiling + // reaches the full 100. + const score = + slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised; + return Math.round(Math.max(0, Math.min(100, score))); +} + /** * Score how well the recent weight trend is closing the gap toward the * target band [targetMin..targetMax]. @@ -196,13 +239,22 @@ export function coefficientOfVariation(values: number[]): number | null { * doesn't read more positive than a 0.05 kg/day drop. * - Below the band → an upward slope is closing. * - * Insufficient data (< 2 readings, no target) → null. + * No target (no stored height → no BMI-22 fallback band) but ≥ 2 + * readings → fall back to scoring the **bare trend** on its own, so a + * user with weight data still gets a populated, weighted pillar instead + * of an empty `{value:null, weight:0, source:"none"}`. See + * `bareWeightTrendScore` for the curve and its rationale. We do NOT + * fabricate a target from the user's own median and pretend it's a goal + * — the trend is scored honestly, without a target. + * + * Insufficient data (< 2 readings) → null regardless of target. */ export function weightTrendAlignment( series: Array<{ date: string; kg: number }>, target: { min: number; max: number } | null, ): number | null { - if (!target || series.length < 2) return null; + if (series.length < 2) return null; + if (!target) return bareWeightTrendScore(series); const points = series.map((p) => ({ date: p.date, value: p.kg })); // Latest reading drives the "are we currently in the band" check. const sorted = [...points].sort((a, b) =>