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

+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