mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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