feat(glucose): add the clinical glucose panel

Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:21:25 +02:00
commit e561f8d893
26 files changed
+1868 -2

No files matched your search

@@ -12,6 +12,7 @@ import { ClinicianView } from "../clinician-view";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
function makeReport(
overrides: Partial<DoctorReportData> = {},
@@ -36,6 +37,9 @@ function makeReport(
},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-01-31T00:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: 24.5,
compliance: {},
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DataSummary } from "@/lib/analytics/trends";
import { GlucoseTirBar } from "../glucose-tir-bar";
import { GlucoseAdvancedDisclosure } from "../glucose-advanced-disclosure";
/** Minimal valid `DataSummary` — the panel + disclosure only read `count`. */
function summary(count: number): DataSummary {
return {
count,
latest: 95,
min: 60,
max: 140,
mean: 100,
median: 98,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
/**
* v1.17.0 — glucose clinical panel render tests.
*
* Covers the two states the maintainer called out: the calm "still learning"
* branch (no asserted TIR / GMI off thin spot data) and the asserted branch
* with the advanced progressive disclosure. The panel leans on `useAuth` +
* `useAnalyticsQuery`; both are mocked so the branch under test is
* deterministic. The TIR bar + advanced disclosure are pure props, tested
* directly.
*/
const authMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => authMock(),
}));
const analyticsMock = vi.fn();
vi.mock("@/lib/queries/use-analytics-query", () => ({
useAnalyticsQuery: () => analyticsMock(),
}));
// Import after the mocks are registered.
const { GlucoseClinicalPanel } = await import("../glucose-clinical-panel");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const NOW = new Date("2026-06-14T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
beforeEach(() => {
authMock.mockReturnValue({
user: { glucoseUnit: "mg/dL" },
isAuthenticated: true,
});
analyticsMock.mockReset();
});
describe("<GlucoseClinicalPanel> learning state", () => {
it("renders the calm still-learning copy and asserts NO TIR/GMI", () => {
// 5 readings over 4 days — below the 14-reading default floor.
const readings = [100, 110, 120, 130, 140].map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
expect(glucoseClinical.stillLearning).toBe(true);
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="learning"');
expect(html).toContain("Still learning your glucose");
// the asserted blocks must NOT be present in the learning state
expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
expect(html).not.toContain("Glucose Management Indicator");
// the spot-reading caveat is always carried
expect(html).toContain("spot-reading estimates");
});
it("renders the empty learning copy when there are zero readings", () => {
const glucoseClinical = computeGlucoseClinicalMetrics([], { now: NOW });
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="learning"');
expect(html).toContain("Once a handful of readings come in");
});
});
describe("<GlucoseClinicalPanel> asserted state", () => {
function assertedClinical() {
// 20 readings, one per day → clears the gate; a spread that lands in all
// three TIR bands so the bar has real segments and CV% is non-trivial.
const values = [
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160,
105, 115, 125, 135, 145,
];
const readings = values.map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
}
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
const glucoseClinical = assertedClinical();
expect(glucoseClinical.stillLearning).toBe(false);
analyticsMock.mockReturnValue({
isLoading: false,
data: {
glucoseClinical,
glucoseByContext: {
FASTING: summary(8),
},
},
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="asserted"');
expect(html).toContain('data-slot="glucose-tir-bar"');
expect(html).toContain("Glucose Management Indicator");
expect(html).toContain("Estimated A1C");
expect(html).toContain('data-slot="glucose-cv-badge"');
// advanced disclosure trigger is present (region collapsed by default in SSR)
expect(html).toContain('data-slot="glucose-advanced-toggle"');
expect(html).toContain("aria-expanded=\"false\"");
expect(html).toContain("J-index");
});
it("flags the CV badge unstable at CV% >= 36", () => {
// wide spread → CV well above 36
const values = Array.from({ length: 20 }, (_, i) =>
i % 2 === 0 ? 60 : 260,
);
const readings = values.map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
now: NOW,
windowDays: 30,
});
expect(glucoseClinical.variability?.unstable).toBe(true);
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-unstable="true"');
expect(html).toContain("Unstable");
});
it("renders nothing when the thick slice carries no glucoseClinical", () => {
analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
const html = render(<GlucoseClinicalPanel />);
expect(html).toBe("");
});
});
describe("<GlucoseTirBar>", () => {
it("renders one segment per non-empty Battelino band summing to 100%", () => {
const dist = computeGlucoseClinicalMetrics(
[40, 60, 120, 200, 300].map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - i * DAY),
mgdl,
})),
{ now: NOW, minReadings: 1, minSpanDays: 1 },
).distribution!;
const html = render(<GlucoseTirBar distribution={dist} />);
// very-low (40), low (60), in-range (120), high (200), very-high (300)
expect(html).toContain("glucose-tir-segment-veryLow");
expect(html).toContain("glucose-tir-segment-low");
expect(html).toContain("glucose-tir-segment-inRange");
expect(html).toContain("glucose-tir-segment-high");
expect(html).toContain("glucose-tir-segment-veryHigh");
// aria summary present
expect(html).toContain('role="img"');
});
});
describe("<GlucoseAdvancedDisclosure>", () => {
it("is collapsed by default and exposes a toggle + the three indices", () => {
const html = render(
<GlucoseAdvancedDisclosure
advanced={{ jIndex: 23.7, lbgi: 1.2, hbgi: 6.8 }}
byContext={{
FASTING: summary(1),
}}
/>,
);
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("hidden"); // region hidden in initial SSR state
expect(html).toContain("J-index");
expect(html).toContain("LBGI");
expect(html).toContain("HBGI");
// singular reading label for a single-reading context
expect(html).toContain("1 reading");
});
});
@@ -0,0 +1,159 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.17.0 — "advanced" progressive disclosure for the glucose panel.
*
* The default panel view leads with TIR / GMI / eA1C / CV%. The research-grade
* composites the maintainer chose for v1.17.0 — J-index (Wojcicki) and the
* Kovatchev low/high blood-glucose risk indices — live behind this disclosure
* so a casual user is not confronted with them, while a clinician or an engaged
* user can open them. Per-context reading counts ride along so the advanced
* view also answers "where do these readings come from". Values are computed
* server-side; this only renders them.
*
* No Collapsible primitive exists in the UI kit, so this is a minimal,
* accessible button + region disclosure (the same pattern other insights cards
* use): `aria-expanded` on the trigger, `aria-controls`/`hidden` on the region.
*/
const CONTEXT_LABEL_KEYS: Record<string, string> = {
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
};
export interface GlucoseAdvancedDisclosureProps {
advanced: NonNullable<GlucoseClinicalMetrics["advanced"]>;
/** Per-context summaries keyed by `GlucoseContext`; optional. */
byContext?: Record<string, DataSummary> | undefined;
/** Decimals for the index values. Defaults to 1. */
fractionDigits?: number;
}
export function GlucoseAdvancedDisclosure({
advanced,
byContext,
fractionDigits = 1,
}: GlucoseAdvancedDisclosureProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const regionId = useId();
const fmt = (v: number | null) =>
v === null ? "—" : v.toFixed(fractionDigits);
const contexts = Object.entries(byContext ?? {}).filter(
([, s]) => s && s.count > 0,
);
return (
<div data-slot="glucose-advanced" className="border-border border-t pt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="glucose-advanced-toggle"
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-between gap-2 text-sm font-medium transition-colors"
>
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
open && "rotate-180",
)}
/>
</button>
<div
id={regionId}
hidden={!open}
data-slot="glucose-advanced-region"
className="space-y-4 pt-3"
>
<p className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.advanced.intro")}
</p>
<dl className="grid grid-cols-3 gap-3">
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.jIndex.label")}
help={t("insights.bloodGlucose.clinical.advanced.jIndex.help")}
value={fmt(advanced.jIndex)}
/>
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.lbgi.label")}
help={t("insights.bloodGlucose.clinical.advanced.lbgi.help")}
value={fmt(advanced.lbgi)}
/>
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.hbgi.label")}
help={t("insights.bloodGlucose.clinical.advanced.hbgi.help")}
value={fmt(advanced.hbgi)}
/>
</dl>
{contexts.length > 0 ? (
<div className="space-y-2" data-slot="glucose-advanced-context">
<p className="text-foreground text-xs font-medium">
{t("insights.bloodGlucose.clinical.advanced.byContext.title")}
</p>
<ul className="text-muted-foreground space-y-1 text-xs">
{contexts.map(([ctx, summary]) => (
<li
key={ctx}
className="flex items-center justify-between gap-2"
>
<span>
{CONTEXT_LABEL_KEYS[ctx] ? t(CONTEXT_LABEL_KEYS[ctx]) : ctx}
</span>
<span className="tabular-nums">
{summary.count === 1
? t(
"insights.bloodGlucose.clinical.advanced.byContext.readingsShortOne",
)
: t(
"insights.bloodGlucose.clinical.advanced.byContext.readingsShort",
{ count: summary.count },
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
}
function AdvancedStat({
label,
help,
value,
}: {
label: string;
help: string;
value: string;
}) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs" title={help}>
{label}
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
</dd>
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import { Activity, Droplet, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import type { DataSummary } from "@/lib/analytics/trends";
import { GlucoseTirBar } from "./glucose-tir-bar";
import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure";
/**
* v1.17.0 — glucose clinical panel.
*
* The default view leads with the three numbers that matter to a person
* managing glucose: time-in-range (Battelino bands, as a stacked bar), a GMI +
* estimated-A1C pair, and a variability badge that flags "unstable" at CV% ≥ 36
* (Monnier). The advanced indices (J-index, LBGI/HBGI) sit behind a calm
* disclosure. Every number is server-computed by the one literature-locked
* engine — the panel renders verbatim and never re-derives.
*
* Honesty contract: HealthLog stores SPOT readings, not a CGM trace, so the
* copy always carries the "spot-reading estimate" caveat, and a thin window
* shows a warm "still learning" state instead of asserting a clinical profile.
* Tone follows the cycle-phase texts: warm, professional, grounded — never
* alarmist, never breezy.
*
* Reads the thick `/api/analytics` payload directly (the slim slice does not
* carry `glucoseClinical`); the cache cell is shared with the dashboard so the
* mount is usually a free hit.
*/
export function GlucoseClinicalPanel() {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const query = useAnalyticsQuery({});
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
if (!isAuthenticated) return null;
if (query.isLoading) {
return (
<Card data-slot="glucose-clinical-panel-skeleton">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-6 w-full" />
<div className="grid grid-cols-3 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
</CardContent>
</Card>
);
}
const clinical = query.data?.glucoseClinical;
// No glucose data at all, or the thick slice did not carry the block — the
// page's own empty state already covers the truly-no-data case, so render
// nothing rather than a redundant card.
if (!clinical) return null;
const byContext = query.data?.glucoseByContext as
| Record<string, DataSummary>
| undefined;
const formatGlucoseValue = (mgdl: number): string => {
const v = convertGlucose(mgdl, glucoseUnit);
return isMmol ? v.toFixed(1) : String(Math.round(v));
};
// Learning state — calm, never asserting TIR / GMI off thin data.
if (clinical.stillLearning) {
const hasAny = clinical.readingCount > 0;
const readingsLabel =
clinical.readingCount === 1
? t("insights.bloodGlucose.clinical.readingsCountOne")
: t("insights.bloodGlucose.clinical.readingsCount", {
count: clinical.readingCount,
});
const spanDays = Math.max(1, Math.round(clinical.actualSpanDays));
const daysLabel =
spanDays === 1
? t("insights.bloodGlucose.clinical.daysCountOne")
: t("insights.bloodGlucose.clinical.daysCount", { days: spanDays });
return (
<Card data-slot="glucose-clinical-panel" data-state="learning">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.learningTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
{hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
</p>
<p className="text-muted-foreground/80 text-xs">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
</CardContent>
</Card>
);
}
// Asserted view.
const dist = clinical.distribution;
const cv = clinical.variability;
return (
<Card data-slot="glucose-clinical-panel" data-state="asserted">
<CardHeader className="pb-2">
<div className="flex flex-col gap-0.5">
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.title")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.subtitle", {
days: Math.max(1, Math.round(clinical.actualSpanDays)),
count: clinical.readingCount,
})}
</span>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Time in range */}
{dist ? (
<section className="space-y-2" data-slot="glucose-tir-section">
<div className="flex items-center justify-between gap-2">
<h3 className="text-foreground text-sm font-medium">
{t("insights.bloodGlucose.clinical.tir.title")}
</h3>
</div>
<GlucoseTirBar distribution={dist} />
<p className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.tir.caption")}
</p>
</section>
) : null}
{/* Headline metrics */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-slot="glucose-headline">
{clinical.meanMgdl !== null ? (
<Metric
icon={<Droplet className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.mean.label")}
value={formatGlucoseValue(clinical.meanMgdl)}
unit={glucoseUnit}
/>
) : null}
{clinical.gmi !== null ? (
<Metric
icon={<Activity className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.gmi.label")}
help={t("insights.bloodGlucose.clinical.gmi.help")}
value={clinical.gmi.toFixed(1)}
unit="%"
/>
) : null}
{clinical.estimatedA1c !== null ? (
<Metric
icon={<TrendingUp className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.eA1c.label")}
help={t("insights.bloodGlucose.clinical.eA1c.help")}
value={clinical.estimatedA1c.toFixed(1)}
unit="%"
/>
) : null}
{cv ? (
<div className="space-y-0.5" data-slot="glucose-cv">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={t("insights.bloodGlucose.clinical.cv.help")}
>
{t("insights.bloodGlucose.clinical.cv.label")}
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
{Math.round(cv.cv)}%
</span>
<Badge
variant={cv.unstable ? "destructive" : "secondary"}
data-slot="glucose-cv-badge"
data-unstable={cv.unstable}
>
{cv.unstable
? t("insights.bloodGlucose.clinical.cv.unstable")
: t("insights.bloodGlucose.clinical.cv.steady")}
</Badge>
</div>
</div>
) : null}
</section>
{/* Spot-reading honesty caveat — always present. */}
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
{/* Advanced indices behind a disclosure. */}
{clinical.advanced ? (
<GlucoseAdvancedDisclosure
advanced={clinical.advanced}
byContext={byContext}
/>
) : null}
</CardContent>
</Card>
);
}
function Metric({
icon,
label,
help,
value,
unit,
}: {
icon: React.ReactNode;
label: string;
help?: string;
value: string;
unit: string;
}) {
return (
<div className="space-y-0.5">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={help}
>
{icon}
{label}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
<span className="text-muted-foreground text-xs">{unit}</span>
</span>
</div>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import type { TimeInRangeDistribution } from "@/lib/analytics/glucose-metrics";
/**
* v1.17.0 — Battelino 2019 time-in-range stacked bar.
*
* A single horizontal 100%-width bar split into the five consensus bands
* (very low / low / in range / high / very high), bottom-to-top severity
* read left-to-right with the in-range band centred and dominant. Fractions
* arrive already computed by the server engine — this component only renders
* them, it never re-derives a band. The level-2 sub-bands are NESTED inside
* level-1 in the source distribution (TBR2 ⊆ TBR1, TAR2 ⊆ TAR1), so the
* segment widths here use the EXCLUSIVE slices to fill exactly 100%.
*/
const BAND_COLORS = {
veryLow: "#ff5555", // dracula-red — urgent low
low: "#ffb86c", // dracula-orange — low
inRange: "#50fa7b", // dracula-green — target
high: "#f1fa8c", // dracula-yellow — high
veryHigh: "#ff79c6", // dracula-pink — urgent high
} as const;
type BandKey = keyof typeof BAND_COLORS;
export interface GlucoseTirBarProps {
distribution: TimeInRangeDistribution;
}
export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
const { t } = useTranslations();
// Exclusive slices from the nested consensus fractions: level-2 is a subset
// of level-1, so the level-1-only slice = level1 − level2.
const veryLow = distribution.tbrLevel2;
const low = Math.max(0, distribution.tbrLevel1 - distribution.tbrLevel2);
const inRange = distribution.tir;
const high = Math.max(0, distribution.tarLevel1 - distribution.tarLevel2);
const veryHigh = distribution.tarLevel2;
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
{ key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
{ key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
{ key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
{ key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
{ key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
];
const pct = (f: number) => Math.round(f * 100);
const ariaLabel = t("insights.bloodGlucose.clinical.tir.ariaLabel", {
tir: pct(inRange),
below: pct(distribution.tbrLevel1),
above: pct(distribution.tarLevel1),
});
return (
<div data-slot="glucose-tir-bar" className="space-y-2">
<div
role="img"
aria-label={ariaLabel}
className="bg-muted flex h-6 w-full overflow-hidden rounded-md"
>
{segments.map((s) =>
s.fraction > 0 ? (
<div
key={s.key}
data-slot={`glucose-tir-segment-${s.key}`}
className="h-full"
style={{
width: `${s.fraction * 100}%`,
background: BAND_COLORS[s.key],
}}
title={`${s.label} · ${pct(s.fraction)}%`}
/>
) : null,
)}
</div>
<ul
className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3"
data-slot="glucose-tir-legend"
>
{segments.map((s) => (
<li key={s.key} className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: BAND_COLORS[s.key] }}
/>
{s.label}
</span>
<span className="text-foreground tabular-nums font-medium">
{pct(s.fraction)}%
</span>
</li>
))}
</ul>
</div>
);
}
@@ -154,6 +154,15 @@ export interface HealthKitMetricPageProps {
* 90-day p50 is not read as an all-time central value.
*/
statMedianLabel?: string;
/**
* v1.17.0 — optional extra content rendered beneath the chart + target
* summary, before the metric-status card. Blood glucose mounts its clinical
* panel (TIR / GMI / eA1C / CV% + advanced indices) here. Only rendered on
* the data-bearing branch (the empty / loading / error branches skip it, so
* a no-data metric never shows a void panel). Additive: every other page
* omits it and renders byte-identically.
*/
afterChart?: ReactNode;
}
export function HealthKitMetricPage({
@@ -176,6 +185,7 @@ export function HealthKitMetricPage({
forecast = false,
statFractionDigits,
statMedianLabel,
afterChart,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -337,6 +347,9 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
panel). Mounted on the data-bearing branch only. */}
{afterChart}
{forecast && isTrajectoryType(measurementType) ? (
<TrajectoryForecastCard
type={measurementType}