mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
533b75fdcf
70 files changed
+4167
-566
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user