mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
43442276a9
commit
d692119454
13 files changed
+726
-34
No files matched your search
+1
-4
@@ -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
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())}`,
|
||||
|
||||
@@ -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())}`,
|
||||
|
||||
Reference in new issue
Block a user