mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useId } from "react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
DerivedConfidence,
|
|
DerivedConfidenceBand,
|
|
DerivedCoverage,
|
|
} from "@/lib/insights/derived/types";
|
|
|
|
/**
|
|
* v1.10.0 — the single reusable data-confidence indicator.
|
|
*
|
|
* `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs`
|
|
* ratio off `Derived<T>.coverage`, tinted by the `confidence.band`, with an
|
|
* accessible breakdown tooltip (present vs required inputs, history days,
|
|
* and the named missing inputs). This is the one place the whole app speaks
|
|
* data-confidence — every score tile, anatomy view, correlation card,
|
|
* briefing and report header renders it identically.
|
|
*
|
|
* It is the load-bearing graceful-degradation primitive: a partial score is
|
|
* shown WITH its confidence, never silently downgraded and never blank. The
|
|
* dots read as intentional ("4 of 5 inputs · 80%"), not apologetic.
|
|
*
|
|
* CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime.
|
|
*/
|
|
|
|
export interface CoverageMeterProps {
|
|
coverage: DerivedCoverage;
|
|
/**
|
|
* Optional confidence facet. When present, the dots take the band tint
|
|
* and the percent label reads the confidence score; when omitted the
|
|
* meter falls back to the present/required ratio for both.
|
|
*/
|
|
confidence?: DerivedConfidence;
|
|
/** Total number of dots rendered. Defaults to 5 (the `●●●●○` grammar). */
|
|
dots?: number;
|
|
/** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */
|
|
size?: "sm" | "md";
|
|
/** Optional className applied to the outer wrapper. */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Dot tint per confidence band. Points at the *semantic* feedback tokens
|
|
* (`--success` / `--warning` / `--destructive`), not the raw `--dracula-*`
|
|
* primitives, so the dots track the AA-safe `:root.light` overrides on the
|
|
* white card — straight `bg-dracula-yellow` cleared barely 1.1:1 on white
|
|
* and read near-invisible. The four bands collapse onto the three semantic
|
|
* severities (medium + low both ride the caution `--warning`); the lit-dot
|
|
* count + the percent label carry the medium↔low distinction, never colour
|
|
* alone.
|
|
*/
|
|
const BAND_DOT_CLASS: Record<DerivedConfidenceBand, string> = {
|
|
high: "bg-success",
|
|
medium: "bg-warning",
|
|
low: "bg-warning",
|
|
draft: "bg-destructive",
|
|
};
|
|
|
|
function clampInt(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.max(min, Math.min(max, Math.round(value)));
|
|
}
|
|
|
|
export function CoverageMeter({
|
|
coverage,
|
|
confidence,
|
|
dots = 5,
|
|
size = "sm",
|
|
className,
|
|
}: CoverageMeterProps) {
|
|
const { t } = useTranslations();
|
|
const tooltipId = useId();
|
|
|
|
const required = clampInt(coverage.requiredInputs, 0, 999);
|
|
const present = clampInt(coverage.presentInputs, 0, required || 999);
|
|
const totalDots = clampInt(dots, 1, 12);
|
|
|
|
// The fill ratio prefers the explicit confidence score (so the meter
|
|
// tracks the same number the band is derived from); without it, fall
|
|
// back to the present/required ratio.
|
|
const ratio =
|
|
confidence != null
|
|
? clampInt(confidence.score, 0, 100) / 100
|
|
: required > 0
|
|
? present / required
|
|
: 0;
|
|
const litDots = clampInt(ratio * totalDots, 0, totalDots);
|
|
const percent = Math.round(ratio * 100);
|
|
const band: DerivedConfidenceBand = confidence?.band ?? "low";
|
|
const dotLit = BAND_DOT_CLASS[band];
|
|
|
|
const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2";
|
|
|
|
const summaryLabel = t("insights.derived.coverage.summary", {
|
|
present,
|
|
required,
|
|
percent,
|
|
});
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-slot="coverage-meter"
|
|
data-band={band}
|
|
data-present={present}
|
|
data-required={required}
|
|
data-percent={percent}
|
|
aria-label={summaryLabel}
|
|
aria-describedby={tooltipId}
|
|
className={cn(
|
|
// 44px hit target via padding while the dots stay optically
|
|
// small; negative margin collapses the row back so the meter
|
|
// doesn't inflate its host row.
|
|
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
|
|
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
|
className,
|
|
)}
|
|
>
|
|
<span
|
|
data-slot="coverage-meter-dots"
|
|
className="inline-flex items-center gap-[3px]"
|
|
aria-hidden="true"
|
|
>
|
|
{Array.from({ length: totalDots }).map((_, i) => {
|
|
const isLit = i < litDots;
|
|
return (
|
|
<span
|
|
key={i}
|
|
data-dot-index={i}
|
|
data-dot-state={isLit ? "lit" : "unlit"}
|
|
className={cn(
|
|
"rounded-full",
|
|
dotSize,
|
|
isLit ? dotLit : "bg-muted/40",
|
|
)}
|
|
/>
|
|
);
|
|
})}
|
|
</span>
|
|
<span
|
|
data-slot="coverage-meter-label"
|
|
className={cn(
|
|
"text-muted-foreground tabular-nums",
|
|
size === "md" ? "text-xs" : "text-[11px]",
|
|
)}
|
|
>
|
|
{t("insights.derived.coverage.ratioLabel", { present, required })}
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
id={tooltipId}
|
|
data-slot="coverage-meter-tooltip"
|
|
className="bg-muted border-border text-foreground max-w-[15rem]"
|
|
>
|
|
<div className="space-y-1 text-xs">
|
|
<p className="font-medium">{summaryLabel}</p>
|
|
<p className="text-muted-foreground">
|
|
{t("insights.derived.coverage.historyDays", {
|
|
count: clampInt(coverage.historyDays, 0, 99999),
|
|
})}
|
|
</p>
|
|
{coverage.missing.length > 0 && (
|
|
<p
|
|
data-slot="coverage-meter-missing"
|
|
className="text-muted-foreground"
|
|
>
|
|
{t("insights.derived.coverage.missing", {
|
|
list: coverage.missing.join(", "),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|