fix(export): make the standard report a click, not a preselection

The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:46:03 +02:00
1 parent 59acbed5ab
commit bc1633db40
17 files changed
+353 -116

No files matched your search

@@ -5,6 +5,13 @@
* export panel and the share-link create form — mount this exact component, so
* two selection UIs cannot drift apart.
*
* Nothing is ever selected for the user. A picker with an empty selection
* stays empty until someone ticks something, and the one shipped template is
* reachable only through the button at the top that carries its name. One
* click still gets a complete doctor's report, so nothing got slower — but the
* click happened, and that is the whole difference between a chosen scope and
* a scope somebody merely failed to untick.
*
* Twelve group rows plus a fenced tier below a separator. The fenced tier has
* NO group checkbox: there is no control anywhere in this component that can
* turn on more than one sensitive leaf at a time, and there is no select-all.
@@ -21,6 +28,7 @@
*/
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -35,6 +43,10 @@ import {
toggleGroup,
toggleLeaf,
} from "@/lib/report-selection/panel-state";
import {
STANDARD_TEMPLATE_LABEL_KEY,
standardTemplateFor,
} from "@/lib/report-selection/template";
import { GroupRow } from "./group-row";
import { LeafGrid } from "./leaf-grid";
@@ -93,8 +105,37 @@ export function ReportScopePicker({
onChange(toggleGroup(selected, group, leaves, remembered));
};
const hintId = `${idPrefix}-standard`;
return (
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. */}
<div className="flex items-start justify-between gap-3">
<p
id={hintId}
className="text-muted-foreground min-w-0 flex-1 text-xs"
data-testid={`report-standard-hint-${surface}`}
>
{t("reportSelection.standardHint")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
aria-describedby={hintId}
data-testid={`report-apply-standard-${surface}`}
onClick={() => onChange(new Set(standardTemplateFor(hiddenLeaves)))}
>
{t("reportSelection.applyStandard", {
template: t(STANDARD_TEMPLATE_LABEL_KEY),
})}
</Button>
</div>
{plainGroups.map((group) => (
<GroupRow
key={group.id}
@@ -7,6 +7,11 @@
* restated by name rather than folded into the count, because a number cannot
* be checked at a glance and "incl. Mood, PHQ-9" can.
*
* On an empty scope it is also the reason the generate / create control is
* disabled, which is why it takes an `id`: the control points its
* `aria-describedby` here, so "why can I not press this" has an answer in the
* accessibility tree as well as on screen.
*
* Meta tier — one sentence about the form beside it, not content.
*
* One per picker, so the test id ends with the same surface its picker does.
@@ -18,10 +23,13 @@ import type { ReportScopeSurface } from "./surface";
export function ScopeSummary({
t,
id,
surface,
selected,
}: {
t: (key: string, vars?: Record<string, string | number>) => string;
/** Referenced by the control this line explains, via `aria-describedby`. */
id?: string;
surface: ReportScopeSurface;
selected: ReadonlySet<ReportLeafId>;
}) {
@@ -29,6 +37,7 @@ export function ScopeSummary({
if (summary.total === 0) {
return (
<p
id={id}
className="text-muted-foreground text-xs"
data-testid={`report-scope-summary-${surface}`}
>
@@ -48,6 +57,7 @@ export function ScopeSummary({
: null;
return (
<p
id={id}
className="text-muted-foreground text-xs"
data-testid={`report-scope-summary-${surface}`}
>
@@ -9,10 +9,11 @@
*
* Three states, by design:
*
* 1. First run (no saved selection): the picker opens EXPANDED with the
* named standard template applied and the fenced tier visibly empty, so
* the user presses Generate having seen twelve named groups. That is
* consent to a named bundle, not a server-chosen default.
* 1. First run (no saved selection): NOTHING is selected. The picker opens
* expanded so the standard-report button and the empty fenced tier are
* both in view, and Generate is disabled until something is ticked. One
* click on that button still produces a complete doctor's report, so the
* fast path costs one interaction, not a scope nobody chose.
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
* selection is loaded, and the scope line under the button says what will
* be in the document. Two interactions after navigation, and the reading
@@ -55,10 +56,6 @@ import {
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
} from "@/lib/report-selection/profile-shape";
import {
STANDARD_TEMPLATE_LABEL_KEY,
STANDARD_TEMPLATE_LEAVES,
} from "@/lib/report-selection/template";
import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
@@ -85,16 +82,19 @@ export function HealthRecordExportPanel() {
const [includeCharts, setIncludeCharts] = useState<boolean>(
SAVED_PROFILE_FALLBACK.includeCharts,
);
// Empty until a human ticks something. There is no default selection to get
// wrong, because there is no default selection.
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
() => new Set(STANDARD_TEMPLATE_LEAVES),
() => new Set<ReportLeafId>(),
);
const [seededUserId, setSeededUserId] = useState<string | null>(null);
// First run opens the picker so the template is seen, not merely applied.
// A first run opens the picker: with nothing selected, the standard-report
// button and the empty groups are the two things the person needs to see.
const [pickerOpen, setPickerOpen] = useState(true);
const [templateApplied, setTemplateApplied] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerPanelId = useId();
const scopeSummaryId = useId();
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
@@ -106,7 +106,6 @@ export function HealthRecordExportPanel() {
setDays(saved.rangeDays);
setIncludeCharts(saved.includeCharts);
setPickerOpen(false);
setTemplateApplied(false);
}
}
@@ -120,10 +119,6 @@ export function HealthRecordExportPanel() {
});
async function handleGenerate() {
if (selected.size === 0) {
setError(t("settings.healthRecord.errorEmptySelection"));
return;
}
setBusy(true);
setError(null);
try {
@@ -171,7 +166,6 @@ export function HealthRecordExportPanel() {
a.click();
a.remove();
URL.revokeObjectURL(url);
setTemplateApplied(false);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
@@ -332,16 +326,6 @@ export function HealthRecordExportPanel() {
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
{templateApplied ? (
<p
className="text-muted-foreground text-xs"
data-testid="report-template-note"
>
{t("reportSelection.templateApplied", {
template: t(STANDARD_TEMPLATE_LABEL_KEY),
})}
</p>
) : null}
<ReportScopePicker
surface="export"
selected={selected}
@@ -365,17 +349,28 @@ export function HealthRecordExportPanel() {
{/* The FHIR note and the generate action share the footer row. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<ScopeSummary t={t} surface="export" selected={selected} />
<ScopeSummary
t={t}
id={scopeSummaryId}
surface="export"
selected={selected}
/>
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : null}
</div>
{/* An empty scope cannot be generated: there is no document to make.
The scope line beside the button carries the reason and the way
out of it, and describes the button so the disabled state is not
silent for a screen reader. */}
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
disabled={busy || selected.size === 0}
aria-describedby={scopeSummaryId}
data-testid="health-record-generate"
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
>
{busy ? (