mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
7c8d82f565
commit
3de53f549c
6 files changed
+224
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user