mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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>([]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user