Merge branch 'feat/biomarker-detail-page' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:42:04 +02:00
commit 95ee088646
36 files changed
+2209 -147

No files matched your search

@@ -0,0 +1,142 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
BiomarkerDto,
LabResultDto,
LabResultListResponse,
} from "../types";
/**
* The per-biomarker detail page mirrors the metric sub-pages: a numbers-first
* stat strip over the numeric readings, the (lazy) chart, the history, and the
* AI assessment card. This test seeds the marker + reading queries and asserts
* the strip, the chart placeholder, and the assessment card all paint.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ insightStatus: true }),
}));
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
const BIOMARKER_ID = "bm-1";
const MARKER: BiomarkerDto = {
id: BIOMARKER_ID,
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: 0,
upperBound: 100,
panel: null,
hasContext: false,
context: null,
hidden: false,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
};
function reading(id: string, value: number, takenAt: string): LabResultDto {
return {
id,
biomarkerId: BIOMARKER_ID,
panel: null,
analyte: "LDL Cholesterol",
value,
valueText: null,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
takenAt,
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: takenAt,
updatedAt: takenAt,
};
}
const LIST: LabResultListResponse = {
results: [
reading("r3", 95, "2026-06-20T08:00:00.000Z"),
reading("r2", 88, "2026-05-20T08:00:00.000Z"),
reading("r1", 102, "2026-04-20T08:00:00.000Z"),
],
meta: { total: 3, limit: 500, offset: 0 },
};
function renderWithCache() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER);
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: BIOMARKER_ID,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
LIST,
);
queryClient.setQueryData(
queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"),
{
hasProvider: true,
text: "Your LDL is steady inside the reference range.",
cached: true,
updatedAt: "2026-06-20T09:00:00.000Z",
},
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabBiomarkerDetail biomarkerId={BIOMARKER_ID} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabBiomarkerDetail>", () => {
const html = renderWithCache();
it("paints the marker heading", () => {
expect(html).toContain("LDL Cholesterol");
});
it("paints the numbers-first stat strip", () => {
expect(html).toContain('data-slot="metric-stat-strip"');
});
it("paints the chart placeholder (lazy chart shell)", () => {
// The recharts chart is `next/dynamic({ ssr: false })`; the server render
// paints the layout-stable skeleton shell in its place.
expect(html).toContain('data-slot="skeleton"');
});
it("paints the assessment card", () => {
// useMounted() is false during SSR, so the card renders its loading
// skeleton geometry — proof the assessment card mounted on the page.
expect(html).toContain('data-testid="insight-status-card-loading"');
});
});
+194 -7
View File
@@ -1,18 +1,26 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,11 +73,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
// It belongs on the detail page beneath the heading (mirroring the metric
// pages' explainer caption). Resolve the catalog slug from the marker name
// exactly as the overview did, then fall back to the user's own `context`.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
}
return map;
}, [t]);
const [addOpen, setAddOpen] = useState(false);
// Sticky-footer slot for the add-value sheet (the form portals here).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
// v1.24 — focused "adjust target range" sheet. Edits only the reference
// bounds (the full marker editor lives behind the pencil); seeded from the
// marker each time it opens.
const [rangeOpen, setRangeOpen] = useState(false);
const [lowerInput, setLowerInput] = useState("");
const [upperInput, setUpperInput] = useState("");
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
@@ -96,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
),
});
const readings: LabResultDto[] = list?.results ?? [];
const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]);
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
@@ -108,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
)[0]
: null;
// v1.24 — the numbers-first stat strip + the AI assessment card mirror the
// metric sub-pages. The strip reads a client-side `DataSummary` over the
// numeric readings; the assessment card consumes the read-only biomarker
// route (stale-while-revalidate, regenerated only on a new reading). The
// strip + card both self-gate on data, so a qualitative-only or brand-new
// marker paints neither.
const numericReadings = useMemo(
() => readings.filter((r) => r.value !== null),
[readings],
);
const summary = useMemo<DataSummary | null>(
() =>
numericReadings.length > 0
? summarize(
numericReadings.map((r) => ({
date: new Date(r.takenAt),
value: r.value as number,
})),
)
: null,
[numericReadings],
);
const mounted = useMounted();
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
@@ -138,6 +190,33 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
function openRange() {
setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : "");
setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : "");
setRangeOpen(true);
}
const saveRange = useMutation({
mutationFn: () => {
const lower = lowerInput.trim() === "" ? null : Number(lowerInput);
const upper = upperInput.trim() === "" ? null : Number(upperInput);
return apiPut(`/api/biomarkers/${biomarkerId}`, {
lowerBound: lower,
upperBound: upper,
});
},
onSuccess: () => {
toast.success(t("labs.biomarker.targetRange.savedToast"));
setRangeOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
});
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
@@ -189,6 +268,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={openRange}
disabled={!marker}
aria-label={t("labs.biomarker.targetRange.title")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -223,9 +312,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : null}
{marker?.context ? (
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
{(() => {
const slug = marker?.name
? slugByName.get(marker.name.trim().toLowerCase())
: undefined;
const description = slug
? t(`labs.catalog.desc.${slug}`)
: (marker?.context ?? null);
return description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
) : null;
})()}
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
numeric readings, mirroring the metric sub-pages. Self-gating:
renders nothing for a qualitative-only or empty marker. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="pt-6">
@@ -272,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : null}
{/* AI assessment — the spine's closing block, mirroring the metric
sub-pages (intro → stat strip → chart → history → assessment). The
card self-suppresses when the operator disabled status cards, and
the hook is gated on the marker having numeric readings. */}
{numericReadings.length > 0 ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<FlaskConical className="h-5 w-5" />}
text={assessment?.text ?? null}
hasProvider={assessment?.hasProvider ?? false}
updatedAt={assessment?.updatedAt ?? null}
coachQuestion={
marker?.name
? t("insights.coach.assessmentPrompt", { metric: marker.name })
: undefined
}
coachAutoSend
loading={!mounted || assessmentLoading}
preparing={assessment?.preparing ?? false}
/>
) : null}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
@@ -310,6 +441,62 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
/>
) : null}
</ResponsiveSheet>
<ResponsiveSheet
open={rangeOpen}
onOpenChange={setRangeOpen}
title={t("labs.biomarker.targetRange.title")}
description={t("labs.biomarker.targetRange.description")}
footer={
<div className="flex w-full justify-end gap-2">
<Button
variant="outline"
onClick={() => setRangeOpen(false)}
disabled={saveRange.isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => saveRange.mutate()}
disabled={saveRange.isPending}
>
{t("labs.biomarker.targetRange.save")}
</Button>
</div>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower-bound">
{t("labs.biomarker.form.lowerBound")}
</Label>
<Input
id="biomarker-lower-bound"
type="number"
inputMode="decimal"
value={lowerInput}
onChange={(e) => setLowerInput(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-upper-bound">
{t("labs.biomarker.form.upperBound")}
</Label>
<Input
id="biomarker-upper-bound"
type="number"
inputMode="decimal"
value={upperInput}
onChange={(e) => setUpperInput(e.target.value)}
/>
</div>
</div>
{marker?.unit ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("labs.biomarker.form.rangeHint")}
</p>
) : null}
</ResponsiveSheet>
</div>
);
}
+145 -46
View File
@@ -1,17 +1,26 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { DeleteButton, SortableHead } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import {
ApiError,
apiDelete,
@@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
// The sample-date column opens descending (newest first); every other column
// opens ascending.
const LAB_DESC_COLUMNS = new Set(["takenAt"]);
// Stable ordering for the reference-range column so a sort produces a
// predictable below → in-range → above → unknown progression.
const RANGE_RANK: Record<string, number> = {
below: 0,
"in-range": 1,
above: 2,
unknown: 3,
};
function toDateTimeLocal(iso: string): string {
const d = new Date(iso);
const offset = d.getTimezoneOffset();
@@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
const { sortBy, sortDir, toggleSort } = useTableSort({
defaultColumn: "takenAt",
defaultDir: "desc",
descColumns: LAB_DESC_COLUMNS,
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
// v1.18.9 — true when the row being edited is a qualitative reading; the
@@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}
const ordered = [...readings].sort(
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
);
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...readings].sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case "value": {
// Numeric rows compare by value; qualitative rows (value null) sort
// after the numeric ones and tie-break on their text.
if (a.value !== null && b.value !== null) {
cmp = a.value - b.value;
} else if (a.value === null && b.value === null) {
cmp = (a.valueText ?? "").localeCompare(b.valueText ?? "");
} else {
cmp = a.value === null ? 1 : -1;
}
break;
}
case "range":
cmp =
(RANGE_RANK[a.rangeStatus] ?? 99) -
(RANGE_RANK[b.rangeStatus] ?? 99);
break;
case "note":
cmp = Number(a.hasNote) - Number(b.hasNote);
break;
case "takenAt":
default:
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
break;
}
// Stable secondary ordering by sample date keeps ties deterministic.
if (cmp === 0) {
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
}
return cmp * dir;
});
}, [readings, sortBy, sortDir]);
return (
<>
<ul className="divide-border divide-y">
{ordered.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(r)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
</div>
<p className="text-muted-foreground text-xs">
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
{formatLabReading(r)}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG 2.5.5),
// compact 36px on desktop. The primary edit control for a row.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</li>
))}
</ul>
</TableCell>
<TableCell>
<ReferenceRangeBadge status={r.rangeStatus} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<ResponsiveSheet
open={editingId !== null}
+2 -30
View File
@@ -11,7 +11,6 @@ import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
@@ -81,23 +80,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
// (no catalog match) carry no subtitle rather than a fabricated one.
// v1.22.1 — build the localized name→slug map once per locale instead of
// re-scanning all catalog seeds (calling `t()` each) per group every render.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
}
return map;
}, [t]);
const describeMarker = (analyte: string): string | null => {
const norm = analyte.trim().toLowerCase();
if (!norm) return null;
const slug = slugByName.get(norm);
return slug ? t(`labs.catalog.desc.${slug}`) : null;
};
// v1.24 — the per-marker description moved to the biomarker detail page
// (beneath the heading); the overview rows no longer carry it.
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
@@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Card>
<CardContent className="divide-border divide-y p-0">
{groups.map((group) => {
const description = describeMarker(group.analyte);
const inner = (
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
@@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
compact
/>
</div>
{description ? (
<p className="text-muted-foreground text-xs">
{description}
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
@@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</li>
) : null}
{groups.map((group) => {
const description = describeMarker(group.analyte);
return (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
@@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
linkLabel={group.analyte}
/>
<CardContent>
{description ? (
<p className="text-muted-foreground mb-2 text-xs">
{description}
</p>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">