feat(insights): RecommendationCard with expandable rationale + mini-chart of data window

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:17:15 +02:00
1 parent 680f84c426
commit 10a67ff6c8
6 files changed
+623 -86

No files matched your search

+8 -1
View File
@@ -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:",
+8 -1
View File
@@ -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:",
@@ -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;
}) => (
<div
data-testid="health-chart-mock"
data-types={types.join(",")}
data-mini={mini ? "true" : "false"}
data-window={windowOverride ?? ""}
>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: ({ mini }: { mini?: boolean }) => (
<div data-testid="mood-chart-mock" data-mini={mini ? "true" : "false"} />
),
}));
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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* 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("<RecommendationCard>", () => {
it("renders the rec text and severity badge in collapsed state", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
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(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
// 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(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
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(<RecommendationCard rec={recBase} index={0} />);
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(
<RecommendationCard
rec={{ ...recBase, referenceId: knownRef.id }}
index={0}
initiallyExpanded
/>,
);
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(
<RecommendationCard rec={legacyRec} index={0} initiallyExpanded />,
);
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(
<RecommendationCard rec="Continue daily logging." index={0} />,
);
expect(html).toContain("Continue daily logging.");
expect(html).not.toMatch(/aria-expanded=/);
});
it("German locale labels — rationale rows translate", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
"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(<RecommendationCard rec={recBase} index={0} />);
// 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(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
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(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
// 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(
<RecommendationCard rec={moodRec} index={0} initiallyExpanded />,
);
expect(html).toContain('data-testid="mood-chart-mock"');
expect(html).toContain('data-mini="true"');
});
});
@@ -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 (
<a
href={ref.url}
target="_blank"
rel="noreferrer"
title={t("insights.recommendation.viewSource")}
className="text-muted-foreground hover:text-foreground mt-0.5 ml-6 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
data-slot="insight-recommendation-source"
data-reference-id={referenceId}
>
<span className="font-medium">
{t("insights.recommendation.source")}:
</span>
<span>
{ref.org} {ref.publishedYear} — {label}
</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}
/** 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({
</div>
)}
{/* 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 && (
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1.5">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="h-7 text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.startAnalysis")}
</Button>
</div>
</div>
)}
{/* 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 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.recommendationsTitle")} />
<ol className="space-y-1.5 pl-0">
{insight.recommendations.map((rec, i) => {
const { text, referenceId } = normaliseRecommendation(rec);
return (
<li key={i} className="flex flex-col gap-0.5 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 font-medium">
{i + 1}.
</span>
<span>{text}</span>
</div>
{referenceId && (
<CitationFootnote
referenceId={referenceId}
locale={locale}
/>
)}
</li>
);
})}
<ol className="space-y-2 pl-0">
{insight.recommendations.map((rec, i) => (
<RecommendationCard
key={typeof rec === "string" ? `${i}-${rec}` : (rec.id ?? i)}
rec={rec}
index={i}
/>
))}
</ol>
</div>
)}
@@ -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<string, string> = {
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 (
<a
href={ref.url}
target="_blank"
rel="noreferrer"
title={t("insights.recommendation.viewSource")}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
data-slot="insight-recommendation-source"
data-reference-id={referenceId}
>
<span className="font-medium">
{t("insights.recommendation.source")}:
</span>
<span>
{ref.org} {ref.publishedYear} — {label}
</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}
function RationaleRow({ label, value }: { label: string; value: string }) {
return (
<div
className="grid grid-cols-[7rem_1fr] items-start gap-2 text-xs"
data-slot="rec-rationale-row"
>
<span className="text-muted-foreground font-medium">{label}</span>
<span className="text-foreground">{value}</span>
</div>
);
}
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 (
<div
data-slot="rec-rationale-card"
className="bg-muted/30 mt-3 space-y-3 rounded-md px-3 py-2.5"
>
<div className="space-y-1.5">
<RationaleRow
label={t("insights.recommendation.rationaleWindow")}
value={rationale.dataWindow}
/>
<RationaleRow
label={t("insights.recommendation.rationaleComparedTo")}
value={rationale.comparedTo}
/>
<RationaleRow
label={t("insights.recommendation.rationaleDeviation")}
value={rationale.deviation}
/>
</div>
{/* 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) ? (
<MoodChart mini windowOverride={rationale.dataWindow} />
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
title={metricType ?? ""}
mini
windowOverride={rationale.dataWindow}
/>
) : null}
{referenceId && (
<div>
<CitationFootnote referenceId={referenceId} locale={locale} />
</div>
)}
<div data-slot="rec-feedback-slot" />
</div>
);
}
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 (
<li
data-slot="rec-card"
data-index={index}
className="border-border/40 bg-card/20 rounded-lg border px-3 py-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span
className="text-muted-foreground mt-0.5 shrink-0 text-xs font-medium tabular-nums"
aria-hidden="true"
>
{index + 1}.
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
{norm.severity && (
<Badge
className={`text-[10px] tracking-wide uppercase ${
SEVERITY_BADGE_STYLES[norm.severity] ?? ""
}`}
variant="outline"
>
{norm.severity}
</Badge>
)}
<p className="text-sm leading-snug">{norm.text}</p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span data-slot="rec-confidence-slot" />
{expandable && (
<button
type="button"
onClick={() => 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 ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
{expandable && expanded && norm.rationale && (
<div
className="animate-insight-in"
style={{ animationDuration: "200ms" }}
>
<RationaleCard
rationale={norm.rationale}
metricSource={norm.metricSource}
referenceId={norm.referenceId}
locale={locale}
/>
</div>
)}
{/* 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 && (
<div className="mt-1 ml-6">
<CitationFootnote referenceId={norm.referenceId} locale={locale} />
</div>
)}
</li>
);
}
+36
View File
@@ -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(),
}),
]);