mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
02cc0631fa
commit
14273051db
22 files changed
+369
-85
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -435,7 +435,8 @@
|
||||
"autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음",
|
||||
"window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음",
|
||||
"windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {total}개 결과 중 {count}개는 과학적으로 검증되지 않은 번역으로 응답했습니다. 참고용으로만 보십시오."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "문제가 생겼어요",
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user