diff --git a/src/components/insights/__tests__/correlation-card.test.tsx b/src/components/insights/__tests__/correlation-card.test.tsx new file mode 100644 index 000000000..657b48cec --- /dev/null +++ b/src/components/insights/__tests__/correlation-card.test.tsx @@ -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 — `` 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 = () =>
scatter
; + Stub.displayName = "ScatterStub"; + return Stub; + }, +})); + +import { CorrelationCard } from "../correlation-card"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +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("", () => { + it("renders title + subtitle + interpretation when status=ok", () => { + const html = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(html).toMatch(/data-kind="bp-compliance"/); + }); + + it("renders German copy when locale=de", () => { + const html = render(, "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(); + // Banned phrases per the conservative-phrasing convention. + expect(html).not.toMatch(/causes?/i); + expect(html).not.toMatch(/is responsible for/i); + }); +}); diff --git a/src/components/insights/__tests__/correlation-row.test.tsx b/src/components/insights/__tests__/correlation-row.test.tsx new file mode 100644 index 000000000..2c910b306 --- /dev/null +++ b/src/components/insights/__tests__/correlation-row.test.tsx @@ -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 — `` 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 + * `` (smoke-checked via the per-card slot). + */ + +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
scatter
; + Stub.displayName = "ScatterStub"; + return Stub; + }, +})); + +import { CorrelationRow } from "../correlation-row"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +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("", () => { + it("renders all three hypothesis cards", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toMatch(/grid-cols-1/); + expect(html).toMatch(/md:grid-cols-2/); + }); + + it("renders German copy when locale=de", () => { + const html = render( + , + "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( + , + ); + const matches = html.match(/Need more data to see this pattern/g) ?? []; + expect(matches.length).toBe(3); + }); +}); diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx new file mode 100644 index 000000000..5092b2f8d --- /dev/null +++ b/src/components/insights/correlation-card.tsx @@ -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 + * `` 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: () => ( +
+ ), + }, +); + +interface CorrelationCardProps { + /** Correlation runner output. `kind` drives title + subtitle copy. */ + result: CorrelationResult; +} + +const TONE_BAR_CLASSNAME: Record = { + "bp-compliance": "bg-dracula-pink", + "mood-pulse": "bg-dracula-cyan", + "weight-weekday": "bg-dracula-purple", +}; + +const TITLE_KEY: Record = { + "bp-compliance": "insights.correlationRow.card.bpComplianceTitle", + "mood-pulse": "insights.correlationRow.card.moodPulseTitle", + "weight-weekday": "insights.correlationRow.card.weightWeekdayTitle", +}; + +const SUBTITLE_KEY: Record = { + "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 ( + + + ); +} + +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)"; + } +} diff --git a/src/components/insights/correlation-row.tsx b/src/components/insights/correlation-row.tsx new file mode 100644 index 000000000..d651fe255 --- /dev/null +++ b/src/components/insights/correlation-row.tsx @@ -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 `` 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 ( +
+
+

+ {t("insights.correlationRow.title")} +

+

+ {t("insights.correlationRow.subtitle")} +

+
+
+ + + +
+

+ {t("insights.correlationRow.disclaimer")} +

+
+ ); +}