mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(labs): list every marker from a server-side per-biomarker overview
This commit is contained in:
1 parent
600145427b
commit
53e60f43f1
10 files changed
+650
-95
No files matched your search
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { LabResultDto, LabResultListResponse } from "../types";
|
||||
import type { LabOverviewResponse, LabResultDto } from "../types";
|
||||
|
||||
/**
|
||||
* Every row of the compact list is five grid cells from `lg` up. The range
|
||||
@@ -55,22 +55,29 @@ function renderList(results: LabResultDto[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
const data: LabResultListResponse = {
|
||||
results,
|
||||
meta: { total: results.length, limit: 500, offset: 0 },
|
||||
};
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
// The overview the server would send for these readings: one marker per
|
||||
// biomarker (or lower-cased analyte), newest reading as `latest`.
|
||||
const byKey = new Map<string, LabResultDto[]>();
|
||||
for (const r of results) {
|
||||
const key = r.biomarkerId
|
||||
? `bm:${r.biomarkerId}`
|
||||
: `analyte:${r.analyte.toLowerCase()}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), r]);
|
||||
}
|
||||
const data: LabOverviewResponse = {
|
||||
markers: [...byKey.entries()].map(([key, readings]) => {
|
||||
const ordered = [...readings].sort(
|
||||
(a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
count: ordered.length,
|
||||
trend: ordered.map((r) => r.value),
|
||||
latest: ordered[ordered.length - 1],
|
||||
};
|
||||
}),
|
||||
data,
|
||||
);
|
||||
};
|
||||
queryClient.setQueryData(queryKeys.labResultsOverview(), data);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
|
||||
@@ -26,57 +26,44 @@ import { useLabDate, useLabNumber } from "./use-lab-format";
|
||||
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import { SourceRangeNote } from "./source-range-note";
|
||||
import type { LabResultDto, LabResultListResponse } from "./types";
|
||||
import type {
|
||||
LabOverviewMarkerDto,
|
||||
LabOverviewResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
|
||||
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
|
||||
key: string;
|
||||
/** Link target when the group is catalog-linked; null for legacy rows. */
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
unit: string;
|
||||
readings: LabResultDto[];
|
||||
/** Every live reading of the marker, counted by the server. */
|
||||
count: number;
|
||||
/** Values of the newest readings, oldest first, for the sparkline. */
|
||||
trend: (number | null)[];
|
||||
latest: LabResultDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group readings by their linked biomarker (or, for legacy un-linked rows,
|
||||
* case-insensitively by analyte). A catalog-linked group deep-links to its
|
||||
* detail chart; legacy groups render inert until the backfill links them.
|
||||
* One group per marker, as the server resolved it over the whole history. A
|
||||
* catalog-linked group deep-links to its detail chart; legacy groups render
|
||||
* inert until the backfill links them. The server already orders by the
|
||||
* newest reading.
|
||||
*/
|
||||
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
const byKey = new Map<string, LabResultDto[]>();
|
||||
for (const r of results) {
|
||||
const key = r.biomarkerId
|
||||
? `bm:${r.biomarkerId}`
|
||||
: `analyte:${r.analyte.toLowerCase()}`;
|
||||
const bucket = byKey.get(key);
|
||||
if (bucket) bucket.push(r);
|
||||
else byKey.set(key, [r]);
|
||||
}
|
||||
|
||||
const groups: MarkerGroup[] = [];
|
||||
for (const [key, readings] of byKey.entries()) {
|
||||
const ordered = [...readings].sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const latest = ordered[ordered.length - 1];
|
||||
groups.push({
|
||||
key,
|
||||
biomarkerId: latest.biomarkerId,
|
||||
analyte: latest.analyte,
|
||||
panel: latest.panel,
|
||||
unit: latest.unit,
|
||||
readings: ordered,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
new Date(b.latest.takenAt).getTime() -
|
||||
new Date(a.latest.takenAt).getTime(),
|
||||
);
|
||||
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.key,
|
||||
biomarkerId: marker.latest.biomarkerId,
|
||||
analyte: marker.latest.analyte,
|
||||
panel: marker.latest.panel,
|
||||
unit: marker.latest.unit,
|
||||
count: marker.count,
|
||||
trend: marker.trend,
|
||||
latest: marker.latest,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,26 +144,17 @@ export function LabList({
|
||||
// (no catalog match) carry no subtitle rather than a fabricated one.
|
||||
// v1.24 — the per-marker description moved to the biomarker detail page
|
||||
// (beneath the heading); the overview rows no longer carry it.
|
||||
const listKey = queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
// One entry per biomarker over the whole history, grouped server-side: a
|
||||
// capped page of raw rows dropped every marker measured only long ago.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: () =>
|
||||
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
|
||||
});
|
||||
|
||||
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, the alpha
|
||||
const base = toGroups(data?.markers ?? []);
|
||||
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. The server
|
||||
// order is 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).
|
||||
@@ -195,14 +173,7 @@ export function LabList({
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.results, prefs.sortDir, prefs.order]);
|
||||
|
||||
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
|
||||
// calm "showing latest N of M" hint when the cap truncates so the count is
|
||||
// never silently wrong — proper cursor paging is a later iteration.
|
||||
const total = data?.meta?.total ?? 0;
|
||||
const shown = data?.results?.length ?? 0;
|
||||
const truncated = total > shown;
|
||||
}, [data?.markers, prefs.sortDir, prefs.order]);
|
||||
|
||||
const listEmpty = !isLoading && !isError && groups.length === 0;
|
||||
useEffect(() => {
|
||||
@@ -266,11 +237,6 @@ export function LabList({
|
||||
// branch above carries `lab-list-loading` instead, so the marker
|
||||
// cannot stand for a silhouette.
|
||||
<div data-slot="lab-list" className="space-y-3">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
) : null}
|
||||
{/* `@container` makes the columns follow the width the list has, not the
|
||||
window's: below `@5xl` (64rem of card) the row is the `lg` table it
|
||||
always was (the two tiers exclude each other, so the result does not
|
||||
@@ -319,7 +285,7 @@ export function LabList({
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
values={group.trend}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
@@ -343,7 +309,7 @@ export function LabList({
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
values={group.trend}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
@@ -380,13 +346,6 @@ export function LabList({
|
||||
groups.length > 1 && "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{truncated ? (
|
||||
<li className="sm:col-span-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
</li>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
return (
|
||||
<li key={group.key} className="contents">
|
||||
@@ -432,10 +391,10 @@ export function LabList({
|
||||
{labDate(group.latest.takenAt)}
|
||||
</span>
|
||||
<LabConvertedNote reading={group.latest} />
|
||||
{group.readings.length > 1 ? (
|
||||
{group.count > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
count: group.readings.length,
|
||||
count: group.count,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -447,7 +406,7 @@ export function LabList({
|
||||
/>
|
||||
</div>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
values={group.trend}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
|
||||
@@ -65,6 +65,21 @@ export interface LabResultListResponse {
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
/** One biomarker in `GET /api/labs/overview`. */
|
||||
export interface LabOverviewMarkerDto {
|
||||
/** `bm:<biomarkerId>`, or `analyte:<lower-cased name>` for a legacy row. */
|
||||
key: string;
|
||||
/** Every live reading of the marker. */
|
||||
count: number;
|
||||
/** Values of the newest readings, oldest first; null for a qualitative one. */
|
||||
trend: (number | null)[];
|
||||
latest: LabResultDto;
|
||||
}
|
||||
|
||||
export interface LabOverviewResponse {
|
||||
markers: LabOverviewMarkerDto[];
|
||||
}
|
||||
|
||||
/** A user-scoped Biomarker catalog entry as the API serialises it. */
|
||||
export interface BiomarkerDto {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user