diff --git a/messages/de.json b/messages/de.json
index 62709141e..ddfde528c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -670,7 +670,14 @@
"recommendationsTitle": "Empfehlungen",
"recommendation": {
"source": "Quelle",
- "viewSource": "Leitlinie in neuem Tab öffnen"
+ "viewSource": "Leitlinie in neuem Tab öffnen",
+ "rationale": "Warum diese Empfehlung",
+ "rationaleWindow": "Fenster",
+ "rationaleComparedTo": "Verglichen mit",
+ "rationaleDeviation": "Abweichung",
+ "rationaleExpand": "Begründung anzeigen",
+ "rationaleCollapse": "Begründung ausblenden",
+ "legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen"
},
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
diff --git a/messages/en.json b/messages/en.json
index 0a9f2314d..05c547768 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -670,7 +670,14 @@
"recommendationsTitle": "Recommendations",
"recommendation": {
"source": "Source",
- "viewSource": "Open guideline in a new tab"
+ "viewSource": "Open guideline in a new tab",
+ "rationale": "Why this recommendation",
+ "rationaleWindow": "Window",
+ "rationaleComparedTo": "Compared to",
+ "rationaleDeviation": "Deviation",
+ "rationaleExpand": "Show why",
+ "rationaleCollapse": "Hide why",
+ "legacyPayloadCta": "Insights updated — regenerate for new explainability features"
},
"dataFoundation": "Data coverage",
"dataGaps": "Data gaps:",
diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx
new file mode 100644
index 000000000..a52dfe1b5
--- /dev/null
+++ b/src/components/insights/__tests__/recommendation-card.test.tsx
@@ -0,0 +1,201 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { I18nProvider } from "@/lib/i18n/context";
+import { RecommendationCard } from "../recommendation-card";
+import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
+
+vi.mock("@/components/charts/health-chart", () => ({
+ HealthChart: ({
+ types,
+ title,
+ mini,
+ windowOverride,
+ }: {
+ types: string[];
+ title: string;
+ mini?: boolean;
+ windowOverride?: string;
+ }) => (
+
+ chart:{title}
+
+ ),
+}));
+
+vi.mock("@/components/charts/mood-chart", () => ({
+ MoodChart: ({ mini }: { mini?: boolean }) => (
+
+ ),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: null, isLoading: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+const knownRef = MEDICAL_REFERENCES[0];
+
+const baseRationale = {
+ dataWindow: "last7days" as const,
+ comparedTo: "your 90-day median (122/78)",
+ deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
+};
+
+const recBase = {
+ id: "rec-1",
+ text: "Discuss home BP log with your physician.",
+ severity: "important" as const,
+ rationale: baseRationale,
+ metricSource: {
+ type: "bloodPressure",
+ timeRange: "last7days",
+ summary: "avg 138/86 across 9 readings",
+ },
+};
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+/**
+ * v1.4.16 phase B5c — Oura-style RecommendationCard.
+ *
+ * Each rec collapses into a one-row summary (text + severity + chevron
+ * + named slots for confidence ring (B5d) + feedback thumbs (B5e)).
+ * Expanding reveals the rationale 3-row card + mini-chart pinned to
+ * the rec's data window + the citation footnote (B5a).
+ *
+ * Default: collapsed. The chevron toggles aria-expanded.
+ */
+
+describe("", () => {
+ it("renders the rec text and severity badge in collapsed state", () => {
+ const html = render( );
+ expect(html).toContain("Discuss home BP log");
+ // The expand button has aria-expanded="false" when collapsed.
+ expect(html).toMatch(/aria-expanded="false"/);
+ });
+
+ it("renders rationale rows when initially expanded", () => {
+ const html = render(
+ ,
+ );
+ // All 3 row labels visible (English locale)
+ expect(html).toContain("Window");
+ expect(html).toContain("Compared to");
+ expect(html).toContain("Deviation");
+ // Values rendered verbatim
+ expect(html).toContain("last7days");
+ expect(html).toContain("your 90-day median (122/78)");
+ expect(html).toContain("+16/+8 mmHg above baseline");
+ // aria-expanded flips to true
+ expect(html).toMatch(/aria-expanded="true"/);
+ });
+
+ it("renders the mini-chart pinned to the rec's data window when expanded", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-testid="health-chart-mock"');
+ expect(html).toContain('data-mini="true"');
+ expect(html).toContain('data-window="last7days"');
+ });
+
+ it("does NOT render the rationale block when collapsed (default)", () => {
+ const html = render( );
+ expect(html).not.toContain("Window");
+ expect(html).not.toContain("your 90-day median (122/78)");
+ expect(html).not.toContain('data-testid="health-chart-mock"');
+ });
+
+ it("renders the citation footnote when referenceId resolves and expanded", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain(knownRef.url);
+ expect(html).toContain('data-slot="insight-recommendation-source"');
+ });
+
+ it("does not render rationale block when rec lacks rationale (legacy migration grace)", () => {
+ const legacyRec = {
+ id: "rec-old",
+ text: "Walk more",
+ severity: "suggestion" as const,
+ };
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Walk more");
+ // No rationale labels
+ expect(html).not.toContain("Window");
+ expect(html).not.toContain("Compared to");
+ // No expand control either since there's nothing to expand to
+ expect(html).not.toMatch(/aria-expanded=/);
+ });
+
+ it("treats a plain-string rec as legacy (no expand chevron)", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Continue daily logging.");
+ expect(html).not.toMatch(/aria-expanded=/);
+ });
+
+ it("German locale labels — rationale rows translate", () => {
+ const html = render(
+ ,
+ "de",
+ );
+ expect(html).toContain("Fenster");
+ expect(html).toContain("Verglichen mit");
+ expect(html).toContain("Abweichung");
+ });
+
+ it("renders named slots for confidence (B5d) and feedback (B5e) so future phases can plug in", () => {
+ const html = render( );
+ // Slot for confidence ring — B5d will fill this.
+ expect(html).toMatch(/data-slot="rec-confidence-slot"/);
+ // Slot for feedback thumbs — B5e will fill this. The slot lives
+ // inside the expanded card, so wrap with initiallyExpanded.
+ const expanded = render(
+ ,
+ );
+ expect(expanded).toMatch(/data-slot="rec-feedback-slot"/);
+ });
+
+ it("maps metricSource.type to the chart's metric handle (BP_SYS for bloodPressure)", () => {
+ const html = render(
+ ,
+ );
+ // The chart mock echoes its `types` prop. bloodPressure → both
+ // sys + dia (we render both lines on the same mini-chart).
+ expect(html).toMatch(/data-types="BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA"/);
+ });
+
+ it("maps metricSource.type=mood to the MoodChart, also mini", () => {
+ const moodRec = {
+ ...recBase,
+ metricSource: {
+ type: "mood",
+ timeRange: "last7days",
+ summary: "5/5 logs",
+ },
+ };
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-testid="mood-chart-mock"');
+ expect(html).toContain('data-mini="true"');
+ });
+});
diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx
index 0505244be..2433a4511 100644
--- a/src/components/insights/insight-advisor-card.tsx
+++ b/src/components/insights/insight-advisor-card.tsx
@@ -1,11 +1,7 @@
"use client";
import { useState } from "react";
-import type {
- InsightResult,
- InsightFinding,
- InsightRecommendation,
-} from "@/lib/ai/types";
+import type { InsightResult, InsightFinding } from "@/lib/ai/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,7 +16,6 @@ import {
ChevronDown,
ChevronUp,
Info,
- ExternalLink,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { HealthChart } from "@/components/charts/health-chart";
@@ -32,8 +27,7 @@ import {
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
-import { getMedicalReferenceById } from "@/lib/ai/medical-references";
-import type { Locale } from "@/lib/i18n/config";
+import { RecommendationCard } from "./recommendation-card";
// ─── Types ────────────────────────────────────────────────
@@ -46,6 +40,12 @@ interface InsightAdvisorCardProps {
onRegenerate?: () => void;
regenerating?: boolean;
cachedAt?: string | null;
+ /**
+ * v1.4.16 phase B5c — set when the cached payload predates B5c
+ * rationale. The card surfaces a one-time CTA pointing at
+ * regenerate; user-initiated only.
+ */
+ legacyPayload?: boolean;
}
// ─── Classification Colors ───────────────────────────────
@@ -270,60 +270,6 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
// ─── Section Separator ────────────────────────────────────
-// ─── Recommendation Citation Footnote ─────────────────────
-//
-// v1.4.16 phase B5a — render a small "Source: …" footnote under a
-// recommendation when the model emitted a `referenceId` pointing into
-// the curated medical-reference bundle. The link opens the guideline
-// in a new tab; per Marc's research §7 we never open the URL inside
-// an in-app webview.
-//
-// `getMedicalReferenceById()` returns undefined for unknown ids — in
-// that case we silently drop the footnote rather than render a broken
-// link. The schema check would already have rejected the payload, so
-// this is defence in depth.
-
-function CitationFootnote({
- referenceId,
- locale,
-}: {
- referenceId: string;
- locale: Locale;
-}) {
- const { t } = useTranslations();
- const ref = getMedicalReferenceById(referenceId);
- if (!ref) return null;
- const label = locale === "de" ? ref.titleDe : ref.title;
- return (
-
-
- {t("insights.recommendation.source")}:
-
-
- {ref.org} {ref.publishedYear} — {label}
-
-
-
- );
-}
-
-/** Normalise the legacy string-rec OR new structured-rec into a uniform shape. */
-function normaliseRecommendation(rec: InsightRecommendation): {
- text: string;
- referenceId?: string;
-} {
- if (typeof rec === "string") return { text: rec };
- return { text: rec.text, referenceId: rec.referenceId };
-}
-
// ─── Section Separator ────────────────────────────────────
function SectionSeparator({ label }: { label: string }) {
@@ -346,8 +292,9 @@ export function InsightAdvisorCard({
onRegenerate,
regenerating = false,
cachedAt,
+ legacyPayload = false,
}: InsightAdvisorCardProps) {
- const { t, locale } = useTranslations();
+ const { t } = useTranslations();
const fmt = useFormatters();
const [dataQualityOpen, setDataQualityOpen] = useState(false);
@@ -565,30 +512,51 @@ export function InsightAdvisorCard({
)}
- {/* Recommendations */}
+ {/* v1.4.16 phase B5c — legacy-payload CTA. Surfaced when the
+ * cached payload predates the rationale field; pointing the
+ * user at the regenerate button. Click-handler is owned by
+ * the parent (the regenerate button). */}
+ {legacyPayload && onRegenerate && (
+
+
+
+
{t("insights.recommendation.legacyPayloadCta")}
+
+ {regenerating ? (
+
+ ) : (
+
+ )}
+ {t("insights.startAnalysis")}
+
+
+
+ )}
+
+ {/* Recommendations — v1.4.16 phase B5c uses RecommendationCard
+ * so each rec gets the Oura-style expand-card with rationale
+ * rows + mini-chart + citation footnote. Plain-string recs
+ * and recs without rationale render non-expanded. */}
{insight.recommendations.length > 0 && (
-
- {insight.recommendations.map((rec, i) => {
- const { text, referenceId } = normaliseRecommendation(rec);
- return (
-
-
-
- {i + 1}.
-
- {text}
-
- {referenceId && (
-
- )}
-
- );
- })}
+
+ {insight.recommendations.map((rec, i) => (
+
+ ))}
)}
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
new file mode 100644
index 000000000..90584a643
--- /dev/null
+++ b/src/components/insights/recommendation-card.tsx
@@ -0,0 +1,318 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { useTranslations } from "@/lib/i18n/context";
+import type {
+ InsightRecommendation,
+ InsightRecommendationRationale,
+} from "@/lib/ai/types";
+import { HealthChart } from "@/components/charts/health-chart";
+import { MoodChart } from "@/components/charts/mood-chart";
+import { getMedicalReferenceById } from "@/lib/ai/medical-references";
+import type { Locale } from "@/lib/i18n/config";
+
+/**
+ * v1.4.16 phase B5c — Oura-style RecommendationCard.
+ *
+ * Each recommendation lives in its own collapsible card. The
+ * collapsed row shows: severity badge + rec text + named slot for
+ * the future confidence-ring (B5d) + chevron toggle.
+ *
+ * Expanding reveals:
+ * - 3-row rationale card (Window / Compared to / Deviation)
+ * - mini-chart pinned to the rationale's data window via
+ * HealthChart's new `mini` + `windowOverride` props
+ * - citation footnote (B5a) when `referenceId` resolves
+ * - named slot for the future feedback-thumbs (B5e)
+ *
+ * Default state: collapsed. Chevron toggles `aria-expanded`. Plain-
+ * string recs (legacy InsightResult shape) and recs without
+ * `rationale` render as a non-expandable list item — there's nothing
+ * to expand to. The legacy-payload CTA at the parent advisor card
+ * level surfaces the regenerate prompt for those.
+ *
+ * Layout slots are deliberately named so B5d (confidence) and B5e
+ * (feedback) can plug in without touching this file:
+ *
+ * - data-slot="rec-confidence-slot" → B5d ConfidenceRing
+ * - data-slot="rec-feedback-slot" → B5e RecommendationFeedback
+ */
+
+const SEVERITY_BADGE_STYLES: Record = {
+ info: "bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25",
+ suggestion:
+ "bg-dracula-purple/10 text-dracula-purple border border-dracula-purple/25",
+ important:
+ "bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25",
+ urgent: "bg-dracula-red/10 text-dracula-red border border-dracula-red/25",
+};
+
+interface RecommendationCardProps {
+ rec: InsightRecommendation;
+ index: number;
+ /** Force the rationale block visible — mostly for tests + storybook. */
+ initiallyExpanded?: boolean;
+}
+
+interface NormalisedRec {
+ text: string;
+ severity?: "info" | "suggestion" | "important" | "urgent";
+ rationale?: InsightRecommendationRationale;
+ metricSource?: { type: string; timeRange: string; summary: string };
+ referenceId?: string;
+ id?: string;
+}
+
+function normalise(rec: InsightRecommendation): NormalisedRec {
+ if (typeof rec === "string") return { text: rec };
+ return {
+ text: rec.text,
+ severity: rec.severity,
+ rationale: rec.rationale,
+ metricSource: rec.metricSource,
+ referenceId: rec.referenceId,
+ id: rec.id,
+ };
+}
+
+/**
+ * Map a `metricSource.type` value to the chart-types[] the dashboard
+ * uses. The model speaks snapshot-key vocabulary ("bloodPressure",
+ * "weight", "pulse", "mood", "medications.compliance30") which
+ * doesn't 1:1 match the measurement-type enum the chart consumes
+ * ("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT", …). This
+ * map is deliberately tight; unknown types fall through to a single-
+ * type chart so a future provider that emits a new key still renders
+ * something rather than nothing.
+ */
+function metricTypeToChartTypes(metricType: string | undefined): string[] {
+ if (!metricType) return [];
+ const lower = metricType.toLowerCase();
+ if (lower === "bloodpressure" || lower === "blood_pressure") {
+ return ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"];
+ }
+ if (lower === "weight") return ["WEIGHT"];
+ if (lower === "pulse") return ["PULSE"];
+ if (lower === "bodyfat" || lower === "body_fat") return ["BODY_FAT"];
+ if (lower === "sleep" || lower === "sleep_duration") {
+ return ["SLEEP_DURATION"];
+ }
+ if (lower === "activity" || lower === "steps") {
+ return ["ACTIVITY_STEPS"];
+ }
+ if (lower === "bloodglucose" || lower === "blood_glucose") {
+ return ["BLOOD_GLUCOSE"];
+ }
+ // Unknown / synthetic key — pass it through verbatim so the chart
+ // can render empty rather than nothing.
+ return [metricType];
+}
+
+function isMoodMetric(metricType: string | undefined): boolean {
+ return metricType?.toLowerCase() === "mood";
+}
+
+function isComplianceMetric(metricType: string | undefined): boolean {
+ return (
+ metricType?.toLowerCase().startsWith("medications.compliance") === true ||
+ metricType?.toLowerCase() === "medication"
+ );
+}
+
+function CitationFootnote({
+ referenceId,
+ locale,
+}: {
+ referenceId: string;
+ locale: Locale;
+}) {
+ const { t } = useTranslations();
+ const ref = getMedicalReferenceById(referenceId);
+ if (!ref) return null;
+ const label = locale === "de" ? ref.titleDe : ref.title;
+ return (
+
+
+ {t("insights.recommendation.source")}:
+
+
+ {ref.org} {ref.publishedYear} — {label}
+
+
+
+ );
+}
+
+function RationaleRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function RationaleCard({
+ rationale,
+ metricSource,
+ referenceId,
+ locale,
+}: {
+ rationale: InsightRecommendationRationale;
+ metricSource: { type: string; timeRange: string; summary: string } | undefined;
+ referenceId: string | undefined;
+ locale: Locale;
+}) {
+ const { t } = useTranslations();
+ const metricType = metricSource?.type;
+ const chartTypes = metricTypeToChartTypes(metricType);
+
+ return (
+
+
+
+
+
+
+
+ {/* Mini-chart pinned to the rec's window. mood + medication-
+ compliance get dedicated wrappers; everything else routes
+ through HealthChart with the metric-key vocabulary. */}
+ {isMoodMetric(metricType) ? (
+
+ ) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
+
+ ) : null}
+
+ {referenceId && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+export function RecommendationCard({
+ rec,
+ index,
+ initiallyExpanded = false,
+}: RecommendationCardProps) {
+ const { t, locale } = useTranslations();
+ const norm = normalise(rec);
+ const expandable = norm.rationale !== undefined;
+ const [expanded, setExpanded] = useState(initiallyExpanded);
+
+ return (
+
+
+
+
+ {index + 1}.
+
+
+
+ {norm.severity && (
+
+ {norm.severity}
+
+ )}
+
{norm.text}
+
+
+
+
+
+ {expandable && (
+ setExpanded((v) => !v)}
+ aria-expanded={expanded}
+ className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors"
+ title={
+ expanded
+ ? t("insights.recommendation.rationaleCollapse")
+ : t("insights.recommendation.rationaleExpand")
+ }
+ data-slot="rec-expand-toggle"
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {expandable && expanded && norm.rationale && (
+
+
+
+ )}
+
+ {/* Inline-only citation footnote: a legacy rec (no rationale)
+ * still surfaces its medical reference under the rec text.
+ * Recs WITH rationale only render the footnote inside the
+ * expanded RationaleCard so it doesn't double-print. */}
+ {!expandable && norm.referenceId && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index d580dc3be..92c938a3a 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -21,18 +21,54 @@ export const insightDataQualitySchema = z.object({
confidence: z.enum(["hoch", "mittel", "gering"]),
});
+/**
+ * v1.4.16 phase B5c — rationale shape mirrors the strict
+ * `aiRecommendationRationaleSchema`. dataWindow values match
+ * `mini-window.ts` so the rec card's mini-chart can pin to the right
+ * window without an extra mapping table.
+ */
+export const insightRecommendationRationaleSchema = z.object({
+ dataWindow: z.enum(["last7days", "last30days", "last90days", "allTime"]),
+ comparedTo: z.string(),
+ deviation: z.string(),
+});
+
+export type InsightRecommendationRationale = z.infer<
+ typeof insightRecommendationRationaleSchema
+>;
+
/**
* v1.4.16 — recommendations can be either the legacy plain-string
* shape OR a structured object carrying an optional medical-reference
* citation. The card renders a footnote with a labelled link when
* `referenceId` is set and resolves to a known entry in
* `MEDICAL_REFERENCES`.
+ *
+ * v1.4.16 phase B5c — adds `rationale` (Oura-style "Contributors")
+ * and `metricSource` so the rec card can render the expand-out
+ * explainability panel + a mini-chart pinned to the rec's window.
+ * Both fields are optional on the UI type because legacy cached
+ * payloads predate them; the server-side `aiRecommendationSchema`
+ * enforces presence on fresh generations.
*/
export const insightRecommendationSchema = z.union([
z.string(),
z.object({
text: z.string(),
referenceId: z.string().optional(),
+ rationale: insightRecommendationRationaleSchema.optional(),
+ metricSource: z
+ .object({
+ type: z.string(),
+ timeRange: z.string(),
+ summary: z.string(),
+ n: z.number().optional(),
+ })
+ .optional(),
+ severity: z
+ .enum(["info", "suggestion", "important", "urgent"])
+ .optional(),
+ id: z.string().optional(),
}),
]);