diff --git a/src/components/settings/arztbericht-hero-card.tsx b/src/components/settings/arztbericht-hero-card.tsx
new file mode 100644
index 000000000..d5fc82fe7
--- /dev/null
+++ b/src/components/settings/arztbericht-hero-card.tsx
@@ -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 `` + `/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 `` variant.
+ * - The CTA is `aria-describedby={valueStatementId}` so a screen
+ * reader announces the value statement alongside the action.
+ * - The hero's `` slots beneath the page-level `` 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(
+ null,
+ );
+ const [error, setError] = useState(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 ``.
+ 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 (
+ ` uses.
+ "relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
+ )}
+ >
+
+
+
+
+
+ {t("settings.sections.export.hero.eyebrow")}
+
+
+
+ {t("settings.sections.export.cards.doctorReport.title")}
+
+
+ {t("settings.sections.export.hero.valueStatement")}
+
+
+
+
+ ` 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 && (
+
+ )}
+ {t("settings.sections.export.hero.cta")}
+
+
+ {t("settings.sections.export.hero.formatHint")}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 92ebbfde9..f775d1cf7 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -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
+ * ``)
* 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`.
*/
-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() {
- {/* Mobile: single column. Desktop (md+): 2-column grid. */}
-
-
-
-
-
-
-
+ {/* 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. */}
+
+
+ {/* 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. */}
+
+
+ {t("settings.sections.export.otherOptionsHeading")}
+
+
+
+
+
+
+
+
);
}
@@ -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(
- null,
- );
- const [error, setError] = useState(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 (
- setOpen(true)}
- disabled={busy}
- >
- {busy && }
- {t("settings.sections.export.cards.doctorReport.action")}
-
- }
- >
- {error && (
-
- {error}
-
- )}
-
-
- );
-}
-
// ─────────────────────────── CSV cards ───────────────────────────
interface DateRangeFieldsProps {
diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts
index 26a781667..b2e7235dc 100644
--- a/src/lib/validations/__tests__/measurement.test.ts
+++ b/src/lib/validations/__tests__/measurement.test.ts
@@ -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);
+ });
+ });
});
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 122f0a4d0..e44416788 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -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({