mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.",
|
||||
"noLinkTitle": "Kein klarer Zusammenhang",
|
||||
"noLinkDescription": "{n} gepaarte Tage, und nichts hebt sich vom Zufall ab.",
|
||||
"weightTitle": "Stimmung vs. Gewicht",
|
||||
"bloodPressureTitle": "Stimmung vs. Blutdruck",
|
||||
"weightAxis": "Gewicht",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "How this was computed",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
"pendingDescription": "{n} of {needed} paired days so far. A correlation appears once there are enough days to tell a pattern from chance.",
|
||||
"noLinkTitle": "No clear link",
|
||||
"noLinkDescription": "{n} paired days, and nothing stands out beyond what chance would produce.",
|
||||
"weightTitle": "Mood vs. weight",
|
||||
"bloodPressureTitle": "Mood vs. blood pressure",
|
||||
"weightAxis": "Weight",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.",
|
||||
"noLinkTitle": "Sin relación clara",
|
||||
"noLinkDescription": "{n} días emparejados, y nada destaca más allá de lo que produciría el azar.",
|
||||
"weightTitle": "Ánimo vs. peso",
|
||||
"bloodPressureTitle": "Ánimo vs. presión arterial",
|
||||
"weightAxis": "Peso",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.",
|
||||
"noLinkTitle": "Aucun lien net",
|
||||
"noLinkDescription": "{n} jours appariés, et rien ne se démarque de ce que le hasard produirait.",
|
||||
"weightTitle": "Humeur vs. poids",
|
||||
"bloodPressureTitle": "Humeur vs. tension artérielle",
|
||||
"weightAxis": "Poids",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.",
|
||||
"noLinkTitle": "Nessun legame chiaro",
|
||||
"noLinkDescription": "{n} giorni appaiati, e nulla emerge oltre a quanto produrrebbe il caso.",
|
||||
"weightTitle": "Umore vs. peso",
|
||||
"bloodPressureTitle": "Umore vs. pressione sanguigna",
|
||||
"weightAxis": "Peso",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "계산 방법",
|
||||
"emptyTitle": "아직 짝지을 데이터가 부족해요",
|
||||
"emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.",
|
||||
"pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.",
|
||||
"noLinkTitle": "뚜렷한 관계 없음",
|
||||
"noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.",
|
||||
"weightTitle": "기분 대 체중",
|
||||
"bloodPressureTitle": "기분 대 혈압",
|
||||
"weightAxis": "체중",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.",
|
||||
"noLinkTitle": "Brak wyraźnego związku",
|
||||
"noLinkDescription": "{n} sparowanych dni i nic nie wykracza poza to, co dałby przypadek.",
|
||||
"weightTitle": "Nastrój vs. waga",
|
||||
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
|
||||
"weightAxis": "Waga",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Structural guard on the unguarded Pearson helper.
|
||||
*
|
||||
* `pearsonCorrelation` bands a coefficient from |r| alone above a floor of
|
||||
* five pairs, with no significance test. Its guarded twin
|
||||
* `significantPearsonCorrelation` sits directly beneath it in the same file
|
||||
* and exists because of exactly one failure: five days of noise sloping the
|
||||
* right way reaching a reader labelled "strong". The comment above the twin
|
||||
* says so in as many words.
|
||||
*
|
||||
* The twin was written once and adopted one surface at a time, which is how
|
||||
* the mood page kept the naive call for four minor lines after the surfaces
|
||||
* either side of it had moved. Nothing failed when it was skipped, because
|
||||
* nothing was watching. This is what watches: a new caller of the unguarded
|
||||
* helper has to be added to the list below, in a diff a reviewer reads, next
|
||||
* to a written reason.
|
||||
*
|
||||
* It is a tripwire, not a proof. It cannot say the remaining entries are
|
||||
* right — only that the set has not grown without someone editing this file.
|
||||
* The two entries it carries today are named as open work, not as blessings.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* Files that may still call `pearsonCorrelation` directly, and why.
|
||||
*
|
||||
* Neither entry is settled. Both are the same migration the user-facing
|
||||
* surfaces already made, still owed by two payload builders whose consumers
|
||||
* pin the n >= 5 shape; moving them changes what those payloads contain and
|
||||
* belongs in its own change with its own tests, not smuggled into this one.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Five correlations on the /insights envelope (weight x BP, mood x BP, " +
|
||||
"mood x weight, mood x pulse, BP-medication continuity). Open: they " +
|
||||
"carry the same small-n exposure the mood page had, and the page's " +
|
||||
"cards read `n` straight off the envelope.",
|
||||
"lib/insights/features.ts":
|
||||
"Eight cross-metric coefficients on the AI feature payload. Open: the " +
|
||||
"prompt reads them as findings, so the same significance bar applies; " +
|
||||
"the snapshot shape and its budget tests move with it.",
|
||||
};
|
||||
|
||||
/** Every non-test, non-generated source file under `src/`. */
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* A call to the unguarded helper. Whitespace-tolerant and anchored on a word
|
||||
* boundary so `significantPearsonCorrelation(` cannot match: a matcher that
|
||||
* quietly matches nothing is the failure mode this repository has shipped
|
||||
* before, so the count is asserted below as well.
|
||||
*/
|
||||
const CALL = /(?<![A-Za-z])pearsonCorrelation\s*\(/;
|
||||
|
||||
describe("the unguarded Pearson helper has a closed caller set", () => {
|
||||
it("finds callers at all, so an empty result cannot read as a pass", () => {
|
||||
const definition = read("lib/analytics/correlations.ts");
|
||||
expect(definition).toMatch(/export function pearsonCorrelation\s*\(/);
|
||||
expect(
|
||||
definition,
|
||||
"the guarded twin must exist for this guard to mean anything",
|
||||
).toMatch(/export function significantPearsonCorrelation\s*\(/);
|
||||
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
expect(callers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("names every caller outside the allowlist", () => {
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
|
||||
expect(
|
||||
callers.filter((rel) => !(rel in ALLOWED)),
|
||||
"A new caller of the unguarded `pearsonCorrelation`. A surface a " +
|
||||
"person reads must use `significantPearsonCorrelation` (n >= 20 " +
|
||||
"AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " +
|
||||
"in this file with the reason, so the exception is reviewed rather " +
|
||||
"than assumed.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the allowlist honest by refusing stale entries", () => {
|
||||
const callers = new Set(
|
||||
sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
),
|
||||
);
|
||||
|
||||
// An entry whose file no longer calls the helper is a licence nobody
|
||||
// needs any more; leaving it standing is how the next one gets waved in.
|
||||
expect(
|
||||
Object.keys(ALLOWED).filter((rel) => !callers.has(rel)),
|
||||
"this file is on the allowlist but no longer calls the unguarded " +
|
||||
"helper — drop the entry",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => {
|
||||
|
||||
describe("computeMoodMetricCorrelation", () => {
|
||||
it("pairs on dayOffset and returns scatter points + a coefficient", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
{ dayOffset: 1, value: 4 },
|
||||
{ dayOffset: 2, value: 3 },
|
||||
{ dayOffset: 3, value: 2 },
|
||||
{ dayOffset: 4, value: 1 },
|
||||
];
|
||||
// Twenty days, not five. This test used to run on five and assert a
|
||||
// coefficient came back, which pinned the very thing the significance
|
||||
// gate exists to refuse: five points that happen to line up are not a
|
||||
// finding. The pairing and the scatter projection are what it is asking
|
||||
// about, and they are unchanged — it just has to ask above the bar.
|
||||
const moodDaily = Array.from({ length: 20 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - (i % 5),
|
||||
}));
|
||||
// Perfectly positively correlated metric.
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.points).toHaveLength(5);
|
||||
expect(corr.n).toBe(20);
|
||||
expect(corr.points).toHaveLength(20);
|
||||
expect(corr.result?.r).toBe(1);
|
||||
expect(corr.suppressed).toBeUndefined();
|
||||
expect(corr.points[0]).toEqual({ x: 5, y: 50 });
|
||||
});
|
||||
|
||||
it("refuses the same shape at five days", () => {
|
||||
const moodDaily = Array.from({ length: 5 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - i,
|
||||
}));
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.result).toBeNull();
|
||||
expect(corr.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("returns a null coefficient below the minimum pair count", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The mood page's cross-metric correlations clear the same bar as every
|
||||
* other user-facing correlation surface.
|
||||
*
|
||||
* `significantPearsonCorrelation` was written for exactly this failure — its
|
||||
* own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the
|
||||
* weight, blood-pressure and mood-status surfaces were moved onto it. The
|
||||
* mood page stayed on the plain `pearsonCorrelation` with its floor of five
|
||||
* pairs and no significance test, so the same five points that the other
|
||||
* surfaces refuse still painted a "strong" badge here.
|
||||
*
|
||||
* These assertions are about what the reader is told, not about which helper
|
||||
* is called: below the bar there is no coefficient to show, and the card is
|
||||
* handed a reason it can name instead of a blank.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeMoodMetricCorrelation,
|
||||
type DailyPoint,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
/** One point per day-offset, values taken from the list in order. */
|
||||
function series(values: number[]): DailyPoint[] {
|
||||
return values.map((value, i) => ({ dayOffset: i, value }));
|
||||
}
|
||||
|
||||
describe("computeMoodMetricCorrelation — significance gate", () => {
|
||||
it("refuses a five-day strong-looking slope and says the pairs are too few", () => {
|
||||
const mood = series([1, 2, 3, 4, 5]);
|
||||
const metric = series([1, 2, 2, 4, 3]);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(5);
|
||||
// No coefficient reaches the card, so no strength band can be painted.
|
||||
expect(out.result).toBeNull();
|
||||
// ... and the card is told WHY, so it can say so rather than showing an
|
||||
// unexplained blank.
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("refuses twenty noisy days as not significant rather than as too few", () => {
|
||||
const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3];
|
||||
const mood = series(ys.map((_, i) => i));
|
||||
const metric = series(ys);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(20);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("notSignificant");
|
||||
});
|
||||
|
||||
it("surfaces twenty days of a real relationship", () => {
|
||||
const mood = series(Array.from({ length: 20 }, (_, i) => i));
|
||||
const metric = series(
|
||||
Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)),
|
||||
);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.suppressed).toBeUndefined();
|
||||
expect(out.result).not.toBeNull();
|
||||
expect(out.result!.n).toBe(20);
|
||||
expect(out.result!.strength).toBe("stark");
|
||||
});
|
||||
|
||||
it("reports no overlap as too few pairs, not as an insignificant finding", () => {
|
||||
const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW);
|
||||
|
||||
expect(out.n).toBe(0);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,11 @@
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
pearsonCorrelation,
|
||||
significantPearsonCorrelation,
|
||||
type CorrelationResult,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
dayOffsetToBerlinDayKey,
|
||||
@@ -384,11 +385,20 @@ export interface CorrelationScatterPoint {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/** Why a pairing produced no coefficient. */
|
||||
export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant";
|
||||
|
||||
export interface MoodMetricCorrelation {
|
||||
result: CorrelationResult | null;
|
||||
/** Mood (x) vs metric (y) scatter points, paired by day. */
|
||||
points: CorrelationScatterPoint[];
|
||||
n: number;
|
||||
/**
|
||||
* Which bar the pairing fell short of, so the card can name it rather
|
||||
* than showing a blank that reads the same whether nothing was logged or
|
||||
* a full year of days simply did not line up. Absent when `result` is set.
|
||||
*/
|
||||
suppressed?: MoodCorrelationSuppression;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,6 +406,14 @@ export interface MoodMetricCorrelation {
|
||||
* series, paired on `dayOffset`. Returns the scatter points so the
|
||||
* caller can paint a `<ScatterCorrelationChart>` from the same pairing
|
||||
* the coefficient was computed over.
|
||||
*
|
||||
* This routes through `significantPearsonCorrelation` — n >= 20 AND
|
||||
* p < 0.05 — for the same reason the weight, blood-pressure and
|
||||
* mood-status surfaces do: the plain `pearsonCorrelation` bands from |r|
|
||||
* alone above a floor of five pairs, so five days of noise sloping the
|
||||
* right way arrive at the reader labelled "strong". A mood page is where
|
||||
* that costs the most, because the reader is looking for something to
|
||||
* change about their week.
|
||||
*/
|
||||
export function computeMoodMetricCorrelation(
|
||||
moodDaily: DailyPoint[],
|
||||
@@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation(
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
): MoodMetricCorrelation {
|
||||
const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz);
|
||||
const result = pearsonCorrelation(pairs);
|
||||
const points = pairs.map((p) => ({ x: p.a, y: p.b }));
|
||||
return { result, points, n: pairs.length };
|
||||
const result = significantPearsonCorrelation(pairs);
|
||||
if (result) return { result, points, n: pairs.length };
|
||||
return {
|
||||
result: null,
|
||||
points,
|
||||
n: pairs.length,
|
||||
suppressed:
|
||||
pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Orchestrated aggregate shape ---
|
||||
|
||||
Reference in new issue
Block a user