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

@@ -125,7 +125,7 @@ export function AppLogPreviewSection() {
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
@@ -133,13 +133,12 @@ export function AppLogPreviewSection() {
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
<p className="text-sm">
{t("admin.section.app-logs.processDetail")}
</p>
</Button>
}
/>
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="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">
+6 -1
View File
@@ -89,7 +89,12 @@ export function SettingsCardHeader({
{titleAccessory}
</div>
{status ? (
<div className="flex shrink-0 items-center gap-2">{status}</div>
// `max-w-full` caps the slot at the row once it wraps under the
// title, so a status line longer than a phone row wraps inside
// the card instead of widening the page.
<div className="flex max-w-full shrink-0 items-center gap-2">
{status}
</div>
) : null}
</div>
{/* One sentence, one text run. This slot deliberately carries no
@@ -294,7 +294,7 @@ function AddProviderControl({
const [picked, setPicked] = useState<string>(addable[0] ?? "");
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={t("settings.ai.providerChain.addProvider")}
value={picked}
@@ -869,7 +869,7 @@ function SortableWidgetRow({
style={style}
data-slot="widget-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
@@ -898,7 +898,12 @@ function SortableWidgetRow({
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
{/* On a phone the two switches and two arrows (~250 px) left the name
five characters ("Gewic…"). Below `sm` the controls wrap onto a
second line, right-aligned under the column heads, and the name
keeps the first line to itself. */}
<span className="basis-full sm:hidden" aria-hidden="true" />
<div className="flex w-12 justify-center max-sm:ml-auto">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
+1 -2
View File
@@ -234,8 +234,6 @@ export function LowStockCard({
disabled={!isAuthenticated || saving}
aria-label={t("notifications.lowStock.toggleAria")}
/>
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.lowStock.statusOn")
@@ -244,6 +242,7 @@ export function LowStockCard({
</label>
}
/>
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
{enabled && (
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-days" className="text-sm font-medium">
@@ -193,8 +193,6 @@ export function MoodReminderCard({
disabled={!isAuthenticated || saving}
aria-label={t("notifications.moodReminder.toggleAria")}
/>
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.moodReminder.statusOn")
@@ -203,6 +201,7 @@ export function MoodReminderCard({
</label>
}
/>
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
{enabled && (
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
@@ -112,8 +112,11 @@ export function ReportScopePicker({
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. */}
<div className="flex items-start justify-between gap-3">
come back to the standard set after experimenting. On a phone the
action's own label is wider than half the row, so beside it the
sentence shrank to a one-word column and the button ran past the
card edge: there the action drops under the sentence instead. */}
<div className="flex items-start justify-between gap-3 max-sm:flex-col">
<p
id={hintId}
className="text-muted-foreground min-w-0 flex-1 text-xs"
+53 -15
View File
@@ -26,6 +26,15 @@ import { cn } from "@/lib/utils";
* shells hand-rolled the whole block to get the second copy, which is how
* they drifted apart from every module page in the first place.
*/
/**
* A one-word German title ("Krankheitstagebuch") is wider than the column a
* phone leaves beside the actions, and without a break opportunity it ran
* underneath them. `hyphens-auto` breaks it by the page's `lang`; the
* `wrap-break-word` floor covers a word the hyphenation dictionary does not.
*/
const HEADING_CLASS =
"min-w-0 text-2xl font-bold tracking-tight hyphens-auto wrap-break-word";
export function PageHeader({
title,
titleId,
@@ -34,6 +43,7 @@ export function PageHeader({
backLink,
topSlot,
actions,
stackActionsOnPhone = false,
className,
}: {
title: ReactNode;
@@ -44,37 +54,65 @@ export function PageHeader({
/** Optional block above the title (e.g. a hub back-link the shell owns). */
topSlot?: ReactNode;
actions?: ReactNode;
/**
* Below `sm`, move the actions onto their own row under the description.
* For text-labelled actions (two outline links, say): beside the title they
* left the H1 a column narrower than its own word. Icon actions fit beside
* the title and leave this off.
*/
stackActionsOnPhone?: boolean;
className?: string;
}) {
const heading =
headingAs === "div" ? (
<div
id={titleId}
role="heading"
aria-level={1}
className="text-2xl font-bold tracking-tight"
>
<div id={titleId} role="heading" aria-level={1} className={HEADING_CLASS}>
{title}
</div>
) : (
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
<h1 id={titleId} className={HEADING_CLASS}>
{title}
</h1>
);
// Title, description and actions share one grid. On a phone the
// description spans the full row under the title + actions: a header with
// three or four icon actions otherwise left the sentence a ~130 px column
// that wrapped five or six times. From `sm` up the actions span both rows,
// so the description sits beside them exactly as before.
return (
<div className={cn("space-y-1.5", className)}>
{backLink ? <BackLink {...backLink} /> : null}
{topSlot}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
{heading}
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
<div
data-slot="page-header-row"
className={cn(
"grid items-start gap-x-3 gap-y-1.5",
actions ? "grid-cols-[minmax(0,1fr)_auto]" : "grid-cols-1",
actions && stackActionsOnPhone && "max-sm:grid-cols-1",
)}
>
{heading}
{actions ? (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
<div
className={cn(
"col-start-2 row-start-1 flex shrink-0 items-center gap-2",
description && "sm:row-span-2",
stackActionsOnPhone &&
"max-sm:col-span-full max-sm:col-start-1 max-sm:row-start-3 max-sm:flex-wrap",
)}
>
{actions}
</div>
) : null}
{description ? (
<p
className={cn(
"text-muted-foreground text-sm",
actions && "col-span-full sm:col-span-1",
)}
>
{description}
</p>
) : null}
</div>
</div>