From 271cc0dcfa30a7b28684e4b0233c5ba5d206f4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:39:16 +0200 Subject: [PATCH 1/3] feat(labs): add structured lab-result store with /api/labs CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record discrete biomarker readings (HbA1c, LDL, ferritin, TSH, …) with their value, unit, reference range and sample date. The store pairs with the annual blood-panel reminder, which until now had nowhere to record the result it reminds the user to obtain. The list/create/update/delete endpoints narrow userId from the session, build the write data field-by-field, soft-delete via deletedAt, and encrypt the optional note at rest. A neutral three-state reference-range classifier (in-range / below / above / unknown) is computed server-side so every surface renders the same calm verdict. findLatestLabResultForAnalytes exposes the read point the blood-panel reminder consults to mark itself satisfied, without coupling the two. --- src/app/api/labs/[id]/route.ts | 188 +++++++++++++++++++++ src/app/api/labs/route.ts | 188 +++++++++++++++++++++ src/lib/labs/store.ts | 71 ++++++++ src/lib/query-keys/index.ts | 2 + src/lib/query-keys/labs.ts | 33 ++++ src/lib/validations/__tests__/labs.test.ts | 174 +++++++++++++++++++ src/lib/validations/labs.ts | 169 ++++++++++++++++++ 7 files changed, 825 insertions(+) create mode 100644 src/app/api/labs/[id]/route.ts create mode 100644 src/app/api/labs/route.ts create mode 100644 src/lib/labs/store.ts create mode 100644 src/lib/query-keys/labs.ts create mode 100644 src/lib/validations/__tests__/labs.test.ts create mode 100644 src/lib/validations/labs.ts diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts new file mode 100644 index 000000000..829f88498 --- /dev/null +++ b/src/app/api/labs/[id]/route.ts @@ -0,0 +1,188 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; +import { annotate } from "@/lib/logging/context"; +import { + classifyReferenceRange, + updateLabResultSchema, +} from "@/lib/validations/labs"; + +/** + * v1.17.1 — single lab-result resource (`/api/labs/{id}`). + * + * GET returns the row including its decrypted note (the detail view is the + * one place the plaintext note surfaces). PUT applies a partial edit + * (`data` built field-by-field; an explicit `null` clears `panel` / `note` / + * a reference bound, an omitted key leaves it untouched). DELETE soft-deletes + * by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed). + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!row || row.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); + + return apiSuccess({ + id: row.id, + panel: row.panel, + analyte: row.analyte, + value: row.value, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + note: row.noteEncrypted + ? decryptNoteFromBytes(row.noteEncrypted) + : null, + rangeStatus: classifyReferenceRange( + row.value, + row.referenceLow, + row.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + }, +); + +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateLabResultSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, labResultId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, labResultId: id }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on `panel` / `note` / a reference bound clears it. + const data: Record = {}; + if (d.panel !== undefined) data.panel = d.panel; + if (d.analyte !== undefined) data.analyte = d.analyte; + if (d.value !== undefined) data.value = d.value; + if (d.unit !== undefined) data.unit = d.unit; + if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow; + if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh; + if (d.takenAt !== undefined) data.takenAt = d.takenAt; + if (d.note !== undefined) { + data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; + } + + const updated = await prisma.labResult.update({ where: { id }, data }); + + await auditLog("labResult.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: id }, + }); + + annotate({ action: { name: "labs.update" }, meta: { labResultId: id } }); + + return apiSuccess({ + id: updated.id, + panel: updated.panel, + analyte: updated.analyte, + value: updated.value, + unit: updated.unit, + referenceLow: updated.referenceLow, + referenceHigh: updated.referenceHigh, + takenAt: updated.takenAt.toISOString(), + source: updated.source, + hasNote: updated.noteEncrypted !== null, + rangeStatus: classifyReferenceRange( + updated.value, + updated.referenceLow, + updated.referenceHigh, + ), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + // Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`, + // so the row is invisible from here on. A re-delete is idempotent. + await prisma.labResult.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("labResult.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: id, analyte: existing.analyte }, + }); + + annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts new file mode 100644 index 000000000..a80ff3f41 --- /dev/null +++ b/src/app/api/labs/route.ts @@ -0,0 +1,188 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptNoteToBytes } from "@/lib/labs/store"; +import { annotate } from "@/lib/logging/context"; +import { + classifyReferenceRange, + createLabResultSchema, + listLabResultsSchema, +} from "@/lib/validations/labs"; + +/** + * v1.17.1 — structured lab-result store (`/api/labs`). + * + * GET lists the caller's live results with optional analyte / panel / date + * filters. POST records a single reading. `userId` is always narrowed from + * the session — never a body field — and the write `data` object is built + * field-by-field (no mass assignment). The free-text note, when present, is + * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write. + */ + +// The serialised row never echoes the encrypted note bytes back; the badge +// status is computed server-side so the client renders a coherent, neutral +// in/out-of-range verdict without re-deriving the rule. +function serialiseLabResult(row: { + id: string; + panel: string | null; + analyte: string; + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; + source: string; + noteEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + panel: row.panel, + analyte: row.analyte, + value: row.value, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + hasNote: row.noteEncrypted !== null, + rangeStatus: classifyReferenceRange( + row.value, + row.referenceLow, + row.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listLabResultsSchema.safeParse(params); + if (!parsed.success) { + annotate({ + action: { name: "labs.list.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data; + + const where = { + userId: user.id, + deletedAt: null, + ...(analyte && { analyte }), + ...(panel && { panel }), + ...(from || to + ? { + takenAt: { + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.labResult.findMany({ + where, + orderBy: { takenAt: sortDir }, + take: limit, + skip: offset, + }), + prisma.labResult.count({ where }), + ]); + + annotate({ + action: { name: "labs.list" }, + meta: { total, limit, offset }, + }); + + return apiSuccess({ + results: rows.map(serialiseLabResult), + meta: { total, limit, offset }, + }); +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult)); + +async function postLabResult(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createLabResultSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + // Free-text `analyte` / `unit` / `note` could land verbatim in a Zod + // issue message — strip values from the audit-ledger breadcrumb. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } = + parsed.data; + + // Field-by-field assignment — never spread `parsed.data`. `source` is + // hardcoded "MANUAL" for this user-facing path; import paths set their own. + const created = await prisma.labResult.create({ + data: { + userId: user.id, + panel: panel ?? null, + analyte, + value, + unit, + referenceLow: referenceLow ?? null, + referenceHigh: referenceHigh ?? null, + takenAt, + source: "MANUAL", + noteEncrypted: note ? encryptNoteToBytes(note) : null, + }, + }); + + await auditLog("labResult.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: created.id, analyte: created.analyte }, + }); + + annotate({ + action: { name: "labs.create" }, + meta: { labResultId: created.id }, + }); + + return apiSuccess(serialiseLabResult(created), 201); +} diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts new file mode 100644 index 000000000..b50ab1e05 --- /dev/null +++ b/src/lib/labs/store.ts @@ -0,0 +1,71 @@ +/** + * v1.17.1 — server-side helpers for the structured lab-result store. + * + * Two concerns live here: + * + * 1. The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The + * free-text note is the only sensitive column on the model; it shares + * the `encrypt()` string format (`"."`) every other + * `*Encrypted` column uses, encoded as UTF-8 bytes. + * + * 2. `findLatestLabResultForAnalytes` — the read point the Vorsorge + * annual-blood-panel reminder consults to decide whether a panel was + * recorded inside its lead-time window. Exposed here, NOT coupled to + * the reminder's code, so the reminder can mark itself satisfied + * without this module taking a dependency on it. + */ +import { Buffer } from "node:buffer"; + +import { decrypt, encrypt } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; + +/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */ +export function encryptNoteToBytes(plaintext: string): Uint8Array { + const ciphertext = encrypt(plaintext); + const encoded = Buffer.from(ciphertext, "utf8"); + // Prisma `Bytes` maps to `Uint8Array`; allocate a fresh + // ArrayBuffer-backed view so the structural type stays stable. + const out = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + out.set(encoded); + return out; +} + +/** Decrypt a stored `Bytes` note back to plaintext. Throws on a bad key id. */ +export function decryptNoteFromBytes(buf: Uint8Array): string { + return decrypt(Buffer.from(buf).toString("utf8")); +} + +/** + * Latest live (non-tombstoned) lab result for any of the given analytes, + * recorded at or after `since`. Returns the single most-recent matching + * row by `takenAt`, or `null` when no matching result exists in the window. + * + * Case-insensitive analyte match so "HbA1c" / "hba1c" resolve the same — a + * lab report's casing is not the user's concern. The Vorsorge reminder + * passes the analytes that satisfy an annual blood panel (e.g. an HbA1c or + * a lipid marker) plus the reminder's window start; a non-null return means + * the panel was recorded and the reminder can mark itself satisfied. + */ +export async function findLatestLabResultForAnalytes( + userId: string, + analytes: string[], + since: Date, +): Promise<{ id: string; analyte: string; takenAt: Date } | null> { + if (analytes.length === 0) return null; + const candidates = await prisma.labResult.findMany({ + where: { + userId, + deletedAt: null, + takenAt: { gte: since }, + // Prisma has no case-insensitive `in`; match each analyte + // case-insensitively and let the DB OR them together. + OR: analytes.map((a) => ({ + analyte: { equals: a, mode: "insensitive" as const }, + })), + }, + orderBy: { takenAt: "desc" }, + take: 1, + select: { id: true, analyte: true, takenAt: true }, + }); + return candidates[0] ?? null; +} diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d864a42bd..7a8e73804 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -21,6 +21,7 @@ import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; +import { labKeys } from "./labs"; import { measurementKeys } from "./measurements"; import { medicationKeys } from "./medications"; import { moodKeys } from "./mood"; @@ -40,6 +41,7 @@ export const queryKeys = { ...workoutKeys, ...settingsKeys, ...cycleKeys, + ...labKeys, }; /** diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts new file mode 100644 index 000000000..374b59753 --- /dev/null +++ b/src/lib/query-keys/labs.ts @@ -0,0 +1,33 @@ +/** + * v1.17.1 — TanStack Query keys for the structured lab-result store. + * + * `labResultsList` bakes the filter + pagination shape into the key so a + * filter change refetches without a same-key cache collision (the project's + * queryKey-factory invariant). + */ +export const labKeys = { + labResults: () => ["lab-results"] as const, + + labResultsList: (params: { + analyte: string | undefined; + panel: string | undefined; + from: string | undefined; + to: string | undefined; + page: number; + sortDir: string; + }) => + [ + "lab-results", + "list", + params.analyte ?? null, + params.panel ?? null, + params.from ?? null, + params.to ?? null, + params.page, + params.sortDir, + ] as const, + + /** Per-analyte trend series (≥2 values) shown inline on a grouped card. */ + labAnalyteTrend: (analyte: string) => + ["lab-results", "trend", analyte] as const, +}; diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts new file mode 100644 index 000000000..712ce8328 --- /dev/null +++ b/src/lib/validations/__tests__/labs.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; + +import { + classifyReferenceRange, + createLabResultSchema, + listLabResultsSchema, + updateLabResultSchema, +} from "../labs"; + +const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +const RECENT = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + +describe("createLabResultSchema", () => { + it("accepts a minimal valid reading", () => { + const r = createLabResultSchema.safeParse({ + analyte: "HbA1c", + value: 5.4, + unit: "%", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); + + it("normalises a backdatable takenAt to a Date", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: 110, + unit: "mg/dL", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.takenAt).toBeInstanceOf(Date); + }); + + it("rejects a future takenAt", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: 110, + unit: "mg/dL", + takenAt: FUTURE, + }); + expect(r.success).toBe(false); + }); + + it("rejects an empty analyte", () => { + const r = createLabResultSchema.safeParse({ + analyte: " ", + value: 1, + unit: "%", + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("rejects a transposed reference range (low > high)", () => { + const r = createLabResultSchema.safeParse({ + analyte: "Ferritin", + value: 50, + unit: "ng/mL", + referenceLow: 300, + referenceHigh: 30, + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("accepts a single (upper-only) reference bound", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: 110, + unit: "mg/dL", + referenceHigh: 116, + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); + + it("normalises an empty-string panel to undefined", () => { + const r = createLabResultSchema.safeParse({ + analyte: "TSH", + value: 2, + unit: "mIU/L", + panel: "", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.panel).toBeUndefined(); + }); + + it("rejects a non-finite value", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: Number.POSITIVE_INFINITY, + unit: "mg/dL", + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); +}); + +describe("updateLabResultSchema", () => { + it("accepts an explicit null to clear panel + note", () => { + const r = updateLabResultSchema.safeParse({ panel: null, note: null }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.panel).toBeNull(); + expect(r.data.note).toBeNull(); + } + }); + + it("accepts a partial value-only edit", () => { + const r = updateLabResultSchema.safeParse({ value: 5.6 }); + expect(r.success).toBe(true); + }); + + it("still enforces the range order when both bounds are supplied", () => { + const r = updateLabResultSchema.safeParse({ + referenceLow: 10, + referenceHigh: 1, + }); + expect(r.success).toBe(false); + }); +}); + +describe("listLabResultsSchema", () => { + it("defaults limit/offset/sortDir", () => { + const r = listLabResultsSchema.safeParse({}); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.limit).toBe(100); + expect(r.data.offset).toBe(0); + expect(r.data.sortDir).toBe("desc"); + } + }); + + it("caps limit at 500", () => { + const r = listLabResultsSchema.safeParse({ limit: "9999" }); + expect(r.success).toBe(false); + }); +}); + +describe("classifyReferenceRange", () => { + it("returns unknown when no bounds are given", () => { + expect(classifyReferenceRange(5, null, null)).toBe("unknown"); + expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown"); + }); + + it("returns in-range inside both bounds", () => { + expect(classifyReferenceRange(5, 4, 6)).toBe("in-range"); + }); + + it("treats the bounds as inclusive", () => { + expect(classifyReferenceRange(4, 4, 6)).toBe("in-range"); + expect(classifyReferenceRange(6, 4, 6)).toBe("in-range"); + }); + + it("returns below when under the low bound", () => { + expect(classifyReferenceRange(3.9, 4, 6)).toBe("below"); + }); + + it("returns above when over the high bound", () => { + expect(classifyReferenceRange(6.1, 4, 6)).toBe("above"); + }); + + it("classifies against a single upper bound", () => { + expect(classifyReferenceRange(120, null, 116)).toBe("above"); + expect(classifyReferenceRange(100, null, 116)).toBe("in-range"); + }); + + it("classifies against a single lower bound", () => { + expect(classifyReferenceRange(8, 10, null)).toBe("below"); + expect(classifyReferenceRange(12, 10, null)).toBe("in-range"); + }); +}); diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts new file mode 100644 index 000000000..fde18bc7c --- /dev/null +++ b/src/lib/validations/labs.ts @@ -0,0 +1,169 @@ +/** + * v1.17.1 — structured lab-result store. + * + * A minimal, generic `LabResult` capture path: a self-hoster records a + * biomarker reading (HbA1c, LDL, ferritin, TSH, …) with its value, unit, + * the lab's reference range, and the sample date. Pairs with the v1.17.1 + * Vorsorge annual-blood-panel reminder, which otherwise has nowhere to + * record the result it reminds the user to obtain. + * + * The model is intentionally free-form on `analyte` + `unit` (no closed + * LOINC enum): a lab report prints whatever name and unit it likes, and a + * forced taxonomy would reject real-world values. The optional `panel` + * groups analytes that arrived together (e.g. "Großes Blutbild"). + */ +import { z } from "zod/v4"; + +import { validateEntryInstant } from "@/lib/validations/entry-instant"; + +/** + * Far-past floor for a lab sample. A self-hoster legitimately backdates an + * old paper report, so the window is generous (50 years) but still rejects + * epoch-zero / corrupted-import instants via the shared 1900 floor. + */ +const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000; + +/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */ +const takenAtField = validateEntryInstant( + z + .string() + .datetime({ message: "takenAt must be an ISO 8601 datetime" }) + .transform((s) => new Date(s)), + { + maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS, + pastMessage: "Sample date is too far in the past", + }, +); + +/** Trimmed, bounded free-text field with a min-length guard. */ +const requiredText = (max: number) => + z + .string() + .trim() + .min(1) + .max(max); + +const optionalPanel = z + .string() + .trim() + .min(1) + .max(120) + .optional() + // An empty-string panel is normalised to "no panel" so the grouping + // stays NULL-distinct rather than carrying a blank label. + .or(z.literal("").transform(() => undefined)); + +const optionalNote = z + .string() + .trim() + .max(2000) + .optional() + .or(z.literal("").transform(() => undefined)); + +/** + * Reference bounds are independently optional (a lab may report only an + * upper bound, e.g. LDL < 116). When BOTH are present the low bound must + * not exceed the high bound — a transposed range is a client error. + */ +const referenceLow = z.number().finite().optional(); +const referenceHigh = z.number().finite().optional(); + +export const createLabResultSchema = z + .object({ + panel: optionalPanel, + analyte: requiredText(120), + value: z.number().finite(), + unit: requiredText(40), + referenceLow, + referenceHigh, + takenAt: takenAtField, + note: optionalNote, + }) + .refine( + (d) => + d.referenceLow === undefined || + d.referenceHigh === undefined || + d.referenceLow <= d.referenceHigh, + { + message: "referenceLow must not exceed referenceHigh", + path: ["referenceLow"], + }, + ); + +export type CreateLabResultInput = z.infer; + +/** + * Update schema: every field optional (partial edit). `panel` and `note` + * accept an explicit `null` to clear the stored value — distinct from an + * omitted key, which leaves the column untouched. + */ +export const updateLabResultSchema = z + .object({ + panel: optionalPanel.or(z.null()), + analyte: requiredText(120).optional(), + value: z.number().finite().optional(), + unit: requiredText(40).optional(), + referenceLow: referenceLow.or(z.null()), + referenceHigh: referenceHigh.or(z.null()), + takenAt: takenAtField.optional(), + note: optionalNote.or(z.null()), + }) + .refine( + (d) => + d.referenceLow === undefined || + d.referenceLow === null || + d.referenceHigh === undefined || + d.referenceHigh === null || + d.referenceLow <= d.referenceHigh, + { + message: "referenceLow must not exceed referenceHigh", + path: ["referenceLow"], + }, + ); + +export type UpdateLabResultInput = z.infer; + +/** List query: filter by analyte (exact) + date range, paginate. */ +export const listLabResultsSchema = z.object({ + analyte: z.string().trim().min(1).max(120).optional(), + panel: z.string().trim().min(1).max(120).optional(), + from: z + .string() + .datetime() + .transform((s) => new Date(s)) + .optional(), + to: z + .string() + .datetime() + .transform((s) => new Date(s)) + .optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), + offset: z.coerce.number().int().min(0).default(0), + sortDir: z.enum(["asc", "desc"]).default("desc"), +}); + +export type ListLabResultsInput = z.infer; + +/** + * Reference-range classification. Deliberately a three-state, NEUTRAL + * verdict — the badge that renders it must stay calm and informative, NOT + * alarming (no red "out of range" tint). `"unknown"` when the lab reported + * no usable bounds. + * + * Bounds are treated as inclusive: a value exactly on the reference limit + * reads as in-range, matching how labs print "≤" / "≥" reference notation. + */ +export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown"; + +export function classifyReferenceRange( + value: number, + referenceLow: number | null | undefined, + referenceHigh: number | null | undefined, +): ReferenceRangeStatus { + const hasLow = referenceLow !== null && referenceLow !== undefined; + const hasHigh = referenceHigh !== null && referenceHigh !== undefined; + if (!hasLow && !hasHigh) return "unknown"; + if (hasLow && value < (referenceLow as number)) return "below"; + if (hasHigh && value > (referenceHigh as number)) return "above"; + return "in-range"; +} From fbecb6b5c88392569e491663033183f82e1e9d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:39:29 +0200 Subject: [PATCH 2/3] feat(labs): surface lab results in the doctor report and FHIR export Add a Lab results section to the report aggregator (latest reading per analyte over the window), a new labs section toggle (ON by default, the same stance as BP and weight since the user recorded these to share with a clinician), a PDF table with a quiet non-colour in/out-of-range marker, and one laboratory Observation per result in the FHIR bundle carrying the lab's reported referenceRange. Everything stays server-authoritative. --- .../__tests__/route.test.ts | 2 + .../doctor-report/__tests__/pdf-route.test.ts | 2 + .../health-record/__tests__/route.test.ts | 2 + .../doctor-report/doctor-report-dialog.tsx | 8 + src/lib/doctor-report-data.ts | 70 ++++++++ src/lib/doctor-report-pdf-core.ts | 83 +++++++++ .../fhir/__tests__/lab-observations.test.ts | 159 ++++++++++++++++++ src/lib/fhir/resources.ts | 41 +++++ src/lib/fhir/types.ts | 12 ++ .../__tests__/doctor-report-prefs.test.ts | 6 + src/lib/validations/doctor-report-prefs.ts | 5 + src/lib/validations/health-record-export.ts | 4 + 12 files changed, 394 insertions(+) create mode 100644 src/lib/fhir/__tests__/lab-observations.test.ts diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 4550fc90c..10b591041 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { compliance: true, sleep: false, cycle: false, + labs: true, }; const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); @@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { compliance: false, sleep: false, cycle: false, + labs: false, }; vi.mocked(prisma.user.findUnique).mockResolvedValue({ doctorReportPrefsJson: current, diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts index 976b297da..50fb75504 100644 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, auditLog: { create: vi.fn() }, }, @@ -71,6 +72,7 @@ function setEmptyDataMocks() { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "testuser", dateOfBirth: null, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 95b700be2..bc8e1a7c0 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -74,6 +75,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); // First findUnique = aggregator profile select; second = route KVNR select. vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "sample", diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index ad53fc728..4af0bd050 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -87,6 +87,11 @@ interface SectionAvailability { // the cycle toggle. Present so `keyof DoctorReportPrefs` indexing // typechecks. Optional — the availability endpoint may not provide it. cycle?: boolean; + // v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog; + // the flagship export panel surfaces the toggle. Optional — the + // availability endpoint may not provide it. Present so the + // `keyof DoctorReportPrefs` indexing typechecks. + labs?: boolean; } export interface DoctorReportSubmitPayload { @@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record = { // v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it); // present so the `keyof DoctorReportPrefs` record stays exhaustive. cycle: "doctorReport.sections.cycle", + // v1.17.1 — labs section; the flagship export panel drives the toggle, + // present here so the `keyof DoctorReportPrefs` record stays exhaustive. + labs: "doctorReport.sections.labs", }; /** diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 4b0aacdef..62ddc5ee0 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -230,6 +230,31 @@ export interface DoctorReportData { * still typecheck. */ cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null; + /** + * v1.17.1 — structured lab results recorded over the report window. + * Populated when the `labs` section toggle is ON (default) and the user + * recorded at least one result in the period. Statistics + the single + * latest reading per analyte; free-text notes never reach this surface. + * Each entry carries the lab's reference bounds so the PDF can print an + * in/out-of-range marker and the FHIR exporter can emit `referenceRange`. + * Null/absent otherwise; both the PDF and FHIR builders skip the section. + * Optional so pre-v1.17.1 fixtures still typecheck. + */ + labResults?: Array<{ + /** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */ + panel: string | null; + /** Biomarker name as the user recorded it. */ + analyte: string; + /** Latest recorded value for this analyte over the window. */ + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + /** ISO timestamp of the latest reading. */ + takenAt: string; + /** Count of readings for this analyte in the window (≥ 1). */ + count: number; + }> | null; } /** The three persisted score types surfaced in the wellness summary. */ @@ -1057,6 +1082,50 @@ export async function collectDoctorReportData( ); } + // v1.17.1 — structured lab results over the window. ON by default; the + // user recorded these specifically to share with a clinician, so the + // privacy stance matches BP / weight, not mood / cycle. We reduce to the + // latest reading per analyte (with a count) so the report is a concise + // panel, not a raw dump; notes are never read here. + let labResults: DoctorReportData["labResults"] = null; + if (sections.labs) { + const labRows = await prisma.labResult.findMany({ + where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null }, + orderBy: { takenAt: "asc" }, + select: { + panel: true, + analyte: true, + value: true, + unit: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + }, + }); + // Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold + // together; rows are ascending so the last seen wins as the latest. + const byAnalyte = new Map< + string, + NonNullable[number] + >(); + for (const r of labRows) { + const key = r.analyte.toLowerCase(); + const prev = byAnalyte.get(key); + byAnalyte.set(key, { + panel: r.panel, + analyte: r.analyte, + value: r.value, + unit: r.unit, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + takenAt: r.takenAt.toISOString(), + count: (prev?.count ?? 0) + 1, + }); + } + const collapsed = Array.from(byAnalyte.values()); + labResults = collapsed.length > 0 ? collapsed : null; + } + return { period: { days, @@ -1109,6 +1178,7 @@ export async function collectDoctorReportData( glp1, wellnessScores, cycle, + labResults, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 1a14e0543..db9bd0fb9 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1319,6 +1319,89 @@ export function buildDoctorReportPdfDocument( } } + // v1.17.1 — structured lab-results section. Populated when the `labs` + // toggle is ON (default) and the user recorded at least one result in the + // window. One row per analyte (latest reading), with the lab's reference + // range and a NEUTRAL in/out-of-range marker — informative, never an + // alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so + // the clinical PDF stays calm. + if (data.labResults && data.labResults.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.labsTitle"), margin, y); + y += 6; + + const rangeText = ( + low: number | null, + high: number | null, + ): string => { + if (low !== null && high !== null) return `${num(low)}–${num(high)}`; + if (high !== null) return `≤ ${num(high)}`; + if (low !== null) return `≥ ${num(low)}`; + return "—"; + }; + // Quiet, non-colour status glyph. In-range reads as a neutral dash so + // the table is not a field of warning marks; out-of-range reads as a + // direction arrow the clinician can scan, with no alarm tint. + const statusGlyph = ( + value: number, + low: number | null, + high: number | null, + ): string => { + if (low === null && high === null) return ""; + if (low !== null && value < low) return "↓"; + if (high !== null && value > high) return "↑"; + return "—"; + }; + + const labRows = data.labResults.map((lr) => [ + lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte, + `${num(lr.value)} ${lr.unit}`.trim(), + rangeText(lr.referenceLow, lr.referenceHigh), + statusGlyph(lr.value, lr.referenceLow, lr.referenceHigh), + fmtDate(lr.takenAt), + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.labsColAnalyte"), + t("doctorReport.labsColValue"), + t("doctorReport.labsColReference"), + t("doctorReport.labsColStatus"), + t("doctorReport.labsColDate"), + ], + ], + body: labRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts new file mode 100644 index 000000000..9d6dcb145 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; + +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +import { buildFhirDocumentBundle } from "../build-bundle"; +import type { FhirObservation } from "../types"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function makeData(overrides?: Partial): DoctorReportData { + return { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { + username: "sample-user", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + }; +} + +function observationsOf(bundle: ReturnType) { + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirObservation => r.resourceType === "Observation"); +} + +describe("lab-result FHIR Observations", () => { + const labResults: NonNullable = [ + { + panel: "Lipid panel", + analyte: "LDL", + value: 110, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 116, + takenAt: "2026-04-20T08:00:00.000Z", + count: 2, + }, + { + panel: null, + analyte: "HbA1c", + value: 5.4, + unit: "%", + referenceLow: 4, + referenceHigh: 5.6, + takenAt: "2026-04-21T08:00:00.000Z", + count: 1, + }, + ]; + + it("emits one laboratory Observation per lab result", () => { + const bundle = buildFhirDocumentBundle( + makeData({ labResults }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const labObs = observationsOf(bundle).filter((o) => + o.category?.some((c) => + c.coding?.some((code) => code.code === "laboratory"), + ), + ); + // Two lab observations (no glucose in this fixture). + expect(labObs).toHaveLength(2); + }); + + it("maps the value, unit, and effective date onto the Observation", () => { + const bundle = buildFhirDocumentBundle( + makeData({ labResults }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)"); + expect(ldl).toBeDefined(); + expect(ldl?.valueQuantity?.value).toBe(110); + expect(ldl?.valueQuantity?.unit).toBe("mg/dL"); + expect(ldl?.effectiveDateTime).toBe("2026-04-20T08:00:00.000Z"); + }); + + it("emits a referenceRange with only the high bound when low is null", () => { + const bundle = buildFhirDocumentBundle( + makeData({ labResults }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)"); + expect(ldl?.referenceRange).toHaveLength(1); + expect(ldl?.referenceRange?.[0].high?.value).toBe(116); + expect(ldl?.referenceRange?.[0].low).toBeUndefined(); + }); + + it("emits a referenceRange with both bounds", () => { + const bundle = buildFhirDocumentBundle( + makeData({ labResults }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const a1c = observationsOf(bundle).find((o) => o.code.text === "HbA1c"); + expect(a1c?.referenceRange?.[0].low?.value).toBe(4); + expect(a1c?.referenceRange?.[0].high?.value).toBe(5.6); + }); + + it("omits referenceRange entirely when no bounds are present", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + labResults: [ + { + panel: null, + analyte: "CRP", + value: 2.1, + unit: "mg/L", + referenceLow: null, + referenceHigh: null, + takenAt: "2026-04-22T08:00:00.000Z", + count: 1, + }, + ], + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const crp = observationsOf(bundle).find((o) => o.code.text === "CRP"); + expect(crp).toBeDefined(); + expect(crp?.referenceRange).toBeUndefined(); + }); + + it("emits no lab Observations when labResults is null", () => { + const bundle = buildFhirDocumentBundle( + makeData({ labResults: null }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const labObs = observationsOf(bundle).filter((o) => + o.category?.some((c) => + c.coding?.some((code) => code.code === "laboratory"), + ), + ); + expect(labObs).toHaveLength(0); + }); +}); diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index f970e1216..41c6e55dd 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -511,6 +511,47 @@ export function observationsFromReportData( }); } + // --- Lab-result Observations (v1.17.1) --------------------------------- + // One `laboratory` Observation per recorded analyte. `analyte` + `unit` + // are user-recorded free text (no closed LOINC enum), so the code is a + // text-only `CodeableConcept` — honest about what we have rather than + // asserting a LOINC mapping we can't validate. The lab's reference bounds + // map onto R4 `referenceRange`. UCUM `code` is omitted (the free-text + // unit can't be asserted as a UCUM symbol); `unit` carries the display. + for (const lab of data.labResults ?? []) { + obsSeq += 1; + const referenceRange = + lab.referenceLow !== null || lab.referenceHigh !== null + ? [ + { + ...(lab.referenceLow !== null + ? { low: { value: lab.referenceLow, unit: lab.unit } } + : {}), + ...(lab.referenceHigh !== null + ? { high: { value: lab.referenceHigh, unit: lab.unit } } + : {}), + }, + ] + : undefined; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("laboratory")], + code: { + text: lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte, + }, + subject: patientRef, + effectiveDateTime: lab.takenAt, + valueQuantity: { + value: lab.value, + unit: lab.unit, + system: UCUM_SYSTEM, + }, + ...(referenceRange ? { referenceRange } : {}), + }); + } + // --- Medication-adherence Observations (one per medication) ------------ for (const [name, comp] of Object.entries(data.compliance)) { if (comp.total <= 0) continue; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 8d2845e55..883606b52 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -85,6 +85,18 @@ export interface FhirObservation { component?: FhirObservationComponent[]; /** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */ note?: FhirAnnotation[]; + /** + * v1.17.1 — R4 `Observation.referenceRange`. Carries the lab's reported + * normal bounds for a structured lab result; either bound may be absent + * (a lab may report only an upper or lower limit). + */ + referenceRange?: FhirObservationReferenceRange[]; +} + +/** R4 `Observation.referenceRange` element. */ +export interface FhirObservationReferenceRange { + low?: FhirQuantity; + high?: FhirQuantity; } export interface FhirDosage { diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index 7c6b2f874..f57fa136c 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -57,6 +57,11 @@ describe("parseDoctorReportPrefs", () => { expect(out.mood).toBe(false); }); + it("defaults labs to ON (recorded to share with a clinician)", () => { + const out = parseDoctorReportPrefs(null); + expect(out.labs).toBe(true); + }); + it("fills missing keys from defaults", () => { const out = parseDoctorReportPrefs({ bp: false }); expect(out).toEqual({ @@ -86,6 +91,7 @@ describe("resolveDoctorReportPrefs", () => { compliance: false, sleep: false, cycle: false, + labs: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); expect(out).toEqual({ ...current, mood: false }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index d390c6261..cd104823d 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -32,6 +32,7 @@ export const doctorReportPrefsSchema = z compliance: z.boolean(), sleep: z.boolean(), cycle: z.boolean(), + labs: z.boolean(), }) .partial(); @@ -51,6 +52,7 @@ export interface DoctorReportPrefs { compliance: boolean; sleep: boolean; cycle: boolean; + labs: boolean; } /** @@ -70,6 +72,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { // Cycle data is opt-in: a user sharing a BP report with a cardiologist // should not auto-leak reproductive data. Same privacy stance as mood. cycle: false, + // Lab results the user recorded for exactly this purpose — sharing + // bloodwork with a clinician. ON by default, like BP / weight. + labs: true, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index d453f785e..5f631c6a6 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -74,6 +74,9 @@ export const exportSectionsSchema = z bmi: z.boolean(), // Cycle / reproductive health — opt-in (privacy default OFF, like mood). cycle: z.boolean(), + // Structured lab results — ON by default (recorded to share with a + // clinician, same stance as BP / weight). + labs: z.boolean(), }) .partial(); @@ -135,5 +138,6 @@ export function toDoctorReportPrefs( sleep: a.sleep ?? fallback.sleep, // Cycle is opt-in: only true when explicitly requested (privacy). cycle: s.cycle === true, + labs: s.labs ?? fallback.labs, }; } From 72fb51c2576bd5a88a654cd4df895d8f9e2e099d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:39:52 +0200 Subject: [PATCH 3/3] feat(labs): add the Laborwerte page, navigation, and OpenAPI contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated /labs page hosts manual entry and a per-analyte view: the four essentials (biomarker, value, unit, date) stay visible, the reference range, panel grouping and note sit under an optional section. Results group by analyte with a small inline trend once an analyte has two or more readings. The reference-range badge is deliberately neutral and calm — an out-of-range value reads as a quiet directional marker, not an alarming red. Adds the labs section toggle to the health-record export panel, the /labs nav entries, the /api/labs OpenAPI routes, and the labs copy across all six locales. --- docs/api/openapi.yaml | 479 ++++++++++++++++++ messages/de.json | 60 ++- messages/en.json | 60 ++- messages/es.json | 60 ++- messages/fr.json | 60 ++- messages/it.json | 60 ++- messages/pl.json | 60 ++- src/app/labs/page.tsx | 91 ++++ src/components/labs/lab-form.tsx | 228 +++++++++ src/components/labs/lab-list.tsx | 199 ++++++++ src/components/labs/lab-trend-sparkline.tsx | 61 +++ src/components/labs/reference-range-badge.tsx | 52 ++ src/components/labs/types.ts | 23 + src/components/layout/bottom-nav.tsx | 2 + src/components/layout/sidebar-nav.tsx | 4 + .../settings/health-record-export-panel.tsx | 8 + src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/labs.ts | 204 ++++++++ 19 files changed, 1690 insertions(+), 24 deletions(-) create mode 100644 src/app/labs/page.tsx create mode 100644 src/components/labs/lab-form.tsx create mode 100644 src/components/labs/lab-list.tsx create mode 100644 src/components/labs/lab-trend-sparkline.tsx create mode 100644 src/components/labs/reference-range-badge.tsx create mode 100644 src/components/labs/types.ts create mode 100644 src/lib/openapi/routes/labs.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bf628854e..9db2458e2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -39,6 +39,7 @@ tags: - name: Export - name: Sync - name: Cycle + - name: Labs - name: Admin - name: Meta paths: @@ -937,6 +938,8 @@ paths: type: boolean cycle: type: boolean + labs: + type: boolean locale: type: string enum: @@ -1065,6 +1068,8 @@ paths: type: boolean cycle: type: boolean + labs: + type: boolean resourceTypes: maxItems: 8 type: array @@ -5202,6 +5207,249 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/labs: + get: + tags: + - Labs + summary: List the caller's lab results + description: Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / + date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted + note itself is not echoed). + parameters: + - in: query + name: analyte + schema: + type: string + minLength: 1 + maxLength: 120 + - in: query + name: panel + schema: + type: string + minLength: 1 + maxLength: 120 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - in: query + name: limit + schema: + default: 100 + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + responses: + "200": + description: Lab-result list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListLabResultsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Labs + summary: Record a lab result + description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is + AES-256-GCM encrypted before write. Audits as `labResult.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + panel: + anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: string + const: "" + analyte: + type: string + minLength: 1 + maxLength: 120 + value: + type: number + unit: + type: string + minLength: 1 + maxLength: 40 + referenceLow: + type: number + referenceHigh: + type: number + takenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + required: + - analyte + - value + - unit + - takenAt + responses: + "201": + description: Created lab result. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLabResultResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/labs/{id}: + get: + tags: + - Labs + summary: Fetch a single lab result + description: Returns the lab result including its decrypted `note`. Cross-user rows surface as 404. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Lab-result detail. + content: + application/json: + schema: + $ref: "#/components/schemas/GetLabResultResponse" + "401": *a1 + "404": &a6 + description: Lab result not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + put: + tags: + - Labs + summary: Edit a lab result + description: Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. + Audits as `labResult.update`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + panel: + anyOf: + - anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: string + const: "" + - type: "null" + analyte: + type: string + minLength: 1 + maxLength: 120 + value: + type: number + unit: + type: string + minLength: 1 + maxLength: 40 + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + - type: "null" + responses: + "200": + description: Updated lab result. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLabResultResponse" + "401": *a1 + "404": *a6 + "422": *a3 + "429": *a4 + delete: + tags: + - Labs + summary: Delete a lab result + description: Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteLabResultResponse" + "401": *a1 + "404": *a6 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -15570,6 +15818,237 @@ components: - kind additionalProperties: false description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount. + ListLabResultsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ListLabResultsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListLabResultsResponse: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/LabResult" + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + required: + - total + - limit + - offset + additionalProperties: false + required: + - results + - meta + additionalProperties: false + LabResult: + type: object + properties: + id: + type: string + panel: + anyOf: + - type: string + - type: "null" + analyte: + type: string + value: + type: number + unit: + type: string + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + source: + type: string + hasNote: + type: boolean + rangeStatus: + $ref: "#/components/schemas/LabReferenceRangeStatus" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - panel + - analyte + - value + - unit + - referenceLow + - referenceHigh + - takenAt + - source + - hasNote + - rangeStatus + - createdAt + - updatedAt + additionalProperties: false + description: A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the + single-resource GET returns the decrypted `note`. + LabReferenceRangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. + Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and + informative — not an alarming red." + CreateLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResultDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LabResultDetail: + type: object + properties: + id: + type: string + panel: + anyOf: + - type: string + - type: "null" + analyte: + type: string + value: + type: number + unit: + type: string + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + source: + type: string + rangeStatus: + $ref: "#/components/schemas/LabReferenceRangeStatus" + createdAt: + type: string + updatedAt: + type: string + note: + anyOf: + - type: string + - type: "null" + required: + - id + - panel + - analyte + - value + - unit + - referenceLow + - referenceHigh + - takenAt + - source + - rangeStatus + - createdAt + - updatedAt + - note + additionalProperties: false + description: Single lab result including its decrypted free-text `note` (or null). + UpdateLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteLabResultResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index 01784ec8b..169d69e54 100644 --- a/messages/de.json +++ b/messages/de.json @@ -151,7 +151,8 @@ "sleep": "Schlafdauer", "cycle": "Menstruationszyklus", "empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.", - "unavailableHint": "Keine Daten in diesem Zeitraum" + "unavailableHint": "Keine Daten in diesem Zeitraum", + "labs": "Laborwerte" }, "insurer": "Krankenkasse", "insuranceNumber": "Versichertennr.", @@ -182,7 +183,13 @@ "FOLLICULAR": "Follikelphase", "OVULATORY": "Ovulation", "LUTEAL": "Lutealphase" - } + }, + "labsTitle": "Laborwerte", + "labsColAnalyte": "Biomarker", + "labsColValue": "Wert", + "labsColReference": "Referenz", + "labsColStatus": "Status", + "labsColDate": "Datum" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -240,7 +247,8 @@ "mood": "Stimmung", "moodDescription": "Stimmung und Tags erfassen" }, - "cycle": "Zyklus" + "cycle": "Zyklus", + "labs": "Laborwerte" }, "auth": { "login": "Anmelden", @@ -4537,7 +4545,8 @@ "includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)", "fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).", "generate": "Erstellen", - "error": "Export fehlgeschlagen ({code})." + "error": "Export fehlgeschlagen ({code}).", + "labs": "Laborwerte" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -6208,5 +6217,48 @@ "deleteN": "Löschen ({count})", "clearSelection": "Auswahl aufheben", "selectionBarLabel": "Auswahlaktionen" + }, + "labs": { + "title": "Laborwerte", + "subtitle": "Blutwerte und Biomarker", + "addResult": "Hinzufügen", + "addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.", + "addFirst": "Ersten Wert erfassen", + "emptyTitle": "Noch keine Laborwerte", + "emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.", + "referenceLabel": "Referenz", + "readingsCount": "{count} Werte", + "deleteLatest": "Letzten Wert löschen", + "deletedToast": "Wert gelöscht", + "deleteError": "Wert konnte nicht gelöscht werden", + "loadError": "Laborwerte konnten nicht geladen werden", + "range": { + "inRange": "Im Bereich", + "below": "Unter Bereich", + "above": "Über Bereich" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "z. B. HbA1c, LDL, Ferritin", + "value": "Wert", + "unit": "Einheit", + "unitPlaceholder": "z. B. mg/dl, %, ng/ml", + "takenAt": "Probendatum", + "optionalGroup": "Optional", + "panel": "Panel", + "panelPlaceholder": "z. B. Großes Blutbild", + "referenceLow": "Referenz unten", + "referenceHigh": "Referenz oben", + "referenceLowPlaceholder": "Untere Grenze", + "referenceHighPlaceholder": "Obere Grenze", + "referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.", + "note": "Notiz", + "notePlaceholder": "Optionaler Kontext zu diesem Wert", + "requiredError": "Biomarker, Wert und Einheit sind erforderlich.", + "rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.", + "save": "Speichern", + "savedToast": "Wert gespeichert", + "saveError": "Wert konnte nicht gespeichert werden" + } } } diff --git a/messages/en.json b/messages/en.json index a78cc1a24..88ef6142c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -151,7 +151,8 @@ "sleep": "Sleep duration", "cycle": "Menstrual cycle", "empty": "No data was recorded in this date range yet. The report will still include the cover page.", - "unavailableHint": "No data in this period" + "unavailableHint": "No data in this period", + "labs": "Lab results" }, "insurer": "Health insurer", "insuranceNumber": "Insurance no.", @@ -182,7 +183,13 @@ "FOLLICULAR": "Follicular", "OVULATORY": "Ovulatory", "LUTEAL": "Luteal" - } + }, + "labsTitle": "Lab results", + "labsColAnalyte": "Biomarker", + "labsColValue": "Value", + "labsColReference": "Reference", + "labsColStatus": "Status", + "labsColDate": "Date" }, "errorBoundary": { "title": "Something went wrong", @@ -240,7 +247,8 @@ "mood": "Mood", "moodDescription": "Log your mood and tags" }, - "cycle": "Cycle" + "cycle": "Cycle", + "labs": "Labs" }, "auth": { "login": "Sign in", @@ -4537,7 +4545,8 @@ "includeAiSummary": "Append AI summary (not clinically validated)", "fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).", "generate": "Generate", - "error": "Export failed ({code})." + "error": "Export failed ({code}).", + "labs": "Lab results" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -6208,5 +6217,48 @@ "deleteN": "Delete ({count})", "clearSelection": "Clear selection", "selectionBarLabel": "Selection actions" + }, + "labs": { + "title": "Lab results", + "subtitle": "Bloodwork and biomarker readings", + "addResult": "Add", + "addDescription": "Record a biomarker reading from a lab report.", + "addFirst": "Add your first result", + "emptyTitle": "No lab results yet", + "emptyDescription": "Record bloodwork like HbA1c, LDL or ferritin to track it over time and share it with your doctor.", + "referenceLabel": "Reference", + "readingsCount": "{count} readings", + "deleteLatest": "Delete latest reading", + "deletedToast": "Result deleted", + "deleteError": "Could not delete the result", + "loadError": "Could not load lab results", + "range": { + "inRange": "In range", + "below": "Below range", + "above": "Above range" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "e.g. HbA1c, LDL, Ferritin", + "value": "Value", + "unit": "Unit", + "unitPlaceholder": "e.g. mg/dL, %, ng/mL", + "takenAt": "Sample date", + "optionalGroup": "Optional", + "panel": "Panel", + "panelPlaceholder": "e.g. Complete blood count", + "referenceLow": "Reference low", + "referenceHigh": "Reference high", + "referenceLowPlaceholder": "Lower bound", + "referenceHighPlaceholder": "Upper bound", + "referenceHint": "Copy the reference range from your lab report. Either bound is optional.", + "note": "Note", + "notePlaceholder": "Optional context for this result", + "requiredError": "Biomarker, value and unit are required.", + "rangeOrderError": "The reference low must not exceed the reference high.", + "save": "Save", + "savedToast": "Result saved", + "saveError": "Could not save the result" + } } } diff --git a/messages/es.json b/messages/es.json index 07ef722cb..a586a4020 100644 --- a/messages/es.json +++ b/messages/es.json @@ -151,7 +151,8 @@ "sleep": "Duración del sueño", "cycle": "Ciclo menstrual", "empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.", - "unavailableHint": "Sin datos en este periodo" + "unavailableHint": "Sin datos en este periodo", + "labs": "Resultados de análisis" }, "insurer": "Seguro médico", "insuranceNumber": "N.º de asegurado", @@ -182,7 +183,13 @@ "FOLLICULAR": "Folicular", "OVULATORY": "Ovulatoria", "LUTEAL": "Lútea" - } + }, + "labsTitle": "Resultados de análisis", + "labsColAnalyte": "Biomarcador", + "labsColValue": "Valor", + "labsColReference": "Referencia", + "labsColStatus": "Estado", + "labsColDate": "Fecha" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -240,7 +247,8 @@ "mood": "Estado de ánimo", "moodDescription": "Registrar estado de ánimo y etiquetas" }, - "cycle": "Ciclo" + "cycle": "Ciclo", + "labs": "Análisis" }, "auth": { "login": "Iniciar sesión", @@ -4537,7 +4545,8 @@ "includeAiSummary": "Añadir resumen de IA (no validado clínicamente)", "fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).", "generate": "Generar", - "error": "Error en la exportación ({code})." + "error": "Error en la exportación ({code}).", + "labs": "Resultados de análisis" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -6208,5 +6217,48 @@ "deleteN": "Eliminar ({count})", "clearSelection": "Borrar selección", "selectionBarLabel": "Acciones de selección" + }, + "labs": { + "title": "Resultados de análisis", + "subtitle": "Analíticas de sangre y biomarcadores", + "addResult": "Añadir", + "addDescription": "Registra un biomarcador a partir de un informe de laboratorio.", + "addFirst": "Añade tu primer resultado", + "emptyTitle": "Aún no hay resultados", + "emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.", + "referenceLabel": "Referencia", + "readingsCount": "{count} valores", + "deleteLatest": "Eliminar el último valor", + "deletedToast": "Resultado eliminado", + "deleteError": "No se pudo eliminar el resultado", + "loadError": "No se pudieron cargar los resultados", + "range": { + "inRange": "En rango", + "below": "Por debajo", + "above": "Por encima" + }, + "form": { + "analyte": "Biomarcador", + "analytePlaceholder": "p. ej. HbA1c, LDL, Ferritina", + "value": "Valor", + "unit": "Unidad", + "unitPlaceholder": "p. ej. mg/dL, %, ng/mL", + "takenAt": "Fecha de la muestra", + "optionalGroup": "Opcional", + "panel": "Panel", + "panelPlaceholder": "p. ej. Hemograma completo", + "referenceLow": "Referencia mínima", + "referenceHigh": "Referencia máxima", + "referenceLowPlaceholder": "Límite inferior", + "referenceHighPlaceholder": "Límite superior", + "referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.", + "note": "Nota", + "notePlaceholder": "Contexto opcional para este resultado", + "requiredError": "El biomarcador, el valor y la unidad son obligatorios.", + "rangeOrderError": "La referencia mínima no puede superar la máxima.", + "save": "Guardar", + "savedToast": "Resultado guardado", + "saveError": "No se pudo guardar el resultado" + } } } diff --git a/messages/fr.json b/messages/fr.json index acec48c77..a9314d126 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -151,7 +151,8 @@ "sleep": "Durée du sommeil", "cycle": "Cycle menstruel", "empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.", - "unavailableHint": "Pas de données sur cette période" + "unavailableHint": "Pas de données sur cette période", + "labs": "Résultats d'analyses" }, "insurer": "Assurance maladie", "insuranceNumber": "N° d’assuré", @@ -182,7 +183,13 @@ "FOLLICULAR": "Folliculaire", "OVULATORY": "Ovulatoire", "LUTEAL": "Lutéale" - } + }, + "labsTitle": "Résultats d'analyses", + "labsColAnalyte": "Biomarqueur", + "labsColValue": "Valeur", + "labsColReference": "Référence", + "labsColStatus": "Statut", + "labsColDate": "Date" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -240,7 +247,8 @@ "mood": "Humeur", "moodDescription": "Enregistrer l'humeur et les tags" }, - "cycle": "Cycle" + "cycle": "Cycle", + "labs": "Analyses" }, "auth": { "login": "Se connecter", @@ -4537,7 +4545,8 @@ "includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)", "fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).", "generate": "Générer", - "error": "Échec de l’export ({code})." + "error": "Échec de l’export ({code}).", + "labs": "Résultats d'analyses" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -6208,5 +6217,48 @@ "deleteN": "Supprimer ({count})", "clearSelection": "Effacer la sélection", "selectionBarLabel": "Actions de sélection" + }, + "labs": { + "title": "Résultats d'analyses", + "subtitle": "Analyses sanguines et biomarqueurs", + "addResult": "Ajouter", + "addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.", + "addFirst": "Ajouter votre premier résultat", + "emptyTitle": "Aucun résultat pour l'instant", + "emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.", + "referenceLabel": "Référence", + "readingsCount": "{count} valeurs", + "deleteLatest": "Supprimer la dernière valeur", + "deletedToast": "Résultat supprimé", + "deleteError": "Impossible de supprimer le résultat", + "loadError": "Impossible de charger les résultats", + "range": { + "inRange": "Dans la norme", + "below": "Sous la norme", + "above": "Au-dessus de la norme" + }, + "form": { + "analyte": "Biomarqueur", + "analytePlaceholder": "ex. HbA1c, LDL, Ferritine", + "value": "Valeur", + "unit": "Unité", + "unitPlaceholder": "ex. mg/dL, %, ng/mL", + "takenAt": "Date du prélèvement", + "optionalGroup": "Facultatif", + "panel": "Panel", + "panelPlaceholder": "ex. Numération formule sanguine", + "referenceLow": "Référence basse", + "referenceHigh": "Référence haute", + "referenceLowPlaceholder": "Borne inférieure", + "referenceHighPlaceholder": "Borne supérieure", + "referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.", + "note": "Note", + "notePlaceholder": "Contexte facultatif pour ce résultat", + "requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.", + "rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.", + "save": "Enregistrer", + "savedToast": "Résultat enregistré", + "saveError": "Impossible d'enregistrer le résultat" + } } } diff --git a/messages/it.json b/messages/it.json index e825a1770..bf62de8c0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -151,7 +151,8 @@ "sleep": "Durata del sonno", "cycle": "Ciclo mestruale", "empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.", - "unavailableHint": "Nessun dato in questo periodo" + "unavailableHint": "Nessun dato in questo periodo", + "labs": "Risultati degli esami" }, "insurer": "Assicurazione sanitaria", "insuranceNumber": "N. assicurato", @@ -182,7 +183,13 @@ "FOLLICULAR": "Follicolare", "OVULATORY": "Ovulatoria", "LUTEAL": "Luteale" - } + }, + "labsTitle": "Risultati degli esami", + "labsColAnalyte": "Biomarcatore", + "labsColValue": "Valore", + "labsColReference": "Riferimento", + "labsColStatus": "Stato", + "labsColDate": "Data" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -240,7 +247,8 @@ "mood": "Umore", "moodDescription": "Registra umore e tag" }, - "cycle": "Ciclo" + "cycle": "Ciclo", + "labs": "Esami" }, "auth": { "login": "Accedi", @@ -4537,7 +4545,8 @@ "includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)", "fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).", "generate": "Genera", - "error": "Esportazione non riuscita ({code})." + "error": "Esportazione non riuscita ({code}).", + "labs": "Risultati degli esami" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -6208,5 +6217,48 @@ "deleteN": "Elimina ({count})", "clearSelection": "Cancella selezione", "selectionBarLabel": "Azioni di selezione" + }, + "labs": { + "title": "Risultati degli esami", + "subtitle": "Analisi del sangue e biomarcatori", + "addResult": "Aggiungi", + "addDescription": "Registra un biomarcatore da un referto di laboratorio.", + "addFirst": "Aggiungi il primo risultato", + "emptyTitle": "Ancora nessun risultato", + "emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.", + "referenceLabel": "Riferimento", + "readingsCount": "{count} valori", + "deleteLatest": "Elimina l'ultimo valore", + "deletedToast": "Risultato eliminato", + "deleteError": "Impossibile eliminare il risultato", + "loadError": "Impossibile caricare i risultati", + "range": { + "inRange": "Nella norma", + "below": "Sotto la norma", + "above": "Sopra la norma" + }, + "form": { + "analyte": "Biomarcatore", + "analytePlaceholder": "es. HbA1c, LDL, Ferritina", + "value": "Valore", + "unit": "Unità", + "unitPlaceholder": "es. mg/dL, %, ng/mL", + "takenAt": "Data del prelievo", + "optionalGroup": "Facoltativo", + "panel": "Pannello", + "panelPlaceholder": "es. Emocromo completo", + "referenceLow": "Riferimento minimo", + "referenceHigh": "Riferimento massimo", + "referenceLowPlaceholder": "Limite inferiore", + "referenceHighPlaceholder": "Limite superiore", + "referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.", + "note": "Nota", + "notePlaceholder": "Contesto facoltativo per questo risultato", + "requiredError": "Biomarcatore, valore e unità sono obbligatori.", + "rangeOrderError": "Il riferimento minimo non può superare quello massimo.", + "save": "Salva", + "savedToast": "Risultato salvato", + "saveError": "Impossibile salvare il risultato" + } } } diff --git a/messages/pl.json b/messages/pl.json index b551d7b0b..12c2ba0e7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -151,7 +151,8 @@ "sleep": "Czas snu", "cycle": "Cykl menstruacyjny", "empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.", - "unavailableHint": "Brak danych w tym okresie" + "unavailableHint": "Brak danych w tym okresie", + "labs": "Wyniki badań" }, "insurer": "Ubezpieczyciel zdrowotny", "insuranceNumber": "Nr ubezpieczonego", @@ -182,7 +183,13 @@ "FOLLICULAR": "Folikularna", "OVULATORY": "Owulacyjna", "LUTEAL": "Lutealna" - } + }, + "labsTitle": "Wyniki badań", + "labsColAnalyte": "Biomarker", + "labsColValue": "Wartość", + "labsColReference": "Norma", + "labsColStatus": "Status", + "labsColDate": "Data" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -240,7 +247,8 @@ "mood": "Nastrój", "moodDescription": "Zapisz nastrój i tagi" }, - "cycle": "Cykl" + "cycle": "Cykl", + "labs": "Wyniki badań" }, "auth": { "login": "Zaloguj się", @@ -4537,7 +4545,8 @@ "includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)", "fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).", "generate": "Generuj", - "error": "Eksport nie powiódł się ({code})." + "error": "Eksport nie powiódł się ({code}).", + "labs": "Wyniki badań" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -6208,5 +6217,48 @@ "deleteN": "Usuń ({count})", "clearSelection": "Wyczyść zaznaczenie", "selectionBarLabel": "Akcje zaznaczenia" + }, + "labs": { + "title": "Wyniki badań", + "subtitle": "Badania krwi i biomarkery", + "addResult": "Dodaj", + "addDescription": "Zapisz biomarker z wyniku laboratoryjnego.", + "addFirst": "Dodaj pierwszy wynik", + "emptyTitle": "Brak wyników badań", + "emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.", + "referenceLabel": "Norma", + "readingsCount": "{count} wyników", + "deleteLatest": "Usuń ostatni wynik", + "deletedToast": "Wynik usunięty", + "deleteError": "Nie udało się usunąć wyniku", + "loadError": "Nie udało się wczytać wyników", + "range": { + "inRange": "W normie", + "below": "Poniżej normy", + "above": "Powyżej normy" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "np. HbA1c, LDL, ferrytyna", + "value": "Wartość", + "unit": "Jednostka", + "unitPlaceholder": "np. mg/dl, %, ng/ml", + "takenAt": "Data pobrania", + "optionalGroup": "Opcjonalne", + "panel": "Panel", + "panelPlaceholder": "np. Morfologia krwi", + "referenceLow": "Norma dolna", + "referenceHigh": "Norma górna", + "referenceLowPlaceholder": "Dolna granica", + "referenceHighPlaceholder": "Górna granica", + "referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.", + "note": "Notatka", + "notePlaceholder": "Opcjonalny kontekst tego wyniku", + "requiredError": "Biomarker, wartość i jednostka są wymagane.", + "rangeOrderError": "Norma dolna nie może przekraczać górnej.", + "save": "Zapisz", + "savedToast": "Wynik zapisany", + "saveError": "Nie udało się zapisać wyniku" + } } } diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx new file mode 100644 index 000000000..524e903e2 --- /dev/null +++ b/src/app/labs/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Plus } from "lucide-react"; + +import { LabForm } from "@/components/labs/lab-form"; +import { LabList } from "@/components/labs/lab-list"; +import { Button } from "@/components/ui/button"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export default function LabsPage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [dialogOpen, setDialogOpen] = useState(false); + + const refreshVisible = useCallback( + () => queryClient.invalidateQueries({ type: "active" }), + [queryClient], + ); + const pull = usePullToRefresh({ + onRefresh: refreshVisible, + disabled: dialogOpen, + }); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+

