From 4da155ddcb2625472b6c0bc406c2338d5358df17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:59:13 +0200 Subject: [PATCH 1/3] feat(labs): default to list view and reuse medication tile primitives Set the compact list as the Labs surface default; tiles in the card view now route through MedicationCardHeader so a lab tile reads identically to a medication or Vorsorge tile side-by-side. Add alphabetical (A-Z / Z-A) sort options to the per-module sort choice. --- src/components/labs/lab-list.tsx | 208 ++++++++++++-------- src/lib/__tests__/module-list-prefs.test.ts | 21 ++ src/lib/module-list-prefs.ts | 60 ++++-- 3 files changed, 190 insertions(+), 99 deletions(-) diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index bf3b0a2f8..e4fcecf23 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -2,14 +2,17 @@ import { useMemo } from "react"; import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronRight, FlaskConical } from "lucide-react"; +import { toast } from "sonner"; +import { DeleteButton } from "@/components/data-list"; +import { MedicationCardHeader } from "@/components/medications/medication-card-header"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +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 { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabReading, formatLabValue } from "@/lib/labs/format-value"; @@ -74,8 +77,22 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const { prefs } = useModuleListPrefs("labs"); + // Delete a biomarker directly from the list (#41/#3). The endpoint's + // `onDelete: SetNull` FK keeps the readings and unlinks them; both the + // catalog and the result list invalidate so the row drops in the same tick. + const deleteBiomarker = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`), + onSuccess: () => { + toast.success(t("labs.biomarker.deletedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + }, + onError: () => toast.error(t("labs.biomarker.deleteError")), + }); + const listKey = queryKeys.labResultsList({ biomarkerId: undefined, analyte: undefined, @@ -95,10 +112,21 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const groups = useMemo(() => { const base = groupReadings(data?.results ?? []); // v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings` - // already returns most-recent-first; `recentAsc` reverses, and `manual` - // applies the persisted biomarker order (legacy un-linked groups, which - // carry no biomarkerId, sort after the ordered block). + // already returns most-recent-first; `recentAsc` reverses, the alpha + // options sort by analyte name (#43), and `manual` applies the persisted + // biomarker order (legacy un-linked groups, which carry no biomarkerId, + // sort after the ordered block). if (prefs.sortDir === "recentAsc") return [...base].reverse(); + if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { + const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; + return [...base].sort( + (a, b) => + dir * + a.analyte.localeCompare(b.analyte, undefined, { + sensitivity: "base", + }), + ); + } if (prefs.sortDir === "manual") { return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); } @@ -153,9 +181,27 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ); } + // Per-group biomarker delete (#41/#3), surfaced on both views. Only + // catalog-linked groups carry an id the endpoint can delete; legacy + // un-linked rows have no biomarker to remove. + function biomarkerDelete(group: MarkerGroup) { + if (!group.biomarkerId) return null; + const id = group.biomarkerId; + return ( + deleteBiomarker.mutate(id)} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="size-9" + iconClassName="h-4 w-4" + /> + ); + } + // v1.18.6 (MOD-03) — compact list view: one bordered card holding tight - // divided rows instead of a card per biomarker. The card view stays the - // default; this is the denser alternative the settings toggle selects. + // divided rows instead of a card per biomarker. This is the default view + // (#40); the card/tile view is the alternative the settings toggle selects. if (prefs.view === "list") { return (
@@ -167,42 +213,50 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {groups.map((group) => { - const row = ( -
-
-
- - {group.analyte} - - -
-
- - {formatLabReading(group.latest)} - - {formatDate(group.latest.takenAt)} -
+ const inner = ( +
+
+ + {group.analyte} + +
-
- r.value)} - /> - {group.biomarkerId ? ( - - ) : null} +
+ + {formatLabReading(group.latest)} + + {formatDate(group.latest.takenAt)}
); - return group.biomarkerId ? ( - - {row} - - ) : ( -
{row}
+ {group.biomarkerId ? ( + + {inner} + r.value)} + /> + + + ) : ( +
+ {inner} + r.value)} + /> +
+ )} + {biomarkerDelete(group)} +
); })} @@ -211,27 +265,37 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ); } + // v1.18.9 (#40) — card/tile view. The tile reuses the medication module's + // `MedicationCardHeader` so a lab tile reads identically to a medication or + // Vorsorge tile side-by-side: name on line 1, the reference-range badge as + // the line-1 chip, the panel as the category badge, and the delete control + // in the header's `actions` slot (a sibling of the header Link, so deleting + // never also deep-links). return ( -
+
    {truncated ? ( -

    - {t("labs.showingLatestOf", { shown, total })} -

    +
  • +

    + {t("labs.showingLatestOf", { shown, total })} +

    +
  • ) : null} - {groups.map((group) => { - const body = ( - <> - - - {group.analyte} - {group.panel ? ( - - {group.panel} - - ) : null} + {groups.map((group) => ( +
  • + + - - + } + href={ + group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined + } + linkLabel={group.analyte} + actions={biomarkerDelete(group)} + />
    @@ -261,34 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ) : null}
    -
    - r.value)} - /> - {group.biomarkerId ? ( - - ) : null} -
    + r.value)} + />
    - - ); - - // Catalog-linked groups deep-link to the per-biomarker detail chart. - // Legacy un-linked groups render as a plain card until the backfill - // links them (or the user re-adds via the structured path). - return group.biomarkerId ? ( - - {body} - - ) : ( - {body} - ); - })} -
