From c7bf62e4d96bf8577ef995356564050db48c47ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 00:07:46 +0200 Subject: [PATCH] feat(settings): relabel Export to Export & Import, mount import area, add gated cycle export, slim the health-record card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 51 ++++++- messages/en.json | 51 ++++++- messages/es.json | 51 ++++++- messages/fr.json | 51 ++++++- messages/it.json | 51 ++++++- messages/pl.json | 51 ++++++- .../__tests__/export-section.test.tsx | 21 ++- src/components/settings/export-section.tsx | 126 +++++++++++++++++- .../settings/health-record-export-panel.tsx | 104 ++++++++------- 9 files changed, 495 insertions(+), 62 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3635e3cc5..b3cb6f540 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." } } }, diff --git a/messages/en.json b/messages/en.json index 5685bbc0f..659f3aa2b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } }, diff --git a/messages/es.json b/messages/es.json index 2621b9d2e..b8fb454ac 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." } } }, diff --git a/messages/fr.json b/messages/fr.json index a77dc2824..5d034153b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." } } }, diff --git a/messages/it.json b/messages/it.json index f4c0652b1..97b5284fb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." } } }, diff --git a/messages/pl.json b/messages/pl.json index 474e3ccac..d238c5397 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}." } } }, diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index 7f6496218..fffd2e24f 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -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(" — SSR smoke", () => { it("renders the section heading + description", () => { const html = render(); - 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(); + // 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(); + // 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(); // v1.12 — the health-record export owns the hero treatment. diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index c4d0be2a4..26f289b57 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -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() { + {/* 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. */} + + + {/* 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. */} + ); } @@ -467,3 +481,113 @@ function FullBackupCard() { ); } + +// ─────────────────────────── 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(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 ( + + {busy ? ( + + ) : ( + + )} + {t("settings.sections.export.actions.download")} + + } + > + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 54fe6e37c..f2bd712a2 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -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 // `` uses. - "relative isolate overflow-hidden rounded-xl p-5 sm:p-6", + "relative isolate overflow-hidden rounded-xl p-4 sm:p-5", )} > -
+
-

+

{t("settings.healthRecord.description")}

-
- {/* Format toggle */} -
- - {t("settings.healthRecord.format")} - -
- {EXPORT_FORMATS.map((f, index) => ( - - ))} -
-
+
+ {/* Format + range share a row on desktop so the panel opens + denser; on mobile they stack. */} +
+
+ + {t("settings.healthRecord.format")} + +
+ {EXPORT_FORMATS.map((f, index) => ( + + ))} +
+
- {/* Date range */} -
- - setDays(Number(e.target.value))} - > - - - - + {/* Date range */} +
+ + setDays(Number(e.target.value))} + > + + + + +
{/* Practice name — PDF only */} {isPdfLike && ( -
+
@@ -427,18 +431,22 @@ export function HealthRecordExportPanel() { )} - {isFhirLike && ( -

- {t("settings.healthRecord.fhirNote")} -

- )} - -
+ {/* 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. */} +
+ {isFhirLike ? ( +

+ {t("settings.healthRecord.fhirNote")} +

+ ) : ( + + )}