feat(insights): RecommendationCard renders ConfidenceMeter in named slot

The `data-slot="rec-confidence-slot"` placeholder reserved by B5c is
now filled with `<ConfidenceMeter value={rec.confidence} />`. The
slot stays empty when `rec.confidence` is undefined (legacy cached
payloads predate the field) so a missing value never gets dressed up
as a draft pill.

Below 50, the expanded rationale card surfaces a "Low confidence —
based on limited data" caption so the rec stays visible but framed
as preliminary. The caption suppresses above 50 — the meter alone
speaks. Below 25 the meter itself is replaced by a draft pill
(handled inside `<ConfidenceMeter>`); the caption still applies
because draft <= low.

The B5e feedback-thumbs slot
(`data-slot="rec-feedback-slot"`) is untouched. The wiring test
explicitly asserts the feedback slot is still present and empty so
the parallel-running B5e agent has a stable plug-in point.

Threshold lives in a single named const
(`LOW_CONFIDENCE_CAPTION_THRESHOLD = 50`) co-located with the band
ladder in `<ConfidenceMeter>` so a future tune-pass can adjust the
ratchet in one place.

8 component tests cover: meter renders in slot at 85, slot stays
empty when confidence is undefined, medium/low/draft band rendering,
caption shows below 50, caption hidden above, German caption
translation, feedback-slot untouched.

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:40:29 +02:00
1 parent 7ec10308b8
commit 63cfd8e2f7
2 files changed
+180 -1

No files matched your search

