From 9c748382b974ff4fbd0272b8022f9659f7269b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:11:03 +0200 Subject: [PATCH 01/10] feat(tables): share column-sort state across the value lists Lift the byte-identical sortBy/sortDir/toggleSort triple the measurement and mood lists each carried inline into a shared useTableSort hook. Both lists now compose their page-reset and selection-clear side effects around the hook's toggle rather than re-implementing the direction math, and the date columns keep their descending-first default via descColumns. --- .../measurements/measurement-list.tsx | 27 +++++++--- src/components/mood/mood-list.tsx | 26 +++++++--- src/hooks/use-table-sort.ts | 51 +++++++++++++++++++ 3 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/hooks/use-table-sort.ts diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 58a194307..8d7f918eb 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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(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 = new Set(["measuredAt"]); + interface Measurement { id: string; type: string; @@ -313,8 +318,16 @@ export function MeasurementList({ const [valueMinInput, setValueMinInputRaw] = useState(""); const [valueMaxInput, setValueMaxInputRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("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(); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..00868224b 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -51,6 +51,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 = new Set(["moodLoggedAt"]); import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -128,8 +133,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [fromDay, setFromDayRaw] = useState(""); const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("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>(() => new Set()); @@ -201,13 +214,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(); } diff --git a/src/hooks/use-table-sort.ts b/src/hooks/use-table-sort.ts new file mode 100644 index 000000000..776b33b85 --- /dev/null +++ b/src/hooks/use-table-sort.ts @@ -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; +}): { + sortBy: string; + sortDir: SortDirection; + toggleSort: (column: string) => void; +} { + const [sortBy, setSortBy] = useState(defaultColumn); + const [sortDir, setSortDir] = useState(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 }; +} From 42b433f2e8068cfa5ec218bbec5bfd01ddae1664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:14:38 +0200 Subject: [PATCH 02/10] feat(tables): make the lab history table sortable Convert the per-biomarker reading history from a static reverse-chronological list to a shadcn Table with clickable sort headers. Reuse the shared useTableSort hook and SortableHead chrome already backing the measurement and mood lists; the sample-date column opens descending, value/range/note open ascending. The sort runs client-side over the in-memory reading feed. --- src/components/labs/lab-history-list.tsx | 191 +++++++++++++++++------ 1 file changed, 145 insertions(+), 46 deletions(-) diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 98b072074..17cfb3e50 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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 = { + 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(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 ( <> -
    - {ordered.map((r) => ( -
  • -
    -
    - - {formatLabReading(r)} - - -
    -

    + + + + + + + + + {t("labs.editReading")} + + + + + {ordered.map((r) => ( + + + {formatLabReading(r)} + + {formatDateShort(r.takenAt, true)} - {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} -

    - -
    - - 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" - /> -
    - - ))} - +
    + + + + + {r.hasNote ? t("labs.hasNote") : ""} + + +
    + + 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" + /> +
    +
    +
    + ))} +
    +
    Date: Sun, 28 Jun 2026 13:20:24 +0200 Subject: [PATCH 03/10] fix(insights): localize and auto-send the coach assessment prompt Thread an autoSend flag through the coach-launch flow so a card's "ask the coach about this" hand-off dispatches its seeded prompt as the conversation's first turn instead of only seeding the composer. The flag rides the launch context, the layout mount, the drawer, and the shared conversation surface, where a ref-guarded mount effect fires the send exactly once for a fresh thread. Localize the previously hardcoded English assessment opener in the metric status card behind insights.coach.assessmentPrompt with a {metric} placeholder, and localize the resting-pulse coach prefill. Both keys land in all six locales. --- messages/de.json | 6 ++-- messages/en.json | 6 ++-- messages/es.json | 6 ++-- messages/fr.json | 6 ++-- messages/it.json | 6 ++-- messages/pl.json | 6 ++-- src/app/insights/resting-pulse/page.tsx | 4 ++- src/components/insights/ask-coach-action.tsx | 9 ++++- .../coach-panel/coach-conversation.tsx | 35 +++++++++++++++++- .../insights/coach-panel/coach-drawer.tsx | 7 ++++ .../insights/insight-status-card.tsx | 12 ++++++- .../insights/layout-coach-mount.tsx | 3 ++ .../insights/metric-status-card.tsx | 10 +++--- src/lib/insights/coach-launch-context.tsx | 36 ++++++++++++++++--- 14 files changed, 125 insertions(+), 27 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..288729d15 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/messages/en.json b/messages/en.json index 14fa8b085..82ab39793 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..a62a65300 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..32175a5e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/messages/it.json b/messages/it.json index 60e5288d4..d90e3a89a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..2f3ec67fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2372,7 +2372,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", @@ -2444,7 +2445,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", diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index ccf61598a..95a16af3a 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -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 ( } 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")} /> ); } diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index 1200ab309..ffc97490d 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -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. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 5a9236f6d..c8aaa00df 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 ``, the page in an @@ -188,6 +195,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, launchScope, + autoSend, renderTitle, renderDescription, leadingHeaderActions, @@ -340,6 +348,31 @@ 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); + 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; + void handleSubmit(seed); + // `handleSubmit` is re-created each render but the ref guard ensures a + // single dispatch; the remaining deps gate the one allowed fire. + }, [ + autoSend, + prefill, + currentConversationId, + send.isStreaming, + handleSubmit, + ]); + // 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 diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 490018b21..98e21ef4e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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 diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index f36c20ca9..03eeca56e 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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 ? (

    - +
    ) : null} diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 1b0947430..b8e8be6be 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -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} /> ); } diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 5644db56b..524401a96 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -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 ( `: the hook is // `enabled: isAuthenticated && enabled`, and both inputs can settle // before a late-hydrating boundary replays its first render — the diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index cf7754813..2313f3409 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -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. `` 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(false); const [prefill, setPrefill] = useState(null); + // Whether the next open auto-sends its prefill as the first turn. + const [autoSend, setAutoSend] = useState(false); // Scope the open conversation is narrowed to. `null` → default snapshot. const [scope, setScope] = useState(null); // Ambient scope + seed opener of the metric surface currently on screen. @@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const ambientPrefillRef = useRef(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 ( From 51aa37f654f409276d0103ddc8671d0ea70a3690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:21:28 +0200 Subject: [PATCH 04/10] feat(labs): delete a biomarker together with its values Deleting a biomarker now removes its recorded values in the same userId-narrowed transaction instead of unlinking them via the SetNull FK. Removing a marker is a deliberate "drop this and its history" action, so leaving orphaned, unlinked readings behind was surprising. Update the delete-confirmation copy in all six locales to state that the values are removed too. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/api/biomarkers/[id]/route.ts | 23 +++++++++++++++-------- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index 288729d15..097118112 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6944,7 +6944,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", diff --git a/messages/en.json b/messages/en.json index 82ab39793..dd62e0249 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6944,7 +6944,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", diff --git a/messages/es.json b/messages/es.json index a62a65300..fddcd2093 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6944,7 +6944,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", diff --git a/messages/fr.json b/messages/fr.json index 32175a5e3..f4cb7352d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6944,7 +6944,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", diff --git a/messages/it.json b/messages/it.json index d90e3a89a..bb20e327f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6944,7 +6944,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", diff --git a/messages/pl.json b/messages/pl.json index 2f3ec67fe..4129312fd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6944,7 +6944,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", diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3b533241b..89dcce4c6 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -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, From d87aac4b4869f047a1b3cd68895a84e267d41151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:24:24 +0200 Subject: [PATCH 05/10] fix(insights): defer the coach auto-send out of the effect body Hold the submit handler in a ref updated from an effect and dispatch the auto-send through a microtask, so the first-turn send no longer calls setState synchronously inside the effect. --- .../coach-panel/coach-conversation.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index c8aaa00df..0432e6a77 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -354,6 +354,14 @@ export function CoachConversation({ // 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; @@ -362,16 +370,10 @@ export function CoachConversation({ const seed = (prefill ?? "").trim(); if (!seed) return; autoSentRef.current = true; - void handleSubmit(seed); - // `handleSubmit` is re-created each render but the ref guard ensures a - // single dispatch; the remaining deps gate the one allowed fire. - }, [ - autoSend, - prefill, - currentConversationId, - send.isStreaming, - handleSubmit, - ]); + 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 From f751f006dea0a743222141a009824e606dfd1a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:28:47 +0200 Subject: [PATCH 06/10] refactor(labs): move the biomarker description to its detail page The per-marker catalog description used to render on every labs overview row, both in the list and the card layout. It reads better as a caption beneath the detail-page heading, mirroring the metric pages' explainer line. Resolve the catalog slug from the marker name and fall back to the user's own context note. --- src/components/labs/lab-biomarker-detail.tsx | 31 ++++++++++++++++--- src/components/labs/lab-list.tsx | 32 ++------------------ 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 0db4c7ed4..3353791c2 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,6 +1,6 @@ "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"; @@ -13,6 +13,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +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,6 +66,18 @@ 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(); + 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(null); @@ -223,9 +236,19 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
    ) : null} - {marker?.context ? ( -

    {marker.context}

    - ) : 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 ? ( +

    + {description} +

    + ) : null; + })()} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 1f45b1518..0d380b4f9 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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(); - 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 } = {}) { {groups.map((group) => { - const description = describeMarker(group.analyte); const inner = (
    @@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { compact />
    - {description ? ( -

    - {description} -

    - ) : null}
    {formatLabReading(group.latest)} @@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
  • ) : null} {groups.map((group) => { - const description = describeMarker(group.analyte); return (
  • @@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { linkLabel={group.analyte} /> - {description ? ( -

    - {description} -

    - ) : null}
    From 02d527720a5df6b10936b58c7b695eb2da05c0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:30:19 +0200 Subject: [PATCH 07/10] feat(labs): adjust a biomarker target range from its detail page Add a control beside the marker actions that opens a focused sheet for the reference low and high only, so a user can tune the band without the full marker editor. Persists through the existing biomarker PUT and invalidates the detail, catalog, and result reads. --- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/pl.json | 9 +- src/components/labs/lab-biomarker-detail.tsx | 105 ++++++++++++++++++- 7 files changed, 151 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index 097118112..a5e5f1469 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6979,7 +6979,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", diff --git a/messages/en.json b/messages/en.json index dd62e0249..c6704f0c4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6979,7 +6979,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", diff --git a/messages/es.json b/messages/es.json index fddcd2093..458ffb5f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6979,7 +6979,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", diff --git a/messages/fr.json b/messages/fr.json index f4cb7352d..48f8b1b65 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6979,7 +6979,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", diff --git a/messages/it.json b/messages/it.json index bb20e327f..acee72e93 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6979,7 +6979,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", diff --git a/messages/pl.json b/messages/pl.json index 4129312fd..458028f3a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6979,7 +6979,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", diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 3353791c2..06994ed7d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -3,16 +3,18 @@ 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 { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; @@ -83,6 +85,12 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(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), @@ -151,6 +159,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 (

    @@ -202,6 +237,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" /> + + +

    + } + > +
    +
    + + setLowerInput(e.target.value)} + /> +
    +
    + + setUpperInput(e.target.value)} + /> +
    +
    + {marker?.unit ? ( +

    + {t("labs.biomarker.form.rangeHint")} +

    + ) : null} +
    ); } From 0790a96ca4be3e24bcd9a79d2f3ede61fea4b457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:43:14 +0200 Subject: [PATCH 08/10] feat(labs): add an AI assessment and stat strip to the biomarker detail page Give each biomarker a detail page that matches the metric sub-pages: a numbers-first Min/Max/Median/Mean strip over the numeric readings and a data-grounded AI assessment card. The assessment is served by a new read-only endpoint GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the same envelope as /api/insights/metric-status so InsightStatusCard consumes it unchanged. A new generator reads the marker's LabResult history and reuses the shared provider runner, the stale-while-revalidate read-only path, and the persist helper. Regeneration is gated on a fingerprint of the latest reading, so the assessment is rebuilt only when a new reading lands; an idle marker re-stamps the cached text without an LLM call. The biomarker: scope rides the existing on-demand status queue. The stat strip and the assessment card both self-gate on numeric data, so a qualitative-only or brand-new marker paints neither. --- docs/api/openapi.yaml | 102 +++++ .../insights/biomarker-assessment/route.ts | 72 ++++ src/components/labs/lab-biomarker-detail.tsx | 65 +++- src/hooks/use-insight-status.ts | 54 +++ src/lib/insights/biomarker-status.ts | 354 ++++++++++++++++++ .../jobs/insight-status-generate-shared.ts | 11 +- src/lib/jobs/insight-status-generate.ts | 22 ++ src/lib/openapi/routes/insights/paths.ts | 27 ++ src/lib/openapi/routes/insights/schemas.ts | 64 ++++ src/lib/query-keys/insights.ts | 10 + 10 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 src/app/api/insights/biomarker-assessment/route.ts create mode 100644 src/lib/insights/biomarker-status.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..37c195fc2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: @@ -20229,6 +20270,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: diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts new file mode 100644 index 000000000..1bc0bd5af --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -0,0 +1,72 @@ +/** + * Per-biomarker assessment route. + * + * `GET /api/insights/biomarker-assessment?biomarkerId=` 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); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 06994ed7d..f4ddab48a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -14,7 +14,12 @@ 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 { 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"; @@ -117,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; @@ -129,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( + () => + 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() }); @@ -295,6 +326,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : 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. */} + + {isLoading ? ( @@ -340,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : 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 ? ( + } + 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} + => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + STATUS_TIMEOUT_MS, + ); + try { + return await apiGet( + `/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, + ), + }); +} diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts new file mode 100644 index 000000000..e39e547ed --- /dev/null +++ b/src/lib/insights/biomarker-status.ts @@ -0,0 +1,354 @@ +/** + * 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:`, 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:` 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 { + 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. + 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, + }); + 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 }; +} diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 0811e46d2..906847142 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -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:`; 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; diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 1ca8d2a28..4b96cd16d 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -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:` 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:` 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 diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index a3826a351..59ef64879 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -9,6 +9,8 @@ import { insightsComprehensiveResponse, metricStatusQuery, metricStatusResponse, + biomarkerAssessmentQuery, + biomarkerAssessmentResponse, derivedMetricQuery, derivedMetricResponse, derivedBatchQuery, @@ -300,6 +302,31 @@ export const insightsPaths: NonNullable = { }, }, }, + "/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"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 82971e74f..09f805707 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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. diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 0a3607438..ff08733d1 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -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 From f17131dbdf03c57afb9c341fe0e90fd4899b4266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:58:43 +0200 Subject: [PATCH 09/10] test(labs): cover the biomarker assessment, detail page, and marker delete/range Add coverage for the new biomarker-assessment seam and the marker detail surface: - the generator serves today's cache, re-stamps unchanged readings without an LLM call, and regenerates only when the latest-reading fingerprint changes - the read-only endpoint returns the envelope, 422s a missing id, and honours the auth + insightStatus gates - the detail page paints the stat strip, the chart placeholder, and the assessment card - the biomarker resource route drops the marker with its readings in one userId-narrowed transaction and rejects an inverted target range Hold the input gate independent of the worker's forced run so an idle marker re-stamps cached text instead of regenerating, and list the new route on the non-Coach gate allowlist. --- .../biomarkers/[id]/__tests__/route.test.ts | 162 ++++++++++++ .../coach-route-gate-inventory.test.ts | 3 + .../__tests__/route.test.ts | 133 ++++++++++ .../__tests__/lab-biomarker-detail.test.tsx | 142 ++++++++++ .../__tests__/biomarker-status.test.ts | 249 ++++++++++++++++++ src/lib/insights/biomarker-status.ts | 12 +- 6 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/__tests__/route.test.ts create mode 100644 src/app/api/insights/biomarker-assessment/__tests__/route.test.ts create mode 100644 src/components/labs/__tests__/lab-biomarker-detail.test.tsx create mode 100644 src/lib/insights/__tests__/biomarker-status.test.ts diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..bb3f0a75d --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -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[]) => 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[], + ) => 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; + }; + 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(); + }); +}); diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..c21fb2bd9 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest"; const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // 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", diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts new file mode 100644 index 000000000..49f419399 --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.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()), + 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; +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"); + }); +}); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx new file mode 100644 index 000000000..8c6b6ab0b --- /dev/null +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -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( + + + + + , + ); +} + +describe("", () => { + 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"'); + }); +}); diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts new file mode 100644 index 000000000..1c677c606 --- /dev/null +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index e39e547ed..39e867291 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -231,7 +231,15 @@ export async function generateBiomarkerStatus(args: { // 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. + // 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, @@ -249,7 +257,7 @@ export async function generateBiomarkerStatus(args: { cacheAction, todayKey, inputHash, - force, + force: false, }); if (unchanged) { return { From 1cb7f0933c0a40eb457bf792a24f52a64b09ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 14:00:55 +0200 Subject: [PATCH 10/10] test(labs): integration coverage for biomarker target-range and delete Drive the biomarker resource route against a real Postgres: PUT persists a new reference window and rejects an inverted one, and DELETE drops the marker together with its readings while leaving another user's marker and readings intact. --- tests/integration/biomarker-detail.test.ts | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/integration/biomarker-detail.test.ts diff --git a/tests/integration/biomarker-detail.test.ts b/tests/integration/biomarker-detail.test.ts new file mode 100644 index 000000000..318fc99cc --- /dev/null +++ b/tests/integration/biomarker-detail.test.ts @@ -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); + }); +});