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 (
+
+