From 28467b2590f8076bd41d173c4bc7f84188e0cd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 21:36:47 +0200 Subject: [PATCH] feat(doctor-report): practice name on cover page (persisted as user preference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc wanted the addressee visible at first glance on the printout — "Praxis Dr. Müller" should be the first thing the GP sees, so the report doubles as a polite opening line. - New optional free-text field "Praxis / clinic name" in the export dialog. The dialog pre-fills it with the user's last-used value so recurring exports don't require re-typing. - Persisted as a user preference: new `User.lastReportPracticeName` column (migration `0031_user_last_report_practice_name`). The value is updated only on a successful POST that supplied a non-empty string — passing `null` does NOT clear the stored preference so the value sticks across years of follow-ups. Best-effort write: a DB failure here MUST NOT break the report (caught + ignored). - `sanitisePracticeName()` enforces a strict envelope upstream: strips ASCII C0 + DEL controls (which jsPDF can't render), collapses whitespace, and hard-caps at 120 characters. Defends the PDF layout from runaway input + protects the PDF text stream from control-character corruption. - PDF cover prints the line in 11pt bold above the separator when set; omitted entirely when `null` so the cover stays compact for self-archive use. Both client (`doctor-report-pdf.ts`) and server (`doctor-report-pdf-core.ts`) renderers carry the change so the visual output matches whichever path the user takes (browser download vs iOS Safari fallback). - `/api/auth/me` now echoes `lastReportPracticeName` so the dialog pre-fills without an extra round-trip. - Audit-log + Wide-Event annotations record `practiceNameProvided: boolean` (NOT the value itself — practice names are personal and we already store one canonical copy on the user record). i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`) in EN + DE. Tests: 12 new unit cases covering sanitisation (whitespace, controls, length cap, type guard), PDF cover rendering (DE label "Praxis:", EN "Practice:", omission when null), API persistence (prisma.user.update called with the right shape, audit detail flag set). 965 / 965 unit pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6 files (pre-existing dashboard-layout.test.ts errors are A4 territory). v1.4.15 phase B6. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 5 +- messages/en.json | 5 +- .../migration.sql | 20 ++++++++ prisma/schema.prisma | 6 +++ src/app/api/auth/me/route.ts | 1 + .../doctor-report/__tests__/pdf-route.test.ts | 28 +++++++++- src/app/api/doctor-report/pdf/route.ts | 20 +++++++- src/app/api/doctor-report/route.ts | 33 ++++++++++-- .../doctor-report/doctor-report-dialog.tsx | 48 ++++++++++++++---- src/components/settings/advanced-section.tsx | 29 ++++++++++- src/lib/__tests__/doctor-report-data.test.ts | Bin 3494 -> 4837 bytes .../__tests__/doctor-report-pdf-core.test.ts | 39 +++++++++++++- src/lib/doctor-report-data.ts | Bin 11103 -> 12603 bytes src/lib/doctor-report-pdf-core.ts | 17 +++++++ src/lib/doctor-report-pdf.ts | 17 +++++++ 15 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 prisma/migrations/0031_user_last_report_practice_name/migration.sql diff --git a/messages/de.json b/messages/de.json index fe0d293e5..aa52c49aa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -121,11 +121,14 @@ "glucoseInTarget": "im Ziel", "glucoseBelowTarget": "unter Ziel", "glucoseAboveTarget": "über Ziel", + "practiceLabel": "Praxis", "dialog": { "title": "Arztbericht (PDF)", - "description": "Lege den Auswertungszeitraum fest — er erscheint auf dem Deckblatt.", + "description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.", "startLabel": "Von", "endLabel": "Bis", + "practiceLabel": "Praxis / Klinik (optional)", + "practicePlaceholder": "z. B. Dr. Müller — Hausarztpraxis", "preset90d": "Letzte 90 Tage", "preset180d": "Letzte 6 Monate", "preset365d": "Letzte 12 Monate", diff --git a/messages/en.json b/messages/en.json index 2326125f7..9e6268cae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -121,11 +121,14 @@ "glucoseInTarget": "in target", "glucoseBelowTarget": "below target", "glucoseAboveTarget": "above target", + "practiceLabel": "Practice", "dialog": { "title": "Doctor report (PDF)", - "description": "Pick the reporting period; it will be printed on the cover.", + "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", "startLabel": "Start date", "endLabel": "End date", + "practiceLabel": "Practice / clinic name (optional)", + "practicePlaceholder": "e.g. Dr. Müller — Family practice", "preset90d": "Last 90 days", "preset180d": "Last 6 months", "preset365d": "Last 12 months", diff --git a/prisma/migrations/0031_user_last_report_practice_name/migration.sql b/prisma/migrations/0031_user_last_report_practice_name/migration.sql new file mode 100644 index 000000000..875f32af0 --- /dev/null +++ b/prisma/migrations/0031_user_last_report_practice_name/migration.sql @@ -0,0 +1,20 @@ +-- v1.4.15 Phase B6: doctor-report v2 — practice / clinic name preference. +-- +-- The doctor-report cover used to be impersonal: title, subtitle, separator. +-- Marc asked for the addressee (his GP's practice name) to be visible at +-- first glance so the printout doubles as an opening line ("Praxis Dr. X — +-- here is what I tracked since the last visit"). +-- +-- We persist the most-recent value the user typed into the export dialog +-- so the input is pre-filled next time. Stored as a plain TEXT column — +-- this is not PII a third party would care about and the user already has +-- write-access to it, but the field is sanitised + length-capped upstream +-- (`sanitisePracticeName()` in `src/lib/doctor-report-data.ts`) before any +-- value lands here. +-- +-- The migration is a no-op for existing rows: the column is nullable with +-- no default; users who never open the doctor-report keep `NULL` and the +-- cover line is omitted entirely. + +ALTER TABLE "users" + ADD COLUMN "last_report_practice_name" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75c399607..0b9aea443 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -97,6 +97,12 @@ model User { // Display name (optional override of username for greetings). displayName String? @map("display_name") + // Doctor-report personalization (v1.4.15 phase B6). + // Persisted between report exports so the dialog can pre-fill the last-used + // practice / clinic name. Free-text, hard-capped to 120 chars upstream by + // `sanitisePracticeName()`. Null = user has never set one. + lastReportPracticeName String? @map("last_report_practice_name") + // Relations passkeys Passkey[] sessions Session[] diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index ac1b786e5..3c31b6246 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -23,5 +23,6 @@ export const GET = apiHandler(async () => { onboardingTourCompleted: user.onboardingTourCompleted, gravatarUrl: user.email ? getGravatarUrl(user.email) : null, glucoseUnit: user.glucoseUnit ?? null, + lastReportPracticeName: user.lastReportPracticeName ?? null, }); }); diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts index da6cc24bb..37a83ba9f 100644 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn() }, auditLog: { create: vi.fn() }, }, })); @@ -241,4 +241,30 @@ describe("POST /api/doctor-report/pdf", () => { ); }); + it("persists practiceName as a user preference and includes it in audit", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + vi.mocked(auditLog).mockResolvedValue(undefined as never); + setEmptyDataMocks(); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await POST( + makeRequest({ days: 30, practiceName: "Praxis Dr. Müller" }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: "Praxis Dr. Müller" }, + }); + expect(auditLog).toHaveBeenCalledWith( + "doctor-report.pdf.generate", + expect.objectContaining({ + details: expect.objectContaining({ practiceNameProvided: true }), + }), + ); + }); }); diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts index 04ef4a540..5b8588998 100644 --- a/src/app/api/doctor-report/pdf/route.ts +++ b/src/app/api/doctor-report/pdf/route.ts @@ -7,7 +7,9 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { collectDoctorReportData, normaliseDateRange, + sanitisePracticeName, } from "@/lib/doctor-report-data"; +import { prisma } from "@/lib/db"; import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; @@ -42,8 +44,21 @@ export const POST = apiHandler(async (request: NextRequest) => { body?.locale, request.headers.get("accept-language"), ); + const rawPracticeName = body?.practiceName; + const practiceName = sanitisePracticeName(rawPracticeName); - const data = await collectDoctorReportData(user.id, range); + if (typeof rawPracticeName === "string" && practiceName !== null) { + try { + await prisma.user.update({ + where: { id: user.id }, + data: { lastReportPracticeName: practiceName }, + }); + } catch { + // Best-effort; never let preference persistence break the report. + } + } + + const data = await collectDoctorReportData(user.id, range, { practiceName }); const { t } = getServerTranslator(locale); const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale }); @@ -56,6 +71,7 @@ export const POST = apiHandler(async (request: NextRequest) => { startDate: range.start.toISOString(), endDate: range.end.toISOString(), locale, + practiceNameProvided: practiceName !== null, }, }); @@ -66,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => { report_end: range.end.toISOString(), locale, bytes: pdfBytes.byteLength, + practice_name_provided: practiceName !== null, }, }); @@ -89,6 +106,7 @@ interface PdfRequestBody { startDate?: unknown; endDate?: unknown; locale?: unknown; + practiceName?: unknown; } /** diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts index 1770fa245..4049375fe 100644 --- a/src/app/api/doctor-report/route.ts +++ b/src/app/api/doctor-report/route.ts @@ -12,7 +12,9 @@ import { NextRequest } from "next/server"; import { collectDoctorReportData, normaliseDateRange, + sanitisePracticeName, } from "@/lib/doctor-report-data"; +import { prisma } from "@/lib/db"; /** * Collect data for doctor report PDF generation (client-side). @@ -20,9 +22,10 @@ import { * * Body shape (all fields optional — sensible defaults apply): * { - * startDate?: string, // ISO timestamp, inclusive - * endDate?: string, // ISO timestamp, inclusive - * days?: number // legacy "last N days" fallback (1..365) + * startDate?: string, // ISO timestamp, inclusive + * endDate?: string, // ISO timestamp, inclusive + * days?: number, // legacy "last N days" fallback (1..365) + * practiceName?: string // free-text, persisted as user preference * } * * Resolution: explicit range wins; otherwise `days` (default 90) is applied. @@ -44,7 +47,27 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const range = normaliseDateRange(body); - const data = await collectDoctorReportData(user.id, range); + const rawPracticeName = (body as Record | null) + ?.practiceName; + const practiceName = sanitisePracticeName(rawPracticeName); + + // Persist the most-recent practice name as a user preference so the + // dialog can pre-fill it next time. We only write when the caller + // actually supplied a non-empty string — passing `null`/empty does NOT + // clear the stored preference (use the dedicated profile endpoint for + // that). Best-effort: a write failure here MUST NOT break the report. + if (typeof rawPracticeName === "string" && practiceName !== null) { + try { + await prisma.user.update({ + where: { id: user.id }, + data: { lastReportPracticeName: practiceName }, + }); + } catch { + // Non-fatal — preference persistence is a UX nicety, not a contract. + } + } + + const data = await collectDoctorReportData(user.id, range, { practiceName }); await auditLog("doctor-report.generate", { userId: user.id, @@ -53,6 +76,7 @@ export const POST = apiHandler(async (request: NextRequest) => { days: range.days, startDate: range.start.toISOString(), endDate: range.end.toISOString(), + practiceNameProvided: practiceName !== null, }, }); @@ -61,6 +85,7 @@ export const POST = apiHandler(async (request: NextRequest) => { report_days: range.days, report_start: range.start.toISOString(), report_end: range.end.toISOString(), + practice_name_provided: practiceName !== null, }, }); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 2f538b372..782023c44 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -3,8 +3,8 @@ /** * Doctor-report export dialog (v1.4.15 phase B6). * - * Wraps the trigger button so the user picks a reporting period before the - * PDF is generated. + * Wraps the trigger button so the user picks a reporting period and + * (optionally) a practice / clinic name before the PDF is generated. * * Defaults: * - end = today @@ -34,16 +34,20 @@ import { useTranslations } from "@/lib/i18n/context"; const ONE_DAY_MS = 86_400_000; const MAX_RANGE_DAYS = 730; +const PRACTICE_NAME_MAX_LENGTH = 120; export interface DoctorReportSubmitPayload { startDate: string; endDate: string; + practiceName: string | null; } interface DoctorReportDialogProps { /** Controlled open state. */ open: boolean; onOpenChange: (open: boolean) => void; + /** Last-used practice name from `/api/auth/me`; pre-fills the input. */ + defaultPracticeName?: string | null; /** * Resolves with the user-confirmed payload. The dialog stays open and * shows the spinner while the promise is pending; closes on resolve. @@ -73,25 +77,28 @@ function defaultStartIso(): string { export function DoctorReportDialog({ open, onOpenChange, + defaultPracticeName, onSubmit, }: DoctorReportDialogProps) { const { t } = useTranslations(); const [startDate, setStartDate] = useState(defaultStartIso); const [endDate, setEndDate] = useState(todayIso); + const [practiceName, setPracticeName] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Reset on open: re-anchor dates to defaults each time the dialog appears - // so a follow-up export doesn't reuse the previous custom range. Using - // the React-recommended "track-the-trigger" pattern (store the last - // observed `open` state and react during render) so the reset happens in - // the render phase rather than a setState-in-effect — that's the cleanest - // way to satisfy the strict `react-hooks/set-state-in-effect` rule. + // Reset on open: re-anchor dates + practice-name to defaults each time + // the dialog appears so a follow-up export doesn't reuse the previous + // custom range. Using the React-recommended "track-the-trigger" pattern + // (store the last observed `open` state and react during render) so the + // reset happens in the render phase rather than a setState-in-effect — + // satisfies the strict `react-hooks/set-state-in-effect` rule. const [lastOpen, setLastOpen] = useState(false); if (open && !lastOpen) { setLastOpen(true); setStartDate(defaultStartIso()); setEndDate(todayIso()); + setPracticeName(defaultPracticeName ?? ""); setError(null); setSubmitting(false); } else if (!open && lastOpen) { @@ -143,7 +150,15 @@ export function DoctorReportDialog({ // captures everything logged on each boundary day. const startIso = new Date(`${startDate}T00:00:00`).toISOString(); const endIso = new Date(`${endDate}T23:59:59.999`).toISOString(); - await onSubmit({ startDate: startIso, endDate: endIso }); + const trimmedPractice = practiceName.trim(); + await onSubmit({ + startDate: startIso, + endDate: endIso, + practiceName: + trimmedPractice.length > 0 + ? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH) + : null, + }); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { @@ -225,6 +240,21 @@ export function DoctorReportDialog({ +
+ + setPracticeName(e.target.value)} + autoComplete="organization" + /> +
+ {error && (

(null); + + // Fetch the previously-stored practice name so the dialog can pre-fill it. + // `useEffect` is the right place for a one-shot side-effect on mount; the + // ESLint `set-state-in-effect` rule is fine here because we are not + // setting state on every render — we are reacting to a fetch. + useEffect(() => { + let cancelled = false; + fetch("/api/auth/me") + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + if (cancelled || !json) return; + const name = json?.data?.lastReportPracticeName; + if (typeof name === "string") setLastPracticeName(name); + }) + .catch(() => { + // Best-effort; the dialog still works without the pre-fill. + }); + return () => { + cancelled = true; + }; + }, []); async function handleExport(format: "csv" | "json") { setExporting(true); @@ -83,6 +105,7 @@ function ExportCard() { body: JSON.stringify({ startDate: payload.startDate, endDate: payload.endDate, + practiceName: payload.practiceName, }), }); if (!res.ok) return; @@ -93,6 +116,9 @@ function ExportCard() { const doc = generateDoctorReportPDF(json.data, { t, locale }); const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); + // Remember the last-used practice name so the next open of the dialog + // pre-fills it without a roundtrip to /api/auth/me. + if (payload.practiceName) setLastPracticeName(payload.practiceName); setReportDialogOpen(false); } finally { setGeneratingReport(false); @@ -142,6 +168,7 @@ function ExportCard() { diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index a974efe8b246c668a4b7c054a8faacb643d45b0d..ea31002923db6a7b9e477c4f5936743c64da98c7 100644 GIT binary patch literal 4837 zcmcIoTW{k;6y^=7zvAE*9Fe$Ay4!BL6bUGTO3)%&2?XU~Vo#F+XKZH1y=+zS$9Ut9 zan6kG*p6djw5(RUmUHIJxqRQbSgf;D8F+@6>qvMs;Ex%P47|XdO4s1s3&XYX z-X0u?LjC|irfSU+q4_y`(&Od}!+&6M$?2_TQW%u_Ua`oCh+ncbzArTg!C{<6CRG!~ z$&yLT45N!mdwj2Pk2eN?1ZSVYGpZKG_Y^m|k{Zm4!#zuK4i76X@u!b9FnS|m5Yq4^ zSNK2tG9AOw@#|k3dZ6&vZ(QY1N29=`UvPYQi?{8NnunzrKK01qt)(D6lH?2iTexw@ zl0?ITMZbedVIIO=O7@oaXSvzO;#|i3PDmb?H}#ver{$9>5dG?oj*s`d_avx7(XY{) zhu(fFWu7E0$y#(3*Mw(6M3ifTkfwN3v4i#qXI(4cWiluB*j^6E!8 z1IZuYoEh$Wvv77i313gb`OS288ix2c2*dDaZv&BBa|d%IXCfkDKoQ3r-WRD^0dfaL zn6gYj+%p-2&X|Nx&*0=J#NVHE!xjK%jZ=j7Y$#or?pxr4r5jNI`D~anWb`Q%R?jBk z^v0T8Sxv-uf4Gbr!sy~I;g|9h2FE?x5LdjjRtENd9DiVa(D=D=2)kmTx?ZJtcxMfh`CgTgp870i+fH1pG5e(32Z!ERrQ@8l#HUjyqR0o zHNZB@LBh0|%tN|UyL7vR2@PwZHO63QR-a$S1i`Mj)a+`L(;2=iaHy{pP*)8d#GLOu zz9#t8q2D&65D7H5DDxW!gJtn{1QV>1UH0$BM^jT1K_!>B%du@I1&RS9zTjAz`Gy8V zL%#>1IHXV$weeI~leC%(q-? zlDtx1{gJc8$EfsFG7F%peGr#&nK7j~TO|CyhHyhf>_;*+6GkH>scRFZgHAD^da_Nn8ZTV6PGwTON1rp5-V1CPLmkEu9n1eIjv#3D9>u#zMXP%bE8aCX zIVIQy`x8_`Zp3*+;|5G?OaPpr4ZPzj9SwDGzrwf+=i5aA^Sw)WaN6AlPz#4x;;e+; z%T!L%#c#AM3AsI`|Lb_=AY1CdkhR<4_OwugpfzYSd3LQ@jV#m`CYqS-mO(CPJ4toA zPz)_yy8W$v!;ayu?f0FlPwRUgc{MA?ygd&*zib_-<*!^DNtunp!-{s?j75B6>5A(Y zUuOn~JR6$!+}!7#SM4l&n(E=GJowD5xs_eVYEGi%Ya}SLHA<6&WwbzS{A?hjDifV~ zMgz}PfL30w1@0LeV?BCpU(%vN6Mt`D@EgFR(6QlRVJTAE z25Rn6W-<+*Uw?UVL5qx0X#&xT(T4`E24xHl`mk`Ln8^ov_CXbhKP!jYDr%-jm<(VB zrn7Lj#jbcHsa)c`?ET~g_=yCYvp=iavXe-28mK!HM~9nD-`s905Bh$4*&oF delta 46 rcmaE=x=eb4g|b3keo<~>PG)hcOJZfQ4l=hSH7GGJJyl_2g*Pt%o-q+r diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index a3995e466..c20aac7ed 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -37,6 +37,7 @@ function makeData(overrides?: Partial): DoctorReportData { gender: "MALE", heightCm: 182, }, + practiceName: null, measurements: { WEIGHT: [ { value: 80, measuredAt: "2026-02-10T08:00:00.000Z" }, @@ -260,7 +261,43 @@ describe("doctor-report-pdf-core type-map coverage", () => { expect(text).toContain("%"); }); - // ── v1.4.15 phase B6 ── configurable date range on cover page. + // ── v1.4.15 phase B6 ── practice name on cover page. + + it("renders the practice name on the cover when supplied (DE)", async () => { + const data = makeData({ practiceName: "Praxis Dr. Müller" }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Praxis Dr. Müller"); + expect(text).toContain("Praxis:"); + }); + + it("renders the practice name on the cover when supplied (EN)", async () => { + const data = makeData({ practiceName: "Family Practice Smith & Co." }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Family Practice Smith & Co."); + expect(text).toContain("Practice:"); + }); + + it("omits the practice line when practiceName is null", async () => { + const data = makeData({ practiceName: null }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // Cover should NOT carry the "Praxis:" label when no name is set. + expect(text).not.toContain("Praxis:"); + }); it("includes the explicit period start AND end on the cover page", async () => { const data = makeData({ diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index ce4ab025627b402dfa5a6fac35b48b3a6168f922..80b27bc8d6527263e9e63de072771f33123d3a3a 100644 GIT binary patch delta 1447 zcmaJ>(QX?>6r~^)VYLq+lt(z~OTCW0>r_HWEm0LY360c-C=IAmTgBt`O*~|FW;HXL zI4a2eK_9|j@PcH-3qQd#FGzd|cV>5~Ta~_8^1U->&b{ZH{rkH=Cch4@FS1^*1E7ap zOEmB__b9E%z03(2awWCQp+!MlmfF)4M#Iktxj84BVyr8r#xRBSF4SueDSBBaH`;j|9Bz${_C`C$`&*B8jvsA3Iexga|Mk%WY+~ci@Ls2r^%lI<9*(Rm z`n)NnMCk|@GwI2dBBws4)|A}Q-Wr8`Cc>P&r9On7LXkf8vq7LP0!}WJB0FHB=W@zh zY^RUHq&OsBS?yXaV>Wqe9H1sp&3O_ zDXv#wqJ%!<6-`JbSNA_!>pP*PmyT9t>YfW#u_%>3)8;}atvpwqo5>Z&cEDE!Xon-zIQ>eT1@+U<%2~)E~-3I#VtDB!~G)rkpsZ$)k?y&#APD_@5-57tk zpn1p$`;)D6!i+FttFe{0P3{e6D|Ejpy=2$o1wYGSe@o7@)s%a~?qa|;Gfm8E6ro}Y z+W4*I6!Le!Rk#h{7oYxltp}L;<-6}QP%4$_0aTod7Cfe?R9{1*#Fo-I2u72ADU zN1mO|VU)T?6wS9`;7B`m{W)Pqg0u0;Evx+5GA;Yp($Q=sT|LX5SiZcy{{D^C;_|oT Hmuvq5I~wS} delta 37 vcmV+=0NVe%V&7J