feat(doctor-report): show unavailable sections as disabled, not hidden

This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:32:02 +02:00
1 parent a911b335a0
commit 2b5d0e9c68
8 files changed
+269 -27

No files matched your search

+2 -1
View File
@@ -145,7 +145,8 @@
"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."
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
"unavailableHint": "Keine Daten in diesem Zeitraum"
}
},
"errorBoundary": {
+2 -1
View File
@@ -145,7 +145,8 @@
"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."
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
"unavailableHint": "No data in this period"
}
},
"errorBoundary": {
+2 -1
View File
@@ -145,7 +145,8 @@
"moodSensitive": "Sensible — desactivado por defecto.",
"compliance": "Cumplimiento de medicación",
"sleep": "Duración del sueño",
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada."
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
"unavailableHint": "Sin datos en este periodo"
}
},
"errorBoundary": {
+2 -1
View File
@@ -145,7 +145,8 @@
"moodSensitive": "Sensible — désactivé par défaut.",
"compliance": "Observance des médicaments",
"sleep": "Durée du sommeil",
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde."
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
"unavailableHint": "Pas de données sur cette période"
}
},
"errorBoundary": {
+2 -1
View File
@@ -145,7 +145,8 @@
"moodSensitive": "Sensibile — disattivato per default.",
"compliance": "Aderenza ai farmaci",
"sleep": "Durata del sonno",
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina."
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
"unavailableHint": "Nessun dato in questo periodo"
}
},
"errorBoundary": {
+2 -1
View File
@@ -145,7 +145,8 @@
"moodSensitive": "Wrażliwe — domyślnie wyłączone.",
"compliance": "Przestrzeganie leków",
"sleep": "Czas snu",
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową."
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
"unavailableHint": "Brak danych w tym okresie"
}
},
"errorBoundary": {
@@ -0,0 +1,174 @@
/**
* 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 compliance/,
);
expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/);
// The strike-through class fires for at least one disabled row.
expect(html).toMatch(/line-through[\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 compliance/,
);
// …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"');
});
});
@@ -297,14 +297,12 @@ 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]);
// 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 }));
@@ -454,8 +452,19 @@ export function DoctorReportDialog({
</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
activeSections={activeSections}
allSections={SECTION_ORDER}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
@@ -496,8 +505,28 @@ export function DoctorReportDialog({
// ────────────────────────────── 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 {
activeSections: Array<keyof DoctorReportPrefs>;
/**
* 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;
@@ -514,8 +543,14 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
sleep: "doctorReport.sections.sleep",
};
function SectionToggles({
activeSections,
/**
* 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,
@@ -543,10 +578,15 @@ function SectionToggles({
);
}
// 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) {
// 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"
@@ -576,19 +616,40 @@ function SectionToggles({
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{activeSections.map((key) => {
{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}
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"
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">
<span
className={`text-sm leading-tight font-medium ${
isAvailable ? "" : "line-through"
}`}
>
{t(SECTION_LABEL_KEYS[key])}
</span>
{key === "mood" && (
{!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>
@@ -598,7 +659,8 @@ function SectionToggles({
id={id}
data-testid={`doctor-report-section-${key}`}
size="sm"
checked={prefs[key]}
disabled={!isAvailable}
checked={isAvailable && prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>