mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user