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"), +};