From d26bbca52a508daa936f0403911db161e97bc78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 01:55:46 +0200 Subject: [PATCH] fix(import): translate machine-readable import failure reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user whose import died to a worker restart saw the literal string interrupted_by_restart in the import card — the reconcile writes it as a machine-readable code, and the card echoed it raw. Classify the two coded reasons (interrupted_by_restart, the insufficient_memory prefix from the memory preflight) onto translated, actionable copy across all six locales; the copy tells the user their data is safe and to upload the export again, which the re-enabled card already supports. Honest free-text reasons keep passing through verbatim. Refs #775. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../settings/__tests__/import-panel.test.tsx | 39 ++++++++++++++++++- .../import-panel/apple-health-import-card.tsx | 37 +++++++++++++++++- 8 files changed, 86 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index d0ee6aac6..1074a011b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4984,6 +4984,8 @@ "rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.", "tooLarge": "Diese Datei ist zu groß (Grenze 1,5 GB).", "failed": "Der Import ist fehlgeschlagen.", + "failureInterrupted": "Der Import wurde unterbrochen, bevor er fertig war, meist durch einen Server-Neustart oder Speichermangel. Die Daten sind sicher; das Archiv einfach erneut hochladen.", + "failureInsufficientMemory": "Das Speicherlimit des Servers ist für ein Archiv dieser Größe zu niedrig. Bitte die Betreiberin oder den Betreiber das Node.js-Heap-Limit erhöhen lassen (NODE_OPTIONS=--max-old-space-size) und das Archiv danach erneut hochladen.", "rowsImported": "Bisher {count} Einträge importiert", "doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", "doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.", diff --git a/messages/en.json b/messages/en.json index 561c0900f..41ef420be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4984,6 +4984,8 @@ "rateLimited": "Too many uploads. Please wait a moment and try again.", "tooLarge": "That file is too large (limit 1.5 GB).", "failed": "The import failed.", + "failureInterrupted": "The import was interrupted before it finished, usually by a server restart or an out-of-memory stop. Your data is safe; upload the export again to retry.", + "failureInsufficientMemory": "The server's memory limit is too low for an archive this size. Ask whoever runs the server to raise the Node.js heap limit (NODE_OPTIONS=--max-old-space-size), then upload the export again.", "rowsImported": "{count} rows imported so far", "doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.", "doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.", diff --git a/messages/es.json b/messages/es.json index 31f3bf89a..3f0b9f45b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4984,6 +4984,8 @@ "rateLimited": "Demasiadas subidas. Espera un momento e inténtalo de nuevo.", "tooLarge": "El archivo es demasiado grande (límite 1,5 GB).", "failed": "La importación falló.", + "failureInterrupted": "La importación se interrumpió antes de terminar, normalmente por un reinicio del servidor o por falta de memoria. Tus datos están a salvo; vuelve a subir el archivo para reintentarlo.", + "failureInsufficientMemory": "El límite de memoria del servidor es demasiado bajo para un archivo de este tamaño. Pide a quien administra el servidor que aumente el límite de memoria de Node.js (NODE_OPTIONS=--max-old-space-size) y vuelve a subir el archivo.", "rowsImported": "{count} filas importadas hasta ahora", "doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.", "doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.", diff --git a/messages/fr.json b/messages/fr.json index 4f49b35d4..9cfc6862e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4984,6 +4984,8 @@ "rateLimited": "Trop d'envois. Patientez un instant et réessayez.", "tooLarge": "Ce fichier est trop volumineux (limite 1,5 Go).", "failed": "L'import a échoué.", + "failureInterrupted": "L'import a été interrompu avant la fin, généralement par un redémarrage du serveur ou un manque de mémoire. Vos données sont en sécurité ; renvoyez l'archive pour réessayer.", + "failureInsufficientMemory": "La limite de mémoire du serveur est trop basse pour une archive de cette taille. Demandez à la personne qui administre le serveur d'augmenter la limite de mémoire de Node.js (NODE_OPTIONS=--max-old-space-size), puis renvoyez l'archive.", "rowsImported": "{count} lignes importées jusqu'ici", "doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", "doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.", diff --git a/messages/it.json b/messages/it.json index f4cbc1387..f97446385 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4984,6 +4984,8 @@ "rateLimited": "Troppi caricamenti. Attendi un momento e riprova.", "tooLarge": "Il file è troppo grande (limite 1,5 GB).", "failed": "L'importazione non è riuscita.", + "failureInterrupted": "L'importazione è stata interrotta prima di finire, di solito per un riavvio del server o per memoria esaurita. I dati sono al sicuro; ricarica l'archivio per riprovare.", + "failureInsufficientMemory": "Il limite di memoria del server è troppo basso per un archivio di queste dimensioni. Chiedi a chi gestisce il server di aumentare il limite di memoria di Node.js (NODE_OPTIONS=--max-old-space-size), poi ricarica l'archivio.", "rowsImported": "{count} righe importate finora", "doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.", "doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.", diff --git a/messages/pl.json b/messages/pl.json index 683cb4607..e2f598d4d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4984,6 +4984,8 @@ "rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.", "tooLarge": "Ten plik jest za duży (limit 1,5 GB).", "failed": "Import nie powiódł się.", + "failureInterrupted": "Import został przerwany przed zakończeniem, zwykle przez restart serwera lub brak pamięci. Dane są bezpieczne; prześlij archiwum ponownie, aby spróbować jeszcze raz.", + "failureInsufficientMemory": "Limit pamięci serwera jest zbyt niski dla archiwum tej wielkości. Poproś osobę administrującą serwerem o zwiększenie limitu pamięci Node.js (NODE_OPTIONS=--max-old-space-size), a następnie prześlij archiwum ponownie.", "rowsImported": "Zaimportowano dotąd {count} wierszy", "doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", "doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.", diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index e4a1b50a6..c2a1be65d 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -27,7 +27,10 @@ vi.mock("next/navigation", () => ({ })); import type { Locale } from "@/lib/i18n/config"; -import { AppleHealthEstimateWarning } from "../import-panel/apple-health-import-card"; +import { + AppleHealthEstimateWarning, + appleHealthFailureKind, +} from "../import-panel/apple-health-import-card"; import { I18nProvider } from "@/lib/i18n/context"; import { ImportPanel, @@ -116,6 +119,40 @@ describe("", () => { ); }); +// (issue #775) — the worker writes two machine-readable failure +// reasons (the reconcile's `interrupted_by_restart` code and the memory +// preflight's `insufficient_memory` prefix). The card must translate +// them instead of echoing a raw code at the user; honest free-text +// reasons keep passing through verbatim. +describe("appleHealthFailureKind", () => { + it("maps the reconcile code onto translated copy", () => { + expect(appleHealthFailureKind("interrupted_by_restart")).toBe( + "interrupted", + ); + }); + + it("maps the memory-preflight refusal onto translated copy", () => { + expect( + appleHealthFailureKind( + "insufficient_memory: export.xml declares 8192 MiB uncompressed; " + + "parsing it needs roughly 320 MiB of heap but the Node.js heap " + + "limit is 256 MiB.", + ), + ).toBe("insufficientMemory"); + }); + + it("passes honest free-text reasons through verbatim", () => { + expect( + appleHealthFailureKind("Import staging file is no longer available"), + ).toBe("raw"); + }); + + it("returns null when the job has no failure reason", () => { + expect(appleHealthFailureKind(null)).toBeNull(); + expect(appleHealthFailureKind("")).toBeNull(); + }); +}); + describe("EXAMPLE_IMPORT payload", () => { it("round-trips through JSON and stays parseable", () => { const serialised = JSON.stringify(EXAMPLE_IMPORT, null, 2); diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index 32b3577ce..fd4e13d97 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -42,6 +42,24 @@ interface JobStatus { failureReason: string | null; } +/** + * Classify a worker `failureReason` for display. The worker writes two + * machine-readable reasons — the reconcile's bare `interrupted_by_restart` + * code and the memory preflight's `insufficient_memory`-prefixed string + * (issue #775) — which must surface as translated, actionable copy + * instead of a raw code. Anything else is honest English free text from + * the parse and passes through verbatim. Exported for the SSR contract + * suite. + */ +export function appleHealthFailureKind( + reason: string | null, +): "interrupted" | "insufficientMemory" | "raw" | null { + if (reason === null || reason.length === 0) return null; + if (reason === "interrupted_by_restart") return "interrupted"; + if (reason.startsWith("insufficient_memory")) return "insufficientMemory"; + return "raw"; +} + export function AppleHealthEstimateWarning({ days }: { days: number }) { const { t } = useTranslations(); return ( @@ -160,6 +178,23 @@ export function AppleHealthImportCard() { const isFailed = status?.status === "failed"; const busy = uploading || isRunning; const percent = status?.progress?.percent ?? null; + const failureText = (() => { + const reason = status?.failureReason ?? null; + switch (appleHealthFailureKind(reason)) { + case "interrupted": + return t( + "settings.sections.export.import.appleHealth.failureInterrupted", + ); + case "insufficientMemory": + return t( + "settings.sections.export.import.appleHealth.failureInsufficientMemory", + ); + case "raw": + return reason; + default: + return null; + } + })(); const ecg = status?.result?.ecg; const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0); @@ -302,7 +337,7 @@ export function AppleHealthImportCard() { /> {uploadError ?? - status?.failureReason ?? + failureText ?? t("settings.sections.export.import.appleHealth.failed")}