From 7ec10308b88941d480444f2d5a0bbb0636932878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:32:40 +0200 Subject: [PATCH] feat(insights): ConfidenceMeter component (bars + ring + draft pill below 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- messages/de.json | 8 +- messages/en.json | 8 +- .../__tests__/confidence-meter.test.tsx | 142 ++++++++++++ src/components/insights/confidence-meter.tsx | 212 ++++++++++++++++++ 4 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/__tests__/confidence-meter.test.tsx create mode 100644 src/components/insights/confidence-meter.tsx diff --git a/messages/de.json b/messages/de.json index 6f9e4d9a6..23b2a5c8f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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:", diff --git a/messages/en.json b/messages/en.json index ec4dfc294..a06d15b4e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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:", diff --git a/src/components/insights/__tests__/confidence-meter.test.tsx b/src/components/insights/__tests__/confidence-meter.test.tsx new file mode 100644 index 000000000..1e85c018e --- /dev/null +++ b/src/components/insights/__tests__/confidence-meter.test.tsx @@ -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 — ``. + * + * 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( + {node}, + ); +} + +describe(" bars variant (default)", () => { + it("renders 5 lit bars at value=100", () => { + const html = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(html).toContain('data-confidence-band="high"'); + expect(html).toMatch(/dracula-green/); + }); + + it("uses yellow band class at value 50..79", () => { + const html = render(); + expect(html).toContain('data-confidence-band="medium"'); + expect(html).toMatch(/dracula-yellow/); + }); + + it("uses orange band class at value 25..49", () => { + const html = render(); + 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(); + // 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(); + expect(html).toContain('data-confidence-band="draft"'); + }); +}); + +describe(" ring variant", () => { + it("renders an SVG when variant=ring", () => { + const html = render(); + expect(html).toContain(" { + const html = render(); + expect(html).toContain('data-confidence-band="draft"'); + // Same draft pill rule as bars. + expect(html).not.toContain(" aria-label", () => { + it("EN: 'Confidence: 67 of 100'", () => { + const html = render(); + expect(html).toMatch(/aria-label="Confidence: 67 of 100"/); + }); + + it("DE: 'Vertrauen: 67 von 100'", () => { + const html = render(, "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(); + expect(html).toMatch(/aria-label="Confidence: 15 of 100"/); + }); +}); + +describe(" input clamping", () => { + it("clamps negative value to 0 (renders draft pill)", () => { + const html = render(); + expect(html).toContain('data-confidence-band="draft"'); + }); + + it("clamps value>100 to 100 (renders 5 bars, high band)", () => { + const html = render(); + const lit = (html.match(/data-bar-state="lit"/g) ?? []).length; + expect(lit).toBe(5); + expect(html).toContain('data-confidence-band="high"'); + }); +}); diff --git a/src/components/insights/confidence-meter.tsx b/src/components/insights/confidence-meter.tsx new file mode 100644 index 000000000..b4b16d994 --- /dev/null +++ b/src/components/insights/confidence-meter.tsx @@ -0,0 +1,212 @@ +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.4.16 phase B5d — ``. + * + * 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, string> = + { + high: "bg-dracula-green", + medium: "bg-dracula-yellow", + low: "bg-dracula-orange", + }; + +const RING_STROKE_COLOUR_BY_BAND: Record< + Exclude, + string +> = { + high: "stroke-dracula-green", + medium: "stroke-dracula-yellow", + low: "stroke-dracula-orange", +}; + +function DraftPill({ ariaLabel }: { ariaLabel: string }) { + const { t } = useTranslations(); + return ( + + {t("insights.recommendation.confidenceDraft")} + + ); +} + +function BarsMeter({ + value, + band, + ariaLabel, +}: { + value: number; + band: Exclude; + ariaLabel: string; +}) { + const lit = litBarsFor(value); + const litColour = BAR_LIT_COLOUR_BY_BAND[band]; + return ( + + {[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 ( + + ); + })} + + ); +} + +function RingMeter({ + value, + band, + ariaLabel, +}: { + value: number; + band: Exclude; + 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 ( + + + + + ); +} + +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 ( + + + + ); + } + + return ( + + {variant === "ring" ? ( + + ) : ( + + )} + + ); +}