fix(health-score): drop the mood pillar when the mood module is disabled

The Personal Health Score weighted the 0.20 mood-stability pillar
unconditionally — the fast-path read mood entries regardless of the
account's module state, so a user who turned the mood module off was
still scored (and silently penalised or rewarded) by a pillar hidden
everywhere else in the product. The dashboard already blanks the mood
tile and the coach snapshot unions mood out; the score ring and the
insights card pillar list did not.

Thread a default-on `moodEnabled` flag through the fast-path. When the
caller resolves the mood module as disabled the helper skips the mood
read entirely and leaves the series empty, so `moodStability` returns
null and the existing proportional null-redistribution re-scales the
surviving pillars to sum to 100. The analytics route resolves the
module server-side and threads the flag; the insights card hides the
Mood row via a new SSR-safe `useModuleEnabled` accessor so it never
advertises a pillar the account turned off.

Golden fixtures pin mood-on vs mood-off to different, correctly
re-weighted scores and assert the disabled read is never issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-16 00:39:55 +02:00
1 parent 7c8d82f565
commit 3de53f549c
6 files changed
+224 -13

No files matched your search

+8
View File
@@ -17,6 +17,7 @@ import {
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { isModuleEnabled } from "@/lib/modules/gate";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -420,12 +421,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// same prior-week delta values). The hand-rolled per-surface assembly that
// let the two diverge is gone.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
// account drops the mood-stability pillar from the ring instead of
// being silently scored on data hidden everywhere else. The dashboard
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
heightCm: user.heightCm ?? null,
now: new Date(),
coverage,
moodEnabled,
});
return {
+19 -3
View File
@@ -78,6 +78,15 @@ export interface HealthScoreCardProps {
* starts collapsed.
*/
initiallyExpanded?: boolean;
/**
* v1.18.0 R4 — when the `mood` module is disabled for the account the
* server already drops the mood-stability pillar from the score (the
* pillar arrives null-weighted). This hides the Mood row from the
* component + provenance lists too so the card never advertises a
* pillar the user turned off. Default-on so omitting it keeps the
* four-row layout for the common (mood-enabled) case.
*/
moodEnabled?: boolean;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -164,6 +173,7 @@ export function HealthScoreCard({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAskCoach: _onAskCoach,
initiallyExpanded = false,
moodEnabled = true,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -198,9 +208,14 @@ export function HealthScoreCard({
// without relying on visual proximity alone.
const deltaExplainerId = useId();
const componentEntries = (
Object.keys(components) as Array<ComponentKey>
).map((key) => ({
// v1.18.0 R4 — drop the Mood pillar from the card's row lists when the
// module is disabled. The server already null-weights it in the score;
// hiding the row keeps the card from naming a pillar the account turned
// off (it would otherwise render as a permanently empty "Mood" line).
const visibleKeys = (Object.keys(components) as Array<ComponentKey>).filter(
(key) => moodEnabled || key !== "mood",
);
const componentEntries = visibleKeys.map((key) => ({
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: components[key].value,
@@ -211,6 +226,7 @@ export function HealthScoreCard({
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const provenanceRows = COMPONENT_ORDER
.filter((key) => moodEnabled || key !== "mood")
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
+7
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { cn } from "@/lib/utils";
import { SuggestedPrompts } from "./suggested-prompts";
import {
@@ -149,6 +150,11 @@ export function HeroStrip({
// tests render the strip without a QueryClient.
const disableCoach = useDisableCoach();
const coachEnabled = flags.coach && !disableCoach;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
// disabled-allowlist gate.
const moodEnabled = useModuleEnabled("mood");
const greetingKey = resolveGreetingKey(now ?? new Date());
const greetingBase = t(greetingKey);
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
@@ -339,6 +345,7 @@ export function HeroStrip({
band={healthScore.band}
components={healthScore.components}
delta={healthScore.delta}
moodEnabled={moodEnabled}
onAskCoach={
// v1.4.37 W5 — short-circuit the prop drilling even
// though `HealthScoreCard` retired its inline button in
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 R4 — SSR-safe accessor for a per-account module flag.
*
* Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and
* crashes when a legacy presentational component (`<HeroStrip>`, the
* isolated score-card snapshots, …) renders without a
* `<QueryClientProvider>`. We gate the inner hook on
* `useQueryClientMounted()` and short-circuit to the default-on result
* when no client is in the tree — the branch is stable across the
* component's lifetime, so the conditional call is safe per the Rules of
* Hooks.
*
* Default-on (disabled allowlist): an absent map, a missing key, or any
* non-`false` value reads as enabled — matching the server gate in
* `src/lib/modules/gate.ts`.
*/
export function useModuleEnabled(moduleKey: ModuleKey): boolean {
const hasClient = useQueryClientMounted();
if (!hasClient) return true;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useModuleEnabledInner(moduleKey);
}
function useModuleEnabledInner(moduleKey: ModuleKey): boolean {
const { user } = useAuth();
return user?.modules?.[moduleKey] !== false;
}
@@ -667,6 +667,127 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.18.0 R4 — module-aware mood pillar. With the mood module disabled
* the helper must NOT read mood, must null-weight the mood pillar, and
* must re-normalise the surviving pillars so the score isn't penalised
* (or inflated) by a pillar the user can no longer see. Golden fixture:
* an identical profile with strong, stable mood scored mood-on vs
* mood-off must produce a different, correctly re-weighted score.
*/
describe("module-aware mood pillar (R4)", () => {
// A profile with BP + steady high mood (low variance → high
// stability) + nothing else, so the mood pillar's contribution is
// unambiguous. Six entries clears the 5-entry stability floor.
function steadyMood(now: Date) {
return Array.from({ length: 6 }, (_v, i) => ({
score: 4,
moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000),
}));
}
it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const now = new Date("2026-06-07T12:00:00.000Z");
MOOD_FIND_MANY.mockResolvedValue(steadyMood(now));
const result = await computeUserHealthScoreFastPath({
userId: "user-mood-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
});
expect(result).not.toBeNull();
// Mood read fired and the pillar carries a non-zero weight.
expect(MOOD_FIND_MANY).toHaveBeenCalled();
expect(result?.components.mood.value).not.toBeNull();
expect(result?.components.mood.weight).toBeGreaterThan(0);
});
it("drops the mood pillar and re-normalises the score when moodEnabled is false", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const now = new Date("2026-06-07T12:00:00.000Z");
// Even if mood rows existed they must be ignored — the helper
// should never issue the read when the module is off.
MOOD_FIND_MANY.mockResolvedValue(steadyMood(now));
const result = await computeUserHealthScoreFastPath({
userId: "user-mood-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
moodEnabled: false,
});
expect(result).not.toBeNull();
// The disabled module is off the round-trip budget entirely.
expect(MOOD_FIND_MANY).not.toHaveBeenCalled();
// Mood pillar null-weighted; surviving pillar (BP) absorbs the
// freed weight via proportional redistribution.
expect(result?.components.mood.value).toBeNull();
expect(result?.components.mood.weight).toBe(0);
expect(result?.components.bp.weight).toBe(1);
// BP is the only present pillar → score is the BP graded value.
expect(result?.score).toBe(60);
});
it("golden: mood-on vs mood-off yield different, correctly re-weighted scores", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// Mood-ON: BP=60 (0.30) + perfect-stability mood=100 (0.20).
// present base weights = 0.50 → bp 0.6, mood 0.4 →
// 60*0.6 + 100*0.4 = 36 + 40 = 76.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
MOOD_FIND_MANY.mockResolvedValue(
// Constant score → coefficient of variation 0 → stability 100.
Array.from({ length: 6 }, (_v, i) => ({
score: 5,
moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000),
})),
);
const onResult = await computeUserHealthScoreFastPath({
userId: "user-golden-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
});
// Mood-OFF: only BP=60 survives → score 60.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const offResult = await computeUserHealthScoreFastPath({
userId: "user-golden-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
moodEnabled: false,
});
expect(onResult?.score).toBe(76);
expect(offResult?.score).toBe(60);
// The mood-off score is NOT silently dragged toward 0 by a phantom
// pillar — it equals the honest single-pillar BP score.
expect(offResult?.score).not.toBe(onResult?.score);
});
});
/**
* v1.15.12 A1 — the graded clinical-proximity score, when supplied,
* becomes the BP pillar VALUE; the binary in-target rate only gates
+36 -10
View File
@@ -136,6 +136,19 @@ export interface HealthScoreFastPathInput {
* the probe cost stays flat in the fan-out.
*/
coverage?: RollupCoverageMap;
/**
* v1.18.0 R4 — module-aware mood pillar. When the caller resolves the
* `mood` module as disabled it threads `moodEnabled: false` and the
* helper drops the 0.20 mood-stability pillar entirely: the mood read
* is skipped and the series stays empty, so `moodStability` returns
* `null` and the existing proportional null-redistribution re-scales
* the surviving pillars to sum to 100. Without this a disabled-mood
* account is silently penalised (or rewarded) by a pillar it can no
* longer see anywhere else in the product. Omit (or pass `true`) to
* keep the pre-v1.18.0 always-weighted behaviour — the field is
* additive so legacy callers are unchanged.
*/
moodEnabled?: boolean;
}
/**
@@ -150,6 +163,10 @@ export async function computeUserHealthScoreFastPath(
): Promise<HealthScoreResult | null> {
const DAY_MS = 24 * 60 * 60 * 1000;
const { userId, bpInTargetPct, heightCm, now } = input;
// v1.18.0 R4 — default-on so omitting the flag preserves the
// pre-v1.18.0 always-weighted mood pillar. Only an explicit `false`
// (the caller resolved `mood` as a disabled module) drops the pillar.
const moodEnabled = input.moodEnabled !== false;
// v1.4.38 — prior-week BP fallback. When the caller omits the field
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
// stays a drop-in replacement for legacy callers; when the caller
@@ -294,16 +311,25 @@ export async function computeUserHealthScoreFastPath(
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
// v1.18.0 R4 — skip the read entirely when the mood module is off.
// An empty series feeds `moodStability` → null → the 0.20 pillar
// weight redistributes across the surviving pillars. Skipping the
// query (rather than reading-then-discarding) keeps the disabled
// module off the round-trip budget too.
moodEnabled
? prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
})
: Promise.resolve(
[] as Array<{ score: number; moodLoggedAt: Date }>,
),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications carry no expected doses
// and are excluded from every compliance rate: they must