feat(doctor-report): mark screener totals answered in an unvalidated translation

The report data counts, per screener score type, how many of the
window's results were answered in a translation that has not been
validated (`screenerUnvalidatedTranslations`, read only when a screener
total is in the report). The PDF marks such a row with an asterisk and
says under the vitals table how many of how many read as orientation;
the clinician share view says the same under the row.

To keep the aggregator and the PDF section from growing, the wellness
summary and the per-context glucose rows move into helpers; the size
ratchets only fall.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 08:14:32 +02:00
1 parent 02cc0631fa
commit 14273051db
22 files changed
+369 -85

No files matched your search

+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -435,7 +435,8 @@
"autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음",
"window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음",
"windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다."
}
},
"screenerUnvalidated": "{label}: {total}개 결과 중 {count}개는 과학적으로 검증되지 않은 번역으로 응답했습니다. 참고용으로만 보십시오."
},
"errorBoundary": {
"title": "문제가 생겼어요",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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,
@@ -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,
@@ -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,
@@ -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() },
@@ -328,6 +328,28 @@ describe("<ClinicianView>", () => {
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");
+28 -10
View File
@@ -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 (
<StatRow
key={type}
label={t(reportMeasurementLabelKey(type))}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
min: show(type, stat!.min, digits),
max: show(type, stat!.max, digits),
})}
/>
<Fragment key={type}>
<StatRow
label={label}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
min: show(type, stat!.min, digits),
max: show(type, stat!.max, digits),
})}
/>
{unvalidated > 0 && (
<p
className="text-muted-foreground text-xs"
data-slot="clinician-screener-translation-note"
>
{t("doctorReport.screenerUnvalidated", {
label,
count: unvalidated,
total: stat!.count,
})}
</p>
)}
</Fragment>
);
})}
</Section>
@@ -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<string> {
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",
+48 -6
View File
@@ -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<Record<string, DoctorReportStats>>;
byType: Readonly<Record<string, ReadonlyArray<{ measuredAt: string }>>>;
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.
@@ -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<typeof resolveGlucoseUnit>,
): 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
+7
View File
@@ -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<string, number> | null;
wellnessScores?: Array<{
/** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */
type: string;
+18 -28
View File
@@ -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,
@@ -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<Record<string, unknown>>,
start: Date,
end: Date,
): Promise<Record<string, number> | 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<string, number> = {};
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;
}
@@ -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();
});
});