mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(doctor-report): practice name on cover page (persisted as user preference)
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d692119454
commit
28467b2590
15 files changed
+249
-19
No files matched your search
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> | 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string>(defaultStartIso);
|
||||
const [endDate, setEndDate] = useState<string>(todayIso);
|
||||
const [practiceName, setPracticeName] = useState<string>("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dr-practice">
|
||||
{t("doctorReport.dialog.practiceLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="dr-practice"
|
||||
type="text"
|
||||
value={practiceName}
|
||||
maxLength={PRACTICE_NAME_MAX_LENGTH}
|
||||
placeholder={t("doctorReport.dialog.practicePlaceholder")}
|
||||
onChange={(e) => setPracticeName(e.target.value)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
@@ -53,6 +53,28 @@ function ExportCard() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [generatingReport, setGeneratingReport] = useState(false);
|
||||
const [reportDialogOpen, setReportDialogOpen] = useState(false);
|
||||
const [lastPracticeName, setLastPracticeName] = useState<string | null>(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() {
|
||||
<DoctorReportDialog
|
||||
open={reportDialogOpen}
|
||||
onOpenChange={setReportDialogOpen}
|
||||
defaultPracticeName={lastPracticeName}
|
||||
onSubmit={handleDoctorReport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
@@ -37,6 +37,7 @@ function makeData(overrides?: Partial<DoctorReportData>): 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({
|
||||
|
||||
Binary file not shown.
@@ -168,6 +168,23 @@ export function buildDoctorReportPdfDocument(
|
||||
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);
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ReportData {
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
};
|
||||
/** Free-text practice / clinic name printed on the cover. Optional. */
|
||||
practiceName?: string | null;
|
||||
stats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
@@ -155,6 +157,21 @@ export function generateDoctorReportPDF(
|
||||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
// Practice / clinic name (optional). Rendered prominently above the
|
||||
// separator so the addressee is visible at first glance.
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user