mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #283 from MBombeck/release/v1.15.7
release/v1.15.7 — data import in the browser
This commit is contained in:
20 files changed
+1584
-75
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user