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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:40:29 +02:00
1 parent af21d4d4a8
commit 7ec10308b8
4 files changed
+368 -2

No files matched your search

+7 -1
View File
@@ -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
View File
@@ -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>
);
}