diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index ff6068a79..29dfcf01b 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -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: { diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 66ce483f7..41283bd96 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -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 { 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(" pillars", () => { composition: ["BLOOD_PRESSURE", "GLYCAEMIA"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: { requiredInputs: 3, @@ -600,7 +601,7 @@ describe(" 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(" 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"); }); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index dc4c21e75..7f59ad7b3 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -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: { diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index f8544d35d..2a7063fd7 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -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( diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 6bca8b52b..3e067b8e8 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -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); }); diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts index aa6581931..01d1419cc 100644 --- a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -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 { 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, diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 16d322cc0..2d92f5a91 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -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), ); }); diff --git a/src/lib/analytics/score/shared.ts b/src/lib/analytics/score/shared.ts index 66f960912..fde7fffd7 100644 --- a/src/lib/analytics/score/shared.ts +++ b/src/lib/analytics/score/shared.ts @@ -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"; } diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index f9e6eca9e..e958263ce 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -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.