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

@@ -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;
}