From a5b3715a46c8f3e97c2f5b187b06ac3cfbd46539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:17:43 +0200 Subject: [PATCH 01/35] feat(labs): record qualitative lab results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add first-class qualitative lab findings (positive / negative / borderline serology) alongside the existing numeric path. A LabResult is now EITHER a numeric reading (value + unit, optional reference bounds) OR a qualitative one (valueText, e.g. "negativ"), never both, never neither. - schema: make `value` nullable, add `value_text`; migration 0181 is additive (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched. - validation: numeric-XOR-qualitative refine on create + update; unit/bounds apply to the numeric path only; valueText capped at 120 chars. The single resource PUT rejects a cross-type edit. - serialise: carry valueText through both DTOs; a qualitative row reports the neutral `unknown` range verdict (no number to classify). - FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier value for confidently-known terms (Negative 260385009, Positive 10828004, Not detected 260415000, Detected 260373001), text-only for borderline / grenzwertig and any unrecognised term. The raw text always rides .text. - UI: a numeric/qualitative toggle in the lab form; list, history, detail, chart, sparkline, and the doctor-report PDF all render qualitative readings as their text and never plot or divide on a null value. - i18n: new labs.form keys across all six locales. - openapi: response carries a nullable value + valueText; regenerated. --- docs/api/openapi.yaml | 37 ++++-- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../migration.sql | 14 +++ prisma/schema.prisma | 9 +- src/app/api/labs/[id]/route.ts | 22 ++++ src/app/api/labs/route.ts | 18 ++- .../__tests__/lab-form-result-type.test.tsx | 51 ++++++++ src/components/labs/lab-biomarker-chart.tsx | 11 +- src/components/labs/lab-biomarker-detail.tsx | 31 +++-- src/components/labs/lab-form.tsx | 118 ++++++++++++++---- src/components/labs/lab-history-list.tsx | 70 ++++++++--- src/components/labs/lab-list.tsx | 11 +- src/components/labs/lab-trend-sparkline.tsx | 15 ++- src/components/labs/types.ts | 5 +- src/lib/doctor-report-data.ts | 14 ++- src/lib/doctor-report-pdf-core.ts | 23 ++-- .../fhir/__tests__/lab-observations.test.ts | 67 ++++++++++ src/lib/fhir/lab-qualitative.ts | 86 +++++++++++++ src/lib/fhir/resources.ts | 56 ++++++--- src/lib/labs/__tests__/serialise.test.ts | 42 +++++++ src/lib/labs/format-value.ts | 17 +++ src/lib/labs/serialise.ts | 26 +++- src/lib/openapi/routes/labs.ts | 11 +- src/lib/validations/__tests__/labs.test.ts | 79 ++++++++++++ src/lib/validations/labs.ts | 49 +++++++- 30 files changed, 819 insertions(+), 123 deletions(-) create mode 100644 prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql create mode 100644 src/components/labs/__tests__/lab-form-result-type.test.tsx create mode 100644 src/lib/fhir/lab-qualitative.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 12e8aed5c..6ecb970d7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6081,6 +6081,10 @@ paths: maxLength: 120 value: type: number + valueText: + type: string + minLength: 1 + maxLength: 120 unit: type: string minLength: 1 @@ -6100,7 +6104,6 @@ paths: - type: string const: "" required: - - value - takenAt responses: "201": @@ -6174,6 +6177,10 @@ paths: maxLength: 120 value: type: number + valueText: + type: string + minLength: 1 + maxLength: 120 unit: type: string minLength: 1 @@ -18838,7 +18845,13 @@ components: analyte: type: string value: - type: number + anyOf: + - type: number + - type: "null" + valueText: + anyOf: + - type: string + - type: "null" unit: type: string referenceLow: @@ -18867,6 +18880,7 @@ components: - panel - analyte - value + - valueText - unit - referenceLow - referenceHigh @@ -18877,10 +18891,12 @@ components: - createdAt - updatedAt additionalProperties: false - description: A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); - when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — - the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; - `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. + description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is + non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links + the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / + `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never + recomputes the range. 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: @@ -18941,7 +18957,13 @@ components: analyte: type: string value: - type: number + anyOf: + - type: number + - type: "null" + valueText: + anyOf: + - type: string + - type: "null" unit: type: string referenceLow: @@ -18972,6 +18994,7 @@ components: - panel - analyte - value + - valueText - unit - referenceLow - referenceHigh diff --git a/messages/de.json b/messages/de.json index 366e5ba2e..535b44c94 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Biomarker auswählen", "defineNew": "+ Neuen Biomarker anlegen", "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.", - "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen." + "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.", + "resultType": "Ergebnistyp", + "numeric": "Numerisch", + "qualitative": "Qualitativ", + "qualitativeResult": "Ergebnis", + "qualitativePlaceholder": "z. B. negativ", + "qualNegative": "negativ", + "qualPositive": "positiv", + "qualBorderline": "grenzwertig" }, "backToLabs": "Zurück zu den Laborwerten", "hasNote": "Notiz", diff --git a/messages/en.json b/messages/en.json index 45e6a3f98..b2372522b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Pick a biomarker", "defineNew": "+ Define new biomarker", "noBiomarkersHint": "Define a biomarker first to record a value against it.", - "pickBiomarkerError": "Pick a biomarker first." + "pickBiomarkerError": "Pick a biomarker first.", + "resultType": "Result type", + "numeric": "Numeric", + "qualitative": "Qualitative", + "qualitativeResult": "Result", + "qualitativePlaceholder": "e.g. negative", + "qualNegative": "negative", + "qualPositive": "positive", + "qualBorderline": "borderline" }, "backToLabs": "Back to lab results", "hasNote": "Note", diff --git a/messages/es.json b/messages/es.json index 983170479..be2580372 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Elige un biomarcador", "defineNew": "+ Definir nuevo biomarcador", "noBiomarkersHint": "Define primero un biomarcador para registrar un valor.", - "pickBiomarkerError": "Elige primero un biomarcador." + "pickBiomarkerError": "Elige primero un biomarcador.", + "resultType": "Tipo de resultado", + "numeric": "Numérico", + "qualitative": "Cualitativo", + "qualitativeResult": "Resultado", + "qualitativePlaceholder": "p. ej. negativo", + "qualNegative": "negativo", + "qualPositive": "positivo", + "qualBorderline": "límite" }, "backToLabs": "Volver a los resultados", "hasNote": "Nota", diff --git a/messages/fr.json b/messages/fr.json index b79b3ce16..4cbe3b34f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Choisir un biomarqueur", "defineNew": "+ Définir un nouveau biomarqueur", "noBiomarkersHint": "Définissez d'abord un biomarqueur pour enregistrer une valeur.", - "pickBiomarkerError": "Choisissez d'abord un biomarqueur." + "pickBiomarkerError": "Choisissez d'abord un biomarqueur.", + "resultType": "Type de résultat", + "numeric": "Numérique", + "qualitative": "Qualitatif", + "qualitativeResult": "Résultat", + "qualitativePlaceholder": "p. ex. négatif", + "qualNegative": "négatif", + "qualPositive": "positif", + "qualBorderline": "limite" }, "backToLabs": "Retour aux résultats", "hasNote": "Note", diff --git a/messages/it.json b/messages/it.json index 09a66cf1a..5866e29ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Scegli un biomarcatore", "defineNew": "+ Definisci nuovo biomarcatore", "noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.", - "pickBiomarkerError": "Scegli prima un biomarcatore." + "pickBiomarkerError": "Scegli prima un biomarcatore.", + "resultType": "Tipo di risultato", + "numeric": "Numerico", + "qualitative": "Qualitativo", + "qualitativeResult": "Risultato", + "qualitativePlaceholder": "es. negativo", + "qualNegative": "negativo", + "qualPositive": "positivo", + "qualBorderline": "borderline" }, "backToLabs": "Torna ai risultati", "hasNote": "Nota", diff --git a/messages/pl.json b/messages/pl.json index 77f4a881d..1acef823f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6513,7 +6513,15 @@ "biomarkerPlaceholder": "Wybierz biomarker", "defineNew": "+ Zdefiniuj nowy biomarker", "noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.", - "pickBiomarkerError": "Najpierw wybierz biomarker." + "pickBiomarkerError": "Najpierw wybierz biomarker.", + "resultType": "Typ wyniku", + "numeric": "Liczbowy", + "qualitative": "Jakościowy", + "qualitativeResult": "Wynik", + "qualitativePlaceholder": "np. ujemny", + "qualNegative": "ujemny", + "qualPositive": "dodatni", + "qualBorderline": "graniczny" }, "backToLabs": "Wróć do wyników", "hasNote": "Notatka", diff --git a/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql new file mode 100644 index 000000000..700ee7365 --- /dev/null +++ b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql @@ -0,0 +1,14 @@ +-- v1.18.9 — qualitative lab results (positive/negative/borderline serology). +-- +-- A LabResult was numeric-only (`value` NOT NULL). Qualitative findings +-- ("Hepatitis Bs-Antigen: negativ") have no number to store. This migration is +-- ADDITIVE and preserves every existing numeric row: +-- +-- 1. `value` becomes nullable so a qualitative row can omit it. Existing rows +-- keep their numeric value untouched (DROP NOT NULL never rewrites data). +-- 2. `value_text` is added (nullable) to hold the qualitative result string. +-- +-- A row is QUANTITATIVE when `value IS NOT NULL`, QUALITATIVE when +-- `value_text IS NOT NULL`. The application layer enforces the exclusive-or. +ALTER TABLE "lab_results" ALTER COLUMN "value" DROP NOT NULL; +ALTER TABLE "lab_results" ADD COLUMN "value_text" text; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7cac927df..deeb77edf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3391,7 +3391,14 @@ model LabResult { panel String? /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin". analyte String - value Float + /// Numeric reading. NULL for a qualitative result (see `valueText`). Exactly + /// one of `value` / `valueText` is set per row: `value IS NOT NULL` marks a + /// quantitative reading, `valueText IS NOT NULL` a qualitative one. + value Float? + /// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" / + /// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The + /// reference bounds + range verdict are meaningless for a qualitative row. + valueText String? @map("value_text") unit String /// Optional reference-range bounds as reported by the lab. referenceLow Float? @map("reference_low") diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 39bd061e5..ca5307478 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -150,6 +150,24 @@ export const PUT = apiHandler( ); } + // v1.18.9 — a row keeps its type. Editing `value` on a qualitative row or + // `valueText` on a numeric row would either leave both columns populated or + // strand the row in an ambiguous state. Reject the cross-type edit with a + // 422 — switch types by deleting and re-adding. + const rowIsQualitative = existing.valueText !== null; + if (rowIsQualitative && d.value !== undefined) { + return apiError( + "This is a qualitative reading — edit its result text, not a numeric value", + 422, + ); + } + if (!rowIsQualitative && d.valueText !== undefined) { + return apiError( + "This is a numeric reading — edit its value, not a qualitative result", + 422, + ); + } + // Build `data` field-by-field. `undefined` → leave the column untouched; // an explicit `null` on `panel` / `note` / a reference bound clears it. // A reading linked to a catalog marker is edited via VALUE / takenAt / @@ -158,6 +176,10 @@ export const PUT = apiHandler( if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; if (d.value !== undefined) data.value = d.value; + // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a + // qualitative row (or `valueText` on a numeric row) crosses the row's type + // and is rejected below. An edit of the matching field updates in place. + if (d.valueText !== undefined) data.valueText = d.valueText; if (d.unit !== undefined) data.unit = d.unit; if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow; if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh; diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index dce2e5a93..0c4821d02 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -173,6 +173,7 @@ async function postLabResult(request: NextRequest) { panel, analyte, value, + valueText, unit, referenceLow, referenceHigh, @@ -180,6 +181,10 @@ async function postLabResult(request: NextRequest) { note, } = parsed.data; + // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead + // of a number. The Zod refine guarantees exactly one of the two is present. + const isQualitative = valueText !== undefined; + // Every row links a catalog marker — no `LabResult` ever persists unlinked // (v1.18.1 High). Two paths converge on a `biomarker`: // - Structured: a `biomarkerId` is supplied; resolve + verify ownership @@ -207,9 +212,12 @@ async function postLabResult(request: NextRequest) { } else { biomarker = await resolveOrMintBiomarker(user.id, { analyte: analyte as string, - unit: unit as string, - referenceLow: referenceLow ?? null, - referenceHigh: referenceHigh ?? null, + // A qualitative reading has no numeric unit / range — mint the catalog + // marker with an empty unit label and no bounds. A numeric reading + // supplies its unit (Zod requires it on the free-text numeric path). + unit: isQualitative ? (unit ?? "") : (unit as string), + referenceLow: isQualitative ? null : (referenceLow ?? null), + referenceHigh: isQualitative ? null : (referenceHigh ?? null), panel: panel ?? null, }); } @@ -224,7 +232,9 @@ async function postLabResult(request: NextRequest) { biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value, + // Exactly one of value / valueText is set (the Zod XOR refine). + value: value ?? null, + valueText: valueText ?? null, unit: biomarker.unit, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, diff --git a/src/components/labs/__tests__/lab-form-result-type.test.tsx b/src/components/labs/__tests__/lab-form-result-type.test.tsx new file mode 100644 index 000000000..73c073ef2 --- /dev/null +++ b/src/components/labs/__tests__/lab-form-result-type.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.18.9 — the lab form's numeric ↔ qualitative result-type toggle. + * + * SSR-only smoke render (the suite's `renderToStaticMarkup` dialect): the form + * mounts in its default NUMERIC mode, so the test asserts the toggle is present + * (both mode labels render) and the numeric value field is shown by default. + * Interactive mode-switching is exercised by the validation + serialise unit + * tests, which pin the numeric-XOR-qualitative contract this toggle feeds. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + // Catalog never resolves within the synchronous SSR render. + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), +})); + +import { LabForm } from "../lab-form"; + +function render(node: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" result-type toggle", () => { + it("renders both numeric and qualitative mode options", () => { + const html = render(); + expect(html).toContain("Numeric"); + expect(html).toContain("Qualitative"); + expect(html).toContain("Result type"); + }); + + it("shows the numeric value field by default (numeric mode)", () => { + const html = render(); + // The numeric value input carries the id `lab-value`; the qualitative input + // (`lab-valueText`) is hidden until the user switches modes. + expect(html).toContain('id="lab-value"'); + expect(html).not.toContain('id="lab-valueText"'); + }); +}); diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 21c05ef95..c1a5521e7 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -68,9 +68,14 @@ export function LabBiomarkerChart({ const [nowMs] = useState(() => Date.now()); const points = useMemo(() => { - const sorted = [...readings].sort( - (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(), - ); + // v1.18.9 — qualitative readings (numeric value null) have nothing to plot; + // drop them so the line never renders a NaN / zero point. A series of only + // qualitative readings yields no points → the honest empty state below. + const sorted = [...readings] + .filter((r): r is LabResultDto & { value: number } => r.value !== null) + .sort( + (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(), + ); const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0; const filtered = days > 0 diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 80e5f43da..436ec36d3 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -101,13 +101,15 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ); } - const latestStatus = latest - ? classifyReferenceRange( - latest.value, - marker?.lowerBound ?? null, - marker?.upperBound ?? null, - ) - : "unknown"; + // A qualitative latest reading (numeric value null) has no range verdict. + const latestStatus = + latest && latest.value !== null + ? classifyReferenceRange( + latest.value, + marker?.lowerBound ?? null, + marker?.upperBound ?? null, + ) + : "unknown"; return (
@@ -147,10 +149,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {latest ? (
- {formatLabValue(latest.value)}{" "} - - {marker?.unit ?? latest.unit} - + {latest.value !== null ? ( + <> + {formatLabValue(latest.value)}{" "} + + {marker?.unit ?? latest.unit} + + + ) : ( + // Qualitative latest reading — show the result text, no unit. + (latest.valueText ?? "") + )}
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 1e8117cfb..97dc2464f 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -93,7 +93,14 @@ export function LabForm({ const [biomarkerId, setBiomarkerId] = useState( lockedBiomarkerId ?? "", ); + // v1.18.9 — numeric vs qualitative result mode. Numeric mode enters a number + // against the marker's unit/range; qualitative mode enters a result text + // ("negativ" / "positiv" / "grenzwertig" / free text) and hides unit/range. + const [resultType, setResultType] = useState<"numeric" | "qualitative">( + "numeric", + ); const [value, setValue] = useState(""); + const [valueText, setValueText] = useState(""); const [takenAt, setTakenAt] = useState(defaultTakenAtValue); const [note, setNote] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -125,21 +132,36 @@ export function LabForm({ e.preventDefault(); setError(null); - const numericValue = parseDecimal(value); if (!biomarkerId) { setError(t("labs.form.pickBiomarkerError")); return; } - if (numericValue === null) { - setError(t("labs.form.requiredError")); - return; + + const isQualitative = resultType === "qualitative"; + let numericValue: number | null = null; + let qualitativeValue: string | null = null; + if (isQualitative) { + const trimmed = valueText.trim(); + if (trimmed === "") { + setError(t("labs.form.requiredError")); + return; + } + qualitativeValue = trimmed; + } else { + numericValue = parseDecimal(value); + if (numericValue === null) { + setError(t("labs.form.requiredError")); + return; + } } setSubmitting(true); try { const created = await apiPost("/api/labs", { biomarkerId, - value: numericValue, + ...(isQualitative + ? { valueText: qualitativeValue } + : { value: numericValue }), takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), }); @@ -216,25 +238,75 @@ export function LabForm({ ) : null}
+
+ +
+ + +
+
+
- - setValue(e.target.value)} - placeholder="0.0" - required - /> + {resultType === "numeric" ? ( + <> + + setValue(e.target.value)} + placeholder="0.0" + required + /> + + ) : ( + <> + + setValueText(e.target.value)} + placeholder={t("labs.form.qualitativePlaceholder")} + maxLength={120} + required + /> + + + + )}
@@ -248,7 +320,7 @@ export function LabForm({
- {selected && referenceText ? ( + {resultType === "numeric" && selected && referenceText ? (

{t("labs.referenceLabel")} {referenceText} {selected.unit}

diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index c094bb45a..49fd352bc 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -20,7 +20,7 @@ import { apiPut, } from "@/lib/api/api-fetch"; import { formatDateShort } from "@/lib/format"; -import { formatLabValue } from "@/lib/labs/format-value"; +import { formatLabReading } from "@/lib/labs/format-value"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -57,6 +57,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); + // v1.18.9 — true when the row being edited is a qualitative reading; the + // editor then shows a text field and the PUT carries `valueText`. + const [editIsQualitative, setEditIsQualitative] = useState(false); + const [editValueText, setEditValueText] = useState(""); const [editTakenAt, setEditTakenAt] = useState(""); const [editNote, setEditNote] = useState(""); const [editLoading, setEditLoading] = useState(false); @@ -99,14 +103,17 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const updateMutation = useMutation({ mutationFn: (body: { id: string; - value: number; + // Exactly one of value / valueText is set, matching the row's type. + value: number | null; + valueText: string | null; takenAt: string; // `undefined` → omit `note` from the PUT (leave the stored note // untouched); `null` → clear it; a string → set it. note: string | null | undefined; }) => apiPut(`/api/labs/${body.id}`, { - value: body.value, + ...(body.value !== null ? { value: body.value } : {}), + ...(body.valueText !== null ? { valueText: body.valueText } : {}), takenAt: body.takenAt, ...(body.note === undefined ? {} : { note: body.note }), }), @@ -122,7 +129,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { async function openEdit(reading: LabResultDto) { setEditingId(reading.id); - setEditValue(String(reading.value)); + const qualitative = reading.value === null; + setEditIsQualitative(qualitative); + setEditValue(reading.value !== null ? String(reading.value) : ""); + setEditValueText(reading.valueText ?? ""); setEditTakenAt(toDateTimeLocal(reading.takenAt)); setEditNote(""); setEditError(null); @@ -154,10 +164,21 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { function submitEdit(e: React.FormEvent) { e.preventDefault(); if (!editingId) return; - const numericValue = parseDecimal(editValue); - if (numericValue === null) { - setEditError(t("labs.form.requiredError")); - return; + let numericValue: number | null = null; + let qualitativeValue: string | null = null; + if (editIsQualitative) { + const trimmed = editValueText.trim(); + if (trimmed === "") { + setEditError(t("labs.form.requiredError")); + return; + } + qualitativeValue = trimmed; + } else { + numericValue = parseDecimal(editValue); + if (numericValue === null) { + setEditError(t("labs.form.requiredError")); + return; + } } const takenDate = new Date(editTakenAt); if (Number.isNaN(takenDate.getTime())) { @@ -167,6 +188,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { updateMutation.mutate({ id: editingId, value: numericValue, + valueText: qualitativeValue, takenAt: takenDate.toISOString(), // `undefined` → omit `note` (server preserves the stored note when its // decrypted load failed); `null` → clear; text → set. @@ -193,7 +215,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- {formatLabValue(r.value)} {r.unit} + {formatLabReading(r)}
@@ -236,14 +258,28 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- - setEditValue(e.target.value)} - required - /> + + {editIsQualitative ? ( + setEditValueText(e.target.value)} + maxLength={120} + required + /> + ) : ( + setEditValue(e.target.value)} + required + /> + )}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d386be609..bf3b0a2f8 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -12,7 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; import { formatReferenceRange } from "@/lib/labs/reference-range"; -import { formatLabValue } from "@/lib/labs/format-value"; +import { formatLabReading, formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; @@ -178,7 +178,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
- {formatLabValue(group.latest.value)} {group.latest.unit} + {formatLabReading(group.latest)} {formatDate(group.latest.takenAt)}
@@ -236,10 +236,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
- {formatLabValue(group.latest.value)} {group.latest.unit} + {formatLabReading(group.latest)} - {group.latest.referenceLow !== null || - group.latest.referenceHigh !== null ? ( + {group.latest.value !== null && + (group.latest.referenceLow !== null || + group.latest.referenceHigh !== null) ? ( {t("labs.referenceLabel")}{" "} {formatReferenceRange( diff --git a/src/components/labs/lab-trend-sparkline.tsx b/src/components/labs/lab-trend-sparkline.tsx index af915743d..1395e1dc9 100644 --- a/src/components/labs/lab-trend-sparkline.tsx +++ b/src/components/labs/lab-trend-sparkline.tsx @@ -13,21 +13,24 @@ * polyline vs Recharts) is used by the doctor-report PDF. The two are not * drift; do not unify them. * - * Rendered only when an analyte has ≥ 2 readings. Stroke uses the neutral - * `currentColor` so it inherits the calm muted-foreground tone of its row — - * no status colour, in keeping with the no-alarming-colour ethos. + * Rendered only when an analyte has ≥ 2 NUMERIC readings. Stroke uses the + * neutral `currentColor` so it inherits the calm muted-foreground tone of its + * row — no status colour, in keeping with the no-alarming-colour ethos. * - * `values` are passed oldest → newest. + * `values` are passed oldest → newest. v1.18.9 — qualitative readings carry no + * number (`null`); they are filtered out here so a series with qualitative + * entries plots only its numeric points and never a NaN. */ export function LabTrendSparkline({ - values, + values: rawValues, width = 72, height = 20, }: { - values: number[]; + values: (number | null)[]; width?: number; height?: number; }) { + const values = rawValues.filter((v): v is number => v !== null); if (values.length < 2) return null; const min = Math.min(...values); diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 315436fb7..1c10ef77a 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -7,7 +7,10 @@ export interface LabResultDto { biomarkerId: string | null; panel: string | null; analyte: string; - value: number; + /** Numeric reading; null for a qualitative row (see `valueText`). v1.18.9. */ + value: number | null; + /** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */ + valueText: string | null; unit: string; referenceLow: number | null; referenceHigh: number | null; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 48ceef4a8..bc2fc0702 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -277,8 +277,16 @@ export interface DoctorReportData { panel: string | null; /** Biomarker name as the user recorded it. */ analyte: string; - /** Latest recorded value for this analyte over the window. */ - value: number; + /** + * Latest numeric value for this analyte over the window; null for a + * qualitative reading (see `valueText`). v1.18.9. + */ + value: number | null; + /** + * v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a + * numeric reading. Exactly one of `value` / `valueText` is set. + */ + valueText: string | null; unit: string; referenceLow: number | null; referenceHigh: number | null; @@ -1329,6 +1337,7 @@ export async function collectDoctorReportData( panel: true, analyte: true, value: true, + valueText: true, unit: true, referenceLow: true, referenceHigh: true, @@ -1348,6 +1357,7 @@ export async function collectDoctorReportData( panel: r.panel, analyte: r.analyte, value: r.value, + valueText: r.valueText, unit: r.unit, referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 306fbab4e..ced3263df 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1489,13 +1489,22 @@ export function buildDoctorReportPdfDocument( } }; - 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), - ]); + const labRows = data.labResults.map((lr) => { + // v1.18.9 — a qualitative reading (`value === null`) prints its result + // text in the value column and has no numeric range / status glyph. + const isQualitative = lr.value === null; + return [ + lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte, + isQualitative + ? (lr.valueText ?? "") + : `${num(lr.value as number)} ${lr.unit}`.trim(), + isQualitative ? "—" : rangeText(lr.referenceLow, lr.referenceHigh), + isQualitative + ? "" + : statusGlyph(lr.value as number, lr.referenceLow, lr.referenceHigh), + fmtDate(lr.takenAt), + ]; + }); autoTable(doc, { startY: y, diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index b3fed82b0..d2f647f5a 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -50,6 +50,7 @@ describe("lab-result FHIR Observations", () => { panel: "Lipid panel", analyte: "LDL", value: 110, + valueText: null, unit: "mg/dL", referenceLow: null, referenceHigh: 116, @@ -60,6 +61,7 @@ describe("lab-result FHIR Observations", () => { panel: null, analyte: "HbA1c", value: 5.4, + valueText: null, unit: "%", referenceLow: 4, referenceHigh: 5.6, @@ -131,6 +133,7 @@ describe("lab-result FHIR Observations", () => { panel: null, analyte: "CRP", value: 2.1, + valueText: null, unit: "mg/L", referenceLow: null, referenceHigh: null, @@ -171,6 +174,7 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { panel: null, analyte: type, value, + valueText: null, unit, referenceLow: null, referenceHigh: null, @@ -246,3 +250,66 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); }); }); + +describe("qualitative lab-result FHIR Observations (v1.18.9)", () => { + function qualOf(analyte: string, valueText: string) { + const bundle = buildFhirDocumentBundle( + makeData({ + labResults: [ + { + panel: null, + analyte, + value: null, + valueText, + unit: "", + referenceLow: null, + referenceHigh: null, + takenAt: "2026-04-20T08:00:00.000Z", + count: 1, + }, + ], + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + return observationsOf(bundle).find((o) => o.code.text === analyte); + } + + it("emits valueCodeableConcept (not valueQuantity) for a negative result", () => { + const o = qualOf("Hepatitis Bs-Antigen", "negativ"); + expect(o?.valueQuantity).toBeUndefined(); + expect(o?.valueCodeableConcept?.coding?.[0].system).toBe( + "http://snomed.info/sct", + ); + expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("260385009"); + expect(o?.valueCodeableConcept?.coding?.[0].display).toBe("Negative"); + // The raw recorded text always rides .text. + expect(o?.valueCodeableConcept?.text).toBe("negativ"); + // No numeric reference range on a qualitative observation. + expect(o?.referenceRange).toBeUndefined(); + }); + + it("maps a positive (English) result to the Positive SNOMED concept", () => { + const o = qualOf("HIV Ab", "positive"); + expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("10828004"); + expect(o?.valueCodeableConcept?.text).toBe("positive"); + }); + + it("maps 'nicht nachweisbar' to the Not detected SNOMED concept", () => { + const o = qualOf("HCV RNA", "nicht nachweisbar"); + expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("260415000"); + }); + + it("keeps borderline / grenzwertig text-only (no fabricated code)", () => { + const o = qualOf("Some Serology", "grenzwertig"); + expect(o?.valueCodeableConcept?.coding).toBeUndefined(); + expect(o?.valueCodeableConcept?.text).toBe("grenzwertig"); + }); + + it("keeps an unrecognised qualitative term text-only", () => { + const o = qualOf("Some Serology", "schwach reaktiv"); + expect(o?.valueCodeableConcept?.coding).toBeUndefined(); + expect(o?.valueCodeableConcept?.text).toBe("schwach reaktiv"); + expect(o?.valueQuantity).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/lab-qualitative.ts b/src/lib/fhir/lab-qualitative.ts new file mode 100644 index 000000000..fb614e39c --- /dev/null +++ b/src/lib/fhir/lab-qualitative.ts @@ -0,0 +1,86 @@ +/** + * v1.18.9 — qualitative lab result → SNOMED CT concept (or honest text-only). + * + * A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar") + * has no number, so its FHIR Observation carries a `valueCodeableConcept` + * instead of a `valueQuantity`. This module maps the common German / English + * result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value + * concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is + * added only when the term resolves CONFIDENTLY. When in doubt the result stays + * text-only — never a fabricated code — mirroring the conservative stance of + * the `lab-loinc` and `illness-snomed` mappers. + * + * Concepts (SNOMED CT International, qualifier value, REFERENCED not + * redistributed per the licence): + * - 260385009 "Negative" — negativ / negative + * - 10828004 "Positive" — positiv / positive + * - 260415000 "Not detected" — nicht nachweisbar / not detected + * - 260373001 "Detected" — nachweisbar / detected + * + * Borderline / grenzwertig is DELIBERATELY left text-only: the candidate + * "Borderline" qualifier concept could not be confidently verified, and an + * unverified code is worse than an honest `.text`. Any unrecognised term + * likewise stays text-only. + * + * Systems: SNOMED CT — http://snomed.info/sct + */ + +import type { FhirCodeableConcept } from "@/lib/fhir/types"; + +/** + * SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to + * keep this leaf module free of the heavy `resources.ts` import graph and avoid + * an import cycle — `resources.ts` consumes THIS module. + */ +const SNOMED_SYSTEM = "http://snomed.info/sct"; + +interface QualitativeConcept { + code: string; + display: string; +} + +/** + * Normalised result term → SNOMED qualifier-value concept. The left side is the + * lower-cased, whitespace-collapsed recorded text. Only confidently-known terms + * appear; everything else falls through to text-only. + */ +const QUALITATIVE_SNOMED: Record = { + // Negative-like + negativ: { code: "260385009", display: "Negative" }, + negative: { code: "260385009", display: "Negative" }, + neg: { code: "260385009", display: "Negative" }, + "nicht nachweisbar": { code: "260415000", display: "Not detected" }, + "not detected": { code: "260415000", display: "Not detected" }, + // Positive-like + positiv: { code: "10828004", display: "Positive" }, + positive: { code: "10828004", display: "Positive" }, + pos: { code: "10828004", display: "Positive" }, + nachweisbar: { code: "260373001", display: "Detected" }, + detected: { code: "260373001", display: "Detected" }, +}; + +/** Collapse a recorded qualitative term to a lookup key. */ +function normaliseQualitativeKey(text: string): string { + return text.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * Build the `valueCodeableConcept` for a qualitative lab result. The raw text + * always rides `.text`; a SNOMED `coding` is added only for a confidently- + * recognised term. Borderline / grenzwertig and any unknown term stay + * text-only. + */ +export function qualitativeValueConcept( + valueText: string, +): FhirCodeableConcept { + const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)]; + if (!concept) { + return { text: valueText }; + } + return { + coding: [ + { system: SNOMED_SYSTEM, code: concept.code, display: concept.display }, + ], + text: valueText, + }; +} diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 68cbda343..1edbd0932 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -45,6 +45,7 @@ import { } from "@/lib/fhir/loinc-map"; import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed"; import { resolveLabCoding } from "@/lib/fhir/lab-loinc"; +import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative"; import type { FhirCodeableConcept, FhirObservation, @@ -640,6 +641,43 @@ export function observationsFromReportData( // lab's reference bounds map onto R4 `referenceRange`. for (const lab of data.labResults ?? []) { obsSeq += 1; + // Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO + // carries a resolved canonical analyte name, thread it as the third arg so + // the catalog name wins over the free-text `analyte`. Absent today. + const coding = resolveLabCoding(lab.analyte, lab.unit); + const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte; + const code = coding + ? { + coding: [ + { + system: LOINC_SYSTEM, + code: coding.loinc, + display: coding.display, + }, + ], + text, + } + : { text }; + + // v1.18.9 — a qualitative result (`value === null`) emits a + // `valueCodeableConcept` (SNOMED qualifier value when confidently known, + // else text-only) and NO `valueQuantity` / `referenceRange` — bounds are + // meaningless for it. A numeric result keeps the established + // valueQuantity + LOINC/UCUM path unchanged. + if (lab.value === null) { + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("laboratory")], + code, + subject: patientRef, + effectiveDateTime: lab.takenAt, + valueCodeableConcept: qualitativeValueConcept(lab.valueText ?? ""), + }); + continue; + } + const referenceRange = lab.referenceLow !== null || lab.referenceHigh !== null ? [ @@ -653,28 +691,12 @@ export function observationsFromReportData( }, ] : undefined; - // Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO - // carries a resolved canonical analyte name, thread it as the third arg so - // the catalog name wins over the free-text `analyte`. Absent today. - const coding = resolveLabCoding(lab.analyte, lab.unit); - const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte; push({ resourceType: "Observation", id: `obs-${obsSeq}`, status: "final", category: [categoryConcept("laboratory")], - code: coding - ? { - coding: [ - { - system: LOINC_SYSTEM, - code: coding.loinc, - display: coding.display, - }, - ], - text, - } - : { text }, + code, subject: patientRef, effectiveDateTime: lab.takenAt, valueQuantity: { diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts index d8aa335ad..f2dd77cd2 100644 --- a/src/lib/labs/__tests__/serialise.test.ts +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -13,6 +13,7 @@ const baseRow: LabRow = { panel: "Legacy panel", analyte: "ldl", // lowercase legacy spelling value: 130, + valueText: null, unit: "mg/dl", // lowercase legacy unit referenceLow: null, referenceHigh: 100, // stale per-row bound @@ -94,3 +95,44 @@ describe("serialiseLabResultDetail", () => { expect(dto.unit).toBe("mg/dL"); }); }); + +describe("qualitative readings (v1.18.9)", () => { + const qualRow: LabRow = { + ...baseRow, + id: "lr_q", + analyte: "Hepatitis Bs-Antigen", + value: null, + valueText: "negativ", + unit: "", + referenceLow: null, + referenceHigh: null, + biomarkerId: null, + }; + + it("serialises valueText and a null numeric value", () => { + const dto = serialiseLabResult(qualRow, null); + expect(dto.value).toBeNull(); + expect(dto.valueText).toBe("negativ"); + }); + + it("reports an 'unknown' range verdict for a qualitative row (no comparison)", () => { + // Even if the linked marker carried numeric bounds, a null value cannot be + // compared — the verdict must stay the neutral 'unknown'. + const dto = serialiseLabResult( + { ...qualRow, biomarkerId: "bm_1" }, + { + ...biomarker, + lowerBound: 0, + upperBound: 1, + }, + ); + expect(dto.rangeStatus).toBe("unknown"); + }); + + it("carries valueText through the detail serialiser", () => { + const dto = serialiseLabResultDetail(qualRow, null, null); + expect(dto.value).toBeNull(); + expect(dto.valueText).toBe("negativ"); + expect(dto.rangeStatus).toBe("unknown"); + }); +}); diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts index 768a031cb..250a6bffc 100644 --- a/src/lib/labs/format-value.ts +++ b/src/lib/labs/format-value.ts @@ -11,3 +11,20 @@ export function formatLabValue(value: number): string { ? String(value) : String(Number(value.toFixed(2))); } + +/** + * v1.18.9 — render a reading's display value: a qualitative row (numeric + * `value` is null) shows its `valueText` verbatim; a numeric row shows the + * formatted number plus its unit. One helper so the list card, the history + * rows, and the detail header stay consistent. + */ +export function formatLabReading(reading: { + value: number | null; + valueText: string | null; + unit: string; +}): string { + if (reading.value === null) { + return reading.valueText ?? ""; + } + return `${formatLabValue(reading.value)} ${reading.unit}`.trim(); +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index 77a8cf29c..adbe528dc 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -33,7 +33,10 @@ export interface LabRow { id: string; panel: string | null; analyte: string; - value: number; + /** Numeric reading; null for a qualitative row (see `valueText`). */ + value: number | null; + /** v1.18.9 — qualitative result text; null for a numeric row. */ + valueText: string | null; unit: string; referenceLow: number | null; referenceHigh: number | null; @@ -86,6 +89,21 @@ export function resolveLabFields( }; } +/** + * v1.18.9 — the reference-range verdict for a row. A qualitative row (no + * numeric `value`) has nothing to compare against the bounds, so it always + * reports `"unknown"` — the neutral, no-verdict state — never a fabricated + * in/out classification. A numeric row classifies against the resolved bounds. + */ +function rowRangeStatus( + value: number | null, + referenceLow: number | null, + referenceHigh: number | null, +) { + if (value === null) return "unknown" as const; + return classifyReferenceRange(value, referenceLow, referenceHigh); +} + /** * Serialise a lab row to the list DTO — never echoes the encrypted note bytes * (only the `hasNote` flag) and resolves name/unit/range server-side. @@ -101,13 +119,14 @@ export function serialiseLabResult( panel: resolved.panel, analyte: resolved.analyte, value: row.value, + valueText: row.valueText, unit: resolved.unit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, takenAt: row.takenAt.toISOString(), source: row.source, hasNote: row.noteEncrypted !== null, - rangeStatus: classifyReferenceRange( + rangeStatus: rowRangeStatus( row.value, resolved.referenceLow, resolved.referenceHigh, @@ -133,13 +152,14 @@ export function serialiseLabResultDetail( panel: resolved.panel, analyte: resolved.analyte, value: row.value, + valueText: row.valueText, unit: resolved.unit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, takenAt: row.takenAt.toISOString(), source: row.source, note, - rangeStatus: classifyReferenceRange( + rangeStatus: rowRangeStatus( row.value, resolved.referenceLow, resolved.referenceHigh, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index e2ee03bde..5d20fc2d6 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -21,13 +21,13 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; createLabResultSchema.meta({ id: "CreateLabResultRequest", description: - "Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). `analyte` + `unit` are free-form (a lab prints its own naming). Reference bounds are independently optional; when both are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.", + 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.', }); updateLabResultSchema.meta({ id: "UpdateLabResultRequest", description: - "Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it.", + "Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it. Edit the matching value field for the row's type — `value` for a numeric row, `valueText` for a qualitative one; a request must not carry both, and a cross-type edit (the wrong field for the row) is rejected.", }); listLabResultsSchema.meta({ @@ -48,7 +48,10 @@ const labResultRow = z biomarkerId: z.string().nullable(), panel: z.string().nullable(), analyte: z.string(), - value: z.number(), + // v1.18.9 — `value` is null for a qualitative reading (`valueText` set); + // exactly one of the two is non-null per row. + value: z.number().nullable(), + valueText: z.string().nullable(), unit: z.string(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), @@ -62,7 +65,7 @@ const labResultRow = z .meta({ id: "LabResult", description: - "A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.", + 'A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.', }); const labResultDetail = labResultRow diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts index 3f090b01e..2a4a65cac 100644 --- a/src/lib/validations/__tests__/labs.test.ts +++ b/src/lib/validations/__tests__/labs.test.ts @@ -122,6 +122,72 @@ describe("createLabResultSchema", () => { }); expect(r.success).toBe(false); }); + + // ── qualitative readings (v1.18.9) ──────────────────────────────── + it("accepts a qualitative reading (valueText, no numeric value/unit)", () => { + const r = createLabResultSchema.safeParse({ + analyte: "Hepatitis Bs-Antigen", + valueText: "negativ", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); + + it("accepts a qualitative reading against a catalog biomarker", () => { + const r = createLabResultSchema.safeParse({ + biomarkerId: "bm_123", + valueText: "positiv", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); + + it("rejects a reading carrying BOTH value and valueText", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: 110, + unit: "mg/dL", + valueText: "negativ", + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("rejects a reading carrying NEITHER value nor valueText", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + unit: "mg/dL", + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("rejects an empty qualitative valueText", () => { + const r = createLabResultSchema.safeParse({ + analyte: "Serology", + valueText: " ", + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("rejects an over-long valueText (> 120 chars)", () => { + const r = createLabResultSchema.safeParse({ + analyte: "Serology", + valueText: "x".repeat(121), + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("does not require a unit for a qualitative reading", () => { + const r = createLabResultSchema.safeParse({ + analyte: "Serology", + valueText: "negativ", + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); }); describe("updateLabResultSchema", () => { @@ -139,6 +205,19 @@ describe("updateLabResultSchema", () => { expect(r.success).toBe(true); }); + it("accepts a partial valueText-only edit (v1.18.9)", () => { + const r = updateLabResultSchema.safeParse({ valueText: "positiv" }); + expect(r.success).toBe(true); + }); + + it("rejects an edit carrying both value and valueText", () => { + const r = updateLabResultSchema.safeParse({ + value: 5.6, + valueText: "negativ", + }); + expect(r.success).toBe(false); + }); + it("still enforces the range order when both bounds are supplied", () => { const r = updateLabResultSchema.safeParse({ referenceLow: 10, diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 20f2f9e66..93bbd4359 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -72,12 +72,22 @@ const referenceHigh = z.number().finite().optional(); */ const biomarkerId = z.string().trim().min(1).max(64).optional(); +/** + * v1.18.9 — a qualitative reading ("negativ" / "positiv" / "grenzwertig" / + * "nicht nachweisbar"). Trimmed, non-empty, sensibly capped. Mutually + * exclusive with the numeric `value` (see the create/update refinements). + */ +const valueText = requiredText(120).optional(); + export const createLabResultSchema = z .object({ biomarkerId, panel: optionalPanel, analyte: requiredText(120).optional(), - value: z.number().finite(), + // v1.18.9 — `value` is no longer required: a reading is EITHER numeric + // (`value`) OR qualitative (`valueText`), never both, never neither. + value: z.number().finite().optional(), + valueText, unit: requiredText(40).optional(), referenceLow, referenceHigh, @@ -89,14 +99,31 @@ export const createLabResultSchema = z message: "Either biomarkerId or analyte is required", path: ["analyte"], }) - // Without a catalog link the unit is required (the catalog otherwise - // supplies it server-side). - .refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, { - message: "unit is required when no biomarkerId is given", - path: ["unit"], + // Numeric XOR qualitative: exactly one of `value` / `valueText` is set. + .refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), { + message: + "Provide exactly one of value (numeric) or valueText (qualitative)", + path: ["value"], }) + // A numeric reading without a catalog link still needs a unit (the catalog + // otherwise supplies it server-side). A qualitative reading needs no unit — + // its result is the text, and a unit/range is meaningless for it. .refine( (d) => + d.valueText !== undefined || + d.biomarkerId !== undefined || + d.unit !== undefined, + { + message: + "unit is required for a numeric reading when no biomarkerId is given", + path: ["unit"], + }, + ) + // Reference bounds apply to the numeric path only; skip them entirely for a + // qualitative reading. + .refine( + (d) => + d.valueText !== undefined || d.referenceLow === undefined || d.referenceHigh === undefined || d.referenceLow <= d.referenceHigh, @@ -118,12 +145,22 @@ export const updateLabResultSchema = z panel: optionalPanel.or(z.null()), analyte: requiredText(120).optional(), value: z.number().finite().optional(), + // v1.18.9 — edit the qualitative result. A row stays single-typed: a PUT + // must not set BOTH `value` and `valueText` (the refine below). Switching a + // row's type (numeric ↔ qualitative) is intentionally not supported here — + // delete and re-add — so an omitted key leaves the row's existing type. + valueText: requiredText(120).optional(), unit: requiredText(40).optional(), referenceLow: referenceLow.or(z.null()), referenceHigh: referenceHigh.or(z.null()), takenAt: takenAtField.optional(), note: optionalNote.or(z.null()), }) + // A single edit never carries both a numeric and a qualitative value. + .refine((d) => d.value === undefined || d.valueText === undefined, { + message: "Provide value (numeric) or valueText (qualitative), not both", + path: ["value"], + }) .refine( (d) => d.referenceLow === undefined || From 979d1ced8d3a4219aaa1e0bc88f66c23fca0111d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:49:33 +0200 Subject: [PATCH 02/35] fix(insights): soften Coach FAB glyph to the on-accent token Drop the pure-white Coach floating-action-button icon for the button's own text-background on-accent token, so the glyph reads as a calmer mark on the purple-to-pink gradient instead of a harsh white. Update the colocated assertion to pin the glyph token. --- .../insights/__tests__/layout-coach-fab.test.tsx | 11 ++++++----- src/components/insights/layout-coach-fab.tsx | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 02183430a..767dca039 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -71,7 +71,7 @@ describe("", () => { expect(html).not.toContain("lg:bottom-6"); }); - it("paints a white glyph + offset focus ring on the gradient", () => { + it("paints the on-accent glyph + offset focus ring on the gradient", () => { mockPathname = "/"; const html = render( @@ -79,10 +79,11 @@ describe("", () => { , ); // The glyph is decorative (aria-hidden) and the button carries its own - // accessible name, so the text-contrast rule does not gate it; the - // white glyph reads as a clean mark on the gradient. The button still - // sets text-background for any non-glyph text it might hold. - expect(html).toContain("text-white"); + // accessible name, so the text-contrast rule does not gate it; the glyph + // shares the button's own on-accent token (text-background) rather than a + // harsh pure white, so it reads as a softer mark on the gradient. + expect(html).not.toContain("text-white"); + expect(html).toMatch(/lucide-sparkles[^"]*text-background/); // The offset ring draws a visible halo around the gradient circle and // provides the non-text UI-component contrast against the page. expect(html).toContain("focus-visible:ring-offset-2"); diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 7eb903ad5..e59d77802 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -238,7 +238,7 @@ export function LayoutCoachFab() { "[body:has([data-testid=onboarding-tour])_&]:invisible", )} > -
+ ) : gap.isLoading ? ( +
+ +
+ ) : gap.isError || !gap.data ? ( +

+ {t("illness.insights.typicalGapLearning")} +

+ ) : ( +

+ {gap.data.typicalRecoveryGapDays !== null + ? t("illness.insights.typicalGap", { + days: gap.data.typicalRecoveryGapDays, + }) + : t("illness.insights.typicalGapLearning")} +

+ )}
)} diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 75ecf6866..70be6ef08 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -61,13 +61,26 @@ export function useIllnessCorrelation(episodeId: string | null) { }); } -/** The cross-episode retrospective summary over a trailing window in days. */ -export function useIllnessInsights(windowDays = 365) { +/** + * The cross-episode retrospective summary over a trailing window in days. + * + * `includeRecoveryGap` is OFF by default: the count breakdown is served by a + * single fast query and the recovery-gap stays null. The illness list mounts + * this lightweight read; only the explicit "Analyse" expansion opts into the + * expensive per-episode correlation (`includeRecoveryGap: true`), which caches + * under its own key. `enabled` lets a caller defer the heavy read until opened. + */ +export function useIllnessInsights( + windowDays = 365, + options: { includeRecoveryGap?: boolean; enabled?: boolean } = {}, +) { + const includeRecoveryGap = options.includeRecoveryGap ?? false; return useQuery({ - queryKey: queryKeys.illnessInsights(windowDays), + queryKey: queryKeys.illnessInsights(windowDays, includeRecoveryGap), + enabled: options.enabled ?? true, queryFn: () => apiGet( - `/api/illness/insights?windowDays=${windowDays}`, + `/api/illness/insights?windowDays=${windowDays}&includeRecoveryGap=${includeRecoveryGap}`, ), }); } diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index 195224c59..878072f52 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -2,13 +2,17 @@ * v1.18.1 P3 — illness retrospective summary aggregation. * * Asserts the count/byMonth/byType tallies, the CHRONIC_ONGOING gap exclusion, - * and the thin-data gate: the "typical recovery gap" is WITHHELD below the - * min-sample floor (never a "typical" claim from one episode). + * and the v1.18.9 signal-density gates: the "typical recovery gap" is WITHHELD + * unless enough episodes EACH clear the per-episode measurement floor AND the + * resulting median magnitude is non-trivial (a 0/±1-day gap is coincidental + * noise, not a finding). */ import { describe, expect, it } from "vitest"; import { summarizeIllnessRetrospective, MIN_EPISODES_FOR_TYPICAL_GAP, + MIN_GAP_MEASUREMENT_DAYS, + MIN_TYPICAL_GAP_MAGNITUDE_DAYS, type RetrospectiveEpisode, } from "../retrospective"; @@ -19,6 +23,9 @@ function ep(over: Partial): RetrospectiveEpisode { onsetDay: "2026-01-10", resolved: true, recoveryGapDays: 2, + // Default to a well-covered episode so the gap-magnitude / sample-count + // tests aren't masked by the per-episode measurement floor. + gapMeasurementDays: MIN_GAP_MEASUREMENT_DAYS + 2, lifecycle: "ACUTE", ...over, }; @@ -76,4 +83,60 @@ describe("summarizeIllnessRetrospective", () => { expect(out.gapSampleSize).toBe(3); expect(out.typicalRecoveryGapDays).toBe(4); }); + + it("drops episodes below the per-episode measurement floor", () => { + // Three computed gaps, but only one episode is well-covered — the other + // two ride on a couple of coincidental readings and must not count. + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 4, gapMeasurementDays: 8 }), + ep({ + id: "b", + recoveryGapDays: 5, + gapMeasurementDays: MIN_GAP_MEASUREMENT_DAYS - 1, + }), + ep({ id: "c", recoveryGapDays: 6, gapMeasurementDays: 0 }), + ]); + expect(out.gapSampleSize).toBe(1); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("treats a missing gapMeasurementDays as non-qualifying", () => { + const bare = (id: string): RetrospectiveEpisode => ({ + id, + type: "INFECTION", + onsetDay: "2026-01-10", + resolved: true, + recoveryGapDays: 4, + lifecycle: "ACUTE", + }); + const out = summarizeIllnessRetrospective([ + bare("a"), + bare("b"), + bare("c"), + ]); + expect(out.gapSampleSize).toBe(0); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("withholds a trivial (sub-threshold) median gap as noise", () => { + // Enough well-covered episodes, but the gaps cluster at 0/±1 — the + // coincidental-data baseline. No speculative gap is surfaced. + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 0 }), + ep({ id: "b", recoveryGapDays: 1 }), + ep({ id: "c", recoveryGapDays: -1 }), + ]); + expect(MIN_TYPICAL_GAP_MAGNITUDE_DAYS).toBe(2); + expect(out.gapSampleSize).toBe(3); // they qualified on coverage… + expect(out.typicalRecoveryGapDays).toBeNull(); // …but the magnitude is noise + }); + + it("surfaces a negative gap once its magnitude clears the floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: -2 }), + ep({ id: "b", recoveryGapDays: -3 }), + ep({ id: "c", recoveryGapDays: -4 }), + ]); + expect(out.typicalRecoveryGapDays).toBe(-3); + }); }); diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts index 08ab725f3..ff93e92b0 100644 --- a/src/lib/illness/retrospective.ts +++ b/src/lib/illness/retrospective.ts @@ -15,9 +15,36 @@ */ import { median } from "@/lib/insights/derived/baseline"; -/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */ +/** + * Minimum episodes that each clear the per-episode signal floor before a + * "typical recovery gap" is asserted. A gap is only honest once several + * episodes independently back it, so one or two coincidental gaps never + * become a "typical" claim. + */ export const MIN_EPISODES_FOR_TYPICAL_GAP = 3; +/** + * Minimum distinct episode days carrying a real measurement before an + * episode's recovery-gap may feed the typical-gap median. Below this an + * episode's window holds too few genuine vitals for its computed gap to mean + * anything — a couple of readings happening to surround the onset/recovery + * markers produce a spurious gap. The correlation engine's own + * `MIN_EPISODE_COVERAGE_DAYS` floor (4) is the engine-side gate; this is the + * aggregate-side reinforcement so a barely-covered episode does not tip the + * median. Sourced from `Derived.coverage.historyDays`. + */ +export const MIN_GAP_MEASUREMENT_DAYS = 4; + +/** + * Minimum absolute magnitude (days) the typical gap must reach before it is + * surfaced. A median gap of 0 — and the ±1-day jitter around it — is the + * dominant outcome of coincidental data (numbers and feeling settle on the + * same day), so it carries no signal worth a headline. Only a gap that + * genuinely separates the felt-better marker from the physiological return + * is shown. + */ +export const MIN_TYPICAL_GAP_MAGNITUDE_DAYS = 2; + /** One episode's contribution to the summary. */ export interface RetrospectiveEpisode { id: string; @@ -28,6 +55,13 @@ export interface RetrospectiveEpisode { resolved: boolean; /** The computed recovery-gap in days, or null when ungated/unavailable. */ recoveryGapDays: number | null; + /** + * Distinct episode days that carried a real measurement (the correlation + * engine's coverage `historyDays`). Drives the per-episode signal floor: + * an episode below `MIN_GAP_MEASUREMENT_DAYS` contributes no gap even if + * the engine computed one. Absent/0 → does not qualify. + */ + gapMeasurementDays?: number; /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ lifecycle: string; } @@ -56,9 +90,17 @@ export interface IllnessRetrospectiveSummary { } /** - * Aggregate the per-episode figures into the retrospective summary. Pure. The - * typical-gap is withheld (null) below the min-sample floor so a single - * episode never becomes a "typical" claim. + * Aggregate the per-episode figures into the retrospective summary. Pure. + * + * The typical-gap is withheld (null) unless ALL THREE signal-density gates + * hold, so coincidental data never surfaces a speculative "Erholungslücke": + * 1. each contributing episode cleared the per-episode measurement floor + * (`gapMeasurementDays >= MIN_GAP_MEASUREMENT_DAYS`), + * 2. at least `MIN_EPISODES_FOR_TYPICAL_GAP` such episodes qualify, and + * 3. the resulting median magnitude reaches + * `MIN_TYPICAL_GAP_MAGNITUDE_DAYS` (a 0/±1-day gap is noise). + * `gapSampleSize` reports the count of episodes that cleared gates 1–2 (the + * transparency figure), independent of the magnitude gate. */ export function summarizeIllnessRetrospective( episodes: RetrospectiveEpisode[], @@ -76,17 +118,27 @@ export function summarizeIllnessRetrospective( if ( e.lifecycle !== "CHRONIC_ONGOING" && e.recoveryGapDays !== null && - Number.isFinite(e.recoveryGapDays) + Number.isFinite(e.recoveryGapDays) && + // Per-episode signal floor: a barely-covered episode contributes no + // gap even if the engine computed one. + (e.gapMeasurementDays ?? 0) >= MIN_GAP_MEASUREMENT_DAYS ) { gaps.push(e.recoveryGapDays); } } - const typicalRecoveryGapDays = + const medianGap = gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP ? Math.round(median(gaps)) : null; + // Magnitude gate: a 0/±1-day gap is the coincidental-data baseline and + // carries no signal worth a headline. + const typicalRecoveryGapDays = + medianGap !== null && Math.abs(medianGap) >= MIN_TYPICAL_GAP_MAGNITUDE_DAYS + ? medianGap + : null; + return { episodeCount: episodes.length, resolvedCount, diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 14b34e3b1..8759a005b 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -93,7 +93,7 @@ const illnessDayLogGetQuery = z illnessInsightsQuerySchema.meta({ id: "IllnessInsightsQuery", description: - "Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365). Retrospective only — the engine summarises past episodes, never forecasts.", + "Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365) and optional `includeRecoveryGap` (default false). With `includeRecoveryGap` off the response carries the count breakdown and a null typical recovery gap on a single fast query; set it true to pay for the per-episode correlation that computes the recovery gap. Retrospective only — the engine summarises past episodes, never forecasts.", }); const illnessSymptom = z diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 79e4f1967..751180365 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -35,7 +35,13 @@ export const illnessKeys = { /** * v1.18.1 P3 — the cross-episode retrospective summary ("sick N times · * typical recovery gap X days") over a trailing window in days. + * + * v1.18.9 — `includeRecoveryGap` is part of the key: the lightweight + * count-only read (list load) and the heavy recovery-gap read (the + * "Analyse" expansion) hit the same endpoint with a different response + * shape, so they MUST cache under distinct keys (same-key + different + * payload silently poisons the cache). */ - illnessInsights: (windowDays: number) => - ["illness", "insights", windowDays] as const, + illnessInsights: (windowDays: number, includeRecoveryGap: boolean) => + ["illness", "insights", windowDays, includeRecoveryGap] as const, }; diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index 7578df218..97b0a29d6 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -182,7 +182,17 @@ export type IllnessDayLogListQuery = z.infer< * The retrospective-insight window query: `GET /api/illness/insights? * windowDays=365`. Bounded 30..1095 days; defaults to a trailing year. * Retrospective only — the engine summarises past episodes, never forecasts. + * + * `includeRecoveryGap` (default false) gates the EXPENSIVE per-episode + * correlation fan-out. The illness LIST loads with it off so the page paints + * on a single fast count query; only the explicit "Analyse" expansion sets it + * true and pays for the recovery-gap computation. Withholding it leaves the + * typical-gap null (the surface shows the calm "still learning" line). */ export const illnessInsightsQuerySchema = z.object({ windowDays: z.coerce.number().int().min(30).max(1095).optional(), + includeRecoveryGap: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional(), }); From 696daf3b8f1442a5993a387eaad5404eacb609c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:58:52 +0200 Subject: [PATCH 06/35] fix(illness): align the journal settings page with the Settings pattern The illness settings sub-page rendered bare sections with text-sm headings and no card surface, breaking from the canonical Settings layout the Stimmung and Medikamente sections follow. Wrap each block in a SettingsCard with a SettingsCardHeader (neutral icon, text-lg title, short muted description), so the view and order controls read as first-class Settings cards consistent with the rest of the surface. --- messages/de.json | 4 ++ messages/en.json | 4 ++ messages/es.json | 4 ++ messages/fr.json | 4 ++ messages/it.json | 4 ++ messages/pl.json | 4 ++ src/components/illness/illness-settings.tsx | 43 +++++++++++++-------- 7 files changed, 50 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 04042b5e4..66c41c8b7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Krankheitstagebuch", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", + "settings": { + "viewDescription": "Wähle, ob Episoden als Karten oder als kompakte Liste erscheinen.", + "orderDescription": "Ziehe deine Episoden in die gewünschte Reihenfolge." + }, "customize": "Krankheitstagebuch anpassen", "newEpisode": "Neue Episode", "logDay": "Tag erfassen", diff --git a/messages/en.json b/messages/en.json index 77ba786cb..6961ab325 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Illness journal", "subtitle": "A retrospective log of your illness and condition episodes.", + "settings": { + "viewDescription": "Choose whether episodes show as cards or as a compact list.", + "orderDescription": "Drag your episodes into the order you want to see them in." + }, "customize": "Customize illness journal", "newEpisode": "New episode", "logDay": "Log a day", diff --git a/messages/es.json b/messages/es.json index bf960df41..0b1de3bf0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Diario de enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", + "settings": { + "viewDescription": "Elige si los episodios se muestran como tarjetas o como lista compacta.", + "orderDescription": "Arrastra tus episodios al orden en que quieres verlos." + }, "customize": "Personalizar diario de enfermedad", "newEpisode": "Nuevo episodio", "logDay": "Registrar un día", diff --git a/messages/fr.json b/messages/fr.json index f10b70a3e..62ef954af 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Journal de maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", + "settings": { + "viewDescription": "Choisis si les épisodes s'affichent en cartes ou en liste compacte.", + "orderDescription": "Fais glisser tes épisodes dans l'ordre où tu veux les voir." + }, "customize": "Personnaliser le journal de maladie", "newEpisode": "Nouvel épisode", "logDay": "Enregistrer un jour", diff --git a/messages/it.json b/messages/it.json index ff1db744a..97c154aa7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Diario delle malattie", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", + "settings": { + "viewDescription": "Scegli se gli episodi appaiono come schede o come elenco compatto.", + "orderDescription": "Trascina i tuoi episodi nell'ordine in cui vuoi vederli." + }, "customize": "Personalizza diario delle malattie", "newEpisode": "Nuovo episodio", "logDay": "Registra un giorno", diff --git a/messages/pl.json b/messages/pl.json index 48a9ed697..c741b7a4b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6692,6 +6692,10 @@ "illness": { "title": "Dziennik chorób", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", + "settings": { + "viewDescription": "Wybierz, czy epizody mają być wyświetlane jako karty czy zwarta lista.", + "orderDescription": "Przeciągnij epizody w kolejność, w jakiej chcesz je widzieć." + }, "customize": "Dostosuj dziennik chorób", "newEpisode": "Nowy epizod", "logDay": "Zapisz dzień", diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index 7b33496f3..d6952bc5e 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -6,8 +6,18 @@ * Reached from the wrench beside the page's "hinzufügen" button. Lets the * user reorder conditions/episodes + pick the card-vs-list page view. Order * + view persist client-side via `useModuleListPrefs("illness")`. + * + * v1.18.9 — restructured onto the canonical Settings pattern: each block is a + * `` with a `` (neutral icon + `text-lg` + * title + a short muted description), matching the Stimmung / Medikamente + * sections rather than the bespoke bare-`
` headings it carried + * before. */ +import { Eye, ListOrdered } from "lucide-react"; + import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; import { @@ -37,23 +47,22 @@ export function IllnessSettings() { })); return ( -
-
-
-

- {t("moduleList.viewHeading")} -

- -
-

- {t("moduleList.viewDescription")} -

-
+
+ + } + /> + -
-

- {t("moduleList.reorder.heading")} -

+ + {isLoading ? (
{Array.from({ length: 3 }, (_, i) => ( @@ -63,7 +72,7 @@ export function IllnessSettings() { ) : ( )} -
+
); } From f46e9d3bb83db1b7ea436e4c5b31f2f295032946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:58:58 +0200 Subject: [PATCH 07/35] feat(coach): surface per-turn token usage on the chat stream Carry an additive usage envelope on the SSE done frame ({ totalTokens, model }) and persist tokensUsed + model onto coach_messages (migration 0182, nullable, no backfill) so the per-message token footer survives a conversation reload. Add an optional reasoning frame the client can render inside the thinking disclosure when a reasoning-capable provider emits a summary; providers without reasoning emit none. Extend CoachMessageDTO and regenerate the OpenAPI contract. Server-authoritative: clients display the count, never recompute it. --- docs/api/openapi.yaml | 29 ++++++-- .../migration.sql | 14 ++++ prisma/schema.prisma | 5 ++ src/app/api/insights/chat/route.ts | 13 ++++ .../__tests__/message-thread.test.tsx | 68 ++++++++++++------ .../insights/coach-panel/use-coach.ts | 69 ++++++++++++++++++- src/lib/ai/coach/persistence.ts | 13 ++++ src/lib/ai/coach/types.ts | 45 +++++++++++- src/lib/openapi/routes/coach.ts | 15 +++- 9 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ecb970d7..f0db8ff6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4148,11 +4148,14 @@ paths: summary: Send a Coach turn (streaming reply) description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk - of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ - type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a - provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame - types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and - rate-limited. Auth via cookie or Bearer." + of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` + (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ + type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, + messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, + completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, + code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` + frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts + a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer." requestBody: required: true content: @@ -15702,6 +15705,20 @@ components: - type: string - type: "null" description: Coach prompt version that produced the reply; null for user turns. + tokensUsed: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a + reload. Null on user turns, refusals, and pre-feature rows. + model: + anyOf: + - type: string + - type: "null" + description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, + refusals, older rows). required: - id - role @@ -15710,6 +15727,8 @@ components: - metricSource - providerType - promptVersion + - tokensUsed + - model additionalProperties: false description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation. CoachProvenance: diff --git a/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql new file mode 100644 index 000000000..14d9a4c88 --- /dev/null +++ b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql @@ -0,0 +1,14 @@ +-- v1.18.9 — per-message Coach token usage + model. +-- +-- The streaming chat endpoint already computes the per-turn token count +-- (`result.tokensUsed`) and the model that produced the reply, but until +-- now neither reached the client. Two additive nullable columns let the +-- quiet per-message token footer survive a conversation reload: the live +-- turn paints from the `done.usage` SSE frame, persisted history paints +-- from these columns. +-- +-- Both NULL by default. No backfill: existing rows (and every user turn +-- or refusal, which never carries a token count) simply render no footer. +ALTER TABLE "coach_messages" + ADD COLUMN "tokens_used" INTEGER, + ADD COLUMN "model" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index deeb77edf..480c5eaa2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3643,6 +3643,11 @@ model CoachMessage { metricSourceJson String? @map("metric_source_json") providerType String? @map("provider_type") promptVersion String? @map("prompt_version") + // v1.18.9 — per-turn token usage + model, persisted so the quiet + // per-message token footer survives a conversation reload. Both + // nullable: user turns, refusals, and pre-feature rows carry NULL. + tokensUsed Int? @map("tokens_used") + model String? createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 88d04749b..ea8907ad7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -652,6 +652,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; metricSource: enrichedProvenance, providerType: workingProviderType, promptVersion: PROMPT_VERSION, + // v1.18.9 — persist the per-turn token count + model so the quiet + // token footer survives a conversation reload. The live turn paints + // from the `done.usage` SSE frame below; reloads read these columns. + tokensUsed: result.tokensUsed ?? null, + model: result.model ?? null, }); // v1.18.7 — the day's spend was already reconciled against the @@ -703,11 +708,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), ); } + // v1.18.9 — additive `usage` envelope on the `done` frame so the client + // can paint the quiet per-message token footer the instant the stream + // closes. Server-authoritative: the client renders these numbers, never + // recomputes them. Older clients drop the unknown key. controller.enqueue( encodeFrame({ type: "done", conversationId: workingConversationId, messageId: assistantMessage.id, + usage: { + totalTokens: result.tokensUsed ?? null, + model: result.model ?? null, + }, }), ); }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index cae800634..1e650e5db 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -60,6 +60,28 @@ import { type InterleavedThreadItem, } from "../message-thread"; import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; +import type { CoachStreamingMessage } from "../use-coach"; + +// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` / +// `reasoning` (per-message token footer + thinking disclosure). The +// fixtures below predate those fields; this builder fills the defaults so +// each test only spells out the fields it cares about. +function streaming( + partial: Partial, +): CoachStreamingMessage { + return { + content: "", + metricSource: null, + suggestion: null, + inProgress: false, + messageId: null, + errorCode: null, + usage: null, + startedAt: null, + reasoning: "", + ...partial, + }; +} function render(node: React.ReactNode, locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = { metricSource: null, providerType: null, promptVersion: null, + tokensUsed: null, + model: null, }, { id: "m2", @@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = { }, providerType: "admin-openai", promptVersion: "4.20.0", + tokensUsed: 312, + model: "gpt-4o", }, ], }; @@ -143,14 +169,14 @@ describe("", () => { const html = render( , ); // 1 persisted user + 1 persisted assistant + 1 streaming assistant. @@ -168,14 +194,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Drafting…"); @@ -186,14 +212,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Thinking"); @@ -203,14 +229,14 @@ describe("", () => { const html = render( , ); // v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights @@ -237,6 +263,8 @@ describe("", () => { metricSource: null, providerType: "admin-openai", promptVersion: "4.20.0", + tokensUsed: null, + model: null, }, ], }; @@ -244,14 +272,14 @@ describe("", () => { const html = render( , ); // 1 persisted user + 2 persisted assistant + 0 streaming → 2. @@ -271,14 +299,14 @@ describe("", () => { const html = render( , ); const assistantBubbles = ( @@ -470,14 +498,14 @@ describe("", () => { content: "Wie ist mein Blutdruck letzte Woche?", conversationId: null, }} - streaming={{ + streaming={streaming({ content: "", metricSource: null, suggestion: null, inProgress: true, messageId: null, errorCode: null, - }} + })} />, ); expect(html).toContain("Wie ist mein Blutdruck letzte Woche?"); @@ -577,14 +605,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); @@ -598,14 +626,14 @@ describe("", () => { const html = render( , ); expect(html).toContain( @@ -627,14 +655,14 @@ describe("", () => { content: "Why was BP higher on Monday?", conversationId: "conv-1", }} - streaming={{ + streaming={streaming({ content: "", metricSource: null, suggestion: null, inProgress: false, messageId: null, errorCode: null, - }} + })} />, ); const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? []) diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 11c462516..7c59eb0e0 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -11,6 +11,7 @@ import type { CoachScope, CoachStreamEvent, CoachSuggestion, + CoachUsage, } from "@/lib/ai/coach/types"; import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; @@ -191,6 +192,26 @@ export interface CoachStreamingMessage { messageId: string | null; /** Server-side error code from an `error` frame, if any. */ errorCode: string | null; + /** + * v1.18.9 — per-turn token usage from the additive `done.usage` frame. + * Null until `done` lands (or when the provider returned no count). The + * thread's token footer reads this for the just-finished streaming + * bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead. + */ + usage: CoachUsage | null; + /** + * v1.18.9 — epoch-ms timestamp the send fired, so the thinking + * disclosure can show a live "thinking for N s" counter that freezes + * to a past-tense summary once the first token lands. Null before a + * turn starts. + */ + startedAt: number | null; + /** + * v1.18.9 — optional reasoning-summary text from the additive + * `reasoning` frame, rendered inside the thinking disclosure when a + * reasoning-capable provider emits it. Empty string when none. + */ + reasoning: string; } /** @@ -231,6 +252,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = { inProgress: false, messageId: null, errorCode: null, + usage: null, + startedAt: null, + reasoning: "", }; export interface SendCoachMessageParams { @@ -324,6 +348,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { inProgress: true, messageId: null, errorCode: null, + usage: null, + // v1.18.9 — anchor the thinking-disclosure elapsed timer to the + // moment the send fired. + startedAt: Date.now(), + reasoning: "", }); // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode @@ -337,6 +366,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { inProgress: false, messageId: null, errorCode: "coach.network", + usage: null, + startedAt: null, + reasoning: "", }); return null; } @@ -370,6 +402,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { inProgress: false, messageId: null, errorCode: "coach.network", + usage: null, + startedAt: null, + reasoning: "", }); return null; } @@ -382,6 +417,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { inProgress: false, messageId: null, errorCode: `coach.http.${response.status}`, + usage: null, + startedAt: null, + reasoning: "", }); return null; } @@ -419,6 +457,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { inProgress: false, messageId: null, errorCode: structured ?? `coach.http.${response.status}`, + usage: null, + startedAt: null, + reasoning: "", }); // v1.16.4 — KEEP the optimistic user bubble: a rejected turn // (budget gate, 4xx) has no persisted twin coming, and dropping @@ -437,6 +478,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { let collectedSuggestion: CoachSuggestion | null = null; let lastError: string | null = null; let messageId: string | null = null; + let collectedUsage: CoachUsage | null = null; + let collectedReasoning = ""; try { while (true) { @@ -468,9 +511,21 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { suggestion: evt.suggestion, })); break; + case "reasoning": + // v1.18.9 — additive reasoning-summary frame. Append so + // multiple chunks accumulate; the disclosure renders it + // when present, else falls back to the elapsed-time label. + collectedReasoning += evt.text; + setStreaming((prev) => ({ + ...prev, + reasoning: prev.reasoning + evt.text, + })); + break; case "done": resolvedConversationId = evt.conversationId; messageId = evt.messageId; + // v1.18.9 — additive per-turn usage envelope. + collectedUsage = evt.usage ?? null; break; case "error": lastError = evt.code; @@ -484,8 +539,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { if (evt.type === "done") { resolvedConversationId = evt.conversationId; messageId = evt.messageId; + collectedUsage = evt.usage ?? null; } else if (evt.type === "error") { lastError = evt.code; + } else if (evt.type === "reasoning") { + collectedReasoning += evt.text; } } } catch (err) { @@ -494,14 +552,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { } } - setStreaming({ + setStreaming((prev) => ({ content: collectedContent, metricSource: collectedProvenance, suggestion: collectedSuggestion, inProgress: false, messageId, errorCode: lastError, - }); + usage: collectedUsage, + // v1.18.9 — keep the send-anchored timestamp so the disclosure can + // freeze the elapsed time to a past-tense "thought for N s" once + // the turn settles. + startedAt: prev.startedAt, + reasoning: collectedReasoning, + })); if (resolvedConversationId) { // Invalidate the freshly-persisted conversation + the rail so @@ -550,4 +614,5 @@ export type { CoachConversationDetailDTO, CoachProvenance, CoachSuggestion, + CoachUsage, }; diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 827dcc63c..dfe7bef9a 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -128,6 +128,13 @@ export interface AppendMessageParams { metricSource?: CoachProvenance | null; providerType?: string | null; promptVersion?: string | null; + /** + * v1.18.9 — per-turn token count + model the reply was produced with, + * persisted so the quiet token footer survives a reload. Omitted on + * user turns and refusals (no token count to record). + */ + tokensUsed?: number | null; + model?: string | null; } /** @@ -168,6 +175,8 @@ export async function appendMessage( metricSourceJson: provenanceToJson(params.metricSource ?? null), providerType: params.providerType ?? null, promptVersion: params.promptVersion ?? null, + tokensUsed: params.tokensUsed ?? null, + model: params.model ?? null, }, }); await tx.coachConversation.update({ @@ -185,6 +194,8 @@ export async function appendMessage( metricSource: provenanceFromJson(result.metricSourceJson), providerType: result.providerType, promptVersion: result.promptVersion, + tokensUsed: result.tokensUsed, + model: result.model, }; } @@ -270,6 +281,8 @@ export async function fetchConversationWithMessages( metricSource: provenanceFromJson(m.metricSourceJson), providerType: m.providerType, promptVersion: m.promptVersion, + tokensUsed: m.tokensUsed, + model: m.model, })); // v1.11.1 — decrypt the rolling conversation summary (fail-closed: an diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 7d50c42ae..13c19b255 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -165,6 +165,21 @@ export interface CoachSuggestion { label: string; } +/** + * v1.18.9 — per-turn token-usage envelope carried on the `done` frame + * (and persisted onto the assistant message). Server-authoritative: the + * client renders these numbers, never recomputes them. `totalTokens` is + * the headline count surfaced in the quiet per-message footer; the + * prompt / completion split is optional (not every provider returns it) + * and `model` names the provider model that produced the reply. + */ +export interface CoachUsage { + totalTokens: number | null; + promptTokens?: number | null; + completionTokens?: number | null; + model?: string | null; +} + /** * SSE event shapes emitted by the streaming endpoint. * @@ -173,6 +188,15 @@ export interface CoachSuggestion { * The `suggestion` frame (v1.18.1) is additive: older web + iOS clients * that don't know it drop it on the floor (the parser keeps only frames * whose `type` it handles), so the chat stays backwards-compatible. + * + * v1.18.9 — two additive shapes: + * - `done.usage` carries the per-turn `CoachUsage` (tokens + model) so + * the client can paint the quiet per-message footer the instant the + * stream closes; older clients ignore the extra key. + * - `reasoning` is an optional frame whose `text` the client renders + * inside the thinking disclosure when a reasoning-capable provider + * emits a cheap reasoning summary. Providers without reasoning emit + * none, and the disclosure falls back to its elapsed-time label. */ export type CoachStreamEvent = | { type: "token"; token: string } @@ -181,7 +205,13 @@ export type CoachStreamEvent = metricSource: CoachProvenance; } | { type: "suggestion"; suggestion: CoachSuggestion } - | { type: "done"; conversationId: string; messageId: string } + | { type: "reasoning"; text: string } + | { + type: "done"; + conversationId: string; + messageId: string; + usage?: CoachUsage; + } | { type: "error"; code: string; message: string }; /** @@ -335,6 +365,19 @@ export interface CoachMessageDTO { metricSource: CoachProvenance | null; providerType: string | null; promptVersion: string | null; + /** + * v1.18.9 — total tokens the assistant turn cost, persisted so the + * quiet per-message footer survives a conversation reload. Null on + * older messages (pre-feature) and on user turns / refusals where no + * token count was recorded. + */ + tokensUsed: number | null; + /** + * v1.18.9 — the provider model that produced the reply (e.g. + * `gpt-4o`), persisted alongside `tokensUsed` for the footer. Null + * when unknown (user turns, refusals, older rows). + */ + model: string | null; } export interface CoachConversationDetailDTO extends CoachConversationDTO { diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index efa0a6ed5..8a091018d 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -307,6 +307,19 @@ const coachMessageSchema = z .describe( "Coach prompt version that produced the reply; null for user turns.", ), + tokensUsed: z + .number() + .int() + .nullable() + .describe( + "v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows.", + ), + model: z + .string() + .nullable() + .describe( + "v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows).", + ), }) .meta({ id: "CoachMessage", @@ -425,7 +438,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Send a Coach turn (streaming reply)", description: - "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", + "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", requestBody: { required: true, content: { From 4da155ddcb2625472b6c0bc406c2338d5358df17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:59:13 +0200 Subject: [PATCH 08/35] feat(labs): default to list view and reuse medication tile primitives Set the compact list as the Labs surface default; tiles in the card view now route through MedicationCardHeader so a lab tile reads identically to a medication or Vorsorge tile side-by-side. Add alphabetical (A-Z / Z-A) sort options to the per-module sort choice. --- src/components/labs/lab-list.tsx | 208 ++++++++++++-------- src/lib/__tests__/module-list-prefs.test.ts | 21 ++ src/lib/module-list-prefs.ts | 60 ++++-- 3 files changed, 190 insertions(+), 99 deletions(-) diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index bf3b0a2f8..e4fcecf23 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -2,14 +2,17 @@ import { useMemo } from "react"; import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronRight, FlaskConical } from "lucide-react"; +import { toast } from "sonner"; +import { DeleteButton } from "@/components/data-list"; +import { MedicationCardHeader } from "@/components/medications/medication-card-header"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabReading, formatLabValue } from "@/lib/labs/format-value"; @@ -74,8 +77,22 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const { prefs } = useModuleListPrefs("labs"); + // Delete a biomarker directly from the list (#41/#3). The endpoint's + // `onDelete: SetNull` FK keeps the readings and unlinks them; both the + // catalog and the result list invalidate so the row drops in the same tick. + const deleteBiomarker = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`), + onSuccess: () => { + toast.success(t("labs.biomarker.deletedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + }, + onError: () => toast.error(t("labs.biomarker.deleteError")), + }); + const listKey = queryKeys.labResultsList({ biomarkerId: undefined, analyte: undefined, @@ -95,10 +112,21 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { 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, and `manual` - // applies the persisted biomarker order (legacy un-linked groups, which - // carry no biomarkerId, sort after the ordered block). + // already returns 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). if (prefs.sortDir === "recentAsc") return [...base].reverse(); + if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { + const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; + return [...base].sort( + (a, b) => + dir * + a.analyte.localeCompare(b.analyte, undefined, { + sensitivity: "base", + }), + ); + } if (prefs.sortDir === "manual") { return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); } @@ -153,9 +181,27 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ); } + // Per-group biomarker delete (#41/#3), surfaced on both views. Only + // catalog-linked groups carry an id the endpoint can delete; legacy + // un-linked rows have no biomarker to remove. + function biomarkerDelete(group: MarkerGroup) { + if (!group.biomarkerId) return null; + const id = group.biomarkerId; + return ( + deleteBiomarker.mutate(id)} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="size-9" + iconClassName="h-4 w-4" + /> + ); + } + // v1.18.6 (MOD-03) — compact list view: one bordered card holding tight - // divided rows instead of a card per biomarker. The card view stays the - // default; this is the denser alternative the settings toggle selects. + // divided rows instead of a card per biomarker. This is the default view + // (#40); the card/tile view is the alternative the settings toggle selects. if (prefs.view === "list") { return (
@@ -167,42 +213,50 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {groups.map((group) => { - const row = ( -
-
-
- - {group.analyte} - - -
-
- - {formatLabReading(group.latest)} - - {formatDate(group.latest.takenAt)} -
+ const inner = ( +
+
+ + {group.analyte} + +
-
- r.value)} - /> - {group.biomarkerId ? ( - - ) : null} +
+ + {formatLabReading(group.latest)} + + {formatDate(group.latest.takenAt)}
); - return group.biomarkerId ? ( - - {row} - - ) : ( -
{row}
+ {group.biomarkerId ? ( + + {inner} + r.value)} + /> + + + ) : ( +
+ {inner} + r.value)} + /> +
+ )} + {biomarkerDelete(group)} +
); })} @@ -211,27 +265,37 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ); } + // v1.18.9 (#40) — card/tile view. The tile reuses the medication module's + // `MedicationCardHeader` so a lab tile reads identically to a medication or + // Vorsorge tile side-by-side: name on line 1, the reference-range badge as + // the line-1 chip, the panel as the category badge, and the delete control + // in the header's `actions` slot (a sibling of the header Link, so deleting + // never also deep-links). return ( -
+
    {truncated ? ( -

    - {t("labs.showingLatestOf", { shown, total })} -

    +
  • +

    + {t("labs.showingLatestOf", { shown, total })} +

    +
  • ) : null} - {groups.map((group) => { - const body = ( - <> - - - {group.analyte} - {group.panel ? ( - - {group.panel} - - ) : null} + {groups.map((group) => ( +
  • + + - - + } + href={ + group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined + } + linkLabel={group.analyte} + actions={biomarkerDelete(group)} + />
    @@ -261,34 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ) : null}
    -
    - r.value)} - /> - {group.biomarkerId ? ( - - ) : null} -
    + r.value)} + />
    - - ); - - // Catalog-linked groups deep-link to the per-biomarker detail chart. - // Legacy un-linked groups render as a plain card until the backfill - // links them (or the user re-adds via the structured path). - return group.biomarkerId ? ( - - {body} - - ) : ( - {body} - ); - })} -
+ + + ))} + ); } diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index 8434b9615..1c9eb58fe 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -38,6 +38,27 @@ describe("module-list-prefs", () => { order: ["x"], }); }); + + it("accepts the alphabetical sort directions (#43)", () => { + for (const sortDir of ["alphaAsc", "alphaDesc"]) { + const parsed = parseModuleListPrefs(JSON.stringify({ sortDir })); + expect(parsed.sortDir).toBe(sortDir); + } + }); + + it("applies a per-call default when no blob is stored (Labs list view, #40)", () => { + const labsDefaults = { + view: "list" as const, + order: [], + sortDir: "recentDesc" as const, + }; + expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults); + // A stored view still wins over the default. + expect( + parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults) + .view, + ).toBe("cards"); + }); }); describe("applyOrder", () => { diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index 0b2e5f4a7..6b7eb0f20 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -28,10 +28,16 @@ export type ModuleListKey = "vorsorge" | "illness" | "labs"; /** * Sort choice for the Labs surface (MOD-04): order biomarker groups by the - * recency of their most-recent reading. `manual` defers to the persisted - * `order` array instead. + * recency of their most-recent reading, alphabetically by analyte name + * (#43), or `manual`, which defers to the persisted `order` array. */ -export const MODULE_SORT_DIRS = ["recentDesc", "recentAsc", "manual"] as const; +export const MODULE_SORT_DIRS = [ + "recentDesc", + "recentAsc", + "alphaAsc", + "alphaDesc", + "manual", +] as const; export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; export interface ModuleListPrefs { @@ -47,6 +53,21 @@ const DEFAULT_PREFS: ModuleListPrefs = { sortDir: "recentDesc", }; +/** + * Per-module default overrides applied when no preference blob is stored yet. + * Labs defaults to the compact list view (#40) so the dense reading table is + * the first thing the user sees; Vorsorge and Illness keep the card grid. + */ +const MODULE_DEFAULTS: Partial< + Record> +> = { + labs: { view: "list" }, +}; + +function defaultsFor(module: ModuleListKey): ModuleListPrefs { + return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] }; +} + function storageKey(module: ModuleListKey): string { return `healthlog:module-list-prefs:${module}`; } @@ -65,20 +86,21 @@ function isSortDir(value: unknown): value is ModuleSortDir { ); } -export function parseModuleListPrefs(raw: string | null): ModuleListPrefs { - if (!raw) return DEFAULT_PREFS; +export function parseModuleListPrefs( + raw: string | null, + defaults: ModuleListPrefs = DEFAULT_PREFS, +): ModuleListPrefs { + if (!raw) return defaults; try { const blob = JSON.parse(raw) as Record; - const view = isView(blob.view) ? blob.view : DEFAULT_PREFS.view; + const view = isView(blob.view) ? blob.view : defaults.view; const order = Array.isArray(blob.order) ? blob.order.filter((v): v is string => typeof v === "string") : []; - const sortDir = isSortDir(blob.sortDir) - ? blob.sortDir - : DEFAULT_PREFS.sortDir; + const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir; return { view, order, sortDir }; } catch { - return DEFAULT_PREFS; + return defaults; } } @@ -124,6 +146,7 @@ export function useModuleListPrefs(module: ModuleListKey): { setSortDir: (sortDir: ModuleSortDir) => void; } { const key = storageKey(module); + const defaults = useMemo(() => defaultsFor(module), [module]); const subscribe = useCallback( (onChange: () => void) => { @@ -154,7 +177,10 @@ export function useModuleListPrefs(module: ModuleListKey): { // changed, so keying the parse on it gives downstream `applyOrder` // memoisation a stable `prefs` reference instead of a fresh object + a // JSON.parse on every render. - const prefs = useMemo(() => parseModuleListPrefs(raw), [raw]); + const prefs = useMemo( + () => parseModuleListPrefs(raw, defaults), + [raw, defaults], + ); const write = useCallback( (next: ModuleListPrefs) => { @@ -167,18 +193,18 @@ export function useModuleListPrefs(module: ModuleListKey): { const setView = useCallback( (view: ModuleListView) => - write({ ...parseModuleListPrefs(getSnapshot()), view }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }), + [write, getSnapshot, defaults], ); const setOrder = useCallback( (order: string[]) => - write({ ...parseModuleListPrefs(getSnapshot()), order }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }), + [write, getSnapshot, defaults], ); const setSortDir = useCallback( (sortDir: ModuleSortDir) => - write({ ...parseModuleListPrefs(getSnapshot()), sortDir }), - [write, getSnapshot], + write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }), + [write, getSnapshot, defaults], ); return { prefs, setView, setOrder, setSortDir }; From 8ae498ffb28467e64d3e53a99646db9b112fa9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:59:19 +0200 Subject: [PATCH 09/35] feat(labs): surface biomarker delete on the detail page Add a delete affordance with a confirm step to the biomarker detail header, reusing the existing DELETE /api/biomarkers/:id endpoint. On success the catalog and result-list queries invalidate and the view returns to the labs surface. Removes the Settings detour for clearing a stray biomarker. --- src/components/labs/lab-biomarker-detail.tsx | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 436ec36d3..2b07f364a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,15 +1,18 @@ "use client"; import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FlaskConical, Pencil, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -34,6 +37,7 @@ import type { */ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { t } = useTranslations(); + const router = useRouter(); const queryClient = useQueryClient(); const [addOpen, setAddOpen] = useState(false); // Sticky-footer slot for the add-value sheet (the form portals here). @@ -93,6 +97,21 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); } + // v1.18.9 (#41/#3) — delete the biomarker directly from its detail page so + // a stray marker is removable without a Settings detour. `onDelete: SetNull` + // keeps the readings and unlinks them. On success, invalidate the catalog + + // result list and return to the labs surface. + const deleteMarker = useMutation({ + mutationFn: () => apiDelete(`/api/biomarkers/${biomarkerId}`), + onSuccess: () => { + toast.success(t("labs.biomarker.deletedToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + router.push("/labs"); + }, + onError: () => toast.error(t("labs.biomarker.deleteError")), + }); + if (markerError || listError) { return (

@@ -136,6 +155,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { > + deleteMarker.mutate()} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" + iconClassName="h-4 w-4" + />

)} - {updatedAt && ( -

- {t("insights.heroGenerated", { - time: formatRelativeTime(updatedAt, t), - })} -

+ {(updatedAt || noProviderStale) && ( +
+ {updatedAt && ( +

+ {t("insights.heroGenerated", { + time: formatRelativeTime(updatedAt, t), + })} +

+ )} + {/* v1.18.9 (#4) — a stale briefing that can never refresh + because no AI provider is connected. State that plainly + and point at Settings → AI, so the days-old read is + understood as held, not presented as current. */} + {noProviderStale && ( +

+ + {t("insights.dailyBriefing.staleNoProviderHint")} + + + {t("insights.dailyBriefing.noProviderAction")} + +

+ )} +
)}
) : noProvider ? ( diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 0e2f1ba6d..63557622a 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { Sparkles } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; @@ -51,6 +52,15 @@ interface HeroStripProps { * Defaults to `new Date()`. Production callers omit this. */ now?: Date; + /** + * v1.18.9 (#4) — true when the hero subtitle is drawn from a cached + * briefing that can never refresh because no AI provider is connected. + * The meta row then appends a discreet "connect a provider" link beside + * the "Generated " age, so the score-area context reads as + * honestly held rather than current. Consistent with the DailyBriefing + * footer hint below. + */ + noProviderStale?: boolean; /** * v1.4.20 phase B5 — Personal Health Score panel data. When supplied * the right side of the hero band paints the score card. When the @@ -108,6 +118,7 @@ export function HeroStrip({ now, healthScore, healthScorePending = false, + noProviderStale = false, }: HeroStripProps) { const { t } = useTranslations(); // v1.18.0 R4 — mood-module state so the score card hides its Mood row @@ -205,6 +216,32 @@ export function HeroStrip({ )} + {/* v1.18.9 (#4) — the subtitle is a cached briefing that can + never refresh (no AI provider). Append a discreet hint + + Settings → AI link so the relative age above reads as + intentionally held, consistent with the briefing card. */} + {noProviderStale && ( + <> + + + + {t("insights.dailyBriefing.staleNoProviderHint")} + + + {t("insights.dailyBriefing.noProviderAction")} + + + + )}
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 3ccf5e425..64dd690d2 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -61,6 +61,16 @@ export interface InsightAdvisorPayload { * instead of waiting for the next mount. */ revalidating?: boolean; + /** + * v1.18.9 (#4) — false when no AI provider is configured anywhere. The + * read path serves the last cached briefing regardless (no provider is + * needed to READ the cache), so a provider-less account keeps seeing a + * days-old briefing presented as current. When this is false the + * surfaces pair the briefing's honest relative age with a discreet + * "connect a provider" affordance. Undefined on a cached payload that + * predates the field — treated as "provider present" (no hint). + */ + hasProvider?: boolean; } /** @@ -259,6 +269,16 @@ export interface UseInsightsAdvisorResult { * regenerate CTA. */ readOutcome: AdvisorFetchOutcome | null; + /** + * v1.18.9 (#4) — false when the GET reported no usable AI provider. The + * read path still serves the last cached briefing (no provider is + * needed to read the cache), so this is the ONLY honest signal that a + * shown-but-stale briefing can never refresh. Surfaces pair it with the + * relative-age line to add a discreet connect-provider hint. Defaults + * true (no hint) when the field is absent — a pre-field cached payload + * or an unsettled query. + */ + hasProvider: boolean; } /** @@ -331,5 +351,9 @@ export function useInsightsAdvisorQuery( regenerateError: (mutation.error as Error | null) ?? null, regenerateOutcome: mutation.data?.outcome ?? null, readOutcome: query.data?.outcome ?? null, + // Absent field (pre-v1.18.9 cached payload, or query still settling) → + // assume a provider is present so a transient unknown never flashes a + // false "no provider" hint. + hasProvider: query.data?.payload?.hasProvider ?? true, }; } From 88ee5bb4f81bc1c25a591db8c7a9cadfa88144f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:05:31 +0200 Subject: [PATCH 13/35] feat(coach): word-fade stream, thinking disclosure, token footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream the assistant turn word-by-word with a soft motion-safe fade (StreamedProse), reduced-motion collapses to instant. Replace the dots-plus-word placeholder with a single thinking indicator: a live elapsed-time line that freezes into an auto-collapsed past-tense disclosure, which surfaces a reasoning summary when one is present and a factual fallback otherwise — never both a spinner and a Thinking word at once. Add a quiet per-message token footer (count plus model, no cost) fed by the live done.usage frame and persisted tokensUsed on reload. Add the localised strings across all six bundles. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../__tests__/message-thread.test.tsx | 7 +- .../insights/coach-panel/message-thread.tsx | 142 +++++++++++++----- .../coach-panel/message-token-footer.tsx | 37 +++++ .../insights/coach-panel/streamed-prose.tsx | 96 ++++++++++++ .../coach-panel/thinking-disclosure.tsx | 138 +++++++++++++++++ 11 files changed, 438 insertions(+), 42 deletions(-) create mode 100644 src/components/insights/coach-panel/message-token-footer.tsx create mode 100644 src/components/insights/coach-panel/streamed-prose.tsx create mode 100644 src/components/insights/coach-panel/thinking-disclosure.tsx diff --git a/messages/de.json b/messages/de.json index 535b44c94..6c931e2e5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,7 +2268,15 @@ "dictate": "Diktieren", "dictateStop": "Diktat beenden", "showConversations": "Unterhaltungen anzeigen", - "hideConversations": "Unterhaltungen ausblenden" + "hideConversations": "Unterhaltungen ausblenden", + "heroGreeting": "Wie kann ich dir helfen?", + "heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Nachgedacht für {seconds}s", + "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", + "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", + "tokensUsed": "{count} Tokens", + "tokensUsedWithModel": "{count} Tokens · {model}" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index b2372522b..838014b3b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,7 +2268,15 @@ "dictate": "Dictate", "dictateStop": "Stop dictation", "showConversations": "Show conversations", - "hideConversations": "Hide conversations" + "hideConversations": "Hide conversations", + "heroGreeting": "How can I help you?", + "heroSubline": "Ask about your trends, medications, or readings.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Thought for {seconds}s", + "thinkingExpandAria": "Show what the Coach considered", + "thinkingDetail": "Answered from your health snapshot.", + "tokensUsed": "{count} tokens", + "tokensUsedWithModel": "{count} tokens · {model}" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index be2580372..f3837a403 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,7 +2268,15 @@ "dictate": "Dictar", "dictateStop": "Detener el dictado", "showConversations": "Mostrar conversaciones", - "hideConversations": "Ocultar conversaciones" + "hideConversations": "Ocultar conversaciones", + "heroGreeting": "¿En qué puedo ayudarte?", + "heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Pensó durante {seconds}s", + "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta", + "thinkingDetail": "Respondido a partir de tu resumen de salud.", + "tokensUsed": "{count} tokens", + "tokensUsedWithModel": "{count} tokens · {model}" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index 4cbe3b34f..dc8e2b96d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,7 +2268,15 @@ "dictate": "Dicter", "dictateStop": "Arrêter la dictée", "showConversations": "Afficher les conversations", - "hideConversations": "Masquer les conversations" + "hideConversations": "Masquer les conversations", + "heroGreeting": "Comment puis-je t'aider ?", + "heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "A réfléchi pendant {seconds}s", + "thinkingExpandAria": "Montrer ce que le Coach a pris en compte", + "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", + "tokensUsed": "{count} jetons", + "tokensUsedWithModel": "{count} jetons · {model}" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index 5866e29ba..c76899edb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,7 +2268,15 @@ "dictate": "Detta", "dictateStop": "Interrompi dettatura", "showConversations": "Mostra conversazioni", - "hideConversations": "Nascondi conversazioni" + "hideConversations": "Nascondi conversazioni", + "heroGreeting": "Come posso aiutarti?", + "heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Ha riflettuto per {seconds}s", + "thinkingExpandAria": "Mostra cosa ha considerato il Coach", + "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", + "tokensUsed": "{count} token", + "tokensUsedWithModel": "{count} token · {model}" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/pl.json b/messages/pl.json index 1acef823f..5c14f1f0e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,7 +2268,15 @@ "dictate": "Dyktuj", "dictateStop": "Zatrzymaj dyktowanie", "showConversations": "Pokaż rozmowy", - "hideConversations": "Ukryj rozmowy" + "hideConversations": "Ukryj rozmowy", + "heroGreeting": "W czym mogę pomóc?", + "heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Myślał przez {seconds}s", + "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", + "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", + "tokensUsed": "{count} tokenów", + "tokensUsedWithModel": "{count} tokenów · {model}" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 1e650e5db..aa20016d2 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -187,7 +187,12 @@ describe("", () => { ).length; expect(userBubbles).toBe(1); expect(assistantBubbles).toBe(2); - expect(html).toContain("Looking at your data"); + // v1.18.9 — the live streaming turn renders each word in its own + // fade-in (``), so the prose is no longer one + // contiguous text node; assert each word is present span-wrapped. + for (const word of ["Looking ", "at ", "your ", "data"]) { + expect(html).toContain(`>${word}`); + } }); it("shows the streaming-only bubble when no conversation is loaded yet", () => { diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 65d26a33b..add50de79 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -20,12 +20,14 @@ import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; +import { StreamedProse } from "./streamed-prose"; +import { ThinkingDisclosure } from "./thinking-disclosure"; +import { MessageTokenFooter } from "./message-token-footer"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, @@ -422,6 +424,8 @@ export function MessageThread({ metricSource={m.metricSource} providerType={m.providerType} messageId={m.id} + tokensUsed={m.tokensUsed} + model={m.model} /> ); @@ -452,8 +456,10 @@ export function MessageThread({ aria-live="polite" aria-relevant="additions text" // v1.18.7 — a soft fade/slide-in on the assistant turn so the - // hand-off from the "Thinking…" beat to the first streamed - // tokens reads as one continuous motion, not a hard swap. + // hand-off from the thinking beat to the first streamed tokens + // reads as one continuous motion, not a hard swap. v1.18.9 — the + // per-word fade now lives in ; this stays a light + // container fade for the disclosure → prose transition. className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300" >
)} @@ -495,6 +507,35 @@ interface ChatBubbleProps { * user can't rate before the message lands on disk. */ messageId?: string; + /** + * v1.18.9 — true for the live streaming assistant turn. Drives the + * word-by-word prose fade (``); persisted bubbles render + * settled plain text. + */ + streaming?: boolean; + /** + * v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn, + * feeding the thinking disclosure's elapsed timer. Null on persisted + * bubbles. + */ + startedAt?: number | null; + /** + * v1.18.9 — reasoning-summary text from the additive `reasoning` SSE + * frame, shown inside the thinking disclosure when present. + */ + reasoning?: string; + /** + * v1.18.9 — per-turn token usage for the just-finished streaming bubble + * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model` + * instead. + */ + usage?: import("./use-coach").CoachUsage | null; + /** + * v1.18.9 — persisted per-message token count + model, for the quiet + * token footer on reload. Null on user turns, refusals, and older rows. + */ + tokensUsed?: number | null; + model?: string | null; } function ChatBubble({ @@ -506,6 +547,12 @@ function ChatBubble({ inProgress, errorCode, messageId, + streaming, + startedAt, + reasoning, + usage, + tokensUsed, + model, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -631,38 +678,53 @@ function ChatBubble({ )}
-
- {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from - the assistant prose before it lands in the bubble. The - raw `content` is the streamed Coach reply (or the - persisted twin after `done` fires); both paths are AI- - authored so they share the same leak surface as the - insight prose elsewhere. - - v1.16.1 — before the first token lands, the bubble shows - a classic typing indicator (three pulsing dots) instead - of the old whole-bubble pulse + "Thinking…" prose. The - prose stays as the screen-reader announcement. */} - {/* v1.16.4 — a failed turn with no streamed prose renders the - error copy INSIDE the bubble instead of an empty bubble - with a caption below it. With partial prose the prose - keeps the bubble and the error stays a caption. */} - {content ? ( - stripChartTokens(content) - ) : inProgress ? ( - - ) : safeError ? ( - {safeError} - ) : ( - "" - )} -
+ {/* v1.18.9 — the single thinking indicator (live elapsed-time line, + then an auto-collapsed past-tense disclosure). Renders only for + the live streaming turn — persisted bubbles pass no `startedAt`, + so the disclosure returns null and history stays settled. This + replaces the old dots-plus-word combo: there is + never a three-dots indicator AND a "Thinking" word at once. */} + {streaming && startedAt != null && ( + + )} + {/* The prose bubble. Skipped while the live turn is still thinking + with no prose (the disclosure carries the live state above), so + the user never sees an empty bubble under the indicator. The + `` fallback below only fires when there is no live + disclosure (e.g. a defensive null `startedAt`). */} + {(content || + safeError || + (inProgress && !(streaming && startedAt != null))) && ( +
+ {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the + assistant prose. v1.18.9 — the live turn streams in + word-by-word with a soft fade (); a settled + / persisted turn renders as plain text. */} + {/* v1.16.4 — a failed turn with no streamed prose renders the + error copy INSIDE the bubble; with partial prose the prose + keeps the bubble and the error stays a caption. */} + {content ? ( + + ) : inProgress ? ( + + ) : safeError ? ( + {safeError} + ) : ( + "" + )} +
+ )} {safeError && content && (

{safeError}

)} @@ -784,6 +846,16 @@ function ChatBubble({ const sug = suggestion ?? metricSource?.suggestion ?? null; return sug ? : null; })()} + {/* v1.18.9 — quiet per-message token footer. The just-finished + streaming turn reads the `done.usage` envelope; a persisted / + reloaded turn reads the message's own `tokensUsed` + `model`. + Skipped on in-flight, errored, and refusal turns. */} + {!inProgress && !errorCode && providerType !== "refusal" && ( + + )} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted assistant messages get the row (skipped for refusals, errors, in-flight stream bubbles). The aggregator buckets diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx new file mode 100644 index 000000000..f6b17fe6c --- /dev/null +++ b/src/components/insights/coach-panel/message-token-footer.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.18.9 — quiet per-message token-usage caption under an assistant + * bubble. Server-authoritative: it renders the count the server reported + * (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed` + * on reload), never a client-side recomputation. No cost — BYOK users pay + * the provider directly and a price table would only drift. + * + * Renders nothing when there is no count (user turns, refusals, in-flight + * bubbles, and pre-feature persisted rows all carry null). + */ +export interface MessageTokenFooterProps { + tokens: number | null | undefined; + /** Provider model name (e.g. `gpt-4o`); appended when present. */ + model?: string | null; +} + +export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) { + const { t } = useTranslations(); + if (tokens == null) return null; + + const label = model + ? t("insights.coach.tokensUsedWithModel", { count: tokens, model }) + : t("insights.coach.tokensUsed", { count: tokens }); + + return ( +

+ {label} +

+ ); +} diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx new file mode 100644 index 000000000..370d70a43 --- /dev/null +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; + +/** + * v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn. + * + * The provider clients return the whole reply in one shot; the chat route + * re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`), + * so `content` grows as frames land. This component renders each + * whitespace segment as its own `` that fades + drifts up on mount, + * giving the reply a "thinking out loud" cadence rather than a hard dump + * of word groups. Once the turn settles (`streaming === false`) the spans + * collapse to a single plain-text node — zero residual animation DOM, and + * the feedback / evidence layout underneath stays stable. + * + * Why no "only animate the new words" bookkeeping: each segment is keyed + * by its index, so when a fresh SSE frame appends words, React keeps the + * already-mounted spans (their CSS entrance already ran and does not + * replay on reconcile) and only MOUNTS the new trailing spans — which is + * exactly when the `animate-in` entrance fires. The browser does the + * "new words only" gating for us; no render-time ref is needed. + * + * CSP-safe: the entrance is Tailwind's `animate-in fade-in-0 + * slide-in-from-bottom-1` (compiled utilities served from `'self'`) plus a + * per-span inline `animationDelay` / `animationDuration`. Inline `style` + * attributes are already permitted under the strict CSP + * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected + * `