mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(settings): relabel Export to Export & Import, mount import area, add gated cycle export, slim the health-record card
Relabel the Export section to Export & Import (heading + description, all six locales; slug unchanged so existing links keep working) and mount the import area in it. Add a cycle export tile that reuses the health-record FHIR export with the reproductive section opted in, gated on cycle tracking being enabled so a non-cycle account never sees it. Compress the health-record export hero — format and date range share a row, the practice-name field tightens, and the FHIR note rides the footer with the generate action — so the card opens denser without losing any controls. Add the import + cycle i18n keys across all locales.
This commit is contained in:
1 parent
a4c3dd73bc
commit
c7bf62e4d9
9 files changed
+495
-62
No files matched your search
@@ -27,6 +27,9 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
// The cycle-export card and the Apple-Health status poll both call
|
||||
// `useQuery`; `data: null` keeps the gated cycle card hidden and the
|
||||
// import poll idle, which is the correct default-render state.
|
||||
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
@@ -53,11 +56,27 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
describe("<ExportSection> — SSR smoke", () => {
|
||||
it("renders the section heading + description", () => {
|
||||
const html = render(<ExportSection />);
|
||||
expect(html).toContain("Export");
|
||||
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
|
||||
expect(html).toContain("Export & Import");
|
||||
// Raw key never leaks past i18n.
|
||||
expect(html).not.toContain("settings.sections.export.");
|
||||
});
|
||||
|
||||
it("mounts the import area below the export options", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// R28 — the import surface (issue #281) lives in the same section.
|
||||
expect(html).toContain('id="settings-section-import-title"');
|
||||
expect(html).toContain('data-testid="import-card-apple-health"');
|
||||
expect(html).toContain('data-testid="import-card-json"');
|
||||
});
|
||||
|
||||
it("keeps the gated cycle export hidden when cycle is off", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// The mocked `useQuery` returns no prefs, so the gated cycle card
|
||||
// must not render (fail-closed gate).
|
||||
expect(html).not.toContain('data-testid="export-card-cycle"');
|
||||
});
|
||||
|
||||
it("mounts the health-record export panel as the page hero", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// v1.12 — the health-record export owns the hero treatment.
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarHeart,
|
||||
Download,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
@@ -41,9 +43,11 @@ import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { ImportPanel } from "@/components/settings/import-panel";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
type ExportFormat = "CSV" | "JSON";
|
||||
type ExportFormat = "CSV" | "JSON" | "FHIR";
|
||||
|
||||
/**
|
||||
* Trigger a browser download for a given URL by spinning up an anchor
|
||||
@@ -109,8 +113,18 @@ export function ExportSection() {
|
||||
<MedicationsCsvCard />
|
||||
<MoodCsvCard />
|
||||
<FullBackupCard />
|
||||
{/* R30 — cycle export, gated on cycle tracking being enabled.
|
||||
The card mounts only for accounts with cycle data; it reuses
|
||||
the health-record FHIR export with the reproductive section
|
||||
opted in, so there is no separate backend path. */}
|
||||
<CycleExportCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* R28 / issue #281 — the import surface for the Apple Health
|
||||
`export.zip` and the generic JSON paths. The export routes had
|
||||
backends with no UI until now. */}
|
||||
<ImportPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -467,3 +481,113 @@ function FullBackupCard() {
|
||||
</ExportCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Cycle export (gated) ───────────────────────────
|
||||
|
||||
/**
|
||||
* R30 — cycle / reproductive-health export, GATED on cycle tracking
|
||||
* being enabled for the account. A standalone cycle export was removed
|
||||
* from the cycle settings in v1.15.4 (it lives in the full backup); this
|
||||
* surfaces an explicit, opt-in cycle export here for accounts that track
|
||||
* a cycle. It reuses the flagship `/api/export/health-record` route with
|
||||
* the reproductive section opted in and every other section off — no
|
||||
* separate backend path. The card does not render at all when cycle
|
||||
* tracking is off, so a non-cycle account never sees it.
|
||||
*/
|
||||
function CycleExportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The ungated prefs read tells us whether cycle tracking is enabled
|
||||
// without tripping the cycle gate's 403.
|
||||
const prefsQuery = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
|
||||
const res = await fetch("/api/auth/me/cycle-prefs", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`prefs-${res.status}`);
|
||||
return (await res.json()).data as { cycleTrackingEnabled: boolean };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Gate: render nothing until we know cycle is on. A failed/absent read
|
||||
// keeps the card hidden (fail-closed) so it never appears spuriously.
|
||||
if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format: "fhir",
|
||||
sections: {
|
||||
vitals: {},
|
||||
cardioFitness: {},
|
||||
activity: {},
|
||||
glucose: false,
|
||||
medications: {},
|
||||
mood: false,
|
||||
bmi: false,
|
||||
cycle: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId="export-card-cycle"
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.sections.export.cards.cycle.title")}
|
||||
description={t("settings.sections.export.cards.cycle.description")}
|
||||
format="FHIR"
|
||||
footer={
|
||||
<Button
|
||||
data-testid="export-action-cycle"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.actions.download")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ExportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -196,66 +196,70 @@ export function HealthRecordExportPanel() {
|
||||
// `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",
|
||||
"relative isolate overflow-hidden rounded-xl p-4 sm:p-5",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<FileText
|
||||
className="text-primary 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"
|
||||
className="text-lg font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
{t("settings.healthRecord.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-5 max-w-2xl text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground mb-4 max-w-2xl text-sm leading-snug">
|
||||
{t("settings.healthRecord.description")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Format toggle */}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-4">
|
||||
{/* Format + range share a row on desktop so the panel opens
|
||||
denser; on mobile they stack. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="mb-1 text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={String(days)}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
<option value="90">{t("settings.healthRecord.range90")}</option>
|
||||
<option value="180">{t("settings.healthRecord.range180")}</option>
|
||||
<option value="365">{t("settings.healthRecord.range365")}</option>
|
||||
</NativeSelect>
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={String(days)}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
<option value="90">{t("settings.healthRecord.range90")}</option>
|
||||
<option value="180">{t("settings.healthRecord.range180")}</option>
|
||||
<option value="365">{t("settings.healthRecord.range365")}</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice name — PDF only */}
|
||||
{isPdfLike && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-practice">
|
||||
{t("settings.healthRecord.practiceName")}
|
||||
</Label>
|
||||
@@ -427,18 +431,22 @@ export function HealthRecordExportPanel() {
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{isFhirLike && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{/* The FHIR note and the generate action share the footer row so
|
||||
the panel ends compact instead of stacking a note above a
|
||||
right-aligned button. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
className="ml-auto min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
Reference in new issue
Block a user