diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f97539c..71f7d466a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [1.15.7] — 2026-06-07 — data import in the browser + +### Added + +- **Import your data from the browser.** Settings → Export is now **Export & Import** and carries a proper import area with two paths: + - **Apple Health** — upload your `export.zip` directly. It streams server-side (handles multi-GB archives) and is idempotent, so re-uploading the same archive merges rather than duplicating; progress and the imported/skipped totals are shown as it runs. + - **Generic JSON** — upload or paste a JSON document of measurements and mood entries (with a downloadable example and a documented schema) for manually-prepared or converted data. + - The data-import schema, the full measurement-type/unit reference, and a "convert a CSV into this" how-to are documented at `docs/integrations/data-import.md`. +- **Cycle export.** When cycle tracking is on, the Export area offers an explicit cycle export. + +### Changed + +- **A more compact health-record export card.** The health-record export keeps its prominence but takes far less vertical space. + +### Fixed + +- **The Apple Health import is reachable again.** The importer existed on the backend, but the web page the docs pointed to was never present; there is now a real import UI in Settings → Export & Import. (#281) + ## [1.15.6] — 2026-06-06 — settings, insights, and medication-card polish ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 723d7ac7c..cb857b4fe 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.6 + version: 1.15.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index f973f4fc7..285982d81 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -35,10 +35,11 @@ clinical records, electrocardiograms). The importer reads ## 2. Upload to HealthLog -Open the import page from the in-app settings tree. Drop the -`export.zip` onto the upload control. The browser streams the body -directly to the server; nothing is buffered into memory on either -side. +Open **Settings → Export & Import** in the app and drop the +`export.zip` onto the Apple Health upload control (or click to pick the +file). The browser streams the body directly to the server; nothing is +buffered into memory on either side. A progress indicator polls the +job until it reports the imported / skipped counts. The endpoint is `POST /api/import/apple-health-export`. The handler: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md new file mode 100644 index 000000000..514a6907b --- /dev/null +++ b/docs/integrations/data-import.md @@ -0,0 +1,224 @@ +# Data import + +HealthLog has two ways to bring data in, both reachable from +**Settings → Export & Import**: + +1. An **Apple Health `export.zip`** upload — the full archive from the + iPhone Health app, parsed server-side. See + [`apple-health.md`](./apple-health.md) for the end-to-end walkthrough, + the HealthKit type mapping, and the streaming / idempotency model. +2. A **generic JSON import** — a small, explicit payload of measurements + and mood entries, useful for restoring a partial export or migrating + from another tracker. This page documents that format. + +Both paths skip rows that already exist, so re-running an import is +safe — it merges rather than duplicating. + +## 1. Apple Health `export.zip` + +On the iPhone: **Health app → profile picture → Export All Health +Data**, then move the resulting `export.zip` somewhere you can reach +from a browser. In HealthLog open **Settings → Export & Import** and drop +the archive onto the Apple Health control (or click to pick it). The +upload streams to the server — a multi-gigabyte archive never buffers in +the browser — and a background job parses it while a progress indicator +polls for the imported / skipped counts. + +Re-uploading the same archive resolves to the same job by content hash, +so it merges instead of creating duplicates. Clinical records +(electrocardiograms, lab documents) are intentionally skipped. The full +type mapping lives in [`apple-health.md`](./apple-health.md). + +## 2. Generic JSON import + +`POST /api/import` accepts a single JSON object with two optional +arrays: + +```json +{ + "measurements": [ /* … */ ], + "moodEntries": [ /* … */ ] +} +``` + +Each array holds at most **10 000** entries. The endpoint is rate-limited +to **5 imports per hour per account**. Existing rows (matched on the +type + timestamp uniqueness, or on `externalId` for mood entries) are +skipped and counted under `skipped` in the response. + +### Measurement entry + +| Field | Required | Type | Notes | +|---|---|---|---| +| `type` | yes | enum | One of the `MeasurementType` values in the table below. | +| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. | +| `unit` | yes | string | The canonical unit for the type (see the table). | +| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. | +| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. | +| `notes` | no | string | Optional free text. | +| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). | + +### Mood entry + +| Field | Required | Type | Notes | +|---|---|---|---| +| `date` | yes | string | `YYYY-MM-DD`. | +| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. | +| `score` | yes | integer | `1`–`5`. | +| `tags` | no | string | Comma-separated tags. | +| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. | +| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. | + +### `MeasurementType` values and canonical units + +Storage is always in the canonical unit listed here; the UI converts for +display per the user's preference. Event-class types record a single +fired event (`value` is `1`) and are normally produced by a device, not +hand-authored. + +| Type | Canonical unit | +|---|---| +| `WEIGHT` | kg | +| `BLOOD_PRESSURE_SYS` | mmHg | +| `BLOOD_PRESSURE_DIA` | mmHg | +| `PULSE` | bpm | +| `RESTING_HEART_RATE` | bpm | +| `WALKING_HEART_RATE_AVERAGE` | bpm | +| `AVERAGE_HEART_RATE` | bpm | +| `MAX_HEART_RATE` | bpm | +| `CARDIO_RECOVERY` | bpm | +| `HEART_RATE_VARIABILITY` | ms (SDNN) | +| `HRV_RMSSD` | ms (RMSSD) | +| `RESPIRATORY_RATE` | count/min | +| `OXYGEN_SATURATION` | percent (0–100) | +| `BLOOD_GLUCOSE` | mg/dL | +| `BODY_FAT` | percent (0–100) | +| `FAT_MASS` | kg | +| `FAT_FREE_MASS` | kg | +| `LEAN_BODY_MASS` | kg | +| `MUSCLE_MASS` | kg | +| `BONE_MASS` | kg | +| `TOTAL_BODY_WATER` | kg | +| `VISCERAL_FAT` | rating | +| `BODY_MASS_INDEX` | kg/m² | +| `BODY_TEMPERATURE` | °C (core) | +| `SKIN_TEMPERATURE` | °C (dermal spot) | +| `WRIST_TEMPERATURE` | °C (overnight wrist) | +| `SLEEP_DURATION` | minutes | +| `SLEEP_NEED` | minutes | +| `SLEEP_PERFORMANCE` | percent (0–100) | +| `SLEEP_EFFICIENCY` | percent (0–100) | +| `SLEEP_CONSISTENCY` | percent (0–100) | +| `SLEEP_DISTURBANCE_COUNT` | count | +| `BREATHING_DISTURBANCES` | count | +| `ACTIVITY_STEPS` | count | +| `FLIGHTS_CLIMBED` | count | +| `WALKING_RUNNING_DISTANCE` | metres | +| `SIX_MINUTE_WALK_DISTANCE` | metres | +| `WALKING_STEP_LENGTH` | metres | +| `WALKING_SPEED` | m/s | +| `STAIR_ASCENT_SPEED` | m/s | +| `STAIR_DESCENT_SPEED` | m/s | +| `WALKING_STEADINESS` | percent (0–100) | +| `WALKING_ASYMMETRY` | percent (0–100) | +| `WALKING_DOUBLE_SUPPORT` | percent (0–100) | +| `ACTIVE_ENERGY_BURNED` | kcal | +| `ENERGY_EXPENDITURE_KJ` | kJ | +| `VO2_MAX` | mL/(kg·min) | +| `PULSE_WAVE_VELOCITY` | m/s | +| `VASCULAR_AGE` | years | +| `FALL_COUNT` | count | +| `AUDIO_EXPOSURE_ENV` | dBA | +| `AUDIO_EXPOSURE_HEADPHONE` | dBA | +| `TIME_IN_DAYLIGHT` | minutes | +| `RECOVERY_SCORE` | score (0–100) | +| `STRESS_SCORE` | score (0–100) | +| `STRAIN_SCORE` | score (0–100) | +| `DAY_STRAIN` | score (0–21) | +| `WORKOUT_STRAIN` | score (0–21) | +| `AUDIO_EXPOSURE_EVENT` | count (event) | +| `IRREGULAR_RHYTHM_NOTIFICATION` | event | +| `HIGH_HEART_RATE_EVENT` | event | +| `LOW_HEART_RATE_EVENT` | event | +| `WALKING_STEADINESS_EVENT` | event | +| `BREATHING_DISTURBANCE_EVENT` | event | + +The live, machine-readable list of writable types and their units is +also served by `GET /api/meta/capabilities` under `ingest.quantityTypes` +— the authoritative source if this table ever lags a release. + +### Worked example + +The **Download example** button in **Settings → Export & Import** mints +exactly this payload, which is a valid import body: + +```json +{ + "measurements": [ + { + "type": "WEIGHT", + "value": 80.5, + "unit": "kg", + "measuredAt": "2026-05-01T08:00:00.000Z", + "source": "manual", + "notes": "morning" + }, + { + "type": "BLOOD_PRESSURE_SYS", + "value": 120, + "unit": "mmHg", + "measuredAt": "2026-05-01T08:05:00.000Z" + }, + { + "type": "BLOOD_PRESSURE_DIA", + "value": 80, + "unit": "mmHg", + "measuredAt": "2026-05-01T08:05:00.000Z" + } + ], + "moodEntries": [ + { + "date": "2026-05-01", + "mood": "GUT", + "score": 4, + "tags": "work,exercise" + } + ] +} +``` + +The response is a count envelope: + +```json +{ + "data": { "measurements": 3, "moodEntries": 1, "skipped": 0 } +} +``` + +## Converting a CSV into the import JSON + +A spreadsheet export is the most common starting point. The shape is +small enough to convert by hand for a few rows, or with a one-off script +for many. The steps are the same either way: + +1. **One CSV per concept.** Keep measurements and mood in separate + sheets — they map to the two arrays. A blood-pressure reading is two + measurement rows (`BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`) that + share a `measuredAt`. +2. **Pick the `type`.** Map each CSV column to a `MeasurementType` from + the table above. A "Weight (kg)" column becomes `type: "WEIGHT"`, + `unit: "kg"`. +3. **Normalise the unit.** Convert each value into the canonical unit + before writing it — e.g. pounds → kg, hours of sleep → minutes, + `mmol/L` glucose → `mg/dL`. The importer rejects values outside the + plausible range for the type, which catches most unit mistakes. +4. **Format the timestamp.** Turn the CSV date/time column into an + ISO-8601 `measuredAt` (mood uses a `YYYY-MM-DD` `date`). Include the + timezone offset (`Z` for UTC) so the reading lands on the right day. +5. **Emit the two arrays.** Collect the rows into `measurements` and + `moodEntries` as shown above and paste the result into the JSON + import control, or upload it as a `.json` file. Keep each array under + 10 000 entries; split a larger history across runs (5 per hour). + +Because the import is idempotent on the unique constraints, you can +re-run a corrected file over a partial one without creating duplicates. diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index c468dd08c..b0ae575a7 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -3,15 +3,17 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** - * Settings → Export consolidated UI smoke. + * Settings → Export & Import consolidated UI smoke. * * Validates that: * 1. The export surfaces are all rendered with their stable testids: * the health-record export hero, the four CSV/JSON tiles, and the * demoted doctor-report card (v1.12). - * 2. The health-record "included data" checklist is a disclosure, + * 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health + * and generic-JSON cards. + * 3. The health-record "included data" checklist is a disclosure, * collapsed by default, and expands on demand. - * 3. Clicking the Measurements CSV download button fires a real + * 4. Clicking the Measurements CSV download button fires a real * browser download — proving the `/api/export/measurements` * endpoint is reachable from the browser end-to-end. * @@ -19,7 +21,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * download is enough to lock in the wiring without doubling the e2e * runtime. */ -test.describe("Settings → Export", () => { +test.describe("Settings → Export & Import", () => { test.use({ storageState: STORAGE_STATE_PATH }); test("renders all export surfaces with stable testids", async ({ page }) => { @@ -39,6 +41,35 @@ test.describe("Settings → Export", () => { } }); + test("renders the import surfaces with stable testids", async ({ page }) => { + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + // v1.15.7 (issue #281) — the Apple Health and generic-JSON import + // cards live in the same section, below the export options. + for (const id of ["import-card-apple-health", "import-card-json"]) { + await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 }); + } + }); + + test("JSON import 'Download example' fires a real download", async ({ + page, + }) => { + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + const exampleBtn = page.getByTestId("import-json-download-example"); + await expect(exampleBtn).toBeVisible({ timeout: 10_000 }); + + const downloadPromise = page.waitForEvent("download", { timeout: 30_000 }); + await exampleBtn.click(); + const download = await downloadPromise; + + const path = await download.path(); + expect(path).toBeTruthy(); + const fs = await import("node:fs/promises"); + const buf = await fs.readFile(path!); + const parsed = JSON.parse(buf.toString("utf8")); + expect(Array.isArray(parsed.measurements)).toBe(true); + expect(Array.isArray(parsed.moodEntries)).toBe(true); + }); + test("included-data checklist is collapsed by default and expands on demand", async ({ page, }) => { 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/package.json b/package.json index d6cc575fb..2713351c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.6", + "version": "1.15.7", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", 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/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx new file mode 100644 index 000000000..7124cea90 --- /dev/null +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -0,0 +1,129 @@ +/** + * v1.15.7 — Settings → Export & Import → `` contract suite. + * + * Project convention is SSR-only component tests (vitest runs in the + * `node` environment; `@testing-library/react` is not installed). The + * panel's interactive paths (upload, poll, paste-and-import) are + * exercised end-to-end by the e2e suite; here we pin: + * + * 1. The page-level shape — both import cards render with their stable + * testids, localised copy, and the accessible controls (labelled + * file inputs, keyboard-operable drop area, aria-live status slot). + * 2. The "Download example" payload — the inline example JSON the + * button mints must parse and carry the documented field shape (the + * two arrays, the German-anchored mood enum), so the docs and the + * button never drift from the route. + * 3. The error guard — the JSON import refuses an unparseable paste + * before it ever hits the network (validated through the exported + * helper). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ImportPanel, + EXAMPLE_IMPORT, + parseImportJson, +} from "../import-panel"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" — SSR smoke", () => { + it("renders the import heading and both cards", () => { + const html = render(); + 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"'); + // Raw i18n keys never leak past the provider. + expect(html).not.toContain("settings.sections.export.import."); + }); + + it("exposes the action controls with stable testids", () => { + const html = render(); + for (const id of [ + "import-action-apple-health", + "import-json-textarea", + "import-json-choose-file", + "import-json-download-example", + "import-action-json", + ]) { + expect(html).toContain(`data-testid="${id}"`); + } + }); + + it("gives the Apple Health drop area a keyboard-operable role", () => { + const html = render(); + // A div masquerading as a button must be focusable + role=button so a + // keyboard user can trigger the file picker. + expect(html).toContain('role="button"'); + expect(html).toContain("aria-live"); + }); + + it("labels the hidden file inputs for assistive tech", () => { + const html = render(); + // Both file inputs are visually hidden (sr-only) but must carry an + // accessible name. + expect(html).toContain('type="file"'); + expect(html).toContain("aria-label"); + }); + + it("renders the German copy under the de locale", () => { + const html = render(, "de"); + expect(html).toContain("Import"); + expect(html).not.toContain("settings.sections.export.import."); + }); +}); + +describe("EXAMPLE_IMPORT payload", () => { + it("round-trips through JSON and stays parseable", () => { + const serialised = JSON.stringify(EXAMPLE_IMPORT, null, 2); + const parsed = parseImportJson(serialised); + expect(parsed.ok).toBe(true); + }); + + it("carries both arrays with the documented field shape", () => { + expect(Array.isArray(EXAMPLE_IMPORT.measurements)).toBe(true); + expect(Array.isArray(EXAMPLE_IMPORT.moodEntries)).toBe(true); + const m = EXAMPLE_IMPORT.measurements[0]!; + expect(m).toHaveProperty("type"); + expect(m).toHaveProperty("value"); + expect(m).toHaveProperty("unit"); + expect(m).toHaveProperty("measuredAt"); + const mood = EXAMPLE_IMPORT.moodEntries[0]!; + expect(mood).toHaveProperty("date"); + expect(mood).toHaveProperty("mood"); + expect(mood).toHaveProperty("score"); + // The mood enum values must be the German-anchored server enum. + expect(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]).toContain( + mood.mood, + ); + }); +}); + +describe("parseImportJson guard", () => { + it("rejects an unparseable string before any network call", () => { + const result = parseImportJson("{ not json"); + expect(result.ok).toBe(false); + }); + + it("accepts a minimal valid body", () => { + const result = parseImportJson('{"measurements":[]}'); + expect(result.ok).toBe(true); + }); +}); 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")} +

