mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
a5b3715a46
commit
4da155ddcb
3 files changed
+190
-99
No files matched your search
@@ -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 (
|
||||
<DeleteButton
|
||||
onConfirm={() => 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 (
|
||||
<div className="space-y-3">
|
||||
@@ -167,42 +213,50 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{groups.map((group) => {
|
||||
const row = (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{group.analyte}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<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)}
|
||||
</span>
|
||||
<span>{formatDate(group.latest.takenAt)}</span>
|
||||
</div>
|
||||
const inner = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{group.analyte}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
{group.biomarkerId ? (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : 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)}
|
||||
</span>
|
||||
<span>{formatDate(group.latest.takenAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return group.biomarkerId ? (
|
||||
<Link
|
||||
// The delete control + chevron sit OUTSIDE the navigating Link
|
||||
// so the delete confirm never also deep-links into the detail.
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 block transition-colors"
|
||||
className="flex items-center justify-between gap-2 px-4 py-2.5"
|
||||
>
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={group.key}>{row}</div>
|
||||
{group.biomarkerId ? (
|
||||
<Link
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 -m-1 flex min-w-0 flex-1 items-center gap-3 rounded-md p-1 transition-colors"
|
||||
>
|
||||
{inner}
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{inner}
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{biomarkerDelete(group)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
<li className="sm:col-span-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
</li>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const body = (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{group.panel}
|
||||
</span>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<li key={group.key} className="contents">
|
||||
<Card className="h-full gap-3">
|
||||
<MedicationCardHeader
|
||||
name={group.analyte}
|
||||
dose=""
|
||||
categoryLabel={group.panel ?? group.unit}
|
||||
nameChip={
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
}
|
||||
href={
|
||||
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
|
||||
}
|
||||
linkLabel={group.analyte}
|
||||
actions={biomarkerDelete(group)}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
@@ -261,34 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
{group.biomarkerId ? (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
|
||||
// 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 ? (
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="hover:bg-muted/40 transition-colors">{body}</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<Card key={group.key}>{body}</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<ModuleListKey, Partial<ModuleListPrefs>>
|
||||
> = {
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
|
||||
Reference in new issue
Block a user