From 121588e3dc9909cbe2e6c6f69338fa5d8261f68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:19:16 +0200 Subject: [PATCH] feat(settings): map and check a lab or medication CSV before importing Settings > Export & Import gains a card that reads the head of the file, suggests the column for each field from its header in eight languages, suggests the separator, decimal mark and date order, and lets every choice be corrected. The check lists each refused row by line and the markers the names land on; the import button names how many rows it will write. Templates for both kinds download from the sheet. --- e2e/record-csv-import.spec.ts | 66 ++ messages/de.json | 100 ++++ messages/en.json | 100 ++++ messages/es.json | 100 ++++ messages/fr.json | 100 ++++ messages/it.json | 100 ++++ messages/ko.json | 100 ++++ messages/nl.json | 100 ++++ messages/pl.json | 100 ++++ .../settings/import-panel/index.tsx | 5 + .../import-panel/record-csv-import-card.tsx | 562 ++++++++++++++++++ src/lib/import/__tests__/csv-mapping.test.ts | 111 ++++ src/lib/import/csv-mapping.ts | 345 +++++++++++ 13 files changed, 1889 insertions(+) create mode 100644 e2e/record-csv-import.spec.ts create mode 100644 src/components/settings/import-panel/record-csv-import-card.tsx create mode 100644 src/lib/import/__tests__/csv-mapping.test.ts create mode 100644 src/lib/import/csv-mapping.ts diff --git a/e2e/record-csv-import.spec.ts b/e2e/record-csv-import.spec.ts new file mode 100644 index 000000000..5ad83d964 --- /dev/null +++ b/e2e/record-csv-import.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Lab results from a spreadsheet (#1215), at phone width, against the real + * server: the mapping is suggested from the headers, the check reports a + * refused row by line and writes nothing, the import writes the rest, and a + * second import of the same file only finds duplicates. + */ +function csv(): string { + const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + return [ + "Analyte;Value;Unit;Date", + `E2E Marker CSV;5,4;mmol/L;${day}`, + `E2E Marker CSV;1.234;mmol/L;${day}`, + ].join("\n"); +} + +test.describe("record CSV import", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("checks, imports and then only finds duplicates", async ({ page }) => { + await page.goto("/settings/export"); + await page.locator('[data-slot="record-import-open-labs"]').click(); + const sheet = page.locator('[data-slot="record-import-sheet"]'); + await expect(sheet).toBeVisible(); + const file = { + name: "labs.csv", + mimeType: "text/csv", + buffer: Buffer.from(csv()), + }; + await page.locator('[data-slot="record-import-file"]').setInputFiles(file); + await expect( + page.locator('[data-slot="record-import-map-analyte"]'), + ).toHaveValue("0"); + await expect( + page.locator('[data-slot="record-import-map-value"]'), + ).toHaveValue("1"); + + await page.locator('[data-slot="record-import-check"]').click(); + await expect( + page.locator('[data-slot="record-import-checked"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="record-import-error-row"]'), + ).toHaveCount(1); + + await page.locator('[data-slot="record-import-run"]').click(); + await expect( + page.locator('[data-slot="record-import-done"]'), + ).toBeVisible(); + + // Second run of the same file. + await page.keyboard.press("Escape"); + await page.locator('[data-slot="record-import-open-labs"]').click(); + await page.locator('[data-slot="record-import-file"]').setInputFiles(file); + await page.locator('[data-slot="record-import-check"]').click(); + await expect( + page.locator('[data-slot="record-import-run"]'), + ).toBeDisabled(); + }); +}); diff --git a/messages/de.json b/messages/de.json index 84b30f1e9..ee5356759 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13581,5 +13581,105 @@ "settingsHint": "Was diese Reihenfolge Tag für Tag entscheidet:", "settingsHintLink": "Woher die Werte kommen", "metricLink": "Woher diese Werte kommen" + }, + "recordImport": { + "title": "Laborwerte und Medikamente aus einer Tabelle", + "description": "Hol dir Laborwerte aus Jahren oder eine Medikamentenliste aus einer CSV-Datei.", + "detail": "Du legst fest, welche Spalte was enthält. HealthLog prüft jede Zeile vorher und importiert erst, wenn du bestätigst. Zeilen, die schon da sind, werden übersprungen.", + "kind": { + "labs": "Laborwerte", + "medications": "Medikamente" + }, + "sheetTitle": { + "labs": "Laborwerte importieren", + "medications": "Medikamente importieren" + }, + "sheetDescription": { + "labs": "Marker werden deinen zugeordnet, Einheiten umgerechnet, wo die Umrechnung bekannt ist.", + "medications": "Name, Dosis, tägliche Uhrzeiten, bei Bedarf, Beginn und Ende. Andere Rhythmen legst du danach in der App fest." + }, + "file": "CSV-Datei", + "template": "Vorlage herunterladen", + "mapping": "Welche Spalte enthält was", + "required": "(Pflicht)", + "notInFile": "Nicht in der Datei", + "column": "Spalte {n}", + "delimiter": "Trennzeichen", + "delimiterOption": { + "comma": "Komma", + "semicolon": "Semikolon", + "tab": "Tabulator" + }, + "decimal": "Dezimalzeichen", + "decimalOption": { + "dot": "Punkt (5.4)", + "comma": "Komma (5,4)" + }, + "dateOrder": "Datumsformat", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (Tag zuerst)", + "mdy": "03/01/2025 (Monat zuerst)" + }, + "header": "Die erste Zeile enthält Spaltennamen", + "reminders": "Erinnerungen für importierte Medikamente einschalten", + "timezoneNote": "Uhrzeiten ohne Zeitzone werden in der Zeitzone deines Kontos gelesen. Ein Datum ohne Uhrzeit bleibt dieser Kalendertag.", + "missing": "Noch auszuwählen: {fields}", + "check": "Datei prüfen", + "runIdle": "Importieren", + "run": "{count} Zeilen importieren", + "close": "Schließen", + "summaryCheck": "{rows} Zeilen gelesen: {inserted} können importiert werden, {duplicates} sind schon da, {failed} haben ein Problem.", + "summaryDone": "{inserted} Zeilen importiert. Übersprungen: {duplicates}, die schon da waren, und {failed} mit einem Problem.", + "markersMatched": "Deinen Markern zugeordnet:", + "markersCreated": "Neue Marker:", + "line": "Zeile {line}", + "errorsTruncated": "Nur die ersten 500 Probleme werden aufgelistet.", + "error": { + "tooLarge": "Die Datei ist größer als 16 MB.", + "rateLimited": "Zu viele Importe in der letzten Stunde. Versuch es später noch einmal.", + "invalid": "Die Datei oder die Spaltenauswahl konnte nicht gelesen werden.", + "failed": "Der Import hat nicht geklappt. Bitte versuch es noch einmal." + }, + "reason": { + "missing_value": "Eine Pflichtzelle ist leer", + "invalid_number": "Keine Zahl", + "ambiguous_number": "Die Zahl ist mehrdeutig; prüf das Dezimalzeichen", + "missing_unit": "Eine Zahl braucht eine Einheit", + "invalid_date": "Kein Datum im gewählten Format", + "invalid_time": "Keine Uhrzeit", + "ambiguous_date": "Das Datum ist mehrdeutig; nutz eine vierstellige Jahreszahl und das gewählte Format", + "future_date": "Das Datum liegt in der Zukunft", + "text_too_long": "Der Text ist zu lang", + "unit_mismatch": "Diese Einheit lässt sich nicht in die Einheit des Markers umrechnen", + "invalid_reference_range": "Der Referenzbereich ist nicht lesbar", + "invalid_times": "Die Uhrzeiten sind nicht lesbar (z. B. 08:00 20:00)", + "missing_times": "Trag Einnahmezeiten ein oder markier das Medikament als bei Bedarf", + "invalid_yes_no": "Bitte ja oder nein", + "as_needed_with_times": "Ein Medikament bei Bedarf hat keine festen Uhrzeiten", + "end_before_start": "Das Ende liegt vor dem Beginn", + "invalid_medication": "So lässt sich das Medikament nicht speichern", + "oversized_row": "Die Zeile ist zu lang oder zu breit", + "row_limit": "Über der Zeilengrenze für einen Import", + "duplicate": "Schon da, übersprungen" + }, + "field": { + "analyte": "Marker", + "value": "Ergebnis", + "unit": "Einheit", + "date": "Datum", + "time": "Uhrzeit", + "referenceRange": "Referenzbereich", + "referenceLow": "Referenz unten", + "referenceHigh": "Referenz oben", + "panel": "Gruppe", + "note": "Notiz", + "name": "Name", + "dose": "Dosis", + "times": "Uhrzeiten", + "asNeeded": "Bei Bedarf", + "startsOn": "Beginn", + "endsOn": "Ende" + } } } diff --git a/messages/en.json b/messages/en.json index fa195cc63..68c6a49b1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13581,5 +13581,105 @@ "settingsHint": "To see what this order decides day by day:", "settingsHintLink": "Where values come from", "metricLink": "Where these values come from" + }, + "recordImport": { + "title": "Lab results and medications from a spreadsheet", + "description": "Bring years of lab results or a medication list in from a CSV file.", + "detail": "You choose which column holds what, HealthLog checks every row first and imports nothing until you confirm. Rows already in your record are skipped.", + "kind": { + "labs": "Lab results", + "medications": "Medications" + }, + "sheetTitle": { + "labs": "Import lab results", + "medications": "Import medications" + }, + "sheetDescription": { + "labs": "Markers are matched to yours, units converted where the conversion is known.", + "medications": "Name, dose, daily times, as needed, start and end. Other rhythms you set up in the app afterwards." + }, + "file": "CSV file", + "template": "Download template", + "mapping": "Which column holds what", + "required": "(required)", + "notInFile": "Not in the file", + "column": "Column {n}", + "delimiter": "Separator", + "delimiterOption": { + "comma": "Comma", + "semicolon": "Semicolon", + "tab": "Tab" + }, + "decimal": "Decimal mark", + "decimalOption": { + "dot": "Dot (5.4)", + "comma": "Comma (5,4)" + }, + "dateOrder": "Date format", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (day first)", + "mdy": "03/01/2025 (month first)" + }, + "header": "The first row holds column names", + "reminders": "Turn reminders on for imported medications", + "timezoneNote": "Times without a time zone are read in your account's time zone. A date without a time is kept as that calendar date.", + "missing": "Still to choose: {fields}", + "check": "Check file", + "runIdle": "Import", + "run": "Import {count} rows", + "close": "Close", + "summaryCheck": "{rows} rows read: {inserted} can be imported, {duplicates} already in your record, {failed} with a problem.", + "summaryDone": "Imported {inserted} rows. Skipped {duplicates} already in your record, {failed} with a problem.", + "markersMatched": "Matched to your markers:", + "markersCreated": "New markers:", + "line": "Line {line}", + "errorsTruncated": "Only the first 500 problems are listed.", + "error": { + "tooLarge": "The file is larger than 16 MB.", + "rateLimited": "Too many imports in the last hour. Try again later.", + "invalid": "The file or the column choice could not be read.", + "failed": "The import did not go through. Please try again." + }, + "reason": { + "missing_value": "A required cell is empty", + "invalid_number": "Not a number", + "ambiguous_number": "The number reads two ways; check the decimal mark", + "missing_unit": "A number needs a unit", + "invalid_date": "Not a date in the chosen order", + "invalid_time": "Not a time of day", + "ambiguous_date": "The date reads two ways; use a four-digit year and the chosen order", + "future_date": "The date lies in the future", + "text_too_long": "The text is too long", + "unit_mismatch": "This unit does not convert into the marker's unit", + "invalid_reference_range": "The reference range cannot be read", + "invalid_times": "The times cannot be read (use 08:00 20:00)", + "missing_times": "Add intake times, or mark the medication as needed", + "invalid_yes_no": "Use yes or no", + "as_needed_with_times": "An as-needed medication has no fixed times", + "end_before_start": "The end lies before the start", + "invalid_medication": "The medication cannot be saved like this", + "oversized_row": "The row is too long or too wide", + "row_limit": "Past the row limit for one import", + "duplicate": "Already in your record, skipped" + }, + "field": { + "analyte": "Marker", + "value": "Result", + "unit": "Unit", + "date": "Date", + "time": "Time", + "referenceRange": "Reference range", + "referenceLow": "Reference low", + "referenceHigh": "Reference high", + "panel": "Panel", + "note": "Note", + "name": "Name", + "dose": "Dose", + "times": "Times", + "asNeeded": "As needed", + "startsOn": "Start", + "endsOn": "End" + } } } diff --git a/messages/es.json b/messages/es.json index 9d70fdebf..129d787da 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13581,5 +13581,105 @@ "settingsHint": "Para ver lo que decide este orden día a día:", "settingsHintLink": "De dónde vienen los valores", "metricLink": "De dónde vienen estos valores" + }, + "recordImport": { + "title": "Resultados de laboratorio y medicamentos desde una hoja de cálculo", + "description": "Trae años de resultados o una lista de medicamentos desde un archivo CSV.", + "detail": "Tú eliges qué columna contiene qué; HealthLog revisa cada fila antes y no importa nada hasta que confirmes. Las filas que ya están se omiten.", + "kind": { + "labs": "Resultados de laboratorio", + "medications": "Medicamentos" + }, + "sheetTitle": { + "labs": "Importar resultados", + "medications": "Importar medicamentos" + }, + "sheetDescription": { + "labs": "Los marcadores se asignan a los tuyos y las unidades se convierten cuando se conoce la conversión.", + "medications": "Nombre, dosis, horas diarias, a demanda, inicio y fin. Otros ritmos se configuran después en la app." + }, + "file": "Archivo CSV", + "template": "Descargar plantilla", + "mapping": "Qué columna contiene qué", + "required": "(obligatorio)", + "notInFile": "No está en el archivo", + "column": "Columna {n}", + "delimiter": "Separador", + "delimiterOption": { + "comma": "Coma", + "semicolon": "Punto y coma", + "tab": "Tabulador" + }, + "decimal": "Separador decimal", + "decimalOption": { + "dot": "Punto (5.4)", + "comma": "Coma (5,4)" + }, + "dateOrder": "Formato de fecha", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (día primero)", + "mdy": "03/01/2025 (mes primero)" + }, + "header": "La primera fila contiene los nombres de columna", + "reminders": "Activar recordatorios para los medicamentos importados", + "timezoneNote": "Las horas sin zona horaria se leen en la zona de tu cuenta. Una fecha sin hora se guarda como ese día del calendario.", + "missing": "Falta elegir: {fields}", + "check": "Revisar archivo", + "runIdle": "Importar", + "run": "Importar {count} filas", + "close": "Cerrar", + "summaryCheck": "{rows} filas leídas: {inserted} se pueden importar, {duplicates} ya están, {failed} con un problema.", + "summaryDone": "{inserted} filas importadas. Omitidas: {duplicates} que ya estaban y {failed} con un problema.", + "markersMatched": "Asignados a tus marcadores:", + "markersCreated": "Marcadores nuevos:", + "line": "Línea {line}", + "errorsTruncated": "Solo se listan los primeros 500 problemas.", + "error": { + "tooLarge": "El archivo supera los 16 MB.", + "rateLimited": "Demasiadas importaciones en la última hora. Inténtalo más tarde.", + "invalid": "No se pudo leer el archivo o la elección de columnas.", + "failed": "La importación no se completó. Inténtalo de nuevo." + }, + "reason": { + "missing_value": "Falta una celda obligatoria", + "invalid_number": "No es un número", + "ambiguous_number": "El número se puede leer de dos maneras; revisa el separador decimal", + "missing_unit": "Un número necesita una unidad", + "invalid_date": "No es una fecha en el formato elegido", + "invalid_time": "No es una hora", + "ambiguous_date": "La fecha se puede leer de dos maneras; usa un año de cuatro cifras y el formato elegido", + "future_date": "La fecha está en el futuro", + "text_too_long": "El texto es demasiado largo", + "unit_mismatch": "Esta unidad no se puede convertir a la del marcador", + "invalid_reference_range": "No se puede leer el rango de referencia", + "invalid_times": "No se pueden leer las horas (ej. 08:00 20:00)", + "missing_times": "Añade horas de toma o marca el medicamento como a demanda", + "invalid_yes_no": "Usa sí o no", + "as_needed_with_times": "Un medicamento a demanda no tiene horas fijas", + "end_before_start": "El fin es anterior al inicio", + "invalid_medication": "El medicamento no se puede guardar así", + "oversized_row": "La fila es demasiado larga o ancha", + "row_limit": "Supera el límite de filas por importación", + "duplicate": "Ya está, omitida" + }, + "field": { + "analyte": "Marcador", + "value": "Resultado", + "unit": "Unidad", + "date": "Fecha", + "time": "Hora", + "referenceRange": "Rango de referencia", + "referenceLow": "Referencia mínima", + "referenceHigh": "Referencia máxima", + "panel": "Grupo", + "note": "Nota", + "name": "Nombre", + "dose": "Dosis", + "times": "Horas", + "asNeeded": "A demanda", + "startsOn": "Inicio", + "endsOn": "Fin" + } } } diff --git a/messages/fr.json b/messages/fr.json index bf6037632..f1567d976 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13581,5 +13581,105 @@ "settingsHint": "Pour voir ce que cet ordre décide jour après jour :", "settingsHintLink": "D'où viennent les valeurs", "metricLink": "D'où viennent ces valeurs" + }, + "recordImport": { + "title": "Résultats d'analyses et médicaments depuis un tableur", + "description": "Importe des années de résultats ou une liste de médicaments depuis un fichier CSV.", + "detail": "Tu choisis quelle colonne contient quoi ; HealthLog vérifie chaque ligne d'abord et n'importe rien avant ta confirmation. Les lignes déjà présentes sont ignorées.", + "kind": { + "labs": "Résultats d'analyses", + "medications": "Médicaments" + }, + "sheetTitle": { + "labs": "Importer des résultats", + "medications": "Importer des médicaments" + }, + "sheetDescription": { + "labs": "Les marqueurs sont rattachés aux tiens, les unités converties quand la conversion est connue.", + "medications": "Nom, dose, heures quotidiennes, si besoin, début et fin. Les autres rythmes se règlent ensuite dans l'app." + }, + "file": "Fichier CSV", + "template": "Télécharger le modèle", + "mapping": "Quelle colonne contient quoi", + "required": "(obligatoire)", + "notInFile": "Absent du fichier", + "column": "Colonne {n}", + "delimiter": "Séparateur", + "delimiterOption": { + "comma": "Virgule", + "semicolon": "Point-virgule", + "tab": "Tabulation" + }, + "decimal": "Séparateur décimal", + "decimalOption": { + "dot": "Point (5.4)", + "comma": "Virgule (5,4)" + }, + "dateOrder": "Format de date", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (jour d'abord)", + "mdy": "03/01/2025 (mois d'abord)" + }, + "header": "La première ligne contient les noms de colonnes", + "reminders": "Activer les rappels pour les médicaments importés", + "timezoneNote": "Les heures sans fuseau sont lues dans le fuseau de ton compte. Une date sans heure reste ce jour du calendrier.", + "missing": "Encore à choisir : {fields}", + "check": "Vérifier le fichier", + "runIdle": "Importer", + "run": "Importer {count} lignes", + "close": "Fermer", + "summaryCheck": "{rows} lignes lues : {inserted} importables, {duplicates} déjà présentes, {failed} avec un problème.", + "summaryDone": "{inserted} lignes importées. Ignorées : {duplicates} déjà présentes et {failed} avec un problème.", + "markersMatched": "Rattachés à tes marqueurs :", + "markersCreated": "Nouveaux marqueurs :", + "line": "Ligne {line}", + "errorsTruncated": "Seuls les 500 premiers problèmes sont listés.", + "error": { + "tooLarge": "Le fichier dépasse 16 Mo.", + "rateLimited": "Trop d'importations au cours de la dernière heure. Réessaie plus tard.", + "invalid": "Le fichier ou le choix des colonnes n'a pas pu être lu.", + "failed": "L'importation n'a pas abouti. Réessaie." + }, + "reason": { + "missing_value": "Une cellule obligatoire est vide", + "invalid_number": "Ce n'est pas un nombre", + "ambiguous_number": "Le nombre se lit de deux façons ; vérifie le séparateur décimal", + "missing_unit": "Un nombre a besoin d'une unité", + "invalid_date": "Ce n'est pas une date au format choisi", + "invalid_time": "Ce n'est pas une heure", + "ambiguous_date": "La date se lit de deux façons ; utilise une année à quatre chiffres et le format choisi", + "future_date": "La date est dans le futur", + "text_too_long": "Le texte est trop long", + "unit_mismatch": "Cette unité ne se convertit pas dans celle du marqueur", + "invalid_reference_range": "La plage de référence est illisible", + "invalid_times": "Les heures sont illisibles (ex. 08:00 20:00)", + "missing_times": "Ajoute des heures de prise ou marque le médicament si besoin", + "invalid_yes_no": "Utilise oui ou non", + "as_needed_with_times": "Un médicament si besoin n'a pas d'heures fixes", + "end_before_start": "La fin précède le début", + "invalid_medication": "Le médicament ne peut pas être enregistré ainsi", + "oversized_row": "La ligne est trop longue ou trop large", + "row_limit": "Au-delà de la limite de lignes par importation", + "duplicate": "Déjà présente, ignorée" + }, + "field": { + "analyte": "Marqueur", + "value": "Résultat", + "unit": "Unité", + "date": "Date", + "time": "Heure", + "referenceRange": "Plage de référence", + "referenceLow": "Référence basse", + "referenceHigh": "Référence haute", + "panel": "Groupe", + "note": "Note", + "name": "Nom", + "dose": "Dose", + "times": "Heures", + "asNeeded": "Si besoin", + "startsOn": "Début", + "endsOn": "Fin" + } } } diff --git a/messages/it.json b/messages/it.json index 79fea56c9..19d71764b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13581,5 +13581,105 @@ "settingsHint": "Per vedere cosa decide questo ordine giorno per giorno:", "settingsHintLink": "Da dove vengono i valori", "metricLink": "Da dove vengono questi valori" + }, + "recordImport": { + "title": "Esami di laboratorio e farmaci da un foglio di calcolo", + "description": "Porta dentro anni di esami o un elenco di farmaci da un file CSV.", + "detail": "Scegli tu quale colonna contiene cosa; HealthLog controlla ogni riga prima e non importa nulla finché non confermi. Le righe già presenti vengono saltate.", + "kind": { + "labs": "Esami di laboratorio", + "medications": "Farmaci" + }, + "sheetTitle": { + "labs": "Importa esami", + "medications": "Importa farmaci" + }, + "sheetDescription": { + "labs": "I marcatori vengono abbinati ai tuoi, le unità convertite quando la conversione è nota.", + "medications": "Nome, dose, orari giornalieri, al bisogno, inizio e fine. Gli altri ritmi si impostano poi nell'app." + }, + "file": "File CSV", + "template": "Scarica il modello", + "mapping": "Quale colonna contiene cosa", + "required": "(obbligatorio)", + "notInFile": "Non nel file", + "column": "Colonna {n}", + "delimiter": "Separatore", + "delimiterOption": { + "comma": "Virgola", + "semicolon": "Punto e virgola", + "tab": "Tabulazione" + }, + "decimal": "Separatore decimale", + "decimalOption": { + "dot": "Punto (5.4)", + "comma": "Virgola (5,4)" + }, + "dateOrder": "Formato data", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (prima il giorno)", + "mdy": "03/01/2025 (prima il mese)" + }, + "header": "La prima riga contiene i nomi delle colonne", + "reminders": "Attiva i promemoria per i farmaci importati", + "timezoneNote": "Gli orari senza fuso orario vengono letti nel fuso del tuo account. Una data senza orario resta quel giorno del calendario.", + "missing": "Ancora da scegliere: {fields}", + "check": "Controlla il file", + "runIdle": "Importa", + "run": "Importa {count} righe", + "close": "Chiudi", + "summaryCheck": "{rows} righe lette: {inserted} importabili, {duplicates} già presenti, {failed} con un problema.", + "summaryDone": "{inserted} righe importate. Saltate: {duplicates} già presenti e {failed} con un problema.", + "markersMatched": "Abbinati ai tuoi marcatori:", + "markersCreated": "Nuovi marcatori:", + "line": "Riga {line}", + "errorsTruncated": "Sono elencati solo i primi 500 problemi.", + "error": { + "tooLarge": "Il file supera i 16 MB.", + "rateLimited": "Troppe importazioni nell'ultima ora. Riprova più tardi.", + "invalid": "Impossibile leggere il file o la scelta delle colonne.", + "failed": "L'importazione non è riuscita. Riprova." + }, + "reason": { + "missing_value": "Una cella obbligatoria è vuota", + "invalid_number": "Non è un numero", + "ambiguous_number": "Il numero si legge in due modi; controlla il separatore decimale", + "missing_unit": "Un numero ha bisogno di un'unità", + "invalid_date": "Non è una data nel formato scelto", + "invalid_time": "Non è un orario", + "ambiguous_date": "La data si legge in due modi; usa un anno a quattro cifre e il formato scelto", + "future_date": "La data è nel futuro", + "text_too_long": "Il testo è troppo lungo", + "unit_mismatch": "Questa unità non si converte in quella del marcatore", + "invalid_reference_range": "L'intervallo di riferimento non è leggibile", + "invalid_times": "Gli orari non sono leggibili (es. 08:00 20:00)", + "missing_times": "Aggiungi gli orari di assunzione o segna il farmaco al bisogno", + "invalid_yes_no": "Usa sì o no", + "as_needed_with_times": "Un farmaco al bisogno non ha orari fissi", + "end_before_start": "La fine precede l'inizio", + "invalid_medication": "Il farmaco non si può salvare così", + "oversized_row": "La riga è troppo lunga o troppo larga", + "row_limit": "Oltre il limite di righe per importazione", + "duplicate": "Già presente, saltata" + }, + "field": { + "analyte": "Marcatore", + "value": "Risultato", + "unit": "Unità", + "date": "Data", + "time": "Ora", + "referenceRange": "Intervallo di riferimento", + "referenceLow": "Riferimento minimo", + "referenceHigh": "Riferimento massimo", + "panel": "Gruppo", + "note": "Nota", + "name": "Nome", + "dose": "Dose", + "times": "Orari", + "asNeeded": "Al bisogno", + "startsOn": "Inizio", + "endsOn": "Fine" + } } } diff --git a/messages/ko.json b/messages/ko.json index 72a30343d..586ce4081 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13581,5 +13581,105 @@ "settingsHint": "이 순서가 날마다 무엇을 정하는지 보려면:", "settingsHintLink": "값의 출처", "metricLink": "이 값의 출처" + }, + "recordImport": { + "title": "스프레드시트에서 검사 결과와 약 가져오기", + "description": "수년간의 검사 결과나 약 목록을 CSV 파일에서 가져옵니다.", + "detail": "어떤 열에 무엇이 있는지 직접 고르면, HealthLog가 모든 행을 먼저 확인하고 확인 전에는 아무것도 가져오지 않습니다. 이미 있는 행은 건너뜁니다.", + "kind": { + "labs": "검사 결과", + "medications": "약" + }, + "sheetTitle": { + "labs": "검사 결과 가져오기", + "medications": "약 가져오기" + }, + "sheetDescription": { + "labs": "마커는 내 마커에 연결되고, 변환 방법을 아는 경우 단위가 변환됩니다.", + "medications": "이름, 용량, 매일 복용 시간, 필요시, 시작일과 종료일. 다른 주기는 나중에 앱에서 설정합니다." + }, + "file": "CSV 파일", + "template": "템플릿 다운로드", + "mapping": "각 열의 내용", + "required": "(필수)", + "notInFile": "파일에 없음", + "column": "{n}열", + "delimiter": "구분자", + "delimiterOption": { + "comma": "쉼표", + "semicolon": "세미콜론", + "tab": "탭" + }, + "decimal": "소수점", + "decimalOption": { + "dot": "마침표 (5.4)", + "comma": "쉼표 (5,4)" + }, + "dateOrder": "날짜 형식", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (일 먼저)", + "mdy": "03/01/2025 (월 먼저)" + }, + "header": "첫 행에 열 이름이 있음", + "reminders": "가져온 약의 알림 켜기", + "timezoneNote": "시간대가 없는 시간은 계정의 시간대로 읽습니다. 시간이 없는 날짜는 그 날짜 그대로 저장됩니다.", + "missing": "아직 선택할 항목: {fields}", + "check": "파일 확인", + "runIdle": "가져오기", + "run": "{count}행 가져오기", + "close": "닫기", + "summaryCheck": "{rows}행 읽음: {inserted}행 가져오기 가능, {duplicates}행 이미 있음, {failed}행 문제 있음.", + "summaryDone": "{inserted}행을 가져왔습니다. 이미 있던 {duplicates}행과 문제가 있는 {failed}행은 건너뛰었습니다.", + "markersMatched": "내 마커에 연결됨:", + "markersCreated": "새 마커:", + "line": "{line}행", + "errorsTruncated": "처음 500개의 문제만 표시됩니다.", + "error": { + "tooLarge": "파일이 16MB보다 큽니다.", + "rateLimited": "지난 한 시간 동안 가져오기가 너무 많았습니다. 나중에 다시 시도하세요.", + "invalid": "파일이나 열 선택을 읽을 수 없습니다.", + "failed": "가져오기에 실패했습니다. 다시 시도하세요." + }, + "reason": { + "missing_value": "필수 칸이 비어 있음", + "invalid_number": "숫자가 아님", + "ambiguous_number": "숫자를 두 가지로 읽을 수 있음. 소수점을 확인하세요", + "missing_unit": "숫자에는 단위가 필요함", + "invalid_date": "선택한 형식의 날짜가 아님", + "invalid_time": "시간이 아님", + "ambiguous_date": "날짜를 두 가지로 읽을 수 있음. 네 자리 연도와 선택한 형식을 쓰세요", + "future_date": "날짜가 미래임", + "text_too_long": "텍스트가 너무 김", + "unit_mismatch": "이 단위는 마커의 단위로 변환할 수 없음", + "invalid_reference_range": "참고 범위를 읽을 수 없음", + "invalid_times": "시간을 읽을 수 없음 (예: 08:00 20:00)", + "missing_times": "복용 시간을 넣거나 필요시 약으로 표시하세요", + "invalid_yes_no": "예 또는 아니오로 쓰세요", + "as_needed_with_times": "필요시 약에는 정해진 시간이 없음", + "end_before_start": "종료일이 시작일보다 앞섬", + "invalid_medication": "이대로는 약을 저장할 수 없음", + "oversized_row": "행이 너무 길거나 넓음", + "row_limit": "한 번에 가져올 수 있는 행 수를 넘음", + "duplicate": "이미 있어 건너뜀" + }, + "field": { + "analyte": "마커", + "value": "결과", + "unit": "단위", + "date": "날짜", + "time": "시간", + "referenceRange": "참고 범위", + "referenceLow": "참고 하한", + "referenceHigh": "참고 상한", + "panel": "그룹", + "note": "메모", + "name": "이름", + "dose": "용량", + "times": "시간", + "asNeeded": "필요시", + "startsOn": "시작", + "endsOn": "종료" + } } } diff --git a/messages/nl.json b/messages/nl.json index fdbb3164d..a61ae1d39 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13581,5 +13581,105 @@ "settingsHint": "Om te zien wat deze volgorde per dag beslist:", "settingsHintLink": "Waar de waarden vandaan komen", "metricLink": "Waar deze waarden vandaan komen" + }, + "recordImport": { + "title": "Labuitslagen en medicijnen uit een spreadsheet", + "description": "Haal jaren aan labuitslagen of een medicijnlijst binnen uit een CSV-bestand.", + "detail": "Jij kiest welke kolom wat bevat; HealthLog controleert eerst elke rij en importeert pas als je bevestigt. Rijen die er al zijn worden overgeslagen.", + "kind": { + "labs": "Labuitslagen", + "medications": "Medicijnen" + }, + "sheetTitle": { + "labs": "Labuitslagen importeren", + "medications": "Medicijnen importeren" + }, + "sheetDescription": { + "labs": "Markers worden aan de jouwe gekoppeld, eenheden omgerekend als de omrekening bekend is.", + "medications": "Naam, dosis, dagelijkse tijden, zo nodig, begin en einde. Andere ritmes stel je daarna in de app in." + }, + "file": "CSV-bestand", + "template": "Sjabloon downloaden", + "mapping": "Welke kolom bevat wat", + "required": "(verplicht)", + "notInFile": "Niet in het bestand", + "column": "Kolom {n}", + "delimiter": "Scheidingsteken", + "delimiterOption": { + "comma": "Komma", + "semicolon": "Puntkomma", + "tab": "Tab" + }, + "decimal": "Decimaalteken", + "decimalOption": { + "dot": "Punt (5.4)", + "comma": "Komma (5,4)" + }, + "dateOrder": "Datumnotatie", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (dag eerst)", + "mdy": "03/01/2025 (maand eerst)" + }, + "header": "De eerste rij bevat kolomnamen", + "reminders": "Herinneringen aanzetten voor geïmporteerde medicijnen", + "timezoneNote": "Tijden zonder tijdzone worden in de tijdzone van je account gelezen. Een datum zonder tijd blijft die kalenderdag.", + "missing": "Nog te kiezen: {fields}", + "check": "Bestand controleren", + "runIdle": "Importeren", + "run": "{count} rijen importeren", + "close": "Sluiten", + "summaryCheck": "{rows} rijen gelezen: {inserted} te importeren, {duplicates} al aanwezig, {failed} met een probleem.", + "summaryDone": "{inserted} rijen geïmporteerd. Overgeslagen: {duplicates} die er al waren en {failed} met een probleem.", + "markersMatched": "Gekoppeld aan je markers:", + "markersCreated": "Nieuwe markers:", + "line": "Regel {line}", + "errorsTruncated": "Alleen de eerste 500 problemen worden getoond.", + "error": { + "tooLarge": "Het bestand is groter dan 16 MB.", + "rateLimited": "Te veel imports in het afgelopen uur. Probeer het later opnieuw.", + "invalid": "Het bestand of de kolomkeuze kon niet worden gelezen.", + "failed": "Het importeren is niet gelukt. Probeer het opnieuw." + }, + "reason": { + "missing_value": "Een verplichte cel is leeg", + "invalid_number": "Geen getal", + "ambiguous_number": "Het getal is op twee manieren te lezen; controleer het decimaalteken", + "missing_unit": "Een getal heeft een eenheid nodig", + "invalid_date": "Geen datum in de gekozen notatie", + "invalid_time": "Geen tijd", + "ambiguous_date": "De datum is op twee manieren te lezen; gebruik een jaartal van vier cijfers en de gekozen notatie", + "future_date": "De datum ligt in de toekomst", + "text_too_long": "De tekst is te lang", + "unit_mismatch": "Deze eenheid is niet om te rekenen naar die van de marker", + "invalid_reference_range": "Het referentiebereik is niet te lezen", + "invalid_times": "De tijden zijn niet te lezen (bijv. 08:00 20:00)", + "missing_times": "Vul innametijden in of markeer het medicijn als zo nodig", + "invalid_yes_no": "Gebruik ja of nee", + "as_needed_with_times": "Een zo-nodig-medicijn heeft geen vaste tijden", + "end_before_start": "Het einde ligt voor het begin", + "invalid_medication": "Het medicijn kan zo niet worden opgeslagen", + "oversized_row": "De rij is te lang of te breed", + "row_limit": "Boven de rijlimiet voor één import", + "duplicate": "Al aanwezig, overgeslagen" + }, + "field": { + "analyte": "Marker", + "value": "Uitslag", + "unit": "Eenheid", + "date": "Datum", + "time": "Tijd", + "referenceRange": "Referentiebereik", + "referenceLow": "Referentie laag", + "referenceHigh": "Referentie hoog", + "panel": "Groep", + "note": "Notitie", + "name": "Naam", + "dose": "Dosis", + "times": "Tijden", + "asNeeded": "Zo nodig", + "startsOn": "Begin", + "endsOn": "Einde" + } } } diff --git a/messages/pl.json b/messages/pl.json index f39398420..8bf862fd0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13581,5 +13581,105 @@ "settingsHint": "Aby zobaczyć, co ta kolejność rozstrzyga dzień po dniu:", "settingsHintLink": "Skąd pochodzą wartości", "metricLink": "Skąd pochodzą te wartości" + }, + "recordImport": { + "title": "Wyniki badań i leki z arkusza kalkulacyjnego", + "description": "Zaimportuj wyniki badań z wielu lat lub listę leków z pliku CSV.", + "detail": "Ty wybierasz, która kolumna zawiera co; HealthLog najpierw sprawdza każdy wiersz i niczego nie importuje bez twojego potwierdzenia. Wiersze, które już są, zostaną pominięte.", + "kind": { + "labs": "Wyniki badań", + "medications": "Leki" + }, + "sheetTitle": { + "labs": "Importuj wyniki badań", + "medications": "Importuj leki" + }, + "sheetDescription": { + "labs": "Markery są przypisywane do twoich, jednostki przeliczane, gdy przeliczenie jest znane.", + "medications": "Nazwa, dawka, codzienne godziny, w razie potrzeby, początek i koniec. Inne rytmy ustawisz potem w aplikacji." + }, + "file": "Plik CSV", + "template": "Pobierz szablon", + "mapping": "Która kolumna zawiera co", + "required": "(wymagane)", + "notInFile": "Brak w pliku", + "column": "Kolumna {n}", + "delimiter": "Separator", + "delimiterOption": { + "comma": "Przecinek", + "semicolon": "Średnik", + "tab": "Tabulator" + }, + "decimal": "Separator dziesiętny", + "decimalOption": { + "dot": "Kropka (5.4)", + "comma": "Przecinek (5,4)" + }, + "dateOrder": "Format daty", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (najpierw dzień)", + "mdy": "03/01/2025 (najpierw miesiąc)" + }, + "header": "Pierwszy wiersz zawiera nazwy kolumn", + "reminders": "Włącz przypomnienia dla zaimportowanych leków", + "timezoneNote": "Godziny bez strefy czasowej są czytane w strefie twojego konta. Data bez godziny pozostaje tym dniem kalendarza.", + "missing": "Do wyboru: {fields}", + "check": "Sprawdź plik", + "runIdle": "Importuj", + "run": "Importuj wiersze: {count}", + "close": "Zamknij", + "summaryCheck": "Odczytane wiersze: {rows}. Do zaimportowania: {inserted}, już są: {duplicates}, z problemem: {failed}.", + "summaryDone": "Zaimportowane wiersze: {inserted}. Pominięte: {duplicates}, które już były, i {failed} z problemem.", + "markersMatched": "Przypisane do twoich markerów:", + "markersCreated": "Nowe markery:", + "line": "Wiersz {line}", + "errorsTruncated": "Wymieniono tylko pierwsze 500 problemów.", + "error": { + "tooLarge": "Plik jest większy niż 16 MB.", + "rateLimited": "Za dużo importów w ostatniej godzinie. Spróbuj później.", + "invalid": "Nie udało się odczytać pliku lub wyboru kolumn.", + "failed": "Import się nie powiódł. Spróbuj ponownie." + }, + "reason": { + "missing_value": "Wymagana komórka jest pusta", + "invalid_number": "To nie jest liczba", + "ambiguous_number": "Liczbę można odczytać na dwa sposoby; sprawdź separator dziesiętny", + "missing_unit": "Liczba wymaga jednostki", + "invalid_date": "To nie jest data w wybranym formacie", + "invalid_time": "To nie jest godzina", + "ambiguous_date": "Datę można odczytać na dwa sposoby; użyj czterocyfrowego roku i wybranego formatu", + "future_date": "Data jest w przyszłości", + "text_too_long": "Tekst jest za długi", + "unit_mismatch": "Tej jednostki nie da się przeliczyć na jednostkę markera", + "invalid_reference_range": "Nie można odczytać zakresu referencyjnego", + "invalid_times": "Nie można odczytać godzin (np. 08:00 20:00)", + "missing_times": "Dodaj godziny przyjmowania albo oznacz lek jako w razie potrzeby", + "invalid_yes_no": "Użyj tak lub nie", + "as_needed_with_times": "Lek w razie potrzeby nie ma stałych godzin", + "end_before_start": "Koniec jest przed początkiem", + "invalid_medication": "Nie można tak zapisać leku", + "oversized_row": "Wiersz jest za długi lub za szeroki", + "row_limit": "Ponad limit wierszy dla jednego importu", + "duplicate": "Już jest, pominięto" + }, + "field": { + "analyte": "Marker", + "value": "Wynik", + "unit": "Jednostka", + "date": "Data", + "time": "Godzina", + "referenceRange": "Zakres referencyjny", + "referenceLow": "Dolna granica", + "referenceHigh": "Górna granica", + "panel": "Grupa", + "note": "Notatka", + "name": "Nazwa", + "dose": "Dawka", + "times": "Godziny", + "asNeeded": "W razie potrzeby", + "startsOn": "Początek", + "endsOn": "Koniec" + } } } diff --git a/src/components/settings/import-panel/index.tsx b/src/components/settings/import-panel/index.tsx index 7f6ebde9e..9f39cf527 100644 --- a/src/components/settings/import-panel/index.tsx +++ b/src/components/settings/import-panel/index.tsx @@ -14,6 +14,9 @@ * (file/paste, client-side parseability guard). * 3. CSV import — `csv-import-card.tsx` * (file/paste, dry-run preview). + * 4. Lab results and medications from a spreadsheet — + * `record-csv-import-card.tsx` (v1.44, #1215; column mapping, check, + * import). * * Both controls surface rate-limit (429) and error states cleanly and * announce progress to assistive tech via `aria-live` regions. @@ -28,6 +31,7 @@ import { HealthConnectImportCard } from "./health-connect-import-card"; import { JsonImportCard } from "./json-import-card"; import { CsvImportCard } from "./csv-import-card"; import { DoseHistoryImportCard } from "./dose-history-import-card"; +import { RecordCsvImportCard } from "./record-csv-import-card"; export { EXAMPLE_IMPORT, @@ -55,6 +59,7 @@ export function ImportPanel() { + ); diff --git a/src/components/settings/import-panel/record-csv-import-card.tsx b/src/components/settings/import-panel/record-csv-import-card.tsx new file mode 100644 index 000000000..2613f2b0a --- /dev/null +++ b/src/components/settings/import-panel/record-csv-import-card.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { apiFetch, ApiError } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + RECORD_IMPORT_TEMPLATES, + fieldsFor, + guessFormat, + guessMapping, + readHead, + type FieldKey, + type FileHead, + type RecordImportKind, +} from "@/lib/import/csv-mapping"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +import { ImportCardShell } from "./import-card-shell"; + +/** The head of the file the mapping screen reads: enough for six rows. */ +const HEAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 16 * 1024 * 1024; + +type Delimiter = "comma" | "semicolon" | "tab"; + +function delimiterName(d: string): Delimiter { + return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma"; +} + +function downloadTemplate(kind: RecordImportKind) { + const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-${kind}-template.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Settings > Export & Import: lab results and medications from a spreadsheet + * (#1215). The card opens a sheet that reads the head of the file, suggests + * the column for each field, lets the person correct every choice, checks + * the whole file on the server (nothing written) and then imports it. + */ +export function RecordCsvImportCard() { + const { t } = useTranslations(); + const [open, setOpen] = useState(null); + const [session, setSession] = useState(0); + return ( + +

{t("recordImport.detail")}

+ + {(["labs", "medications"] as const).map((kind) => ( + + ))} + + {open ? ( + !next && setOpen(null)} + /> + ) : null} +
+ ); +} + +function RecordCsvImportSheet({ + kind, + onOpenChange, +}: { + kind: RecordImportKind; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const fileId = useId(); + const [file, setFile] = useState(null); + const [head, setHead] = useState(null); + const [mapping, setMapping] = useState>>({}); + const [delimiter, setDelimiter] = useState("comma"); + const [decimal, setDecimal] = useState<"dot" | "comma">("dot"); + const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso"); + const [header, setHeader] = useState(true); + const [reminders, setReminders] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [check, setCheck] = useState(null); + const [done, setDone] = useState(null); + + const fields = fieldsFor(kind); + const columns = useMemo(() => { + if (!head) return [] as string[]; + const width = Math.max(...head.rows.map((r) => r.length), 0); + return Array.from({ length: width }, (_, i) => + header && head.rows[0]?.[i]?.trim() + ? head.rows[0][i].trim() + : t("recordImport.column", { n: i + 1 }), + ); + }, [head, header, t]); + const missing = fields.filter( + (f) => f.required && mapping[f.key] === undefined, + ); + + async function onFile(e: React.ChangeEvent) { + const picked = e.target.files?.[0] ?? null; + e.target.value = ""; + setCheck(null); + setDone(null); + setError(null); + if (!picked) return; + if (picked.size > MAX_FILE_BYTES) { + setError(t("recordImport.error.tooLarge")); + return; + } + const text = await picked.slice(0, HEAD_BYTES).text(); + const parsedHead = readHead(text); + const guessed = guessMapping(parsedHead.rows[0] ?? [], kind); + const format = guessFormat( + parsedHead, + guessed[kind === "labs" ? "date" : "startsOn"], + ); + setFile(picked); + setHead(parsedHead); + setMapping(guessed); + setDelimiter(delimiterName(parsedHead.delimiter)); + setDecimal(format.decimal); + setDateOrder(format.dateOrder); + } + + async function send(dryRun: boolean) { + if (!file) return; + setBusy(true); + setError(null); + const params = new URLSearchParams({ + dryRun: dryRun ? "1" : "0", + header: header ? "1" : "0", + delimiter, + decimal, + dateOrder, + }); + if (kind === "medications") params.set("reminders", reminders ? "1" : "0"); + for (const f of fields) { + const index = mapping[f.key]; + if (index !== undefined) params.set(f.key, String(index)); + } + try { + const result = await apiFetch( + `/api/import/${kind}-csv?${params}`, + { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: file, + // A large file is read and written row by row; give it time. + signal: AbortSignal.timeout(5 * 60_000), + }, + ); + if (dryRun) setCheck(result); + else setDone(result); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + setError( + status === 429 + ? t("recordImport.error.rateLimited") + : status === 413 + ? t("recordImport.error.tooLarge") + : status === 422 + ? t("recordImport.error.invalid") + : t("recordImport.error.failed"), + ); + } finally { + setBusy(false); + } + } + + const result = done ?? check; + + return ( + onOpenChange(false)} + > + {t("recordImport.close")} + + ) : ( +
+ + +
+ ) + } + > +
+
+ + + +
+ + {head ? ( + <> +
+ + {t("recordImport.mapping")} + + {fields.map((f) => ( +
+ + { + setCheck(null); + const v = e.target.value; + setMapping((m) => { + const next = { ...m }; + // One column feeds one field: taking it here frees it elsewhere. + for (const k of Object.keys(next) as FieldKey[]) { + if (v !== "" && next[k] === Number(v)) delete next[k]; + } + if (v === "") delete next[f.key]; + else next[f.key] = Number(v); + return next; + }); + }} + > + + {columns.map((name, i) => ( + + ))} + +
+ ))} +
+ +
+
+ + { + setCheck(null); + setDelimiter(e.target.value as Delimiter); + }} + > + {(["comma", "semicolon", "tab"] as const).map((d) => ( + + ))} + +
+
+ + { + setCheck(null); + setDecimal(e.target.value as "dot" | "comma"); + }} + > + + + +
+
+ + { + setCheck(null); + setDateOrder(e.target.value as "iso" | "dmy" | "mdy"); + }} + > + {(["iso", "dmy", "mdy"] as const).map((o) => ( + + ))} + +
+
+ +
+ + { + setCheck(null); + setHeader(v); + }} + /> +
+ {kind === "medications" ? ( +
+ + { + setCheck(null); + setReminders(v); + }} + /> +
+ ) : null} +

