feat(insights): TrendAnnotation + TrendsRow components

Add two presentational pieces for the Insights redesign:

- `<TrendAnnotation>` — pure renderer for the AI-authored
  one-sentence annotation that sits below each Trends-row chart.
  Empty state is locale-aware ("Awaiting more data to annotate
  this trend"). Optional confidence chip surfaces a discrete
  low / moderate / high band.
- `<TrendsRow>` — 3-up responsive grid (1 column < md, 3 columns
  >= md) wrapping BP / Weight / Mood mini charts with their
  annotations. Recharts ships through the existing `next/dynamic`
  defer-load pattern so the bundle savings stay intact.

Adds the EN + DE i18n keys (trendsRow, trendAnnotation,
correlationRow). 15 unit tests cover the locale switch, the
empty-state hint per metric, the confidence chip variants, and
the row layout chrome.

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:47:08 +02:00
1 parent f9afaf77a0
commit 2ae69d7e64
6 files changed
+489

No files matched your search

@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TrendAnnotation } from "../trend-annotation";
/**
* v1.4.20 phase B3 — single-sentence trend annotation under each
* Trends-row chart.
*
* Tests cover:
* 1. Rendered annotation prose (load-bearing slot for parents).
* 2. Empty-state hint per metric (locale-aware).
* 3. Optional confidence chip with three discrete bands.
* 4. Locale switch between EN and DE.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<TrendAnnotation>", () => {
it("renders the annotation prose when supplied", () => {
const html = render(
<TrendAnnotation
metric="bp"
annotation="Systolic trending down — a pattern worth watching."
/>,
);
expect(html).toMatch(/data-slot="trend-annotation"/);
expect(html).toContain("Systolic trending down");
});
it("uses the empty-state hint when annotation is null", () => {
const html = render(<TrendAnnotation metric="bp" annotation={null} />);
expect(html).toMatch(/data-slot="trend-annotation-empty"/);
expect(html).toContain("Awaiting more data");
});
it("renders the empty hint in German when locale=de", () => {
const html = render(<TrendAnnotation metric="weight" annotation={null} />, "de");
expect(html).toContain("Mehr Daten");
});
it("renders the high-confidence chip when supplied", () => {
const html = render(
<TrendAnnotation
metric="weight"
annotation="Weight down 1.4 kg over 30 days."
confidence="high"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-confidence"/);
expect(html).toContain("High confidence");
});
it("renders the moderate-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="moderate" />,
);
expect(html).toContain("Moderate confidence");
});
it("renders the low-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="low" />,
);
expect(html).toContain("Low confidence");
});
it("does not render the chip when confidence is omitted", () => {
const html = render(<TrendAnnotation metric="bp" annotation="x" />);
expect(html).not.toMatch(/data-slot="trend-annotation-confidence"/);
});
it("tags the wrapper element with the metric for parent layouts", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." />,
);
expect(html).toMatch(/data-metric="mood"/);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.20 phase B3 — `<TrendsRow>` mounts three small charts (BP /
* weight / mood) and an annotation under each.
*
* Recharts is dynamic-imported behind `next/dynamic`, so SSR
* snapshots show the loading skeleton — that's still enough to verify
* the row's layout chrome (3-up grid wrapper + per-metric annotation
* slots).
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = ({ title }: { title?: string }) => (
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
);
Stub.displayName = "TrendsRowChartStub";
return Stub;
},
}));
import { TrendsRow } from "../trends-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<TrendsRow>", () => {
it("renders the row title in English", () => {
const html = render(<TrendsRow />);
expect(html).toMatch(/data-slot="trends-row"/);
expect(html).toContain("Trends");
});
it("renders the row title in German", () => {
const html = render(<TrendsRow />, "de");
expect(html).toContain("Trends");
});
it("mounts a card per metric (BP / weight / mood)", () => {
const html = render(<TrendsRow />);
expect(html).toMatch(/data-metric="bp"/);
expect(html).toMatch(/data-metric="weight"/);
expect(html).toMatch(/data-metric="mood"/);
});
it("renders a 3-up grid layout", () => {
const html = render(<TrendsRow />);
expect(html).toMatch(/md:grid-cols-3/);
expect(html).toMatch(/grid-cols-1/);
});
it("renders annotations when supplied", () => {
const html = render(
<TrendsRow
annotations={{
bp: "BP trending down — a pattern worth watching.",
weight: "Weight down 1.4 kg over 30 days.",
mood: "Mood stable, scoring 4 of 5 most days.",
}}
/>,
);
expect(html).toContain("BP trending down");
expect(html).toContain("Weight down 1.4 kg");
expect(html).toContain("Mood stable");
});
it("renders the empty-state hint when annotations are absent", () => {
const html = render(<TrendsRow />);
expect(html).toContain("Awaiting more data");
// All three metric slots show the hint when nothing is supplied.
const matches = html.match(/Awaiting more data/g) ?? [];
expect(matches.length).toBe(3);
});
it("propagates per-metric confidence chips", () => {
const html = render(
<TrendsRow
annotations={{
bp: "BP trending down.",
weight: "Weight steady.",
mood: "Mood stable.",
}}
confidence={{ bp: "high", weight: "moderate", mood: "low" }}
/>,
);
expect(html).toContain("High confidence");
expect(html).toContain("Moderate confidence");
expect(html).toContain("Low confidence");
});
});
@@ -0,0 +1,97 @@
"use client";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
* below a Trends-row chart.
*
* Pure presentational. The annotation string is sourced from the AI
* advisor payload's `trendAnnotations.{bp,weight,mood}` block (see
* `src/lib/ai/schema.ts`). When the model omits the field, the parent
* passes `annotation={null}` and we render an empty-state hint.
*
* Confidence band is optional — surfaced as a small `Badge` chip when
* a backing correlation gives us one. The chip is purely visual and
* never adds new copy beyond the `low / moderate / high` translation.
*/
export type TrendAnnotationConfidenceBand = "low" | "moderate" | "high";
interface TrendAnnotationProps {
/** The metric this annotation describes. Drives the empty-state copy. */
metric: "bp" | "weight" | "mood";
/** AI-authored sentence. `null` renders the empty-state hint. */
annotation: string | null;
/** Optional discrete confidence band. */
confidence?: TrendAnnotationConfidenceBand;
}
const CONFIDENCE_BADGE_CLASSNAME: Record<
TrendAnnotationConfidenceBand,
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<TrendAnnotationConfidenceBand, string> = {
high: "insights.trendAnnotation.confidenceHigh",
moderate: "insights.trendAnnotation.confidenceModerate",
low: "insights.trendAnnotation.confidenceLow",
};
const EMPTY_KEY: Record<TrendAnnotationProps["metric"], string> = {
bp: "insights.trendAnnotation.emptyBp",
weight: "insights.trendAnnotation.emptyWeight",
mood: "insights.trendAnnotation.emptyMood",
};
export function TrendAnnotation({
metric,
annotation,
confidence,
}: TrendAnnotationProps) {
const { t } = useTranslations();
if (!annotation) {
return (
<p
data-slot="trend-annotation-empty"
data-metric={metric}
className="text-muted-foreground text-xs italic"
>
{t(EMPTY_KEY[metric])}
</p>
);
}
return (
<div
data-slot="trend-annotation"
data-metric={metric}
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<Sparkles
className="text-dracula-purple mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-foreground text-xs leading-snug">{annotation}</p>
{confidence ? (
<Badge
data-slot="trend-annotation-confidence"
variant="outline"
className={cn("text-[10px]", CONFIDENCE_BADGE_CLASSNAME[confidence])}
>
{t(CONFIDENCE_LABEL_KEY[confidence])}
</Badge>
) : null}
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
} from "./trend-annotation";
/**
* v1.4.20 phase B3 — Trends row.
*
* Renders three small charts (BP / Weight / Mood) above a one-sentence
* AI annotation each. Recharts is ~108 KiB Brotli, so we mirror the
* `<ScatterCorrelationChart>` defer-load pattern from `/insights` for
* the two HealthChart-backed cards. The MoodChart already has its own
* fetch wired so it composes naturally.
*
* Layout:
* - `<md`: single column, full width
* - `>=md`: 3-up grid, equal column tracks
*
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the AI
* advisor payload. When a metric's annotation is null, the
* `<TrendAnnotation>` empty state hints at the gap without breaking
* the row's visual rhythm.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
),
},
);
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
),
},
);
interface TrendsRowProps {
/**
* AI-authored one-sentence annotations, keyed by metric. Optional —
* legacy advisor payloads (pre-PROMPT_VERSION 4.20.1) won't carry
* the field.
*/
annotations?: {
bp?: string | null;
weight?: string | null;
mood?: string | null;
} | null;
/**
* Optional per-metric confidence bands surfaced as small chips below
* each annotation. When omitted, the annotation renders without a
* chip.
*/
confidence?: {
bp?: TrendAnnotationConfidenceBand;
weight?: TrendAnnotationConfidenceBand;
mood?: TrendAnnotationConfidenceBand;
};
}
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
const { t } = useTranslations();
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
return (
<section
data-slot="trends-row"
aria-label={t("insights.trendsRow.title")}
className="space-y-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold">
{t("insights.trendsRow.title")}
</h2>
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div data-slot="trends-row-card" data-metric="bp" className="space-y-2">
<HealthChart
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
mini
/>
<TrendAnnotation
metric="bp"
annotation={bpAnnotation}
confidence={confidence?.bp}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="weight"
className="space-y-2"
>
<HealthChart
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
mini
/>
<TrendAnnotation
metric="weight"
annotation={weightAnnotation}
confidence={confidence?.weight}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="mood"
className="space-y-2"
>
<MoodChart title={t("charts.mood")} mini />
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
confidence={confidence?.mood}
/>
</div>
</div>
</section>
);
}