Merge remote-tracking branch 'origin/main' into fix/v1397-derive

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:30:10 +02:00
commit 533b75fdcf
70 files changed
+4167 -566

No files matched your search

+4 -1
View File
@@ -163,7 +163,10 @@ export function LabBiomarkerChart({
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
// A reference window that starts at 0 (LDL "0–130") must not drag the
// axis below zero: a concentration cannot be negative, and a "-13" tick
// reads as a value. The pad only stays below zero when the data already is.
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
}, [points, lowerBound, upperBound, sourceBand]);
const animate = !prefersReducedMotion();
+4
View File
@@ -368,6 +368,10 @@ export function LabForm({
variant="outline"
data-action="save-and-add-another"
disabled={submitting}
// Three buttons do not fit one phone-width row: the secondary save
// takes a row of its own above Cancel + Save instead of pushing
// Cancel off the left edge of the sheet.
className="max-md:order-first max-md:w-full"
>
{pendingAction === "saveAndAddAnother" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+139 -94
View File
@@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}, [readings, sortBy, sortDir]);
// One action pair per reading, shared by the phone list and the table.
const renderActions = (r: LabResultDto) => (
<div className="flex items-center justify-end gap-1">
{canManageLabs && (
<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
domain="labs"
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>
);
return (
<>
<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">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</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">
{canManageLabs && (
<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
domain="labs"
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>
{/* Phones get one stacked row per reading. The table needs ~510 px for
its five columns, so on a phone it panned sideways inside its
container and parked the edit / delete buttons off-screen, while the
range bar's end labels were clipped by the same container. */}
<ul
data-slot="lab-history-mobile-list"
className="divide-border divide-y lg:hidden"
>
{ordered.map((r) => (
<li key={r.id} className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r)}
</div>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
{r.hasNote ? (
<span className="text-xs">{t("labs.hasNote")}</span>
) : null}
</div>
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
{renderActions(r)}
</li>
))}
</ul>
<div data-slot="lab-history-table" className="max-lg:hidden">
<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>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
{renderActions(r)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ResponsiveSheet
open={editingId !== null}
+57 -15
View File
@@ -80,7 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className="w-48">
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
<LabReferenceRangeBar
value={reading.value}
referenceLow={reading.referenceLow}
@@ -91,6 +91,35 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
);
}
/**
* The compact list is a five-column table from `lg` up: name and reading, range
* badge, range bar, trend, chevron. Below that the same five cells fold into
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
* trend + gaps) are wider than a phone, and the only flexible column — the
* name — was the one that gave way: it collapsed to nothing and the row
* panned sideways off the card.
*
* Phone layout, per row:
*
* name / reading · date badge ›
* range bar trend
*
* The placement classes are all `max-lg:` so the desktop table keeps exactly
* the subgrid it had.
*/
const ROW_CLASS =
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
const MOBILE_CELL = {
reading: "max-lg:col-start-1 max-lg:row-start-1",
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
trend:
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
end: "max-lg:col-start-3 max-lg:row-start-1",
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
@@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</p>
) : null}
<Card>
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] lg:gap-x-3">
{groups.map((group) => {
const inner = (
<div className="min-w-0 flex-1">
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="truncate font-medium">
<span
data-slot="lab-list-analyte"
className="truncate font-medium"
>
{group.analyte}
</span>
</div>
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
className={cn(
ROW_CLASS,
"hover:bg-muted/40 transition-colors",
)}
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0",
MOBILE_CELL.end,
)}
/>
</Link>
) : (
// Un-linked group (not backfilled to a catalog biomarker):
// render inert but say so, so it doesn't read as a broken
// link next to its clickable neighbours.
<div
key={group.key}
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
>
<div key={group.key} className={ROW_CLASS}>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span className="text-muted-foreground shrink-0 text-xs italic">
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
+10 -2
View File
@@ -129,9 +129,17 @@ export function OcrRowEditor({
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
<Badge variant="outline" className="text-muted-foreground">
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
{t("labs.ocr.linksExisting", { name: row.analyte })}
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">