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

+33
View File
@@ -776,6 +776,39 @@
"emptyAction": "Briefing erstellen",
"loadingLabel": "Briefing wird zusammengestellt"
},
"trendsRow": {
"title": "Trends",
"subtitle": "Die letzten 30 Tage auf einen Blick"
},
"trendAnnotation": {
"emptyBp": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
"emptyWeight": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
"emptyMood": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
"confidenceHigh": "Hohe Konfidenz",
"confidenceModerate": "Mittlere Konfidenz",
"confidenceLow": "Geringe Konfidenz"
},
"correlationRow": {
"title": "Muster",
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
"disclaimer": "Muster sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
"experimentCta": "7-Tage-Experiment starten",
"experimentTooltip": "Bald verfügbar",
"emptyTitle": "Mehr Daten nötig, um dieses Muster zu zeigen",
"sourceChip": "basierend auf {n} gepaarten Messungen · {window}",
"sourceWindowLast30": "letzte 30 Tage",
"confidenceHigh": "Hohe Konfidenz",
"confidenceModerate": "Mittlere Konfidenz",
"confidenceLow": "Geringe Konfidenz",
"card": {
"bpComplianceTitle": "BD × Medikamenten-Compliance",
"bpComplianceSubtitle": "Täglicher Systolischer gegen Compliance-%",
"moodPulseTitle": "Stimmung × Ruhepuls",
"moodPulseSubtitle": "Stimmung und Ruhepuls am selben Tag",
"weightWeekdayTitle": "Gewicht × Wochentag",
"weightWeekdaySubtitle": "Gewicht nach Wochentag gruppiert"
}
},
"coach": {
"refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
+33
View File
@@ -776,6 +776,39 @@
"emptyAction": "Generate briefing",
"loadingLabel": "Synthesising today's briefing"
},
"trendsRow": {
"title": "Trends",
"subtitle": "Last 30 days at a glance"
},
"trendAnnotation": {
"emptyBp": "Awaiting more data to annotate this trend.",
"emptyWeight": "Awaiting more data to annotate this trend.",
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
},
"correlationRow": {
"title": "Patterns",
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
"disclaimer": "Patterns are observational, not causal — talk to your doctor if BP runs high.",
"experimentCta": "Try a 7-day experiment",
"experimentTooltip": "Coming soon",
"emptyTitle": "Need more data to see this pattern",
"sourceChip": "based on {n} paired readings · {window}",
"sourceWindowLast30": "last 30 days",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence",
"card": {
"bpComplianceTitle": "BP × medication compliance",
"bpComplianceSubtitle": "Daily systolic against compliance %",
"moodPulseTitle": "Mood × resting pulse",
"moodPulseSubtitle": "Same-day mood and resting pulse",
"weightWeekdayTitle": "Weight × weekday",
"weightWeekdaySubtitle": "Weight grouped by day of week"
}
},
"coach": {
"refusalOutOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
@@ -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>
);
}