@@ -0,0 +1,141 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: () => <div data-testid="health-chart-mock" />,
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart-mock" />,
}));
/**
* v1.4.16 phase B5d — confidence meter wiring inside
* `<RecommendationCard>`.
*
* The card reserves a `data-slot="rec-confidence-slot"` placeholder
* (B5c). B5d fills that slot with `<ConfidenceMeter>`. Behaviour:
*
* - rec.confidence undefined -> slot stays empty (legacy payload).
* - >=50 -> meter only.
* - 25..49 -> meter + low-confidence caption inside the expanded
* rationale card ("Low confidence — based on limited data").
* - <25 -> "draft" pill replaces the meter (handled by
* <ConfidenceMeter> itself); the low-confidence caption ALSO
* surfaces because draft <= low.
*
* The meter sits in the collapsed-row slot so users can see the
* confidence at a glance without expanding.
*/
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>,
);
}
describe("<RecommendationCard> — confidence slot wiring", () => {
it("renders ConfidenceMeter inside rec-confidence-slot when confidence is set", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 85 }}
index={0}
/>,
);
// Slot wraps the meter
expect(html).toMatch(
/data-slot="rec-confidence-slot"[\s\S]*?data-slot="confidence-meter"/,
);
expect(html).toContain('data-confidence-band="high"');
});
it("leaves rec-confidence-slot empty when rec has no confidence (legacy)", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
// Slot is present but should not contain a confidence-meter inside it.
expect(html).toMatch(/data-slot="rec-confidence-slot"/);
expect(html).not.toContain('data-slot="confidence-meter"');
});
it("yellow band for medium-confidence (50-79)", () => {
const html = render(
<RecommendationCard rec={{ ...recBase, confidence: 67 }} index={0} />,
);
expect(html).toContain('data-confidence-band="medium"');
});
it("orange low band + low-confidence caption when value 25-49 and expanded", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 35 }}
index={0}
initiallyExpanded
/>,
);
expect(html).toContain('data-confidence-band="low"');
expect(html).toMatch(/Low confidence — based on limited data/);
});
it("low-confidence caption is suppressed when meter is high or medium", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 85 }}
index={0}
initiallyExpanded
/>,
);
expect(html).not.toMatch(/Low confidence/);
});
it("draft pill below 25 (rendered by ConfidenceMeter)", () => {
const html = render(
<RecommendationCard rec={{ ...recBase, confidence: 15 }} index={0} />,
);
expect(html).toContain('data-confidence-band="draft"');
expect(html).toMatch(/Draft/);
});
it("German locale renders translated low-confidence caption", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 35 }}
index={0}
initiallyExpanded
/>,
"de",
);
expect(html).toMatch(/Geringes Vertrauen/);
});
it("does NOT touch rec-feedback-slot (owned by B5e)", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 85 }}
index={0}
initiallyExpanded
/>,
);
// The feedback slot is still present, but B5d must not have
// injected anything into it.
expect(html).toMatch(/data-slot="rec-feedback-slot"[^>]*>\s*<\/div>/);
});
});
@@ -17,6 +17,7 @@ import {
type RecommendationFeedbackSeverity,
type RecommendationFeedbackTimeRange,
} from "./recommendation-feedback";
import { ConfidenceMeter } from "./confidence-meter";
/**
* v1.4.16 phase B5c — Oura-style RecommendationCard.
@@ -68,6 +69,13 @@ interface NormalisedRec {
metricSource?: { type: string; timeRange: string; summary: string };
referenceId?: string;
id?: string;
/**
* v1.4.16 phase B5d — server-computed confidence (0-100). Optional
* because legacy cached payloads predate the field; the rec card's
* confidence slot stays empty in that case rather than tagging
* legacy recs as "draft".
*/
confidence?: number;
}
function normalise(rec: InsightRecommendation): NormalisedRec {
@@ -79,9 +87,20 @@ function normalise(rec: InsightRecommendation): NormalisedRec {
metricSource: rec.metricSource,
referenceId: rec.referenceId,
id: rec.id,
confidence: rec.confidence,
};
}
/**
* v1.4.16 phase B5d — caption threshold. Below 50, the expanded
* rationale card surfaces a "Low confidence — based on limited data"
* sentence so the rec stays visible but framed as preliminary.
* Above 50 the meter alone speaks; below 25 the meter is replaced by
* a draft pill (handled inside `<ConfidenceMeter>`) and the caption
* still applies (draft <= low).
*/
const LOW_CONFIDENCE_CAPTION_THRESHOLD = 50;
/**
* Map a `metricSource.type` value to the chart-types[] the dashboard
* uses. The model speaks snapshot-key vocabulary ("bloodPressure",
@@ -174,12 +193,14 @@ function RationaleCard({
rationale,
metricSource,
referenceId,
confidence,
locale,
feedbackProps,
}: {
rationale: InsightRecommendationRationale;
metricSource: { type: string; timeRange: string; summary: string } | undefined;
referenceId: string | undefined;
confidence: number | undefined;
locale: Locale;
feedbackProps: {
recId: string;
@@ -192,6 +213,9 @@ function RationaleCard({
const { t } = useTranslations();
const metricType = metricSource?.type;
const chartTypes = metricTypeToChartTypes(metricType);
const showLowConfidenceCaption =
typeof confidence === "number" &&
confidence < LOW_CONFIDENCE_CAPTION_THRESHOLD;
return (
<div
@@ -227,6 +251,15 @@ function RationaleCard({
/>
) : null}
{showLowConfidenceCaption && (
<p
data-slot="rec-low-confidence-caption"
className="text-muted-foreground text-xs italic"
>
{t("insights.recommendation.confidenceLow")}
</p>
)}
{referenceId && (
<div>
<CitationFootnote referenceId={referenceId} locale={locale} />
@@ -326,7 +359,11 @@ export function RecommendationCard({
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span data-slot="rec-confidence-slot" />
<span data-slot="rec-confidence-slot">
{typeof norm.confidence === "number" && (
<ConfidenceMeter value={norm.confidence} />
)}
</span>
{expandable && (
<button
type="button"
@@ -359,6 +396,7 @@ export function RecommendationCard({
rationale={norm.rationale}
metricSource={norm.metricSource}
referenceId={norm.referenceId}
confidence={norm.confidence}
locale={locale}
feedbackProps={feedbackProps}
/>