mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(doctor-report): per-section toggle UI + persistence
This commit is contained in:
1 parent
b1a3d59918
commit
5cb4a1dde1
7 files changed
+737
-13
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user