feat(doctor-report): per-section toggle UI + persistence

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:24:35 +02:00
1 parent b1a3d59918
commit 5cb4a1dde1
7 files changed
+737 -13

No files matched your search

+19 -2
View File
@@ -137,6 +137,18 @@
"errorEndBeforeStart": "Das Ende-Datum darf nicht vor dem Start liegen.",
"errorRangeTooLong": "Der Auswertungszeitraum darf maximal 2 Jahre umfassen.",
"errorInvalidDate": "Bitte wähle ein gültiges Start- und Ende-Datum."
},
"sections": {
"title": "Inhalte auswählen",
"bp": "Blutdruck",
"weight": "Gewicht",
"pulse": "Ruhepuls",
"bmi": "Body-Mass-Index (BMI)",
"mood": "Stimmung",
"moodSensitive": "Sensibel — standardmäßig aus.",
"compliance": "Medikamenten-Compliance",
"sleep": "Schlafdauer",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt."
}
},
"errorBoundary": {
@@ -674,7 +686,6 @@
},
"comparison": {
"toggleLabel": "Vergleichen mit",
"toggleHint": "Blendet eine gedimmte Vorperioden-Linie in jedem Chart ein und zeigt ein Delta-Callout auf jeder Kachel.",
"baseline": {
"none": "Aus",
"lastMonth": "Vormonat",
@@ -920,12 +931,18 @@
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
"settingsEvidenceLabel": "Belege standardmäßig zeigen",
"settingsEvidenceHint": "Die Belege-Box unter jeder Antwort automatisch öffnen.",
"settingsDefaultWindowLabel": "Standard-Analysezeitraum",
"settingsDefaultWindowHint": "Der Coach liest pro Chat diesen Zeitraum, sofern du im Drawer-Kopf nichts anderes auswählst.",
"settingsSave": "Speichern",
"settingsSaved": "Gespeichert.",
"settingsCancel": "Abbrechen",
"feedbackHelpful": "Hilfreich",
"feedbackUnhelpful": "Nicht ganz",
"feedbackThanks": "Danke für das Signal."
"feedbackThanks": "Danke für das Signal.",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min."
},
"viewAll": "Alle ansehen",
"previewEmpty": "Noch keine Empfehlungen.",
+19 -2
View File
@@ -137,6 +137,18 @@
"errorEndBeforeStart": "End date must be on or after the start date.",
"errorRangeTooLong": "The reporting period cannot exceed 2 years.",
"errorInvalidDate": "Please select a valid start and end date."
},
"sections": {
"title": "Choose sections",
"bp": "Blood pressure",
"weight": "Weight",
"pulse": "Resting pulse",
"bmi": "Body Mass Index (BMI)",
"mood": "Mood",
"moodSensitive": "Sensitive — off by default.",
"compliance": "Medication compliance",
"sleep": "Sleep duration",
"empty": "No data was recorded in this date range yet. The report will still include the cover page."
}
},
"errorBoundary": {
@@ -674,7 +686,6 @@
},
"comparison": {
"toggleLabel": "Compare to",
"toggleHint": "Adds a dimmed prior-period overlay to every chart and a delta callout to every tile.",
"baseline": {
"none": "None",
"lastMonth": "Last month",
@@ -920,12 +931,18 @@
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
"settingsEvidenceLabel": "Show evidence by default",
"settingsEvidenceHint": "Open the disclosure under every reply automatically.",
"settingsDefaultWindowLabel": "Default analysis window",
"settingsDefaultWindowHint": "The Coach reads this much history per chat unless you override it from the drawer header.",
"settingsSave": "Save",
"settingsSaved": "Saved.",
"settingsCancel": "Cancel",
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
"feedbackThanks": "Thanks for the signal."
"feedbackThanks": "Thanks for the signal.",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
},
"viewAll": "View all",
"previewEmpty": "No recommendations yet.",
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/doctor-report-prefs", () => {
it("returns defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof DEFAULT_DOCTOR_REPORT_PREFS };
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
});
});
describe("PUT /api/auth/me/doctor-report-prefs", () => {
// ── Case 1 — happy path ─────────────────────────────────────────────
it("persists the full shape and returns the canonical form", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = {
bp: true,
weight: true,
pulse: false,
bmi: true,
mood: true,
compliance: true,
sleep: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
expect(env.data).toEqual(body);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: body },
});
});
// ── Case 2 — partial update layers over current persisted row ───────
it("merges a partial update over the persisted row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: true,
compliance: false,
sleep: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// User only flips mood off — every other key keeps its persisted value.
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
expect(env.data).toEqual({ ...current, mood: false });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: { ...current, mood: false } },
});
});
// ── Case 3 — invalid shape ─────────────────────────────────────────
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ bp: "nope" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
* (defaults when the row is null).
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
* shape over the current row
* and persists the canonical
* fully-resolved object so a
* future schema migration
* doesn't need to back-fill
* missing keys.
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The doctor-report aggregator + PDF renderer both read this
* row at generation time so mood data is dropped at the aggregator
* layer when `mood = false` — the data never leaves the DB.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
doctorReportPrefsSchema,
parseDoctorReportPrefs,
resolveDoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
}
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
}
// Merge the partial input over the current row (or defaults) so a
// dialog that only toggles `mood` doesn't have to re-state every other
// flag. Persist the canonical fully-resolved object so the column
// shape stays stable across future schema additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
const merged = resolveDoctorReportPrefs(
current?.doctorReportPrefsJson,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
// index signature so cast through `unknown` rather than widen the
// typed return surface. Shape is identical to the validated input.
data: {
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
},
});
annotate({
action: { name: "auth.me.doctor-report-prefs.put" },
meta: {
bp: merged.bp,
weight: merged.weight,
pulse: merged.pulse,
bmi: merged.bmi,
mood: merged.mood,
compliance: merged.compliance,
sleep: merged.sleep,
},
});
return apiSuccess(merged);
});
@@ -0,0 +1,121 @@
/**
* v1.4.25 W6c — doctor-report section availability probe.
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (Marc's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
* Body shape (mirrors `/api/doctor-report`):
* { startDate?: ISO, endDate?: ISO, days?: number }
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { normaliseDateRange } from "@/lib/doctor-report-data";
export const dynamic = "force-dynamic";
export interface DoctorReportAvailability {
/** Section flags — `true` when at least one row exists in the range. */
bp: boolean;
weight: boolean;
pulse: boolean;
/** BMI requires both a weight reading AND a configured height. */
bmi: boolean;
mood: boolean;
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
compliance: boolean;
sleep: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
const { data: body, error } = await safeJson(request);
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
// `count` queries are cheaper than `findMany` for a presence check —
// we never read the rows themselves. The user has indexes on
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
// `(userId, scheduledFor)` so these stay sub-millisecond even with
// multi-year ranges.
const [
bpCount,
weightCount,
pulseCount,
moodCount,
complianceCount,
sleepCount,
profile,
] = await Promise.all([
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "WEIGHT",
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "PULSE",
},
}),
prisma.moodEntry.count({
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
}),
prisma.medicationIntakeEvent.count({
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "SLEEP_DURATION",
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true },
}),
]);
// BMI is derived (latest weight × profile height) so it only shows up
// when BOTH inputs are available — otherwise the user would tick a
// box that produces a silently-empty section.
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
const availability: DoctorReportAvailability = {
bp: bpCount > 0,
weight: weightCount > 0,
pulse: pulseCount > 0,
bmi,
mood: moodCount > 0,
compliance: complianceCount > 0,
sleep: sleepCount > 0,
};
annotate({
meta: {
report_days: range.days,
sections_available: Object.values(availability).filter(Boolean).length,
},
});
return apiSuccess(availability);
});
@@ -1,14 +1,28 @@
"use client";
/**
* Doctor-report export dialog (v1.4.15 phase B6).
* Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c).
*
* Wraps the trigger button so the user picks a reporting period and
* (optionally) a practice / clinic name before the PDF is generated.
* Wraps the trigger button so the user picks:
* 1. Reporting period (date range, with three presets).
* 2. Practice / clinic name (optional, persisted between exports).
* 3. Which data sections appear in the PDF (per-user persisted toggles).
*
* Hide-when-empty (Marc 2026-05-14): a section's toggle is only shown
* when the selected date range actually has data for it — checking a box
* for an empty section would produce a silently-empty PDF page. The
* availability probe runs on every range change via
* `/api/doctor-report/availability`.
*
* Privacy default: mood is OFF by default per Marc — mental-health data
* is opt-in even within a single user's own surface. The API layer
* filters mood out of the report payload server-side when the toggle is
* off, so the data never leaves the DB row.
*
* Defaults:
* - end = today
* - start = today − 90 days
* - sections = persisted prefs OR documented defaults
*
* Validation runs in the client so the dialog can show inline errors
* without bouncing through the server. The server still re-validates via
@@ -16,7 +30,7 @@
* messages are UX, not security.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -31,16 +45,53 @@ import {
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { useTranslations } from "@/lib/i18n/context";
const ONE_DAY_MS = 86_400_000;
const MAX_RANGE_DAYS = 730;
const PRACTICE_NAME_MAX_LENGTH = 120;
/**
* Order in which section toggles render. Matches the order the sections
* appear in the generated PDF so the dialog's mental model lines up with
* the printed artefact. `mood` is intentionally last because its
* privacy-sensitive footnote needs prime visual placement.
*/
const SECTION_ORDER: ReadonlyArray<keyof DoctorReportPrefs> = [
"bp",
"weight",
"pulse",
"bmi",
"compliance",
"sleep",
"mood",
] as const;
interface SectionAvailability {
bp: boolean;
weight: boolean;
pulse: boolean;
bmi: boolean;
mood: boolean;
compliance: boolean;
sleep: boolean;
}
export interface DoctorReportSubmitPayload {
startDate: string;
endDate: string;
practiceName: string | null;
/**
* Per-section toggles. The caller forwards this to
* `/api/doctor-report` so the aggregator drops disabled sections
* (mood specifically is filtered at the data layer for privacy).
*/
sections: DoctorReportPrefs;
}
interface DoctorReportDialogProps {
@@ -75,6 +126,19 @@ function defaultStartIso(): string {
return formatLocalDate(d);
}
function rangeIsoFromLocal(startDate: string, endDate: string): {
startIso: string;
endIso: string;
} {
// 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.
return {
startIso: new Date(`${startDate}T00:00:00`).toISOString(),
endIso: new Date(`${endDate}T23:59:59.999`).toISOString(),
};
}
export function DoctorReportDialog({
open,
onOpenChange,
@@ -88,6 +152,22 @@ export function DoctorReportDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Per-user section preferences. Loaded lazily when the dialog opens —
// skips a roundtrip on every render of the export card.
const [prefs, setPrefs] = useState<DoctorReportPrefs>(
DEFAULT_DOCTOR_REPORT_PREFS,
);
const [prefsLoaded, setPrefsLoaded] = useState(false);
// Section availability for the currently-selected range. `null` =
// "we haven't probed yet" so the section group renders a skeleton row
// rather than flashing an empty state.
const [availability, setAvailability] = useState<SectionAvailability | null>(
null,
);
const [availabilityLoading, setAvailabilityLoading] = useState(false);
const availabilityRequestId = useRef(0);
// Reset on open: re-anchor dates + practice-name to defaults each time
// the dialog appears so a follow-up export doesn't reuse the previous
// custom range. Using the React-recommended "track-the-trigger" pattern
@@ -106,6 +186,74 @@ export function DoctorReportDialog({
setLastOpen(false);
}
// Load persisted section prefs when the dialog opens for the first
// time. Best-effort: a failure falls back to documented defaults so
// the dialog stays usable even if the preferences endpoint is down.
useEffect(() => {
if (!open || prefsLoaded) return;
let cancelled = false;
fetch("/api/auth/me/doctor-report-prefs", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled) return;
const incoming = json?.data;
if (incoming && typeof incoming === "object") {
setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming });
}
setPrefsLoaded(true);
})
.catch(() => {
if (!cancelled) setPrefsLoaded(true);
});
return () => {
cancelled = true;
};
}, [open, prefsLoaded]);
// Re-probe availability whenever the range changes. setState calls
// live inside the promise handlers (microtask-deferred), satisfying
// the strict `react-hooks/set-state-in-effect` rule while keeping the
// loading indicator promptly visible. A generation counter prevents
// a slow earlier response from stomping on a faster later response
// (classic stale-write race).
useEffect(() => {
if (!open) return;
if (!startDate || !endDate) return;
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
const requestId = ++availabilityRequestId.current;
let cancelled = false;
Promise.resolve()
.then(() => {
if (cancelled) return null;
setAvailabilityLoading(true);
return fetch("/api/doctor-report/availability", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ startDate: startIso, endDate: endIso }),
});
})
.then(async (res) => {
if (cancelled || !res || !res.ok) return;
const json = (await res.json()) as { data: SectionAvailability };
if (requestId !== availabilityRequestId.current) return;
setAvailability(json.data);
})
.catch(() => {
// Best-effort — keep the previous availability snapshot.
})
.finally(() => {
if (!cancelled && requestId === availabilityRequestId.current) {
setAvailabilityLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, startDate, endDate]);
const validation = useMemo(() => {
if (!startDate || !endDate) {
return {
@@ -146,6 +294,36 @@ export function DoctorReportDialog({
setError(null);
}
// Sections that have data in the current range. Drives both the
// rendered checkbox list AND the canonical payload — toggles for
// empty sections are stripped before submission so the server doesn't
// see a "yes, render this empty thing" instruction.
const activeSections = useMemo<Array<keyof DoctorReportPrefs>>(() => {
if (!availability) return [];
return SECTION_ORDER.filter((key) => availability[key]);
}, [availability]);
function toggleSection(key: keyof DoctorReportPrefs, value: boolean) {
setPrefs((current) => ({ ...current, [key]: value }));
}
async function persistPrefs(next: DoctorReportPrefs) {
// Best-effort PUT — a network blip mustn't block PDF generation. The
// current dialog still submits with the chosen `next` value either
// way, so the user gets the PDF they asked for even if persistence
// failed (they'll just see the defaults next time).
try {
await fetch("/api/auth/me/doctor-report-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(next),
});
} catch {
// ignore
}
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!validation.ok) {
@@ -155,12 +333,21 @@ export function DoctorReportDialog({
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();
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
const trimmedPractice = practiceName.trim();
// Compose the section payload: keep the persisted prefs for keys
// that ARE available in the range; force `false` for keys that
// aren't (so the server never tries to render an empty section).
const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS };
for (const key of SECTION_ORDER) {
sections[key] =
(availability?.[key] ?? false) && prefs[key] === true;
}
// Fire-and-forget persistence so the user doesn't wait on it.
void persistPrefs(prefs);
await onSubmit({
startDate: startIso,
endDate: endIso,
@@ -168,6 +355,7 @@ export function DoctorReportDialog({
trimmedPractice.length > 0
? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH)
: null,
sections,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
@@ -263,6 +451,15 @@ export function DoctorReportDialog({
/>
</div>
{/* ── Section toggles ──────────────────────────────────────── */}
<SectionToggles
activeSections={activeSections}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
onToggle={toggleSection}
/>
{error && (
<p
role="alert"
@@ -294,3 +491,119 @@ export function DoctorReportDialog({
</Dialog>
);
}
// ────────────────────────────── Subviews ──────────────────────────────
interface SectionTogglesProps {
activeSections: Array<keyof DoctorReportPrefs>;
availability: SectionAvailability | null;
availabilityLoading: boolean;
prefs: DoctorReportPrefs;
onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void;
}
const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
bp: "doctorReport.sections.bp",
weight: "doctorReport.sections.weight",
pulse: "doctorReport.sections.pulse",
bmi: "doctorReport.sections.bmi",
mood: "doctorReport.sections.mood",
compliance: "doctorReport.sections.compliance",
sleep: "doctorReport.sections.sleep",
};
function SectionToggles({
activeSections,
availability,
availabilityLoading,
prefs,
onToggle,
}: SectionTogglesProps) {
const { t } = useTranslations();
// First open: show a quiet skeleton row instead of the full toggle
// list. Keeps the dialog from "flashing" between empty and populated
// states while the probe is in-flight.
if (availability === null && availabilityLoading) {
return (
<div
className="border-border bg-muted/30 space-y-2 rounded-lg border p-3"
data-testid="doctor-report-sections-loading"
>
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<div className="bg-muted h-9 animate-pulse rounded-md" />
<div className="bg-muted h-9 animate-pulse rounded-md" />
</div>
</div>
);
}
// No data in the selected range — render an empty-state hint so the
// user understands why the toggle list is missing. The PDF still
// generates (cover + period + an empty body) so they get something.
if (availability && activeSections.length === 0) {
return (
<div
className="border-border bg-muted/30 rounded-lg border p-3"
data-testid="doctor-report-sections-empty"
>
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
{t("doctorReport.sections.title")}
</p>
<p className="text-muted-foreground mt-1.5 text-xs leading-relaxed">
{t("doctorReport.sections.empty")}
</p>
</div>
);
}
return (
<div
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3"
data-testid="doctor-report-sections"
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{activeSections.map((key) => {
const id = `dr-section-${key}`;
return (
<li key={key} className="min-h-11">
<label
htmlFor={id}
className="hover:bg-muted/50 -mx-2 flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors"
>
<span className="flex flex-col">
<span className="text-sm font-medium leading-tight">
{t(SECTION_LABEL_KEYS[key])}
</span>
{key === "mood" && (
<span className="text-muted-foreground text-[11px] leading-tight">
{t("doctorReport.sections.moodSensitive")}
</span>
)}
</span>
<Switch
id={id}
data-testid={`doctor-report-section-${key}`}
size="sm"
checked={prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>
</li>
);
})}
</ul>
</div>
);
}
@@ -183,6 +183,10 @@ function DoctorReportCard() {
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
// v1.4.25 W6c — per-section toggles. The aggregator drops
// disabled sections (mood is filtered server-side so the
// data never leaves the DB row).
sections: payload.sections,
}),
});
if (!res.ok) {