mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user