mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user