mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
f9afaf77a0
commit
2ae69d7e64
6 files changed
+489
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user