feat(export): promote Arztbericht hero card and shell on settings export

The doctor-report PDF is HealthLog's flagship "data out" path. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid; this change lifts it into a hero card at the top of
`/settings/export`. The remaining four export cards (Measurements CSV,
Medications CSV, Mood CSV, Full JSON backup) move under a dedicated
"Weitere Export-Optionen" / "Other export options" sub-heading.

The hero reuses the `hero-gradient + glow-purple` visual treatment from
the Insights hero strip, so the surface feels consistent across the
app. The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first: at
< sm the CTA falls onto its own row and clears the 44 px WCAG touch
floor (`h-11 sm:h-10`); the button carries an `aria-describedby` link
to the value statement so screen readers announce the framing
alongside the action label.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:26:24 +02:00
1 parent 5a6f921928
commit 0af77230c8
4 files changed
+312 -120

No files matched your search

@@ -0,0 +1,196 @@
"use client";
/**
* v1.4.37 W7a — Settings → Export Arztbericht hero 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:
*
* "Können wir unter Einstellungen > Export den Arztbericht als Hero
* Card machen?"
*
* 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) │
* │ 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).
*/
import { useEffect, useId, useState } from "react";
import { Loader2, Stethoscope } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DoctorReportDialog,
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();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const valueStatementId = useId();
// Pre-fill the practice name from the user profile so the dialog opens
// ready-to-submit. Best-effort: if the request fails, the dialog still
// works without a pre-fill — mirrors the legacy `<DoctorReportCard>`.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json) return;
const name = json?.data?.lastReportPracticeName;
if (typeof name === "string") setDefaultPracticeName(name);
})
.catch(() => {
// ignore — pre-fill is a UX nicety
});
return () => {
cancelled = true;
};
}, []);
async function handleSubmit(payload: DoctorReportSubmitPayload) {
setBusy(true);
setError(null);
try {
const res = await fetch("/api/doctor-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
sections: payload.sections,
}),
});
if (!res.ok) {
setError(`${res.status}`);
return;
}
const json = await res.json();
const { generateDoctorReportPDF } =
await import("@/lib/doctor-report-pdf");
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
if (payload.practiceName) setDefaultPracticeName(payload.practiceName);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<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",
)}
>
<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">
{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"
>
{t("settings.sections.export.hero.valueStatement")}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border/50 pt-4">
<Button
data-testid="export-hero-doctor-report-action"
variant="default"
// `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.
className="h-11 px-5 text-sm font-medium sm:h-10"
onClick={() => setOpen(true)}
disabled={busy}
aria-describedby={valueStatementId}
>
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.hero.cta")}
</Button>
<span className="text-muted-foreground text-xs">
{t("settings.sections.export.hero.formatHint")}
</span>
</div>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
</div>
<DoctorReportDialog
open={open}
onOpenChange={setOpen}
defaultPracticeName={defaultPracticeName}
onSubmit={handleSubmit}
/>
</section>
);
}
+38 -120
View File
@@ -9,16 +9,23 @@
* per export type, each with its own filter inputs and a clear
* "Download" / "Generate" button.
*
* 1. Doctor Report (PDF) — date range + practice name dialog
* 1. Doctor Report (PDF) — hero card at the top of the page
* (v1.4.37 W7a, see
* `<ArztberichtHeroCard>`)
* 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
*
* 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.
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Download,
FileJson,
@@ -26,7 +33,6 @@ import {
FileText,
Loader2,
Pill,
Stethoscope,
Waves,
} from "lucide-react";
@@ -34,13 +40,10 @@ import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DoctorReportDialog,
type DoctorReportSubmitPayload,
} from "@/components/doctor-report/doctor-report-dialog";
import { ArztberichtHeroCard } from "@/components/settings/arztbericht-hero-card";
import { useTranslations } from "@/lib/i18n/context";
type ExportFormat = "PDF" | "CSV" | "JSON";
type ExportFormat = "CSV" | "JSON";
/**
* Trigger a browser download for a given URL by spinning up an anchor
@@ -82,14 +85,33 @@ export function ExportSection() {
</p>
</header>
{/* Mobile: single column. Desktop (md+): 2-column grid. */}
<div className="grid gap-4 md:grid-cols-2">
<DoctorReportCard />
<MeasurementsCsvCard />
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
</div>
{/* 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.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.
Mobile: single column. Desktop (md+): 2-column grid. */}
<section
aria-labelledby="settings-section-export-other-title"
className="space-y-3"
>
<h2
id="settings-section-export-other-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.otherOptionsHeading")}
</h2>
<div className="grid gap-4 md:grid-cols-2">
<MeasurementsCsvCard />
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
</div>
</section>
</section>
);
}
@@ -140,110 +162,6 @@ function ExportCardShell({
);
}
// ─────────────────────────── Doctor Report ───────────────────────────
function DoctorReportCard() {
const { t, locale } = useTranslations();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
// Pre-fill the practice name from the user profile so the dialog opens
// ready-to-submit. Best-effort: if the request fails, the dialog still
// works without a pre-fill.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json) return;
const name = json?.data?.lastReportPracticeName;
if (typeof name === "string") setDefaultPracticeName(name);
})
.catch(() => {
// ignore — pre-fill is a UX nicety
});
return () => {
cancelled = true;
};
}, []);
async function handleSubmit(payload: DoctorReportSubmitPayload) {
setBusy(true);
setError(null);
try {
const res = await fetch("/api/doctor-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
// v1.4.25 W6c — per-section toggles. The aggregator drops
// disabled sections (mood is filtered server-side so the
// data never leaves the DB row).
sections: payload.sections,
}),
});
if (!res.ok) {
setError(`${res.status}`);
return;
}
const json = await res.json();
const { generateDoctorReportPDF } =
await import("@/lib/doctor-report-pdf");
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
if (payload.practiceName) setDefaultPracticeName(payload.practiceName);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-doctor-report"
icon={Stethoscope}
title={t("settings.sections.export.cards.doctorReport.title")}
description={t("settings.sections.export.cards.doctorReport.description")}
format="PDF"
outerClassName="md:col-span-2"
footer={
<Button
data-testid="export-action-doctor-report"
variant="default"
size="sm"
onClick={() => setOpen(true)}
disabled={busy}
>
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
{t("settings.sections.export.cards.doctorReport.action")}
</Button>
}
>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
<DoctorReportDialog
open={open}
onOpenChange={setOpen}
defaultPracticeName={defaultPracticeName}
onSubmit={handleSubmit}
/>
</ExportCardShell>
);
}
// ─────────────────────────── CSV cards ───────────────────────────
interface DateRangeFieldsProps {
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createMeasurementSchema,
getUnitForType,
listMeasurementsSchema,
validateMeasurementRange,
} from "../measurement";
@@ -173,4 +174,65 @@ describe("measurement validation", () => {
}
});
});
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types. The schema pins the optional `groupBy` enum + `dayKey`
// shape; the route gates each branch on whether the resolved type
// is in `CUMULATIVE_HK_TYPES`. Tests below pin the parser surface.
describe("listMeasurementsSchema — v1.4.37 W7c groupBy + dayKey", () => {
it("accepts an omitted groupBy + dayKey (legacy per-sample list)", () => {
const parsed = listMeasurementsSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.groupBy).toBeUndefined();
expect(parsed.data.dayKey).toBeUndefined();
}
});
it("accepts groupBy=day", () => {
const parsed = listMeasurementsSchema.safeParse({
type: "ACTIVITY_STEPS",
groupBy: "day",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.groupBy).toBe("day");
}
});
it("rejects an unknown groupBy value", () => {
const parsed = listMeasurementsSchema.safeParse({
type: "ACTIVITY_STEPS",
groupBy: "week",
});
expect(parsed.success).toBe(false);
});
it("accepts a well-formed YYYY-MM-DD dayKey", () => {
const parsed = listMeasurementsSchema.safeParse({
type: "ACTIVITY_STEPS",
dayKey: "2026-05-15",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.dayKey).toBe("2026-05-15");
}
});
it("rejects a malformed dayKey (DD.MM.YYYY)", () => {
const parsed = listMeasurementsSchema.safeParse({
type: "ACTIVITY_STEPS",
dayKey: "15.05.2026",
});
expect(parsed.success).toBe(false);
});
it("rejects a partial dayKey (missing day component)", () => {
const parsed = listMeasurementsSchema.safeParse({
type: "ACTIVITY_STEPS",
dayKey: "2026-05",
});
expect(parsed.success).toBe(false);
});
});
});
+16
View File
@@ -272,6 +272,22 @@ export const listMeasurementsSchema = z.object({
// when the rollup bucket set is empty for the requested window so
// brand-new accounts still see correct data on their first chart.
source: z.enum(["rollup"]).optional(),
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types (steps, active energy, distance, flights, daylight). When
// `groupBy=day` is set and `type` is a cumulative HK type, the route
// returns one synthesised row per user-TZ day with `value` = SUM
// and `sampleCount` = number of per-sample rows behind the bucket.
// Omitted = legacy per-sample list behaviour (iOS contract stable).
groupBy: z.enum(["day"]).optional(),
// v1.4.37 W7c — drill-down to per-sample rows for a single day in
// the user's IANA timezone. Format `YYYY-MM-DD`; the route resolves
// the day boundary against the user's `User.timezone`. Used by the
// expandable list row to reveal the chunks that contributed to the
// collapsed daily total.
dayKey: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "dayKey must be YYYY-MM-DD")
.optional(),
});
export const createBatchMeasurementSchema = z.object({