mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
With groups opening only when flagged, the open state was worked out from the filtered rows. Filtering to in-range markers, or searching for an unremarkable one, closed the header while the counter reported matches. A group's default now follows the server's status for the whole category again, and while any filter is active every group with a match is open. A group closed by hand during filtering stays closed while the filter changes, is not stored, and the saved state returns once the filter is cleared.
291 lines
9.2 KiB
TypeScript
291 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The categories of the labs list: which markers sit under which header, the
|
|
* header itself, and which categories are open.
|
|
*
|
|
* Everything about a category comes from the server (`categories` on
|
|
* `GET /api/labs/overview`): its members, label, order, counts and status,
|
|
* which describe the full category. When no effective list filters are
|
|
* active, this module renders those server category counts and status;
|
|
* when filters narrow the visible rows, it summarizes the visible subset
|
|
* client-side.
|
|
*/
|
|
import { useMemo, useState } from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
|
|
import { ScorePill } from "@/components/ui/score-pill";
|
|
import { TagChip } from "@/components/ui/tag-chip";
|
|
import { useMounted } from "@/hooks/use-mounted";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { priorityReferenceRangeStatus } from "@/lib/labs/reference-range";
|
|
import type { ModuleGroupsOpen } from "@/lib/module-list-prefs";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import type { MarkerRow } from "./lab-list-rows";
|
|
import type { LabOverviewCategoryDto } from "./types";
|
|
|
|
export interface MarkerGroup {
|
|
category: LabOverviewCategoryDto;
|
|
rows: MarkerRow[];
|
|
}
|
|
|
|
/**
|
|
* The rows under each category, in the order the rows already have (the
|
|
* person's sort applies inside a category); the categories keep the server
|
|
* order. A category none of whose rows is present drops out.
|
|
*/
|
|
export function groupMarkerRows(
|
|
rows: readonly MarkerRow[],
|
|
categories: readonly LabOverviewCategoryDto[],
|
|
): MarkerGroup[] {
|
|
const categoryOf = new Map<string, string>();
|
|
for (const category of categories) {
|
|
for (const key of category.markerKeys) categoryOf.set(key, category.key);
|
|
}
|
|
const byCategory = new Map<string, MarkerRow[]>();
|
|
for (const row of rows) {
|
|
const key = categoryOf.get(row.key);
|
|
if (key === undefined) continue;
|
|
const list = byCategory.get(key) ?? [];
|
|
list.push(row);
|
|
byCategory.set(key, list);
|
|
}
|
|
return categories
|
|
.map((category) => ({
|
|
category,
|
|
rows: byCategory.get(category.key) ?? [],
|
|
}))
|
|
.filter((group) => group.rows.length > 0);
|
|
}
|
|
|
|
const STORAGE_KEY = "healthlog:labs-groups-open";
|
|
|
|
interface StoredOpen {
|
|
/** The `groupsOpen` choice the toggles were made under. */
|
|
mode: ModuleGroupsOpen;
|
|
open: Record<string, boolean>;
|
|
}
|
|
|
|
export function readStoredOpen(
|
|
mode: ModuleGroupsOpen,
|
|
): Record<string, boolean> {
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw) as Partial<StoredOpen>;
|
|
if (parsed.mode !== mode || typeof parsed.open !== "object") return {};
|
|
const out: Record<string, boolean> = {};
|
|
for (const [key, value] of Object.entries(parsed.open ?? {})) {
|
|
if (typeof value === "boolean") out[key] = value;
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function writeStoredOpen(
|
|
mode: ModuleGroupsOpen,
|
|
open: Record<string, boolean>,
|
|
): void {
|
|
try {
|
|
window.localStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({ mode, open } satisfies StoredOpen),
|
|
);
|
|
} catch {
|
|
// Storage unavailable: the toggle still holds for this page.
|
|
}
|
|
}
|
|
|
|
function openByDefault(
|
|
category: LabOverviewCategoryDto,
|
|
mode: ModuleGroupsOpen,
|
|
): boolean {
|
|
if (mode === "expanded") return true;
|
|
if (mode === "collapsed") return false;
|
|
// The server's status for the whole category, never a filtered summary:
|
|
// what a filter hides must not open or close a group.
|
|
return category.status === "above" || category.status === "below";
|
|
}
|
|
|
|
/**
|
|
* Which categories are open. The `groupsOpen` choice sets the start; a
|
|
* category the person opened or closed by hand stays so in this browser
|
|
* (keyed by the opaque category key, never by its label) until the choice
|
|
* itself changes. Storage is read only after hydration and may be missing.
|
|
*
|
|
* While a filter is active every category that still has a match is open,
|
|
* whatever the choice or the stored toggles say, so a match is never hidden
|
|
* behind a closed header. A category opened or closed by hand during
|
|
* filtering stays so while the filter changes, is not written to storage,
|
|
* and the stored state returns once the filter is cleared.
|
|
*/
|
|
export function useGroupsOpen(
|
|
mode: ModuleGroupsOpen,
|
|
filtering = false,
|
|
): {
|
|
isOpen: (category: LabOverviewCategoryDto) => boolean;
|
|
toggle: (category: LabOverviewCategoryDto) => void;
|
|
} {
|
|
const mounted = useMounted();
|
|
const stored = useMemo(
|
|
() => (mounted ? readStoredOpen(mode) : {}),
|
|
[mounted, mode],
|
|
);
|
|
const [toggled, setToggled] = useState<StoredOpen>({ mode, open: {} });
|
|
const own = toggled.mode === mode ? toggled.open : {};
|
|
|
|
const [filtered, setFiltered] = useState<{
|
|
filtering: boolean;
|
|
open: Record<string, boolean>;
|
|
}>({ filtering, open: {} });
|
|
if (filtered.filtering !== filtering) {
|
|
setFiltered({ filtering, open: {} });
|
|
}
|
|
const whileFiltering = filtered.filtering === filtering ? filtered.open : {};
|
|
|
|
const isOpen = (category: LabOverviewCategoryDto) =>
|
|
filtering
|
|
? (whileFiltering[category.key] ?? true)
|
|
: (own[category.key] ??
|
|
stored[category.key] ??
|
|
openByDefault(category, mode));
|
|
|
|
const toggle = (category: LabOverviewCategoryDto) => {
|
|
if (filtering) {
|
|
setFiltered({
|
|
filtering,
|
|
open: { ...whileFiltering, [category.key]: !isOpen(category) },
|
|
});
|
|
return;
|
|
}
|
|
const next = { ...stored, ...own, [category.key]: !isOpen(category) };
|
|
setToggled({ mode, open: next });
|
|
writeStoredOpen(mode, next);
|
|
};
|
|
return { isOpen, toggle };
|
|
}
|
|
|
|
export interface LabGroupSummary {
|
|
counts: LabOverviewCategoryDto["counts"];
|
|
status: LabOverviewCategoryDto["status"];
|
|
}
|
|
|
|
/** Summarise counts and overall status for a list of rows. */
|
|
export function summariseGroupRows(rows: readonly MarkerRow[]): LabGroupSummary {
|
|
const counts = { inRange: 0, below: 0, above: 0, unknown: 0 };
|
|
for (const row of rows) {
|
|
const s = row.latest.rangeStatus;
|
|
if (s === "in-range") counts.inRange += 1;
|
|
else if (s === "below") counts.below += 1;
|
|
else if (s === "above") counts.above += 1;
|
|
else counts.unknown += 1;
|
|
}
|
|
const status = priorityReferenceRangeStatus(counts);
|
|
return { counts, status };
|
|
}
|
|
|
|
/**
|
|
* Group-status semantics:
|
|
* When filters or search are active, the group summary (both the count badge
|
|
* and the range status pill, e.g. "All in range" or "X of Y outside range")
|
|
* describes only the visible rows currently matching the filters in this
|
|
* group, rather than unfiltered server category totals.
|
|
*/
|
|
function GroupStatus({
|
|
counts,
|
|
status,
|
|
}: {
|
|
counts: LabOverviewCategoryDto["counts"];
|
|
status: LabOverviewCategoryDto["status"];
|
|
}) {
|
|
const { t, tCount } = useTranslations();
|
|
if (status === "unknown") return null;
|
|
if (status === "in-range") {
|
|
return <ScorePill tone="neutral">{t("labs.groups.allInRange")}</ScorePill>;
|
|
}
|
|
return (
|
|
<ScorePill tone={status === "above" ? "warning" : "info"}>
|
|
{tCount("labs.groups.outsideCount", counts.above + counts.below, {
|
|
total: counts.inRange + counts.below + counts.above + counts.unknown,
|
|
})}
|
|
</ScorePill>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A category header: a heading around one disclosure button holding the
|
|
* chevron, the label, the member count and the status, so the status is part
|
|
* of what the button announces. No region role: a list can carry more than a
|
|
* handful of categories.
|
|
*/
|
|
export function LabGroupHeader({
|
|
category,
|
|
rows,
|
|
open,
|
|
onToggle,
|
|
panelId,
|
|
className,
|
|
isFiltered = false,
|
|
status: statusProp,
|
|
summary: summaryProp,
|
|
}: {
|
|
category: LabOverviewCategoryDto;
|
|
rows: readonly MarkerRow[];
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
panelId: string;
|
|
className?: string;
|
|
isFiltered?: boolean;
|
|
status?: LabOverviewCategoryDto["status"];
|
|
summary?: LabGroupSummary;
|
|
}) {
|
|
const { counts, status } = useMemo(() => {
|
|
if (isFiltered) {
|
|
const summary = summaryProp ?? summariseGroupRows(rows);
|
|
return {
|
|
counts: summary.counts,
|
|
status: statusProp ?? summary.status,
|
|
};
|
|
}
|
|
const { inRange, below, above, unknown } = category.counts;
|
|
return {
|
|
counts: { inRange, below, above, unknown },
|
|
status: statusProp ?? category.status,
|
|
};
|
|
}, [category, rows, isFiltered, statusProp, summaryProp]);
|
|
|
|
return (
|
|
<h2 className={cn("min-w-0", className)}>
|
|
<button
|
|
type="button"
|
|
data-slot="lab-list-group"
|
|
data-category={category.key}
|
|
aria-expanded={open}
|
|
aria-controls={panelId}
|
|
onClick={onToggle}
|
|
className="focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-[3px] sm:min-h-9"
|
|
>
|
|
<ChevronDown
|
|
aria-hidden
|
|
className={cn(
|
|
"text-muted-foreground size-4 shrink-0 transition-transform motion-reduce:transition-none",
|
|
!open && "-rotate-90",
|
|
)}
|
|
/>
|
|
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
|
|
{category.label}
|
|
</span>
|
|
<TagChip className="shrink-0 tabular-nums">
|
|
{rows.length}
|
|
</TagChip>
|
|
<span className="ml-auto shrink-0">
|
|
<GroupStatus counts={counts} status={status} />
|
|
</span>
|
|
</button>
|
|
</h2>
|
|
);
|
|
}
|