diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts
index dbe14a4d8..a6ef1db5c 100644
--- a/e2e/settings-export.spec.ts
+++ b/e2e/settings-export.spec.ts
@@ -101,12 +101,11 @@ test.describe("Settings → Export & Import", () => {
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
page,
}) => {
- // The three panel states are the release's whole point, and the first one
- // is the only place a template is applied. It has to be VISIBLE — a
- // template behind a collapsed disclosure is opt-out wearing a hat — so the
- // picker, its twelve group rows and the fenced tier all render without a
- // click. Asserted against the export mount; the share form runs its own
- // picker with its own defaults.
+ // The three panel states are the release's whole point, and on the first
+ // one nothing is selected at all. The picker still opens without a click,
+ // because with an empty scope the standard-report button and the named
+ // groups are exactly what the person needs in front of them. Asserted
+ // against the export mount; the share form runs its own picker.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
timeout: 10_000,
@@ -128,6 +127,61 @@ test.describe("Settings → Export & Import", () => {
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
});
+ test("first run selects nothing and refuses to generate until it does", async ({
+ page,
+ }) => {
+ // The rule the whole surface exists for: nothing reaches the report that
+ // was not ticked. So a first run starts empty, Generate refuses the press
+ // and says why, and the one-click action is what makes that cost nothing —
+ // it fills the standard set and the control comes alive.
+ await openGesundheitsakte(page);
+ const generate = page.getByTestId("health-record-generate");
+ await expect(generate).toBeVisible({ timeout: 10_000 });
+ await expect(generate).toBeDisabled();
+ await expect(
+ page.getByTestId("report-group-row-vitals-export"),
+ ).toContainText("0/7");
+ await expect(page.getByTestId("report-scope-summary-export")).toContainText(
+ "Nothing selected yet",
+ );
+
+ await page.getByTestId("report-apply-standard-export").click();
+
+ await expect(generate).toBeEnabled();
+ // Exactly the shipped set: the three classic vitals, weight and BMI, and
+ // nothing at all from the groups the template leaves alone.
+ await expect(
+ page.getByTestId("report-group-row-vitals-export"),
+ ).toContainText("3/7");
+ await expect(
+ page.getByTestId("report-group-row-body-export"),
+ ).toContainText("2/12");
+ await expect(
+ page.getByTestId("report-group-row-cardio-export"),
+ ).toContainText("0/14");
+ // The fenced tier is not part of any one-click set.
+ await expect(
+ page.getByTestId("report-scope-summary-export"),
+ ).not.toContainText("incl.");
+ });
+
+ test("a share link cannot be minted on an empty scope either", async ({
+ page,
+ }) => {
+ await openGesundheitsakte(page);
+ const create = page.getByTestId("share-create-submit");
+ await expect(create).toBeVisible({ timeout: 10_000 });
+ await page.locator("#share-label").fill("Cardiology");
+ // A label alone is not a scope: the link would serve a page with nothing
+ // on it, so the control stays shut until something is chosen.
+ await expect(create).toBeDisabled();
+ await expect(page.getByTestId("report-scope-summary-share")).toContainText(
+ "Nothing selected yet",
+ );
+ await page.getByTestId("report-apply-standard-share").click();
+ await expect(create).toBeEnabled();
+ });
+
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openGesundheitsakte(page);
diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts
index 28d00a7d6..862f4a426 100644
--- a/e2e/share-documents.spec.ts
+++ b/e2e/share-documents.spec.ts
@@ -51,9 +51,10 @@ interface CreatedShare {
}
/**
- * Drive the owner create flow: open the picker, isolate the share-fixture trio
- * by search, select all three, create the link, and read the one-time token +
- * passphrase off the reveal card. Also asserts the QR promotion.
+ * Drive the owner create flow: name the link, choose a record scope, open the
+ * picker, isolate the share-fixture trio by search, select all three, create
+ * the link, and read the one-time token + passphrase off the reveal card. Also
+ * asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
@@ -67,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise {
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
+ // A record link starts with an empty scope and cannot be minted on one, so
+ // the scope is chosen here the way an owner chooses it: one click on the
+ // named standard set.
+ await page.getByTestId("report-apply-standard-share").click();
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
diff --git a/messages/de.json b/messages/de.json
index 334d94127..4728f85d6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "Bis",
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
- "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.",
- "errorEmptySelection": "Wählen Sie mindestens einen Eintrag aus."
+ "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
@@ -8888,11 +8887,12 @@
"leafMood": "Stimmung",
"leafCycle": "Menstruationszyklus",
"templateStandard": "Standard-Arztbericht",
- "templateApplied": "{template} ist vorausgewählt. Prüfen Sie ihn vor dem Erstellen.",
+ "applyStandard": "{template} übernehmen",
+ "standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Einträge in {group} anzeigen",
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
- "scopeNone": "Nichts ausgewählt — das Dokument wäre leer.",
+ "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}"
}
diff --git a/messages/en.json b/messages/en.json
index 564f393b2..4191253be 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "To",
"errorGeneric": "The export could not be generated. Please try again.",
"errorRateLimit": "You have reached the export limit for this hour.",
- "errorModuleDisabled": "The health record export is switched off for this account.",
- "errorEmptySelection": "Pick at least one entry to include."
+ "errorModuleDisabled": "The health record export is switched off for this account."
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
@@ -8888,11 +8887,12 @@
"leafMood": "Mood",
"leafCycle": "Menstrual cycle",
"templateStandard": "Standard doctor's report",
- "templateApplied": "{template} is preselected. Check it before you generate.",
+ "applyStandard": "Apply {template}",
+ "standardHint": "One click fills in what a general visit usually needs. Every entry stays editable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Show the entries in {group}",
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
- "scopeNone": "Nothing selected — the document would be empty.",
+ "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}"
}
diff --git a/messages/es.json b/messages/es.json
index 7f14c9594..d046fbd97 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "Hasta",
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
- "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.",
- "errorEmptySelection": "Seleccione al menos una entrada."
+ "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
@@ -8888,11 +8887,12 @@
"leafMood": "Estado de ánimo",
"leafCycle": "Ciclo menstrual",
"templateStandard": "Informe médico estándar",
- "templateApplied": "{template} está preseleccionado. Revíselo antes de generar.",
+ "applyStandard": "Aplicar {template}",
+ "standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostrar las entradas de {group}",
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
- "scopeNone": "Nada seleccionado: el documento estaría vacío.",
+ "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}"
}
diff --git a/messages/fr.json b/messages/fr.json
index 8aa90fa95..2eda57ea9 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "Au",
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
- "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.",
- "errorEmptySelection": "Sélectionnez au moins un élément."
+ "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
@@ -8888,11 +8887,12 @@
"leafMood": "Humeur",
"leafCycle": "Cycle menstruel",
"templateStandard": "Compte rendu médical standard",
- "templateApplied": "{template} est présélectionné. Vérifiez-le avant de générer.",
+ "applyStandard": "Appliquer {template}",
+ "standardHint": "Un clic remplit ce dont une consultation générale a généralement besoin. Chaque élément reste modifiable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Afficher les éléments de {group}",
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
- "scopeNone": "Rien de sélectionné — le document serait vide.",
+ "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}"
}
diff --git a/messages/it.json b/messages/it.json
index 198f067a2..f772e54ed 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "Al",
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
- "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.",
- "errorEmptySelection": "Seleziona almeno una voce."
+ "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
@@ -8888,11 +8887,12 @@
"leafMood": "Umore",
"leafCycle": "Ciclo mestruale",
"templateStandard": "Referto medico standard",
- "templateApplied": "{template} è preselezionato. Controllalo prima di generare.",
+ "applyStandard": "Applica {template}",
+ "standardHint": "Un clic compila ciò che serve di solito a una visita generale. Ogni voce resta modificabile.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostra le voci di {group}",
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
- "scopeNone": "Niente selezionato: il documento sarebbe vuoto.",
+ "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}"
}
diff --git a/messages/pl.json b/messages/pl.json
index 563f4fc1f..45354f0dc 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5317,8 +5317,7 @@
"rangeCustomEnd": "Do",
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
- "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.",
- "errorEmptySelection": "Wybierz co najmniej jedną pozycję."
+ "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
@@ -8888,11 +8887,12 @@
"leafMood": "Nastrój",
"leafCycle": "Cykl menstruacyjny",
"templateStandard": "Standardowy raport lekarski",
- "templateApplied": "{template} jest wstępnie wybrany. Sprawdź go przed wygenerowaniem.",
+ "applyStandard": "Zastosuj {template}",
+ "standardHint": "Jedno kliknięcie wypełnia to, czego zwykle wymaga wizyta u lekarza rodzinnego. Każdą pozycję można potem zmienić.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Pokaż pozycje w {group}",
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
- "scopeNone": "Nic nie wybrano — dokument byłby pusty.",
+ "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}"
}
diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx
index 3484046ff..7d0a34249 100644
--- a/src/components/settings/__tests__/report-selection-panel.test.tsx
+++ b/src/components/settings/__tests__/report-selection-panel.test.tsx
@@ -7,22 +7,26 @@
* stick, and it is gated on the user id so a late re-resolve never clobbers
* edits in flight.
*
- * The three panel states are the load-bearing part. First run must open the
- * picker EXPANDED with the named template applied and the fenced tier visibly
- * empty, so the user presses Generate having seen twelve named groups; a
- * repeat run must open collapsed carrying the owner's own last scope. A
- * template silently applied behind a collapsed disclosure would be opt-out
- * wearing a hat.
+ * The three panel states are the load-bearing part. A first run must select
+ * NOTHING — no template, no server default, no group half on — and must leave
+ * Generate disabled until a person ticks something, with the standard-report
+ * button one click away. A repeat run must open collapsed carrying the owner's
+ * own last scope. A set that arrives without being asked for is a
+ * preselection whatever it is called, and material nobody ticked is exactly
+ * what this surface must never send.
*
* SSR-only, per the project convention for this surface.
*
- * Mutation checks:
+ * Mutation checks (each verified red, then reverted):
* - remove the seeding block → "pre-fills the input" and "restores the saved
* selection" go red.
+ * - seed `selected` from `STANDARD_TEMPLATE_LEAVES` → "selects nothing on a
+ * first run", "counts nothing in any group" and "disables Generate" go
+ * red.
* - start `pickerOpen` at `false` → "opens expanded on the first run" goes
* red.
- * - add a fenced leaf to `STANDARD_TEMPLATE_LEAVES` → "checks no fenced leaf
- * on the first run" goes red.
+ * - drop `selected.size === 0` from the Generate `disabled` expression →
+ * "disables Generate while the scope is empty" goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -126,6 +130,15 @@ function practiceInputValue(html: string): string | null {
return input.match(/value="([^"]*)"/)?.[1] ?? "";
}
+/** Whether the Generate control refuses the press. */
+function generateDisabled(html: string): boolean {
+ const button = html.match(
+ /
)}
+ {/* A record link with an empty scope cannot be minted: it would serve
+ a page with nothing on it. The scope line above says so and says
+ what to do about it, and describes the button so the disabled state
+ is not silent for a screen reader. */}
{createMutation.isPending && (
diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
index 8a36daf46..dc491a744 100644
--- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts
+++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
@@ -29,7 +29,7 @@ import {
STRUCTURED_LEAF_IDS,
leafLabelKey,
} from "../catalogue";
-import { STANDARD_TEMPLATE_LEAVES } from "../template";
+import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template";
const EN = JSON.parse(
readFileSync(
@@ -112,3 +112,37 @@ describe("template purity", () => {
);
});
});
+
+/**
+ * The set behind the one-click action, checked as a set.
+ *
+ * The button is the only thing that applies the template now, so what it
+ * applies has to be exactly the shipped list and nothing beside it — a leaf
+ * that crept in here would land in a document the person believes they chose
+ * from a named list.
+ *
+ * Mutation check (verified red, then reverted): make `standardTemplateFor`
+ * ignore its argument → "drops only what the surface hides" goes red.
+ */
+describe("the standard-report action", () => {
+ it("applies exactly the shipped set and nothing outside it", () => {
+ expect(standardTemplateFor()).toEqual([...STANDARD_TEMPLATE_LEAVES]);
+ const outside = standardTemplateFor().filter(
+ (leaf) => !STANDARD_TEMPLATE_LEAVES.includes(leaf),
+ );
+ expect(outside).toEqual([]);
+ // Nothing fenced, on any surface: the fence is not a matter of which
+ // button you press.
+ expect(
+ standardTemplateFor().filter((leaf) => SENSITIVE_LEAF_IDS.includes(leaf)),
+ ).toEqual([]);
+ });
+
+ it("drops only what the surface hides", () => {
+ const onShare = standardTemplateFor(["INSURANCE"]);
+ expect(onShare).not.toContain("INSURANCE");
+ expect(onShare).toEqual(
+ STANDARD_TEMPLATE_LEAVES.filter((leaf) => leaf !== "INSURANCE"),
+ );
+ });
+});
diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts
index b83811681..347b4e051 100644
--- a/src/lib/report-selection/template.ts
+++ b/src/lib/report-selection/template.ts
@@ -1,14 +1,15 @@
/**
* The one shipped template: what a general doctor's visit usually needs.
*
- * It is applied on the FIRST run only, with the panel open, so the user
- * presses Generate having seen twelve named groups and an empty fenced tier.
- * That is consent to a named bundle, not a server-chosen default — nothing
- * here is applied to a stored selection, to a share link, or to any surface
- * that cannot ask a human.
+ * Nothing applies it on its own. It reaches a selection only when a person
+ * presses the button that carries its name, on a panel they are looking at —
+ * never on load, never on a stored selection, never on a share link, never on
+ * a surface that cannot ask a human. A set that arrives without being asked
+ * for is a preselection whatever it is called, and the one thing this release
+ * refuses is material in a report nobody ticked.
*
* The template intersects the fenced tier at zero leaves, and
- * `report-template-purity.test.ts` fails the build if that stops being true.
+ * `catalogue-guard.test.ts` fails the build if that stops being true.
*/
import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
@@ -39,8 +40,23 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"ILLNESS_EPISODES",
];
-/** The template's i18n label — it is named in the panel, never anonymous. */
+/** The template's i18n label — it is named on the button, never anonymous. */
export const STANDARD_TEMPLATE_LABEL_KEY = "reportSelection.templateStandard";
+/**
+ * The template as one surface can offer it: the shipped set minus the leaves
+ * that surface may not carry (the share-link form hides the insurance leaf,
+ * which the server refuses anyway).
+ *
+ * Pure, so "one click applies exactly the standard set and nothing outside it"
+ * is provable without a DOM.
+ */
+export function standardTemplateFor(
+ hiddenLeaves: readonly ReportLeafId[] = [],
+): ReportLeafId[] {
+ const hidden = new Set(hiddenLeaves);
+ return STANDARD_TEMPLATE_LEAVES.filter((leaf) => !hidden.has(leaf));
+}
+
/** Exported so the purity guard reads the same set the panel does. */
export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS;