From 0af77230c86cf53aa095ed7c4d0753adbb00a3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 14:26:24 +0200 Subject: [PATCH] feat(export): promote Arztbericht hero card and shell on settings export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` + `/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. --- .../settings/arztbericht-hero-card.tsx | 196 ++++++++++++++++++ src/components/settings/export-section.tsx | 158 ++++---------- .../validations/__tests__/measurement.test.ts | 62 ++++++ src/lib/validations/measurement.ts | 16 ++ 4 files changed, 312 insertions(+), 120 deletions(-) create mode 100644 src/components/settings/arztbericht-hero-card.tsx 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 ` + + {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({