diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 628a28b4a..7d37fde48 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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 { diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 228304661..ee2091d43 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -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 - ).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).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 = diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 88fd6221c..9b71bd770 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -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 diff --git a/src/hooks/use-module-enabled.ts b/src/hooks/use-module-enabled.ts new file mode 100644 index 000000000..fd4d6bf3e --- /dev/null +++ b/src/hooks/use-module-enabled.ts @@ -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 (``, the + * isolated score-card snapshots, …) renders without a + * ``. 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; +} diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 14ae10efc..ace458c43 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -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(); + 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(); + 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(); + 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 diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index b6bb3fb1b..eb1f461eb 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -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 { 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