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(), }), ]);