Merge branch 'v1424/readiness' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:24:29 +02:00
commit 6313c0174a
37 files changed
+1588 -147

No files matched your search

+8 -2
View File
@@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and this list in step. Root-owned on purpose: the app user can
# run them but not rewrite them.
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# is the entrypoint's own: it logs the pending migration count before
# `migrate deploy`. Root-owned on purpose: the app user can run them but not
# rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
# it stops at its own usage line; a missing module throws before reaching it.
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')"
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
+3
View File
@@ -85,6 +85,9 @@ done
echo "HealthLog: Database is reachable."
# ── Run Prisma migrations ────────────────────────────────────
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
echo "HealthLog: Running database migrations..."
NODE_PATH=/opt/prisma-cli/node_modules \
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
+91
View File
@@ -14924,6 +14924,26 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a12
/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."
parameters: []
responses:
"200":
description: Lab overview.
content:
application/json:
schema:
$ref: "#/components/schemas/LabOverviewEnvelope"
"401": *a1
"403": *a11
"422": *a4
"429": *a5
/api/labs/{id}:
get:
tags:
@@ -61210,9 +61230,26 @@ components:
additionalProperties: false
description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`,
and no entry carries all of them."
medications:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
- name
additionalProperties: false
description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the
entry names, to know which medications are GLP-1: a medication whose events are all older than the newest
`limit` entries has none in `entries`. Empty when `hasGlp1` is false."
required:
- hasGlp1
- entries
- medications
additionalProperties: false
description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with
their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days.
@@ -67585,6 +67622,60 @@ components:
- data
- error
additionalProperties: false
LabOverviewEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/LabOverviewResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
LabOverviewResponse:
type: object
properties:
markers:
type: array
items:
$ref: "#/components/schemas/LabOverviewMarker"
required:
- markers
additionalProperties: false
LabOverviewMarker:
type: object
properties:
key:
type: string
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
trend:
type: array
items:
anyOf:
- type: number
- type: "null"
latest:
$ref: "#/components/schemas/LabResult"
required:
- key
- count
- trend
- latest
additionalProperties: false
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.
GetLabResultResponse:
type: object
properties:
+6
View File
@@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
docker compose pull app
+39
View File
@@ -361,3 +361,42 @@ For off-host encrypted backups to S3/R2/B2 see
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
encryption key safely (keep the old key until zero rows remain on it) see
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
## Updates
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
```bash
# 1. Dump the database (same command as under "Backup and restore").
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 2. Pull the new image and recreate the app container.
docker compose pull app
docker compose up -d app
# 3. Confirm the running build.
curl -s http://localhost:3000/api/version
```
Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The line is a notice and does not pause the start. If you see a count and
have no dump yet, stop the container (`docker compose stop app`), take the
dump, and start it again; the migrations run on that start.
For the ordering on a host where you want the migration result before the
new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md).
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.",
"recentTitle": "Letzte 30 Tage",
"loading": "Wird geladen…",
"loadMore": "Weitere laden",
"deleteCta": "Löschen",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.",
"recentTitle": "Last 30 days",
"loading": "Loading…",
"loadMore": "Load more",
"deleteCta": "Delete",
"deleteError": "Could not delete the entry",
"errorSeverity": "Severity must be between 1 and 5",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.",
"recentTitle": "Últimos 30 días",
"loading": "Cargando…",
"loadMore": "Cargar más",
"deleteCta": "Eliminar",
"deleteError": "No se pudo eliminar la entrada",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.",
"recentTitle": "30 derniers jours",
"loading": "Chargement…",
"loadMore": "Charger plus",
"deleteCta": "Supprimer",
"deleteError": "Impossible de supprimer l'entrée",
"errorSeverity": "La sévérité doit être entre 1 et 5",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.",
"recentTitle": "Ultimi 30 giorni",
"loading": "Caricamento…",
"loadMore": "Carica altri",
"deleteCta": "Elimina",
"deleteError": "Impossibile eliminare la voce",
"errorSeverity": "La gravità deve essere tra 1 e 5",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.",
"recentTitle": "최근 30일",
"loading": "불러오는 중…",
"loadMore": "더 불러오기",
"deleteCta": "삭제",
"deleteError": "기록을 삭제하지 못했어요",
"errorSeverity": "심각도는 1에서 5 사이여야 해요",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.",
"recentTitle": "Ostatnie 30 dni",
"loading": "Ładowanie…",
"loadMore": "Załaduj więcej",
"deleteCta": "Usuń",
"deleteError": "Nie udało się usunąć wpisu",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
+95
View File
@@ -0,0 +1,95 @@
/**
* Boot-time notice before `prisma migrate deploy`: how many migrations this
* image is about to apply, and a reminder to have a backup first.
*
* Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`.
* It only reports. A migration that changes data cannot be undone by pulling
* the previous image, so an operator who reads the count in the log still has
* a moment to stop the container and take a `pg_dump`; the entrypoint does
* not wait for that.
*
* Best effort by design: any failure to count (no `_prisma_migrations` table
* yet is not a failure, it means every migration is pending) prints one line
* saying so and exits 0, because a notice is never a reason to keep the app
* from starting. `migrate deploy` right after it is what decides.
*/
import { readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
/** Migration directory names that carry a `migration.sql`, sorted. */
export function shippedMigrations(migrationsDir) {
return readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(join(migrationsDir, name, "migration.sql")))
.sort();
}
/**
* The shipped migrations the database has not finished applying. Reads
* `_prisma_migrations` in the schema the connection string names (Prisma's
* `?schema=` parameter, `public` when absent); a database without that table
* has applied nothing.
*/
export async function pendingMigrations({ connectionString, migrationsDir }) {
const shipped = shippedMigrations(migrationsDir);
const { default: pg } = await import("pg");
const url = new URL(connectionString);
const schema = url.searchParams.get("schema") || "public";
url.searchParams.delete("schema");
const client = new pg.Client({
connectionString: url.toString(),
connectionTimeoutMillis: 5_000,
statement_timeout: 5_000,
});
await client.connect();
try {
const table = await client.query(
"SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t",
[schema],
);
if (!table.rows[0]?.t) return shipped;
const applied = await client.query(
`SELECT migration_name FROM ${table.rows[0].t}
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
);
const done = new Set(applied.rows.map((row) => row.migration_name));
return shipped.filter((name) => !done.has(name));
} finally {
await client.end().catch(() => {});
}
}
/** The log line for a count, as the entrypoint prints it. */
export function pendingNotice(count) {
if (count === 0) return "HealthLog: Database schema is up to date.";
const noun = count === 1 ? "migration" : "migrations";
return (
`HealthLog: ${count} database ${noun} pending. ` +
"Back up the database before updating (pg_dump, see the Updates section " +
"of docs/self-hosting/getting-started.md); a migration is not undone by " +
"going back to the previous image."
);
}
async function main() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) return;
try {
const pending = await pendingMigrations({
connectionString,
migrationsDir: join(process.cwd(), "prisma", "migrations"),
});
console.log(pendingNotice(pending.length));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.log(
`HealthLog: Could not count pending migrations (${reason}); continuing.`,
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}
@@ -0,0 +1,207 @@
/**
* No client list or picker with a fixed row cap and nothing to reach past it.
*
* A client read that asks for "the newest N" and renders the answer as the
* whole list loses everything beyond N without a word: the Labs list asked
* for 500 raw rows and grouped them in the browser, so on a long history
* every marker measured only long ago had no row at all. The visit-form lab
* picker was the same class before it, and the GLP-1 curve, the side-effect
* log and the symptom timeline each had a quieter copy.
*
* The rule: every literal `limit=<number>` a client fetch sends either sits
* beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or
* a `useInfiniteQuery` in the file), or is listed below with the reason the
* cap cannot cut anything off.
*
* What it matches: `limit=<digits>` inside a URL string, and the two
* `URLSearchParams` spellings, `.set("limit", "<digits>")` and
* `limit: "<digits>"`. What it does not: a limit routed through a named
* constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every
* case today. It walks client code only (`components/`, `hooks/`, and `app/`
* outside `app/api/`); a server-side `take` is not a fetch.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
interface Allowed {
file: string;
limit: number;
reason: string;
}
const ALLOWLIST: readonly Allowed[] = [
{
file: "app/labs/page.tsx",
limit: 1,
reason:
"Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.",
},
{
file: "components/onboarding/first-result-screen.tsx",
limit: 1,
reason: "Asks only whether a type has a reading, and which is newest.",
},
{
file: "components/insights/glp1-substance-curve-summary.tsx",
limit: 1,
reason:
"Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.",
},
{
file: "components/charts/chart-series-request.ts",
limit: 5000,
reason:
"Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.",
},
{
file: "components/insights/trend-annotation.tsx",
limit: 5000,
reason: "Daily rollup buckets over a 30-day window: at most 31 rows.",
},
{
file: "components/insights/coach-panel/attachment-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "components/settings/share-document-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "components/documents/document-link-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "hooks/use-mood-day.ts",
limit: 50,
reason:
"One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.",
},
];
const LIMIT_PATTERNS: readonly RegExp[] = [
/[?&]limit=(\d+)/g,
/\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g,
/\blimit:\s*["'](\d+)["']/g,
];
const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/;
const PAGER_LINES = 3;
function clientFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter(
(p) =>
p.startsWith("components/") ||
p.startsWith("hooks/") ||
(p.startsWith("app/") && !p.startsWith("app/api/")),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !/\.test\.tsx?$/.test(p))
.sort();
}
interface Hit {
file: string;
line: number;
limit: number;
paged: boolean;
}
/** Every literal limit in the given source, with whether a pager is beside it. */
function findLimitHits(file: string, source: string): Hit[] {
const lines = source.split("\n");
const infinite = /\buseInfiniteQuery\b/.test(source);
const hits: Hit[] = [];
lines.forEach((text, index) => {
// Comments describe old shapes; they send nothing.
const trimmed = text.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
for (const pattern of LIMIT_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const near = lines
.slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1)
.join("\n");
hits.push({
file,
line: index + 1,
limit: Number(match[1]),
paged: infinite || PAGER_NEARBY.test(near),
});
}
}
});
return hits;
}
function allHits(): Hit[] {
return clientFiles().flatMap((file) =>
findLimitHits(file, readFileSync(join(SRC, file), "utf8")),
);
}
function isAllowed(hit: Hit): boolean {
return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit);
}
describe("client fetch limits", () => {
it("finds the literal limits it is meant to police", () => {
// An empty sweep would agree with any allowlist. Every allowlisted cap is
// a live hit, so the hit set can never be smaller than the list.
expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length);
});
it("puts every fixed limit beside a pager or on the allowlist", () => {
const offenders = allHits()
.filter((hit) => !hit.paged && !isAllowed(hit))
.map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`);
expect(
offenders,
"A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.",
).toEqual([]);
});
it("keeps no allowlist entry that no longer matches anything", () => {
const hits = allHits();
const stale = ALLOWLIST.filter(
(a) => !hits.some((h) => h.file === a.file && h.limit === a.limit),
).map((a) => `${a.file} limit=${a.limit}`);
expect(stale).toEqual([]);
});
it("gives every allowlist entry a reason", () => {
for (const entry of ALLOWLIST) {
expect(entry.reason.length, entry.file).toBeGreaterThan(20);
}
});
it("flags the shape that dropped markers from the Labs list", () => {
const hits = findLimitHits(
"components/labs/lab-list.tsx",
'apiGet("/api/labs?limit=500&sortDir=desc")',
);
expect(hits).toHaveLength(1);
expect(hits[0].paged).toBe(false);
expect(isAllowed(hits[0])).toBe(false);
});
it("accepts a limit that travels with an offset", () => {
const hits = findLimitHits(
"components/example.tsx",
"fetch(`/api/x?status=taken&limit=100&offset=${offset}`)",
);
expect(hits).toHaveLength(1);
expect(hits[0].paged).toBe(true);
});
});
@@ -69,6 +69,18 @@ function documentedContainerScripts(): string[] {
return [...found].sort();
}
/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */
function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
(m) => m[1],
),
),
].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
@@ -109,3 +121,21 @@ describe("scripts the runbooks run inside the container ship in the image", () =
expect(missing).toEqual([]);
});
});
describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
const missing = entrypointScripts()
.flatMap((script) => [script, ...localImports(script)])
.filter((path) => !copiedIntoRunner(path));
expect(
missing,
"docker-entrypoint.sh runs these on every boot, but the Dockerfile " +
"runner stage does not COPY them; the container would fail to start.",
).toEqual([]);
});
});
+8 -1
View File
@@ -847,6 +847,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "labs",
why: "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.",
},
"app/api/labs/overview/route.ts": {
domain: "labs",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
},
"app/api/biomarkers/route.ts": {
domain: "labs",
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.",
@@ -2097,8 +2101,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.42 — the score-history read joins the record list and the manage
* literal (a score's daily course, MANAGE like the derived score itself).
* 252 -> 254.
*
* v1.42.4 -- the Labs list's per-marker overview joins the record list
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
*/
const FROZEN_ENTRY_COUNT = 255;
const FROZEN_ENTRY_COUNT = 256;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -242,6 +242,8 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
// The Labs list's per-marker overview reads the same row store.
"src/app/api/labs/overview/route.ts",
// v1.18.1 — the lab-result delete-Undo restore endpoint and the
// user-scoped Biomarker catalog CRUD share the LabResult data-layer
// reasoning: the labs module gates the SURFACES (the Labs page), not the
+9 -2
View File
@@ -68,7 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
if (meds.length === 0) {
return apiSuccess({ hasGlp1: false, entries: [] });
return apiSuccess({ hasGlp1: false, entries: [], medications: [] });
}
const ninetyDaysAgo = new Date();
@@ -173,5 +173,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
// `medications` is every GLP-1 medication, independent of `limit`: a
// consumer that needs the set (the substance-curve summary) must not
// derive it from a capped page of entries.
return apiSuccess({
hasGlp1: true,
entries: entries.slice(0, limit),
medications: meds.map((med) => ({ id: med.id, name: med.name })),
});
});
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
/**
* `GET /api/labs/overview` — one entry per biomarker over the caller's whole
* live lab history: the newest reading, the reading count and a short trend.
* The Labs list renders from this instead of grouping a capped page of raw
* rows, so a marker measured only long ago still has its row. Same read gate
* as `GET /api/labs` (`requireRecordAuth("read", "labs")`).
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "labs");
const parsed = labOverviewQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "labs.overview.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const markers = await loadLabOverview(user.id);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length },
});
return apiSuccess({ markers });
});
+10 -8
View File
@@ -24,10 +24,12 @@
* · per-day cumulative totals — `stats:<HK>:<YYYY-MM-DD>` (Steps,
* Active Energy, Sleep Duration, Walking/Running Distance,
* Flights Climbed);
* · v1.19.0 (iOS #34) hourly heart-rate buckets —
* `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
* carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops
* uploading one row per raw HR sample.
* · v1.19.0 (iOS #34) heart-rate buckets, 10 minutes wide since
* v1.30.7 — `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>`
* where `<bucket-start>` is the ISO-8601 UTC 10-minute boundary
* (minutes 00/10/20/30/40/50), carrying the bucket's AVERAGE bpm
* as one PULSE row, so iOS stops uploading one row per raw HR
* sample.
* Both surface as `updated` (the row's value is overwritten) rather
* than `duplicate` (the new value dropped). Sample-class externalIds
* (every other prefix) keep the strict immutable `duplicate`
@@ -153,11 +155,11 @@ const batchEntrySchema = z.object({
// device's own verdict / severity; resolved to a `rhythmClassification`
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
categoryValue: z.number().int().min(0).max(20).optional(),
// v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly
// heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry
// the hour's MIN / MAX so a client renders the intra-hour band without
// v1.19.2 (iOS #34 extension) — per-bucket spread for the 10-minute
// heart-rate bucket. `value` carries the bucket's AVERAGE bpm; these carry
// its MIN / MAX so a client renders the band inside the bucket without
// re-uploading raw samples. Persisted ONLY on a well-formed
// `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored
// `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row; ignored (stored
// NULL) for every other entry. Optional + backward-compatible: a pre-W192
// iOS build omits them and the bucket keeps the avg-only contract.
valueMin: z.number().finite().optional(),
@@ -9,9 +9,12 @@
*
* Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative
* GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of
* GLP-1 medication names comes from the timeline's entries (every entry is
* from a `treatmentClass === "GLP1"` medication), and the passed medication
* list is filtered to that set so non-GLP-1 medications never render a curve.
* GLP-1 medications comes from the response's `medications` list, which the
* server fills from every `treatmentClass === "GLP1"` medication independent
* of the entry cap, and the passed medication list is filtered to that set so
* non-GLP-1 medications never render a curve. (It used to be derived from the
* names in a capped page of entries, which dropped a medication whose events
* were all older than that page.)
*
* The chart carries its own `MedicationDetailSection` card chrome + estimate
* disclaimer, so it reads as one system with the surrounding Insights cards
@@ -36,7 +39,7 @@ const DrugLevelChart = dynamic(
interface Glp1TimelineResponse {
hasGlp1: boolean;
entries: { medicationName?: string }[];
medications: { id: string; name: string }[];
}
export function Glp1SubstanceCurveSummary({
@@ -45,24 +48,17 @@ export function Glp1SubstanceCurveSummary({
medications: { id: string; name: string; dose: string }[];
}) {
const { data } = useQuery({
queryKey: queryKeys.insightsGlp1Timeline(200),
// Only `medications` is read, so the smallest page of entries will do.
queryKey: queryKeys.insightsGlp1Timeline(1),
queryFn: () =>
apiGet<Glp1TimelineResponse>("/api/insights/glp1-timeline?limit=200"),
apiGet<Glp1TimelineResponse>("/api/insights/glp1-timeline?limit=1"),
staleTime: 60_000,
});
if (!data?.hasGlp1) return null;
// Every timeline entry is from a GLP-1 medication (server filters on
// `treatmentClass: "GLP1"`). Medication names match the comprehensive
// list exactly — both derive from `medication.name` server-side.
const glp1Names = new Set(
data.entries
.map((e) => e.medicationName)
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
const glp1Meds = medications.filter((m) => glp1Names.has(m.name));
const glp1Ids = new Set(data.medications.map((m) => m.id));
const glp1Meds = medications.filter((m) => glp1Ids.has(m.id));
if (glp1Meds.length === 0) return null;
const showNameLabel = glp1Meds.length > 1;
@@ -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">
+38 -79
View File
@@ -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}
/>
+15
View File
@@ -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;
@@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => {
import {
DrugLevelChart,
loadTakenIntakesSince,
parseDoseMg,
resolveDoseMg,
} from "../drug-level-chart";
@@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => {
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
});
});
describe("loadTakenIntakesSince", () => {
const DAY = 24 * 60 * 60 * 1000;
// 400 daily taken doses, newest first, the newest one a day ago.
const now = Date.parse("2026-06-01T08:00:00.000Z");
const all = Array.from({ length: 400 }, (_, i) => ({
id: `i-${i}`,
takenAt: new Date(now - (i + 1) * DAY).toISOString(),
skipped: false,
scheduledFor: new Date(now - (i + 1) * DAY).toISOString(),
}));
function pager() {
const urls: string[] = [];
const fetchPage = async (url: string) => {
urls.push(url);
const params = new URL(url, "http://localhost").searchParams;
const limit = Number(params.get("limit"));
const offset = Number(params.get("offset"));
return {
events: all.slice(offset, offset + limit),
meta: { total: all.length },
};
};
return { urls, fetchPage };
}
it("walks back past a single page to the horizon, and no further", async () => {
const { urls, fetchPage } = pager();
// 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page.
const since = new Date(now - 150.5 * DAY);
const { events } = await loadTakenIntakesSince("med-1", since, fetchPage);
expect(events).toHaveLength(150);
expect(urls).toHaveLength(2);
for (const url of urls) {
expect(url).toContain("status=taken");
expect(url).toContain("sortBy=takenAt");
}
});
it("stops at the end of the history", async () => {
const { urls, fetchPage } = pager();
const { events } = await loadTakenIntakesSince(
"med-1",
new Date(now - 10_000 * DAY),
fetchPage,
);
expect(events).toHaveLength(400);
expect(urls).toHaveLength(4);
});
});
@@ -87,8 +87,14 @@ function seedSideEffects(
}>,
) {
client.setQueryData(queryKeys.medicationSideEffects(medId), {
items: rows,
meta: { total: rows.length },
pages: [
{
items: rows,
meta: { total: rows.length },
from: "2026-01-01T00:00:00.000Z",
},
],
pageParams: [null],
});
}
@@ -131,13 +137,24 @@ async function executeSideEffectsQuery(
medicationId: string,
): Promise<void> {
const queryKey = queryKeys.medicationSideEffects(medicationId);
const queryFn = client.getQueryCache().find({ queryKey, exact: true })
?.options.queryFn;
if (typeof queryFn !== "function") {
const options = client.getQueryCache().find({ queryKey, exact: true })
?.options as
| {
queryFn?: unknown;
initialPageParam?: unknown;
getNextPageParam?: unknown;
}
| undefined;
if (typeof options?.queryFn !== "function") {
throw new Error("Side-effects query function was not registered");
}
try {
await client.fetchQuery({ queryKey, queryFn });
await client.fetchInfiniteQuery({
queryKey,
queryFn: options.queryFn as never,
initialPageParam: options.initialPageParam as never,
getNextPageParam: options.getNextPageParam as never,
});
} catch {
// The settled error is the state under test.
}
@@ -420,3 +437,88 @@ describe("<SideEffectsSection> — timeline rows", () => {
expect(html).toContain("Mittel");
});
});
describe("<SideEffectsSection> — paging", () => {
function sideEffectsResponse(
items: Array<{ id: string; occurredAt: string }>,
): Response {
return new Response(
JSON.stringify({
data: {
items: items.map((row) => ({
...row,
category: "GI",
entry: "NAUSEA",
severity: 2,
notes: null,
})),
meta: { total: items.length },
},
error: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
it("offers a next page after a full one and asks for the entries before it", async () => {
const base = Date.parse("2026-05-20T12:00:00.000Z");
const firstPage = Array.from({ length: 50 }, (_, i) => ({
id: `se-${i}`,
occurredAt: new Date(base - i * 60_000).toISOString(),
}));
const oldest = firstPage[firstPage.length - 1];
const fetchMock = vi
.fn()
.mockResolvedValueOnce(sideEffectsResponse(firstPage))
// The boundary entry comes back once more and is shown once.
.mockResolvedValueOnce(
sideEffectsResponse([
oldest,
{ id: "se-older", occurredAt: "2026-05-01T08:00:00.000Z" },
]),
);
vi.stubGlobal("fetch", fetchMock);
const client = makeClient();
render(<SideEffectsSection medicationId="med-paged" />, client);
await executeSideEffectsQuery(client, "med-paged");
await waitForQueryStatus(client, "med-paged", "success");
const firstHtml = render(
<SideEffectsSection medicationId="med-paged" />,
client,
);
expect(firstHtml).toContain(en.medications.sideEffects.loadMore);
await client
.getQueryCache()
.find({
queryKey: queryKeys.medicationSideEffects("med-paged"),
exact: true,
})
?.fetch(undefined, { meta: { fetchMore: { direction: "forward" } } });
const secondUrl = new URL(
String(fetchMock.mock.calls[1]?.[0]),
"http://localhost",
);
expect(secondUrl.searchParams.get("to")).toBe(
new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
);
const firstUrl = new URL(
String(fetchMock.mock.calls[0]?.[0]),
"http://localhost",
);
expect(secondUrl.searchParams.get("from")).toBe(
firstUrl.searchParams.get("from"),
);
const secondHtml = render(
<SideEffectsSection medicationId="med-paged" />,
client,
);
// 50 + 1 new, the repeated boundary entry dropped; a short page ends it.
expect(secondHtml).toContain("(51)");
expect(secondHtml).not.toContain(en.medications.sideEffects.loadMore);
});
});
@@ -56,6 +56,7 @@ import {
} from "@/lib/medications/glp1-pk";
import {
findDrugIdByBrand,
GLP1_DRUGS,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMg } from "@/lib/medications/dose-string";
@@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24;
const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY;
const WINDOW_HOURS_AFTER = 0;
/** Taken intakes per request while walking back to the model's horizon. */
const INTAKE_PAGE_SIZE = 100;
/**
* Half-lives before the chart's left edge whose doses still count. After five
* half-lives a dose holds about 3% of its peak, below what the chart can show.
*/
const HORIZON_HALF_LIVES = 5;
/**
* Every taken intake from `since` on, newest first, walked page by page. The
* curve is a sum over every dose still in the body, so a fixed count of
* recent intakes was wrong in both directions: twenty daily doses of a
* long-acting drug stop well short of steady state, and planned or skipped
* rows used up the count without being doses at all.
*/
export async function loadTakenIntakesSince(
medicationId: string,
since: Date,
fetchPage: (url: string) => Promise<{
events: IntakeEvent[];
meta: { total: number };
}> = (url) => apiGet(url),
): Promise<{ events: IntakeEvent[] }> {
const events: IntakeEvent[] = [];
for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) {
const page = await fetchPage(
`/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`,
);
events.push(...page.events);
const oldest = page.events[page.events.length - 1];
const reachedHorizon =
!oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime();
if (
reachedHorizon ||
page.events.length < INTAKE_PAGE_SIZE ||
offset + INTAKE_PAGE_SIZE >= page.meta.total
) {
break;
}
}
return {
events: events.filter(
(ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(),
),
};
}
interface IntakeEvent {
id: string;
takenAt: string | null;
@@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id),
queryFn: async () => {
try {
return await apiGet<{ events: IntakeEvent[] }>(
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
const halfLifeHours =
GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY;
const since = new Date(
Date.now() -
(WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) *
60 *
60 *
1000,
);
return await loadTakenIntakesSince(medication.id, since);
} catch {
return null;
}
@@ -3,7 +3,11 @@
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react";
@@ -110,6 +114,16 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Entries per page of the side-effect list. */
const SIDE_EFFECTS_PAGE_SIZE = 50;
interface SideEffectPageParam {
/** The window start, fixed when the first page loaded. */
from: string;
/** Exclusive upper bound: just after the oldest entry already shown. */
to: string;
}
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -133,18 +147,40 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading, isError, refetch } = useQuery({
// The last 30 days, newest first, one page at a time. The window is fixed
// when the first page loads; each further page asks for the entries before
// the oldest one shown (`to` is exclusive, so the boundary instant is
// re-asked one millisecond later and the overlap is dropped by id).
const {
data,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: listKey,
queryFn: async () => {
// Last 30 days, newest-first.
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`;
initialPageParam: null as SideEffectPageParam | null,
queryFn: async ({ pageParam }) => {
const to = pageParam ? new Date(pageParam.to) : new Date();
const from = pageParam
? new Date(pageParam.from)
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
const { data: payload } = await apiFetchEnvelope<{
items: SideEffectRow[];
meta: { total: number };
}>(url);
return payload;
return { ...payload, from: from.toISOString() };
},
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
const oldest = lastPage.items[lastPage.items.length - 1];
return {
from: lastPage.from,
to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
};
},
staleTime: 30 * 1000,
});
@@ -184,7 +220,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
},
});
const items = data?.items ?? [];
const items = useMemo(() => {
const seen = new Set<string>();
const out: SideEffectRow[] = [];
for (const page of data?.pages ?? []) {
for (const row of page.items) {
if (seen.has(row.id)) continue;
seen.add(row.id);
out.push(row);
}
}
return out;
}, [data]);
const filteredEntries = useMemo(
() => entriesByCategory(category),
@@ -363,6 +410,20 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</li>
))}
</ul>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("medications.sideEffects.loadMore")}
</Button>
</div>
) : null}
</div>
)}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { SymptomEventDTO } from "@/lib/symptoms/shared";
import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms";
/**
* A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most
* `limit` rows, `hasMore` when the page is full.
*/
function fakeRoute(all: SymptomEventDTO[]) {
const urls: string[] = [];
const fetchPage = async (url: string): Promise<SymptomEventList> => {
urls.push(url);
const params = new URL(url, "http://localhost").searchParams;
const to = params.get("to")!;
const limit = Number(params.get("limit"));
const rows = all
.filter((e) => e.occurredAt <= to)
.sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1))
.slice(0, limit);
return { events: rows, limit, hasMore: rows.length === limit };
};
return { urls, fetchPage };
}
function event(i: number, occurredAt: string): SymptomEventDTO {
return { id: `e-${i}`, occurredAt } as SymptomEventDTO;
}
describe("loadSymptomEvents", () => {
it("reads past the first page until the window is exhausted", async () => {
const base = Date.parse("2026-06-01T00:00:00.000Z");
// 1,200 occurrences an hour apart, two of them sharing a page boundary.
const all = Array.from({ length: 1_200 }, (_, i) =>
event(i, new Date(base - i * 3_600_000).toISOString()),
);
all.push(event(9_999, all[499].occurredAt));
const { urls, fetchPage } = fakeRoute(all);
const out = await loadSymptomEvents(
"1900-01-01T00:00:00.000Z",
"2026-06-02T00:00:00.000Z",
"ep-1",
fetchPage,
);
expect(out.events).toHaveLength(1_201);
expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201);
expect(out.hasMore).toBe(false);
expect(urls.length).toBeGreaterThan(2);
expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true);
});
it("stops after one request when the first page is not full", async () => {
const { urls, fetchPage } = fakeRoute([
event(1, "2026-05-01T00:00:00.000Z"),
]);
const out = await loadSymptomEvents(
"2026-04-01T00:00:00.000Z",
"2026-06-01T00:00:00.000Z",
null,
fetchPage,
);
expect(out.events).toHaveLength(1);
expect(urls).toHaveLength(1);
});
});
+48 -6
View File
@@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) {
});
}
/** Occurrences per request while walking a window. */
const SYMPTOM_EVENTS_PAGE_SIZE = 500;
/**
* Every occurrence inside `[from, to]` (ISO instants), newest first, read page
* by page. The route answers at most one page and says `hasMore`; each
* further page asks up to the oldest instant already read (`to` is
* inclusive, so the boundary instant comes back and is dropped by id). A
* chronic episode can hold more than one page, and a single capped read
* showed only its newest part.
*/
export async function loadSymptomEvents(
from: string,
to: string,
episodeId: string | null,
fetchPage: (url: string) => Promise<SymptomEventList> = (url) =>
apiGet<SymptomEventList>(url),
): Promise<SymptomEventList> {
const seen = new Set<string>();
const events: SymptomEventDTO[] = [];
let upTo = to;
for (;;) {
const params = new URLSearchParams({
from,
to: upTo,
limit: String(SYMPTOM_EVENTS_PAGE_SIZE),
});
if (episodeId) params.set("episodeId", episodeId);
const page = await fetchPage(`/api/symptoms/events?${params}`);
let added = 0;
for (const event of page.events) {
if (seen.has(event.id)) continue;
seen.add(event.id);
events.push(event);
added += 1;
}
const oldest = page.events[page.events.length - 1];
// A page that adds nothing (a full page of one instant) cannot move the
// bound, so it ends the walk rather than repeating forever.
if (!page.hasMore || !oldest || added === 0) {
return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false };
}
upTo = oldest.occurredAt;
}
}
/**
* Occurrences inside `[from, to]` (ISO instants), optionally only those filed
* against one episode. At most 500, newest first.
* against one episode. Every one of them, newest first.
*/
export function useSymptomEvents(
from: string,
@@ -68,11 +114,7 @@ export function useSymptomEvents(
return useQuery({
queryKey: queryKeys.symptomEvents(from, to, episodeId),
enabled: options.enabled ?? true,
queryFn: () => {
const params = new URLSearchParams({ from, to, limit: "500" });
if (episodeId) params.set("episodeId", episodeId);
return apiGet<SymptomEventList>(`/api/symptoms/events?${params}`);
},
queryFn: () => loadSymptomEvents(from, to, episodeId),
});
}
+131
View File
@@ -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;
});
}
@@ -2878,6 +2878,11 @@ export const glp1TimelineResponse = z
.describe(
"Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.",
),
medications: z
.array(z.object({ id: z.string(), name: z.string() }))
.describe(
"Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the entry names, to know which medications are GLP-1: a medication whose events are all older than the newest `limit` entries has none in `entries`. Empty when `hasGlp1` is false.",
),
})
.meta({
id: "Glp1TimelineResponse",
+45
View File
@@ -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"],
+5
View File
@@ -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`
+7
View File
@@ -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
@@ -0,0 +1,276 @@
/**
* `GET /api/labs/overview` against a real Postgres, on a long history.
*
* The Labs list used to group the newest 500 raw rows in the browser, so on
* an account with thousands of readings every marker measured only long ago
* had no row at all. The overview groups server-side over every live row;
* this suite seeds 2,000 readings over 25 years and checks that each marker
* comes back once, with its full count, its newest reading and a trend.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-labs-overview-test";
const OTHER_USER_ID = "user-labs-overview-other";
const DAY_MS = 24 * 60 * 60 * 1000;
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
/** Days before now, so the fixture never ages out of its own windows. */
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
const FREQUENT_READINGS = 1_700;
const RARE_MARKERS = 58;
const RARE_READINGS_EACH = 5;
const LEGACY_READINGS = 10;
const TOTAL =
FREQUENT_READINGS + RARE_MARKERS * RARE_READINGS_EACH + LEGACY_READINGS;
interface Seeded {
frequentId: string;
rareIds: string[];
}
/**
* 2,000 live readings over 25 years: one marker measured almost daily over
* the last five years (1,700), 58 markers measured five times each between 25
* and 6 years ago (290), and one legacy unlinked marker whose ten readings
* spell the analyte in different cases (10). The newest 500 rows are all the
* frequent marker, which is exactly the history the capped list lost.
*/
async function seedLongHistory(): Promise<Seeded> {
const prisma = getPrismaClient();
const frequent = await prisma.biomarker.create({
data: {
userId: USER_ID,
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 99,
},
});
const rare = await Promise.all(
Array.from({ length: RARE_MARKERS }, (_, i) =>
prisma.biomarker.create({
data: {
userId: USER_ID,
name: `Rare marker ${String(i).padStart(2, "0")}`,
unit: "U/L",
lowerBound: 1,
upperBound: 50,
},
}),
),
);
const rows: Array<{
userId: string;
biomarkerId: string | null;
analyte: string;
value: number;
unit: string;
takenAt: Date;
}> = [];
for (let i = 0; i < FREQUENT_READINGS; i++) {
rows.push({
userId: USER_ID,
biomarkerId: frequent.id,
analyte: "Glucose",
value: 80 + (i % 20),
unit: "mg/dL",
takenAt: daysAgo(1 + i),
});
}
rare.forEach((marker, m) => {
for (let k = 0; k < RARE_READINGS_EACH; k++) {
rows.push({
userId: USER_ID,
biomarkerId: marker.id,
analyte: marker.name,
value: 10 + k,
unit: "U/L",
// Between ~25 and ~6 years ago, one marker per fortnight-ish slot.
takenAt: daysAgo(25 * 365 - m * 120 - k * 20),
});
}
});
for (let k = 0; k < LEGACY_READINGS; k++) {
rows.push({
userId: USER_ID,
biomarkerId: null,
analyte: k % 2 === 0 ? "Vitamin X" : "VITAMIN x",
value: 1 + k,
unit: "nmol/L",
takenAt: daysAgo(24 * 365 - k * 30),
});
}
await prisma.labResult.createMany({ data: rows });
// Noise that must not count: a deleted reading of a rare marker, and
// another account's reading.
await prisma.labResult.create({
data: {
userId: USER_ID,
biomarkerId: rare[0].id,
analyte: rare[0].name,
value: 99,
unit: "U/L",
takenAt: daysAgo(0),
deletedAt: daysAgo(0),
},
});
await prisma.labResult.create({
data: {
userId: OTHER_USER_ID,
analyte: "Other account marker",
value: 1,
unit: "U/L",
takenAt: daysAgo(0),
},
});
return { frequentId: frequent.id, rareIds: rare.map((m) => m.id) };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{ id: USER_ID, username: "labs-ov", email: "labs-ov@example.test" },
{
id: OTHER_USER_ID,
username: "labs-ov-other",
email: "labs-ov-other@example.test",
},
],
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
});
interface OverviewBody {
data: {
markers: Array<{
key: string;
count: number;
trend: (number | null)[];
latest: {
id: string;
biomarkerId: string | null;
analyte: string;
value: number | null;
takenAt: string;
};
}>;
};
}
async function getOverview(query = "") {
const { GET } = await import("@/app/api/labs/overview/route");
return GET(new NextRequest(`http://localhost/api/labs/overview${query}`));
}
describe("GET /api/labs/overview on a 25-year history", () => {
it("returns every marker once, with its full count and newest reading", async () => {
const seeded = await seedLongHistory();
const res = await getOverview();
expect(res.status).toBe(200);
const { markers } = ((await res.json()) as OverviewBody).data;
// 1 frequent + 58 rare + 1 legacy, and nothing from the other account.
expect(markers).toHaveLength(1 + RARE_MARKERS + 1);
expect(markers.reduce((sum, m) => sum + m.count, 0)).toBe(TOTAL);
expect(TOTAL).toBe(2_000);
const byKey = new Map(markers.map((m) => [m.key, m]));
for (const id of seeded.rareIds) {
const marker = byKey.get(`bm:${id}`);
expect(marker, `marker ${id} missing`).toBeDefined();
// The deleted reading neither counts nor becomes the newest one.
expect(marker!.count).toBe(RARE_READINGS_EACH);
expect(marker!.latest.value).toBe(14);
expect(marker!.trend).toEqual([10, 11, 12, 13, 14]);
}
const frequent = byKey.get(`bm:${seeded.frequentId}`)!;
expect(frequent.count).toBe(FREQUENT_READINGS);
// Capped trend, oldest first, ending on the newest reading.
expect(frequent.trend).toHaveLength(50);
expect(frequent.trend.at(-1)).toBe(frequent.latest.value);
expect(frequent.latest.value).toBe(80);
// Legacy rows group case-insensitively by analyte.
const legacy = byKey.get("analyte:vitamin x")!;
expect(legacy.count).toBe(LEGACY_READINGS);
expect(legacy.latest.biomarkerId).toBeNull();
expect(legacy.latest.value).toBe(LEGACY_READINGS);
// Newest reading first.
expect(markers[0].key).toBe(`bm:${seeded.frequentId}`);
const times = markers.map((m) => Date.parse(m.latest.takenAt));
expect([...times].sort((a, b) => b - a)).toEqual(times);
expect(
markers.some((m) => m.latest.analyte === "Other account marker"),
).toBe(false);
});
it("covers the markers a 500-row page of the list endpoint drops", async () => {
await seedLongHistory();
const { GET } = await import("@/app/api/labs/route");
const page = await GET(
new NextRequest("http://localhost/api/labs?limit=500&sortDir=desc"),
);
const pageBody = (await page.json()) as {
data: { results: Array<{ biomarkerId: string | null }> };
};
const onPage = new Set(pageBody.data.results.map((r) => r.biomarkerId));
// The old list's view of this account: one marker.
expect(onPage.size).toBe(1);
const { markers } = ((await (await getOverview()).json()) as OverviewBody)
.data;
expect(markers).toHaveLength(1 + RARE_MARKERS + 1);
});
it("refuses a query parameter instead of ignoring it", async () => {
const res = await getOverview("?limit=500");
expect(res.status).toBe(422);
});
it("answers an empty list for an account without readings", async () => {
const res = await getOverview();
expect(res.status).toBe(200);
expect(((await res.json()) as OverviewBody).data.markers).toEqual([]);
});
});
@@ -0,0 +1,78 @@
/**
* The boot notice `docker-entrypoint.sh` prints before `migrate deploy`
* (`scripts/pending-migrations.mjs`), against the migrated test database.
*
* Every shipped migration is applied here, so the count is zero; a
* migrations directory with one more entry than the database has applied
* stands for a freshly pulled image, and a schema with no migrations table
* for a first boot.
*/
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
pendingMigrations,
pendingNotice,
shippedMigrations,
} from "../../scripts/pending-migrations.mjs";
const MIGRATIONS = join(process.cwd(), "prisma", "migrations");
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true });
});
function connectionString(): string {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set for the integration run");
return url;
}
describe("pending-migrations boot notice", () => {
it("counts nothing pending on a database that has applied every migration", async () => {
const pending = await pendingMigrations({
connectionString: connectionString(),
migrationsDir: MIGRATIONS,
});
expect(shippedMigrations(MIGRATIONS).length).toBeGreaterThan(300);
expect(pending).toEqual([]);
expect(pendingNotice(pending.length)).toBe(
"HealthLog: Database schema is up to date.",
);
});
it("counts a migration the image ships and the database has not applied", async () => {
const dir = mkdtempSync(join(tmpdir(), "hl-migrations-"));
tempDirs.push(dir);
cpSync(MIGRATIONS, dir, { recursive: true });
mkdirSync(join(dir, "9999_not_applied_yet"));
writeFileSync(join(dir, "9999_not_applied_yet", "migration.sql"), "");
// A directory without migration.sql is not a migration.
mkdirSync(join(dir, "9998_no_sql"));
const pending = await pendingMigrations({
connectionString: connectionString(),
migrationsDir: dir,
});
expect(pending).toEqual(["9999_not_applied_yet"]);
const notice = pendingNotice(pending.length);
expect(notice).toContain("1 database migration pending");
expect(notice).toContain("pg_dump");
});
it("treats a schema without a migrations table as a first boot", async () => {
const url = new URL(connectionString());
url.searchParams.set("schema", "schema_never_migrated");
const pending = await pendingMigrations({
connectionString: url.toString(),
migrationsDir: MIGRATIONS,
});
expect(pending).toEqual(shippedMigrations(MIGRATIONS));
expect(pendingNotice(pending.length)).toMatch(
/^HealthLog: \d+ database migrations pending\./,
);
});
});