From 10a67ff6c83e1664387209ad0596d21c90b4242d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:15:22 +0200 Subject: [PATCH] feat(insights): RecommendationCard with expandable rationale + mini-chart of data window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each recommendation now lives in its own collapsible card. Collapsed state shows: severity badge + rec text + named slot for the future B5d confidence-ring + chevron toggle. Expanding reveals the Oura-style "Contributors" panel: - 3 rationale rows (Window / Compared to / Deviation) - mini-chart pinned to the rec's data window via HealthChart's new `mini` + `windowOverride` props (or MoodChart's mirror props for mood-typed metrics) - citation footnote (B5a) when referenceId resolves - named slot for the future B5e feedback-thumbs Default state: collapsed. ChevronDown / ChevronUp toggle aria-expanded and a 200ms ease-out animation reveals the rationale block. Recs without rationale (legacy payloads) and plain-string recs render as a non-expandable list item with the inline citation footnote preserved — defence in depth so a stale cached payload still surfaces its source even though the rationale block is empty. InsightAdvisorCard renders the new component and surfaces the legacyPayload CTA when the route flagged the cached blob as pre-B5c shape — clicking the regenerate button is the single trigger; we never auto-regenerate. InsightRecommendation type widens to optionally carry rationale + metricSource + severity + id (mirrors the strict aiRecommendationSchema). Optional in the UI type because legacy cached payloads predate them; the server-side schema enforces presence on fresh generations. Layout slots are deliberately named so B5d (confidence) and B5e (feedback) plug into named positions without touching this file: - data-slot="rec-confidence-slot" → B5d ConfidenceRing - data-slot="rec-feedback-slot" → B5e RecommendationFeedback 11 new tests in `recommendation-card.test.tsx` pin the contract (severity badge, default-collapsed, rationale row labels in both locales, mini-chart routing for BP / mood, slot positions, legacy fallback). Existing 6 citation-footnote tests still green via the inline-footnote fallback path. 7 new EN+DE i18n keys under `insights.recommendation.*`: rationale, rationaleWindow, rationaleComparedTo, rationaleDeviation, rationaleExpand, rationaleCollapse, legacyPayloadCta. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 9 +- messages/en.json | 9 +- .../__tests__/recommendation-card.test.tsx | 201 +++++++++++ .../insights/insight-advisor-card.tsx | 136 +++----- .../insights/recommendation-card.tsx | 318 ++++++++++++++++++ src/lib/ai/types.ts | 36 ++ 6 files changed, 623 insertions(+), 86 deletions(-) create mode 100644 src/components/insights/__tests__/recommendation-card.test.tsx create mode 100644 src/components/insights/recommendation-card.tsx 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")}

+ +
+
+ )} + + {/* 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 ( -
  1. -
    - - {i + 1}. - - {text} -
    - {referenceId && ( - - )} -
  2. - ); - })} +
      + {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 ( +
  • +
    +
    + +
    +
    + {norm.severity && ( + + {norm.severity} + + )} +

    {norm.text}

    +
    +
    +
    +
    + + {expandable && ( + + )} +
    +
    + + {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(), }), ]);