+ {t("labs.title")} +

+

+ {t("labs.subtitle")} +

+
+ +
+ + + { + setDialogOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.labResults(), + }); + }} + onCancel={() => setDialogOpen(false)} + /> + + + setDialogOpen(true)} /> +
+ ); +} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx new file mode 100644 index 000000000..e49a52d66 --- /dev/null +++ b/src/components/labs/lab-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { DateTimeInput } from "@/components/ui/date-input"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { LabResultDto } from "./types"; + +const NOTE_MAX_LENGTH = 2000; + +function defaultTakenAtValue() { + const now = new Date(); + const offset = now.getTimezoneOffset(); + const local = new Date(now.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); +} + +/** Parse a free-text decimal that may use a comma separator. */ +function parseDecimal(raw: string): number | null { + const trimmed = raw.trim().replace(",", "."); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +interface LabFormProps { + onSuccess?: (created: LabResultDto) => void; + onCancel?: () => void; +} + +/** + * v1.17.1 — manual lab-result entry. + * + * Progressive disclosure: the four essentials (analyte, value, unit, date) + * are always visible; the optional reference range, panel grouping, and + * note sit below under a clearly-labelled "optional" heading so a quick + * single-value entry stays a four-field affair. + */ +export function LabForm({ onSuccess, onCancel }: LabFormProps) { + const { t } = useTranslations(); + + const [analyte, setAnalyte] = useState(""); + const [value, setValue] = useState(""); + const [unit, setUnit] = useState(""); + const [takenAt, setTakenAt] = useState(defaultTakenAtValue); + const [panel, setPanel] = useState(""); + const [refLow, setRefLow] = useState(""); + const [refHigh, setRefHigh] = useState(""); + const [note, setNote] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const numericValue = parseDecimal(value); + if (!analyte.trim() || numericValue === null || !unit.trim()) { + setError(t("labs.form.requiredError")); + return; + } + + const low = parseDecimal(refLow); + const high = parseDecimal(refHigh); + if (low !== null && high !== null && low > high) { + setError(t("labs.form.rangeOrderError")); + return; + } + + setSubmitting(true); + try { + const created = await apiPost("/api/labs", { + analyte: analyte.trim(), + value: numericValue, + unit: unit.trim(), + takenAt: new Date(takenAt).toISOString(), + ...(panel.trim() ? { panel: panel.trim() } : {}), + ...(low !== null ? { referenceLow: low } : {}), + ...(high !== null ? { referenceHigh: high } : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }); + toast.success(t("labs.form.savedToast")); + onSuccess?.(created); + } catch (err) { + const message = + err instanceof ApiError ? err.message : t("labs.form.saveError"); + setError(message); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ + setAnalyte(e.target.value)} + placeholder={t("labs.form.analytePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setValue(e.target.value)} + placeholder="0.0" + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setTakenAt(e.target.value)} + max={defaultTakenAtValue()} + required + /> +
+
+ +
+ + {t("labs.form.optionalGroup")} + +
+ + setPanel(e.target.value)} + placeholder={t("labs.form.panelPlaceholder")} + maxLength={120} + /> +
+
+
+ + setRefLow(e.target.value)} + placeholder={t("labs.form.referenceLowPlaceholder")} + /> +
+
+ + setRefHigh(e.target.value)} + placeholder={t("labs.form.referenceHighPlaceholder")} + /> +
+
+

+ {t("labs.form.referenceHint")} +

+
+ +