diff --git a/messages/de.json b/messages/de.json index aa52c49aa..fe0d293e5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -121,14 +121,11 @@ "glucoseInTarget": "im Ziel", "glucoseBelowTarget": "unter Ziel", "glucoseAboveTarget": "über Ziel", - "practiceLabel": "Praxis", "dialog": { "title": "Arztbericht (PDF)", - "description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.", + "description": "Lege den Auswertungszeitraum fest — er erscheint 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 9e6268cae..2326125f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -121,14 +121,11 @@ "glucoseInTarget": "in target", "glucoseBelowTarget": "below target", "glucoseAboveTarget": "above target", - "practiceLabel": "Practice", "dialog": { "title": "Doctor report (PDF)", - "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", + "description": "Pick the reporting period; it will be 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/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts index 62395201c..da6cc24bb 100644 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -183,4 +183,62 @@ describe("POST /api/doctor-report/pdf", () => { }), ); }); + + // ── v1.4.15 phase B6 ── configurable date range + practice name. + + it("accepts an explicit startDate / endDate range in the body", 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(); + + const startDate = "2026-01-01T00:00:00.000Z"; + const endDate = "2026-04-01T00:00:00.000Z"; + const res = await POST(makeRequest({ startDate, endDate })); + expect(res.status).toBe(200); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + measuredAt: { gte: new Date(startDate), lte: new Date(endDate) }, + }), + }), + ); + expect(auditLog).toHaveBeenCalledWith( + "doctor-report.pdf.generate", + expect.objectContaining({ + details: expect.objectContaining({ startDate, endDate }), + }), + ); + }); + + it("falls back when range is invalid (end < start)", 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(); + + const res = await POST( + makeRequest({ + startDate: "2026-04-01T00:00:00.000Z", + endDate: "2026-01-01T00:00:00.000Z", + }), + ); + expect(res.status).toBe(200); + // Falls back to days=90. + expect(auditLog).toHaveBeenCalledWith( + "doctor-report.pdf.generate", + expect.objectContaining({ + details: expect.objectContaining({ days: 90 }), + }), + ); + }); + }); diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts index 7971299fc..04ef4a540 100644 --- a/src/app/api/doctor-report/pdf/route.ts +++ b/src/app/api/doctor-report/pdf/route.ts @@ -6,7 +6,7 @@ import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { collectDoctorReportData, - normaliseDays, + normaliseDateRange, } from "@/lib/doctor-report-data"; import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -37,13 +37,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // Body is optional — JSON is allowed but not required. const body = await readOptionalJsonBody(request); - const days = normaliseDays(body?.days); + const range = normaliseDateRange(body ?? undefined); const locale = resolveLocale( body?.locale, request.headers.get("accept-language"), ); - const data = await collectDoctorReportData(user.id, days); + const data = await collectDoctorReportData(user.id, range); const { t } = getServerTranslator(locale); const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale }); @@ -51,10 +51,23 @@ export const POST = apiHandler(async (request: NextRequest) => { await auditLog("doctor-report.pdf.generate", { userId: user.id, ipAddress: getClientIp(request), - details: { days, locale }, + details: { + days: range.days, + startDate: range.start.toISOString(), + endDate: range.end.toISOString(), + locale, + }, }); - annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } }); + annotate({ + meta: { + report_days: range.days, + report_start: range.start.toISOString(), + report_end: range.end.toISOString(), + locale, + bytes: pdfBytes.byteLength, + }, + }); const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`; // NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid @@ -73,6 +86,8 @@ export const POST = apiHandler(async (request: NextRequest) => { interface PdfRequestBody { days?: unknown; + startDate?: unknown; + endDate?: unknown; locale?: unknown; } diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts index ed936e3e4..1770fa245 100644 --- a/src/app/api/doctor-report/route.ts +++ b/src/app/api/doctor-report/route.ts @@ -11,12 +11,21 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { collectDoctorReportData, - normaliseDays, + normaliseDateRange, } from "@/lib/doctor-report-data"; /** * Collect data for doctor report PDF generation (client-side). * Returns aggregated health data for the specified time range. + * + * 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) + * } + * + * Resolution: explicit range wins; otherwise `days` (default 90) is applied. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -34,16 +43,26 @@ export const POST = apiHandler(async (request: NextRequest) => { const { data: body, error: jsonError } = await safeJson(request); if (jsonError) return jsonError; - const days = normaliseDays((body as Record)?.days); - const data = await collectDoctorReportData(user.id, days); + const range = normaliseDateRange(body); + const data = await collectDoctorReportData(user.id, range); await auditLog("doctor-report.generate", { userId: user.id, ipAddress: getClientIp(request), - details: { days }, + details: { + days: range.days, + startDate: range.start.toISOString(), + endDate: range.end.toISOString(), + }, }); - annotate({ meta: { report_days: days } }); + annotate({ + meta: { + report_days: range.days, + report_start: range.start.toISOString(), + report_end: range.end.toISOString(), + }, + }); return apiSuccess(data); }); diff --git a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx new file mode 100644 index 000000000..1566bb1dd --- /dev/null +++ b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * SSR contract tests for the doctor-report export dialog. + * + * Radix UI's `Dialog` portals its content out of the React tree, so + * `renderToStaticMarkup()` returns an empty string for the open dialog. + * Instead we assert the *contract* surfaces the dialog depends on: + * + * - All required i18n keys exist in EN + DE. + * - The component module compiles + imports cleanly. + * - The exported submit-payload type stays the same shape the API + * route reads (`{ startDate, endDate }`). + * + * Behavioural coverage (open → validate → submit → download) lives in + * `e2e/doctor-report.spec.ts`, which drives the dialog through Playwright. + * + * v1.4.15 phase B6. + */ + +const ROOT = join(__dirname, "../../../.."); + +function loadMessages(locale: "en" | "de"): Record { + return JSON.parse( + readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), + ); +} + +const REQUIRED_KEYS = [ + "title", + "description", + "startLabel", + "endLabel", + "preset90d", + "preset180d", + "preset365d", + "submit", + "cancel", + "errorEndBeforeStart", + "errorRangeTooLong", + "errorInvalidDate", +]; + +describe("doctor-report dialog — i18n contract", () => { + for (const locale of ["en", "de"] as const) { + it(`exposes every doctor-report dialog key in ${locale}.json`, () => { + const messages = loadMessages(locale); + const root = messages.doctorReport as Record | undefined; + expect(root, `doctorReport namespace missing in ${locale}.json`).toBeDefined(); + const dialog = root!.dialog as Record | undefined; + expect(dialog, `doctorReport.dialog missing in ${locale}.json`).toBeDefined(); + for (const key of REQUIRED_KEYS) { + expect( + dialog![key], + `doctorReport.dialog.${key} missing in ${locale}.json`, + ).toBeTruthy(); + expect(typeof dialog![key]).toBe("string"); + } + }); + } + + it("EN dialog uses the expected English title + 90d preset", () => { + const en = loadMessages("en"); + const dialog = (en.doctorReport as { dialog: Record }) + .dialog; + expect(dialog.title).toContain("Doctor report"); + expect(dialog.preset90d.toLowerCase()).toContain("90"); + }); + + it("DE dialog uses the German title", () => { + const de = loadMessages("de"); + const dialog = (de.doctorReport as { dialog: Record }) + .dialog; + expect(dialog.title).toContain("Arztbericht"); + expect(dialog.preset90d).toContain("90"); + }); +}); + +describe("doctor-report dialog — module surface", () => { + it("exports `DoctorReportDialog` and a `DoctorReportSubmitPayload` type", async () => { + const mod = await import("../doctor-report-dialog"); + expect(typeof mod.DoctorReportDialog).toBe("function"); + // The type is erased at runtime; verifying export presence is the + // best we can do without `tsc --noEmit` here. + expect("DoctorReportDialog" in mod).toBe(true); + }); +}); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx new file mode 100644 index 000000000..2f538b372 --- /dev/null +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -0,0 +1,258 @@ +"use client"; + +/** + * 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. + * + * Defaults: + * - end = today + * - start = today − 90 days + * + * Validation runs in the client so the dialog can show inline errors + * without bouncing through the server. The server still re-validates via + * `normaliseDateRange()` (silent fallback to last-90-days) — the client + * messages are UX, not security. + */ + +import { useMemo, useState } from "react"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "@/lib/i18n/context"; + +const ONE_DAY_MS = 86_400_000; +const MAX_RANGE_DAYS = 730; + +export interface DoctorReportSubmitPayload { + startDate: string; + endDate: string; +} + +interface DoctorReportDialogProps { + /** Controlled open state. */ + open: boolean; + onOpenChange: (open: boolean) => void; + /** + * Resolves with the user-confirmed payload. The dialog stays open and + * shows the spinner while the promise is pending; closes on resolve. + */ + onSubmit: (payload: DoctorReportSubmitPayload) => Promise; +} + +/** YYYY-MM-DD in the user's local timezone. */ +function todayIso(): string { + const now = new Date(); + return formatLocalDate(now); +} + +function formatLocalDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function defaultStartIso(): string { + const d = new Date(); + d.setDate(d.getDate() - 90); + return formatLocalDate(d); +} + +export function DoctorReportDialog({ + open, + onOpenChange, + onSubmit, +}: DoctorReportDialogProps) { + const { t } = useTranslations(); + const [startDate, setStartDate] = useState(defaultStartIso); + const [endDate, setEndDate] = useState(todayIso); + 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. + const [lastOpen, setLastOpen] = useState(false); + if (open && !lastOpen) { + setLastOpen(true); + setStartDate(defaultStartIso()); + setEndDate(todayIso()); + setError(null); + setSubmitting(false); + } else if (!open && lastOpen) { + setLastOpen(false); + } + + const validation = useMemo(() => { + if (!startDate || !endDate) { + return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" }; + } + const start = new Date(startDate); + const end = new Date(endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" }; + } + if (end.getTime() < start.getTime()) { + return { + ok: false as const, + key: "doctorReport.dialog.errorEndBeforeStart", + }; + } + const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS); + if (spanDays > MAX_RANGE_DAYS) { + return { ok: false as const, key: "doctorReport.dialog.errorRangeTooLong" }; + } + return { ok: true as const }; + }, [startDate, endDate]); + + function applyPreset(days: number) { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - days); + setStartDate(formatLocalDate(start)); + setEndDate(formatLocalDate(end)); + setError(null); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!validation.ok) { + setError(t(validation.key)); + return; + } + setSubmitting(true); + setError(null); + try { + // Send dates as ISO timestamps anchored to the local day's + // start/end so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` + // 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 }); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setSubmitting(false); + } + } + + return ( + + + + {t("doctorReport.dialog.title")} + + {t("doctorReport.dialog.description")} + + + +
+
+
+ + { + setStartDate(e.target.value); + setError(null); + }} + required + /> +
+
+ + { + setEndDate(e.target.value); + setError(null); + }} + required + /> +
+
+ +
+ + + +
+ + {error && ( +

+ {error} +

+ )} + + + + + +
+
+
+ ); +} diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 145f0fa4c..7b29a4c18 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -16,6 +16,10 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + DoctorReportDialog, + type DoctorReportSubmitPayload, +} from "@/components/doctor-report/doctor-report-dialog"; import { useTranslations } from "@/lib/i18n/context"; export function AdvancedSection() { @@ -48,6 +52,7 @@ function ExportCard() { const { t, locale } = useTranslations(); const [exporting, setExporting] = useState(false); const [generatingReport, setGeneratingReport] = useState(false); + const [reportDialogOpen, setReportDialogOpen] = useState(false); async function handleExport(format: "csv" | "json") { setExporting(true); @@ -69,13 +74,16 @@ function ExportCard() { } } - async function handleDoctorReport() { + async function handleDoctorReport(payload: DoctorReportSubmitPayload) { setGeneratingReport(true); try { const res = await fetch("/api/doctor-report", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ days: 90 }), + body: JSON.stringify({ + startDate: payload.startDate, + endDate: payload.endDate, + }), }); if (!res.ok) return; const json = await res.json(); @@ -85,6 +93,7 @@ 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`); + setReportDialogOpen(false); } finally { setGeneratingReport(false); } @@ -121,7 +130,7 @@ function ExportCard() { + ); } diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts new file mode 100644 index 000000000..a974efe8b --- /dev/null +++ b/src/lib/__tests__/doctor-report-data.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; + +import { normaliseDays, normaliseDateRange } from "../doctor-report-data"; + +describe("normaliseDays", () => { + it("returns the value when it's a valid integer in [1, 365]", () => { + expect(normaliseDays(1)).toBe(1); + expect(normaliseDays(90)).toBe(90); + expect(normaliseDays(365)).toBe(365); + }); + + it("falls back to 90 for invalid input", () => { + expect(normaliseDays(undefined)).toBe(90); + expect(normaliseDays(0)).toBe(90); + expect(normaliseDays(-1)).toBe(90); + expect(normaliseDays(366)).toBe(90); + expect(normaliseDays(7.5)).toBe(90); + expect(normaliseDays("90")).toBe(90); + expect(normaliseDays(null)).toBe(90); + }); + + it("respects a custom fallback", () => { + expect(normaliseDays(undefined, 30)).toBe(30); + }); +}); + +describe("normaliseDateRange", () => { + const NOW = new Date("2026-05-09T12:00:00.000Z"); + + it("uses the explicit range when both dates are valid and span <= 730 days", () => { + const range = normaliseDateRange( + { + startDate: "2026-02-01T00:00:00.000Z", + endDate: "2026-05-01T00:00:00.000Z", + }, + NOW, + ); + expect(range.start.toISOString()).toBe("2026-02-01T00:00:00.000Z"); + expect(range.end.toISOString()).toBe("2026-05-01T00:00:00.000Z"); + // 89-day span rounded up to the inclusive day-count. + expect(range.days).toBe(89); + }); + + it("falls back to last-90-days when range is missing", () => { + const range = normaliseDateRange({}, NOW); + expect(range.days).toBe(90); + expect(range.end.toISOString()).toBe(NOW.toISOString()); + const expectedStart = new Date(NOW); + expectedStart.setDate(expectedStart.getDate() - 90); + expect(range.start.toISOString()).toBe(expectedStart.toISOString()); + }); + + it("falls back when endDate is before startDate", () => { + const range = normaliseDateRange( + { + startDate: "2026-05-01T00:00:00.000Z", + endDate: "2026-02-01T00:00:00.000Z", + }, + NOW, + ); + expect(range.days).toBe(90); + }); + + it("falls back when range exceeds 730 days", () => { + const range = normaliseDateRange( + { + startDate: "2023-01-01T00:00:00.000Z", + endDate: "2026-01-01T00:00:00.000Z", + }, + NOW, + ); + expect(range.days).toBe(90); + }); + + it("accepts the maximum 730-day window", () => { + const range = normaliseDateRange( + { + startDate: "2024-05-09T00:00:00.000Z", + endDate: "2026-05-09T00:00:00.000Z", + }, + NOW, + ); + expect(range.days).toBeGreaterThan(700); + expect(range.days).toBeLessThanOrEqual(730); + }); + + it("falls back when startDate is unparseable", () => { + const range = normaliseDateRange( + { startDate: "not-a-date", endDate: "2026-05-01T00:00:00.000Z" }, + NOW, + ); + expect(range.days).toBe(90); + }); + + it("uses the legacy days fallback when only days is provided", () => { + const range = normaliseDateRange({ days: 30 }, NOW); + expect(range.days).toBe(30); + expect(range.end.toISOString()).toBe(NOW.toISOString()); + }); + + it("equal start+end produces a 1-day window (not zero)", () => { + const range = normaliseDateRange( + { + startDate: "2026-05-09T00:00:00.000Z", + endDate: "2026-05-09T00:00:00.000Z", + }, + NOW, + ); + expect(range.days).toBe(1); + }); + + it("ignores non-object input", () => { + const range = normaliseDateRange("not-an-object", NOW); + expect(range.days).toBe(90); + }); +}); + diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 2468a2d66..a3995e466 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -25,7 +25,12 @@ const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); function makeData(overrides?: Partial): DoctorReportData { return { - period: { days: 90, since: "2026-02-02T00:00:00.000Z" }, + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, patient: { username: "marc", dateOfBirth: "1985-06-15T00:00:00.000Z", @@ -255,6 +260,29 @@ describe("doctor-report-pdf-core type-map coverage", () => { expect(text).toContain("%"); }); + // ── v1.4.15 phase B6 ── configurable date range on cover page. + + it("includes the explicit period start AND end on the cover page", async () => { + const data = makeData({ + period: { + days: 89, + since: "2026-02-01T00:00:00.000Z", + start: "2026-02-01T00:00:00.000Z", + end: "2026-04-30T00:00:00.000Z", + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // German formatting: dd.mm.yyyy. Berlin tz on these UTC midnight + // boundaries shifts the date by one day forward (CET = UTC+1). + expect(text).toContain("01.02.2026"); + expect(text).toContain("30.04.2026"); + }); + it("renders body composition rows in English when locale is en", async () => { const data = makeData({ stats: { diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index eda6d846d..ce4ab0256 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -44,7 +44,12 @@ export interface DoctorReportMood { } export interface DoctorReportData { - period: { days: number; since: string }; + /** + * Reporting period bounds. `start` and `end` are ISO timestamps; `days` is + * the inclusive day-count between the two (rounded up) so existing callers + * that displayed "last N days" continue to work without a UI rewrite. + */ + period: { days: number; since: string; start: string; end: string }; patient: { username: string | null; dateOfBirth: string | null; @@ -93,21 +98,100 @@ export function normaliseDays(value: unknown, fallback = 90): number { return fallback; } +const MIN_RANGE_DAYS = 1; /** - * Aggregate the doctor-report payload for a user over the last `days` days. + * Maximum span between `startDate` and `endDate`. Two years balances the + * "give me everything I have" case (chronic condition follow-up) against + * unbounded server work; the existing `days` fallback is capped at 365 so a + * caller asking for a 730-day window must come through the explicit-range + * surface and proves they want it. + */ +const MAX_RANGE_DAYS = 730; +const DEFAULT_RANGE_DAYS = 90; + +export interface DoctorReportRange { + /** Start of the reporting window (UTC, inclusive). */ + start: Date; + /** End of the reporting window (UTC, inclusive). */ + end: Date; + /** Inclusive day-count between `start` and `end` (rounded up). */ + days: number; +} + +function parseIsoDate(value: unknown): Date | null { + if (typeof value !== "string" || value.length === 0) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d; +} + +/** + * Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated + * inclusive reporting window. + * + * Resolution order: + * 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with + * `endDate >= startDate` and span <= 730 days → use them. + * 2. Neither/invalid range, but a valid `days` integer (1..365) is provided + * → fall back to "last `days` days ending now". + * 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default). + * + * The function is intentionally tolerant: invalid input never throws — bad + * shapes silently fall through to the next tier so a malformed request + * still produces a useful report rather than a 422. + */ +export function normaliseDateRange( + value: unknown, + now: Date = new Date(), +): DoctorReportRange { + const body = + typeof value === "object" && value !== null + ? (value as Record) + : {}; + const startCandidate = parseIsoDate(body.startDate); + const endCandidate = parseIsoDate(body.endDate); + + if (startCandidate && endCandidate) { + const startMs = startCandidate.getTime(); + const endMs = endCandidate.getTime(); + if (endMs >= startMs) { + const spanMs = endMs - startMs; + const spanDays = Math.ceil(spanMs / 86_400_000) || 1; + if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) { + return { + start: startCandidate, + end: endCandidate, + days: spanDays, + }; + } + } + } + + const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS); + const end = now; + const start = new Date(end); + start.setDate(start.getDate() - days); + return { start, end, days }; +} + +/** + * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. + * + * `range` is validated via `normaliseDateRange()` upstream; this function + * trusts it and uses both bounds in the Prisma `where` clause so a custom + * window (not just "last N days") filters correctly. */ export async function collectDoctorReportData( userId: string, - days: number, + range: DoctorReportRange, ): Promise { - const since = new Date(); - since.setDate(since.getDate() - days); + const { start, end, days } = range; const [measurements, medications, intakeEvents, moodEntries, userProfile] = await Promise.all([ prisma.measurement.findMany({ - where: { userId, measuredAt: { gte: since } }, + where: { userId, measuredAt: { gte: start, lte: end } }, orderBy: { measuredAt: "asc" }, }), prisma.medication.findMany({ @@ -115,12 +199,12 @@ export async function collectDoctorReportData( include: { schedules: true }, }), prisma.medicationIntakeEvent.findMany({ - where: { userId, scheduledFor: { gte: since } }, + where: { userId, scheduledFor: { gte: start, lte: end } }, include: { medication: { select: { name: true } } }, orderBy: { scheduledFor: "asc" }, }), prisma.moodEntry.findMany({ - where: { userId, moodLoggedAt: { gte: since } }, + where: { userId, moodLoggedAt: { gte: start, lte: end } }, orderBy: { moodLoggedAt: "asc" }, }), prisma.user.findUnique({ @@ -239,7 +323,14 @@ export async function collectDoctorReportData( } return { - period: { days, since: since.toISOString() }, + period: { + days, + // `since` is preserved for backwards compatibility with any in-flight + // clients that read the old field name; mirrors `start`. + since: start.toISOString(), + start: start.toISOString(), + end: end.toISOString(), + }, patient: { username: userProfile?.username ?? null, dateOfBirth: userProfile?.dateOfBirth diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 8103bfd96..406879239 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -198,8 +198,13 @@ export function buildDoctorReportPdfDocument( `${t("doctorReport.height")}: ${data.patient.heightCm} cm`, ); } + // 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(data.period.since)} — ${fmtDate(now.toISOString())}`, + `${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`, ); patientInfo.push( `${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`, diff --git a/src/lib/doctor-report-pdf.ts b/src/lib/doctor-report-pdf.ts index 55a4e8697..265affe56 100644 --- a/src/lib/doctor-report-pdf.ts +++ b/src/lib/doctor-report-pdf.ts @@ -13,7 +13,7 @@ import type { Locale } from "./i18n/config"; import { convertGlucose, resolveGlucoseUnit } from "./glucose"; interface ReportData { - period: { days: number; since: string }; + period: { days: number; since: string; start?: string; end?: string }; patient: { username: string | null; dateOfBirth: string | null; @@ -185,8 +185,12 @@ export function generateDoctorReportPDF( `${t("doctorReport.height")}: ${data.patient.heightCm} cm`, ); } + // Reporting period uses explicit start/end when available; falls back to + // (since, now()) for legacy payloads. + const periodStart = data.period.start ?? data.period.since; + const periodEnd = data.period.end ?? new Date().toISOString(); patientInfo.push( - `${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(new Date().toISOString())}`, + `${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`, ); patientInfo.push( `${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`,