mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Per-marker overview of an account's lab history (`GET /api/labs/overview`).
|
||||
*
|
||||
* The Labs list shows one row per biomarker: its newest reading, how many
|
||||
* readings it has, and a small trend. It used to build that by fetching the
|
||||
* newest 500 raw rows and grouping them in the browser, so on a long history
|
||||
* every marker whose readings all lay beyond the 500th row vanished from the
|
||||
* list without a word. The grouping now happens here, over every live row, so
|
||||
* the answer does not depend on how many readings the other markers have.
|
||||
*
|
||||
* Grouping matches what the list always did: by the linked biomarker, or for a
|
||||
* legacy unlinked row case-insensitively by its analyte name.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseLabResult } from "@/lib/labs/serialise";
|
||||
|
||||
/**
|
||||
* Readings per marker that feed the trend sparkline, newest kept. The
|
||||
* sparkline is a 72px glyph; more points than this only overlap. The count
|
||||
* beside it is the full count, not this cap.
|
||||
*/
|
||||
export const LAB_OVERVIEW_TREND_POINTS = 50;
|
||||
|
||||
export interface LabOverviewMarker {
|
||||
/** `bm:<biomarkerId>`, or `analyte:<lower-cased name>` for a legacy row. */
|
||||
key: string;
|
||||
/** Every live reading of this marker. */
|
||||
count: number;
|
||||
/** Values of the newest readings, oldest first; null for a qualitative one. */
|
||||
trend: (number | null)[];
|
||||
/** The newest reading, as the list endpoint serialises a row. */
|
||||
latest: ReturnType<typeof serialiseLabResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One window-function read ranks every live row inside its marker, keeps the
|
||||
* newest `LAB_OVERVIEW_TREND_POINTS` of each, and a second read loads the
|
||||
* newest row per marker with its biomarker so it serialises exactly like a
|
||||
* list row. Markers come back newest reading first.
|
||||
*/
|
||||
export async function loadLabOverview(
|
||||
userId: string,
|
||||
): Promise<LabOverviewMarker[]> {
|
||||
// Parameter-bound throughout. `id` breaks a `taken_at` tie the same way the
|
||||
// list endpoint's order does, so "newest" means the same row on both.
|
||||
const ranked = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
grp: string;
|
||||
value: number | null;
|
||||
rn: number;
|
||||
n: number;
|
||||
}>
|
||||
>`
|
||||
SELECT r."id", r."grp", r."value", r."rn"::int AS "rn", r."n"::int AS "n"
|
||||
FROM (
|
||||
SELECT g."id", g."grp", g."value",
|
||||
row_number() OVER (
|
||||
PARTITION BY g."grp" ORDER BY g."taken_at" DESC, g."id" DESC
|
||||
) AS "rn",
|
||||
count(*) OVER (PARTITION BY g."grp") AS "n"
|
||||
FROM (
|
||||
SELECT l."id", l."value", l."taken_at",
|
||||
CASE
|
||||
WHEN l."biomarker_id" IS NOT NULL THEN 'bm:' || l."biomarker_id"
|
||||
ELSE 'analyte:' || lower(l."analyte")
|
||||
END AS "grp"
|
||||
FROM "lab_results" l
|
||||
WHERE l."user_id" = ${userId} AND l."deleted_at" IS NULL
|
||||
) g
|
||||
) r
|
||||
WHERE r."rn" <= ${LAB_OVERVIEW_TREND_POINTS}
|
||||
`;
|
||||
if (ranked.length === 0) return [];
|
||||
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
latestId: string | null;
|
||||
count: number;
|
||||
points: Array<[number, number | null]>;
|
||||
}
|
||||
>();
|
||||
for (const row of ranked) {
|
||||
let group = groups.get(row.grp);
|
||||
if (!group) {
|
||||
group = { latestId: null, count: row.n, points: [] };
|
||||
groups.set(row.grp, group);
|
||||
}
|
||||
if (row.rn === 1) group.latestId = row.id;
|
||||
group.points.push([row.rn, row.value]);
|
||||
}
|
||||
|
||||
const latestIds = [...groups.values()]
|
||||
.map((g) => g.latestId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId, id: { in: latestIds } },
|
||||
include: {
|
||||
biomarker: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
|
||||
const markers: LabOverviewMarker[] = [];
|
||||
for (const [key, group] of groups) {
|
||||
const row = group.latestId ? byId.get(group.latestId) : undefined;
|
||||
// Deleted between the two reads: the marker is gone, not half-shown.
|
||||
if (!row) continue;
|
||||
markers.push({
|
||||
key,
|
||||
count: group.count,
|
||||
trend: group.points.sort((a, b) => b[0] - a[0]).map(([, value]) => value),
|
||||
latest: serialiseLabResult(row, row.biomarker ?? null),
|
||||
});
|
||||
}
|
||||
return markers.sort((a, b) => {
|
||||
const byTime = Date.parse(b.latest.takenAt) - Date.parse(a.latest.takenAt);
|
||||
if (byTime !== 0) return byTime;
|
||||
return a.latest.id < b.latest.id ? 1 : a.latest.id > b.latest.id ? -1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
createLabResultSchema,
|
||||
labOverviewQuerySchema,
|
||||
listLabResultsSchema,
|
||||
updateLabResultSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
@@ -116,6 +117,29 @@ const listResponse = z
|
||||
})
|
||||
.meta({ id: "ListLabResultsResponse" });
|
||||
|
||||
const labOverviewQuery = labOverviewQuerySchema.meta({
|
||||
id: "LabOverviewQuery",
|
||||
description:
|
||||
"The overview takes no query parameters; it always covers the whole live history. An unknown parameter is refused with 422.",
|
||||
});
|
||||
|
||||
const labOverviewMarker = z
|
||||
.object({
|
||||
key: z.string(),
|
||||
count: z.number().int(),
|
||||
trend: z.array(z.number().nullable()),
|
||||
latest: labResultRow,
|
||||
})
|
||||
.meta({
|
||||
id: "LabOverviewMarker",
|
||||
description:
|
||||
"One biomarker in the caller's lab history. `key` is `bm:<biomarkerId>` for a catalog-linked marker or `analyte:<lower-cased name>` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row.",
|
||||
});
|
||||
|
||||
const labOverviewResponse = z
|
||||
.object({ markers: z.array(labOverviewMarker) })
|
||||
.meta({ id: "LabOverviewResponse" });
|
||||
|
||||
const notFound = {
|
||||
"404": {
|
||||
description: "Lab result not found (or owned by another user).",
|
||||
@@ -175,6 +199,27 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/labs/overview": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
summary: "Per-marker overview of the caller's lab history",
|
||||
description:
|
||||
"One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size.",
|
||||
requestParams: { query: labOverviewQuery },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Lab overview.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labOverviewResponse, "LabOverviewEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/labs/{id}": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
|
||||
@@ -29,6 +29,11 @@ export const labKeys = {
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
// The Labs list: one entry per biomarker over the whole history
|
||||
// (`GET /api/labs/overview`). The `lab-results` prefix lets every result
|
||||
// mutation's `labResults()` invalidation evict it.
|
||||
labResultsOverview: () => ["lab-results", "overview"] as const,
|
||||
|
||||
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
|
||||
// that exact analyte name, one row, to find its marker's page. Its own key
|
||||
// because the page size differs from every list read; the `lab-results`
|
||||
|
||||
@@ -306,6 +306,13 @@ export const listLabResultsSchema = z.object({
|
||||
|
||||
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
|
||||
|
||||
/**
|
||||
* The per-marker overview takes no parameters: it always covers the whole
|
||||
* live history. Strict, so a stray `limit` is refused rather than silently
|
||||
* ignored by a caller expecting it to cap something.
|
||||
*/
|
||||
export const labOverviewQuerySchema = z.object({}).strict();
|
||||
|
||||
/**
|
||||
* Reference-range classification lives in `@/lib/labs/reference-range` so the
|
||||
* API response, the doctor-report PDF, and the lab list card all share one
|
||||
|
||||
Reference in new issue
Block a user