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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:49:00 +02:00
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>
);
}