mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/biomarker-detail-page' into release/v1.25.0
This commit is contained in:
commit
95ee088646
36 files changed
+2209
-147
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user