mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(medications): prove the reported export lands, row for row
The fixture is the export a self-hoster attached, de-identified as it arrived: 3,395 rows, sixteen medications, three and a half years, two UTC offsets, three statuses, two units, blank cells in four columns. A hand-built fixture would have carried one status and one offset and proved nothing about the file that prompted the work. The integration pass runs it through the real derivation into a real Postgres, because the claim is about rows: 3,387 land, 8 are refused because the file records no decision about them, and imported plus refused equals every row read. 2,799 of the doses were taken after they were due, which is the fact that survives only because the scheduled slot and the take are two columns. Structural cover so the verdicts cannot rot: every column of both shapes has exactly one ruling, and every parse-time refusal is spelled the same as a job skip reason — a reason that exists in only one vocabulary renders as "reason not recorded", which is a refusal nobody can act on.
This commit is contained in:
1 parent
11e6402b5a
commit
34a220421c
20 files changed
+5322
-25
No files matched your search
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "{count} Einträge übersprungen: {reason}",
|
||||
"reasonDuplicateInFile": "ein anderer Eintrag derselben Datei hat dasselbe Datum und dieselbe Uhrzeit",
|
||||
"reasonAlreadyRecorded": "für dieses Datum und diese Uhrzeit ist bereits eine Einnahme erfasst",
|
||||
"reasonUnknown": "Grund nicht erfasst"
|
||||
"reasonUnknown": "Grund nicht erfasst",
|
||||
"reasonMedicationNotFound": "kein Medikament im Bestand passt zum Namen in der Datei",
|
||||
"reasonMedicationAmbiguous": "der Name in der Datei passt auf mehr als ein Medikament",
|
||||
"reasonMedicationIsMirrored": "das Medikament ist eine schreibgeschützte Kopie aus einer anderen App",
|
||||
"reasonStatusNotRecorded": "die Datei hält zu dieser Dosis keine Entscheidung fest",
|
||||
"reasonStatusUnknown": "der Status in der Datei gehört nicht zum dokumentierten Format",
|
||||
"reasonMissingTimestamp": "die Zeile nennt keine Zeit",
|
||||
"reasonMissingOffset": "die Zeit trägt keinen UTC-Offset, der Zeitpunkt ist damit unbekannt",
|
||||
"reasonUnreadableTimestamp": "die Zeit entspricht nicht dem dokumentierten Format",
|
||||
"reasonImplausibleTimestamp": "die Zeit nennt keinen echten Zeitpunkt oder liegt in der Zukunft",
|
||||
"reasonMissingMedication": "die Zeile nennt kein Medikament",
|
||||
"reasonUnreadableDosage": "die Dosis ist keine Zahl",
|
||||
"reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil",
|
||||
"reasonDuplicate": "bereits importiert",
|
||||
"reasonUnknown": "übersprungen"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Einnahmeverlauf",
|
||||
"description": "Ein Einnahmeverlauf, wie ihn ein anderer Medikamenten-Tracker exportiert, direkt gelesen. Eine Datei deckt alle Medikamente darin ab.",
|
||||
"pasteLabel": "Export einfügen",
|
||||
"fileInputLabel": "Datei mit Einnahmeverlauf wählen",
|
||||
"uploadFile": "Datei wählen",
|
||||
"preview": "Vorschau",
|
||||
"import": "Importieren",
|
||||
"empty": "Erst den Export einfügen oder eine Datei wählen.",
|
||||
"readFailed": "Diese Datei konnte nicht gelesen werden.",
|
||||
"rateLimited": "Höchstens 5 Importe pro Stunde. Später erneut versuchen.",
|
||||
"tooLarge": "Diese Datei überschreitet das Limit von 16 MB.",
|
||||
"inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.",
|
||||
"workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.",
|
||||
"failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
|
||||
"columnsHeading": "Was mit jeder Spalte passiert",
|
||||
"jsonOnly": "nur JSON",
|
||||
"previewLine": "Zeilen gelesen: {rows} · würde importieren: {queued} · abgewiesen: {refused}",
|
||||
"submittedLine": "Zeilen gelesen: {rows} · zum Import eingereiht: {queued}",
|
||||
"unmatched": "Kein Medikament im Bestand passt zu: {names}",
|
||||
"ambiguous": "Passt auf mehr als ein Medikament: {names}",
|
||||
"mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}",
|
||||
"unknownColumns": "Spalten ohne Regel im Importer, ungelesen: {names}",
|
||||
"codingsNotRead": "Zeilen mit einem nicht gelesenen Codings-Wert: {count}",
|
||||
"archivedRows": "Zeilen von einem in der Quell-App archivierten Medikament, trotzdem importiert: {count}",
|
||||
"verdict": {
|
||||
"honoured": "verwendet",
|
||||
"reported": "gemeldet",
|
||||
"ignored": "nicht gelesen"
|
||||
},
|
||||
"column": {
|
||||
"date": "Der Zeitpunkt der tatsächlichen Einnahme. Sein UTC-Offset wird genau so gelesen, wie er dasteht.",
|
||||
"scheduledDate": "Der Slot, zu dem die Dosis gehörte, getrennt vom Zeitpunkt der Einnahme. Leer heißt: die Dosis gehörte zu keinem Slot.",
|
||||
"medication": "Wird einem Medikament zugeordnet, das es im Bestand schon gibt. Aus der Datei wird nichts angelegt.",
|
||||
"nickname": "Wird als zweiter Name versucht, wenn der erste zu nichts passt.",
|
||||
"dosage": "Wird nur dann an der Dosis festgehalten, wenn sie von der geplanten Menge abweicht.",
|
||||
"scheduledDosage": "Wird mit der tatsächlichen Menge verglichen, um eine Abweichung zu erkennen. Nie eigenständig gespeichert.",
|
||||
"unit": "Wird mit der Menge geschrieben, geschrieben wie in der Datei. „count“ heißt Vielfache der konfigurierten Dosis.",
|
||||
"status": "„Taken“ wird eine Dosis, „Skipped“ ein bewusstes Auslassen. Jeder andere Wert hält keine Entscheidung fest und die Zeile wird abgewiesen.",
|
||||
"archived": "Wird gezählt und zurückgemeldet. Eine erfolgte Einnahme wird nicht dadurch ungültig, dass das Medikament später archiviert wurde.",
|
||||
"codings": "Die CSV dokumentiert für diese Spalte keine Kodierung, also bleibt sie unberührt statt geraten. Gefüllte Zellen werden gezählt.",
|
||||
"form": "Beschreibt das Medikament, nicht die Dosis.",
|
||||
"start": "Das Datum des Behandlungsbeginns, nicht der Zeitpunkt dieser Dosis.",
|
||||
"end": "Das Datum des Behandlungsendes, nicht der Zeitpunkt dieser Dosis."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Diese Datei enthält keine Zeilen.",
|
||||
"missingColumns": "Dieser Datei fehlt eine Spalte, die der Importer braucht: Date, Medication oder Status.",
|
||||
"unreadableJson": "Diese Datei ist kein gültiges JSON.",
|
||||
"jsonNotAnArray": "Dieses JSON enthält keine Liste von Dosen.",
|
||||
"jsonNoIntakeTime": "Der JSON-Export hält fest, wann eine Dosis fällig war, nicht wann sie genommen wurde, und eine geplante Zeit wird nicht als Einnahmezeit geschrieben. Stattdessen die CSV exportieren, die beides trägt.",
|
||||
"tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
|
||||
|
||||
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "{count} entries skipped: {reason}",
|
||||
"reasonDuplicateInFile": "another entry in the same file has the same date and time",
|
||||
"reasonAlreadyRecorded": "an intake is already recorded for that date and time",
|
||||
"reasonUnknown": "reason not recorded"
|
||||
"reasonUnknown": "reason not recorded",
|
||||
"reasonMedicationNotFound": "no medication on record matches the name in the file",
|
||||
"reasonMedicationAmbiguous": "the name in the file matches more than one medication on record",
|
||||
"reasonMedicationIsMirrored": "the medication is a read-only copy of another app's list",
|
||||
"reasonStatusNotRecorded": "the file records no decision about that dose",
|
||||
"reasonStatusUnknown": "the status in the file is not one the format defines",
|
||||
"reasonMissingTimestamp": "the row states no time",
|
||||
"reasonMissingOffset": "the time carries no UTC offset, so the instant is unknown",
|
||||
"reasonUnreadableTimestamp": "the time is not in the format the export documents",
|
||||
"reasonImplausibleTimestamp": "the time names no real instant, or lies in the future",
|
||||
"reasonMissingMedication": "the row names no medication",
|
||||
"reasonUnreadableDosage": "the dosage is not a number",
|
||||
"reasonUnreadableRow": "the row has fewer columns than the header"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "external ID is not stable across exports",
|
||||
"reasonDuplicate": "already imported",
|
||||
"reasonUnknown": "skipped"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Medication dose history",
|
||||
"description": "A dose history exported from another medication tracker, read as it comes. One file covers every medication in it.",
|
||||
"pasteLabel": "Paste the export",
|
||||
"fileInputLabel": "Choose a dose-history export file",
|
||||
"uploadFile": "Choose file",
|
||||
"preview": "Preview",
|
||||
"import": "Import",
|
||||
"empty": "Paste the export or choose a file first.",
|
||||
"readFailed": "That file could not be read.",
|
||||
"rateLimited": "Maximum 5 imports per hour. Try again later.",
|
||||
"tooLarge": "That file is over the 16 MB limit.",
|
||||
"inProgress": "A dose-history import is already running. Wait for it to finish.",
|
||||
"workerUnavailable": "The background worker is not available, so nothing was imported.",
|
||||
"failed": "The import failed. Nothing was written.",
|
||||
"columnsHeading": "What each column does",
|
||||
"jsonOnly": "JSON only",
|
||||
"previewLine": "Rows read: {rows} · would import: {queued} · refused: {refused}",
|
||||
"submittedLine": "Rows read: {rows} · queued for import: {queued}",
|
||||
"unmatched": "No medication on record matches: {names}",
|
||||
"ambiguous": "Matches more than one medication: {names}",
|
||||
"mirrored": "Mirrored from another app, so no dose can be added: {names}",
|
||||
"unknownColumns": "Columns the importer has no rule for, left unread: {names}",
|
||||
"codingsNotRead": "Rows carrying a Codings value that was not read: {count}",
|
||||
"archivedRows": "Rows from a medication archived in the source app, imported anyway: {count}",
|
||||
"verdict": {
|
||||
"honoured": "used",
|
||||
"reported": "reported",
|
||||
"ignored": "not read"
|
||||
},
|
||||
"column": {
|
||||
"date": "The time the dose was actually taken. Its UTC offset is read exactly as written.",
|
||||
"scheduledDate": "The slot the dose belonged to, kept apart from the time it was taken. Empty means the dose belonged to no slot.",
|
||||
"medication": "Matched against a medication already on your record. Nothing is created from the file.",
|
||||
"nickname": "Tried as a second name when the first one matches nothing.",
|
||||
"dosage": "Recorded on the dose only where it differs from the scheduled amount.",
|
||||
"scheduledDosage": "Compared against the actual amount to decide whether that dose deviated. Never stored on its own.",
|
||||
"unit": "Written with the amount, spelled as the file spells it. \"count\" means multiples of the configured dose.",
|
||||
"status": "\"Taken\" becomes a dose and \"Skipped\" a deliberate skip. Any other value records no decision and the row is refused.",
|
||||
"archived": "Counted and reported back. A dose that happened is not undone by archiving the medication afterwards.",
|
||||
"codings": "The CSV gives this column no documented encoding, so it is left alone rather than guessed at. Populated cells are counted.",
|
||||
"form": "Describes the medication, not the dose.",
|
||||
"start": "The date you started the medication, not the time of this dose.",
|
||||
"end": "The date you stopped the medication, not the time of this dose."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "That file holds no rows.",
|
||||
"missingColumns": "That file is missing a column the importer needs: Date, Medication or Status.",
|
||||
"unreadableJson": "That file is not valid JSON.",
|
||||
"jsonNotAnArray": "That JSON holds no list of doses.",
|
||||
"jsonNoIntakeTime": "The JSON export records when a dose was due but not when it was taken, and a scheduled time will not be written as if it were the take. Export the CSV instead, which carries both.",
|
||||
"tooManyRows": "That file holds more rows than one import can take. Split it and import the parts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Export your data or import from a backup."
|
||||
|
||||
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "{count} entradas omitidas: {reason}",
|
||||
"reasonDuplicateInFile": "otra entrada del mismo archivo tiene la misma fecha y hora",
|
||||
"reasonAlreadyRecorded": "ya hay una toma registrada para esa fecha y hora",
|
||||
"reasonUnknown": "motivo no registrado"
|
||||
"reasonUnknown": "motivo no registrado",
|
||||
"reasonMedicationNotFound": "ningún medicamento registrado coincide con el nombre del archivo",
|
||||
"reasonMedicationAmbiguous": "el nombre del archivo coincide con más de un medicamento",
|
||||
"reasonMedicationIsMirrored": "el medicamento es una copia de solo lectura de otra aplicación",
|
||||
"reasonStatusNotRecorded": "el archivo no registra ninguna decisión sobre esa dosis",
|
||||
"reasonStatusUnknown": "el estado del archivo no es uno de los que define el formato",
|
||||
"reasonMissingTimestamp": "la fila no indica ninguna hora",
|
||||
"reasonMissingOffset": "la hora no lleva desplazamiento UTC, así que el instante es desconocido",
|
||||
"reasonUnreadableTimestamp": "la hora no tiene el formato que documenta la exportación",
|
||||
"reasonImplausibleTimestamp": "la hora no nombra un instante real, o está en el futuro",
|
||||
"reasonMissingMedication": "la fila no nombra ningún medicamento",
|
||||
"reasonUnreadableDosage": "la dosis no es un número",
|
||||
"reasonUnreadableRow": "la fila tiene menos columnas que la cabecera"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "el ID externo no es estable entre exportaciones",
|
||||
"reasonDuplicate": "ya importada",
|
||||
"reasonUnknown": "omitida"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historial de dosis",
|
||||
"description": "Un historial de dosis exportado desde otra aplicación de seguimiento, leído tal cual. Un archivo abarca todos los medicamentos que contiene.",
|
||||
"pasteLabel": "Pega la exportación",
|
||||
"fileInputLabel": "Elegir un archivo de historial de dosis",
|
||||
"uploadFile": "Elegir archivo",
|
||||
"preview": "Previsualizar",
|
||||
"import": "Importar",
|
||||
"empty": "Pega la exportación o elige un archivo primero.",
|
||||
"readFailed": "No se pudo leer ese archivo.",
|
||||
"rateLimited": "Máximo 5 importaciones por hora. Inténtalo más tarde.",
|
||||
"tooLarge": "Ese archivo supera el límite de 16 MB.",
|
||||
"inProgress": "Ya hay una importación de historial en curso. Espera a que termine.",
|
||||
"workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.",
|
||||
"failed": "La importación falló. No se escribió nada.",
|
||||
"columnsHeading": "Qué hace cada columna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Filas leídas: {rows} · se importarían: {queued} · rechazadas: {refused}",
|
||||
"submittedLine": "Filas leídas: {rows} · en cola para importar: {queued}",
|
||||
"unmatched": "Ningún medicamento registrado coincide con: {names}",
|
||||
"ambiguous": "Coincide con más de un medicamento: {names}",
|
||||
"mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}",
|
||||
"unknownColumns": "Columnas sin regla en el importador, no leídas: {names}",
|
||||
"codingsNotRead": "Filas con un valor de Codings no leído: {count}",
|
||||
"archivedRows": "Filas de un medicamento archivado en la aplicación de origen, importadas igualmente: {count}",
|
||||
"verdict": {
|
||||
"honoured": "usada",
|
||||
"reported": "informada",
|
||||
"ignored": "no leída"
|
||||
},
|
||||
"column": {
|
||||
"date": "La hora en que se tomó realmente la dosis. Su desplazamiento UTC se lee tal como está escrito.",
|
||||
"scheduledDate": "La franja a la que pertenecía la dosis, separada de la hora en que se tomó. Vacío significa que no pertenecía a ninguna franja.",
|
||||
"medication": "Se empareja con un medicamento que ya está en tu registro. Nada se crea a partir del archivo.",
|
||||
"nickname": "Se prueba como segundo nombre cuando el primero no coincide con nada.",
|
||||
"dosage": "Se registra en la dosis solo cuando difiere de la cantidad prevista.",
|
||||
"scheduledDosage": "Se compara con la cantidad real para decidir si esa dosis se desvió. Nunca se guarda por sí sola.",
|
||||
"unit": "Se escribe con la cantidad, tal como la escribe el archivo. \"count\" significa múltiplos de la dosis configurada.",
|
||||
"status": "\"Taken\" se convierte en una dosis y \"Skipped\" en una omisión deliberada. Cualquier otro valor no registra decisión alguna y la fila se rechaza.",
|
||||
"archived": "Se cuenta y se informa. Una dosis que ocurrió no se deshace al archivar el medicamento después.",
|
||||
"codings": "El CSV no documenta ninguna codificación para esta columna, así que se deja intacta en vez de adivinarla. Las celdas con contenido se cuentan.",
|
||||
"form": "Describe el medicamento, no la dosis.",
|
||||
"start": "La fecha en que empezaste el medicamento, no la hora de esta dosis.",
|
||||
"end": "La fecha en que dejaste el medicamento, no la hora de esta dosis."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ese archivo no contiene filas.",
|
||||
"missingColumns": "A ese archivo le falta una columna que el importador necesita: Date, Medication o Status.",
|
||||
"unreadableJson": "Ese archivo no es JSON válido.",
|
||||
"jsonNotAnArray": "Ese JSON no contiene ninguna lista de dosis.",
|
||||
"jsonNoIntakeTime": "La exportación JSON registra cuándo tocaba una dosis pero no cuándo se tomó, y una hora prevista no se escribirá como si fuera la toma. Exporta el CSV, que lleva ambas.",
|
||||
"tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
|
||||
|
||||
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "{count} entrées ignorées : {reason}",
|
||||
"reasonDuplicateInFile": "une autre entrée du même fichier porte les mêmes date et heure",
|
||||
"reasonAlreadyRecorded": "une prise est déjà enregistrée pour cette date et cette heure",
|
||||
"reasonUnknown": "motif non enregistré"
|
||||
"reasonUnknown": "motif non enregistré",
|
||||
"reasonMedicationNotFound": "aucun médicament enregistré ne correspond au nom du fichier",
|
||||
"reasonMedicationAmbiguous": "le nom du fichier correspond à plusieurs médicaments",
|
||||
"reasonMedicationIsMirrored": "le médicament est une copie en lecture seule provenant d'une autre application",
|
||||
"reasonStatusNotRecorded": "le fichier ne consigne aucune décision sur cette dose",
|
||||
"reasonStatusUnknown": "le statut du fichier ne fait pas partie de ceux que définit le format",
|
||||
"reasonMissingTimestamp": "la ligne n'indique aucune heure",
|
||||
"reasonMissingOffset": "l'heure ne porte pas de décalage UTC, l'instant est donc inconnu",
|
||||
"reasonUnreadableTimestamp": "l'heure n'est pas au format documenté par l'export",
|
||||
"reasonImplausibleTimestamp": "l'heure ne désigne aucun instant réel, ou se situe dans le futur",
|
||||
"reasonMissingMedication": "la ligne ne nomme aucun médicament",
|
||||
"reasonUnreadableDosage": "la dose n'est pas un nombre",
|
||||
"reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre",
|
||||
"reasonDuplicate": "déjà importée",
|
||||
"reasonUnknown": "ignorée"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historique des prises",
|
||||
"description": "Un historique de prises exporté depuis une autre application de suivi, lu tel quel. Un fichier couvre tous les médicaments qu'il contient.",
|
||||
"pasteLabel": "Coller l'export",
|
||||
"fileInputLabel": "Choisir un fichier d'historique des prises",
|
||||
"uploadFile": "Choisir un fichier",
|
||||
"preview": "Aperçu",
|
||||
"import": "Importer",
|
||||
"empty": "Collez d'abord l'export ou choisissez un fichier.",
|
||||
"readFailed": "Ce fichier n'a pas pu être lu.",
|
||||
"rateLimited": "Maximum 5 imports par heure. Réessayez plus tard.",
|
||||
"tooLarge": "Ce fichier dépasse la limite de 16 Mo.",
|
||||
"inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.",
|
||||
"workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.",
|
||||
"failed": "L'import a échoué. Rien n'a été écrit.",
|
||||
"columnsHeading": "Ce que devient chaque colonne",
|
||||
"jsonOnly": "JSON uniquement",
|
||||
"previewLine": "Lignes lues : {rows} · seraient importées : {queued} · refusées : {refused}",
|
||||
"submittedLine": "Lignes lues : {rows} · en file d'import : {queued}",
|
||||
"unmatched": "Aucun médicament enregistré ne correspond à : {names}",
|
||||
"ambiguous": "Correspond à plusieurs médicaments : {names}",
|
||||
"mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}",
|
||||
"unknownColumns": "Colonnes sans règle dans l'importeur, non lues : {names}",
|
||||
"codingsNotRead": "Lignes portant une valeur Codings non lue : {count}",
|
||||
"archivedRows": "Lignes d'un médicament archivé dans l'application source, importées quand même : {count}",
|
||||
"verdict": {
|
||||
"honoured": "utilisée",
|
||||
"reported": "signalée",
|
||||
"ignored": "non lue"
|
||||
},
|
||||
"column": {
|
||||
"date": "L'heure à laquelle la dose a réellement été prise. Son décalage UTC est lu exactement tel qu'il est écrit.",
|
||||
"scheduledDate": "Le créneau auquel la dose appartenait, distinct de l'heure de prise. Vide signifie qu'elle n'appartenait à aucun créneau.",
|
||||
"medication": "Rapproché d'un médicament déjà présent dans votre dossier. Rien n'est créé à partir du fichier.",
|
||||
"nickname": "Essayé comme second nom lorsque le premier ne correspond à rien.",
|
||||
"dosage": "Consigné sur la dose uniquement lorsqu'il diffère de la quantité prévue.",
|
||||
"scheduledDosage": "Comparé à la quantité réelle pour décider s'il y a eu écart. Jamais conservé seul.",
|
||||
"unit": "Écrit avec la quantité, orthographié comme dans le fichier. « count » signifie des multiples de la dose configurée.",
|
||||
"status": "« Taken » devient une prise et « Skipped » une omission délibérée. Toute autre valeur ne consigne aucune décision et la ligne est refusée.",
|
||||
"archived": "Compté et signalé. Une prise qui a eu lieu n'est pas annulée parce que le médicament a été archivé ensuite.",
|
||||
"codings": "Le CSV ne documente aucun encodage pour cette colonne, elle est donc laissée telle quelle plutôt que devinée. Les cellules remplies sont comptées.",
|
||||
"form": "Décrit le médicament, pas la prise.",
|
||||
"start": "La date de début du traitement, pas l'heure de cette prise.",
|
||||
"end": "La date d'arrêt du traitement, pas l'heure de cette prise."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ce fichier ne contient aucune ligne.",
|
||||
"missingColumns": "Il manque à ce fichier une colonne dont l'importeur a besoin : Date, Medication ou Status.",
|
||||
"unreadableJson": "Ce fichier n'est pas du JSON valide.",
|
||||
"jsonNotAnArray": "Ce JSON ne contient aucune liste de prises.",
|
||||
"jsonNoIntakeTime": "L'export JSON consigne le moment où une dose était due, pas celui où elle a été prise, et une heure prévue ne sera pas écrite comme si c'était la prise. Exportez le CSV, qui porte les deux.",
|
||||
"tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
|
||||
|
||||
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "{count} voci ignorate: {reason}",
|
||||
"reasonDuplicateInFile": "un’altra voce dello stesso file ha la stessa data e la stessa ora",
|
||||
"reasonAlreadyRecorded": "per quella data e ora è già registrata un’assunzione",
|
||||
"reasonUnknown": "motivo non registrato"
|
||||
"reasonUnknown": "motivo non registrato",
|
||||
"reasonMedicationNotFound": "nessun farmaco registrato corrisponde al nome nel file",
|
||||
"reasonMedicationAmbiguous": "il nome nel file corrisponde a più di un farmaco",
|
||||
"reasonMedicationIsMirrored": "il farmaco è una copia in sola lettura da un'altra app",
|
||||
"reasonStatusNotRecorded": "il file non registra alcuna decisione su quella dose",
|
||||
"reasonStatusUnknown": "lo stato nel file non è uno di quelli definiti dal formato",
|
||||
"reasonMissingTimestamp": "la riga non indica alcun orario",
|
||||
"reasonMissingOffset": "l'orario non porta un offset UTC, quindi l'istante è ignoto",
|
||||
"reasonUnreadableTimestamp": "l'orario non è nel formato documentato dall'esportazione",
|
||||
"reasonImplausibleTimestamp": "l'orario non indica un istante reale, o è nel futuro",
|
||||
"reasonMissingMedication": "la riga non nomina alcun farmaco",
|
||||
"reasonUnreadableDosage": "il dosaggio non è un numero",
|
||||
"reasonUnreadableRow": "la riga ha meno colonne dell'intestazione"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro",
|
||||
"reasonDuplicate": "già importata",
|
||||
"reasonUnknown": "saltata"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Storico delle assunzioni",
|
||||
"description": "Uno storico di assunzioni esportato da un'altra app di monitoraggio, letto così com'è. Un file copre tutti i farmaci che contiene.",
|
||||
"pasteLabel": "Incolla l'esportazione",
|
||||
"fileInputLabel": "Scegli un file con lo storico delle assunzioni",
|
||||
"uploadFile": "Scegli file",
|
||||
"preview": "Anteprima",
|
||||
"import": "Importa",
|
||||
"empty": "Prima incolla l'esportazione o scegli un file.",
|
||||
"readFailed": "Non è stato possibile leggere quel file.",
|
||||
"rateLimited": "Massimo 5 importazioni all'ora. Riprova più tardi.",
|
||||
"tooLarge": "Quel file supera il limite di 16 MB.",
|
||||
"inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.",
|
||||
"workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.",
|
||||
"failed": "L'importazione è fallita. Non è stato scritto nulla.",
|
||||
"columnsHeading": "Cosa fa ogni colonna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Righe lette: {rows} · da importare: {queued} · rifiutate: {refused}",
|
||||
"submittedLine": "Righe lette: {rows} · in coda per l'importazione: {queued}",
|
||||
"unmatched": "Nessun farmaco registrato corrisponde a: {names}",
|
||||
"ambiguous": "Corrisponde a più di un farmaco: {names}",
|
||||
"mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}",
|
||||
"unknownColumns": "Colonne senza regola nell'importatore, non lette: {names}",
|
||||
"codingsNotRead": "Righe con un valore Codings non letto: {count}",
|
||||
"archivedRows": "Righe di un farmaco archiviato nell'app di origine, importate comunque: {count}",
|
||||
"verdict": {
|
||||
"honoured": "usata",
|
||||
"reported": "segnalata",
|
||||
"ignored": "non letta"
|
||||
},
|
||||
"column": {
|
||||
"date": "L'ora in cui la dose è stata effettivamente assunta. Il suo offset UTC viene letto esattamente come è scritto.",
|
||||
"scheduledDate": "La fascia a cui la dose apparteneva, tenuta distinta dall'ora di assunzione. Vuoto significa che non apparteneva a nessuna fascia.",
|
||||
"medication": "Abbinato a un farmaco già presente nella tua scheda. Dal file non viene creato nulla.",
|
||||
"nickname": "Provato come secondo nome quando il primo non corrisponde a niente.",
|
||||
"dosage": "Registrato sulla dose solo quando differisce dalla quantità prevista.",
|
||||
"scheduledDosage": "Confrontato con la quantità effettiva per stabilire se quella dose si è discostata. Mai salvato da solo.",
|
||||
"unit": "Scritto con la quantità, come lo scrive il file. \"count\" indica multipli della dose configurata.",
|
||||
"status": "\"Taken\" diventa una dose e \"Skipped\" un'omissione deliberata. Qualsiasi altro valore non registra alcuna decisione e la riga viene rifiutata.",
|
||||
"archived": "Contato e segnalato. Una dose che è avvenuta non si annulla archiviando il farmaco dopo.",
|
||||
"codings": "Il CSV non documenta alcuna codifica per questa colonna, quindi viene lasciata stare invece di essere indovinata. Le celle piene vengono contate.",
|
||||
"form": "Descrive il farmaco, non la dose.",
|
||||
"start": "La data in cui hai iniziato il farmaco, non l'ora di questa dose.",
|
||||
"end": "La data in cui hai smesso il farmaco, non l'ora di questa dose."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Quel file non contiene righe.",
|
||||
"missingColumns": "A quel file manca una colonna che serve all'importatore: Date, Medication o Status.",
|
||||
"unreadableJson": "Quel file non è JSON valido.",
|
||||
"jsonNotAnArray": "Quel JSON non contiene alcun elenco di dosi.",
|
||||
"jsonNoIntakeTime": "L'esportazione JSON registra quando una dose era prevista, non quando è stata assunta, e un orario previsto non verrà scritto come se fosse l'assunzione. Esporta il CSV, che porta entrambi.",
|
||||
"tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Esporta i tuoi dati o importa da un backup."
|
||||
|
||||
+67
-1
@@ -1917,7 +1917,19 @@
|
||||
"skipGroupOther": "Pominięto {count} wpisów: {reason}",
|
||||
"reasonDuplicateInFile": "inny wpis w tym samym pliku ma tę samą datę i godzinę",
|
||||
"reasonAlreadyRecorded": "przyjęcie o tej dacie i godzinie jest już zapisane",
|
||||
"reasonUnknown": "powód nieodnotowany"
|
||||
"reasonUnknown": "powód nieodnotowany",
|
||||
"reasonMedicationNotFound": "żaden zapisany lek nie odpowiada nazwie z pliku",
|
||||
"reasonMedicationAmbiguous": "nazwa z pliku odpowiada więcej niż jednemu lekowi",
|
||||
"reasonMedicationIsMirrored": "lek jest kopią tylko do odczytu z innej aplikacji",
|
||||
"reasonStatusNotRecorded": "plik nie zapisuje żadnej decyzji o tej dawce",
|
||||
"reasonStatusUnknown": "status w pliku nie należy do tych, które definiuje format",
|
||||
"reasonMissingTimestamp": "wiersz nie podaje godziny",
|
||||
"reasonMissingOffset": "godzina nie ma przesunięcia UTC, więc moment jest nieznany",
|
||||
"reasonUnreadableTimestamp": "godzina nie ma formatu opisanego w dokumentacji eksportu",
|
||||
"reasonImplausibleTimestamp": "godzina nie wskazuje rzeczywistego momentu albo leży w przyszłości",
|
||||
"reasonMissingMedication": "wiersz nie nazywa żadnego leku",
|
||||
"reasonUnreadableDosage": "dawka nie jest liczbą",
|
||||
"reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4547,6 +4559,60 @@
|
||||
"reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami",
|
||||
"reasonDuplicate": "już zaimportowano",
|
||||
"reasonUnknown": "pominięto"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historia dawek",
|
||||
"description": "Historia dawek wyeksportowana z innej aplikacji, czytana wprost. Jeden plik obejmuje wszystkie leki, które zawiera.",
|
||||
"pasteLabel": "Wklej eksport",
|
||||
"fileInputLabel": "Wybierz plik z historią dawek",
|
||||
"uploadFile": "Wybierz plik",
|
||||
"preview": "Podgląd",
|
||||
"import": "Importuj",
|
||||
"empty": "Najpierw wklej eksport albo wybierz plik.",
|
||||
"readFailed": "Nie udało się odczytać tego pliku.",
|
||||
"rateLimited": "Maksymalnie 5 importów na godzinę. Spróbuj później.",
|
||||
"tooLarge": "Ten plik przekracza limit 16 MB.",
|
||||
"inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.",
|
||||
"workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.",
|
||||
"failed": "Import nie udał się. Nic nie zostało zapisane.",
|
||||
"columnsHeading": "Co się dzieje z każdą kolumną",
|
||||
"jsonOnly": "tylko JSON",
|
||||
"previewLine": "Odczytane wiersze: {rows} · do zaimportowania: {queued} · odrzucone: {refused}",
|
||||
"submittedLine": "Odczytane wiersze: {rows} · w kolejce do importu: {queued}",
|
||||
"unmatched": "Żaden zapisany lek nie odpowiada: {names}",
|
||||
"ambiguous": "Odpowiada więcej niż jednemu lekowi: {names}",
|
||||
"mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}",
|
||||
"unknownColumns": "Kolumny bez reguły w importerze, nieodczytane: {names}",
|
||||
"codingsNotRead": "Wiersze z nieodczytaną wartością Codings: {count}",
|
||||
"archivedRows": "Wiersze leku zarchiwizowanego w aplikacji źródłowej, zaimportowane mimo to: {count}",
|
||||
"verdict": {
|
||||
"honoured": "użyta",
|
||||
"reported": "zgłoszona",
|
||||
"ignored": "nieodczytana"
|
||||
},
|
||||
"column": {
|
||||
"date": "Godzina rzeczywistego przyjęcia dawki. Jej przesunięcie UTC czytane jest dokładnie tak, jak zapisane.",
|
||||
"scheduledDate": "Okno, do którego należała dawka, trzymane osobno od godziny przyjęcia. Puste znaczy, że dawka nie należała do żadnego okna.",
|
||||
"medication": "Dopasowane do leku, który już jest w Twojej kartotece. Z pliku nic nie jest tworzone.",
|
||||
"nickname": "Próbowane jako druga nazwa, gdy pierwsza do niczego nie pasuje.",
|
||||
"dosage": "Zapisywane przy dawce tylko wtedy, gdy różni się od zaplanowanej ilości.",
|
||||
"scheduledDosage": "Porównywane z rzeczywistą ilością, by rozstrzygnąć, czy dawka odbiegła od planu. Nigdy nie zapisywane samo.",
|
||||
"unit": "Zapisywana razem z ilością, dokładnie tak, jak w pliku. „count” oznacza wielokrotności skonfigurowanej dawki.",
|
||||
"status": "„Taken” staje się dawką, a „Skipped” świadomym pominięciem. Każda inna wartość nie zapisuje żadnej decyzji i wiersz zostaje odrzucony.",
|
||||
"archived": "Liczone i zgłaszane. Dawka, która się odbyła, nie znika przez późniejsze zarchiwizowanie leku.",
|
||||
"codings": "CSV nie dokumentuje dla tej kolumny żadnego kodowania, więc zostaje nietknięta zamiast zgadywana. Wypełnione komórki są liczone.",
|
||||
"form": "Opisuje lek, nie dawkę.",
|
||||
"start": "Data rozpoczęcia leku, nie godzina tej dawki.",
|
||||
"end": "Data zakończenia leku, nie godzina tej dawki."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ten plik nie zawiera wierszy.",
|
||||
"missingColumns": "W tym pliku brakuje kolumny, której potrzebuje importer: Date, Medication albo Status.",
|
||||
"unreadableJson": "Ten plik nie jest poprawnym JSON-em.",
|
||||
"jsonNotAnArray": "Ten JSON nie zawiera listy dawek.",
|
||||
"jsonNoIntakeTime": "Eksport JSON zapisuje, kiedy dawka była zaplanowana, a nie kiedy została przyjęta, i zaplanowana godzina nie zostanie zapisana jako godzina przyjęcia. Wyeksportuj CSV, który zawiera obie.",
|
||||
"tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
|
||||
|
||||
@@ -212,6 +212,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications/compliance/route.ts",
|
||||
"src/app/api/medications/intake/route.ts",
|
||||
"src/app/api/medications/intake/bulk/route.ts",
|
||||
// The account-wide dose-history import is the same class as the
|
||||
// per-medication importer below: it writes intake rows and reads its own job.
|
||||
"src/app/api/medications/intake/dose-history-import/route.ts",
|
||||
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
|
||||
// NB: `medications/extract` is NOT exempt — it gates on
|
||||
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
|
||||
// surface), so the inventory already counts it as a delegated gate.
|
||||
|
||||
@@ -239,14 +239,20 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
// A legacy `zaehler` on the first row rides along and decides nothing:
|
||||
// both keys come from the instant, so two different times can never
|
||||
// collapse onto one key again.
|
||||
// This body carries one time per row and nothing to say which slot it
|
||||
// belonged to, so `scheduledFor` mirrors the take: every dose it
|
||||
// describes is ad-hoc, anchored on itself. The export-reading route is
|
||||
// the one with two facts to write.
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
|
||||
scheduledFor: expect.any(String),
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
|
||||
scheduledFor: expect.any(String),
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -55,18 +55,64 @@ function useSkipReasonLabel(): (reason: string) => string {
|
||||
const labels: Record<string, string> = {
|
||||
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
|
||||
already_recorded: t("medications.import.reasonAlreadyRecorded"),
|
||||
medication_not_found: t("medications.import.reasonMedicationNotFound"),
|
||||
medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
|
||||
medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
|
||||
status_not_recorded: t("medications.import.reasonStatusNotRecorded"),
|
||||
status_unknown: t("medications.import.reasonStatusUnknown"),
|
||||
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
|
||||
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
|
||||
unreadable_timestamp: t("medications.import.reasonUnreadableTimestamp"),
|
||||
implausible_timestamp: t("medications.import.reasonImplausibleTimestamp"),
|
||||
missing_medication: t("medications.import.reasonMissingMedication"),
|
||||
unreadable_dosage: t("medications.import.reasonUnreadableDosage"),
|
||||
unreadable_row: t("medications.import.reasonUnreadableRow"),
|
||||
};
|
||||
const fallback = t("medications.import.reasonUnknown");
|
||||
return (reason: string) => labels[reason] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouped skip lines on their own, without the outcome sentence above them.
|
||||
*
|
||||
* Split out because a dry-run preview needs exactly these lines and must NOT
|
||||
* borrow the outcome sentence: a preview has written nothing, so classifying it
|
||||
* by the counts it WOULD produce would put a success tick on a run that never
|
||||
* happened. The reason vocabulary is shared; the verdict is not.
|
||||
*/
|
||||
export function IntakeImportSkipGroups({
|
||||
groups,
|
||||
testId = "intake-import-skip-groups",
|
||||
}: {
|
||||
groups: readonly IntakeImportSkipGroup[];
|
||||
testId?: string;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const labelFor = useSkipReasonLabel();
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<ul
|
||||
data-testid={testId}
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount("medications.import.skipGroup", group.count, {
|
||||
count: group.count,
|
||||
reason: labelFor(group.reason),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntakeImportResultView({
|
||||
result,
|
||||
}: {
|
||||
result: IntakeImportResultState;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const labelFor = useSkipReasonLabel();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (result.kind === "notice") {
|
||||
return (
|
||||
@@ -106,21 +152,7 @@ export function IntakeImportResultView({
|
||||
message={message}
|
||||
testId="intake-import-outcome"
|
||||
/>
|
||||
{result.skipReasons.length > 0 && (
|
||||
<ul
|
||||
data-testid="intake-import-skip-groups"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{result.skipReasons.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount("medications.import.skipGroup", group.count, {
|
||||
count: group.count,
|
||||
reason: labelFor(group.reason),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<IntakeImportSkipGroups groups={result.skipReasons} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,10 @@ const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
|
||||
path: "src/components/medications/intake-import-dialog.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
},
|
||||
];
|
||||
|
||||
/** The success affordance: the tick component and the success colour token. */
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* What the dose-history importer does with every field of the export.
|
||||
*
|
||||
* Visible before a file is chosen, not discovered afterwards. A column read and
|
||||
* silently dropped is indistinguishable, from the outside, from a column read and
|
||||
* used — which is how a person ends up believing an importer honoured something
|
||||
* it never looked at.
|
||||
*
|
||||
* The rulings come from `AUTO_EXPORT_COLUMN_RULINGS`, so the parser and this list
|
||||
* cannot drift: a structural test asserts every column the format defines has
|
||||
* exactly one ruling, and the sentence for each ruling is a literal `t()` call
|
||||
* below so the i18n call-site guard sees all of them.
|
||||
*/
|
||||
import { AUTO_EXPORT_COLUMN_RULINGS } from "@/lib/medications/import/auto-export-format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** One literal `t()` per note key. A computed key would be invisible to the guard. */
|
||||
function useColumnNote(): (noteKey: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const notes: Record<string, string> = {
|
||||
date: t("settings.sections.export.import.doseHistory.column.date"),
|
||||
scheduledDate: t(
|
||||
"settings.sections.export.import.doseHistory.column.scheduledDate",
|
||||
),
|
||||
medication: t(
|
||||
"settings.sections.export.import.doseHistory.column.medication",
|
||||
),
|
||||
nickname: t("settings.sections.export.import.doseHistory.column.nickname"),
|
||||
dosage: t("settings.sections.export.import.doseHistory.column.dosage"),
|
||||
scheduledDosage: t(
|
||||
"settings.sections.export.import.doseHistory.column.scheduledDosage",
|
||||
),
|
||||
unit: t("settings.sections.export.import.doseHistory.column.unit"),
|
||||
status: t("settings.sections.export.import.doseHistory.column.status"),
|
||||
archived: t("settings.sections.export.import.doseHistory.column.archived"),
|
||||
codings: t("settings.sections.export.import.doseHistory.column.codings"),
|
||||
form: t("settings.sections.export.import.doseHistory.column.form"),
|
||||
start: t("settings.sections.export.import.doseHistory.column.start"),
|
||||
end: t("settings.sections.export.import.doseHistory.column.end"),
|
||||
};
|
||||
return (noteKey: string) => notes[noteKey] ?? "";
|
||||
}
|
||||
|
||||
function useVerdictLabel(): (verdict: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labels: Record<string, string> = {
|
||||
honoured: t("settings.sections.export.import.doseHistory.verdict.honoured"),
|
||||
reported: t("settings.sections.export.import.doseHistory.verdict.reported"),
|
||||
ignored: t("settings.sections.export.import.doseHistory.verdict.ignored"),
|
||||
};
|
||||
return (verdict: string) => labels[verdict] ?? verdict;
|
||||
}
|
||||
|
||||
export function DoseHistoryColumnRulings() {
|
||||
const { t } = useTranslations();
|
||||
const noteFor = useColumnNote();
|
||||
const verdictFor = useVerdictLabel();
|
||||
|
||||
return (
|
||||
<details
|
||||
className="group border-border rounded-md border px-3 py-2"
|
||||
data-testid="dose-history-columns"
|
||||
>
|
||||
<summary className="cursor-pointer text-xs font-medium">
|
||||
{t("settings.sections.export.import.doseHistory.columnsHeading")}
|
||||
</summary>
|
||||
<dl className="mt-2 space-y-2">
|
||||
{AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => (
|
||||
<div key={ruling.column} data-testid="dose-history-column-ruling">
|
||||
<dt className="text-foreground flex flex-wrap items-baseline gap-x-2 text-xs">
|
||||
<code className="font-mono">{ruling.column}</code>
|
||||
<span
|
||||
className="text-muted-foreground text-[0.6875rem] uppercase"
|
||||
data-verdict={ruling.verdict}
|
||||
>
|
||||
{verdictFor(ruling.verdict)}
|
||||
</span>
|
||||
{ruling.jsonOnly && (
|
||||
<span className="text-muted-foreground text-[0.6875rem]">
|
||||
{t("settings.sections.export.import.doseHistory.jsonOnly")}
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{noteFor(ruling.noteKey)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Import a dose history exported from another medication tracker.
|
||||
*
|
||||
* Account-wide, because such a file covers a whole regimen — the per-medication
|
||||
* import dialog on a medication's own page stays what it is, a way to add dates
|
||||
* and times for that one medication.
|
||||
*
|
||||
* Preview first. The dry run returns the entire verdict — how many rows land, how
|
||||
* many are refused and under which reason, which medication names match nothing
|
||||
* on the record — before a single row is written. Importing years of history
|
||||
* unseen is not something to ask of anybody.
|
||||
*
|
||||
* The outcome renders through `IntakeImportResultView`, the same component the
|
||||
* per-medication dialog uses, so a run that wrote nothing cannot carry a success
|
||||
* tick here either and the skip reasons are grouped with a count rather than
|
||||
* repeated per row.
|
||||
*/
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Loader2, PillBottle, Upload } from "lucide-react";
|
||||
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
IntakeImportSkipGroups,
|
||||
type IntakeImportResultState,
|
||||
} from "@/components/medications/intake-import-result";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
MedicationIntakeImportError,
|
||||
waitForMedicationIntakeImport,
|
||||
} from "@/lib/medications/intake-import-poll";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
|
||||
import { DoseHistoryColumnRulings } from "./dose-history-columns";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
import { MAX_PASTE_CHARS } from "./constants";
|
||||
|
||||
/** The file-level verdict, exactly as the route returns it. */
|
||||
interface DoseHistoryFileVerdict {
|
||||
rowsRead: number;
|
||||
queued: number;
|
||||
refused: number;
|
||||
refusedByReason: Array<{ reason: string; count: number }>;
|
||||
unmatchedMedications: string[];
|
||||
ambiguousMedications: string[];
|
||||
mirroredMedications: string[];
|
||||
unknownColumns: string[];
|
||||
codingsNotRead: number;
|
||||
fromArchivedMedications: number;
|
||||
}
|
||||
|
||||
interface DoseHistoryKickoff {
|
||||
dryRun: boolean;
|
||||
jobId: string | null;
|
||||
statusUrl: string | null;
|
||||
file: DoseHistoryFileVerdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain language per machine-readable file-level refusal. Literal `t()` calls so
|
||||
* the i18n call-site guard sees every one.
|
||||
*/
|
||||
function useFatalMessage(): (code: string | undefined) => string {
|
||||
const { t } = useTranslations();
|
||||
const messages: Record<string, string> = {
|
||||
empty_file: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.emptyFile",
|
||||
),
|
||||
missing_required_columns: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.missingColumns",
|
||||
),
|
||||
unreadable_json: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.unreadableJson",
|
||||
),
|
||||
json_not_an_array: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.jsonNotAnArray",
|
||||
),
|
||||
json_carries_no_intake_time: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.jsonNoIntakeTime",
|
||||
),
|
||||
too_many_rows: t(
|
||||
"settings.sections.export.import.doseHistory.fatal.tooManyRows",
|
||||
),
|
||||
};
|
||||
const fallback = t("settings.sections.export.import.doseHistory.failed");
|
||||
return (code: string | undefined) =>
|
||||
(code === undefined ? undefined : messages[code]) ?? fallback;
|
||||
}
|
||||
|
||||
/** `true` when the pasted or loaded text looks like the JSON shape. */
|
||||
function looksLikeJson(text: string): boolean {
|
||||
const head = text.trimStart().charAt(0);
|
||||
return head === "[" || head === "{";
|
||||
}
|
||||
|
||||
export function DoseHistoryImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fatalMessageFor = useFatalMessage();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaId = useId();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [verdict, setVerdict] = useState<DoseHistoryFileVerdict | null>(null);
|
||||
const [previewOnly, setPreviewOnly] = useState(false);
|
||||
const [outcome, setOutcome] = useState<IntakeImportResultState | null>(null);
|
||||
|
||||
async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setVerdict(null);
|
||||
setOutcome(null);
|
||||
try {
|
||||
setText(await file.text());
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.doseHistory.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function send(dryRun: boolean) {
|
||||
setError(null);
|
||||
setVerdict(null);
|
||||
setOutcome(null);
|
||||
if (text.trim().length === 0) {
|
||||
setError(t("settings.sections.export.import.doseHistory.empty"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw(
|
||||
`/api/medications/intake/dose-history-import${dryRun ? "?dryRun=1" : ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": looksLikeJson(text)
|
||||
? "application/json"
|
||||
: "text/csv",
|
||||
},
|
||||
credentials: "include",
|
||||
body: text,
|
||||
},
|
||||
);
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.doseHistory.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setError(t("settings.sections.export.import.doseHistory.tooLarge"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) {
|
||||
setError(t("settings.sections.export.import.doseHistory.inProgress"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 503) {
|
||||
setError(
|
||||
t("settings.sections.export.import.doseHistory.workerUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 422 || res.status === 415) {
|
||||
const body = await res.json().catch(() => null);
|
||||
const code =
|
||||
typeof body?.meta?.errorCode === "string"
|
||||
? body.meta.errorCode
|
||||
: undefined;
|
||||
setError(fatalMessageFor(code));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.doseHistory.failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()).data as DoseHistoryKickoff;
|
||||
setVerdict(data.file);
|
||||
setPreviewOnly(data.dryRun);
|
||||
if (data.dryRun || !data.statusUrl) return;
|
||||
|
||||
const result = await waitForMedicationIntakeImport(data.statusUrl);
|
||||
setOutcome({
|
||||
kind: "outcome",
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
skipReasons: result.skipReasons,
|
||||
});
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
// A failed job carries a sanitised reason; anything else is a transport
|
||||
// failure with nothing to add. Either way the run did not write, and the
|
||||
// card says so rather than leaving the last verdict on screen.
|
||||
setError(
|
||||
err instanceof MedicationIntakeImportError && err.message.length > 0
|
||||
? err.message
|
||||
: t("settings.sections.export.import.doseHistory.failed"),
|
||||
);
|
||||
setVerdict(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-dose-history"
|
||||
icon={PillBottle}
|
||||
title={t("settings.sections.export.import.doseHistory.title")}
|
||||
description={t("settings.sections.export.import.doseHistory.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.doseHistory.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-dose-history-textarea"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_PASTE_CHARS}
|
||||
spellCheck={false}
|
||||
placeholder="Date,Scheduled Date,Medication,…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-xs tabular-nums">
|
||||
{t("settings.sections.export.import.charCount", {
|
||||
used: text.length,
|
||||
max: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
className="sr-only"
|
||||
aria-label={t(
|
||||
"settings.sections.export.import.doseHistory.fileInputLabel",
|
||||
)}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<DoseHistoryColumnRulings />
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{verdict && (
|
||||
<div
|
||||
data-testid="dose-history-verdict"
|
||||
className="space-y-1 text-xs"
|
||||
data-preview={previewOnly ? "true" : "false"}
|
||||
>
|
||||
<p className="text-foreground">
|
||||
{previewOnly
|
||||
? t("settings.sections.export.import.doseHistory.previewLine", {
|
||||
rows: verdict.rowsRead,
|
||||
queued: verdict.queued,
|
||||
refused: verdict.refused,
|
||||
})
|
||||
: t(
|
||||
"settings.sections.export.import.doseHistory.submittedLine",
|
||||
{
|
||||
rows: verdict.rowsRead,
|
||||
queued: verdict.queued,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
{/* The reason lines, never the outcome sentence: a preview has
|
||||
written nothing, and classifying it by the counts it would
|
||||
produce would put a success tick on a run that never ran. */}
|
||||
<IntakeImportSkipGroups
|
||||
groups={verdict.refusedByReason}
|
||||
testId="dose-history-refusal-groups"
|
||||
/>
|
||||
{verdict.unmatchedMedications.length > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground"
|
||||
data-testid="dose-history-unmatched"
|
||||
>
|
||||
{t("settings.sections.export.import.doseHistory.unmatched", {
|
||||
names: verdict.unmatchedMedications.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{verdict.ambiguousMedications.length > 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.sections.export.import.doseHistory.ambiguous", {
|
||||
names: verdict.ambiguousMedications.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{verdict.mirroredMedications.length > 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.sections.export.import.doseHistory.mirrored", {
|
||||
names: verdict.mirroredMedications.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{verdict.unknownColumns.length > 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"settings.sections.export.import.doseHistory.unknownColumns",
|
||||
{ names: verdict.unknownColumns.join(", ") },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{verdict.codingsNotRead > 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"settings.sections.export.import.doseHistory.codingsNotRead",
|
||||
{
|
||||
count: verdict.codingsNotRead,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{verdict.fromArchivedMedications > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground"
|
||||
data-testid="dose-history-archived"
|
||||
>
|
||||
{t("settings.sections.export.import.doseHistory.archivedRows", {
|
||||
count: verdict.fromArchivedMedications,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{outcome && <IntakeImportResultView result={outcome} />}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-dose-history-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.doseHistory.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(true)}
|
||||
data-testid="import-dose-history-preview"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sections.export.import.doseHistory.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(false)}
|
||||
data-testid="import-action-dose-history"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PillBottle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.doseHistory.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AppleHealthImportCard } from "./apple-health-import-card";
|
||||
import { JsonImportCard } from "./json-import-card";
|
||||
import { CsvImportCard } from "./csv-import-card";
|
||||
import { DoseHistoryImportCard } from "./dose-history-import-card";
|
||||
|
||||
export {
|
||||
EXAMPLE_IMPORT,
|
||||
@@ -50,6 +51,7 @@ export function ImportPanel() {
|
||||
<AppleHealthImportCard />
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
<DoseHistoryImportCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -365,9 +365,9 @@ interface ProcessChunkOutcome {
|
||||
* what to recompute — two medications touched on the same day are two rollup
|
||||
* rows. The medication is folded in ahead of a single space, which occurs in
|
||||
* neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted
|
||||
* in the progress blob and Postgres rejects ` | ||||