Files
HealthLog/src/lib/doctor-report-pdf/header-profile-section.ts
T
Marc-André Bombeck f74efc44aa fix(profile): let the third gender value through every surface that handles it
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.

Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.

The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.

Refs #645
2026-07-26 17:09:03 +02:00

157 lines
4.8 KiB
TypeScript

import { buildClinicalSummaryLines } from "./clinical-summary";
import {
pdfCursorState,
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
export function buildHeaderProfileSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const {
doc,
data,
t,
num,
fmtDate,
now,
insuranceNumber,
margin,
pageWidth,
contentMaxY,
ensureSpace,
} = context;
let y = state.y;
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.title"), margin, y);
y += 8;
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 100, 100);
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
// Practice / clinic name on the cover. Rendered prominently above the
// separator so the addressee is the first thing the doctor sees when
// skimming the printout. Skipped entirely when the user did not supply
// one — keeps the cover compact for self-archive use.
if (data.practiceName) {
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
margin,
y,
);
y += 6;
doc.setFont("helvetica", "normal");
}
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(80, 80, 80);
const patientInfo: string[] = [];
// v1.7.0 — prefer the legal full name on the cover; fall back to the
// username when no full name is set (collapses exactly like the
// practice name when neither is present).
const patientName = data.patient.fullName ?? data.patient.username ?? null;
if (patientName) {
patientInfo.push(`${t("doctorReport.patient")}: ${patientName}`);
}
if (data.patient.dateOfBirth) {
patientInfo.push(
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
);
}
if (data.patient.gender) {
// Only the three stored values get a label. A row on a clinical
// document is a positive claim, so an unrecognised string leaves the
// line out rather than asserting a third value the account never chose.
const genderKeys: Record<string, string> = {
MALE: "doctorReport.genderMale",
FEMALE: "doctorReport.genderFemale",
OTHER: "doctorReport.genderOther",
};
const genderKey = genderKeys[data.patient.gender];
if (genderKey) {
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
}
}
if (data.patient.heightCm) {
patientInfo.push(
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
);
}
// v1.7.0 — optional insurer + KVNR. Each line collapses when its field
// is unset, mirroring the existing practice-name behaviour.
if (data.patient.insurerName) {
patientInfo.push(
`${t("doctorReport.insurer")}: ${data.patient.insurerName}`,
);
}
if (insuranceNumber) {
patientInfo.push(
`${t("doctorReport.insuranceNumber")}: ${insuranceNumber}`,
);
}
// Reporting period uses the explicit `start`/`end` from the data payload
// (set by `normaliseDateRange()`). For older payloads that only carried
// `since`, fall back to (since, now()) to preserve previous behaviour.
const periodStart = data.period.start ?? data.period.since;
const periodEnd = data.period.end ?? now.toISOString();
patientInfo.push(
`${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`,
);
patientInfo.push(
`${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`,
);
for (const line of patientInfo) {
doc.text(line, margin, y);
y += 4.5;
}
y += 4;
// v1.7.0 — deterministic clinical-summary block. Pure data (NOT AI),
// built from the same aggregated stats the tables print. Gives a
// physician a one-paragraph orientation before the detail tables.
const summaryLines = buildClinicalSummaryLines(data, t, num);
if (summaryLines.length > 0) {
// Keep the heading with at least its first line (heading 5 mm + one body
// line 4.5 mm + trailing gap).
y = ensureSpace(y, 5 + 4.5 + 4);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.summaryTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setTextColor(60, 60, 60);
for (const line of summaryLines) {
const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin);
for (const w of wrapped) {
if (y + 4.5 > contentMaxY) {
doc.addPage();
y = margin;
}
doc.text(w, margin, y);
y += 4.5;
}
}
y += 4;
}
return pdfCursorState(doc, y);
}