mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(doctor-report): show unavailable sections as disabled, not hidden
This commit is contained in:
1 parent
a911b335a0
commit
2b5d0e9c68
8 files changed
+269
-27
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user