Let the score's recipe decide what counts, not the module switches

Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.

The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.

Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.

A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:47:12 +02:00
1 parent 956746fe7d
commit 76e68f0247
7 files changed
+125 -19

No files matched your search

+1
View File
@@ -571,6 +571,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
sleep: sleepEnabled,
mentalHealth: mentalHealthEnabled,
},
healthScoreConfigJson: user.healthScoreConfigJson,
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
+2
View File
@@ -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,
+80
View File
@@ -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<ScorePillarId> = 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<ScorePillarId>();
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),
);
}
+33 -19
View File
@@ -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({
@@ -195,6 +195,7 @@ function baseUser(
insightsCachedAt: null,
dashboardWidgetsJson: null,
thresholdsJson: null,
healthScoreConfigJson: null,
...overrides,
};
}
+1
View File
@@ -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
+7
View File
@@ -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,