mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(insights): gate the mood correlation cards on significance
The mood page called the plain `pearsonCorrelation` — strength banded from |r| alone above a floor of five pairs, no significance test — so five days of noise sloping the right way arrived at the reader as a "Strong" badge, with n and r behind an info icon. The guarded twin was written for exactly that failure and names it in its own comment, and the weight, blood-pressure and mood-status surfaces had already moved onto it. The mood page had not. Route the page's five cross-metric channels through `significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason a pairing was refused, so the card can distinguish "still building" from "twenty days and nothing in them" instead of showing the same blank for both. New copy in all seven locales. Add a structural guard that fails when a new caller of the unguarded helper appears outside a written allowlist. Two callers remain on it, each named as open work rather than as a blessing: the /insights envelope and the AI feature payload both still band at n >= 5, and moving them changes payload shapes their own tests pin.
This commit is contained in:
1 parent
214b268b41
commit
f72e236b8a
13 files changed
+416
-16
No files matched your search
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* What the mood correlation card is allowed to claim.
|
||||
*
|
||||
* The card used to paint a "Strong" badge from any coefficient it was handed,
|
||||
* as long as five days had been paired. The coefficient itself now clears the
|
||||
* significance bar before it leaves the server, so the card's job here is the
|
||||
* other half: when the bar refuses, say which bar and how far off the reader
|
||||
* is, rather than falling back to the same "not enough paired data" blank that
|
||||
* a reader with no data at all sees.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
} from "../mood-correlation-cards";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NOTHING: MoodMetricCorrelationData = {
|
||||
result: null,
|
||||
points: [],
|
||||
n: 0,
|
||||
suppressed: "insufficientPairs",
|
||||
};
|
||||
|
||||
/** The sleep card's own markup — the four siblings carry their own copy. */
|
||||
function renderSleep(sleep: MoodMetricCorrelationData) {
|
||||
const html = render(
|
||||
<MoodCorrelationCards
|
||||
sleep={sleep}
|
||||
steps={NOTHING}
|
||||
pulse={NOTHING}
|
||||
weight={NOTHING}
|
||||
bloodPressureSystolic={NOTHING}
|
||||
/>,
|
||||
);
|
||||
const start = html.indexOf('data-kind="sleep"');
|
||||
const end = html.indexOf('data-kind="steps"');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
describe("<MoodCorrelationCards>", () => {
|
||||
it("shows no strength badge for a refused correlation", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
expect(html).not.toContain("Strong");
|
||||
expect(html).not.toContain("Moderate");
|
||||
});
|
||||
|
||||
it("names how many paired days are still missing instead of a bare blank", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
// The reader is told where they stand, not only that something is absent.
|
||||
expect(html).toContain("5 of 20");
|
||||
});
|
||||
|
||||
it("keeps the plain empty copy when nothing has been paired at all", () => {
|
||||
const html = renderSleep(NOTHING);
|
||||
|
||||
expect(html).toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("0 of 20");
|
||||
});
|
||||
|
||||
it("says a twenty-day pairing showed no reliable link", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 20,
|
||||
suppressed: "notSignificant",
|
||||
});
|
||||
|
||||
expect(html).toContain("No clear link");
|
||||
expect(html).not.toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("Weak");
|
||||
});
|
||||
|
||||
it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => {
|
||||
const html = renderSleep({
|
||||
result: { r: 0.82, strength: "stark", n: 24 },
|
||||
points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 24,
|
||||
});
|
||||
|
||||
expect(html).toContain("Strong");
|
||||
expect(html).toContain("24 paired days");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
|
||||
* pulse) pair. The Pearson math comes pre-computed from
|
||||
* `/api/mood/insights` (single source of truth with the LLM snapshot).
|
||||
* Below-threshold pairs render an EmptyState rather than a misleading
|
||||
* near-zero scatter.
|
||||
* near-zero scatter — and the EmptyState names which threshold, because a
|
||||
* pairing that is still building and one that found nothing are different
|
||||
* answers and used to read as the same blank.
|
||||
*/
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
@@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData {
|
||||
result: CorrelationResult | null;
|
||||
points: MoodCorrelationPoint[];
|
||||
n: number;
|
||||
/** Which bar the pairing fell short of, when there is no coefficient. */
|
||||
suppressed?: "insufficientPairs" | "notSignificant";
|
||||
}
|
||||
|
||||
/**
|
||||
* The paired-day floor the server's significance gate enforces. Repeated
|
||||
* here only to tell the reader how far off they are; the decision itself is
|
||||
* the server's and arrives as `suppressed`.
|
||||
*/
|
||||
const MIN_PAIRED_DAYS = 20;
|
||||
|
||||
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
|
||||
|
||||
const TITLE_KEY: Record<MetricKind, string> = {
|
||||
@@ -95,7 +106,33 @@ function MoodCorrelationCard({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasResult = data.result != null && data.n >= 5;
|
||||
const hasResult = data.result != null;
|
||||
|
||||
// Three different silences, and the reader deserves to know which one they
|
||||
// are looking at: nothing paired yet, some days paired but not enough to
|
||||
// separate a pattern from chance, or enough days and no pattern in them.
|
||||
// The last one is a finding, not a gap, and used to render as the same
|
||||
// "not enough data" blank as the first.
|
||||
const emptyCopy =
|
||||
data.suppressed === "notSignificant"
|
||||
? {
|
||||
title: t("insights.mood.correlation.noLinkTitle"),
|
||||
description: t("insights.mood.correlation.noLinkDescription", {
|
||||
n: data.n,
|
||||
}),
|
||||
}
|
||||
: data.n > 0
|
||||
? {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.pendingDescription", {
|
||||
n: data.n,
|
||||
needed: MIN_PAIRED_DAYS,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.emptyDescription"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -168,8 +205,8 @@ function MoodCorrelationCard({
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<Activity className="size-5" />}
|
||||
title={t("insights.mood.correlation.emptyTitle")}
|
||||
description={t("insights.mood.correlation.emptyDescription")}
|
||||
title={emptyCopy.title}
|
||||
description={emptyCopy.description}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user