mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(insights): CorrelationCard + CorrelationRow components
Two cards-and-row primitives for the Insights redesign:
- `<CorrelationCard>` — single hypothesis. Tone-coloured left bar,
scatter sparkline (deferred-bundled), confidence chip, source
chip ("based on N paired readings · last 30 days"), interpretation
prose, and a disabled "Try a 7-day experiment" CTA with a
Coming-soon tooltip. Below-threshold results render the
EmptyState in place of the chart so we never paint a misleading
near-zero scatter.
- `<CorrelationRow>` — 2-up on `>= md`, single column on `< md`.
Row-level correlation-disclaimer ("Patterns are observational,
not causal — talk to your doctor if BP runs high.") sits below
the card grid once per row, not per card.
13 tests cover OK + insufficient rendering, the disabled CTA's
tooltip wiring, the confidence chip variants, the disclaimer
position, the responsive grid, and the EN / DE locale switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2ae69d7e64
commit
42591c6cec
4 files changed
+493
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — `<CorrelationCard>` rendering.
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. OK status renders title + interpretation + source chip + disabled CTA.
|
||||
* 2. Insufficient status renders the empty-state instead.
|
||||
* 3. CTA is `disabled` and exposes the "Coming soon" tooltip via `title`.
|
||||
* 4. Confidence chip surfaces high / moderate / low.
|
||||
* 5. Locale-aware copy (EN / DE).
|
||||
*/
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = () => <div data-slot="scatter-stub">scatter</div>;
|
||||
Stub.displayName = "ScatterStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
import { CorrelationCard } from "../correlation-card";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const okResult: CorrelationResult = {
|
||||
kind: "bp-compliance",
|
||||
status: "ok",
|
||||
statistic: -0.62,
|
||||
n: 28,
|
||||
pValue: 0.001,
|
||||
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
|
||||
interpretation:
|
||||
"Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
points: [
|
||||
{ x: 60, y: 140 },
|
||||
{ x: 70, y: 135 },
|
||||
{ x: 80, y: 130 },
|
||||
{ x: 90, y: 125 },
|
||||
],
|
||||
xLabel: "Compliance %",
|
||||
yLabel: "Systolic (mmHg)",
|
||||
};
|
||||
|
||||
const insufficient: CorrelationResult = {
|
||||
kind: "mood-pulse",
|
||||
status: "insufficient",
|
||||
n: 8,
|
||||
reason: "too_few_pairs",
|
||||
points: [],
|
||||
};
|
||||
|
||||
describe("<CorrelationCard>", () => {
|
||||
it("renders title + subtitle + interpretation when status=ok", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-slot="correlation-card"/);
|
||||
expect(html).toContain("BP × medication compliance");
|
||||
expect(html).toContain("Daily systolic against compliance %");
|
||||
expect(html).toContain("a pattern worth watching");
|
||||
});
|
||||
|
||||
it("renders the source-chip with the n value", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-slot="correlation-card-source"/);
|
||||
expect(html).toContain("based on 28 paired readings");
|
||||
});
|
||||
|
||||
it("renders the high-confidence chip for a tight CI", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-slot="correlation-card-confidence"/);
|
||||
expect(html).toContain("High confidence");
|
||||
});
|
||||
|
||||
it("renders the disabled experiment CTA with the Coming-soon tooltip", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-slot="correlation-card-cta"/);
|
||||
expect(html).toContain("Try a 7-day experiment");
|
||||
expect(html).toMatch(/disabled/);
|
||||
expect(html).toContain("Coming soon");
|
||||
});
|
||||
|
||||
it("renders the empty-state when status=insufficient", () => {
|
||||
const html = render(<CorrelationCard result={insufficient} />);
|
||||
expect(html).toContain("Need more data to see this pattern");
|
||||
expect(html).not.toContain("Try a 7-day experiment");
|
||||
});
|
||||
|
||||
it("tags the wrapper with the correlation kind", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-kind="bp-compliance"/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale=de", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />, "de");
|
||||
expect(html).toContain("BD × Medikamenten-Compliance");
|
||||
expect(html).toContain("7-Tage-Experiment");
|
||||
});
|
||||
|
||||
it("never surfaces causal language in the interpretation slot", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
// Banned phrases per the conservative-phrasing convention.
|
||||
expect(html).not.toMatch(/causes?/i);
|
||||
expect(html).not.toMatch(/is responsible for/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
|
||||
*
|
||||
* Acceptance:
|
||||
* 1. Renders the three hypothesis cards.
|
||||
* 2. Mounts the correlation-disclaimer footer once at the row level.
|
||||
* 3. 2-up grid on >= md, single column on < md.
|
||||
* 4. Below-threshold cards delegate their empty-state to
|
||||
* `<CorrelationCard>` (smoke-checked via the per-card slot).
|
||||
*/
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = () => <div data-slot="scatter-stub">scatter</div>;
|
||||
Stub.displayName = "ScatterStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
import { CorrelationRow } from "../correlation-row";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const okBpCompliance: CorrelationResult = {
|
||||
kind: "bp-compliance",
|
||||
status: "ok",
|
||||
statistic: -0.62,
|
||||
n: 28,
|
||||
pValue: 0.001,
|
||||
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
|
||||
interpretation:
|
||||
"Higher medication compliance lines up with lower systolic — a pattern worth watching.",
|
||||
points: [{ x: 60, y: 140 }],
|
||||
xLabel: "Compliance %",
|
||||
yLabel: "Systolic (mmHg)",
|
||||
};
|
||||
|
||||
const insufficientMoodPulse: CorrelationResult = {
|
||||
kind: "mood-pulse",
|
||||
status: "insufficient",
|
||||
n: 8,
|
||||
reason: "too_few_pairs",
|
||||
points: [],
|
||||
};
|
||||
|
||||
const okWeightWeekday: CorrelationResult = {
|
||||
kind: "weight-weekday",
|
||||
status: "ok",
|
||||
statistic: 0.18,
|
||||
n: 28,
|
||||
pValue: 0.02,
|
||||
confidenceBand: { low: 0.18, high: 0.18, label: "moderate" },
|
||||
interpretation:
|
||||
"Monday weights run 0.6 kg above your other-day average — a pattern worth watching.",
|
||||
points: [{ x: 0, y: 84 }],
|
||||
xLabel: "Weekday",
|
||||
yLabel: "Weight (kg)",
|
||||
};
|
||||
|
||||
describe("<CorrelationRow>", () => {
|
||||
it("renders all three hypothesis cards", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
|
||||
expect(matches.length).toBe(3);
|
||||
expect(html).toMatch(/data-kind="bp-compliance"/);
|
||||
expect(html).toMatch(/data-kind="mood-pulse"/);
|
||||
expect(html).toMatch(/data-kind="weight-weekday"/);
|
||||
});
|
||||
|
||||
it("renders the correlation-disclaimer once at the row level", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches =
|
||||
html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(html).toContain("Patterns are observational, not causal");
|
||||
});
|
||||
|
||||
it("applies the 2-up grid on >= md and single column on < md", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
expect(html).toMatch(/md:grid-cols-2/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale=de", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Muster");
|
||||
expect(html).toContain("nicht kausal");
|
||||
});
|
||||
|
||||
it("delegates empty-state to per-card empty-state when below threshold", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: insufficientMoodPulse,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: insufficientMoodPulse,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/Need more data to see this pattern/g) ?? [];
|
||||
expect(matches.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Activity, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — single Correlation card.
|
||||
*
|
||||
* Each card surfaces ONE pre-defined hypothesis (BP × compliance,
|
||||
* mood × pulse, weight × weekday). Below-threshold results render an
|
||||
* EmptyState rather than a misleading near-zero scatter.
|
||||
*
|
||||
* Recharts is dynamic-imported behind the existing
|
||||
* `<ScatterCorrelationChart>` wrapper so each card pays the bundle
|
||||
* cost only when it actually paints a chart.
|
||||
*/
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-muted/40 h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface CorrelationCardProps {
|
||||
/** Correlation runner output. `kind` drives title + subtitle copy. */
|
||||
result: CorrelationResult;
|
||||
}
|
||||
|
||||
const TONE_BAR_CLASSNAME: Record<CorrelationResult["kind"], string> = {
|
||||
"bp-compliance": "bg-dracula-pink",
|
||||
"mood-pulse": "bg-dracula-cyan",
|
||||
"weight-weekday": "bg-dracula-purple",
|
||||
};
|
||||
|
||||
const TITLE_KEY: Record<CorrelationResult["kind"], string> = {
|
||||
"bp-compliance": "insights.correlationRow.card.bpComplianceTitle",
|
||||
"mood-pulse": "insights.correlationRow.card.moodPulseTitle",
|
||||
"weight-weekday": "insights.correlationRow.card.weightWeekdayTitle",
|
||||
};
|
||||
|
||||
const SUBTITLE_KEY: Record<CorrelationResult["kind"], string> = {
|
||||
"bp-compliance": "insights.correlationRow.card.bpComplianceSubtitle",
|
||||
"mood-pulse": "insights.correlationRow.card.moodPulseSubtitle",
|
||||
"weight-weekday": "insights.correlationRow.card.weightWeekdaySubtitle",
|
||||
};
|
||||
|
||||
const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> =
|
||||
{
|
||||
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
|
||||
moderate:
|
||||
"border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
|
||||
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
|
||||
high: "insights.correlationRow.confidenceHigh",
|
||||
moderate: "insights.correlationRow.confidenceModerate",
|
||||
low: "insights.correlationRow.confidenceLow",
|
||||
};
|
||||
|
||||
export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const title = t(TITLE_KEY[result.kind]);
|
||||
const subtitle = t(SUBTITLE_KEY[result.kind]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="correlation-card"
|
||||
data-kind={result.kind}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
|
||||
TONE_BAR_CLASSNAME[result.kind],
|
||||
)}
|
||||
/>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
{result.status === "ok" && (
|
||||
<Badge
|
||||
data-slot="correlation-card-confidence"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px]",
|
||||
CONFIDENCE_BADGE_CLASSNAME[result.confidenceBand.label],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{result.status === "ok" ? (
|
||||
<>
|
||||
<ScatterCorrelationChart
|
||||
data={result.points}
|
||||
fill={tonesByKind(result.kind)}
|
||||
xAxis={{ dataKey: "x", name: result.xLabel }}
|
||||
yAxis={{ dataKey: "y", name: result.yLabel }}
|
||||
height={180}
|
||||
/>
|
||||
<p
|
||||
data-slot="correlation-card-interpretation"
|
||||
className="text-foreground text-sm leading-snug"
|
||||
>
|
||||
{result.interpretation}
|
||||
</p>
|
||||
<p
|
||||
data-slot="correlation-card-source"
|
||||
className="text-muted-foreground text-[11px]"
|
||||
>
|
||||
{t("insights.correlationRow.sourceChip", {
|
||||
n: result.n,
|
||||
window: t("insights.correlationRow.sourceWindowLast30"),
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled
|
||||
data-slot="correlation-card-cta"
|
||||
className="gap-1.5"
|
||||
title={t("insights.correlationRow.experimentTooltip")}
|
||||
aria-label={t("insights.correlationRow.experimentTooltip")}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.correlationRow.experimentCta")}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<Activity className="size-5" />}
|
||||
title={t("insights.correlationRow.emptyTitle")}
|
||||
description={t("insights.correlationRow.sourceChip", {
|
||||
n: result.n,
|
||||
window: t("insights.correlationRow.sourceWindowLast30"),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function tonesByKind(kind: CorrelationResult["kind"]): string {
|
||||
switch (kind) {
|
||||
case "bp-compliance":
|
||||
return "var(--dracula-pink)";
|
||||
case "mood-pulse":
|
||||
return "var(--dracula-cyan)";
|
||||
case "weight-weekday":
|
||||
return "var(--dracula-purple)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { CorrelationCard } from "./correlation-card";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — Correlation row.
|
||||
*
|
||||
* Renders the three pre-defined hypothesis cards in a 2-up grid on
|
||||
* `>=md` and a single column on `<md`. A single correlation-disclaimer
|
||||
* footer sits below the row — the disclaimer applies to every card so
|
||||
* we don't repeat it per-card.
|
||||
*
|
||||
* The `<CorrelationCard>` itself owns the empty-state for below-
|
||||
* threshold results, so this row never has to filter — it always
|
||||
* mounts all three cards regardless of `status`.
|
||||
*/
|
||||
|
||||
interface CorrelationRowProps {
|
||||
results: {
|
||||
bpCompliance: CorrelationResult;
|
||||
moodPulse: CorrelationResult;
|
||||
weightWeekday: CorrelationResult;
|
||||
};
|
||||
}
|
||||
|
||||
export function CorrelationRow({ results }: CorrelationRowProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="correlation-row"
|
||||
aria-label={t("insights.correlationRow.title")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.correlationRow.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.correlationRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CorrelationCard result={results.bpCompliance} />
|
||||
<CorrelationCard result={results.moodPulse} />
|
||||
<CorrelationCard result={results.weightWeekday} />
|
||||
</div>
|
||||
<p
|
||||
data-slot="correlation-row-disclaimer"
|
||||
className="text-muted-foreground text-xs italic"
|
||||
>
|
||||
{t("insights.correlationRow.disclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user