diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 227c94387..997fffdca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8004,9 +8004,11 @@ paths: 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to - `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, - externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the - 5/hour `import:` rate bucket." + `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the + normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column + is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, + source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without + writing. Shares the 5/hour `import:` rate bucket." parameters: - name: dryRun in: query @@ -8027,6 +8029,7 @@ paths: type,value,unit,measuredAt,glucoseContext,notes,externalId WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning, BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001 + BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001 responses: "200": description: Per-row import outcome. @@ -17199,7 +17202,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" @@ -18015,7 +18019,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index e01d4247d..bd4d13097 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -1,6 +1,6 @@ # Data import -HealthLog has two ways to bring data in, both reachable from +HealthLog has three ways to bring data in, all reachable from **Settings → Export & Import**: 1. An **Apple Health `export.zip`** upload — the full archive from the @@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from 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. +3. A **CSV import** — one measurement per row, for a spreadsheet or a + meter / sensor export. Documented in section 3 below. -Both paths skip rows that already exist, so re-running an import is +All three paths skip rows that already exist, so re-running an import is safe — it merges rather than duplicating. +Every import reports what it actually wrote. A file where nothing was +written is shown as a failure, a file where some rows landed and some +were refused is shown as a partial result, and the refused rows are +grouped by reason so one bad column reads as one line rather than a +scroll. + ## 1. Apple Health `export.zip` On the iPhone: **Health app → profile picture → Export All Health @@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response. | `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). | +| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). | + +### Glucose context + +`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`, +`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave +it out (JSON) or leave the cell blank (CSV) and the reading is stored +with no context. + +That is the normal case for a continuous sensor or meter export, which +records a value and a timestamp per reading and classifies nothing. A +reading with no context still counts everywhere a reading counts: the +chart, the all-time summary, and the trailing-30-day clinical panel +(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is +left out only of the per-context breakdowns — the doctor report's +fasting / post-meal rows, the matching FHIR observations, the per-context +targets — because no classification was recorded and none is invented. + +Setting the column on any type other than `BLOOD_GLUCOSE` is rejected. +Setting it to a value outside the four above is rejected. Entering a +reading by hand in the app still asks for a context, because at that +moment you know it. ### Mood entry @@ -195,8 +224,48 @@ The response is a count envelope: } ``` +## 3. CSV import + +`POST /api/import/csv` takes the raw file as `text/csv` — one measurement +per row, no JSON wrapper. The **CSV file** control in +**Settings → Export & Import** posts to it, and **Download example** +mints a file with the right header. + +A header row is required; column order does not matter and header +matching is case-insensitive. + +| Column | Required | Notes | +| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `type` | yes | A `MeasurementType` from the table above. | +| `value` | yes | A number, in the row's `unit`. | +| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. | +| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. | +| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). | +| `notes` | no | Free text, up to 200 characters. | +| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. | + +A sensor export therefore needs no glucose column at all: + +```csv +type,value,unit,measuredAt,glucoseContext,notes,externalId +BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1 +BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2 +``` + +Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per +hour (shared with the JSON import). `?dryRun=1` validates and previews +without writing anything — the **Preview** button uses it. + +The response carries per-row outcomes alongside the counts, and the card +renders the counts, not the status code: rows written and none skipped is +a success, a mixed result is a warning, and nothing written is a failure +with the reasons grouped by count. + ## Converting a CSV into the import JSON +The CSV import above covers measurements. Mood entries have no CSV +route, so a mood history still goes through the JSON payload. + 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: diff --git a/messages/de.json b/messages/de.json index bfaf09acd..05226a816 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4495,7 +4495,10 @@ "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." + "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.", + "resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", + "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." }, "csv": { "title": "CSV-Datei", @@ -4516,7 +4519,30 @@ "readFailed": "Diese Datei konnte nicht gelesen werden.", "previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.", "resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.", - "rowError": "Zeile {line}: {reason}" + "rowError": "Zeile {line}: {reason}", + "resultSuccess": "{inserted} neu importiert und {updated} aktualisiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Zeilen wurden übersprungen.", + "resultEmpty": "Diese Datei hat eine Kopfzeile, aber keine Datenzeilen.", + "previewSuccess": "Vorschau: {inserted} würden importiert.", + "previewNothing": "Vorschau: nichts würde importiert. Alle {skipped} Zeilen würden übersprungen.", + "skipGroupOne": "{count} Zeile übersprungen: {reason}", + "skipGroupFew": "{count} Zeilen übersprungen: {reason}", + "skipGroupOther": "{count} Zeilen übersprungen: {reason}", + "skipDetails": "Alle übersprungenen Zeilen anzeigen", + "reasonUnknownType": "unbekannter Messwerttyp", + "reasonInvalidValue": "Wert ist keine Zahl", + "reasonValueOutOfRange": "Wert außerhalb des plausiblen Bereichs", + "reasonUnknownUnit": "Einheit für diesen Typ nicht erkannt", + "reasonMissingTimezoneOffset": "Zeitstempel ohne Zeitzonen-Offset", + "reasonInvalidTimestamp": "Zeitstempel nicht lesbar", + "reasonImplausibleTimestamp": "Zeitstempel liegt in der Zukunft oder vor 1900", + "reasonUnexpectedGlucoseContext": "Glukose-Kontext in einer Zeile, die kein Blutzucker ist", + "reasonInvalidGlucoseContext": "Glukose-Kontext ist kein zulässiger Wert", + "reasonNotesTooLong": "Notiz ist zu lang", + "reasonExternalIdTooLong": "Externe ID ist zu lang", + "reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil", + "reasonDuplicate": "bereits importiert", + "reasonUnknown": "übersprungen" } }, "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." diff --git a/messages/en.json b/messages/en.json index 6a16a1816..da58b3e62 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4485,7 +4485,10 @@ "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." + "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.", + "resultSuccess": "Imported {measurements} measurements and {moods} mood entries.", + "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", + "resultEmpty": "That file carried no measurements and no mood entries." }, "csv": { "title": "CSV file", @@ -4506,7 +4509,30 @@ "readFailed": "Couldn't read that file.", "previewSummary": "Preview: {inserted} would import, {skipped} skipped.", "resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.", - "rowError": "Line {line}: {reason}" + "rowError": "Line {line}: {reason}", + "resultSuccess": "Imported {inserted} new and updated {updated}.", + "resultNothing": "Nothing was imported. All {skipped} rows were skipped.", + "resultEmpty": "That file has a header but no data rows.", + "previewSuccess": "Preview: {inserted} would import.", + "previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.", + "skipGroupOne": "{count} row skipped: {reason}", + "skipGroupFew": "{count} rows skipped: {reason}", + "skipGroupOther": "{count} rows skipped: {reason}", + "skipDetails": "Show every skipped row", + "reasonUnknownType": "unknown measurement type", + "reasonInvalidValue": "value is not a number", + "reasonValueOutOfRange": "value outside the plausible range", + "reasonUnknownUnit": "unit not recognised for this type", + "reasonMissingTimezoneOffset": "timestamp has no timezone offset", + "reasonInvalidTimestamp": "timestamp could not be read", + "reasonImplausibleTimestamp": "timestamp is in the future or before 1900", + "reasonUnexpectedGlucoseContext": "glucose context set on a row that is not blood glucose", + "reasonInvalidGlucoseContext": "glucose context is not one of the allowed values", + "reasonNotesTooLong": "note is too long", + "reasonExternalIdTooLong": "external ID is too long", + "reasonUnstableExternalId": "external ID is not stable across exports", + "reasonDuplicate": "already imported", + "reasonUnknown": "skipped" } }, "subtitle": "Export your data or import from a backup." diff --git a/messages/es.json b/messages/es.json index 61b9bbad9..0e94c76ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4497,7 +4497,10 @@ "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." + "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.", + "resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", + "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." }, "csv": { "title": "Archivo CSV", @@ -4518,7 +4521,30 @@ "readFailed": "No se pudo leer ese archivo.", "previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.", "resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.", - "rowError": "Línea {line}: {reason}" + "rowError": "Línea {line}: {reason}", + "resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} filas.", + "resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.", + "previewSuccess": "Vista previa: {inserted} se importarían.", + "previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.", + "skipGroupOne": "{count} fila omitida: {reason}", + "skipGroupFew": "{count} filas omitidas: {reason}", + "skipGroupOther": "{count} filas omitidas: {reason}", + "skipDetails": "Mostrar todas las filas omitidas", + "reasonUnknownType": "tipo de medición desconocido", + "reasonInvalidValue": "el valor no es un número", + "reasonValueOutOfRange": "valor fuera del rango plausible", + "reasonUnknownUnit": "unidad no reconocida para este tipo", + "reasonMissingTimezoneOffset": "marca de tiempo sin desfase de zona horaria", + "reasonInvalidTimestamp": "no se pudo leer la marca de tiempo", + "reasonImplausibleTimestamp": "la marca de tiempo está en el futuro o es anterior a 1900", + "reasonUnexpectedGlucoseContext": "contexto de glucosa en una fila que no es de glucosa en sangre", + "reasonInvalidGlucoseContext": "el contexto de glucosa no es un valor permitido", + "reasonNotesTooLong": "la nota es demasiado larga", + "reasonExternalIdTooLong": "el ID externo es demasiado largo", + "reasonUnstableExternalId": "el ID externo no es estable entre exportaciones", + "reasonDuplicate": "ya importada", + "reasonUnknown": "omitida" } }, "subtitle": "Exporta tus datos o importa desde una copia de seguridad." diff --git a/messages/fr.json b/messages/fr.json index 73294c491..c9809da25 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4499,7 +4499,10 @@ "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." + "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.", + "resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.", + "resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.", + "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur." }, "csv": { "title": "Fichier CSV", @@ -4520,7 +4523,30 @@ "readFailed": "Impossible de lire ce fichier.", "previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.", "resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.", - "rowError": "Ligne {line} : {reason}" + "rowError": "Ligne {line} : {reason}", + "resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.", + "resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.", + "resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.", + "previewSuccess": "Aperçu : {inserted} seraient importées.", + "previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.", + "skipGroupOne": "{count} ligne ignorée : {reason}", + "skipGroupFew": "{count} lignes ignorées : {reason}", + "skipGroupOther": "{count} lignes ignorées : {reason}", + "skipDetails": "Afficher toutes les lignes ignorées", + "reasonUnknownType": "type de mesure inconnu", + "reasonInvalidValue": "la valeur n'est pas un nombre", + "reasonValueOutOfRange": "valeur hors de la plage plausible", + "reasonUnknownUnit": "unité non reconnue pour ce type", + "reasonMissingTimezoneOffset": "horodatage sans décalage horaire", + "reasonInvalidTimestamp": "horodatage illisible", + "reasonImplausibleTimestamp": "horodatage dans le futur ou antérieur à 1900", + "reasonUnexpectedGlucoseContext": "contexte glycémique sur une ligne qui n'est pas une glycémie", + "reasonInvalidGlucoseContext": "le contexte glycémique n'est pas une valeur autorisée", + "reasonNotesTooLong": "la note est trop longue", + "reasonExternalIdTooLong": "l'identifiant externe est trop long", + "reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre", + "reasonDuplicate": "déjà importée", + "reasonUnknown": "ignorée" } }, "subtitle": "Exportez vos données ou importez depuis une sauvegarde." diff --git a/messages/it.json b/messages/it.json index ec0ae461c..c5af13184 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4489,7 +4489,10 @@ "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." + "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.", + "resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", + "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." }, "csv": { "title": "File CSV", @@ -4510,7 +4513,30 @@ "readFailed": "Impossibile leggere questo file.", "previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.", "resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.", - "rowError": "Riga {line}: {reason}" + "rowError": "Riga {line}: {reason}", + "resultSuccess": "{inserted} nuove importate e {updated} aggiornate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.", + "resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.", + "previewSuccess": "Anteprima: {inserted} verrebbero importate.", + "previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.", + "skipGroupOne": "{count} riga saltata: {reason}", + "skipGroupFew": "{count} righe saltate: {reason}", + "skipGroupOther": "{count} righe saltate: {reason}", + "skipDetails": "Mostra tutte le righe saltate", + "reasonUnknownType": "tipo di misurazione sconosciuto", + "reasonInvalidValue": "il valore non è un numero", + "reasonValueOutOfRange": "valore fuori dall'intervallo plausibile", + "reasonUnknownUnit": "unità non riconosciuta per questo tipo", + "reasonMissingTimezoneOffset": "marca temporale senza offset del fuso orario", + "reasonInvalidTimestamp": "marca temporale illeggibile", + "reasonImplausibleTimestamp": "marca temporale nel futuro o precedente al 1900", + "reasonUnexpectedGlucoseContext": "contesto glicemico su una riga che non è glicemia", + "reasonInvalidGlucoseContext": "il contesto glicemico non è un valore consentito", + "reasonNotesTooLong": "la nota è troppo lunga", + "reasonExternalIdTooLong": "l'ID esterno è troppo lungo", + "reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro", + "reasonDuplicate": "già importata", + "reasonUnknown": "saltata" } }, "subtitle": "Esporta i tuoi dati o importa da un backup." diff --git a/messages/pl.json b/messages/pl.json index 7b852c5ef..4d25b9249 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4483,7 +4483,10 @@ "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}." + "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.", + "resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", + "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." }, "csv": { "title": "Plik CSV", @@ -4504,7 +4507,30 @@ "readFailed": "Nie udało się odczytać tego pliku.", "previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.", "resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.", - "rowError": "Wiersz {line}: {reason}" + "rowError": "Wiersz {line}: {reason}", + "resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.", + "resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.", + "previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.", + "previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.", + "skipGroupOne": "Pominięto {count} wiersz: {reason}", + "skipGroupFew": "Pominięto {count} wiersze: {reason}", + "skipGroupOther": "Pominięto {count} wierszy: {reason}", + "skipDetails": "Pokaż wszystkie pominięte wiersze", + "reasonUnknownType": "nieznany typ pomiaru", + "reasonInvalidValue": "wartość nie jest liczbą", + "reasonValueOutOfRange": "wartość poza wiarygodnym zakresem", + "reasonUnknownUnit": "jednostka nierozpoznana dla tego typu", + "reasonMissingTimezoneOffset": "znacznik czasu bez przesunięcia strefy czasowej", + "reasonInvalidTimestamp": "nie udało się odczytać znacznika czasu", + "reasonImplausibleTimestamp": "znacznik czasu w przyszłości lub sprzed 1900 roku", + "reasonUnexpectedGlucoseContext": "kontekst glukozy w wierszu, który nie jest pomiarem glukozy", + "reasonInvalidGlucoseContext": "kontekst glukozy nie jest dozwoloną wartością", + "reasonNotesTooLong": "notatka jest zbyt długa", + "reasonExternalIdTooLong": "identyfikator zewnętrzny jest zbyt długi", + "reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami", + "reasonDuplicate": "już zaimportowano", + "reasonUnknown": "pominięto" } }, "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." diff --git a/prisma/migrations/0274_glucose_context_optional/migration.sql b/prisma/migrations/0274_glucose_context_optional/migration.sql new file mode 100644 index 000000000..088d8db6a --- /dev/null +++ b/prisma/migrations/0274_glucose_context_optional/migration.sql @@ -0,0 +1,31 @@ +-- v1.32.x — a blood-glucose reading may carry no context. +-- +-- Migration 0021 introduced `measurements.glucose_context` with a CHECK that +-- read both ways at once: a BLOOD_GLUCOSE row MUST carry a context, and every +-- other type MUST NOT. The second half is right and stays. The first half was +-- written when the only glucose writer was the manual entry form, where a +-- person picks fasting / post-meal / random / bedtime at the moment of the +-- reading. +-- +-- Every machine writer that arrived later has no such classification per +-- sample: a continuous sensor export, the Nightscout SGV pull, the MCP +-- `log_measurement` tool and the Telegram numeric capture all build their row +-- without a context, so the insert failed against this constraint — a CSV +-- import of a sensor history wrote nothing, a Nightscout tick counted every +-- reading as a failed row. +-- +-- A missing context is missing. It stores as NULL, never as a fifth enum +-- member: every per-context reader (the doctor report, the FHIR per-context +-- Observations, the analytics + dashboard breakdowns, the target builder) +-- selects by equality against the four members, so NULL falls out of each +-- rather than being presented as a classification that nobody recorded. The +-- context-agnostic readers (the clinical panel, the charts, the all-time +-- summary) keep the row in full. +ALTER TABLE "measurements" + DROP CONSTRAINT IF EXISTS "measurements_glucose_context_requires_type"; + +ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_glucose_context_requires_type" + CHECK ( + type = 'BLOOD_GLUCOSE' OR glucose_context IS NULL + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd31c7cf6..3b31a9a02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1637,7 +1637,13 @@ model Measurement { // explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs // them. aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") - // Only set for BLOOD_GLUCOSE measurements. NULL for all others. + // Only set for BLOOD_GLUCOSE measurements. NULL for all others, and NULL + // for a BLOOD_GLUCOSE row whose writer has no classification to record — + // a sensor export, the Nightscout pull, the MCP tool, the Telegram capture. + // A CHECK constraint (migrations 0021 + 0274) enforces the type pairing in + // one direction only: a non-glucose row can never carry a context. Every + // per-context reader selects by equality against the four enum members, so + // a NULL context stays absent rather than reading as a classification. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext diff --git a/src/components/settings/import-panel/csv-import-card.tsx b/src/components/settings/import-panel/csv-import-card.tsx index 1b4676291..609b43d7a 100644 --- a/src/components/settings/import-panel/csv-import-card.tsx +++ b/src/components/settings/import-panel/csv-import-card.tsx @@ -4,7 +4,6 @@ import { useId, useRef, useState } from "react"; import Link from "next/link"; import { AlertCircle, - CheckCircle2, Download, FileSpreadsheet, Loader2, @@ -19,21 +18,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { MAX_PASTE_CHARS } from "./constants"; import { EXAMPLE_CSV } from "./import-examples"; - -interface CsvRowResult { - line: number; - status: "inserted" | "updated" | "skipped"; - reason?: string; -} - -interface CsvImportResult { - inserted: number; - updated: number; - skipped: number; - total: number; - dryRun: boolean; - rows: CsvRowResult[]; -} +import { + CsvImportResultView, + type CsvImportResult, +} from "./import-result-view"; export function CsvImportCard() { const { t } = useTranslations(); @@ -117,8 +105,6 @@ export function CsvImportCard() { } } - const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? []; - return (
- {result && ( -
-

-

- {errorRows.length > 0 && ( -
    - {errorRows.slice(0, 50).map((r) => ( -
  • - {t("settings.sections.export.import.csv.rowError", { - line: r.line, - reason: r.reason ?? "skipped", - })} -
  • - ))} -
- )} -
- )} + {result && } {error && (

0 ? "failed" : "empty"; + return skipped > 0 ? "partial" : "success"; +} + +export interface SkipReasonGroup { + /** The machine-readable reason as the route reported it. */ + reason: string; + count: number; +} + +/** A row of the per-row envelope, narrowed to what the grouping needs. */ +export interface ImportRowResult { + line: number; + status: string; + reason?: string; +} + +const UNKNOWN_REASON = "unknown"; + +/** + * Collapse the per-row envelope into one entry per skip reason. + * + * A sensor export that trips one validation trips it on every row, so the + * raw list is the same sentence a few thousand times. Grouping turns "scroll + * fifty identical lines" into one fact with a count. Ordered by count + * descending, then by reason, so the same result always renders the same way. + */ +export function groupSkipReasons( + rows: readonly ImportRowResult[], +): SkipReasonGroup[] { + const counts = new Map(); + for (const row of rows) { + if (row.status !== "skipped") continue; + const reason = row.reason ?? UNKNOWN_REASON; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); +} diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx new file mode 100644 index 000000000..78ac1fe44 --- /dev/null +++ b/src/components/settings/import-panel/import-result-view.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { + classifyImportOutcome, + groupSkipReasons, + type ImportOutcome, + type ImportRowResult, +} from "./import-result-state"; + +/** + * The one mapping from an import outcome to how it looks. + * + * `CheckCircle2` and `text-success` appear exactly once in the import panel, + * on the `success` key, so no future edit can attach a tick to a result that + * wrote nothing. `import-result-guard.test.ts` holds that line. + */ +const OUTCOME_PRESENTATION: Record< + ImportOutcome, + { Icon: LucideIcon; className: string } +> = { + success: { Icon: CheckCircle2, className: "text-success" }, + partial: { Icon: TriangleAlert, className: "text-warning" }, + failed: { Icon: AlertCircle, className: "text-destructive" }, + empty: { Icon: Info, className: "text-muted-foreground" }, +}; + +/** + * The headline line of an import result. Sits inside each card's existing + * `aria-live="polite"` region, so the outcome is announced as it lands. + */ +export function ImportOutcomeLine({ + outcome, + message, + testId, +}: { + outcome: ImportOutcome; + message: string; + testId: string; +}) { + const { Icon, className } = OUTCOME_PRESENTATION[outcome]; + return ( +

+

+ ); +} + +export interface CsvImportResult { + inserted: number; + updated: number; + skipped: number; + total: number; + dryRun: boolean; + rows: ImportRowResult[]; +} + +/** + * Plain-language label per machine-readable skip reason. + * + * Written as literal `t(...)` calls rather than a key lookup so the i18n + * call-site coverage guard sees every one of them. + */ +function useSkipReasonLabel(): (reason: string) => string { + const { t } = useTranslations(); + const labels: Record = { + unknown_type: t("settings.sections.export.import.csv.reasonUnknownType"), + invalid_value: t("settings.sections.export.import.csv.reasonInvalidValue"), + value_out_of_range: t( + "settings.sections.export.import.csv.reasonValueOutOfRange", + ), + unknown_unit: t("settings.sections.export.import.csv.reasonUnknownUnit"), + missing_timezone_offset: t( + "settings.sections.export.import.csv.reasonMissingTimezoneOffset", + ), + invalid_timestamp: t( + "settings.sections.export.import.csv.reasonInvalidTimestamp", + ), + implausible_timestamp: t( + "settings.sections.export.import.csv.reasonImplausibleTimestamp", + ), + unexpected_glucose_context: t( + "settings.sections.export.import.csv.reasonUnexpectedGlucoseContext", + ), + invalid_glucose_context: t( + "settings.sections.export.import.csv.reasonInvalidGlucoseContext", + ), + notes_too_long: t("settings.sections.export.import.csv.reasonNotesTooLong"), + external_id_too_long: t( + "settings.sections.export.import.csv.reasonExternalIdTooLong", + ), + unstable_external_id: t( + "settings.sections.export.import.csv.reasonUnstableExternalId", + ), + duplicate: t("settings.sections.export.import.csv.reasonDuplicate"), + }; + const fallback = t("settings.sections.export.import.csv.reasonUnknown"); + return (reason: string) => labels[reason] ?? fallback; +} + +/** + * The CSV import result: an honest headline, then the skipped rows grouped by + * reason with a count, with the per-line detail kept one disclosure away. + */ +export function CsvImportResultView({ result }: { result: CsvImportResult }) { + const { t, tCount } = useTranslations(); + const labelFor = useSkipReasonLabel(); + + const written = result.inserted + result.updated; + const outcome = classifyImportOutcome({ written, skipped: result.skipped }); + const groups = groupSkipReasons(result.rows); + const skippedRows = result.rows.filter((row) => row.status === "skipped"); + + const message = (() => { + if (outcome === "empty") { + return t("settings.sections.export.import.csv.resultEmpty"); + } + if (result.dryRun) { + if (outcome === "failed") { + return t("settings.sections.export.import.csv.previewNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.previewSummary", { + inserted: result.inserted, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.previewSuccess", { + inserted: result.inserted, + }); + } + if (outcome === "failed") { + return t("settings.sections.export.import.csv.resultNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.resultSummary", { + inserted: result.inserted, + updated: result.updated, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.resultSuccess", { + inserted: result.inserted, + updated: result.updated, + }); + })(); + + return ( +
+ + {groups.length > 0 && ( +
    + {groups.map((group) => ( +
  • + {tCount( + "settings.sections.export.import.csv.skipGroup", + group.count, + { count: group.count, reason: labelFor(group.reason) }, + )} +
  • + ))} +
+ )} + {skippedRows.length > 0 && ( +
+ + {t("settings.sections.export.import.csv.skipDetails")} + +
    + {skippedRows.map((row) => ( +
  • + {t("settings.sections.export.import.csv.rowError", { + line: row.line, + reason: labelFor(row.reason ?? "unknown"), + })} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 0c48645f0..128b2a7c4 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -2,14 +2,7 @@ import { useId, useRef, useState } from "react"; import Link from "next/link"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - Loader2, - Upload, -} from "lucide-react"; +import { AlertCircle, Download, FileJson, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -19,6 +12,8 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { MAX_PASTE_CHARS } from "./constants"; import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples"; +import { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; interface JsonImportResult { measurements: number; @@ -28,6 +23,38 @@ interface JsonImportResult { export function JsonImportCard() { const { t } = useTranslations(); + + // Same reasoning as the CSV card: the JSON route answers 200 with a per- + // concept count envelope, so a payload where every entry was refused used + // to render the success tick. The counts decide the tone. + const jsonOutcome = (r: JsonImportResult) => + classifyImportOutcome({ + written: r.measurements + r.moodEntries, + skipped: r.skipped, + }); + const jsonMessage = (r: JsonImportResult) => { + const outcome = jsonOutcome(r); + if (outcome === "empty") { + return t("settings.sections.export.import.json.resultEmpty"); + } + if (outcome === "failed") { + return t("settings.sections.export.import.json.resultNothing", { + skipped: r.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.json.resultSummary", { + measurements: r.measurements, + moods: r.moodEntries, + skipped: r.skipped, + }); + } + return t("settings.sections.export.import.json.resultSuccess", { + measurements: r.measurements, + moods: r.moodEntries, + }); + }; + const fileInputRef = useRef(null); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); @@ -164,22 +191,11 @@ export function JsonImportCard() {
{result && ( -

-

+ )} {error && (

= { tags: ["Import"], summary: "Import measurements from CSV", description: - "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", + "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", parameters: [ { name: "dryRun", @@ -71,7 +71,7 @@ export const importPaths: NonNullable = { schema: { type: "string", example: - "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001", + "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001\nBLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001", }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 2621db8c3..4c4660d36 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -256,7 +256,9 @@ export const measurementResource = z .string() .nullable() .optional() - .describe("Set only on BLOOD_GLUCOSE rows."), + .describe( + "Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one.", + ), sleepStage: z .string() .nullable() diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 090d510c4..a3eeaca0a 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -70,7 +70,7 @@ loginPasswordSchema.meta({ createMeasurementSchema.meta({ id: "CreateMeasurementRequest", description: - "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", + "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422. `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample.", }); listMeasurementsSchema.meta({ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7c9e3fa22..32dbc045f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -648,8 +648,10 @@ export const createMeasurementSchema = z // `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and // forging them would pollute the per-source canonical picker. source: writableMeasurementSourceEnum.optional().default("MANUAL"), - // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK - // constraint in Postgres (see migration 0021). + // Only applies when type === BLOOD_GLUCOSE. The "not on any other type" + // half is mirrored by a CHECK constraint in Postgres (migrations 0021 + + // 0274). The "required on a glucose row" half below is a rule of THIS + // schema only — see the refine. glucoseContext: glucoseContextEnum.optional(), // v1.4.25 W10 reconcile (code-review M4): the single-entry POST // dropped `deviceType` silently because the column existed on @@ -669,6 +671,14 @@ export const createMeasurementSchema = z .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range", }) + // Deliberately asymmetric with the bulk ingest paths. This schema backs the + // hand-entry surface, where a person is present at the reading and knows + // whether it was fasting, after a meal, random or at bedtime — the form + // always offers the choice, so requiring it costs nothing and keeps the + // per-context breakdowns meaningful. A sensor or meter export has no such + // answer per sample, so the CSV import, the JSON import and the device sync + // paths accept a contextless reading and store NULL rather than guessing. + // Absence stays absence; it is never widened into a classification. .refine( (data) => (data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||