Merge v189/labsui into release/v1.18.9

Default the labs surface to list view and reuse the medication card header for
tiles; surface confirm-gated biomarker delete on detail and list rows; add
alphabetical sort; align the labs settings page to the canonical SettingsCard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:00:11 +02:00
commit 09a04f6eb7
11 files changed
+277 -127

No files matched your search

+2
View File
@@ -6485,6 +6485,8 @@
"label": "Reihenfolge der Werte",
"recentDesc": "Neueste Messung zuerst",
"recentAsc": "Älteste Messung zuerst",
"alphaAsc": "Name (A–Z)",
"alphaDesc": "Name (Z–A)",
"manual": "Manuell sortieren",
"hint": "Bei manueller Sortierung kannst du die Biomarker unten in deine eigene Reihenfolge bringen."
},
+2
View File
@@ -6485,6 +6485,8 @@
"label": "Order of values",
"recentDesc": "Most recent reading first",
"recentAsc": "Oldest reading first",
"alphaAsc": "Name (A–Z)",
"alphaDesc": "Name (Z–A)",
"manual": "Sort manually",
"hint": "With manual sorting you can drag the biomarkers below into your own order."
},
+2
View File
@@ -6485,6 +6485,8 @@
"label": "Orden de los valores",
"recentDesc": "Medición más reciente primero",
"recentAsc": "Medición más antigua primero",
"alphaAsc": "Nombre (A–Z)",
"alphaDesc": "Nombre (Z–A)",
"manual": "Ordenar manualmente",
"hint": "Con el orden manual puedes arrastrar los biomarcadores de abajo en tu propio orden."
},
+2
View File
@@ -6485,6 +6485,8 @@
"label": "Ordre des valeurs",
"recentDesc": "Mesure la plus récente d'abord",
"recentAsc": "Mesure la plus ancienne d'abord",
"alphaAsc": "Nom (A–Z)",
"alphaDesc": "Nom (Z–A)",
"manual": "Trier manuellement",
"hint": "En tri manuel, vous pouvez glisser les biomarqueurs ci-dessous dans votre propre ordre."
},
+2
View File
@@ -6485,6 +6485,8 @@
"label": "Ordine dei valori",
"recentDesc": "Misurazione più recente prima",
"recentAsc": "Misurazione più vecchia prima",
"alphaAsc": "Nome (A–Z)",
"alphaDesc": "Nome (Z–A)",
"manual": "Ordina manualmente",
"hint": "Con l'ordinamento manuale puoi trascinare i biomarcatori qui sotto nel tuo ordine."
},
+2
View File
@@ -6485,6 +6485,8 @@
"label": "Kolejność wartości",
"recentDesc": "Najnowszy pomiar najpierw",
"recentAsc": "Najstarszy pomiar najpierw",
"alphaAsc": "Nazwa (A–Z)",
"alphaDesc": "Nazwa (Z–A)",
"manual": "Sortuj ręcznie",
"hint": "Przy sortowaniu ręcznym możesz przeciągnąć biomarkery poniżej we własnej kolejności."
},
+29 -2
View File
@@ -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 (
<p className="text-destructive py-8 text-center text-sm">
@@ -136,6 +155,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => 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"
/>
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
+126 -82
View File
@@ -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>
);
}
+46 -26
View File
@@ -14,7 +14,15 @@
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
*/
import { useQuery } from "@tanstack/react-query";
import {
ArrowDownUp,
FlaskConical,
LayoutGrid,
ListOrdered,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -54,22 +62,30 @@ export function LabsSettings() {
const manual = prefs.sortDir === "manual";
// v1.18.9 (#44) — the Labs settings sub-page now follows the canonical
// Settings dialect: every block is a `<SettingsCard>` with a
// `<SettingsCardHeader>` (neutral icon + title + short description), the
// same shape as Stimmung / AI / Mitteilungen, instead of bare `<section>` +
// `text-sm` headings.
return (
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">
{t("moduleList.viewHeading")}
</h2>
<ModuleViewToggle view={prefs.view} onChange={setView} />
</div>
<p className="text-muted-foreground text-sm">
{t("moduleList.viewDescription")}
</p>
</section>
<div className="space-y-6">
{/* View — card vs compact list. */}
<SettingsCard id="labs-view" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={LayoutGrid}
title={t("moduleList.viewHeading")}
description={t("moduleList.viewDescription")}
status={<ModuleViewToggle view={prefs.view} onChange={setView} />}
/>
</SettingsCard>
<section className="space-y-3">
<h2 className="text-sm font-semibold">{t("labs.sort.heading")}</h2>
{/* Sort order — recency, alphabetical, or manual. */}
<SettingsCard id="labs-sort-card" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={ArrowDownUp}
title={t("labs.sort.heading")}
description={t("labs.sort.hint")}
/>
<div className="space-y-2">
<Label htmlFor="labs-sort">{t("labs.sort.label")}</Label>
<NativeSelect
@@ -79,17 +95,19 @@ export function LabsSettings() {
>
<option value="recentDesc">{t("labs.sort.recentDesc")}</option>
<option value="recentAsc">{t("labs.sort.recentAsc")}</option>
<option value="alphaAsc">{t("labs.sort.alphaAsc")}</option>
<option value="alphaDesc">{t("labs.sort.alphaDesc")}</option>
<option value="manual">{t("labs.sort.manual")}</option>
</NativeSelect>
<p className="text-muted-foreground text-xs">{t("labs.sort.hint")}</p>
</div>
</section>
</SettingsCard>
{manual ? (
<section className="space-y-3">
<h2 className="text-sm font-semibold">
{t("moduleList.reorder.heading")}
</h2>
<SettingsCard id="labs-order" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
@@ -99,15 +117,17 @@ export function LabsSettings() {
) : (
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
)}
</section>
</SettingsCard>
) : null}
<section className="space-y-3">
<h2 className="text-sm font-semibold">
{t("labs.biomarker.manageTitle")}
</h2>
{/* Biomarker catalog — define / edit / delete. */}
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={FlaskConical}
title={t("labs.biomarker.manageTitle")}
/>
<BiomarkerManager />
</section>
</SettingsCard>
</div>
);
}
@@ -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", () => {
+43 -17
View File
@@ -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 };