From 42b433f2e8068cfa5ec218bbec5bfd01ddae1664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:14:38 +0200 Subject: [PATCH] feat(tables): make the lab history table sortable Convert the per-biomarker reading history from a static reverse-chronological list to a shadcn Table with clickable sort headers. Reuse the shared useTableSort hook and SortableHead chrome already backing the measurement and mood lists; the sample-date column opens descending, value/range/note open ascending. The sort runs client-side over the in-memory reading feed. --- src/components/labs/lab-history-list.tsx | 191 +++++++++++++++++------ 1 file changed, 145 insertions(+), 46 deletions(-) diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 98b072074..17cfb3e50 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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 = { + 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(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 ( <> -
    - {ordered.map((r) => ( -
  • -
    -
    - - {formatLabReading(r)} - - -
    -

    + + + + + + + + + {t("labs.editReading")} + + + + + {ordered.map((r) => ( + + + {formatLabReading(r)} + + {formatDateShort(r.takenAt, true)} - {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} -

    - -
    - - 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" - /> -
    - - ))} - +
    + + + + + {r.hasNote ? t("labs.hasNote") : ""} + + +
    + + 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" + /> +
    +
    +
    + ))} +
    +