+ + + ))} + ); } diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index 8434b9615..1c9eb58fe 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -38,6 +38,27 @@ describe("module-list-prefs", () => { order: ["x"], }); }); + + it("accepts the alphabetical sort directions (#43)", () => { + for (const sortDir of ["alphaAsc", "alphaDesc"]) { + const parsed = parseModuleListPrefs(JSON.stringify({ sortDir })); + expect(parsed.sortDir).toBe(sortDir); + } + }); + + it("applies a per-call default when no blob is stored (Labs list view, #40)", () => { + const labsDefaults = { + view: "list" as const, + order: [], + sortDir: "recentDesc" as const, + }; + expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults); + // A stored view still wins over the default. + expect( + parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults) + .view, + ).toBe("cards"); + }); }); describe("applyOrder", () => { diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index 0b2e5f4a7..6b7eb0f20 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -28,10 +28,16 @@ export type ModuleListKey = "vorsorge" | "illness" | "labs"; /** * Sort choice for the Labs surface (MOD-04): order biomarker groups by the - * recency of their most-recent reading. `manual` defers to the persisted - * `order` array instead. + * recency of their most-recent reading, alphabetically by analyte name + * (#43), or `manual`, which defers to the persisted `order` array. */ -export const MODULE_SORT_DIRS = ["recentDesc", "recentAsc", "manual"] as const; +export const MODULE_SORT_DIRS = [ + "recentDesc", + "recentAsc", + "alphaAsc", + "alphaDesc", + "manual", +] as const; export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; export interface ModuleListPrefs { @@ -47,6 +53,21 @@ const DEFAULT_PREFS: ModuleListPrefs = { sortDir: "recentDesc", }; +/** + * Per-module default overrides applied when no preference blob is stored yet. + * Labs defaults to the compact list view (#40) so the dense reading table is + * the first thing the user sees; Vorsorge and Illness keep the card grid. + */ +const MODULE_DEFAULTS: Partial< + Record> +> = { + labs: { view: "list" }, +}; + +function defaultsFor(module: ModuleListKey): ModuleListPrefs { + return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] }; +} + function storageKey(module: ModuleListKey): string { return `healthlog:module-list-prefs:${module}`; } @@ -65,20 +86,21 @@ function isSortDir(value: unknown): value is ModuleSortDir { ); } -export function parseModuleListPrefs(raw: string | null): ModuleListPrefs { - if (!raw) return DEFAULT_PREFS; +export function parseModuleListPrefs( + raw: string | null, + defaults: ModuleListPrefs = DEFAULT_PREFS, +): ModuleListPrefs { + if (!raw) return defaults; try { const blob = JSON.parse(raw) as Record; - const view = isView(blob.view) ? blob.view : DEFAULT_PREFS.view; + const view = isView(blob.view) ? blob.view : defaults.view; const order = Array.isArray(blob.order) ? blob.order.filter((v): v is string => typeof v === "string") : []; - const sortDir = isSortDir(blob.sortDir) - ? blob.sortDir - : DEFAULT_PREFS.sortDir; + const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir; return { view, order, sortDir }; } catch { - return DEFAULT_PREFS; + return defaults; } } @@ -124,6 +146,7 @@ export function useModuleListPrefs(module: ModuleListKey): { setSortDir: (sortDir: ModuleSortDir) => void; } { const key = storageKey(module); + const defaults = useMemo(() => defaultsFor(module), [module]); const subscribe = useCallback( (onChange: () => void) => { @@ -154,7 +177,10 @@ export function useModuleListPrefs(module: ModuleListKey): { // changed, so keying the parse on it gives downstream `applyOrder` // memoisation a stable `prefs` reference instead of a fresh object + a // JSON.parse on every render. - const prefs = useMemo(() => parseModuleListPrefs(raw), [raw]); + const prefs = useMemo( + () => parseModuleListPrefs(raw, defaults), + [raw, defaults], + ); const write = useCallback( (next: ModuleListPrefs) => { @@ -167,18 +193,18 @@ export function useModuleListPrefs(module: ModuleListKey): { const setView = useCallback( (view: ModuleListView) => - write({ ...parseModuleListPrefs(getSnapshot()), view }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }), + [write, getSnapshot, defaults], ); const setOrder = useCallback( (order: string[]) => - write({ ...parseModuleListPrefs(getSnapshot()), order }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }), + [write, getSnapshot, defaults], ); const setSortDir = useCallback( (sortDir: ModuleSortDir) => - write({ ...parseModuleListPrefs(getSnapshot()), sortDir }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }), + [write, getSnapshot, defaults], ); return { prefs, setView, setOrder, setSortDir }; From 8ae498ffb28467e64d3e53a99646db9b112fa9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:59:19 +0200 Subject: [PATCH 2/3] feat(labs): surface biomarker delete on the detail page Add a delete affordance with a confirm step to the biomarker detail header, reusing the existing DELETE /api/biomarkers/:id endpoint. On success the catalog and result-list queries invalidate and the view returns to the labs surface. Removes the Settings detour for clearing a stray biomarker. --- src/components/labs/lab-biomarker-detail.tsx | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 436ec36d3..2b07f364a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,15 +1,18 @@ "use client"; import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FlaskConical, Pencil, Plus } 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 { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -34,6 +37,7 @@ import type { */ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { t } = useTranslations(); + const router = useRouter(); const queryClient = useQueryClient(); const [addOpen, setAddOpen] = useState(false); // Sticky-footer slot for the add-value sheet (the form portals here). @@ -93,6 +97,21 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); } + // v1.18.9 (#41/#3) — delete the biomarker directly from its detail page so + // a stray marker is removable without a Settings detour. `onDelete: SetNull` + // keeps the readings and unlinks them. On success, invalidate the catalog + + // result list and return to the labs surface. + const deleteMarker = useMutation({ + mutationFn: () => apiDelete(`/api/biomarkers/${biomarkerId}`), + onSuccess: () => { + toast.success(t("labs.biomarker.deletedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + router.push("/labs"); + }, + onError: () => toast.error(t("labs.biomarker.deleteError")), + }); + if (markerError || listError) { return (

@@ -136,6 +155,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { > + deleteMarker.mutate()} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" + iconClassName="h-4 w-4" + />