+ {t("recordImport.timezoneNote")} +

+ +
+ + + {head.rows.map((row, r) => ( + + {row.map((c, i) => ( + + ))} + + ))} + +
+ {c} +
+
+ + ) : null} + + {missing.length > 0 && head ? ( +

+ {t("recordImport.missing", { + fields: missing + .map((f) => t(`recordImport.field.${f.key}`)) + .join(", "), + })} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {result ? ( +
+

+ {done + ? t("recordImport.summaryDone", { + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + }) + : t("recordImport.summaryCheck", { + rows: fmt.integer(result.rows), + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + })} +

+ {result.markers && + (result.markers.created.length > 0 || + result.markers.matched.length > 0) ? ( +
+ {result.markers.matched.length > 0 ? ( +

+ + {t("recordImport.markersMatched")}{" "} + + {result.markers.matched + .map((m) => + m.name === m.marker + ? m.name + : `${m.name} (${m.marker})`, + ) + .join(", ")} +

+ ) : null} + {result.markers.created.length > 0 ? ( +

+ + {t("recordImport.markersCreated")}{" "} + + {result.markers.created.join(", ")} +

+ ) : null} +
+ ) : null} + {result.errors.length > 0 ? ( +
    + {result.errors.map((e) => ( +
  • + + {t("recordImport.line", { line: fmt.integer(e.line) })} + + + {t(`recordImport.reason.${e.reason}`)} + {e.field + ? ` (${t(`recordImport.field.${e.field}`)})` + : ""} + +
  • + ))} +
+ ) : null} + {result.errorsTruncated ? ( +

+ {t("recordImport.errorsTruncated")} +

+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/lib/import/__tests__/csv-mapping.test.ts b/src/lib/import/__tests__/csv-mapping.test.ts new file mode 100644 index 000000000..11c249c75 --- /dev/null +++ b/src/lib/import/__tests__/csv-mapping.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, +} from "@/lib/validations/record-import"; + +import { + RECORD_IMPORT_TEMPLATES, + guessFormat, + guessMapping, + readHead, +} from "../csv-mapping"; +import { normaliseLabRow } from "../labs-csv"; +import { normaliseMedicationRow } from "../medications-csv"; + +describe("guessMapping", () => { + it("reads English and German headers, required fields first", () => { + expect( + guessMapping( + ["Analyte", "Result", "Unit", "Date", "Reference range"], + "labs", + ), + ).toEqual({ + analyte: 0, + value: 1, + unit: 2, + date: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Datum", "Laborwert", "Wert", "Einheit", "Referenzbereich"], + "labs", + ), + ).toMatchObject({ + date: 0, + analyte: 1, + value: 2, + unit: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Medikament", "Dosis", "Uhrzeiten", "Bei Bedarf", "Beginn", "Ende"], + "medications", + ), + ).toEqual({ + name: 0, + dose: 1, + times: 2, + asNeeded: 3, + startsOn: 4, + endsOn: 5, + }); + }); + + it("offers a column to one field only and leaves unknown headers alone", () => { + const m = guessMapping(["name", "foo", "bar"], "labs"); + expect(m).toEqual({ analyte: 0 }); + }); +}); + +describe("guessFormat", () => { + it("sees a German file", () => { + const head = readHead("Analyt;Wert;Datum\nHbA1c;5,4;01.03.2025\n"); + expect(head.delimiter).toBe(";"); + expect(guessFormat(head, 2)).toEqual({ + decimal: "comma", + dateOrder: "dmy", + }); + }); + it("sees a US file by a day above twelve", () => { + const head = readHead("a,b,date\nx,5.4,03/15/2025\n"); + expect(guessFormat(head, 2)).toEqual({ decimal: "dot", dateOrder: "mdy" }); + }); + it("sees ISO", () => { + expect(guessFormat(readHead("a,date\nx,2025-03-01\n"), 1).dateOrder).toBe( + "iso", + ); + }); +}); + +describe("the templates", () => { + it("import cleanly with the mapping the screen suggests", () => { + const labs = readHead(RECORD_IMPORT_TEMPLATES.labs, 10); + const labMap = guessMapping(labs.rows[0], "labs"); + const labQuery = labsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(labMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of labs.rows.slice(1).entries()) { + expect(normaliseLabRow({ line: i + 2, fields }, labQuery, "UTC").ok).toBe( + true, + ); + } + const meds = readHead(RECORD_IMPORT_TEMPLATES.medications, 10); + const medMap = guessMapping(meds.rows[0], "medications"); + const medQuery = medicationsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(medMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of meds.rows.slice(1).entries()) { + expect( + normaliseMedicationRow({ line: i + 2, fields }, medQuery, "UTC").ok, + ).toBe(true); + } + }); +}); diff --git a/src/lib/import/csv-mapping.ts b/src/lib/import/csv-mapping.ts new file mode 100644 index 000000000..e8135729a --- /dev/null +++ b/src/lib/import/csv-mapping.ts @@ -0,0 +1,345 @@ +/** + * The client half of the CSV record imports (#1215): reading the head of a + * file for the mapping screen, suggesting which column feeds which field and + * how the file writes numbers and dates, and the downloadable templates. + * + * Suggestions only. The person sees every choice on the mapping screen and + * the server checks every row against what was chosen; nothing here decides + * a value. + */ +import { CsvTokenizer, type CsvDelimiter } from "@/lib/import/csv-stream"; + +export type RecordImportKind = "labs" | "medications"; + +export const LAB_FIELDS = [ + { key: "analyte", required: true }, + { key: "value", required: true }, + { key: "unit", required: false }, + { key: "date", required: true }, + { key: "time", required: false }, + { key: "referenceRange", required: false }, + { key: "referenceLow", required: false }, + { key: "referenceHigh", required: false }, + { key: "panel", required: false }, + { key: "note", required: false }, +] as const; + +export const MEDICATION_FIELDS = [ + { key: "name", required: true }, + { key: "dose", required: true }, + { key: "times", required: false }, + { key: "asNeeded", required: false }, + { key: "startsOn", required: false }, + { key: "endsOn", required: false }, +] as const; + +export type FieldKey = + | (typeof LAB_FIELDS)[number]["key"] + | (typeof MEDICATION_FIELDS)[number]["key"]; + +export function fieldsFor(kind: RecordImportKind) { + return kind === "labs" ? LAB_FIELDS : MEDICATION_FIELDS; +} + +/** Header words per field, in the languages HealthLog speaks, folded. */ +const SYNONYMS: Record = { + analyte: [ + "analyte", + "marker", + "biomarker", + "test", + "parameter", + "analyt", + "laborwert", + "wert name", + "name", + "prueba", + "analyse", + "esame", + "badanie", + ], + value: [ + "value", + "result", + "wert", + "ergebnis", + "resultado", + "valor", + "valeur", + "resultat", + "valore", + "risultato", + "wynik", + "waarde", + "uitslag", + "결과", + "값", + ], + unit: [ + "unit", + "units", + "einheit", + "unidad", + "unite", + "unita", + "jednostka", + "eenheid", + "단위", + ], + date: [ + "date", + "datum", + "fecha", + "data", + "day", + "tag", + "taken", + "taken at", + "collected", + "abnahme", + "날짜", + ], + time: [ + "time", + "uhrzeit", + "zeit", + "hora", + "heure", + "ora", + "godzina", + "tijd", + "시간", + ], + referenceRange: [ + "reference", + "reference range", + "range", + "referenz", + "referenzbereich", + "normbereich", + "norm", + "rango", + "reference interval", + "intervalle", + "riferimento", + "zakres", + "referentie", + ], + referenceLow: ["low", "min", "lower", "untergrenze", "von", "ref low"], + referenceHigh: ["high", "max", "upper", "obergrenze", "bis", "ref high"], + panel: ["panel", "group", "gruppe", "profil", "category", "kategorie"], + note: [ + "note", + "notes", + "comment", + "kommentar", + "notiz", + "bemerkung", + "nota", + "remarque", + "uwagi", + "opmerking", + ], + name: [ + "name", + "medication", + "medicine", + "drug", + "medikament", + "arznei", + "präparat", + "praparat", + "wirkstoff", + "medicamento", + "medicament", + "farmaco", + "lek", + "medicijn", + "약", + ], + dose: [ + "dose", + "dosage", + "dosis", + "dosierung", + "strength", + "stärke", + "starke", + "dosage", + "dawka", + "용량", + ], + times: [ + "times", + "time", + "schedule", + "uhrzeit", + "uhrzeiten", + "einnahme", + "zeiten", + "horario", + "heures", + "orari", + "godziny", + "tijden", + "시간", + ], + asNeeded: [ + "as needed", + "prn", + "bei bedarf", + "bedarf", + "a demanda", + "si besoin", + "al bisogno", + "w razie potrzeby", + "zo nodig", + "필요시", + ], + startsOn: [ + "start", + "starts", + "start date", + "begin", + "beginn", + "seit", + "from", + "von", + "inicio", + "debut", + "inizio", + "od", + "begin datum", + ], + endsOn: [ + "end", + "ends", + "end date", + "until", + "bis", + "ende", + "fin", + "fine", + "do", + "einddatum", + ], +}; + +function fold(text: string): string { + return text + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[_\-./()]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Suggest a column per field from the header names. Exact synonym first, then + * a header that starts with one; a column is offered to one field at most, + * and the required fields choose first. + */ +export function guessMapping( + headers: readonly string[], + kind: RecordImportKind, +): Partial> { + const folded = headers.map(fold); + const taken = new Set(); + const out: Partial> = {}; + const order = [...fieldsFor(kind)].sort( + (a, b) => Number(b.required) - Number(a.required), + ); + for (const pass of ["exact", "prefix"] as const) { + for (const { key } of order) { + if (out[key] !== undefined) continue; + const words = SYNONYMS[key]; + const index = folded.findIndex( + (h, i) => + !taken.has(i) && + words.some((w) => + pass === "exact" ? h === w : h.startsWith(`${w} `), + ), + ); + if (index >= 0) { + out[key] = index; + taken.add(index); + } + } + } + return out; +} + +export interface FileHead { + delimiter: CsvDelimiter; + rows: string[][]; +} + +/** The first `maxRows` records of a file's head text. */ +export function readHead(text: string, maxRows = 6): FileHead { + const tokenizer = new CsvTokenizer("auto"); + const rows: string[][] = []; + for (const record of [...tokenizer.push(text), ...tokenizer.end()]) { + rows.push(record.fields); + if (rows.length >= maxRows) break; + } + return { delimiter: tokenizer.detectedDelimiter ?? ",", rows }; +} + +/** + * How the sample writes numbers and dates. A semicolon file almost always + * writes a decimal comma; a sample cell like `5,4` says so outright. The date + * order follows the first date-shaped cell: ISO, then a first number above 12 + * (day first) or a second above 12 (month first), then the separator habit + * (`.` is day first, `/` alone is left to the person: it stays ISO-default + * `dmy` only when a dot is seen). + */ +export function guessFormat( + head: FileHead, + dateColumn: number | undefined, +): { decimal: "dot" | "comma"; dateOrder: "iso" | "dmy" | "mdy" } { + const data = head.rows.slice(1); + const cells = data.flat(); + const commaNumber = cells.some((c) => /^-?\d+,\d{1,2}$/.test(c.trim())); + const decimal = head.delimiter === ";" || commaNumber ? "comma" : "dot"; + + let dateOrder: "iso" | "dmy" | "mdy" = "iso"; + const dates = data + .map((r) => (dateColumn === undefined ? "" : (r[dateColumn] ?? "").trim())) + .filter(Boolean); + for (const d of dates) { + if (/^\d{4}-\d{2}-\d{2}/.test(d)) { + dateOrder = "iso"; + break; + } + const m = /^(\d{1,2})([./-])(\d{1,2})\2\d{4}/.exec(d); + if (!m) continue; + if (Number(m[1]) > 12) { + dateOrder = "dmy"; + break; + } + if (Number(m[3]) > 12) { + dateOrder = "mdy"; + break; + } + dateOrder = m[2] === "." ? "dmy" : "mdy"; + } + return { decimal, dateOrder }; +} + +export const RECORD_IMPORT_TEMPLATES: Record = { + labs: [ + "analyte,value,unit,date,time,reference range,panel,note", + "HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,", + "Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,", + "Urine protein,negative,,2025-03-01,,,Urine,", + "", + ].join("\n"), + medications: [ + "name,dose,times,as needed,start,end", + "Ramipril,5 mg,08:00,,2025-01-01,", + "Metformin,500 mg,08:00 20:00,,,", + "Ibuprofen,400 mg,,yes,,", + "", + ].join("\n"), +};