mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Turn the health score green at 70
The app carries three scores and meant two things by a green dot. Readiness and the sleep score both call 70 green; the health score alone wanted 75. One app cannot do that, so the outlier moved. The yellow floor stays at 50: raising the bar for a reward and lowering the bar for a warning are different decisions, and only the first one was on the table. The band is still not a function of the number. The composite takes the worse of the mean's band and the worst counted pillar's, so a composite carrying a red pillar stays red however high the mean sits - the stored band on a recorded day exists for exactly that reason. Only the mean-side thresholds moved. The method version moves with it. A day already recorded keeps the band it was actually shown under, and the notice key carries the version, so each account is told once that the rules moved. The week-over-week comparison is left alone on purpose: the number itself does not change, only the colour it is drawn in, so a subtraction across the change still compares two numbers worked out the same way.
This commit is contained in:
1 parent
4e934a191d
commit
9bcd5dfaf5
9 files changed
+182
-24
No files matched your search
@@ -9,6 +9,7 @@ import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
|
||||
/**
|
||||
@@ -148,7 +149,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
composition: pillars.map((p) => p.id),
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage,
|
||||
confidence: { score: 100, band: "high" },
|
||||
@@ -157,7 +158,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
HealthScoreCardSkeleton,
|
||||
@@ -211,7 +212,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
|
||||
pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR],
|
||||
delta: null,
|
||||
deltaReason: "no_current_score",
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
@@ -241,7 +242,7 @@ function scoredReport(
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -371,7 +372,7 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -600,7 +601,7 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
expect(html).toContain("—");
|
||||
expect(html).not.toContain("/ 100");
|
||||
expect(html).toContain("Method version 2");
|
||||
expect(html).toContain(`Method version ${SCORE_VERSION}`);
|
||||
});
|
||||
|
||||
it("explains a missing comparison where the trend would be", () => {
|
||||
@@ -665,7 +666,9 @@ describe("<HealthScoreCard> footer", () => {
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain('data-slot="health-score-method"');
|
||||
expect(region).toContain("Overall band set by none");
|
||||
expect(region).toContain("Method version 2. Weekly comparison floor");
|
||||
expect(region).toContain(
|
||||
`Method version ${SCORE_VERSION}. Weekly comparison floor`,
|
||||
);
|
||||
expect(region).toContain('data-slot="provenance-explainer-method"');
|
||||
expect(region).toContain("equal-weighted average");
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
|
||||
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
|
||||
// it so the SSR render does not reach for a QueryClient the test omits.
|
||||
@@ -116,7 +117,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -130,7 +131,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
SCORE_ALGORITHM_CHANGED_AT,
|
||||
} from "../composite";
|
||||
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
|
||||
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
|
||||
import { SCORE_GREEN_FLOOR, scoreBand } from "../shared";
|
||||
import {
|
||||
SCORE_VERSION,
|
||||
type PillarValue,
|
||||
type ScorePillarId,
|
||||
type ScorePillarResult,
|
||||
} from "../types";
|
||||
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
|
||||
@@ -86,6 +92,111 @@ function evaluation(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the band changes, and what the band is NOT.
|
||||
*
|
||||
* v1.35.1 moved green from 75 to 70, so the Health Score says the same
|
||||
* thing by a green dot that Readiness and the sleep score already did.
|
||||
* Boundaries are pinned on both sides because an off-by-one here is
|
||||
* invisible: a `>` where a `>=` belongs looks perfectly healthy at 80.
|
||||
*
|
||||
* The last case is the one that must survive every future threshold
|
||||
* edit. The band is not a function of the number: `computeComposite`
|
||||
* takes the worse of the mean's band and the worst counted pillar's, and
|
||||
* `HealthScoreRecord.band` is stored rather than re-derived precisely
|
||||
* because of it. A mean above the green floor with a red pillar under it
|
||||
* is still red.
|
||||
*/
|
||||
describe("the band the mean alone gives", () => {
|
||||
const CASES: Array<[number, string]> = [
|
||||
[69, "yellow"],
|
||||
[70, "green"],
|
||||
[74, "green"],
|
||||
[75, "green"],
|
||||
[49, "red"],
|
||||
[50, "yellow"],
|
||||
];
|
||||
|
||||
for (const [score, expected] of CASES) {
|
||||
it(`calls ${score} ${expected}`, () => {
|
||||
expect(scoreBand(score)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("says the same thing the app's other two scores say", () => {
|
||||
// Readiness and the sleep score both call 70 green. Three scores in
|
||||
// one app cannot mean three things by one colour, and the Health
|
||||
// Score was the outlier.
|
||||
expect(scoreBand(SCORE_GREEN_FLOOR)).toBe("green");
|
||||
expect(scoreBand(SCORE_GREEN_FLOOR - 1)).toBe("yellow");
|
||||
expect(SCORE_GREEN_FLOOR).toBe(70);
|
||||
});
|
||||
|
||||
it("moved the method version with it", () => {
|
||||
// A stored day keeps the band it was shown under, and the notice key
|
||||
// carries this number, so each account hears once that the rules
|
||||
// moved. A threshold edit without a version bump is a silent one.
|
||||
expect(SCORE_VERSION).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the worst pillar still holds the band down", () => {
|
||||
it("keeps a composite red when a counted pillar is red, above the green floor", () => {
|
||||
// Mean 73: green on the number alone, and green under the OLD floor
|
||||
// it would have been yellow — so this case can only pass because the
|
||||
// worst-pillar rule survived the threshold change.
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 20),
|
||||
pillar("ACTIVITY", 100),
|
||||
pillar("SLEEP", 100),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(result.value.score).toBe(73);
|
||||
expect(scoreBand(result.value.score)).toBe("green");
|
||||
expect(result.value.band).toBe("red");
|
||||
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
|
||||
});
|
||||
|
||||
it("keeps a composite yellow when its worst pillar is yellow", () => {
|
||||
// Mean 84, every pillar above the red floor, one of them yellow.
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 52),
|
||||
pillar("ACTIVITY", 100),
|
||||
pillar("SLEEP", 100),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(scoreBand(result.value.score)).toBe("green");
|
||||
expect(result.value.band).toBe("yellow");
|
||||
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
|
||||
});
|
||||
|
||||
it("names no setter when every pillar clears the mean's band", () => {
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 71),
|
||||
pillar("ACTIVITY", 90),
|
||||
pillar("SLEEP", 90),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(result.value.band).toBe("green");
|
||||
expect(result.value.bandSetter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference-score composite", () => {
|
||||
it("requires three domains and one measured physiological domain", () => {
|
||||
const tooNarrow = evaluation(
|
||||
|
||||
@@ -512,16 +512,16 @@ describe("a settings change is never narrated as a health event", () => {
|
||||
const unconfigured = await computeUserHealthScore(scoreInput(null));
|
||||
|
||||
expect(configured.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2:config:3",
|
||||
"health_score_algorithm:3:config:3",
|
||||
);
|
||||
expect(configured.algorithmNotice!.dismissed).toBe(false);
|
||||
// An account that never chose keeps the key its acknowledgement is
|
||||
// already filed under.
|
||||
// An account that never chose keeps the bare method key, with no
|
||||
// recipe suffix on it.
|
||||
expect(unconfigured.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2",
|
||||
"health_score_algorithm:3",
|
||||
);
|
||||
|
||||
dismissedKeys.add("health_score_algorithm:2:config:3");
|
||||
dismissedKeys.add("health_score_algorithm:3:config:3");
|
||||
expect(
|
||||
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
|
||||
.dismissed,
|
||||
@@ -539,7 +539,7 @@ describe("a settings change is never narrated as a health event", () => {
|
||||
),
|
||||
);
|
||||
expect(next.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2:config:4",
|
||||
"health_score_algorithm:3:config:4",
|
||||
);
|
||||
expect(next.algorithmNotice!.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "../types";
|
||||
import { SCORE_VERSION } from "../types";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
@@ -115,7 +116,7 @@ function composite(score: number, asOf: Date): Derived<CompositeValue> {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
configured: false,
|
||||
noiseFloor: 1,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
@@ -190,7 +191,7 @@ describe("the recipe boundary", () => {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY"],
|
||||
configured: false,
|
||||
noiseFloor: 1,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "../types";
|
||||
import { SCORE_VERSION } from "../types";
|
||||
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
|
||||
@@ -103,7 +104,7 @@ function report(
|
||||
pillars,
|
||||
delta: null,
|
||||
deltaReason: "no_previous_window",
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
|
||||
algorithmNotice: null,
|
||||
};
|
||||
@@ -144,7 +145,7 @@ describe("stored bands", () => {
|
||||
|
||||
describe("input fingerprint", () => {
|
||||
const base = {
|
||||
scoreVersion: 2,
|
||||
scoreVersion: 1,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
|
||||
pillars: HEALTHY,
|
||||
};
|
||||
@@ -156,7 +157,7 @@ describe("input fingerprint", () => {
|
||||
});
|
||||
|
||||
it("moves when the algorithm version moves", () => {
|
||||
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
|
||||
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,9 +19,37 @@ export function clampScore(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
/** Green from here up. Yellow from {@link SCORE_YELLOW_FLOOR} up. */
|
||||
export const SCORE_GREEN_FLOOR = 70;
|
||||
|
||||
/** Below this is red. */
|
||||
export const SCORE_YELLOW_FLOOR = 50;
|
||||
|
||||
/**
|
||||
* The band a 0-100 score falls in, from the number alone.
|
||||
*
|
||||
* v1.35.1 — green starts at 70, not 75. The app carries three scores and
|
||||
* had two conventions: Readiness and the sleep score both call 70 green
|
||||
* (`bandForScore` in `src/lib/insights/derived/readiness.ts` and
|
||||
* `src/lib/insights/derived/sleep-score.ts`), and the Health Score alone
|
||||
* sat at 75. One app cannot mean two things by a green dot, so the
|
||||
* outlier moved. The yellow floor stays at 50 deliberately: raising the
|
||||
* bar for a reward and lowering the bar for a warning are different
|
||||
* decisions, and only the first one was asked for.
|
||||
*
|
||||
* This is a METHOD change, so `SCORE_VERSION` moved with it. Stored days
|
||||
* keep the band they were actually shown under, and the notice key
|
||||
* carries the version, so each account is told once that the method
|
||||
* moved.
|
||||
*
|
||||
* NOT the whole band rule. `computeComposite` takes the worse of this
|
||||
* band and the worst counted pillar's band, so a composite carrying a red
|
||||
* pillar stays red however high the mean sits. That rule is untouched;
|
||||
* only the mean-side thresholds moved.
|
||||
*/
|
||||
export function scoreBand(score: number): ScoreBand {
|
||||
if (score >= 75) return "green";
|
||||
if (score >= 50) return "yellow";
|
||||
if (score >= SCORE_GREEN_FLOOR) return "green";
|
||||
if (score >= SCORE_YELLOW_FLOOR) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,19 @@ import type {
|
||||
DerivedProvenanceSource,
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
export const SCORE_VERSION = 2 as const;
|
||||
/**
|
||||
* The scoring method's identity.
|
||||
*
|
||||
* Moves whenever the method changes, which is what lets a stored day say
|
||||
* which rules produced it and lets each account be told once that the
|
||||
* rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version
|
||||
* of the report.
|
||||
*
|
||||
* 3 — v1.35.1: green starts at 70 rather than 75, matching the two other
|
||||
* scores in the app, and the fitness pillar left the catalogue.
|
||||
* 2 — the composite the previous line shipped.
|
||||
*/
|
||||
export const SCORE_VERSION = 3 as const;
|
||||
|
||||
/**
|
||||
* The pillars a score can be built from.
|
||||
|
||||
Reference in new issue
Block a user