refactor(export): highlight the health-record export, demote the doctor report, collapse the included-data list

The Settings export page over-emphasised the doctor-report PDF: it owned
the page hero while the broader health-record export (PDF + FHIR R4 + zip
package) sat below it as a plain card. The relative prominence is now the
other way round.

- The health-record export panel takes the page hero, with the same
  hero-gradient + glow-purple treatment the Insights hero strip uses.
- The doctor-report card drops the hero styling and moves to the bottom
  of the page as a small secondary card. It stays fully functional — the
  same dialog + /api/doctor-report flow runs from there.
- The health-record "included data" checklist is now a disclosure,
  collapsed by default, so the panel opens compact instead of as a long
  always-expanded list. It uses the inline aria-expanded/aria-controls
  pattern already used elsewhere, with no new dependency.

Pure presentation/IA change: export routes, payloads, and the exported
data are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 21:43:02 +02:00
1 parent 68cad9d11c
commit d4801d4646
6 files changed
+196 -129

No files matched your search

+33 -11
View File
@@ -3,12 +3,16 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 phase B7 — Settings → Export consolidated UI smoke.
* Settings → Export consolidated UI smoke.
*
* Validates that:
* 1. The five export cards are all rendered with their stable testids.
* 2. Clicking the Measurements CSV download button fires a real
* browser download — proving the new `/api/export/measurements`
* 1. The export surfaces are all rendered with their stable testids:
* the health-record export hero, the four CSV/JSON tiles, and the
* demoted doctor-report card (v1.12).
* 2. The health-record "included data" checklist is a disclosure,
* collapsed by default, and expands on demand.
* 3. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
* The other CSV / JSON cards share the same code path; one happy-path
@@ -18,24 +22,42 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("Settings → Export", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renders all five export cards with stable testids", async ({
page,
}) => {
test("renders all export surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.4.37 lifted the doctor-report card to a hero block with the
// `export-hero-*` testid prefix; the four CSV/backup tiles keep
// the `export-card-*` shape.
// v1.12 — the health-record export is the page hero; the
// doctor-report card (`export-hero-*` prefix) is demoted to the
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
for (const id of [
"export-hero-doctor-report",
"health-record-export-panel",
"export-card-measurements-csv",
"export-card-medications-csv",
"export-card-mood-csv",
"export-card-full-backup",
"export-hero-doctor-report",
]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("included-data checklist is collapsed by default and expands on demand", async ({
page,
}) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const toggle = page.getByTestId("health-record-included-data-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toHaveCount(0);
// Expands on demand.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toBeVisible();
});
test("Measurements CSV download fires a real download event", async ({
page,
}) => {
@@ -1,11 +1,13 @@
/**
* v1.4.37 W7a — Arztbericht hero card contract.
* Arztbericht (doctor-report) card contract.
*
* The flagship doctor-report PDF surface was promoted from one of five
* grid cards into a hero card at the top of `/settings/export`. This
* suite pins the SSR contract:
* v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
* back to a small secondary card at the bottom of `/settings/export`
* (the health-record export now owns the hero). The card stays fully
* functional — only the framing and visual weight changed. This suite
* pins the SSR contract:
*
* 1. The hero renders with the expected `data-testid` slot.
* 1. The card renders with the expected `data-testid` slot.
* 2. The value statement is present (Marc-Voice copy, no AI mention).
* 3. The CTA button is present, clears the 44 px touch floor, and
* carries an `aria-describedby` link to the value statement.
@@ -37,7 +39,7 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
}
describe("<ArztberichtHeroCard> — SSR contract", () => {
it("renders the hero slot with eyebrow + title", () => {
it("renders the card slot with eyebrow + title", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report"');
// Title falls back to the existing doctorReport.title key — keeps
@@ -59,10 +61,10 @@ describe("<ArztberichtHeroCard> — SSR contract", () => {
it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
// 44 px touch floor (WCAG 2.5.5) — the hero CTA pins `h-11` on
// mobile and falls back to the compact `sm:h-10` on pointer
// 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
// on mobile and falls back to the compact `sm:min-h-9` on pointer
// devices.
expect(html).toContain("h-11");
expect(html).toContain("min-h-11");
// Aria-describedby links the CTA to the value statement so screen
// readers announce the framing alongside the action label.
expect(html).toMatch(/aria-describedby="[^"]+"/);
@@ -84,12 +86,15 @@ describe("<ArztberichtHeroCard> — SSR contract", () => {
expect(html).not.toContain("settings.sections.export.hero");
});
it("uses the hero-gradient + glow-purple visual treatment", () => {
it("renders as a plain secondary card, not a hero", () => {
const html = renderSSR(<ArztberichtHeroCard />);
// Same surface utilities as the Insights hero strip — keeps the
// app's hero language consistent.
expect(html).toContain("hero-gradient");
expect(html).toContain("glow-purple");
// v1.12 demoted the doctor-report from the page hero — the
// health-record export now owns the hero treatment, so this card
// must NOT carry the hero gradient/glow utilities.
expect(html).not.toContain("hero-gradient");
expect(html).not.toContain("glow-purple");
// It renders with the standard secondary-card surface.
expect(html).toContain("bg-card");
});
it("does not paint dialog markup in the closed-state SSR pass", () => {
@@ -1,24 +1,24 @@
/**
* v1.4.16 Phase B7 — Settings → Export section.
*
* The acceptance criterion for the consolidated Export menu was "five
* cards":
* 1. Doctor Report (PDF) — promoted to the hero in v1.4.37 W7a;
* now lives at the top of the page, NOT in the grid
* 2. Measurements CSV
* 3. Medications CSV (with optional intake-history toggle)
* 4. Mood CSV
* 5. Full JSON Backup
* The consolidated Export page surfaces:
* - Health Record export panel (the page hero, v1.12)
* - Measurements CSV
* - Medications CSV (with optional intake-history toggle)
* - Mood CSV
* - Full JSON Backup
* - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
*
* Each card must surface a title, a 1-line description, and a
* download/generate button. SSR-only smoke test — interaction is
* exercised by the e2e suite.
*
* v1.4.37 W7a — the doctor-report card was extracted into
* `<ArztberichtHeroCard>` and rendered above the grid. The remaining
* four cards stay under a "Weitere Export-Optionen" / "Other export
* options" sub-heading. The hero owns its own contract test; this
* suite pins the page-level shape.
* v1.12 — the health-record export takes the page hero; the
* doctor-report card (`<ArztberichtHeroCard>`) is demoted to a small
* secondary card at the bottom of the page. The four CSV/JSON tiles
* stay under a "Weitere Export-Optionen" / "Other export options"
* sub-heading. Each component owns its own contract test; this suite
* pins the page-level shape.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -60,11 +60,19 @@ describe("<ExportSection> — SSR smoke", () => {
expect(html).not.toContain("settings.sections.export.");
});
it("renders the Arztbericht hero card at the top of the page", () => {
it("mounts the health-record export panel as the page hero", () => {
const html = render(<ExportSection />);
// The hero owns its own contract suite; here we pin that the page
// v1.12 — the health-record export owns the hero treatment.
expect(html).toContain('data-testid="health-record-export-panel"');
expect(html).toContain("hero-gradient");
expect(html).toContain("glow-purple");
});
it("mounts the demoted doctor-report card", () => {
const html = render(<ExportSection />);
// The card owns its own contract suite; here we pin that the page
// mounts it, and that the legacy in-grid doctor-report card is
// gone (v1.4.37 W7a moved it into the hero).
// gone. The card no longer carries the hero gradient/glow (v1.12).
expect(html).toContain('data-testid="export-hero-doctor-report"');
expect(html).not.toContain('data-testid="export-card-doctor-report"');
});
@@ -1,46 +1,30 @@
"use client";
/**
* v1.4.37 W7a — Settings → Export Arztbericht hero card.
* Settings → Export Arztbericht (doctor-report) card.
*
* The doctor-report PDF is HealthLog's flagship export — the artefact a
* user actually carries into a doctor's appointment. The v1.4.16 phase
* B7 layout treated it as one of five equal cards in the grid, which
* undersold the feature. Marc 2026-05-17:
* The doctor-report PDF is the artefact a user carries into a doctor's
* appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
* it back to a small secondary card at the bottom of the export page —
* the health-record export now owns the page hero. The card is fully
* functional: the CTA routes through the existing `<DoctorReportDialog>`
* + `/api/doctor-report` flow exactly as before. Only the framing and
* visual weight changed.
*
* "Können wir unter Einstellungen > Export den Arztbericht als Hero
* Card machen?"
* Layout (compact card, no hero gradient):
*
* This component lifts the doctor-report card out of the grid and
* paints it with the same `hero-gradient + glow-purple` visual treatment
* as the Insights hero strip (`src/components/insights/hero-strip.tsx`),
* so the surface feels consistent with the rest of the app. The CTA
* routes through the existing `<DoctorReportDialog>` + `/api/doctor-
* report` flow — only the framing changed.
*
* Layout:
*
* ┌─ hero ────────────────────────────────────────────┐
* │ [icon] eyebrow │
* │ H2 title (settings.sections.export.cards. │
* │ doctorReport.title) │
* ┌─ card ────────────────────────────────────────────┐
* │ [icon] H2 title eyebrow │
* │ one-line value statement │
* │ ───── │
* │ [Configure & generate] PDF · printable │
* └───────────────────────────────────────────────────┘
*
* Mobile-first: at < sm the CTA stacks below the value statement (the
* action row is `flex-wrap`), the hero pads down to `px-4 py-5`, and
* the title clamps to a single line.
*
* a11y:
* - The CTA button clears the 44 px touch floor (`min-h-11`).
* - `focus-visible:ring` ships via the default `<Button>` variant.
* - The CTA is `aria-describedby={valueStatementId}` so a screen
* reader announces the value statement alongside the action.
* - The hero's `<h2>` slots beneath the page-level `<h1>` so the
* outline reads h1 (Export) → h2 (Arztbericht hero) → h2
* (Weitere Export-Optionen) → h2 (each remaining card).
* - The card's `<h2>` slots beneath the page-level `<h1>`.
*/
import { useEffect, useId, useState } from "react";
@@ -52,7 +36,6 @@ import {
type DoctorReportSubmitPayload,
} from "@/components/doctor-report/doctor-report-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
export function ArztberichtHeroCard() {
const { t, locale } = useTranslations();
@@ -122,52 +105,47 @@ export function ArztberichtHeroCard() {
<section
data-testid="export-hero-doctor-report"
aria-labelledby="export-hero-doctor-report-title"
className={cn(
"hero-gradient glow-purple animate-insight-in",
// `isolate` traps the purple glow inside the hero so the
// shadow doesn't leak through the cards below — same trick
// the Insights `<HeroStrip>` uses.
"relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
)}
className="bg-card border-border rounded-xl border p-5 sm:p-6"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Stethoscope
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Stethoscope
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="export-hero-doctor-report-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.cards.doctorReport.title")}
</h2>
</div>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("settings.sections.export.hero.eyebrow")}
</span>
</div>
<h2
id="export-hero-doctor-report-title"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{t("settings.sections.export.cards.doctorReport.title")}
</h2>
<p
id={valueStatementId}
data-testid="export-hero-doctor-report-value"
className="text-muted-foreground max-w-2xl text-sm leading-relaxed"
className="text-muted-foreground max-w-2xl text-xs leading-relaxed"
>
{t("settings.sections.export.hero.valueStatement")}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border/50 pt-4">
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="export-hero-doctor-report-action"
variant="default"
variant="outline"
size="sm"
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
// we override the default `<Button>` height on mobile and
// fall back to the compact `sm:h-10` on pointer devices.
// v1.4.38 W-D P3-2 — pair the explicit `h-11` with a
// `min-h-11` floor (and a `sm:min-h-9` desktop floor) so a
// future className override can't accidentally re-lift the
// mobile minimum below the touch-target contract.
className="h-11 min-h-11 px-5 text-sm font-medium sm:h-10 sm:min-h-9"
// we keep it on mobile and fall back to the compact
// `sm:min-h-9` on pointer devices so a future className
// override can't re-lift the mobile minimum below the
// touch-target contract.
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
disabled={busy}
aria-describedby={valueStatementId}
+20 -19
View File
@@ -9,18 +9,20 @@
* per export type, each with its own filter inputs and a clear
* "Download" / "Generate" button.
*
* 1. Doctor Report (PDF) — hero card at the top of the page
* (v1.4.37 W7a, see
* `<ArztberichtHeroCard>`)
* 1. Health Record export — hero panel at the top of the page
* (PDF + FHIR R4 + zip package, see
* `<HealthRecordExportPanel>`)
* 2. Measurements CSV — optional `since`/`until`
* 3. Medications CSV — optional intake-history toggle
* 4. Mood CSV — optional `since`/`until`
* 5. Full JSON Backup — single-file user-scoped dump
* 6. Doctor Report (PDF) — small secondary card at the bottom
* (`<ArztberichtHeroCard>`)
*
* v1.4.37 W7a — the doctor-report card was promoted to the page hero so
* the flagship "PDF for the doctor" flow no longer competes for visual
* weight with the secondary CSV/JSON exports. The remaining four cards
* live under a "Weitere Export-Optionen" sub-heading.
* v1.12 — the health-record export is the headline "data out" path; it
* takes the page hero. The doctor-report PDF is demoted to a small
* secondary card at the bottom of the page — fully functional, just no
* longer the flagship surface it was in v1.4.37 W7a.
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
@@ -86,20 +88,14 @@ export function ExportSection() {
</p>
</header>
{/* v1.4.37 W7a — Arztbericht hero. The doctor-report PDF is the
flagship "data out" path; the previous grid layout buried it
alongside the four CSV/JSON exports. The hero now sits at the
top of the page; the same generation flow runs underneath. */}
<ArztberichtHeroCard />
{/* v1.7.0 — full health-record export. PDF + FHIR R4 + a combined
zip package, with grouped data-section toggles and a format
switch. Sits above the secondary CSV/JSON cards. */}
{/* v1.12 — full health-record export is now the page hero. PDF +
FHIR R4 + a combined zip package, with grouped data-section
toggles (collapsed by default) and a format switch. This is the
headline "data out" path; it carries the primary visual weight. */}
<HealthRecordExportPanel />
{/* v1.4.37 W7a — secondary export options. The doctor-report
card moved into the hero above, so this group renders the
four remaining destinations under a dedicated sub-heading.
{/* v1.12 — secondary export options. The four CSV/JSON
destinations live under a dedicated sub-heading.
Mobile: single column. Desktop (md+): 2-column grid. */}
<section
aria-labelledby="settings-section-export-other-title"
@@ -118,6 +114,11 @@ export function ExportSection() {
<FullBackupCard />
</div>
</section>
{/* v1.12 — doctor-report PDF, demoted from the former page hero to a
small secondary card at the bottom. Fully functional — the same
`<DoctorReportDialog>` + `/api/doctor-report` flow runs from here. */}
<ArztberichtHeroCard />
</section>
);
}
@@ -12,10 +12,17 @@
* Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
* name, charts) hide when the format is FHIR. The ePA-compat note shows
* for the FHIR + package formats.
*
* v1.12 — this is the export page's hero. It carries the same
* `hero-gradient + glow-purple` treatment as the Insights hero strip so
* the headline "data out" path owns the primary visual weight. The
* grouped data-section toggles live behind a collapsible disclosure,
* collapsed by default, so the panel opens compact instead of as a long
* always-expanded checklist.
*/
import { useState } from "react";
import { Download, FileText, Loader2 } from "lucide-react";
import { useId, useState } from "react";
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -24,6 +31,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -106,6 +114,10 @@ export function HealthRecordExportPanel() {
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The included-data checklist is a disclosure, collapsed by default —
// the panel opens compact instead of as a long always-expanded list.
const [includedDataOpen, setIncludedDataOpen] = useState(false);
const includedDataPanelId = useId();
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
@@ -179,15 +191,27 @@ export function HealthRecordExportPanel() {
<section
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
className="bg-card border-border rounded-xl border p-6"
className={cn(
"hero-gradient glow-purple animate-insight-in",
// `isolate` traps the purple glow inside the hero so the shadow
// doesn't leak through the cards below — same trick the Insights
// `<HeroStrip>` uses.
"relative isolate overflow-hidden rounded-xl p-5 sm:p-6",
)}
>
<div className="mb-4 flex items-center gap-2">
<FileText className="text-primary h-5 w-5" aria-hidden="true" />
<h2 id="health-record-export-title" className="text-lg font-semibold">
<div className="mb-2 flex items-center gap-2">
<FileText
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="health-record-export-title"
className="text-xl font-semibold tracking-tight sm:text-2xl"
>
{t("settings.healthRecord.title")}
</h2>
</div>
<p className="text-muted-foreground mb-4 text-sm">
<p className="text-muted-foreground mb-5 max-w-2xl text-sm leading-relaxed">
{t("settings.healthRecord.description")}
</p>
@@ -245,12 +269,39 @@ export function HealthRecordExportPanel() {
</div>
)}
{/* Section groups */}
{/* Section groups — collapsible, collapsed by default so the
panel opens compact. The disclosure follows the inline
aria-expanded/aria-controls pattern used elsewhere in the app
(e.g. the insights recommendation cards). */}
<fieldset className="space-y-3">
<legend className="text-sm font-medium">
<legend className="sr-only">
{t("settings.healthRecord.includedData")}
</legend>
<button
type="button"
data-testid="health-record-included-data-toggle"
aria-expanded={includedDataOpen}
aria-controls={includedDataPanelId}
onClick={() => setIncludedDataOpen((v) => !v)}
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("settings.healthRecord.includedData")}</span>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
includedDataOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{includedDataOpen && (
<div
id={includedDataPanelId}
data-testid="health-record-included-data-panel"
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupVitals")}
@@ -372,6 +423,8 @@ export function HealthRecordExportPanel() {
onToggle={() => setIncludeAiSummary((v) => !v)}
/>
)}
</div>
)}
</fieldset>
{isFhirLike && (