fix(analytics): return null health score when no pillar clears its own bar

The empty-path guard in the score fast-path checked raw record presence
(any row at all) rather than each pillar's own eligibility threshold —
weight needs 2 readings, mood needs 5 entries, BP needs a paired
reading, compliance needs an active scheduled medication. An account
with some data but no eligible pillar (a single mood entry, a single
weight reading) fell through to computeHealthScore, which summed no
components and returned a raw 0/red score indistinguishable from a
genuinely poor result.

Check the computed result's own components instead of re-deriving the
thresholds, so the guard can never drift from computeHealthScore's
actual eligibility rules. A pillar legitimately computed as 0 keeps its
0 value and is never swept into the null branch.

Closes #582.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 09:08:18 +02:00
1 parent 62eb431d66
commit 35a3842bba
2 files changed
+173 -27

No files matched your search

@@ -444,6 +444,136 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* Issue #582 — a raw-record-presence check let a `0`/red score through
* when every pillar still failed its own eligibility bar (weight needs
* >= 2 readings, mood needs >= 5 entries, BP needs a paired reading,
* compliance needs an active scheduled medication). The fix checks the
* COMPUTED result's components instead of raw row counts.
*/
describe("insufficient-data guard (#582)", () => {
it("returns null — not a 0 score — when only one mood entry exists and no other pillar is eligible", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// No weight rows, no BP rows, no medications.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce(
[],
);
// Exactly one mood entry — below the 5-entry stability floor, so
// moodStability() resolves to null even though the raw row count
// is nonzero.
MOOD_FIND_MANY.mockResolvedValue([
{ score: 4, moodLoggedAt: now },
]);
const result = await computeUserHealthScoreFastPath({
userId: "user-one-mood-entry",
bpInTargetPct: null,
heightCm: null,
now,
coverage,
});
expect(result).toBeNull();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const nullCall = calls.find(
(c) => c?.meta?.healthScore?.reason === "no_components_available",
);
expect(nullCall).toBeDefined();
});
it("returns null — not a 0 score — when only one weight reading exists and no other pillar is eligible", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// Exactly one weight reading — below the 2-reading regression floor,
// so weightTrendAlignment() resolves to null.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-06-05T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]); // BP-SYS source attribution, empty.
const result = await computeUserHealthScoreFastPath({
userId: "user-one-weight-reading",
bpInTargetPct: null,
heightCm: null,
now,
coverage,
});
expect(result).toBeNull();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const nullCall = calls.find(
(c) => c?.meta?.healthScore?.reason === "no_components_available",
);
expect(nullCall).toBeDefined();
});
it("returns null when raw records exist across multiple pillars but none clears its own minimum", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// One weight reading (< 2) + one mood entry (< 5) + no BP + no
// active medications. Every pillar has SOME data, but none is
// individually eligible.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-06-05T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]);
MOOD_FIND_MANY.mockResolvedValue([{ score: 3, moodLoggedAt: now }]);
const result = await computeUserHealthScoreFastPath({
userId: "user-multi-pillar-ineligible",
bpInTargetPct: null,
heightCm: null,
now,
coverage,
});
expect(result).toBeNull();
});
it("preserves a legitimately computed score of 0 rather than treating it as insufficient data", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// No weight, no mood, no medications — but a real, computed BP
// pillar that lands on exactly 0. This must stay a genuine `0`
// score, not get swept into the null branch.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([
{ measuredAt: new Date("2026-06-05T08:00:00.000Z"), source: "MANUAL" },
]);
const result = await computeUserHealthScoreFastPath({
userId: "user-legit-zero",
bpInTargetPct: 0,
bpGradedScore: 0,
heightCm: null,
now,
coverage,
});
expect(result).not.toBeNull();
expect(result?.components.bp.value).toBe(0);
expect(result?.score).toBe(0);
});
});
describe("coverage probing", () => {
it("probes coverage when the caller omits the map", async () => {
const coverage = new Map<string, boolean>([]);
+43 -27
View File
@@ -465,33 +465,6 @@ export async function computeUserHealthScoreFastPath(
score: r.score,
}));
// Empty-path early-out — nothing computable, hero hides cleanly.
if (
bpInTargetPct === null &&
weightSeriesLast30d.length === 0 &&
moodSeriesLast30d.length === 0 &&
medicationCompliance30.length === 0
) {
annotate({
meta: {
healthScore: {
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
return null;
}
const windowEndAt = now.toISOString();
const bpSourceTokens = uniqueComponentSources(
@@ -557,6 +530,49 @@ export async function computeUserHealthScoreFastPath(
};
const result = computeHealthScore(current, previous);
// No-pillar-eligible early-out — nothing computable, hero hides cleanly.
//
// The naive raw-record check this replaced only asked whether any pillar
// had a nonzero row count, not whether that count cleared the pillar's own
// eligibility bar: weight needs >= 2 readings, mood needs >= 5 entries, BP
// needs a paired reading, compliance needs an active scheduled medication.
// An account that clears the row-count check but misses every pillar's
// bar (e.g. exactly one mood entry, or a single weight reading) fell
// through to `computeHealthScore`, which sums no components and returns a
// raw `0` — indistinguishable from a genuinely poor score. Checking the
// COMPUTED result's components (the same eligibility rules the score
// itself already applies) instead of re-deriving the thresholds here means
// this can never drift out of sync with `computeHealthScore`. A component
// that is legitimately computed as `0` still has `value: 0`, not `null`,
// so it is never swept into this branch.
const noComponentEligible =
result.components.bp.value === null &&
result.components.weight.value === null &&
result.components.mood.value === null &&
result.components.compliance.value === null;
if (noComponentEligible) {
annotate({
meta: {
healthScore: {
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
return null;
}
annotate({
meta: {
healthScore: {