mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(import): accept a blood-glucose reading that carries no context
A CSV import of a continuous-sensor export wrote nothing and rendered a green success state. Three faults compounded, and the reporter took all three apart before filing. The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext. Behind it, the CHECK constraint from migration 0021 read both ways at once: a glucose row MUST carry a context, every other type MUST NOT. The second half is right and stays. The first was written when the only glucose writer was the entry form, where a person picks fasting or post-meal at the moment of the reading. Every machine writer that arrived since has no such answer per sample, so the Nightscout pull, the MCP log tool, the Telegram capture and both bulk importers were all building rows the database then refused. A missing context now stores as NULL, never as a fifth enum member. Every per-context reader selects by equality against the four members, so the reading stays out of the doctor report's fasting rows, the matching FHIR observations and the per-context targets, while the chart, the all-time summary and the clinical panel keep it in full. Hand entry still asks for a context, because at that moment you know it. The import cards now read the counts rather than the status code. Nothing written is a failure, a mixed file is a warning, and the refused rows are grouped by reason with a count instead of fifty identical lines, with the per-line detail one disclosure away. Both cards render through one outcome table, so a tick cannot reach a result that wrote nothing. Refs #640
This commit is contained in:
1 parent
019ad52c0d
commit
0c9832eb8d
19 files changed
+639
-114
No files matched your search
+10
-5
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
+28
-2
@@ -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."
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
@@ -169,40 +155,7 @@ export function CsvImportCard() {
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<p className="text-foreground flex items-start gap-2 text-xs">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{result.dryRun
|
||||
? t("settings.sections.export.import.csv.previewSummary", {
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
: t("settings.sections.export.import.csv.resultSummary", {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{errorRows.length > 0 && (
|
||||
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
|
||||
{errorRows.slice(0, 50).map((r) => (
|
||||
<li key={r.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: r.line,
|
||||
reason: r.reason ?? "skipped",
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result && <CsvImportResultView result={result} />}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Import result classification — the one place that decides whether an
|
||||
* import reads as a success.
|
||||
*
|
||||
* A per-row importer answers HTTP 200 for a file where every single row was
|
||||
* refused; the transport succeeded, the import did not. Rendering that as a
|
||||
* green tick is how a self-hoster's 1 597-reading sensor export could write
|
||||
* nothing and still look finished. So the counts, not the status code, pick
|
||||
* the state:
|
||||
*
|
||||
* - nothing accepted, nothing skipped → `empty` (the file carried no data)
|
||||
* - nothing accepted, rows skipped → `failed` (never a success tone)
|
||||
* - some accepted, some skipped → `partial` (a warning, not a tick)
|
||||
* - all accepted → `success`
|
||||
*
|
||||
* Both import cards render from this, and `import-result-state.test.ts` pins
|
||||
* the invariant that `success` is unreachable while `written === 0`.
|
||||
*/
|
||||
|
||||
export type ImportOutcome = "empty" | "failed" | "partial" | "success";
|
||||
|
||||
export interface ImportCounts {
|
||||
/** Rows that reached the database — inserted + updated. */
|
||||
written: number;
|
||||
/** Rows the importer refused, for any reason. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function classifyImportOutcome({
|
||||
written,
|
||||
skipped,
|
||||
}: ImportCounts): ImportOutcome {
|
||||
if (written <= 0) return skipped > 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<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
data-testid={testId}
|
||||
data-outcome={outcome}
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<Icon
|
||||
className={`${className} mt-0.5 h-3.5 w-3.5 shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="import-csv-outcome"
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
<ul
|
||||
data-testid="import-csv-skip-groups"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount(
|
||||
"settings.sections.export.import.csv.skipGroup",
|
||||
group.count,
|
||||
{ count: group.count, reason: labelFor(group.reason) },
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{skippedRows.length > 0 && (
|
||||
<details className="text-muted-foreground text-xs">
|
||||
<summary className="cursor-pointer">
|
||||
{t("settings.sections.export.import.csv.skipDetails")}
|
||||
</summary>
|
||||
<ul className="mt-1 max-h-32 space-y-0.5 overflow-auto">
|
||||
{skippedRows.map((row) => (
|
||||
<li key={row.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: row.line,
|
||||
reason: labelFor(row.reason ?? "unknown"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -164,22 +191,11 @@ export function JsonImportCard() {
|
||||
|
||||
<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="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.json.resultSummary", {
|
||||
measurements: result.measurements,
|
||||
moods: result.moodEntries,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<ImportOutcomeLine
|
||||
outcome={jsonOutcome(result)}
|
||||
message={jsonMessage(result)}
|
||||
testId="import-json-result"
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
* without an offset is skipped — the importer never guesses
|
||||
* a timezone. The instant is bounded by `validateEntryInstant`
|
||||
* (no future beyond a 5-min skew, no instant before 1900).
|
||||
* - `glucoseContext` — required for BLOOD_GLUCOSE, forbidden otherwise.
|
||||
* - `glucoseContext` — optional on BLOOD_GLUCOSE, forbidden otherwise. A
|
||||
* blank cell stores NULL: a continuous sensor export
|
||||
* classifies nothing per reading, and an absent context
|
||||
* must read as absent rather than as a guessed one. A
|
||||
* non-empty cell is still checked against the enum.
|
||||
* - `notes` — free text, ≤ 200 chars.
|
||||
* - `externalId` — optional source-stable id. When present, the write loop
|
||||
* upserts on `(userId, type, source=IMPORT, externalId)` so a
|
||||
@@ -64,13 +68,11 @@ export type CsvRowReason =
|
||||
| "missing_timezone_offset"
|
||||
| "invalid_timestamp"
|
||||
| "implausible_timestamp"
|
||||
| "missing_glucose_context"
|
||||
| "unexpected_glucose_context"
|
||||
| "invalid_glucose_context"
|
||||
| "notes_too_long"
|
||||
| "external_id_too_long"
|
||||
| "unstable_external_id"
|
||||
| "wrong_column_count";
|
||||
| "unstable_external_id";
|
||||
|
||||
export type CsvRowStatus = "ok" | "skipped";
|
||||
|
||||
@@ -278,18 +280,16 @@ export function parseCsvMeasurements(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Glucose context: required for BLOOD_GLUCOSE, forbidden otherwise.
|
||||
// Glucose context: optional on BLOOD_GLUCOSE, forbidden otherwise. A
|
||||
// blank cell leaves the column NULL — the row is a reading either way,
|
||||
// and a sensor export carries no fasting / post-meal classification.
|
||||
let glucoseContext: string | undefined;
|
||||
if (type === "BLOOD_GLUCOSE") {
|
||||
if (!rawContext) {
|
||||
skip("missing_glucose_context");
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_GLUCOSE_CONTEXTS.has(rawContext)) {
|
||||
if (rawContext && !KNOWN_GLUCOSE_CONTEXTS.has(rawContext)) {
|
||||
skip("invalid_glucose_context");
|
||||
continue;
|
||||
}
|
||||
glucoseContext = rawContext;
|
||||
glucoseContext = rawContext || undefined;
|
||||
} else if (rawContext) {
|
||||
skip("unexpected_glucose_context");
|
||||
continue;
|
||||
|
||||
@@ -53,7 +53,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in new issue
Block a user