Merge pull request #283 from MBombeck/release/v1.15.7

release/v1.15.7 — data import in the browser
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 00:46:03 +02:00
commit 8cc7407e22
20 files changed
+1584 -75

No files matched your search

+18
View File
@@ -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
+1 -1
View File
@@ -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.
+5 -4
View File
@@ -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:
+224
View File
@@ -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.
+35 -4
View File
@@ -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,
}) => {
+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}."
}
}
},
+1 -1
View File
@@ -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",
@@ -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.
@@ -0,0 +1,129 @@
/**
* v1.15.7 — Settings → Export & Import → `<ImportPanel>` 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ImportPanel> — SSR smoke", () => {
it("renders the import heading and both cards", () => {
const html = render(<ImportPanel />);
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(<ImportPanel />);
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(<ImportPanel />);
// 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(<ImportPanel />);
// 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(<ImportPanel />, "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);
});
});
+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" />
+622
View File
@@ -0,0 +1,622 @@
"use client";
/**
* v1.15.7 — Settings → Export & Import → Import area.
*
* Web UI for the two existing import backends (issue #281: the routes
* shipped without a surface). Two clearly-separated controls:
*
* 1. Apple Health `export.zip` — multipart POST to
* `/api/import/apple-health-export` (field `file`), then poll
* `GET /api/import/apple-health-export/[jobId]/status` on a
* `refetchInterval` while the job runs, stopping on a terminal
* state (`done` / `failed`). The upload streams server-side so a
* multi-GB archive never buffers in the browser beyond the POST.
* Idempotent on the file's SHA-256 — re-uploading the same archive
* merges rather than duplicating.
*
* 2. Generic JSON import — upload a `.json` file OR paste JSON,
* validated client-side for parseability, POSTed to `/api/import`.
* A "Download example" button mints a small valid example as a Blob
* (no static asset). Returns `{ measurements, moodEntries, skipped }`
* counts.
*
* Both controls surface rate-limit (429) and error states cleanly and
* announce progress to assistive tech via `aria-live` regions.
*/
import { useCallback, useId, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileJson,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
// ─────────────────────────── Section wrapper ───────────────────────────
export function ImportPanel() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<AppleHealthImportCard />
<JsonImportCard />
</div>
</section>
);
}
// ─────────────────────────── Card shell ───────────────────────────
interface ImportCardShellProps {
testId: string;
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}
function ImportCardShell({
testId,
icon: Icon,
title,
description,
children,
}: ImportCardShellProps) {
return (
<div
data-testid={testId}
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
</div>
);
}
// ─────────────────────────── Apple Health import ───────────────────────────
/** Terminal states the status poll stops on. */
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
interface JobStatus {
jobId: string;
status: string;
progress: {
currentPhase?: string;
recordsRead?: number;
rowsUpserted?: number;
percent?: number | null;
} | null;
result: {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
} | null;
failureReason: string | null;
}
function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const dropDescId = useId();
const statusQuery = useQuery({
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
enabled: jobId !== null,
refetchInterval: (query) => {
const data = query.state.data as JobStatus | undefined;
if (data && TERMINAL_STATES.includes(data.status)) return false;
return 2000;
},
queryFn: async (): Promise<JobStatus> => {
const res = await fetch(
`/api/import/apple-health-export/${jobId}/status`,
{ credentials: "include" },
);
if (!res.ok) {
throw new Error(`status-${res.status}`);
}
return (await res.json()).data as JobStatus;
},
});
const upload = useCallback(
async (file: File) => {
setUploadError(null);
setUploading(true);
setJobId(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/import/apple-health-export", {
method: "POST",
credentials: "include",
body: form,
});
if (res.status === 429) {
setUploadError(t("settings.sections.export.import.appleHealth.rateLimited"));
return;
}
if (res.status === 413) {
setUploadError(t("settings.sections.export.import.appleHealth.tooLarge"));
return;
}
if (!res.ok) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
return;
}
const body = (await res.json()).data as {
jobId: string;
status: string;
};
if (!body?.jobId) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
return;
}
setJobId(body.jobId);
} catch {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
} finally {
setUploading(false);
}
},
[t],
);
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void upload(file);
// Reset so picking the same file twice re-fires the change event.
e.target.value = "";
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}
const status = statusQuery.data ?? null;
const isRunning =
status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
return (
<ImportCardShell
testId="import-card-apple-health"
icon={Upload}
title={t("settings.sections.export.import.appleHealth.title")}
description={t("settings.sections.export.import.appleHealth.description")}
>
{/* Keyboard-operable drop area. The hidden file input does the
actual file selection; the div forwards Enter/Space to it. */}
<div
role="button"
tabIndex={0}
aria-describedby={dropDescId}
aria-disabled={busy}
onClick={() => !busy && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => !busy && onDrop(e)}
className={cn(
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
dragActive && "border-primary bg-primary/5",
busy && "pointer-events-none opacity-60",
)}
>
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<span className="text-foreground text-sm font-medium">
{t("settings.sections.export.import.appleHealth.dropLabel")}
</span>
<span id={dropDescId} className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.dropHint")}
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t("settings.sections.export.import.appleHealth.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
</p>
{/* Live progress / outcome — announced to assistive tech. */}
<div aria-live="polite" className="space-y-2">
{uploading && (
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t("settings.sections.export.import.appleHealth.uploading")}
</p>
)}
{isRunning && (
<div
data-testid="import-apple-health-progress"
className="space-y-1.5"
>
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t(
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
)}
</p>
{typeof percent === "number" && (
<Progress value={percent} />
)}
{typeof status?.progress?.rowsUpserted === "number" && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.rowsImported", {
count: status.progress.rowsUpserted,
})}
</p>
)}
</div>
)}
{isDone && (
<div
data-testid="import-apple-health-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.appleHealth.doneSummary", {
imported: status?.result?.totals?.rowsUpserted ?? 0,
read: status?.result?.totals?.recordsRead ?? 0,
skipped: status?.result?.clinical?.skipped ?? 0,
})}
</span>
</div>
)}
{(isFailed || uploadError) && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{uploadError ??
status?.failureReason ??
t("settings.sections.export.import.appleHealth.failed")}
</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
data-testid="import-action-apple-health"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</ImportCardShell>
);
}
// ─────────────────────────── 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<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<JsonImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<ImportCardShell
testId="import-card-json"
icon={FileJson}
title={t("settings.sections.export.import.json.title")}
description={t("settings.sections.export.import.json.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.json.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-json-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
/>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="sr-only"
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.json.schemaHint")}{" "}
<a
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.json.docsLink")}
</a>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<p
data-testid="import-json-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})}
</span>
</p>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
data-testid="import-json-choose-file"
>
<Upload className="mr-1 h-3.5 w-3.5" />
{t("settings.sections.export.import.json.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="mr-1 h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</Button>
<Button
type="button"
size="sm"
disabled={busy || text.trim().length === 0}
onClick={handleImport}
data-testid="import-action-json"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileJson className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</ImportCardShell>
);
}
+12
View File
@@ -521,6 +521,18 @@ export const queryKeys = {
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
* merges into the seeded chip grid. */
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
/**
* v1.15.7 — in-flight Apple Health `export.zip` import. Keyed by the
* `jobId` returned from `POST /api/import/apple-health-export` so the
* Export & Import settings area can poll
* `GET /api/import/apple-health-export/[jobId]/status` with a
* `refetchInterval` while the job is running and stop on a terminal
* state. Each upload mints its own key, so a re-upload (or a second
* archive) never collides with a previous job's cached status.
*/
importJobStatus: (jobId: string) =>
["import", "apple-health", "status", jobId] as const,
};
/**
@@ -31,7 +31,7 @@
* 7. Legacy `daysOfWeek` fallback with `intervalWeeks > 1`.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -719,6 +719,21 @@ describe("legacy daysOfWeek fallback", () => {
// ────────────────────────────────────────────────────────────────────
describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => {
// A taken-write must never snap onto a FUTURE slot, so the 07:00 slot
// these cases mint has to sit in the past relative to "now" — which it
// does not when the suite runs before 07:00 local. Pin the clock to
// local noon (kept on today via the real date) and fake only Date, so
// Prisma's real timers are untouched.
beforeEach(() => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(noon);
});
afterEach(() => {
vi.useRealTimers();
});
it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
@@ -8,7 +8,7 @@
* - returns `duplicate` when an idempotencyKey is re-used
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -87,7 +87,10 @@ beforeEach(async () => {
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
// Long expiry so the session survives the cases that pin the clock
// forward to local noon (a 60-minute expiry would read as expired
// under the faked time).
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
@@ -169,6 +172,25 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
describe("v1.8.2 — source-agnostic slot collapse", () => {
const TZ = "Europe/Berlin";
// A taken-write must never snap onto a FUTURE slot, so the 07:00
// taken-write cases below pin the clock to local noon (see
// `pinClockAfterMorningSlot`) so the slot sits in the past even when
// the suite runs before 07:00 local. Sibling cases that use a later
// slot or fixed dates keep real time, hence the per-test pin. This
// afterEach is a no-op for the cases that never faked.
afterEach(() => {
vi.useRealTimers();
});
// Pin only Date (leaving Prisma's real timers) to today's local noon,
// computed from the real date so it stays on the current day.
function pinClockAfterMorningSlot(): void {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(noon);
}
async function makeScheduledMed(timesOfDay: string[]): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
@@ -192,6 +214,7 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
}
it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => {
pinClockAfterMorningSlot();
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
// The projector/worker minted this pending REMINDER row.
@@ -237,6 +260,7 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
});
it("collapses even when the write's scheduledFor drifts by 1 minute", async () => {
pinClockAfterMorningSlot();
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
const slot = localHmAsUtc(new Date(), TZ, 7, 0);