diff --git a/messages/de.json b/messages/de.json index a1637297b..ae2bbef23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -435,7 +435,8 @@ "autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein", "window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen", "windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen." - } + }, + "screenerUnvalidated": "{label}: {count} von {total} Ergebnissen wurden in einer nicht wissenschaftlich validierten Übersetzung beantwortet; sie dienen der Orientierung." }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", diff --git a/messages/en.json b/messages/en.json index fba41c53b..0c7e01d4f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -435,7 +435,8 @@ "autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow", "window": "analysed {from} to {to}, which can reach beyond the report period", "windowLine": "Analysed {from} to {to}. This window can reach beyond the report period." - } + }, + "screenerUnvalidated": "{label}: {count} of {total} results were answered in a translation that has not been scientifically validated; treat them as orientation." }, "errorBoundary": { "title": "Something went wrong", diff --git a/messages/es.json b/messages/es.json index bcf55e6a5..c0eb52e19 100644 --- a/messages/es.json +++ b/messages/es.json @@ -435,7 +435,8 @@ "autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos", "window": "analizado del {from} al {to}, puede ir más allá del periodo del informe", "windowLine": "Analizado del {from} al {to}. Este intervalo puede ir más allá del periodo del informe." - } + }, + "screenerUnvalidated": "{label}: {count} de {total} resultados se respondieron con una traducción no validada científicamente; deben tomarse como orientación." }, "errorBoundary": { "title": "Algo ha salido mal", diff --git a/messages/fr.json b/messages/fr.json index 16cda5d4a..1ae591455 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -435,7 +435,8 @@ "autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites", "window": "analysé du {from} au {to}, ce qui peut dépasser la période du rapport", "windowLine": "Analysé du {from} au {to}. Cette fenêtre peut dépasser la période du rapport." - } + }, + "screenerUnvalidated": "{label} : {count} résultat(s) sur {total} ont été obtenus avec une traduction non validée scientifiquement ; à considérer comme une indication." }, "errorBoundary": { "title": "Une erreur est survenue", diff --git a/messages/it.json b/messages/it.json index a620474fa..9e30bf5ca 100644 --- a/messages/it.json +++ b/messages/it.json @@ -435,7 +435,8 @@ "autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti", "window": "analizzato dal {from} al {to}, può andare oltre il periodo del report", "windowLine": "Analizzato dal {from} al {to}. Questo intervallo può andare oltre il periodo del report." - } + }, + "screenerUnvalidated": "{label}: {count} risultati su {total} sono stati ottenuti con una traduzione non validata scientificamente; vanno considerati come orientamento." }, "errorBoundary": { "title": "Qualcosa è andato storto", diff --git a/messages/ko.json b/messages/ko.json index 0e935e3a6..0197d663d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -435,7 +435,8 @@ "autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음", "window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음", "windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다." - } + }, + "screenerUnvalidated": "{label}: {total}개 결과 중 {count}개는 과학적으로 검증되지 않은 번역으로 응답했습니다. 참고용으로만 보십시오." }, "errorBoundary": { "title": "문제가 생겼어요", diff --git a/messages/nl.json b/messages/nl.json index c3d4af302..86a5440ec 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -435,7 +435,8 @@ "autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn", "window": "geanalyseerd van {from} tot {to}, dit kan buiten de rapportperiode vallen", "windowLine": "Geanalyseerd van {from} tot {to}. Deze periode kan buiten de rapportperiode vallen." - } + }, + "screenerUnvalidated": "{label}: {count} van {total} uitkomsten zijn beantwoord in een niet wetenschappelijk gevalideerde vertaling; zie ze als een indicatie." }, "errorBoundary": { "title": "Er is iets misgegaan", diff --git a/messages/pl.json b/messages/pl.json index 4249dbe91..916c2239a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -435,7 +435,8 @@ "autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie", "window": "analizowano od {from} do {to}, może to wykraczać poza okres raportu", "windowLine": "Analizowano od {from} do {to}. Ten okres może wykraczać poza okres raportu." - } + }, + "screenerUnvalidated": "{label}: {count} z {total} wyników uzyskano w tłumaczeniu niezwalidowanym naukowo; należy traktować je orientacyjnie." }, "errorBoundary": { "title": "Coś poszło nie tak", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index f73571d6b..fcd90cced 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -435,7 +435,8 @@ "autocorrelation": "forte arrasto de um dia para o outro, então as faixas ainda podem estar estreitas demais", "window": "analisado de {from} a {to}, o que pode ir além do período do relatório", "windowLine": "Analisado de {from} a {to}. Esta janela pode ir além do período do relatório." - } + }, + "screenerUnvalidated": "{label}: {count} de {total} resultados foram respondidos em uma tradução não validada cientificamente; considere-os como orientação." }, "errorBoundary": { "title": "Algo deu errado", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 96b76c3ce..8f78154a2 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 123, - "total": 145428, + "count": 122, + "total": 144625, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -66,7 +66,6 @@ "lib/dashboard-layout.ts": 971, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, - "lib/doctor-report/collect.ts": 803, "lib/export/backup-plan.ts": 853, "lib/export/coach-backup.ts": 1070, "lib/export/profile-backup.ts": 1019, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ca5b8cee3..618e52e47 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148109, + "total": 148064, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -54,7 +54,7 @@ "app/api/medications/intake/bulk/route.ts :: postBulk": 846, "app/api/medications/intake/route.ts :: apiHandler(arg) #2": 432, "app/api/medications/route.ts :: postMedication": 403, - "app/api/mental-health/assessments/route.ts :: postAssessment": 267, + "app/api/mental-health/assessments/route.ts :: postAssessment": 254, "app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 302, "app/api/mood-entries/bulk/route.ts :: postBulk": 479, "app/api/mood-entries/route.ts :: postMoodEntry": 329, @@ -210,8 +210,8 @@ "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, - "components/mental-health/check-in-wizard.tsx :: CheckInWizard": 205, - "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 209, + "components/mental-health/check-in-wizard.tsx :: CheckInWizard": 204, + "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, "components/mood/mood-form.tsx :: MoodForm": 513, @@ -313,9 +313,9 @@ "lib/dashboard/snapshot.ts :: buildExtras": 240, "lib/day/records.ts :: medicationsPart": 306, "lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648, - "lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 398, + "lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384, "lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509, - "lib/doctor-report/collect.ts :: collectDoctorReportData": 678, + "lib/doctor-report/collect.ts :: collectDoctorReportData": 668, "lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210, "lib/documents/fenced-chat.ts :: streamFencedReply": 309, "lib/documents/ingest.ts :: ingestDocument": 272, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 14f0c8f75..38081a16b 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2954, - "total": 10500, + "total": 10499, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1249,7 +1249,7 @@ "components/mental-health/crisis-card.tsx": 1, "components/mental-health/instrument-card.tsx": 5, "components/mental-health/instrument-detail.tsx": 3, - "components/mental-health/mental-wellbeing.tsx": 4, + "components/mental-health/mental-wellbeing.tsx": 3, "components/mental-health/types.ts": 2, "components/module-list/module-order-editor.tsx": 1, "components/module-list/module-view-toggle.tsx": 1, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 861553985..d60b0c635 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -37,7 +37,12 @@ vi.mock("@/lib/db", () => ({ userHealthProfile: { findUnique: vi.fn() }, healthProfileFactRevision: { findMany: vi.fn() }, // v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry. - mentalHealthAssessment: { findMany: vi.fn(async () => []) }, + // `groupBy` counts the screener totals answered in an unvalidated + // translation once a screener leaf is in the report. + mentalHealthAssessment: { + findMany: vi.fn(async () => []), + groupBy: vi.fn(async () => []), + }, // v1.44 — the per-sport workout block. workout: { findMany: vi.fn(async () => []) }, user: { findUnique: vi.fn(), update: vi.fn() }, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 49eb46428..da8a4a3f3 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -328,6 +328,28 @@ describe("", () => { expect(html).toContain("0.495"); }); + it("notes a screener total answered in an unvalidated translation under its row", () => { + const stats = { + SCI_SCORE: { avg: 18, min: 14, max: 22, count: 3, latest: 20 }, + PHQ9_SCORE: { avg: 6, min: 4, max: 8, count: 2, latest: 4 }, + }; + const selection = selectionFromLeaves(["SCI_SCORE", "PHQ9_SCORE"]); + const marked = render( + makeReport({ stats, screenerUnvalidatedTranslations: { SCI_SCORE: 2 } }), + { selection }, + ); + expect( + marked.split('data-slot="clinician-screener-translation-note"').length - + 1, + ).toBe(1); + expect(marked).toContain("2 of 3 results were answered in a translation"); + const plain = render( + makeReport({ stats, screenerUnvalidatedTranslations: null }), + { selection }, + ); + expect(plain).not.toContain("clinician-screener-translation-note"); + }); + it("renders the fenced wellness card with the descriptive disclaimer", () => { const html = render(makeReport()); expect(html).toContain("Wellness scores"); diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index 4a96a6d59..ce8a9a162 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -245,17 +245,35 @@ export function MeasurementGroups({ > {group.rows.map(({ type, stat }) => { const digits = reportStatFractionDigits(type, stat!, glucoseUnit); + const label = t(reportMeasurementLabelKey(type)); + // A screener total answered in a translation that has not been + // validated says so under its row, as the PDF does. + const unvalidated = + report.screenerUnvalidatedTranslations?.[type] ?? 0; return ( - + + + {unvalidated > 0 && ( +

