From 2ae69d7e64bd257f93b34aae34ee3522782d2d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 16:47:08 +0200 Subject: [PATCH] feat(insights): TrendAnnotation + TrendsRow components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two presentational pieces for the Insights redesign: - `` — 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. - `` — 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) --- messages/de.json | 33 ++++ messages/en.json | 33 ++++ .../__tests__/trend-annotation.test.tsx | 83 ++++++++++ .../insights/__tests__/trends-row.test.tsx | 96 ++++++++++++ src/components/insights/trend-annotation.tsx | 97 ++++++++++++ src/components/insights/trends-row.tsx | 147 ++++++++++++++++++ 6 files changed, 489 insertions(+) create mode 100644 src/components/insights/__tests__/trend-annotation.test.tsx create mode 100644 src/components/insights/__tests__/trends-row.test.tsx create mode 100644 src/components/insights/trend-annotation.tsx create mode 100644 src/components/insights/trends-row.tsx diff --git a/messages/de.json b/messages/de.json index 3062b603a..62f406533 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 6c10f55bf..dea5e05a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/components/insights/__tests__/trend-annotation.test.tsx b/src/components/insights/__tests__/trend-annotation.test.tsx new file mode 100644 index 000000000..26792f9fa --- /dev/null +++ b/src/components/insights/__tests__/trend-annotation.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders the annotation prose when supplied", () => { + const html = render( + , + ); + 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(); + 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(, "de"); + expect(html).toContain("Mehr Daten"); + }); + + it("renders the high-confidence chip when supplied", () => { + const html = render( + , + ); + expect(html).toMatch(/data-slot="trend-annotation-confidence"/); + expect(html).toContain("High confidence"); + }); + + it("renders the moderate-confidence chip", () => { + const html = render( + , + ); + expect(html).toContain("Moderate confidence"); + }); + + it("renders the low-confidence chip", () => { + const html = render( + , + ); + expect(html).toContain("Low confidence"); + }); + + it("does not render the chip when confidence is omitted", () => { + const html = render(); + expect(html).not.toMatch(/data-slot="trend-annotation-confidence"/); + }); + + it("tags the wrapper element with the metric for parent layouts", () => { + const html = render( + , + ); + expect(html).toMatch(/data-metric="mood"/); + }); +}); diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx new file mode 100644 index 000000000..46660608a --- /dev/null +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -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 — `` 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 }) => ( +
{title ?? "chart"}
+ ); + Stub.displayName = "TrendsRowChartStub"; + return Stub; + }, +})); + +import { TrendsRow } from "../trends-row"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the row title in English", () => { + const html = render(); + expect(html).toMatch(/data-slot="trends-row"/); + expect(html).toContain("Trends"); + }); + + it("renders the row title in German", () => { + const html = render(, "de"); + expect(html).toContain("Trends"); + }); + + it("mounts a card per metric (BP / weight / mood)", () => { + const html = render(); + 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(); + expect(html).toMatch(/md:grid-cols-3/); + expect(html).toMatch(/grid-cols-1/); + }); + + it("renders annotations when supplied", () => { + const html = render( + , + ); + 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(); + 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( + , + ); + expect(html).toContain("High confidence"); + expect(html).toContain("Moderate confidence"); + expect(html).toContain("Low confidence"); + }); +}); diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx new file mode 100644 index 000000000..9c86acd12 --- /dev/null +++ b/src/components/insights/trend-annotation.tsx @@ -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 = { + high: "insights.trendAnnotation.confidenceHigh", + moderate: "insights.trendAnnotation.confidenceModerate", + low: "insights.trendAnnotation.confidenceLow", +}; + +const EMPTY_KEY: Record = { + 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 ( +

+ {t(EMPTY_KEY[metric])} +

+ ); + } + + return ( +
+
+ ); +} diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx new file mode 100644 index 000000000..02c58cfc6 --- /dev/null +++ b/src/components/insights/trends-row.tsx @@ -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 + * `` 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`: 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 + * `` 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: () => ( +
+ ), + }, +); + +const MoodChart = dynamic( + () => + import("@/components/charts/mood-chart").then((mod) => ({ + default: mod.MoodChart, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +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 ( +
+
+

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

+

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

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +}