feat(doctor-report): configurable date range with default last-90-days

The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.

- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
  prompts the user before the request fires. Defaults: end=today,
  start=today-90d. Inline validation: end >= start, span <= 2 years.
  Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
  `{ startDate?, endDate?, days? }` and falls back to last-90-days for
  invalid input — silent fallback rather than 422 so a malformed retry
  still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
  measurements / intake events / mood entries with both `gte` AND `lte`,
  so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
  reads from `period.start` / `period.end` instead of the legacy
  (since, now()) calculation. Old `period.since` field preserved for
  backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
  alongside `days` so a forensic replay can reconstruct the requested
  window.

Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test 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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:34:28 +02:00
1 parent 43442276a9
commit d692119454
13 files changed
+726 -34

No files matched your search

+1 -4
View File
@@ -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",
+1 -4
View File
@@ -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",
@@ -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 }),
}),
);
});
});
+20 -5
View File
@@ -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;
}
+24 -5
View File
@@ -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<string, unknown>)?.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);
});
@@ -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<string, unknown> {
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<string, unknown> | undefined;
expect(root, `doctorReport namespace missing in ${locale}.json`).toBeDefined();
const dialog = root!.dialog as Record<string, unknown> | 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<string, string> })
.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<string, string> })
.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);
});
});
@@ -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<void>;
}
/** 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<string>(defaultStartIso);
const [endDate, setEndDate] = useState<string>(todayIso);
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.
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<HTMLFormElement>) {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("doctorReport.dialog.title")}</DialogTitle>
<DialogDescription>
{t("doctorReport.dialog.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="dr-start">
{t("doctorReport.dialog.startLabel")}
</Label>
<Input
id="dr-start"
type="date"
value={startDate}
max={endDate || todayIso()}
onChange={(e) => {
setStartDate(e.target.value);
setError(null);
}}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dr-end">
{t("doctorReport.dialog.endLabel")}
</Label>
<Input
id="dr-end"
type="date"
value={endDate}
min={startDate}
max={todayIso()}
onChange={(e) => {
setEndDate(e.target.value);
setError(null);
}}
required
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(90)}
>
{t("doctorReport.dialog.preset90d")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(180)}
>
{t("doctorReport.dialog.preset180d")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(365)}
>
{t("doctorReport.dialog.preset365d")}
</Button>
</div>
{error && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="doctor-report-dialog-error"
>
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("doctorReport.dialog.cancel")}
</Button>
<Button type="submit" disabled={submitting || !validation.ok}>
{submitting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("doctorReport.dialog.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+17 -3
View File
@@ -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() {
<Button
variant="outline"
size="sm"
onClick={handleDoctorReport}
onClick={() => setReportDialogOpen(true)}
disabled={generatingReport}
>
{generatingReport && (
@@ -130,6 +139,11 @@ function ExportCard() {
{t("settings.doctorReport")}
</Button>
</div>
<DoctorReportDialog
open={reportDialogOpen}
onOpenChange={setReportDialogOpen}
onSubmit={handleDoctorReport}
/>
</div>
);
}
@@ -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);
});
});
@@ -25,7 +25,12 @@ const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
function makeData(overrides?: Partial<DoctorReportData>): 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: {
+100 -9
View File
@@ -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<string, unknown>)
: {};
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<DoctorReportData> {
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
+6 -1
View File
@@ -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())}`,
+6 -2
View File
@@ -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())}`,