+ ) : ( + + )} +
+ + ); +} + +// ─────────────────────────── Generic JSON import ─────────────────────────── + +/** + * Small valid example payload, minted as a downloadable Blob by the + * "Download example" button and used by the docs. Exported so a test can + * assert it stays a valid import body — the button and the route schema + * must never drift. + */ +export const EXAMPLE_IMPORT = { + measurements: [ + { + type: "WEIGHT", + value: 80.5, + unit: "kg", + measuredAt: "2026-05-01T08:00:00.000Z", + source: "manual", + notes: "morning", + }, + { + type: "BLOOD_PRESSURE_SYS", + value: 120, + unit: "mmHg", + measuredAt: "2026-05-01T08:05:00.000Z", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 80, + unit: "mmHg", + measuredAt: "2026-05-01T08:05:00.000Z", + }, + ], + moodEntries: [ + { + date: "2026-05-01", + mood: "GUT", + score: 4, + tags: "work,exercise", + }, + ], +}; + +interface JsonImportResult { + measurements: number; + moodEntries: number; + skipped: number; +} + +/** + * Client-side parse guard for the JSON-import textarea. Returns the + * parsed value when the text is valid JSON, otherwise a failure marker — + * we never POST an unparseable body. Exported for unit testing so the + * guard is covered without a browser. + */ +export function parseImportJson( + text: string, +): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function JsonImportCard() { + const { t } = useTranslations(); + const fileInputRef = useRef(null); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const textareaId = useId(); + + async function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setError(null); + setResult(null); + try { + const content = await file.text(); + setText(content); + } catch { + setError(t("settings.sections.export.import.json.readFailed")); + } + } + + function downloadExample() { + const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "healthlog-import-example.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + async function handleImport() { + setError(null); + setResult(null); + const parsed = parseImportJson(text); + if (!parsed.ok) { + setError(t("settings.sections.export.import.json.invalidJson")); + return; + } + const payload = parsed.value; + setBusy(true); + try { + const res = await fetch("/api/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + if (res.status === 429) { + setError(t("settings.sections.export.import.json.rateLimited")); + return; + } + if (res.status === 422) { + const body = await res.json().catch(() => null); + setError( + body?.error ?? t("settings.sections.export.import.json.invalidPayload"), + ); + return; + } + if (!res.ok) { + setError(t("settings.sections.export.import.json.failed")); + return; + } + const data = (await res.json()).data as JsonImportResult; + setResult({ + measurements: data?.measurements ?? 0, + moodEntries: data?.moodEntries ?? 0, + skipped: data?.skipped ?? 0, + }); + } catch { + setError(t("settings.sections.export.import.json.failed")); + } finally { + setBusy(false); + } + } + + return ( + +
+ +