mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(insights): ConfidenceMeter component (bars + ring + draft pill below 25)
Renders the deterministic 0..100 score from `computeConfidence()` as
either a 5-bar meter (default) or a 28x28 SVG ring. Color bands:
- >=80: green ("high"), 5 bars / full ring
- 50..79: yellow ("medium"), 3-4 bars
- 25..49: orange ("low"), 2 bars / partial ring
- <25: red "Draft" pill REPLACES the meter — Apple's
quiet-when-unsure affordance per research §2.A. The rec is
drafted but the model isn't asserting it.
Bars rise in height across the row (4 / 6 / 8 / 10 / 12 px) so the
"more is more" cue is independent of color, satisfying the
non-color-vision a11y baseline. SVG ring uses stroke-dasharray with
the dracula band-stroke per band so the same color story is reused.
aria-label always announces the numeric score
("Confidence: 67 of 100" / "Vertrauen: 67 von 100"), even in the
draft-pill state, so screenreaders aren't blind to the visual band
distinction.
Inputs are clamped: NaN / Infinity / negative -> 0 (draft); >100 ->
100 (high). The component is otherwise a pure render — no state, no
effects.
5 i18n keys per locale: confidence, confidenceAria (interpolates
{value}), confidenceHigh, confidenceMedium, confidenceLow,
confidenceDraft. EN+DE parity test green.
17 component tests cover all 5 value bands (high/medium/low/draft +
band edges), both variants (bars/ring), aria-label translation in
EN+DE, and clamp behaviour at the negative + >100 ends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af21d4d4a8
commit
7ec10308b8
4 files changed
+368
-2
No files matched your search
+7
-1
@@ -682,7 +682,13 @@
|
||||
"feedbackNotHelpful": "Nicht hilfreich",
|
||||
"feedbackThanks": "Danke für dein Feedback",
|
||||
"feedbackConfirmed": "Feedback gespeichert",
|
||||
"feedbackAlreadyRated": "Bereits bewertet"
|
||||
"feedbackAlreadyRated": "Bereits bewertet",
|
||||
"confidence": "Vertrauen",
|
||||
"confidenceAria": "Vertrauen: {value} von 100",
|
||||
"confidenceHigh": "Hohes Vertrauen",
|
||||
"confidenceMedium": "Mittleres Vertrauen",
|
||||
"confidenceLow": "Geringes Vertrauen — wenige Datenpunkte",
|
||||
"confidenceDraft": "Entwurf"
|
||||
},
|
||||
"dataFoundation": "Datengrundlage",
|
||||
"dataGaps": "Datenlücken:",
|
||||
|
||||
+7
-1
@@ -682,7 +682,13 @@
|
||||
"feedbackNotHelpful": "Not helpful",
|
||||
"feedbackThanks": "Thanks for your feedback",
|
||||
"feedbackConfirmed": "Feedback recorded",
|
||||
"feedbackAlreadyRated": "Already rated"
|
||||
"feedbackAlreadyRated": "Already rated",
|
||||
"confidence": "Confidence",
|
||||
"confidenceAria": "Confidence: {value} of 100",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceMedium": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence — based on limited data",
|
||||
"confidenceDraft": "Draft"
|
||||
},
|
||||
"dataFoundation": "Data coverage",
|
||||
"dataGaps": "Data gaps:",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ConfidenceMeter } from "../confidence-meter";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5d — `<ConfidenceMeter>`.
|
||||
*
|
||||
* Two variants:
|
||||
* - `bars` (default): five vertical bars, lit count = ceil(value/20).
|
||||
* 0..20 = 1, 21..40 = 2, 41..60 = 3, 61..80 = 4, 81..100 = 5.
|
||||
* - `ring`: SVG ring, fill proportional to value/100.
|
||||
*
|
||||
* Color bands match research §2.A "three-band visual" (extended to 4
|
||||
* for resolution under 50):
|
||||
* - >=80 → green
|
||||
* - 50..79 → yellow
|
||||
* - 25..49 → orange
|
||||
* - <25 → red, AND the meter is replaced with a "draft" pill so the
|
||||
* UI signals "model is unsure, not asserting"
|
||||
*
|
||||
* aria-label translates correctly per locale ("Confidence: 67 of 100").
|
||||
* Mobile-friendly: meter components fit within 96 px wide.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ConfidenceMeter> bars variant (default)", () => {
|
||||
it("renders 5 lit bars at value=100", () => {
|
||||
const html = render(<ConfidenceMeter value={100} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(5);
|
||||
});
|
||||
|
||||
it("renders 4 lit bars at value=80", () => {
|
||||
const html = render(<ConfidenceMeter value={80} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(4);
|
||||
});
|
||||
|
||||
it("renders 3 lit bars at value=60", () => {
|
||||
const html = render(<ConfidenceMeter value={60} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(3);
|
||||
});
|
||||
|
||||
it("renders 2 lit bars at value=40", () => {
|
||||
const html = render(<ConfidenceMeter value={40} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(2);
|
||||
});
|
||||
|
||||
it("renders 2 lit bars at value=25 (lowest non-draft band)", () => {
|
||||
// Below 25 the meter is replaced by a draft pill. The bars-only
|
||||
// floor is therefore at 25 — and ceil(25/20) = 2.
|
||||
const html = render(<ConfidenceMeter value={25} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(2);
|
||||
});
|
||||
|
||||
it("uses green band class at value>=80", () => {
|
||||
const html = render(<ConfidenceMeter value={85} />);
|
||||
expect(html).toContain('data-confidence-band="high"');
|
||||
expect(html).toMatch(/dracula-green/);
|
||||
});
|
||||
|
||||
it("uses yellow band class at value 50..79", () => {
|
||||
const html = render(<ConfidenceMeter value={65} />);
|
||||
expect(html).toContain('data-confidence-band="medium"');
|
||||
expect(html).toMatch(/dracula-yellow/);
|
||||
});
|
||||
|
||||
it("uses orange band class at value 25..49", () => {
|
||||
const html = render(<ConfidenceMeter value={35} />);
|
||||
expect(html).toContain('data-confidence-band="low"');
|
||||
expect(html).toMatch(/dracula-orange/);
|
||||
});
|
||||
|
||||
it("renders the 'draft' pill INSTEAD of bars when value<25", () => {
|
||||
const html = render(<ConfidenceMeter value={15} />);
|
||||
// No bars drawn at all
|
||||
expect(html).not.toMatch(/data-bar-state="lit"/);
|
||||
// Pill rendered
|
||||
expect(html).toContain('data-confidence-band="draft"');
|
||||
expect(html).toMatch(/Draft|Entwurf/);
|
||||
});
|
||||
|
||||
it("the draft pill is also rendered at value=0", () => {
|
||||
const html = render(<ConfidenceMeter value={0} />);
|
||||
expect(html).toContain('data-confidence-band="draft"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfidenceMeter> ring variant", () => {
|
||||
it("renders an SVG when variant=ring", () => {
|
||||
const html = render(<ConfidenceMeter value={67} variant="ring" />);
|
||||
expect(html).toContain("<svg");
|
||||
expect(html).toContain('data-confidence-band="medium"');
|
||||
});
|
||||
|
||||
it("ring variant respects the draft band below 25", () => {
|
||||
const html = render(<ConfidenceMeter value={10} variant="ring" />);
|
||||
expect(html).toContain('data-confidence-band="draft"');
|
||||
// Same draft pill rule as bars.
|
||||
expect(html).not.toContain("<svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfidenceMeter> aria-label", () => {
|
||||
it("EN: 'Confidence: 67 of 100'", () => {
|
||||
const html = render(<ConfidenceMeter value={67} />);
|
||||
expect(html).toMatch(/aria-label="Confidence: 67 of 100"/);
|
||||
});
|
||||
|
||||
it("DE: 'Vertrauen: 67 von 100'", () => {
|
||||
const html = render(<ConfidenceMeter value={67} />, "de");
|
||||
expect(html).toMatch(/aria-label="Vertrauen: 67 von 100"/);
|
||||
});
|
||||
|
||||
it("draft state aria-label still announces the score (so screenreaders aren't blind to it)", () => {
|
||||
const html = render(<ConfidenceMeter value={15} />);
|
||||
expect(html).toMatch(/aria-label="Confidence: 15 of 100"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfidenceMeter> input clamping", () => {
|
||||
it("clamps negative value to 0 (renders draft pill)", () => {
|
||||
const html = render(<ConfidenceMeter value={-5} />);
|
||||
expect(html).toContain('data-confidence-band="draft"');
|
||||
});
|
||||
|
||||
it("clamps value>100 to 100 (renders 5 bars, high band)", () => {
|
||||
const html = render(<ConfidenceMeter value={250} />);
|
||||
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(5);
|
||||
expect(html).toContain('data-confidence-band="high"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5d — `<ConfidenceMeter>`.
|
||||
*
|
||||
* Renders a deterministic 0..100 confidence score (server-computed by
|
||||
* `computeConfidence()` and overridden in `generateInsight()`) as
|
||||
* either a 5-bar meter (default) or an SVG ring.
|
||||
*
|
||||
* Color bands match research §2.A "three-band visual" extended to 4
|
||||
* bands so the under-50 region splits into "low" + "draft":
|
||||
*
|
||||
* - >=80: green ("high"), 5 bars / full ring
|
||||
* - 50..79: yellow ("medium"), 3-4 bars
|
||||
* - 25..49: orange ("low"), 2 bars / partial ring + caption
|
||||
* - <25: "draft" pill REPLACES the meter — Apple's
|
||||
* "quiet-when-unsure" affordance per research §2.A. The rec is
|
||||
* drafted but the model isn't asserting it.
|
||||
*
|
||||
* aria-label always announces the numeric score so a screen reader
|
||||
* isn't blind to the visual band distinction. Mobile-friendly: the
|
||||
* bars variant fits in 56 px wide; the ring variant in 28×28.
|
||||
*/
|
||||
|
||||
export type ConfidenceBand = "high" | "medium" | "low" | "draft";
|
||||
export type ConfidenceMeterVariant = "bars" | "ring";
|
||||
|
||||
export interface ConfidenceMeterProps {
|
||||
/** Score from `computeConfidence()` — 0..100, integer expected. */
|
||||
value: number;
|
||||
/** Visual variant. Defaults to `bars`. */
|
||||
variant?: ConfidenceMeterVariant;
|
||||
/** Optional className applied to the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DRAFT_THRESHOLD = 25;
|
||||
|
||||
function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function bandFor(value: number): ConfidenceBand {
|
||||
if (value < DRAFT_THRESHOLD) return "draft";
|
||||
if (value < 50) return "low";
|
||||
if (value < 80) return "medium";
|
||||
return "high";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit-bar count = ceil(value / 20). 0..20 is 1, 21..40 is 2, etc.
|
||||
* Note: when band is "draft" we don't render bars at all, so this
|
||||
* helper is only consulted for non-draft bands. We still clamp to
|
||||
* [1, 5] to defend against off-by-one drift.
|
||||
*/
|
||||
function litBarsFor(value: number): number {
|
||||
const lit = Math.ceil(value / 20);
|
||||
if (lit < 1) return 1;
|
||||
if (lit > 5) return 5;
|
||||
return lit;
|
||||
}
|
||||
|
||||
const BAR_LIT_COLOUR_BY_BAND: Record<Exclude<ConfidenceBand, "draft">, string> =
|
||||
{
|
||||
high: "bg-dracula-green",
|
||||
medium: "bg-dracula-yellow",
|
||||
low: "bg-dracula-orange",
|
||||
};
|
||||
|
||||
const RING_STROKE_COLOUR_BY_BAND: Record<
|
||||
Exclude<ConfidenceBand, "draft">,
|
||||
string
|
||||
> = {
|
||||
high: "stroke-dracula-green",
|
||||
medium: "stroke-dracula-yellow",
|
||||
low: "stroke-dracula-orange",
|
||||
};
|
||||
|
||||
function DraftPill({ ariaLabel }: { ariaLabel: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<span
|
||||
data-confidence-band="draft"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="bg-dracula-red/10 text-dracula-red border-dracula-red/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.recommendation.confidenceDraft")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsMeter({
|
||||
value,
|
||||
band,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: number;
|
||||
band: Exclude<ConfidenceBand, "draft">;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const lit = litBarsFor(value);
|
||||
const litColour = BAR_LIT_COLOUR_BY_BAND[band];
|
||||
return (
|
||||
<span
|
||||
data-confidence-band={band}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="inline-flex items-end gap-[2px]"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const isLit = i <= lit;
|
||||
// Bars rise in height to give a visual "more is more" cue
|
||||
// independent of colour (accessibility): 4 / 6 / 8 / 10 / 12 px.
|
||||
const heightClass = ["h-1", "h-1.5", "h-2", "h-2.5", "h-3"][i - 1];
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-bar-index={i}
|
||||
data-bar-state={isLit ? "lit" : "unlit"}
|
||||
className={`w-[3px] rounded-sm ${heightClass} ${
|
||||
isLit ? litColour : "bg-muted/40"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RingMeter({
|
||||
value,
|
||||
band,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: number;
|
||||
band: Exclude<ConfidenceBand, "draft">;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const stroke = RING_STROKE_COLOUR_BY_BAND[band];
|
||||
// Ring geometry: r=10, circumference=2π·10≈62.83. Fill ratio = value/100.
|
||||
const radius = 10;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const dashArray = circumference;
|
||||
const dashOffset = circumference * (1 - value / 100);
|
||||
return (
|
||||
<svg
|
||||
data-confidence-band={band}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="14"
|
||||
r={radius}
|
||||
className="stroke-muted/30"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="14"
|
||||
r={radius}
|
||||
className={stroke}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={dashArray}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 14 14)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfidenceMeter({
|
||||
value,
|
||||
variant = "bars",
|
||||
className,
|
||||
}: ConfidenceMeterProps) {
|
||||
const { t } = useTranslations();
|
||||
const clamped = clamp(value);
|
||||
const band = bandFor(clamped);
|
||||
const ariaLabel = t("insights.recommendation.confidenceAria", {
|
||||
value: clamped,
|
||||
});
|
||||
|
||||
const wrapperClass = `inline-flex items-center ${className ?? ""}`.trim();
|
||||
|
||||
if (band === "draft") {
|
||||
return (
|
||||
<span data-slot="confidence-meter" className={wrapperClass}>
|
||||
<DraftPill ariaLabel={ariaLabel} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-slot="confidence-meter" className={wrapperClass}>
|
||||
{variant === "ring" ? (
|
||||
<RingMeter value={clamped} band={band} ariaLabel={ariaLabel} />
|
||||
) : (
|
||||
<BarsMeter value={clamped} band={band} ariaLabel={ariaLabel} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user