diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index ca9b9c5ad..9bbfa031b 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -571,6 +571,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { sleep: sleepEnabled, mentalHealth: mentalHealthEnabled, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets: clinicalBpTargets, bpEnvelope, bpEnvelopePriorWeek, diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4ae00e227..1c61420c1 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -23,6 +23,7 @@ export async function computeHealthScoreDerived( heightCm: true, timezone: true, thresholdsJson: true, + healthScoreConfigJson: true, }, }); const now = new Date(); @@ -67,6 +68,7 @@ export async function computeHealthScoreDerived( sleep: modules.sleep !== false, mentalHealth: modules.mentalHealth !== false, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets, bpEnvelope, bpEnvelopePriorWeek, diff --git a/src/lib/analytics/score/modules.ts b/src/lib/analytics/score/modules.ts new file mode 100644 index 000000000..091c8e5c1 --- /dev/null +++ b/src/lib/analytics/score/modules.ts @@ -0,0 +1,80 @@ +/** + * v1.35.0 — which modules put data in front of which score pillar. + * + * Until this release the mapping existed only as ternaries inside the + * reader's `availablePillars` expression, where nothing could read it + * and nothing could test it. It is named here because two questions + * that used to have one answer now have two, and confusing them is how + * a person's number moves without them asking for it: + * + * - the CONFIG says which pillars a person counts toward their score; + * - a MODULE says whether that pillar's data is being recorded at all. + * + * The score takes the intersection. A module that is off cannot be + * overridden by a selection, because turning the module off closes the + * data path itself; and a pillar left out of the config stays out + * however much data flows into it. + * + * `GLYCAEMIA` appears under two modules on purpose: it scores off + * fasting glucose OR HbA1c, so either module alone keeps it fed. That + * was the `glucose || labs` ternary, and it is the entry that would be + * lost by assuming one module per pillar. + * + * `BLOOD_PRESSURE`, `ACTIVITY`, `ADIPOSITY` and `FITNESS` are absent + * from the map, which is the same thing the reader has always done: + * they read core measurement domains that carry no module switch, so no + * module can withdraw their data. Absent means ungated, not forgotten — + * the test beside this file pins the four so a silent addition to the + * map cannot quietly start gating one of them. + */ +import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types"; + +/** + * The modules the score reader is handed. Every key here gates at least + * one pillar; a module that gates nothing has no reason to be passed. + */ +export interface ScoreReaderModules { + glucose: boolean; + labs: boolean; + sleep: boolean; + mentalHealth: boolean; +} + +export type ScoreModuleKey = keyof ScoreReaderModules; + +/** Which pillars each module feeds. Registry ids, checked by the tests. */ +export const MODULE_SCORE_PILLARS: Record< + ScoreModuleKey, + readonly ScorePillarId[] +> = { + glucose: ["GLYCAEMIA"], + labs: ["GLYCAEMIA", "LIPIDS"], + sleep: ["SLEEP"], + mentalHealth: ["WELLBEING"], +}; + +const MODULE_KEYS = Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]; + +/** Pillars whose data any module can withdraw. Derived, never restated. */ +export const MODULE_GATED_PILLARS: ReadonlySet = new Set( + MODULE_KEYS.flatMap((key) => MODULE_SCORE_PILLARS[key]), +); + +/** + * The registry-ordered pillars whose data is actually being recorded, + * given the account's modules. This is availability, not choice: it says + * nothing about what the person wants counted, only about what there + * could be data for. + */ +export function pillarsWithModuleData( + modules: ScoreReaderModules, +): ScorePillarId[] { + const fed = new Set(); + for (const key of MODULE_KEYS) { + if (!modules[key]) continue; + for (const id of MODULE_SCORE_PILLARS[key]) fed.add(id); + } + return SCORE_PILLAR_IDS.filter( + (id) => !MODULE_GATED_PILLARS.has(id) || fed.has(id), + ); +} diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index bf2e6ddfd..950f7d39f 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -27,6 +27,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { ACTIVITY_WINDOW_DAYS } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; +import { resolveHealthScoreConfig } from "./config"; +import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; import { computeWeightGoal } from "./weight-goal"; @@ -45,19 +47,20 @@ export interface ScoreReaderProfile { thresholdsJson: unknown; } -export interface ScoreReaderModules { - glucose: boolean; - labs: boolean; - sleep: boolean; - mentalHealth: boolean; -} - export interface UserHealthScoreInput { prisma?: PrismaClient; userId: string; now: Date; profile: ScoreReaderProfile; modules: ScoreReaderModules; + /** + * The raw `User.healthScoreConfigJson` blob. Required, and deliberately + * not defaulted: a caller that forgets it would silently score the + * account on someone else's recipe, and a plausible default is exactly + * how that stays invisible. `null` is a real, meaningful value here — + * it means the person never chose, and every pillar counts. + */ + healthScoreConfigJson: unknown; bpTargets: BpTargets | null; bpEnvelope: BpInTargetEnvelope | null; bpEnvelopePriorWeek: BpInTargetEnvelope | null; @@ -763,18 +766,29 @@ export async function computeUserHealthScore( ), ]); - const availablePillars: ScorePillarId[] = [ - "BLOOD_PRESSURE", - ...(input.modules.glucose || input.modules.labs - ? ["GLYCAEMIA" as const] - : []), - "ACTIVITY", - ...(input.modules.sleep ? ["SLEEP" as const] : []), - "ADIPOSITY", - ...(input.modules.mentalHealth ? ["WELLBEING" as const] : []), - "FITNESS", - ...(input.modules.labs ? ["LIPIDS" as const] : []), - ]; + // v1.35.0 — what counts is the person's choice, narrowed to what there + // can be data for. Before this release the module switches decided the + // composition on their own; now they only say whether a pillar's data + // is being recorded, and the stored recipe says what counts. Two + // things deciding one number is the defect this replaces, so the old + // expression is gone rather than kept beside the new one. + // + // The promise on upgrade is that nobody's number moves, and it holds + // by construction rather than by a backfill: an account that never + // chose resolves to every pillar, and every pillar intersected with + // module availability is precisely the set the ternaries built. + // + // A pillar the config selects but whose module is off drops out here + // and therefore produces no row at all — not a score, not a "waiting + // for data" line. Turning a module off is a deliberate act with its + // own meaning, and asking someone for data they chose to stop + // recording would be nagging in the voice of their own settings. The + // place that pillar comes back is the modules screen. + const config = resolveHealthScoreConfig(input.healthScoreConfigJson); + const recorded = new Set(pillarsWithModuleData(input.modules)); + const availablePillars: ScorePillarId[] = config.pillars.filter((id) => + recorded.has(id), + ); const previousAt = new Date(input.now.getTime() - 7 * DAY_MS); const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS); const currentBp = scoreBpEnvelope({ diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 644e4d108..8077c2b43 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -195,6 +195,7 @@ function baseUser( insightsCachedAt: null, dashboardWidgetsJson: null, thresholdsJson: null, + healthScoreConfigJson: null, ...overrides, }; } diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index da2423aaa..acfcc4b25 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -79,6 +79,7 @@ export async function readDashboardSnapshotCached( dashboardWidgetsJson: user.dashboardWidgetsJson, sourcePriorityJson: user.sourcePriorityJson, thresholdsJson: user.thresholdsJson, + healthScoreConfigJson: user.healthScoreConfigJson, }; // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index f45bde80a..f2f45d9b9 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -490,6 +490,12 @@ export interface SnapshotUserInput { * dashboard reference bands. */ thresholdsJson: unknown; + /** + * Raw per-user Health Score recipe. Part of the number's identity, so + * it is required rather than optional: a snapshot built without it + * would show a score composed from someone else's rules. + */ + healthScoreConfigJson: unknown; } /** Wall-clock hour in `tz`, used for the server-side greeting. */ @@ -754,6 +760,7 @@ async function buildExtras( sleep: modules.sleep !== false, mentalHealth: modules.mentalHealth !== false, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets: clinicalBpTargets, bpEnvelope, bpEnvelopePriorWeek,