mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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",
|
"emptyAction": "Briefing erstellen",
|
||||||
"loadingLabel": "Briefing wird zusammengestellt"
|
"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": {
|
"coach": {
|
||||||
"refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
|
"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.",
|
"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",
|
"emptyAction": "Generate briefing",
|
||||||
"loadingLabel": "Synthesising today's 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": {
|
"coach": {
|
||||||
"refusalOutOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
"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.",
|
"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