mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'v1424/readiness' into release/v1.42.4
This commit is contained in:
commit
6313c0174a
37 files changed
+1588
-147
No files matched your search
+8
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1386,6 +1386,7 @@
|
||||
"emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.",
|
||||
"recentTitle": "최근 30일",
|
||||
"loading": "불러오는 중…",
|
||||
"loadMore": "더 불러오기",
|
||||
"deleteCta": "삭제",
|
||||
"deleteError": "기록을 삭제하지 못했어요",
|
||||
"errorSeverity": "심각도는 1에서 5 사이여야 해요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user