fix(import): translate machine-readable import failure reasons

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 06:16:44 +02:00
1 parent 50ff90a409
commit d26bbca52a
8 files changed
+86 -2

No files matched your search

@@ -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("<AppleHealthEstimateWarning>", () => {
);
});
// (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);
@@ -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() {
/>
<span>
{uploadError ??
status?.failureReason ??
failureText ??
t("settings.sections.export.import.appleHealth.failed")}
</span>
</p>