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

+8 -1
View File
@@ -44,6 +44,12 @@ export interface AskCoachActionProps {
* briefing), which read better against the default all-source snapshot.
*/
scope?: CoachLaunchScope;
/**
* Auto-send the seeded question as the conversation's first turn instead
* of only seeding the composer. The assessment hand-off sets this so the
* Coach answers immediately; defaults to false (seed-only).
*/
autoSend?: boolean;
/** Optional visible-label override; defaults to the shared CTA copy. */
label?: string;
/** className passthrough for per-card alignment. */
@@ -53,6 +59,7 @@ export interface AskCoachActionProps {
export function AskCoachAction({
question,
scope,
autoSend,
label,
className,
}: AskCoachActionProps) {
@@ -76,7 +83,7 @@ export function AskCoachAction({
variant="ghost"
size="sm"
data-slot="ask-coach-action"
onClick={() => launch.askCoach(question, scope)}
onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
// Discreet: muted text, hover lifts to foreground, no fill. Tight
// height so it sits as a quiet footer link, not a primary CTA.
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useReducer, useState } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -117,6 +117,13 @@ export interface CoachConversationProps {
* established scope. Null → the route's default all-source snapshot.
*/
launchScope?: CoachLaunchScope | null;
/**
* When true, the `prefill` is dispatched as the conversation's first turn
* automatically, exactly once on mount (ref-guarded). Used by the
* assessment hand-off so the answer lands without a manual send. The send
* only fires for a fresh conversation with a non-empty prefill.
*/
autoSend?: boolean;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
@@ -188,6 +195,7 @@ export interface CoachConversationProps {
export function CoachConversation({
prefill,
launchScope,
autoSend,
renderTitle,
renderDescription,
leadingHeaderActions,
@@ -340,6 +348,33 @@ export function CoachConversation({
}
}
// Auto-send the prefill as the conversation's first turn, exactly once.
// A card hand-off (e.g. the assessment "ask about this") opens the Coach
// with `autoSend` so the answer lands without a manual send. Ref-guarded so
// it fires a single time per mount; only for a fresh conversation with a
// non-empty prefill and no stream in flight.
const autoSentRef = useRef(false);
// Latest-handler ref so the auto-send effect never lists `handleSubmit` (it
// re-creates each render) and never calls it synchronously in the effect
// body — the dispatch is deferred to a microtask so the composer state it
// touches settles outside the effect.
const handleSubmitRef = useRef(handleSubmit);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
});
useEffect(() => {
if (!autoSend) return;
if (autoSentRef.current) return;
if (currentConversationId !== null) return;
if (send.isStreaming) return;
const seed = (prefill ?? "").trim();
if (!seed) return;
autoSentRef.current = true;
queueMicrotask(() => {
void handleSubmitRef.current(seed);
});
}, [autoSend, prefill, currentConversationId, send.isStreaming]);
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
@@ -54,6 +54,11 @@ export interface CoachDrawerProps {
* source(s) + window. Null → the route's default all-source snapshot.
*/
scope?: CoachLaunchScope | null;
/**
* When true, the conversation auto-sends `prefill` as its first turn
* exactly once on open. Used by the assessment hand-off.
*/
autoSend?: boolean;
}
export function CoachDrawer({
@@ -61,6 +66,7 @@ export function CoachDrawer({
onOpenChange,
prefill,
scope,
autoSend,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
@@ -139,6 +145,7 @@ export function CoachDrawer({
surface="drawer"
prefill={prefill}
launchScope={scope}
autoSend={autoSend}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to a
@@ -69,6 +69,11 @@ interface InsightStatusCardProps {
*/
coachQuestion?: string;
coachScope?: CoachLaunchScope;
/**
* Auto-send the seeded opener as the first turn (assessment hand-off)
* instead of only seeding the composer. Defaults to false.
*/
coachAutoSend?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -83,6 +88,7 @@ export function InsightStatusCard({
preparing = false,
coachQuestion,
coachScope,
coachAutoSend,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -248,7 +254,11 @@ export function InsightStatusCard({
caller supplied an opener. */}
{coachQuestion ? (
<div className="flex justify-end">
<AskCoachAction question={coachQuestion} scope={coachScope} />
<AskCoachAction
question={coachQuestion}
scope={coachScope}
autoSend={coachAutoSend}
/>
</div>
) : null}
</CardContent>
@@ -56,6 +56,9 @@ export function LayoutCoachMount() {
// opened from a metric surface or insight card is pre-narrowed to
// the relevant source(s).
scope={launch.scope}
// Auto-send the prefill as the first turn when the launch requested it
// (assessment hand-off), so the answer lands without a manual send.
autoSend={launch.autoSend}
/>
);
}
@@ -46,12 +46,9 @@ export function MetricStatusCard({
// carry a snapshot block. Unmapped metrics still get a metric-named
// opener, just against the default snapshot.
const coachSource = scopeSourceFromMetricKey(metric);
const coachQuestion = `Walk me through my ${metric
.toLowerCase()
.replace(
/_/g,
" ",
)} assessment — what does it mean and what should I do about it?`;
const coachQuestion = t("insights.coach.assessmentPrompt", {
metric: metric.toLowerCase().replace(/_/g, " "),
});
return (
<InsightStatusCard
@@ -62,6 +59,7 @@ export function MetricStatusCard({
updatedAt={status?.updatedAt ?? null}
coachQuestion={coachQuestion}
coachScope={coachSource ? { metric: coachSource } : undefined}
coachAutoSend
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
@@ -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">
@@ -52,6 +52,7 @@ import {
MoreHorizontal,
} from "lucide-react";
import { Fragment, useCallback, useId, useState } from "react";
import { useTableSort } from "@/hooks/use-table-sort";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
@@ -105,6 +106,10 @@ import {
*/
const CUMULATIVE_TYPES = new Set<string>(CUMULATIVE_DAY_SUM_TYPES);
// Columns that open descending when first selected. The date column reads
// newest-first by default; every other column opens ascending.
const MEASUREMENT_DESC_COLUMNS: ReadonlySet<string> = new Set(["measuredAt"]);
interface Measurement {
id: string;
type: string;
@@ -313,8 +318,16 @@ export function MeasurementList({
const [valueMinInput, setValueMinInputRaw] = useState<string>("");
const [valueMaxInput, setValueMaxInputRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// Shared column-sort state (measuredAt opens descending).
const {
sortBy,
sortDir,
toggleSort: applySort,
} = useTableSort({
defaultColumn: "measuredAt",
defaultDir: "desc",
descColumns: MEASUREMENT_DESC_COLUMNS,
});
// v1.15.13 — page-scoped multi-select. Holds the ids selected on the
// CURRENT page; cleared on any page / filter / sort change (per the
@@ -399,13 +412,11 @@ export function MeasurementList({
clearSelection();
};
// Compose the page-reset + selection-clear side effects around the
// shared sort toggle (the rows the selection referred to are about to
// re-order and the list snaps back to page 1).
function toggleSort(column: string) {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(column === "measuredAt" ? "desc" : "asc");
}
applySort(column);
setPage(1);
clearSelection();
}
+18 -8
View File
@@ -52,6 +52,11 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTableSort } from "@/hooks/use-table-sort";
// Columns that open descending when first selected. The logged-at column
// reads newest-first by default; every other column opens ascending.
const MOOD_DESC_COLUMNS: ReadonlySet<string> = new Set(["moodLoggedAt"]);
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -129,8 +134,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("moodLoggedAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// Shared column-sort state (moodLoggedAt opens descending).
const {
sortBy,
sortDir,
toggleSort: applySort,
} = useTableSort({
defaultColumn: "moodLoggedAt",
defaultDir: "desc",
descColumns: MOOD_DESC_COLUMNS,
});
// v1.15.13 — page-scoped multi-select selection (current page ids only).
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
@@ -202,13 +215,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
clearSelection();
};
// Compose the page-reset + selection-clear side effects around the
// shared sort toggle.
function toggleSort(column: string) {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(column === "moodLoggedAt" ? "desc" : "asc");
}
applySort(column);
setPage(1);
clearSelection();
}