+ {t("doctorReport.screenerUnvalidated", { + label, + count: unvalidated, + total: stat!.count, + })} +

+ )} +
); })} diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 8fd1fcc04..70e15692c 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -11,6 +11,7 @@ import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; import type { DoctorReportData } from "../doctor-report-data"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import { getServerTranslator } from "../i18n/server-translator"; +import { reportMeasurementLabelKey } from "../report-selection/catalogue"; async function extractText(bytes: Uint8Array): Promise { const parser = new PDFParse({ data: bytes }); @@ -251,6 +252,47 @@ describe("renderDoctorReportPdfBytes", () => { expect(text).not.toContain("mg/dL"); }); + it("marks a screener total answered in an unvalidated translation and says how many", async () => { + const data = makeData({ + stats: { + ...makeData().stats, + SCI_SCORE: { avg: 18, min: 14, max: 22, count: 3, latest: 20 }, + PHQ9_SCORE: { avg: 6, min: 4, max: 8, count: 2, latest: 4 }, + }, + screenerUnvalidatedTranslations: { SCI_SCORE: 2 }, + }); + const { t } = getServerTranslator("en"); + const bytes = renderDoctorReportPdfBytes(data, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t, + locale: "en", + now: FIXED_NOW, + }); + const text = (await extractText(bytes)).replace(/\s+/g, " "); + const sci = t(reportMeasurementLabelKey("SCI_SCORE")); + expect(text).toContain(`${sci} *`); + expect(text).toContain("2 of 3 results were answered in a translation"); + // The PHQ-9 row, all answered in validated wording, carries no mark. + const phq = t(reportMeasurementLabelKey("PHQ9_SCORE")); + expect(text).not.toContain(`${phq} *`); + }); + + it("prints no translation note when every total was answered in validated wording", async () => { + const bytes = renderDoctorReportPdfBytes( + makeData({ screenerUnvalidatedTranslations: null }), + { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }, + ); + const text = (await extractText(bytes)).replace(/\s+/g, " "); + expect(text).not.toContain("not been scientifically validated"); + }); + it("omits the sleep vitals row when there are no sleep stats", async () => { const bytes = renderDoctorReportPdfBytes(makeData(), { timeFormat: "AUTO", diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index 584efdd22..096664de3 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -25,12 +25,14 @@ import { lastNonSkippedTakenAt, tallyComplianceFromLedger, } from "@/lib/analytics/compliance"; -import type { - CanonicalCollapseRow, - DoctorReportCompliance, - DoctorReportComplianceIntake, - DoctorReportComplianceMedication, - DoctorReportRange, +import { + WELLNESS_SCORE_REPORT_TYPES, + type CanonicalCollapseRow, + type DoctorReportCompliance, + type DoctorReportComplianceIntake, + type DoctorReportComplianceMedication, + type DoctorReportRange, + type DoctorReportStats, } from "./doctor-report-types"; /** @@ -188,6 +190,46 @@ export function summariseCanonicalRecovery( }; } +/** + * The wellness summary of a report: one row per wellness score leaf the + * report admits and holds readings for, from the same stats the measurement + * table prints, so the two cannot disagree. RECOVERY_SCORE carries both the + * native row and the computed proxy for one day; it resolves to the canonical + * row so the PDF reads the SAME value the tile shows. Null when no row is + * left. + */ +export function summariseWellnessScores(input: { + admits: (type: (typeof WELLNESS_SCORE_REPORT_TYPES)[number]) => boolean; + stats: Readonly>; + byType: Readonly>>; + measurements: readonly RecoveryMeasurementRow[]; + timezone: string; +}): WellnessScoreSummary[] | null { + const { admits, stats, byType, measurements, timezone } = input; + const summaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { + if (!admits(type)) return []; + if (type === "RECOVERY_SCORE") { + const summary = summariseCanonicalRecovery(measurements, timezone); + return summary ? [summary] : []; + } + const s = stats[type]; + const rows = byType[type]; + if (!s || !rows || rows.length === 0) return []; + return [ + { + type, + latest: Math.round(s.latest), + avg: Math.round(s.avg), + min: Math.round(s.min), + max: Math.round(s.max), + count: s.count, + latestAt: rows[rows.length - 1].measuredAt, + }, + ]; + }); + return summaries.length > 0 ? summaries : null; +} + /** * Validate and normalise the requested reporting window. * Accepts an unknown value (typically `body.days`); falls back to 90. diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 91e19f5a6..9f559f1a5 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -184,6 +184,80 @@ function groupSubHeadRow(label: string): VitalTableRow { ]; } +/** + * Per-context glucose rows. Values are stored canonically in mg/dL and print + * in the owner's unit, in the precision their readings carry. + */ +function glucoseContextTableRows( + context: DoctorReportPdfRenderContext, + glucoseUnit: ReturnType, +): string[][] { + const { data, t, num } = context; + const rows: string[][] = []; + for (const ctx of GLUCOSE_CONTEXTS) { + const s = data.glucoseStats?.[ctx]; + if (!s) continue; + const digits = reportStatFractionDigits("BLOOD_GLUCOSE", s, glucoseUnit); + const show = (v: number) => num(convertGlucose(v, glucoseUnit), digits); + rows.push([ + t(GLUCOSE_LABEL_KEYS[ctx]), + `${show(s.latest)} ${glucoseUnit}`.trim(), + `${show(s.avg)} ${glucoseUnit}`.trim(), + show(s.min), + show(s.max), + String(s.count), + ]); + } + return rows; +} + +/** + * A row's label. A screener total answered in a translation that has not + * been validated carries a mark, which the line under the table explains. + */ +function vitalRowLabel( + context: DoctorReportPdfRenderContext, + type: string, +): string { + const label = context.t(reportMeasurementLabelKey(type as MeasurementType)); + return context.data.screenerUnvalidatedTranslations?.[type] + ? `${label} *` + : label; +} + +/** One line under the table per marked screener: how many, of how many. */ +function drawScreenerTranslationNotes( + context: DoctorReportPdfRenderContext, + startY: number, +): number { + const { doc, data, t, margin, pageWidth, ensureSpace } = context; + const notes = Object.entries(data.screenerUnvalidatedTranslations ?? {}) + .filter(([type, count]) => count > 0 && data.stats[type]) + .map(([type, count]) => + t("doctorReport.screenerUnvalidated", { + label: t(reportMeasurementLabelKey(type as MeasurementType)), + count, + total: data.stats[type].count, + }), + ); + if (notes.length === 0) return startY; + let y = startY; + doc.setFontSize(8); + doc.setFont("helvetica", "italic"); + doc.setTextColor(110, 110, 110); + for (const note of notes) { + for (const line of doc.splitTextToSize( + `* ${note}`, + pageWidth - 2 * margin, + )) { + y = ensureSpace(y, 4); + doc.text(line, margin, y); + y += 4; + } + } + return y + 4; +} + export function buildMeasurementsChartsSection( context: DoctorReportPdfRenderContext, state: DoctorReportPdfCursorState, @@ -219,25 +293,9 @@ export function buildMeasurementsChartsSection( // leaf that was withheld or has no reading — the table cannot over-serve. const vitalRows: VitalTableRow[] = []; - // Per-context glucose rows. Values stored canonically in mg/dL — convert to - // the user's display unit. They belong under the glucose group heading with - // the raw series, not appended after the whole table. + // Per-context glucose rows, in the owner's unit, under the glucose heading. const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); - const glucoseContextRows: string[][] = []; - for (const ctx of GLUCOSE_CONTEXTS) { - const s = data.glucoseStats?.[ctx]; - if (!s) continue; - const digits = reportStatFractionDigits("BLOOD_GLUCOSE", s, glucoseUnit); - const show = (v: number) => num(convertGlucose(v, glucoseUnit), digits); - glucoseContextRows.push([ - t(GLUCOSE_LABEL_KEYS[ctx]), - `${show(s.latest)} ${glucoseUnit}`.trim(), - `${show(s.avg)} ${glucoseUnit}`.trim(), - show(s.min), - show(s.max), - String(s.count), - ]); - } + const glucoseContextRows = glucoseContextTableRows(context, glucoseUnit); for (const group of vitalGroups) { const groupRows: string[][] = []; @@ -253,7 +311,7 @@ export function buildMeasurementsChartsSection( const show = (v: number) => num(reportStatValue(type, v, glucoseUnit), digits); groupRows.push([ - t(reportMeasurementLabelKey(type as MeasurementType)), + vitalRowLabel(context, type), `${show(s.latest)} ${unit}`.trim(), `${show(s.avg)} ${unit}`.trim(), show(s.min), @@ -307,6 +365,8 @@ export function buildMeasurementsChartsSection( .finalY + 8; } + y = drawScreenerTranslationNotes(context, y); + // v1.18.0 — clinical glucose panel (TIR / GMI / eA1C / mean / CV%). The // server already computes this (`data.glucoseClinical`) by the one // literature-locked engine the insights panel + coach consume, and zeroes it diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 24430b282..22e8a5aab 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -236,6 +236,13 @@ export interface DoctorReportData { * user has no computed scores in the window (the renderer + FHIR builder * then skip the section). Optional so pre-v1.10 fixtures still typecheck. */ + /** + * Per screener score type (`PHQ9_SCORE`, `SCI_SCORE`, …), how many + * of the window's results were answered in a translation that has not been + * validated. The renderers note them beside the type's row, so a clinician + * reads those totals as orientation. Null (or absent) when there are none. + */ + screenerUnvalidatedTranslations?: Record | null; wellnessScores?: Array<{ /** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */ type: string; diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 371d49918..541dcd403 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -42,7 +42,6 @@ import { import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue"; import type { ReportSelection } from "@/lib/report-selection/selection"; import { - WELLNESS_SCORE_REPORT_TYPES, type CollectDoctorReportOptions, type DoctorReportData, type DoctorReportMood, @@ -54,7 +53,7 @@ import { collapseMeasurementsToCanonical, minMaxOf, sanitisePracticeName, - summariseCanonicalRecovery, + summariseWellnessScores, } from "@/lib/doctor-report-helpers"; import { excludedMeasurementTypes, @@ -81,6 +80,7 @@ import { } from "./clinical-records"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { readingsMean } from "@/lib/measurements/day-mean"; +import { loadScreenerUnvalidatedTranslations } from "./screener-translations"; const DENSE_REPORT_RAW_WINDOW_DAYS = 90; @@ -620,33 +620,22 @@ export async function collectDoctorReportData( }) : null; - // The wellness summary is filtered to the same score leaves the stats carry, - // so it cannot disagree with them. RECOVERY_SCORE carries both the native row - // and the computed proxy for one day; resolve to the canonical row so the PDF - // reads the SAME value the tile shows. - const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { - if (!gate.admits(type)) return []; - if (type === "RECOVERY_SCORE") { - const summary = summariseCanonicalRecovery(measurements, reportTz); - return summary ? [summary] : []; - } - const s = stats[type]; - const rows = byType[type]; - if (!s || !rows || rows.length === 0) return []; - return [ - { - type, - latest: Math.round(s.latest), - avg: Math.round(s.avg), - min: Math.round(s.min), - max: Math.round(s.max), - count: s.count, - latestAt: rows[rows.length - 1].measuredAt, - }, - ]; + // The wellness summary reads the same score leaves the stats carry. + const wellnessScores = summariseWellnessScores({ + admits: (type) => gate.admits(type), + stats, + byType, + measurements, + timezone: reportTz, }); - const wellnessScores = - wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null; + // Screener totals answered in an unvalidated translation, per score type. + const screenerUnvalidatedTranslations = + await loadScreenerUnvalidatedTranslations( + userId, + filteredStats, + start, + end, + ); // Each remaining structured block: read only when its leaf was chosen. const [ @@ -787,6 +776,7 @@ export async function collectDoctorReportData( mood, glp1, wellnessScores, + screenerUnvalidatedTranslations, cycle, labResults, illnessEpisodes, diff --git a/src/lib/doctor-report/screener-translations.ts b/src/lib/doctor-report/screener-translations.ts new file mode 100644 index 000000000..5fd13fb39 --- /dev/null +++ b/src/lib/doctor-report/screener-translations.ts @@ -0,0 +1,50 @@ +/** + * Which screener totals in a report were answered in a translation that has + * not been validated. + * + * A questionnaire in a language without a validated version is shown in a + * translation written for HealthLog, and its result reads as orientation. + * The report's renderers mark those totals beside the score type's row, so a + * clinician reads them the same way. Counted per score type the report + * carries, over the report window; the table is not asked at all when no + * screener total is in the report. + */ +import { prisma } from "@/lib/db"; +import { + INSTRUMENT_MEASUREMENT_TYPE, + INSTRUMENT_ORDER, +} from "@/lib/mental-health/instruments"; + +/** + * Per score type (`SCI_SCORE`, …), the number of the window's administrations + * answered in an unvalidated translation. Null when there are none, which is + * every report of a reader whose language has validated versions. + */ +export async function loadScreenerUnvalidatedTranslations( + userId: string, + stats: Readonly>, + start: Date, + end: Date, +): Promise | null> { + const instruments = INSTRUMENT_ORDER.filter( + (id) => stats[INSTRUMENT_MEASUREMENT_TYPE[id]] !== undefined, + ); + if (instruments.length === 0) return null; + const groups = await prisma.mentalHealthAssessment.groupBy({ + by: ["instrument"], + where: { + userId, + deletedAt: null, + wording: "unvalidated_translation", + instrument: { in: [...instruments] }, + takenAt: { gte: start, lte: end }, + }, + _count: { _all: true }, + }); + const counts: Record = {}; + for (const id of instruments) { + const count = groups.find((g) => g.instrument === id)?._count._all ?? 0; + if (count > 0) counts[INSTRUMENT_MEASUREMENT_TYPE[id]] = count; + } + return Object.keys(counts).length > 0 ? counts : null; +} diff --git a/tests/integration/mental-health-wording.test.ts b/tests/integration/mental-health-wording.test.ts index 76819a1f4..5414872b9 100644 --- a/tests/integration/mental-health-wording.test.ts +++ b/tests/integration/mental-health-wording.test.ts @@ -10,7 +10,9 @@ * `unvalidated_translation`; * - the switch records `original_en`; * - English records `validated` whatever the switch says. - * The PHQ-9 crisis signpost answers item 9 the same way in every wording. + * The PHQ-9 crisis signpost answers item 9 the same way in every wording, + * and the doctor report counts the totals answered in an unvalidated + * translation per score type, so its renderers can mark them. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +21,8 @@ import { POST as createAssessment, } from "@/app/api/mental-health/assessments/route"; import { GET as readAssessment } from "@/app/api/mental-health/assessments/[id]/route"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; import { callRoute } from "../kit/call-route"; import { makeSession, makeUser, type SessionFixture } from "../kit/factories"; @@ -181,3 +185,39 @@ describe("the wording a check-in records", () => { expect(await getPrismaClient().mentalHealthAssessment.count()).toBe(0); }); }); + +describe("the doctor report", () => { + it("counts the screener totals answered in an unvalidated translation, per score type", async () => { + await submit({ instrument: "SCI", items: SCI, locale: "nl" }); + await submit({ instrument: "SCI", items: SCI, locale: "nl" }); + await submit({ + instrument: "SCI", + items: SCI, + locale: "nl", + englishOriginal: true, + }); + await submit({ instrument: "PHQ9", items: PHQ9_ITEM9, locale: "nl" }); + + const now = Date.now(); + const range = { + start: new Date(now - 7 * 86_400_000), + end: new Date(now + 60_000), + days: 8, + }; + const report = await collectDoctorReportData( + session.userId, + range, + selectionFromLeaves(["SCI_SCORE", "PHQ9_SCORE"]), + ); + expect(report.stats.SCI_SCORE?.count).toBe(3); + expect(report.screenerUnvalidatedTranslations).toEqual({ SCI_SCORE: 2 }); + + // Without the screener leaves the table is not asked at all. + const withoutScreeners = await collectDoctorReportData( + session.userId, + range, + selectionFromLeaves(["WEIGHT"]), + ); + expect(withoutScreeners.screenerUnvalidatedTranslations).toBeNull(); + }); +});