score the weight trend even without a target weight

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 14:45:09 +02:00
1 parent faeb770cbb
commit 9c282ee66d
3 files changed
+135 -4

No files matched your search

@@ -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<string, boolean>([["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", () => {
@@ -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", () => {
+54 -2
View File
@@ -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) =>