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:
Marc-André Bombeck committed 2026-06-07 00:07:53 +02:00
1 parent a4c3dd73bc
commit c7bf62e4d9
9 files changed
+495 -62

No files matched your search

+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
},
"export": {
"title": "Export",
"description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter.",
"title": "Export & Import",
"description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter — oder importiere Daten aus Apple Health oder einer JSON-Datei.",
"otherOptionsHeading": "Weitere Export-Optionen",
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Voll-Backup",
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload."
},
"cycle": {
"title": "Zyklus",
"description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
}
},
"import": {
"heading": "Import",
"description": "Importiere Daten aus einem Apple-Health-Export oder einer JSON-Datei. Bereits vorhandene Einträge werden übersprungen, ein erneuter Import ist also unbedenklich.",
"appleHealth": {
"title": "Apple Health",
"description": "Lade die export.zip aus der Health-App auf dem iPhone hoch (Profil → Alle Gesundheitsdaten exportieren).",
"dropLabel": "export.zip hier ablegen",
"dropHint": "oder zum Auswählen klicken",
"fileInputLabel": "Eine Apple-Health-export.zip-Datei auswählen",
"choose": "Datei auswählen",
"idempotencyNote": "Ein erneuter Upload desselben Archivs führt zusammen — es entstehen keine Duplikate. Große Archive werden im Hintergrund verarbeitet.",
"uploading": "Wird hochgeladen…",
"uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.",
"rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.",
"tooLarge": "Diese Datei ist zu groß (Grenze 1,5 GB).",
"failed": "Der Import ist fehlgeschlagen.",
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"phase": {
"queued": "In Warteschlange…",
"unpacking": "Archiv wird entpackt…",
"parsing": "Export wird gelesen…",
"upserting": "Datensätze werden importiert…"
}
},
"json": {
"title": "JSON-Datei",
"description": "JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.",
"pasteLabel": "JSON einfügen",
"fileInputLabel": "Eine JSON-Datei zum Import auswählen",
"schemaHint": "Zwei Arrays: measurements und moodEntries.",
"docsLink": "Schema und Beispiel ansehen.",
"uploadFile": "Datei hochladen",
"downloadExample": "Beispiel herunterladen",
"import": "Importieren",
"invalidJson": "Das ist kein gültiges JSON. Bitte die Syntax prüfen und erneut versuchen.",
"invalidPayload": "Die Daten passen nicht zum Import-Schema.",
"rateLimited": "Zu viele Importe in dieser Stunde (Grenze 5). Bitte später erneut versuchen.",
"failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.",
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen."
}
}
},
+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
},
"export": {
"title": "Export",
"description": "Download your health data as PDF, CSV, or a full JSON backup.",
"title": "Export & Import",
"description": "Download your health data as PDF, CSV, or a full JSON backup — or bring data in from Apple Health or a JSON file.",
"otherOptionsHeading": "Other export options",
"downloadFailed": "Couldn't download the export. Please try again.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Full backup",
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload."
},
"cycle": {
"title": "Cycle",
"description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
}
},
"import": {
"heading": "Import",
"description": "Bring data into HealthLog from an Apple Health export or a JSON file. Imports skip rows that already exist, so re-running one is safe.",
"appleHealth": {
"title": "Apple Health",
"description": "Upload the export.zip from the Health app on iPhone (Profile → Export All Health Data).",
"dropLabel": "Drop export.zip here",
"dropHint": "or click to choose a file",
"fileInputLabel": "Choose an Apple Health export.zip file",
"choose": "Choose file",
"idempotencyNote": "Re-uploading the same archive merges — it won't create duplicates. Large archives are processed in the background.",
"uploading": "Uploading…",
"uploadFailed": "Upload failed. Please try again.",
"rateLimited": "Too many uploads. Please wait a moment and try again.",
"tooLarge": "That file is too large (limit 1.5 GB).",
"failed": "The import failed.",
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"phase": {
"queued": "Queued…",
"unpacking": "Unpacking the archive…",
"parsing": "Reading the export…",
"upserting": "Importing records…"
}
},
"json": {
"title": "JSON file",
"description": "Paste JSON or upload a .json file with measurements and mood entries.",
"pasteLabel": "Paste JSON",
"fileInputLabel": "Choose a JSON file to import",
"schemaHint": "Two arrays: measurements and moodEntries.",
"docsLink": "See the schema and an example.",
"uploadFile": "Upload file",
"downloadExample": "Download example",
"import": "Import",
"invalidJson": "That isn't valid JSON. Check the syntax and try again.",
"invalidPayload": "The data doesn't match the import schema.",
"rateLimited": "Too many imports this hour (limit 5). Please try again later.",
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped."
}
}
},
+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
},
"export": {
"title": "Exportar",
"description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa.",
"title": "Exportar e importar",
"description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa, o importa datos desde Apple Health o un archivo JSON.",
"otherOptionsHeading": "Otras opciones de exportación",
"downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Copia completa",
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador."
},
"cycle": {
"title": "Ciclo",
"description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
}
},
"import": {
"heading": "Importar",
"description": "Importa datos a HealthLog desde una exportación de Apple Health o un archivo JSON. La importación omite las filas que ya existen, así que repetirla es seguro.",
"appleHealth": {
"title": "Apple Health",
"description": "Sube el archivo export.zip de la app Salud del iPhone (Perfil → Exportar todos los datos de salud).",
"dropLabel": "Suelta export.zip aquí",
"dropHint": "o haz clic para elegir un archivo",
"fileInputLabel": "Elegir un archivo export.zip de Apple Health",
"choose": "Elegir archivo",
"idempotencyNote": "Volver a subir el mismo archivo lo fusiona; no crea duplicados. Los archivos grandes se procesan en segundo plano.",
"uploading": "Subiendo…",
"uploadFailed": "Error al subir. Inténtalo de nuevo.",
"rateLimited": "Demasiadas subidas. Espera un momento e inténtalo de nuevo.",
"tooLarge": "El archivo es demasiado grande (límite 1,5 GB).",
"failed": "La importación falló.",
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"phase": {
"queued": "En cola…",
"unpacking": "Descomprimiendo el archivo…",
"parsing": "Leyendo la exportación…",
"upserting": "Importando registros…"
}
},
"json": {
"title": "Archivo JSON",
"description": "Pega JSON o sube un archivo .json con mediciones y entradas de ánimo.",
"pasteLabel": "Pegar JSON",
"fileInputLabel": "Elegir un archivo JSON para importar",
"schemaHint": "Dos arrays: measurements y moodEntries.",
"docsLink": "Ver el esquema y un ejemplo.",
"uploadFile": "Subir archivo",
"downloadExample": "Descargar ejemplo",
"import": "Importar",
"invalidJson": "Eso no es JSON válido. Revisa la sintaxis e inténtalo de nuevo.",
"invalidPayload": "Los datos no coinciden con el esquema de importación.",
"rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.",
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas."
}
}
},
+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
},
"export": {
"title": "Exporter",
"description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète.",
"title": "Export et import",
"description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète, ou importez des données depuis Apple Santé ou un fichier JSON.",
"otherOptionsHeading": "Autres options d'export",
"downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur."
},
"cycle": {
"title": "Cycle",
"description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
}
},
"import": {
"heading": "Import",
"description": "Importez des données dans HealthLog depuis un export Apple Santé ou un fichier JSON. L'import ignore les lignes déjà présentes : le relancer est sans risque.",
"appleHealth": {
"title": "Apple Santé",
"description": "Importez le fichier export.zip de l'app Santé sur iPhone (Profil → Exporter toutes les données de santé).",
"dropLabel": "Déposez export.zip ici",
"dropHint": "ou cliquez pour choisir un fichier",
"fileInputLabel": "Choisir un fichier export.zip d'Apple Santé",
"choose": "Choisir un fichier",
"idempotencyNote": "Réimporter la même archive fusionne les données ; aucun doublon n'est créé. Les grandes archives sont traitées en arrière-plan.",
"uploading": "Envoi…",
"uploadFailed": "Échec de l'envoi. Veuillez réessayer.",
"rateLimited": "Trop d'envois. Patientez un instant et réessayez.",
"tooLarge": "Ce fichier est trop volumineux (limite 1,5 Go).",
"failed": "L'import a échoué.",
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"phase": {
"queued": "En file d'attente…",
"unpacking": "Décompression de l'archive…",
"parsing": "Lecture de l'export…",
"upserting": "Import des enregistrements…"
}
},
"json": {
"title": "Fichier JSON",
"description": "Collez du JSON ou importez un fichier .json contenant des mesures et des entrées d'humeur.",
"pasteLabel": "Coller du JSON",
"fileInputLabel": "Choisir un fichier JSON à importer",
"schemaHint": "Deux tableaux : measurements et moodEntries.",
"docsLink": "Voir le schéma et un exemple.",
"uploadFile": "Importer un fichier",
"downloadExample": "Télécharger l'exemple",
"import": "Importer",
"invalidJson": "Ce n'est pas du JSON valide. Vérifiez la syntaxe et réessayez.",
"invalidPayload": "Les données ne correspondent pas au schéma d'import.",
"rateLimited": "Trop d'imports cette heure-ci (limite 5). Réessayez plus tard.",
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées."
}
}
},
+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
},
"export": {
"title": "Esporta",
"description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo.",
"title": "Esporta e importa",
"description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo, oppure importa dati da Apple Salute o da un file JSON.",
"otherOptionsHeading": "Altre opzioni di esportazione",
"downloadFailed": "Impossibile scaricare l'esportazione. Riprova.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Backup completo",
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore."
},
"cycle": {
"title": "Ciclo",
"description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
}
},
"import": {
"heading": "Importa",
"description": "Importa dati in HealthLog da un'esportazione di Apple Salute o da un file JSON. L'importazione salta le righe già presenti, quindi ripeterla è sicuro.",
"appleHealth": {
"title": "Apple Salute",
"description": "Carica il file export.zip dall'app Salute su iPhone (Profilo → Esporta tutti i dati sanitari).",
"dropLabel": "Trascina qui export.zip",
"dropHint": "o fai clic per scegliere un file",
"fileInputLabel": "Scegli un file export.zip di Apple Salute",
"choose": "Scegli file",
"idempotencyNote": "Ricaricare lo stesso archivio lo unisce: non crea duplicati. Gli archivi grandi vengono elaborati in background.",
"uploading": "Caricamento…",
"uploadFailed": "Caricamento non riuscito. Riprova.",
"rateLimited": "Troppi caricamenti. Attendi un momento e riprova.",
"tooLarge": "Il file è troppo grande (limite 1,5 GB).",
"failed": "L'importazione non è riuscita.",
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"phase": {
"queued": "In coda…",
"unpacking": "Estrazione dell'archivio…",
"parsing": "Lettura dell'esportazione…",
"upserting": "Importazione dei record…"
}
},
"json": {
"title": "File JSON",
"description": "Incolla JSON o carica un file .json con misurazioni e voci dell'umore.",
"pasteLabel": "Incolla JSON",
"fileInputLabel": "Scegli un file JSON da importare",
"schemaHint": "Due array: measurements e moodEntries.",
"docsLink": "Vedi lo schema e un esempio.",
"uploadFile": "Carica file",
"downloadExample": "Scarica esempio",
"import": "Importa",
"invalidJson": "Questo non è JSON valido. Controlla la sintassi e riprova.",
"invalidPayload": "I dati non corrispondono allo schema di importazione.",
"rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.",
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate."
}
}
},
+49 -2
View File
@@ -3289,8 +3289,8 @@
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
},
"export": {
"title": "Eksportuj",
"description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON.",
"title": "Eksport i import",
"description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON albo zaimportuj dane z Apple Health lub pliku JSON.",
"otherOptionsHeading": "Inne opcje eksportu",
"downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.",
"hero": {
@@ -3327,6 +3327,53 @@
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora."
},
"cycle": {
"title": "Cykl",
"description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
}
},
"import": {
"heading": "Import",
"description": "Zaimportuj dane do HealthLog z eksportu Apple Health lub pliku JSON. Import pomija wiersze, które już istnieją, więc ponowne uruchomienie jest bezpieczne.",
"appleHealth": {
"title": "Apple Health",
"description": "Prześlij plik export.zip z aplikacji Zdrowie na iPhonie (Profil → Eksportuj wszystkie dane zdrowotne).",
"dropLabel": "Upuść tutaj export.zip",
"dropHint": "lub kliknij, aby wybrać plik",
"fileInputLabel": "Wybierz plik export.zip z Apple Health",
"choose": "Wybierz plik",
"idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane — nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.",
"uploading": "Przesyłanie…",
"uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
"rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.",
"tooLarge": "Ten plik jest za duży (limit 1,5 GB).",
"failed": "Import nie powiódł się.",
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"phase": {
"queued": "W kolejce…",
"unpacking": "Rozpakowywanie archiwum…",
"parsing": "Odczytywanie eksportu…",
"upserting": "Importowanie rekordów…"
}
},
"json": {
"title": "Plik JSON",
"description": "Wklej JSON lub prześlij plik .json z pomiarami i wpisami nastroju.",
"pasteLabel": "Wklej JSON",
"fileInputLabel": "Wybierz plik JSON do zaimportowania",
"schemaHint": "Dwie tablice: measurements i moodEntries.",
"docsLink": "Zobacz schemat i przykład.",
"uploadFile": "Prześlij plik",
"downloadExample": "Pobierz przykład",
"import": "Importuj",
"invalidJson": "To nie jest prawidłowy JSON. Sprawdź składnię i spróbuj ponownie.",
"invalidPayload": "Dane nie pasują do schematu importu.",
"rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj później.",
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}."
}
}
},
@@ -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 &amp; 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.
+125 -1
View File
@@ -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" />