mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user