Merge branch 'feat/biomarker-detail-page' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:42:04 +02:00
commit 95ee088646
36 files changed
+2209 -147

No files matched your search

+102
View File
@@ -6052,6 +6052,47 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/insights/biomarker-assessment:
get:
tags:
- Insights
summary: Per-biomarker assessment
description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history.
Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a
cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the
assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient`
without an LLM call. Auth via cookie or Bearer."
parameters:
- in: query
name: biomarkerId
schema:
type: string
minLength: 1
maxLength: 64
description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a
404 (existence sealed).
required: true
description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a
404 (existence sealed).
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
content:
application/json:
schema:
$ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/insights/derived:
get:
tags:
@@ -20286,6 +20327,67 @@ components:
description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the
`InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation
out of band and serves the last-good text meanwhile."
BiomarkerAssessmentResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/BiomarkerAssessmentResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
BiomarkerAssessmentResponse:
type: object
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
text:
anyOf:
- type: string
- type: "null"
description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is
preparing, or when the marker has no numeric readings.
cached:
type: boolean
description: True when `text` is served from cache (incl. last-good).
updatedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: When the served assessment was generated; null when none.
preparing:
description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls
until it lands.
type: boolean
revalidating:
description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in
flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the
warmed assessment without a remount.
type: boolean
insufficient:
description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not
rendered.
type: boolean
required:
- hasProvider
- text
- cached
- updatedAt
additionalProperties: false
description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the
`InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation
out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands."
DerivedMetricResponseEnvelope:
type: object
properties:
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
}
},
"assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "Noch kein Ruhepuls",
"description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat."
}
},
"coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?"
},
"oxygenSaturation": {
"title": "Sauerstoffsättigung",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarker gelöscht",
"deleteError": "Biomarker konnte nicht gelöscht werden",
"deleteConfirmTitle": "Biomarker löschen?",
"deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.",
"deleteConfirmDescription": "Der Biomarker und alle erfassten Werte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.",
"emptyTitle": "Noch keine Biomarker",
"emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.",
"loadError": "Biomarker konnten nicht geladen werden",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarker wiederhergestellt",
"hideError": "Biomarker konnte nicht aktualisiert werden",
"allHidden": "Alle Biomarker sind ausgeblendet.",
"hiddenSection": "Ausgeblendet"
"hiddenSection": "Ausgeblendet",
"targetRange": {
"title": "Zielbereich anpassen",
"description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.",
"save": "Bereich speichern",
"savedToast": "Zielbereich aktualisiert",
"saveError": "Zielbereich konnte nicht aktualisiert werden"
}
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.",
"correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
"recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
}
},
"assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "No resting heart rate yet",
"description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day."
}
},
"coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
},
"oxygenSaturation": {
"title": "Oxygen saturation",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarker deleted",
"deleteError": "Could not delete the biomarker",
"deleteConfirmTitle": "Delete biomarker?",
"deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.",
"deleteConfirmDescription": "The biomarker and all of its recorded values are permanently deleted. This cannot be undone.",
"emptyTitle": "No biomarkers yet",
"emptyDescription": "Define markers like LDL or HbA1c to record values against them.",
"loadError": "Could not load biomarkers",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarker restored",
"hideError": "Could not update the biomarker",
"allHidden": "Every biomarker is hidden.",
"hiddenSection": "Hidden"
"hiddenSection": "Hidden",
"targetRange": {
"title": "Adjust target range",
"description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.",
"save": "Save range",
"savedToast": "Target range updated",
"saveError": "Could not update the target range"
}
},
"catalog": {
"total-cholesterol": "Total cholesterol",
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.",
"correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?",
"recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?"
}
},
"assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "Aún no hay FC en reposo",
"description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo."
}
},
"coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?"
},
"oxygenSaturation": {
"title": "Saturación de oxígeno",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarcador eliminado",
"deleteError": "No se pudo eliminar el biomarcador",
"deleteConfirmTitle": "¿Eliminar biomarcador?",
"deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.",
"deleteConfirmDescription": "El biomarcador y todos sus valores registrados se eliminan de forma permanente. Esta acción no se puede deshacer.",
"emptyTitle": "Aún no hay biomarcadores",
"emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.",
"loadError": "No se pudieron cargar los biomarcadores",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarcador restaurado",
"hideError": "No se pudo actualizar el biomarcador",
"allHidden": "Todos los biomarcadores están ocultos.",
"hiddenSection": "Ocultos"
"hiddenSection": "Ocultos",
"targetRange": {
"title": "Ajustar el rango objetivo",
"description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.",
"save": "Guardar rango",
"savedToast": "Rango objetivo actualizado",
"saveError": "No se pudo actualizar el rango objetivo"
}
},
"catalog": {
"total-cholesterol": "Colesterol total",
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.",
"correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?",
"recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?"
}
},
"assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "Aucune FC au repos",
"description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme."
}
},
"coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?"
},
"oxygenSaturation": {
"title": "Saturation en oxygène",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarqueur supprimé",
"deleteError": "Impossible de supprimer le biomarqueur",
"deleteConfirmTitle": "Supprimer le biomarqueur ?",
"deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.",
"deleteConfirmDescription": "Le biomarqueur et toutes ses valeurs enregistrées sont définitivement supprimés. Cette action est irréversible.",
"emptyTitle": "Aucun biomarqueur pour l'instant",
"emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.",
"loadError": "Impossible de charger les biomarqueurs",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarqueur restauré",
"hideError": "Impossible de mettre à jour le biomarqueur",
"allHidden": "Tous les biomarqueurs sont masqués.",
"hiddenSection": "Masqués"
"hiddenSection": "Masqués",
"targetRange": {
"title": "Ajuster la plage cible",
"description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.",
"save": "Enregistrer la plage",
"savedToast": "Plage cible mise à jour",
"saveError": "Impossible de mettre à jour la plage cible"
}
},
"catalog": {
"total-cholesterol": "Cholestérol total",
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.",
"correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?",
"recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?"
}
},
"assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "Ancora nessuna FC a riposo",
"description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla."
}
},
"coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?"
},
"oxygenSaturation": {
"title": "Saturazione di ossigeno",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarcatore eliminato",
"deleteError": "Impossibile eliminare il biomarcatore",
"deleteConfirmTitle": "Eliminare il biomarcatore?",
"deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.",
"deleteConfirmDescription": "Il biomarcatore e tutti i suoi valori registrati vengono eliminati definitivamente. L'operazione non è reversibile.",
"emptyTitle": "Ancora nessun biomarcatore",
"emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.",
"loadError": "Impossibile caricare i biomarcatori",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarcatore ripristinato",
"hideError": "Impossibile aggiornare il biomarcatore",
"allHidden": "Tutti i biomarcatori sono nascosti.",
"hiddenSection": "Nascosti"
"hiddenSection": "Nascosti",
"targetRange": {
"title": "Regola l'intervallo obiettivo",
"description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.",
"save": "Salva intervallo",
"savedToast": "Intervallo obiettivo aggiornato",
"saveError": "Impossibile aggiornare l'intervallo obiettivo"
}
},
"catalog": {
"total-cholesterol": "Colesterolo totale",
+13 -4
View File
@@ -2387,7 +2387,8 @@
"correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.",
"correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?",
"recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?"
}
},
"assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -2459,7 +2460,8 @@
"emptyState": {
"title": "Brak tętna spoczynkowego",
"description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku."
}
},
"coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?"
},
"oxygenSaturation": {
"title": "Saturacja tlenu",
@@ -6991,7 +6993,7 @@
"deletedToast": "Biomarker usunięty",
"deleteError": "Nie udało się usunąć biomarkera",
"deleteConfirmTitle": "Usunąć biomarker?",
"deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.",
"deleteConfirmDescription": "Biomarker i wszystkie jego zapisane wartości zostaną trwale usunięte. Tej operacji nie można cofnąć.",
"emptyTitle": "Brak biomarkerów",
"emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.",
"loadError": "Nie udało się wczytać biomarkerów",
@@ -7026,7 +7028,14 @@
"unhiddenToast": "Biomarker przywrócony",
"hideError": "Nie udało się zaktualizować biomarkera",
"allHidden": "Wszystkie biomarkery są ukryte.",
"hiddenSection": "Ukryte"
"hiddenSection": "Ukryte",
"targetRange": {
"title": "Dostosuj zakres docelowy",
"description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.",
"save": "Zapisz zakres",
"savedToast": "Zaktualizowano zakres docelowy",
"saveError": "Nie udało się zaktualizować zakresu docelowego"
}
},
"catalog": {
"total-cholesterol": "Cholesterol całkowity",
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() },
labResult: { deleteMany: vi.fn() },
auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/labs/biomarker-store", () => ({
encryptContextToBytes: vi.fn(() => new Uint8Array([1])),
decryptContextFromBytes: vi.fn(() => "ctx"),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT, DELETE } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const EXISTING = {
id: "bm-1",
userId: "user-1",
name: "LDL",
unit: "mg/dL",
lowerBound: 0,
upperBound: 100,
panel: null,
hidden: false,
contextEncrypted: null,
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
};
const params = (id: string) => ({ params: Promise.resolve({ id }) });
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/biomarkers/bm-1", {
method: "PUT",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
function delReq(): NextRequest {
return new NextRequest("http://localhost/api/biomarkers/bm-1", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation((async (
ops: Promise<unknown>[],
) => Promise.all(ops)) as never);
});
describe("DELETE /api/biomarkers/[id]", () => {
it("drops the marker and its readings in one userId-narrowed transaction", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({
count: 3,
} as never);
vi.mocked(prisma.biomarker.delete).mockResolvedValue(EXISTING as never);
const res = await DELETE(delReq(), params("bm-1"));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } | null };
expect(body.data?.deleted).toBe(true);
expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1", biomarkerId: "bm-1" },
});
expect(prisma.biomarker.delete).toHaveBeenCalledWith({
where: { id: "bm-1" },
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it("404s a cross-user marker without deleting anything", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({
...EXISTING,
userId: "other-user",
} as never);
const res = await DELETE(delReq(), params("bm-1"));
expect(res.status).toBe(404);
expect(prisma.labResult.deleteMany).not.toHaveBeenCalled();
expect(prisma.biomarker.delete).not.toHaveBeenCalled();
});
});
describe("PUT /api/biomarkers/[id] — target range", () => {
it("persists an updated reference window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
vi.mocked(prisma.biomarker.update).mockResolvedValue({
...EXISTING,
lowerBound: 10,
upperBound: 50,
} as never);
const res = await PUT(
putReq({ lowerBound: 10, upperBound: 50 }),
params("bm-1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { lowerBound: number; upperBound: number } | null;
};
expect(body.data?.lowerBound).toBe(10);
expect(body.data?.upperBound).toBe(50);
const updateCall = vi.mocked(prisma.biomarker.update).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(updateCall.data.lowerBound).toBe(10);
expect(updateCall.data.upperBound).toBe(50);
});
it("422s an inverted target range without updating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
const res = await PUT(
putReq({ lowerBound: 80, upperBound: 20 }),
params("bm-1"),
);
expect(res.status).toBe(422);
expect(prisma.biomarker.update).not.toHaveBeenCalled();
});
});
+15 -8
View File
@@ -24,10 +24,10 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
*
* PUT applies a partial edit (`data` built field-by-field; an explicit `null`
* clears `context` / `panel` / a bound, an omitted key leaves it untouched).
* DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on
* `LabResult.biomarkerId` unlinks every reading without losing it (the row
* keeps its legacy `analyte` / `unit` historical fields). Cross-user rows
* surface as 404 (existence sealed).
* DELETE hard-deletes the catalog definition together with every reading it
* owns, in one userId-narrowed transaction — removing a biomarker is a
* deliberate "drop this and its values" action. Cross-user rows surface as
* 404 (existence sealed).
*/
type RouteParams = { params: Promise<{ id: string }> };
@@ -187,10 +187,17 @@ export const DELETE = apiHandler(
return apiError("Biomarker not found", 404);
}
// Hard delete — the `onDelete: SetNull` FK unlinks every reading rather
// than cascading it away. The readings keep their legacy `analyte` /
// `unit` so the history survives the catalog edit.
await prisma.biomarker.delete({ where: { id } });
// Hard delete the marker AND its readings in one transaction. Deleting a
// biomarker is a deliberate "remove this and its history" action, so the
// readings go with it rather than lingering as orphaned, unlinked rows.
// Both legs are userId-narrowed so a cross-user id can never reach another
// account's data.
await prisma.$transaction([
prisma.labResult.deleteMany({
where: { userId: user.id, biomarkerId: id },
}),
prisma.biomarker.delete({ where: { id } }),
]);
await auditLog("biomarker.delete", {
userId: user.id,
@@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest";
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
// Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as
// the metric-status + specialised status routes (no Coach prose).
"src/app/api/insights/biomarker-assessment/route.ts",
"src/app/api/insights/blood-pressure-status/route.ts",
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/cards/route.ts",
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
// The route resolves the `insights` module gate after `requireAuth()`. Mock
// it default-enabled so the assertions ride through; the off → 403 coverage
// lives in the route-gate inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the heavy generator so the route test stays isolated from the real
// status pipeline.
vi.mock("@/lib/insights/biomarker-status", () => ({
generateBiomarkerStatus: vi.fn(async () => ({
hasProvider: true,
text: "ok",
cached: true,
updatedAt: new Date().toISOString(),
})),
}));
vi.mock("@/lib/insights/metric-status", () => ({
resolveMetricStatusLocale: () => "en",
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(biomarkerId?: string): NextRequest {
const url = new URL("http://localhost/api/insights/biomarker-assessment");
if (biomarkerId !== undefined)
url.searchParams.set("biomarkerId", biomarkerId);
return new NextRequest(url);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("GET /api/insights/biomarker-assessment", () => {
it("returns 200 + envelope and calls the generator read-only", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { text: string; hasProvider: boolean } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.text).toBe("ok");
expect(generateBiomarkerStatus).toHaveBeenCalledWith(
expect.objectContaining({
biomarkerId: "bm-1",
userId: "user-1",
readOnly: true,
}),
);
});
it("422s on a missing biomarkerId without calling the generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(generateBiomarkerStatus).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
});
@@ -0,0 +1,72 @@
/**
* Per-biomarker assessment route.
*
* `GET /api/insights/biomarker-assessment?biomarkerId=<id>` serves the
* data-driven assessment for one user-scoped biomarker. The shape is
* byte-identical to `/api/insights/metric-status` so `InsightStatusCard`
* consumes it unchanged.
*
* Read-only by construction: a cache miss enqueues an out-of-band
* generation (via `resolveReadOnlyStatusMiss` inside the generator) and
* serves the last-good text (stale-while-revalidate), never blocking on the
* provider. The generator regenerates ONLY when the latest reading
* fingerprint changes, so an idle marker re-stamps cached text without an
* LLM round-trip.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
export const dynamic = "force-dynamic";
const querySchema = z.object({
biomarkerId: z.string().min(1).max(64),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = querySchema.safeParse({
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.biomarker-status.invalid" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMetricStatusLocale(resolved);
const result = await generateBiomarkerStatus({
biomarkerId: parsed.data.biomarkerId,
userId: user.id,
locale,
force: false,
readOnly: true,
});
annotate({
action: { name: "insights.biomarker-status" },
meta: { biomarkerId: parsed.data.biomarkerId },
});
return apiSuccess(result);
});
+3 -1
View File
@@ -3,6 +3,7 @@
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.32 — `/insights/resting-pulse`.
@@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* trend that's not muddied by mid-walk readings.
*/
export default function InsightsRestingHrPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
@@ -27,7 +29,7 @@ export default function InsightsRestingHrPage() {
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
coachPrefill={t("insights.restingHr.coachPrefill")}
/>
);
}
+8 -1
View File
@@ -44,6 +44,12 @@ export interface AskCoachActionProps {
* briefing), which read better against the default all-source snapshot.
*/
scope?: CoachLaunchScope;
/**
* Auto-send the seeded question as the conversation's first turn instead
* of only seeding the composer. The assessment hand-off sets this so the
* Coach answers immediately; defaults to false (seed-only).
*/
autoSend?: boolean;
/** Optional visible-label override; defaults to the shared CTA copy. */
label?: string;
/** className passthrough for per-card alignment. */
@@ -53,6 +59,7 @@ export interface AskCoachActionProps {
export function AskCoachAction({
question,
scope,
autoSend,
label,
className,
}: AskCoachActionProps) {
@@ -76,7 +83,7 @@ export function AskCoachAction({
variant="ghost"
size="sm"
data-slot="ask-coach-action"
onClick={() => launch.askCoach(question, scope)}
onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
// Discreet: muted text, hover lifts to foreground, no fill. Tight
// height so it sits as a quiet footer link, not a primary CTA.
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useReducer, useState } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -117,6 +117,13 @@ export interface CoachConversationProps {
* established scope. Null → the route's default all-source snapshot.
*/
launchScope?: CoachLaunchScope | null;
/**
* When true, the `prefill` is dispatched as the conversation's first turn
* automatically, exactly once on mount (ref-guarded). Used by the
* assessment hand-off so the answer lands without a manual send. The send
* only fires for a fresh conversation with a non-empty prefill.
*/
autoSend?: boolean;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
@@ -188,6 +195,7 @@ export interface CoachConversationProps {
export function CoachConversation({
prefill,
launchScope,
autoSend,
renderTitle,
renderDescription,
leadingHeaderActions,
@@ -340,6 +348,33 @@ export function CoachConversation({
}
}
// Auto-send the prefill as the conversation's first turn, exactly once.
// A card hand-off (e.g. the assessment "ask about this") opens the Coach
// with `autoSend` so the answer lands without a manual send. Ref-guarded so
// it fires a single time per mount; only for a fresh conversation with a
// non-empty prefill and no stream in flight.
const autoSentRef = useRef(false);
// Latest-handler ref so the auto-send effect never lists `handleSubmit` (it
// re-creates each render) and never calls it synchronously in the effect
// body — the dispatch is deferred to a microtask so the composer state it
// touches settles outside the effect.
const handleSubmitRef = useRef(handleSubmit);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
});
useEffect(() => {
if (!autoSend) return;
if (autoSentRef.current) return;
if (currentConversationId !== null) return;
if (send.isStreaming) return;
const seed = (prefill ?? "").trim();
if (!seed) return;
autoSentRef.current = true;
queueMicrotask(() => {
void handleSubmitRef.current(seed);
});
}, [autoSend, prefill, currentConversationId, send.isStreaming]);
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
@@ -54,6 +54,11 @@ export interface CoachDrawerProps {
* source(s) + window. Null → the route's default all-source snapshot.
*/
scope?: CoachLaunchScope | null;
/**
* When true, the conversation auto-sends `prefill` as its first turn
* exactly once on open. Used by the assessment hand-off.
*/
autoSend?: boolean;
}
export function CoachDrawer({
@@ -61,6 +66,7 @@ export function CoachDrawer({
onOpenChange,
prefill,
scope,
autoSend,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
@@ -139,6 +145,7 @@ export function CoachDrawer({
surface="drawer"
prefill={prefill}
launchScope={scope}
autoSend={autoSend}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to a
@@ -69,6 +69,11 @@ interface InsightStatusCardProps {
*/
coachQuestion?: string;
coachScope?: CoachLaunchScope;
/**
* Auto-send the seeded opener as the first turn (assessment hand-off)
* instead of only seeding the composer. Defaults to false.
*/
coachAutoSend?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -83,6 +88,7 @@ export function InsightStatusCard({
preparing = false,
coachQuestion,
coachScope,
coachAutoSend,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -248,7 +254,11 @@ export function InsightStatusCard({
caller supplied an opener. */}
{coachQuestion ? (
<div className="flex justify-end">
<AskCoachAction question={coachQuestion} scope={coachScope} />
<AskCoachAction
question={coachQuestion}
scope={coachScope}
autoSend={coachAutoSend}
/>
</div>
) : null}
</CardContent>
@@ -56,6 +56,9 @@ export function LayoutCoachMount() {
// opened from a metric surface or insight card is pre-narrowed to
// the relevant source(s).
scope={launch.scope}
// Auto-send the prefill as the first turn when the launch requested it
// (assessment hand-off), so the answer lands without a manual send.
autoSend={launch.autoSend}
/>
);
}
@@ -46,12 +46,9 @@ export function MetricStatusCard({
// carry a snapshot block. Unmapped metrics still get a metric-named
// opener, just against the default snapshot.
const coachSource = scopeSourceFromMetricKey(metric);
const coachQuestion = `Walk me through my ${metric
.toLowerCase()
.replace(
/_/g,
" ",
)} assessment — what does it mean and what should I do about it?`;
const coachQuestion = t("insights.coach.assessmentPrompt", {
metric: metric.toLowerCase().replace(/_/g, " "),
});
return (
<InsightStatusCard
@@ -62,6 +59,7 @@ export function MetricStatusCard({
updatedAt={status?.updatedAt ?? null}
coachQuestion={coachQuestion}
coachScope={coachSource ? { metric: coachSource } : undefined}
coachAutoSend
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
@@ -0,0 +1,142 @@
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";
import { queryKeys } from "@/lib/query-keys";
import type {
BiomarkerDto,
LabResultDto,
LabResultListResponse,
} from "../types";
/**
* The per-biomarker detail page mirrors the metric sub-pages: a numbers-first
* stat strip over the numeric readings, the (lazy) chart, the history, and the
* AI assessment card. This test seeds the marker + reading queries and asserts
* the strip, the chart placeholder, and the assessment card all paint.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ insightStatus: true }),
}));
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
const BIOMARKER_ID = "bm-1";
const MARKER: BiomarkerDto = {
id: BIOMARKER_ID,
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: 0,
upperBound: 100,
panel: null,
hasContext: false,
context: null,
hidden: false,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
};
function reading(id: string, value: number, takenAt: string): LabResultDto {
return {
id,
biomarkerId: BIOMARKER_ID,
panel: null,
analyte: "LDL Cholesterol",
value,
valueText: null,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
takenAt,
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: takenAt,
updatedAt: takenAt,
};
}
const LIST: LabResultListResponse = {
results: [
reading("r3", 95, "2026-06-20T08:00:00.000Z"),
reading("r2", 88, "2026-05-20T08:00:00.000Z"),
reading("r1", 102, "2026-04-20T08:00:00.000Z"),
],
meta: { total: 3, limit: 500, offset: 0 },
};
function renderWithCache() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER);
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: BIOMARKER_ID,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
LIST,
);
queryClient.setQueryData(
queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"),
{
hasProvider: true,
text: "Your LDL is steady inside the reference range.",
cached: true,
updatedAt: "2026-06-20T09:00:00.000Z",
},
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabBiomarkerDetail biomarkerId={BIOMARKER_ID} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabBiomarkerDetail>", () => {
const html = renderWithCache();
it("paints the marker heading", () => {
expect(html).toContain("LDL Cholesterol");
});
it("paints the numbers-first stat strip", () => {
expect(html).toContain('data-slot="metric-stat-strip"');
});
it("paints the chart placeholder (lazy chart shell)", () => {
// The recharts chart is `next/dynamic({ ssr: false })`; the server render
// paints the layout-stable skeleton shell in its place.
expect(html).toContain('data-slot="skeleton"');
});
it("paints the assessment card", () => {
// useMounted() is false during SSR, so the card renders its loading
// skeleton geometry — proof the assessment card mounted on the page.
expect(html).toContain('data-testid="insight-status-card-loading"');
});
});
+194 -7
View File
@@ -1,18 +1,26 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { FlaskConical, Pencil, Plus, SlidersHorizontal } 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,11 +73,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
// It belongs on the detail page beneath the heading (mirroring the metric
// pages' explainer caption). Resolve the catalog slug from the marker name
// exactly as the overview did, then fall back to the user's own `context`.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
}
return map;
}, [t]);
const [addOpen, setAddOpen] = useState(false);
// Sticky-footer slot for the add-value sheet (the form portals here).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
// v1.24 — focused "adjust target range" sheet. Edits only the reference
// bounds (the full marker editor lives behind the pencil); seeded from the
// marker each time it opens.
const [rangeOpen, setRangeOpen] = useState(false);
const [lowerInput, setLowerInput] = useState("");
const [upperInput, setUpperInput] = useState("");
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
@@ -96,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
),
});
const readings: LabResultDto[] = list?.results ?? [];
const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]);
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
@@ -108,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
)[0]
: null;
// v1.24 — the numbers-first stat strip + the AI assessment card mirror the
// metric sub-pages. The strip reads a client-side `DataSummary` over the
// numeric readings; the assessment card consumes the read-only biomarker
// route (stale-while-revalidate, regenerated only on a new reading). The
// strip + card both self-gate on data, so a qualitative-only or brand-new
// marker paints neither.
const numericReadings = useMemo(
() => readings.filter((r) => r.value !== null),
[readings],
);
const summary = useMemo<DataSummary | null>(
() =>
numericReadings.length > 0
? summarize(
numericReadings.map((r) => ({
date: new Date(r.takenAt),
value: r.value as number,
})),
)
: null,
[numericReadings],
);
const mounted = useMounted();
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
@@ -138,6 +190,33 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
function openRange() {
setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : "");
setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : "");
setRangeOpen(true);
}
const saveRange = useMutation({
mutationFn: () => {
const lower = lowerInput.trim() === "" ? null : Number(lowerInput);
const upper = upperInput.trim() === "" ? null : Number(upperInput);
return apiPut(`/api/biomarkers/${biomarkerId}`, {
lowerBound: lower,
upperBound: upper,
});
},
onSuccess: () => {
toast.success(t("labs.biomarker.targetRange.savedToast"));
setRangeOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
});
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
@@ -189,6 +268,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={openRange}
disabled={!marker}
aria-label={t("labs.biomarker.targetRange.title")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -223,9 +312,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : null}
{marker?.context ? (
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
{(() => {
const slug = marker?.name
? slugByName.get(marker.name.trim().toLowerCase())
: undefined;
const description = slug
? t(`labs.catalog.desc.${slug}`)
: (marker?.context ?? null);
return description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
) : null;
})()}
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
numeric readings, mirroring the metric sub-pages. Self-gating:
renders nothing for a qualitative-only or empty marker. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="pt-6">
@@ -272,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : null}
{/* AI assessment — the spine's closing block, mirroring the metric
sub-pages (intro → stat strip → chart → history → assessment). The
card self-suppresses when the operator disabled status cards, and
the hook is gated on the marker having numeric readings. */}
{numericReadings.length > 0 ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<FlaskConical className="h-5 w-5" />}
text={assessment?.text ?? null}
hasProvider={assessment?.hasProvider ?? false}
updatedAt={assessment?.updatedAt ?? null}
coachQuestion={
marker?.name
? t("insights.coach.assessmentPrompt", { metric: marker.name })
: undefined
}
coachAutoSend
loading={!mounted || assessmentLoading}
preparing={assessment?.preparing ?? false}
/>
) : null}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
@@ -310,6 +441,62 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
/>
) : null}
</ResponsiveSheet>
<ResponsiveSheet
open={rangeOpen}
onOpenChange={setRangeOpen}
title={t("labs.biomarker.targetRange.title")}
description={t("labs.biomarker.targetRange.description")}
footer={
<div className="flex w-full justify-end gap-2">
<Button
variant="outline"
onClick={() => setRangeOpen(false)}
disabled={saveRange.isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => saveRange.mutate()}
disabled={saveRange.isPending}
>
{t("labs.biomarker.targetRange.save")}
</Button>
</div>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower-bound">
{t("labs.biomarker.form.lowerBound")}
</Label>
<Input
id="biomarker-lower-bound"
type="number"
inputMode="decimal"
value={lowerInput}
onChange={(e) => setLowerInput(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-upper-bound">
{t("labs.biomarker.form.upperBound")}
</Label>
<Input
id="biomarker-upper-bound"
type="number"
inputMode="decimal"
value={upperInput}
onChange={(e) => setUpperInput(e.target.value)}
/>
</div>
</div>
{marker?.unit ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("labs.biomarker.form.rangeHint")}
</p>
) : null}
</ResponsiveSheet>
</div>
);
}
+145 -46
View File
@@ -1,17 +1,26 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { DeleteButton, SortableHead } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import {
ApiError,
apiDelete,
@@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
// The sample-date column opens descending (newest first); every other column
// opens ascending.
const LAB_DESC_COLUMNS = new Set(["takenAt"]);
// Stable ordering for the reference-range column so a sort produces a
// predictable below → in-range → above → unknown progression.
const RANGE_RANK: Record<string, number> = {
below: 0,
"in-range": 1,
above: 2,
unknown: 3,
};
function toDateTimeLocal(iso: string): string {
const d = new Date(iso);
const offset = d.getTimezoneOffset();
@@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
const { sortBy, sortDir, toggleSort } = useTableSort({
defaultColumn: "takenAt",
defaultDir: "desc",
descColumns: LAB_DESC_COLUMNS,
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
// v1.18.9 — true when the row being edited is a qualitative reading; the
@@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}
const ordered = [...readings].sort(
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
);
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...readings].sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case "value": {
// Numeric rows compare by value; qualitative rows (value null) sort
// after the numeric ones and tie-break on their text.
if (a.value !== null && b.value !== null) {
cmp = a.value - b.value;
} else if (a.value === null && b.value === null) {
cmp = (a.valueText ?? "").localeCompare(b.valueText ?? "");
} else {
cmp = a.value === null ? 1 : -1;
}
break;
}
case "range":
cmp =
(RANGE_RANK[a.rangeStatus] ?? 99) -
(RANGE_RANK[b.rangeStatus] ?? 99);
break;
case "note":
cmp = Number(a.hasNote) - Number(b.hasNote);
break;
case "takenAt":
default:
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
break;
}
// Stable secondary ordering by sample date keeps ties deterministic.
if (cmp === 0) {
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
}
return cmp * dir;
});
}, [readings, sortBy, sortDir]);
return (
<>
<ul className="divide-border divide-y">
{ordered.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(r)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
</div>
<p className="text-muted-foreground text-xs">
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
{formatLabReading(r)}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG 2.5.5),
// compact 36px on desktop. The primary edit control for a row.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</li>
))}
</ul>
</TableCell>
<TableCell>
<ReferenceRangeBadge status={r.rangeStatus} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<ResponsiveSheet
open={editingId !== null}
+2 -30
View File
@@ -11,7 +11,6 @@ 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 { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
@@ -81,23 +80,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
// (no catalog match) carry no subtitle rather than a fabricated one.
// v1.22.1 — build the localized name→slug map once per locale instead of
// re-scanning all catalog seeds (calling `t()` each) per group every render.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
}
return map;
}, [t]);
const describeMarker = (analyte: string): string | null => {
const norm = analyte.trim().toLowerCase();
if (!norm) return null;
const slug = slugByName.get(norm);
return slug ? t(`labs.catalog.desc.${slug}`) : null;
};
// v1.24 — the per-marker description moved to the biomarker detail page
// (beneath the heading); the overview rows no longer carry it.
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
@@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Card>
<CardContent className="divide-border divide-y p-0">
{groups.map((group) => {
const description = describeMarker(group.analyte);
const inner = (
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
@@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
compact
/>
</div>
{description ? (
<p className="text-muted-foreground text-xs">
{description}
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
@@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</li>
) : null}
{groups.map((group) => {
const description = describeMarker(group.analyte);
return (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
@@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
linkLabel={group.analyte}
/>
<CardContent>
{description ? (
<p className="text-muted-foreground mb-2 text-xs">
{description}
</p>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
@@ -52,6 +52,7 @@ import {
MoreHorizontal,
} from "lucide-react";
import { Fragment, useCallback, useId, useState } from "react";
import { useTableSort } from "@/hooks/use-table-sort";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
@@ -105,6 +106,10 @@ import {
*/
const CUMULATIVE_TYPES = new Set<string>(CUMULATIVE_DAY_SUM_TYPES);
// Columns that open descending when first selected. The date column reads
// newest-first by default; every other column opens ascending.
const MEASUREMENT_DESC_COLUMNS: ReadonlySet<string> = new Set(["measuredAt"]);
interface Measurement {
id: string;
type: string;
@@ -313,8 +318,16 @@ export function MeasurementList({
const [valueMinInput, setValueMinInputRaw] = useState<string>("");
const [valueMaxInput, setValueMaxInputRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// Shared column-sort state (measuredAt opens descending).
const {
sortBy,
sortDir,
toggleSort: applySort,
} = useTableSort({
defaultColumn: "measuredAt",
defaultDir: "desc",
descColumns: MEASUREMENT_DESC_COLUMNS,
});
// v1.15.13 — page-scoped multi-select. Holds the ids selected on the
// CURRENT page; cleared on any page / filter / sort change (per the
@@ -399,13 +412,11 @@ export function MeasurementList({
clearSelection();
};
// Compose the page-reset + selection-clear side effects around the
// shared sort toggle (the rows the selection referred to are about to
// re-order and the list snaps back to page 1).
function toggleSort(column: string) {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(column === "measuredAt" ? "desc" : "asc");
}
applySort(column);
setPage(1);
clearSelection();
}
+18 -8
View File
@@ -52,6 +52,11 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTableSort } from "@/hooks/use-table-sort";
// Columns that open descending when first selected. The logged-at column
// reads newest-first by default; every other column opens ascending.
const MOOD_DESC_COLUMNS: ReadonlySet<string> = new Set(["moodLoggedAt"]);
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -129,8 +134,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("moodLoggedAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// Shared column-sort state (moodLoggedAt opens descending).
const {
sortBy,
sortDir,
toggleSort: applySort,
} = useTableSort({
defaultColumn: "moodLoggedAt",
defaultDir: "desc",
descColumns: MOOD_DESC_COLUMNS,
});
// v1.15.13 — page-scoped multi-select selection (current page ids only).
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
@@ -202,13 +215,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
clearSelection();
};
// Compose the page-reset + selection-clear side effects around the
// shared sort toggle.
function toggleSort(column: string) {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(column === "moodLoggedAt" ? "desc" : "asc");
}
applySort(column);
setPage(1);
clearSelection();
}
+54
View File
@@ -241,3 +241,57 @@ export function useInsightMetricStatus(
),
});
}
/**
* Per-biomarker assessment loader for the lab-marker detail page. Mirrors
* `useInsightMetricStatus` exactly — the same `InsightStatusData` envelope,
* the 8 s client ceiling, the no-retry policy, and the bounded `preparing`
* poll — but keyed by the marker id against
* `GET /api/insights/biomarker-assessment?biomarkerId=…`.
*
* Read-only + stale-while-revalidate: the route serves cached text and warms
* a regeneration out of band on a cache miss, exactly like every metric
* page. Generation never warms on mount beyond that shared seam; the worker
* cron + the read-only enqueue are the only producers.
*
* `enabled` lets the page suppress the fetch when the marker has no readings
* (the page renders its empty state instead of the card), so a brand-new
* marker never fires an assessment round-trip.
*/
export function useInsightBiomarkerAssessment(
biomarkerId: string,
enabled = true,
) {
const { isAuthenticated } = useAuth();
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
queryFn: async (): Promise<InsightStatusData> => {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
return await apiGet<InsightStatusData>(
`/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent(
biomarkerId,
)}&locale=${locale}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated && enabled && biomarkerId.length > 0,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useCallback, useState } from "react";
export type SortDirection = "asc" | "desc";
/**
* Shared column-sort state for the data-management tables (measurements,
* mood, lab history). Lifted from the byte-identical `sortBy` / `sortDir` /
* `toggleSort` triple the measurement + mood lists each carried inline.
*
* `toggleSort(column)` flips the direction when the active column is tapped
* again, otherwise switches to the new column with its default direction:
* a column listed in `descColumns` (typically the date column) opens
* descending, every other column ascending. Callers that need extra side
* effects on a sort change (reset the page, clear a selection) compose them
* around the returned `toggleSort` rather than re-implementing the math.
*/
export function useTableSort({
defaultColumn,
defaultDir = "desc",
descColumns,
}: {
/** Column the table sorts by on first render. */
defaultColumn: string;
/** Direction for the initial column. */
defaultDir?: SortDirection;
/** Columns that open descending when first selected (e.g. the date column). */
descColumns?: ReadonlySet<string>;
}): {
sortBy: string;
sortDir: SortDirection;
toggleSort: (column: string) => void;
} {
const [sortBy, setSortBy] = useState<string>(defaultColumn);
const [sortDir, setSortDir] = useState<SortDirection>(defaultDir);
const toggleSort = useCallback(
(column: string) => {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(descColumns?.has(column) ? "desc" : "asc");
}
},
[sortBy, descColumns],
);
return { sortBy, sortDir, toggleSort };
}
@@ -0,0 +1,249 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn() },
labResult: { findMany: vi.fn(), count: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import {
generateBiomarkerStatus,
biomarkerStatusScope,
} from "../biomarker-status";
const MARKER = {
id: "bm-1",
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: 0,
upperBound: 100,
};
const TAKEN_AT = new Date("2026-06-20T08:00:00.000Z");
function stubCompletion(content: string, capture?: { userPrompt: string }) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
/** The fingerprint the generator computes for a single-reading marker. */
function inputHashFor(reading: {
id: string;
value: number;
takenAt: Date;
}): string {
return hashInsightSnapshot({
scope: biomarkerStatusScope(MARKER.id),
locale: "en",
promptVersion: PROMPT_VERSION,
count: 1,
bounds: { lower: MARKER.lowerBound, upper: MARKER.upperBound },
latest: {
id: reading.id,
takenAt: reading.takenAt.toISOString(),
value: reading.value,
},
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-20T09:00:00.000Z"),
} as never);
});
describe("generateBiomarkerStatus — empty-data guard", () => {
it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
readOnly: true,
});
expect(result.insufficient).toBe(true);
expect(result.text).toBeNull();
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(prisma.labResult.count).not.toHaveBeenCalled();
});
it("returns insufficient for an unknown / cross-user marker", async () => {
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null as never);
const result = await generateBiomarkerStatus({
biomarkerId: "nope",
userId: "u1",
locale: "en",
readOnly: true,
});
expect(result.insufficient).toBe(true);
expect(runStatusCompletion).not.toHaveBeenCalled();
});
});
describe("generateBiomarkerStatus — cache read", () => {
it("serves today's cached text without calling the provider", async () => {
const todayKey = toBerlinDayKey(new Date());
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Your LDL is steady.",
model: "x",
}),
} as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(result.cached).toBe(true);
expect(result.text).toBe("Your LDL is steady.");
expect(runStatusCompletion).not.toHaveBeenCalled();
// Cache hit short-circuits before the reading read.
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
});
describe("generateBiomarkerStatus — generation path", () => {
it("builds a snapshot, runs the completion, and persists with an inputHash", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const capture = { userPrompt: "" };
stubCompletion(
'{"summary":"Your LDL sits inside the reference range."}',
capture,
);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Your LDL sits inside the reference range.");
expect(result.cached).toBe(false);
expect(result.hasProvider).toBe(true);
// The snapshot carries the marker metadata + the latest reading.
const match = capture.userPrompt.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
expect(snapshot.marker.name).toBe("LDL Cholesterol");
expect(snapshot.marker.unit).toBe("mg/dL");
expect(snapshot.latest.value).toBe(95);
// Persisted under the biomarker scope cache action, carrying the inputHash.
const createCall = vi
.mocked(prisma.auditLog.create)
.mock.calls.at(-1)![0] as {
data: { action: string; details: string };
};
expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en");
const persisted = JSON.parse(createCall.data.details);
expect(persisted.inputHash).toBe(inputHashFor(reading));
});
});
describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => {
it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
// A prior assessment from an earlier day whose inputHash matches the
// current latest reading — readFreshStatusText misses on the day key, but
// the input gate matches the fingerprint.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: "2000-01-01",
locale: "en",
text: "Stable LDL from a previous reading.",
model: "x",
inputHash: inputHashFor(reading),
}),
} as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(result.cached).toBe(true);
expect(result.text).toBe("Stable LDL from a previous reading.");
});
it("regenerates when a new reading lands (inputHash differs)", async () => {
const oldReading = { id: "r1", value: 95, takenAt: TAKEN_AT };
const newReading = {
id: "r2",
value: 130,
takenAt: new Date("2026-06-25T08:00:00.000Z"),
};
// The cached row's inputHash is for the OLD reading; the live latest is the
// new reading, so the fingerprint differs and the gate misses.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: "2000-01-01",
locale: "en",
text: "Stale assessment.",
model: "x",
inputHash: inputHashFor(oldReading),
}),
} as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
newReading,
] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
stubCompletion('{"summary":"LDL rose above the reference range."}');
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("LDL rose above the reference range.");
expect(result.cached).toBe(false);
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* Per-biomarker assessment generator.
*
* Mirrors `generateMetricStatus` (the generic HealthKit-metric generator)
* but reads `LabResult` rows for one user-scoped `Biomarker` instead of
* `Measurement` rows for a `MeasurementType`. It reuses the exact same
* machinery the metric cards use:
* - the shared provider runner (`runStatusCompletion`),
* - the stale-while-revalidate read-only path (`resolveReadOnlyStatusMiss`),
* - the standard cache-row persist (`persistStatusInsight`),
* - the cheap INPUT gate (`gateUnchangedStatusInput`) keyed on the latest
* reading fingerprint so a fresh assessment is produced ONLY when a new
* reading lands — an idle day re-stamps the cached text without an LLM
* round-trip.
*
* The return shape is byte-identical to the metric generator so
* `InsightStatusCard` consumes it unchanged. The cache scope is
* `biomarker:<id>`, which the on-demand queue worker routes back here.
*
* Empty-data guard: a marker with no numeric readings returns an
* `insufficient` marker WITHOUT touching the provider chain.
*/
import { prisma } from "@/lib/db";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
summarizeSeries,
} from "@/lib/insights/status-shared";
import {
gateUnchangedStatusInput,
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
export interface BiomarkerStatusResult {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** True when last-good text is served while a fresh generation runs. */
revalidating?: boolean;
/** True when the marker has no numeric readings; no LLM was called. */
insufficient?: boolean;
}
/** The `biomarker:<id>` cache/queue scope for one marker. */
export function biomarkerStatusScope(
biomarkerId: string,
): `biomarker:${string}` {
return `biomarker:${biomarkerId}`;
}
/** Cap on the recent reading rows folded into the prompt snapshot. */
const SNAPSHOT_READING_CAP = 60;
interface BiomarkerReading {
id: string;
value: number;
takenAt: Date;
}
function insufficient(): BiomarkerStatusResult {
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
insufficient: true,
};
}
function getSystemPrompt(locale: SupportedLocale, markerName: string): string {
if (locale === "de") {
return [
`Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`,
"Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.",
"Schreibe 2 bis 4 ruhige Sätze in klarer Alltagssprache: nenne den aktuellen Wert und seinen Bezug zum Referenzbereich, ordne die Tendenz über die letzten Messungen ein und gib höchstens einen sachlichen Hinweis.",
"Keine Panikmache, keine Marketing-Sprache, keine Befehle. Bei auffälligen Werten verweise neutral auf eine ärztliche Einordnung.",
'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.',
].join(" ");
}
return [
`You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`,
"Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.",
"Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.",
"No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.",
'Respond as a JSON object of the form { "summary": "…" } with no other text.',
].join(" ");
}
function getUserPrompt(
snapshotJson: string,
todayKey: string,
locale: SupportedLocale,
): string {
return locale === "de"
? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}`
: `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`;
}
/**
* Generate (or read from cache) the assessment for one biomarker. Mirrors
* the metric generator's contract:
* - cache hit (today, non-stub) → serve it,
* - `readOnly` miss → enqueue out-of-band + serve last-good,
* - no numeric readings → `insufficient`, no LLM call,
* - unchanged latest reading → re-stamp cached text, no LLM call,
* - otherwise → build snapshot, run completion, persist.
*/
export async function generateBiomarkerStatus(args: {
biomarkerId: string;
userId: string;
locale?: string | null;
force?: boolean;
/** Read-only navigation path — never blocks on the provider. */
readOnly?: boolean;
}): Promise<BiomarkerStatusResult> {
const marker = await prisma.biomarker.findFirst({
where: { id: args.biomarkerId, userId: args.userId },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
},
});
if (!marker) {
// Unknown / cross-user marker — degrade gracefully rather than throwing.
return insufficient();
}
const locale = normalizeLocale(args.locale);
const force = args.force === true;
const readOnly = args.readOnly === true;
const scope = biomarkerStatusScope(marker.id);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
userId: args.userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// Numeric readings only — a qualitative-only marker carries no range verdict
// or trend, so there is nothing to assess. Done BEFORE any enqueue so an
// empty marker never queues an LLM job nor blocks on the provider.
const rows = await prisma.labResult.findMany({
where: {
userId: args.userId,
biomarkerId: marker.id,
deletedAt: null,
value: { not: null },
},
orderBy: { takenAt: "desc" },
take: SNAPSHOT_READING_CAP,
select: { id: true, value: true, takenAt: true },
});
if (rows.length === 0) {
annotate({
action: { name: "insights.biomarker-status.insufficient" },
meta: { biomarkerId: marker.id },
});
return insufficient();
}
const readings: BiomarkerReading[] = rows.map((r) => ({
id: r.id,
value: r.value as number,
takenAt: r.takenAt,
}));
const latest = readings[0];
const totalCount = await prisma.labResult.count({
where: {
userId: args.userId,
biomarkerId: marker.id,
deletedAt: null,
value: { not: null },
},
});
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId: args.userId,
metric: scope,
locale,
});
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
return {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
// INPUT gate: fingerprint the latest reading + count + bounds + locale +
// prompt version. When the fingerprint matches the cached assessment's
// stored `inputHash`, nothing the prompt could see has changed since the
// last reading, so re-stamp that text under today's day key and skip the
// LLM entirely. A new reading flips the latest id / value / count and
// forces a real regeneration — so the assessment regenerates ONLY on a new
// reading.
//
// The gate runs with `force: false` even on the worker's forced path: the
// outer `force` exists to bypass the SAME-DAY text cache (so a fresh day
// re-evaluates the fingerprint), not to burn an LLM call on an unchanged
// marker. The worker is re-enqueued whenever today's text cache misses, so
// letting the input gate run keeps an idle marker to one cheap re-stamp per
// day instead of a daily regeneration.
const inputHash = hashInsightSnapshot({
scope,
locale,
promptVersion: PROMPT_VERSION,
count: totalCount,
bounds: { lower: marker.lowerBound, upper: marker.upperBound },
latest: {
id: latest.id,
takenAt: latest.takenAt.toISOString(),
value: latest.value,
},
});
const unchanged = await gateUnchangedStatusInput({
userId: args.userId,
cacheAction,
todayKey,
inputHash,
force: false,
});
if (unchanged) {
return {
hasProvider: true,
text: unchanged.text,
cached: true,
updatedAt: unchanged.updatedAt,
};
}
// Ascending series (oldest → newest) for the trend summary the prompt reads.
const ascending = [...readings].reverse();
const summary = summarizeSeries(ascending.map((r) => ({ value: r.value })));
const latestStatus = classifyReferenceRange(
latest.value,
marker.lowerBound,
marker.upperBound,
);
const snapshot = {
locale,
promptVersion: PROMPT_VERSION,
generatedForDay: todayKey,
marker: {
name: marker.name,
unit: marker.unit,
referenceRange:
marker.lowerBound != null || marker.upperBound != null
? { lower: marker.lowerBound, upper: marker.upperBound }
: null,
},
dataCoverage: {
totalReadings: totalCount,
readingsInSnapshot: readings.length,
newestTakenAt: latest.takenAt.toISOString(),
oldestInSnapshot: ascending[0].takenAt.toISOString(),
},
latest: {
value: latest.value,
takenAt: latest.takenAt.toISOString(),
rangeStatus: latestStatus,
},
summary,
series: ascending.map((r) => ({
day: toBerlinDayKey(r.takenAt),
value: r.value,
})),
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
});
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getSystemPrompt(locale, marker.name),
userPrompt: getUserPrompt(snapshotJson, todayKey, locale),
temperature: 0.45,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const text = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!text) {
throw new Error(`Biomarker-status summary was empty for ${marker.id}`);
}
const updatedAt = await persistStatusInsight({
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
inputHash,
});
return { hasProvider: true, text, cached: false, updatedAt };
}
+31 -5
View File
@@ -67,6 +67,12 @@ interface CoachLaunchValue {
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
/**
* When true, the opened conversation auto-sends the prefill as its first
* turn (exactly once), so a card's "ask about this" hand-off lands the
* answer directly instead of only seeding the composer. Cleared on close.
*/
autoSend: boolean;
/**
* Scope the next/open conversation is narrowed to (or null for the
* default all-source snapshot). Set by `askCoach(prefill, scope)`, or
@@ -74,8 +80,16 @@ interface CoachLaunchValue {
* explicit one. `<LayoutCoachMount>` forwards it to the drawer.
*/
scope: CoachLaunchScope | null;
/** Open the drawer with an optional prefill + scope hint. */
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
/**
* Open the drawer with an optional prefill + scope hint. When `autoSend`
* is true the prefill is dispatched as the conversation's first turn
* automatically (used by the assessment hand-off).
*/
askCoach: (
prefill?: string | null,
scope?: CoachLaunchScope,
autoSend?: boolean,
) => void;
/**
* v1.21.0 (C4 H1) — register the metric surface the user is currently
* looking at so the global FAB (which calls `askCoach()` with no args)
@@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps {
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
// Whether the next open auto-sends its prefill as the first turn.
const [autoSend, setAutoSend] = useState<boolean>(false);
// Scope the open conversation is narrowed to. `null` → default snapshot.
const [scope, setScope] = useState<CoachLaunchScope | null>(null);
// Ambient scope + seed opener of the metric surface currently on screen.
@@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const ambientPrefillRef = useRef<string | null>(null);
const askCoach = useCallback(
(nextPrefill?: string | null, nextScope?: CoachLaunchScope) => {
(
nextPrefill?: string | null,
nextScope?: CoachLaunchScope,
nextAutoSend?: boolean,
) => {
// v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight
// card, metric-card affordance) wins; otherwise inherit the metric
// page's ambient scope so the FAB opens contextual to where the user
@@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
(usingAmbientScope ? ambientPrefillRef.current : null) ??
null,
);
// Auto-send only applies when an explicit prefill is given (a card
// hand-off), never for an ambient/blank open.
setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill));
setOpen(true);
},
[],
@@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
// Drop the prefill + scope on close so the next open starts clean.
// Drop the prefill + scope + auto-send on close so the next open
// starts clean.
setPrefill(null);
setScope(null);
setAutoSend(false);
}
}, []);
@@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
() => ({
open,
prefill,
autoSend,
scope,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
[open, prefill, scope, askCoach, registerScope, handleSetOpen],
[open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen],
);
return (
+10 -1
View File
@@ -67,7 +67,16 @@ export type InsightStatusScope =
* always fills the field synchronously — this queue only warms the AI prose
* that overrides it on the next read.
*/
| `derived-score:${string}`;
| `derived-score:${string}`
/**
* Per-biomarker assessment scopes ride the same on-demand queue. Their
* scope id is `biomarker:<id>`; the worker routes the prefix to the
* biomarker generator, which reads `LabResult` rows for the marker and
* applies its own empty-data + input-hash gates, so a job enqueued for a
* marker with no new reading costs only a cheap fingerprint, not an LLM
* call.
*/
| `biomarker:${string}`;
export interface InsightStatusGeneratePayload {
userId: string;
+22
View File
@@ -35,6 +35,7 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { isMetricStatusId } from "@/lib/insights/metric-status-registry";
import { prisma } from "@/lib/db";
import {
@@ -115,6 +116,27 @@ export async function runInsightStatusGenerate(
return;
}
// A `biomarker:<id>` scope routes to the per-biomarker generator, which
// reads `LabResult` rows for the marker. Its own empty-data + input-hash
// gates keep a job for an unchanged marker cheap (no LLM call).
if (payload.metric.startsWith("biomarker:")) {
const biomarkerId = payload.metric.slice("biomarker:".length);
if (!biomarkerId) {
annotate({
action: { name: "insights.status.generate.unknown_metric" },
meta: { metric: payload.metric },
});
return;
}
await generateBiomarkerStatus({
biomarkerId,
userId: payload.userId,
locale: payload.locale,
force: true,
});
return;
}
// v1.13.2 — a `derived-score:<ID>` scope warms the per-score assessment AI
// prose. The route already filled the deterministic text synchronously; this
// recomputes the derived value and persists the warmer prose so the next
+27
View File
@@ -9,6 +9,8 @@ import {
insightsComprehensiveResponse,
metricStatusQuery,
metricStatusResponse,
biomarkerAssessmentQuery,
biomarkerAssessmentResponse,
derivedMetricQuery,
derivedMetricResponse,
derivedBatchQuery,
@@ -300,6 +302,31 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/biomarker-assessment": {
get: {
tags: ["Insights"],
summary: "Per-biomarker assessment",
description:
"Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.",
requestParams: {
query: biomarkerAssessmentQuery,
},
responses: {
"200": {
description: "Assessment envelope (fresh, cached, or preparing).",
content: {
"application/json": {
schema: dataEnvelope(
biomarkerAssessmentResponse,
"BiomarkerAssessmentResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/derived": {
get: {
tags: ["Insights"],
@@ -473,6 +473,70 @@ export const insightStatusResponse = z
"Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.",
});
// Per-biomarker assessment. The marker is identified by a user-scoped id
// (the generic metric-status route fixes its metric by a closed registry
// enum; biomarkers are user-defined, so the id is a free-form string).
export const biomarkerAssessmentQuery = z
.object({
biomarkerId: z
.string()
.min(1)
.max(64)
.describe(
"User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).",
),
locale: z
.enum(["de", "en"])
.optional()
.describe("Optional UI-locale override; defaults to the session locale."),
})
.meta({ id: "BiomarkerAssessmentQuery" });
export const biomarkerAssessmentResponse = z
.object({
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
),
text: z
.string()
.nullable()
.describe(
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.",
),
cached: z
.boolean()
.describe("True when `text` is served from cache (incl. last-good)."),
updatedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("When the served assessment was generated; null when none."),
preparing: z
.boolean()
.optional()
.describe(
"True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.",
),
revalidating: z
.boolean()
.optional()
.describe(
"True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.",
),
insufficient: z
.boolean()
.optional()
.describe(
"True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.",
),
})
.meta({
id: "BiomarkerAssessmentResponse",
description:
"Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands.",
});
// The medication-compliance route carries a richer envelope than the
// other six: a `summary` narrative plus a per-medication `text` array,
// instead of a single `text` field.
+10
View File
@@ -43,6 +43,16 @@ export const insightsKeys = {
*/
insightsMetricStatus: (metric: string, locale: string) =>
["insights", "metric-status", metric, locale] as const,
/**
* Per-biomarker assessment for the lab-marker detail page. One generic
* route (`/api/insights/biomarker-assessment?biomarkerId=…`) backs every
* marker, so the key is parameterised by the marker id alongside the
* locale. Same read-only + stale-while-revalidate contract as
* `insightsMetricStatus`; the `["insights"]` prefix keeps it in the
* standard invalidation fan-out.
*/
insightsBiomarkerAssessment: (biomarkerId: string, locale: string) =>
["insights", "biomarker-assessment", biomarkerId, locale] as const,
/**
* v1.8.7.1 — mutationKey for the on-demand full-warm POST
* (`/api/insights/pregenerate`). Lives in the factory so the bare-array
+172
View File
@@ -0,0 +1,172 @@
/**
* Integration suite for the biomarker resource route
* (`/api/biomarkers/{id}`) against a real Postgres.
*
* Covers the two detail-page mutations:
* - PUT adjusts the target range (and rejects an inverted window),
* - DELETE drops the marker together with its readings in one
* userId-narrowed transaction, leaving another user's marker untouched.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-biomarker-detail-test";
const OTHER_USER_ID = "user-biomarker-other-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{ id: USER_ID, username: "bm-detail", email: "bm-detail@example.test" },
{
id: OTHER_USER_ID,
username: "bm-other",
email: "bm-other@example.test",
},
],
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
});
const params = (id: string) => ({ params: Promise.resolve({ id }) });
function putReq(id: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost/api/biomarkers/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function delReq(id: string): NextRequest {
return new NextRequest(`http://localhost/api/biomarkers/${id}`, {
method: "DELETE",
});
}
async function seedMarkerWithReadings(userId: string, name: string) {
const prisma = getPrismaClient();
const marker = await prisma.biomarker.create({
data: { userId, name, unit: "mg/dL", lowerBound: 0, upperBound: 100 },
});
await prisma.labResult.createMany({
data: [
{
userId,
biomarkerId: marker.id,
analyte: name,
unit: "mg/dL",
value: 95,
takenAt: new Date("2026-06-20T08:00:00.000Z"),
},
{
userId,
biomarkerId: marker.id,
analyte: name,
unit: "mg/dL",
value: 88,
takenAt: new Date("2026-05-20T08:00:00.000Z"),
},
],
});
return marker;
}
describe("PUT /api/biomarkers/{id} — target range (real Postgres)", () => {
it("persists an updated reference window", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const marker = await seedMarkerWithReadings(USER_ID, "LDL");
const res = await PUT(
putReq(marker.id, { lowerBound: 10, upperBound: 50 }),
params(marker.id),
);
expect(res.status).toBe(200);
const row = await getPrismaClient().biomarker.findUnique({
where: { id: marker.id },
});
expect(row?.lowerBound).toBe(10);
expect(row?.upperBound).toBe(50);
});
it("422s an inverted target range and leaves the bounds untouched", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const marker = await seedMarkerWithReadings(USER_ID, "HbA1c");
const res = await PUT(
putReq(marker.id, { lowerBound: 80, upperBound: 20 }),
params(marker.id),
);
expect(res.status).toBe(422);
const row = await getPrismaClient().biomarker.findUnique({
where: { id: marker.id },
});
expect(row?.lowerBound).toBe(0);
expect(row?.upperBound).toBe(100);
});
});
describe("DELETE /api/biomarkers/{id} (real Postgres)", () => {
it("drops the marker together with its readings", async () => {
const { DELETE } = await import("@/app/api/biomarkers/[id]/route");
const marker = await seedMarkerWithReadings(USER_ID, "Ferritin");
const res = await DELETE(delReq(marker.id), params(marker.id));
expect(res.status).toBe(200);
const prisma = getPrismaClient();
expect(await prisma.biomarker.count({ where: { id: marker.id } })).toBe(0);
expect(
await prisma.labResult.count({ where: { biomarkerId: marker.id } }),
).toBe(0);
});
it("404s another user's marker and leaves it (and its readings) intact", async () => {
const { DELETE } = await import("@/app/api/biomarkers/[id]/route");
const foreign = await seedMarkerWithReadings(OTHER_USER_ID, "TSH");
const res = await DELETE(delReq(foreign.id), params(foreign.id));
expect(res.status).toBe(404);
const prisma = getPrismaClient();
expect(await prisma.biomarker.count({ where: { id: foreign.id } })).toBe(1);
expect(
await prisma.labResult.count({ where: { biomarkerId: foreign.id } }),
).toBe(2);
});
});