From 63cfd8e2f79c8dea0c856d705208032a72ae5dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:34:59 +0200 Subject: [PATCH] feat(insights): RecommendationCard renders ConfidenceMeter in named slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `data-slot="rec-confidence-slot"` placeholder reserved by B5c is now filled with ``. 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 ``); 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 `` 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) --- .../recommendation-card-confidence.test.tsx | 141 ++++++++++++++++++ .../insights/recommendation-card.tsx | 40 ++++- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/components/insights/__tests__/recommendation-card-confidence.test.tsx diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx new file mode 100644 index 000000000..ad27d0ae0 --- /dev/null +++ b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx @@ -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: () =>
, +})); + +vi.mock("@/components/charts/mood-chart", () => ({ + MoodChart: () =>
, +})); + +/** + * v1.4.16 phase B5d — confidence meter wiring inside + * ``. + * + * The card reserves a `data-slot="rec-confidence-slot"` placeholder + * (B5c). B5d fills that slot with ``. 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 + * 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( + {node}, + ); +} + +describe(" — confidence slot wiring", () => { + it("renders ConfidenceMeter inside rec-confidence-slot when confidence is set", () => { + const html = render( + , + ); + // 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(); + // 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( + , + ); + expect(html).toContain('data-confidence-band="medium"'); + }); + + it("orange low band + low-confidence caption when value 25-49 and expanded", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).not.toMatch(/Low confidence/); + }); + + it("draft pill below 25 (rendered by ConfidenceMeter)", () => { + const html = render( + , + ); + expect(html).toContain('data-confidence-band="draft"'); + expect(html).toMatch(/Draft/); + }); + + it("German locale renders translated low-confidence caption", () => { + const html = render( + , + "de", + ); + expect(html).toMatch(/Geringes Vertrauen/); + }); + + it("does NOT touch rec-feedback-slot (owned by B5e)", () => { + const html = render( + , + ); + // 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>/); + }); +}); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 054853b12..b513d2076 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -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 ``) 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 (
) : null} + {showLowConfidenceCaption && ( +

+ {t("insights.recommendation.confidenceLow")} +

+ )} + {referenceId && (
@@ -326,7 +359,11 @@ export function RecommendationCard({
- + + {typeof norm.confidence === "number" && ( + + )} + {expandable && (