From 2b5d0e9c6893232662e11ebb6e10ccf6b055118c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 22:22:08 +0200 Subject: [PATCH] feat(doctor-report): show unavailable sections as disabled, not hidden --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../doctor-report-section-toggles.test.tsx | 174 ++++++++++++++++++ .../doctor-report/doctor-report-dialog.tsx | 104 ++++++++--- 8 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx diff --git a/messages/de.json b/messages/de.json index f9333f0c7..2031ee733 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 450bfd142..dc1634301 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 44a81af62..2acb90ec9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index ddeee7003..ce1d8d908 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 717188b07..27d5157d3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 937d0ad43..5309c304f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx new file mode 100644 index 000000000..90bbf63fc --- /dev/null +++ b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx @@ -0,0 +1,174 @@ +/** + * v1.4.43 QoL (M4) — `` 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 `` 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( + {node}, + ); +} + +describe(" 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( + , + ); + + // 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( + , + ); + // 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( + , + "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( + , + "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( + , + ); + // 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"'); + }); +}); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 4a3bc1566..d730002c6 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -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>(() => { - 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({ {/* ── 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. + */} ; + interface SectionTogglesProps { - activeSections: Array; + /** + * 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; availability: SectionAvailability | null; availabilityLoading: boolean; prefs: DoctorReportPrefs; @@ -514,8 +543,14 @@ const SECTION_LABEL_KEYS: Record = { 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 ``. + */ +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 (
    - {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 (