refactor(doctor-report): remove the orphaned legacy doctor-report surface

The DoctorReportDialog + its /api/doctor-report, /pdf and /availability routes
were referenced only by their own tests — the health-record (Gesundheitsakte)
export is the live surface. Remove the dead dialog, routes, their tests, and
the now-stale docs page; the shared doctor-report-data aggregator stays (the
health-record export uses it). The route-gate inventory exempt list is updated
accordingly.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:16:38 +02:00
1 parent b6db529dee
commit 63901edea7
10 files changed
+5 -1822

No files matched your search

@@ -1,125 +0,0 @@
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");
}
});
// v1.4.25 W6c — per-section toggle strings.
it(`exposes every doctor-report section-toggle key in ${locale}.json`, () => {
const messages = loadMessages(locale);
const root = messages.doctorReport as Record<string, unknown> | undefined;
const sections = root!.sections as Record<string, unknown> | undefined;
expect(
sections,
`doctorReport.sections missing in ${locale}.json`,
).toBeDefined();
const sectionKeys = [
"title",
"bp",
"weight",
"pulse",
"bmi",
"mood",
"moodSensitive",
"compliance",
"sleep",
"empty",
];
for (const key of sectionKeys) {
expect(
sections![key],
`doctorReport.sections.${key} missing in ${locale}.json`,
).toBeTruthy();
expect(typeof sections![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);
});
});
@@ -1,177 +0,0 @@
/**
* v1.4.43 QoL (M4) — `<SectionToggles>` strike-through disabled rows.
*
* Pre-fix, sections without data in the current range were filtered
* out of the rendered list, so a user had no signal that "Compliance"
* was a candidate. The fix renders every section in `SECTION_ORDER`;
* empties show with a strike-through label + tooltip explaining why
* they're disabled. Submitting still force-clears their toggle so the
* server never renders an empty section.
*
* Lives in its own SSR file because the `<DoctorReportDialog>` shell
* portals through Radix and is invisible to `renderToStaticMarkup`;
* here we exercise the toggle list directly through the internal
* `SectionToggles` export.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
__test_SECTION_ORDER,
SectionToggles,
} from "../doctor-report-dialog";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SectionToggles> v1.4.43 M4 disabled-row behaviour", () => {
it("renders a disabled row + strike-through for sections without data", () => {
const onToggle = vi.fn();
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={onToggle}
/>,
);
// Every section row mounts — bp + weight available, others disabled.
for (const key of __test_SECTION_ORDER) {
expect(html).toContain(`data-testid="doctor-report-section-${key}"`);
}
// Pulse / compliance / sleep / mood / bmi rows carry the disabled
// marker so a future styling tweak can't silently re-enable them.
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Resting pulse/,
);
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Medication adherence/,
);
expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/);
// v1.4.43 W10 design-M4 — disabled rows render with muted text
// colour (not strike-through, which reads as "deleted" rather
// than "unavailable"). The italic hint line below carries the
// semantic visually.
expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/);
});
it("renders the unavailable-hint tooltip text via the title attribute", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
);
// The disabled rows expose a `title=` attribute with the
// unavailable-hint copy so hovering a disabled toggle on desktop
// surfaces the explanation.
expect(html).toContain('title="No data in this period"');
});
it("renders disabled rows with the de locale's hint copy", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"de",
);
// Every-section-empty triggers the wider empty-state hint instead
// of the per-row tooltip. Pin both copies are present in the de
// locale shipping.
expect(html).toContain("Für den gewählten Zeitraum");
});
it("renders disabled rows when at least one section has data", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"de",
);
// German tooltip copy on the disabled rows.
expect(html).toContain('title="Keine Daten in diesem Zeitraum"');
});
it("force-clears the displayed checked state for unavailable rows", () => {
// Even if `prefs.compliance === true` (e.g. previously persisted),
// the displayed switch reads `false` when the section is not
// available in the range. The user can't toggle the disabled
// switch so the desync resolves itself; pin it here.
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: true,
compliance: false,
sleep: true,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={vi.fn()}
/>,
);
// The compliance row exists and is marked unavailable…
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Medication adherence/,
);
// …and the disabled switch is rendered (aria-disabled or
// data-disabled survives in the SSR markup).
expect(html).toContain('data-testid="doctor-report-section-compliance"');
});
});
@@ -1,687 +0,0 @@
"use client";
/**
* Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c).
*
* 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 (the maintainer 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 the maintainer — 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
* `normaliseDateRange()` (silent fallback to last-90-days) — the client
* messages are UX, not security.
*/
import { useEffect, useMemo, useRef, 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 { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
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;
// v1.15.0 — cycle section. Not rendered by this legacy dialog
// (SECTION_ORDER omits it); the flagship health-record export surfaces
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
// typechecks. Optional — the availability endpoint may not provide it.
cycle?: boolean;
// v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog;
// the flagship export panel surfaces the toggle. Optional — the
// availability endpoint may not provide it. Present so the
// `keyof DoctorReportPrefs` indexing typechecks.
labs?: 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 {
/** Controlled open state. */
open: boolean;
onOpenChange: (open: boolean) => void;
/** Last-used practice name from `/api/auth/me`; pre-fills the input. */
defaultPracticeName?: string | null;
/**
* Resolves with the user-confirmed payload. The dialog stays open and
* shows the spinner while the promise is pending; closes on resolve.
*/
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);
}
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,
defaultPracticeName,
onSubmit,
}: DoctorReportDialogProps) {
const { t } = useTranslations();
const [startDate, setStartDate] = useState<string>(defaultStartIso);
const [endDate, setEndDate] = useState<string>(todayIso);
const [practiceName, setPracticeName] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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
// (store the last observed `open` state and react during render) so the
// reset happens in the render phase rather than a setState-in-effect —
// satisfies the strict `react-hooks/set-state-in-effect` rule.
const [lastOpen, setLastOpen] = useState(false);
if (open && !lastOpen) {
setLastOpen(true);
setStartDate(defaultStartIso());
setEndDate(todayIso());
setPracticeName(defaultPracticeName ?? "");
setError(null);
setSubmitting(false);
} else if (!open && lastOpen) {
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;
apiGet<Partial<DoctorReportPrefs> | null | undefined>(
"/api/auth/me/doctor-report-prefs",
{ credentials: "include" },
)
.then((incoming) => {
if (cancelled) return;
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 apiPost<SectionAvailability>(
"/api/doctor-report/availability",
{ startDate: startIso, endDate: endIso },
{ credentials: "include" },
);
})
.then((data) => {
if (cancelled || !data) return;
if (requestId !== availabilityRequestId.current) return;
setAvailability(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 {
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);
}
// v1.4.43 QoL (M4) — pre-fix this memo built the on-screen toggle
// list, so sections without data in the current range vanished
// entirely. The dialog now renders the full `SECTION_ORDER` with
// strike-through disabled rows for the empties, and the submission
// payload still force-clears any unavailable toggle so the server
// never renders an empty section.
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 apiPut("/api/auth/me/doctor-report-prefs", next, {
credentials: "include",
});
} catch {
// ignore
}
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!validation.ok) {
setError(t(validation.key));
return;
}
setSubmitting(true);
setError(null);
try {
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,
practiceName:
trimmedPractice.length > 0
? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH)
: null,
sections,
});
} 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>
<DateInput
id="dr-start"
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>
<DateInput
id="dr-end"
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>
<div className="space-y-1.5">
<Label htmlFor="dr-practice">
{t("doctorReport.dialog.practiceLabel")}
</Label>
<Input
id="dr-practice"
type="text"
value={practiceName}
maxLength={PRACTICE_NAME_MAX_LENGTH}
placeholder={t("doctorReport.dialog.practicePlaceholder")}
onChange={(e) => setPracticeName(e.target.value)}
autoComplete="organization"
/>
</div>
{/* ── Section toggles ──────────────────────────────────────── */}
{/*
v1.4.43 QoL (M4) — pass the full `SECTION_ORDER` (not the
availability-filtered `activeSections`) so the user sees a
row for every section that *could* be in the report. Rows
without data in the current range render disabled with a
strike-through label + tooltip, so the user understands
which section is empty and why their PDF has fewer
sections than expected. The submission payload still
force-clears any unavailable toggle, so the server never
renders an empty section.
*/}
<SectionToggles
allSections={SECTION_ORDER}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
onToggle={toggleSection}
/>
{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="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("doctorReport.dialog.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ────────────────────────────── Subviews ──────────────────────────────
/**
* Exported alongside `SectionToggles` for the SSR component test. The
* order array is the canonical render order both inside the live
* dialog and inside the test.
*/
export const __test_SECTION_ORDER = [
"bp",
"weight",
"pulse",
"bmi",
"compliance",
"sleep",
"mood",
] as const satisfies ReadonlyArray<keyof DoctorReportPrefs>;
interface SectionTogglesProps {
/**
* Sections in render order — always the full `SECTION_ORDER` so the
* dialog shows the user every section that *could* be available, with
* disabled rows for ones that have no data in the current range.
*/
allSections: ReadonlyArray<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",
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
cycle: "doctorReport.sections.cycle",
// v1.17.1 — labs section; the flagship export panel drives the toggle,
// present here so the `keyof DoctorReportPrefs` record stays exhaustive.
labs: "doctorReport.sections.labs",
};
/**
* Exported only so the SSR component test in
* `__tests__/doctor-report-section-toggles.test.tsx` can render the
* toggle list outside the Radix `Dialog` portal. Treat as internal:
* the public surface is `<DoctorReportDialog>`.
*/
export function SectionToggles({
allSections,
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-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<Skeleton className="bg-muted h-9" />
<Skeleton className="bg-muted h-9" />
</div>
</div>
);
}
// v1.4.43 QoL (M4) — `availableCount` drives the empty-state hint.
// Pre-fix, an empty range filtered the list down to zero rows; now
// we always render every row with the empties disabled, so the
// empty-state guard kicks in only when *no* section has data.
const availableCount = availability
? allSections.filter((key) => availability[key]).length
: 0;
if (availability && availableCount === 0) {
return (
<div
className="border-border bg-muted/30 rounded-lg border p-3"
data-testid="doctor-report-sections-empty"
>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{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-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin motion-reduce:animate-none" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{allSections.map((key) => {
const id = `dr-section-${key}`;
// v1.4.43 QoL (M4) — sections without data in the current
// range render disabled with a strike-through label and an
// explanatory tooltip; the user immediately understands
// why their PDF carries fewer sections than expected.
const isAvailable = availability?.[key] ?? false;
const unavailableHint = t("doctorReport.sections.unavailableHint");
return (
<li key={key} className="min-h-11">
<label
htmlFor={id}
title={!isAvailable ? unavailableHint : undefined}
className={`-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors ${
isAvailable
? "hover:bg-muted/50 cursor-pointer"
: "cursor-not-allowed opacity-60"
}`}
data-unavailable={!isAvailable ? "true" : undefined}
>
<span className="flex flex-col">
<span
className={`text-sm leading-tight font-medium ${
isAvailable ? "" : "text-muted-foreground"
}`}
>
{t(SECTION_LABEL_KEYS[key])}
</span>
{!isAvailable && (
<span className="text-muted-foreground text-[11px] leading-tight italic">
{unavailableHint}
</span>
)}
{key === "mood" && isAvailable && (
<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"
disabled={!isAvailable}
checked={isAvailable && prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>
</li>
);
})}
</ul>
</div>
);
}