feat(import): carry every field the app's own files write

The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.

The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.

json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 21:33:06 +02:00
1 parent 43002d37e8
commit a840e30fac
31 files changed
+1814 -508

No files matched your search

@@ -0,0 +1,54 @@
/**
* What the JSON import card says once a file is in.
*
* SSR-only, like the sibling card tests: the markup each result renders. The
* claim worth pinning is the one the counts cannot make. A mood entry whose
* tag the account does not have lands without that link, and the card names
* the tag rather than reporting a clean import.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
JsonImportResultView,
readJsonImportResult,
} from "../json-import-card";
function render(data: Parameters<typeof readJsonImportResult>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<JsonImportResultView result={readJsonImportResult(data)} />
</I18nProvider>,
);
}
describe("<JsonImportResultView>", () => {
it("names the tags an import could not link", () => {
const html = render({
measurements: 3,
moodEntries: 2,
skipped: 0,
droppedTagKeys: ["custom:long_walk"],
droppedFactorKeys: ["retired_factor"],
});
expect(html).toContain('data-testid="import-json-dropped-links"');
expect(html).toContain("custom:long_walk, retired_factor");
expect(html).toContain("Imported 3 measurements and 2 mood entries");
});
it("says nothing about links when every one landed", () => {
const html = render({ measurements: 3, moodEntries: 2, skipped: 0 });
expect(html).not.toContain("import-json-dropped-links");
});
it("reads a missing count as none", () => {
expect(readJsonImportResult(null)).toEqual({
measurements: 0,
moodEntries: 0,
skipped: 0,
droppedLinkKeys: [],
});
});
});
@@ -18,45 +18,90 @@ import {
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
interface JsonImportResult {
export interface JsonImportResult {
measurements: number;
moodEntries: number;
skipped: number;
/**
* Tag and factor keys of imported mood entries this account has no tag
* for (another account's own tags, mostly). The entries landed without
* those links, which the count alone would not say.
*/
droppedLinkKeys: string[];
}
export function JsonImportCard() {
/** The route's `data`, read field by field; a missing count reads as none. */
export function readJsonImportResult(
data: {
measurements?: number;
moodEntries?: number;
skipped?: number;
droppedTagKeys?: string[];
droppedFactorKeys?: string[];
} | null,
): JsonImportResult {
return {
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
droppedLinkKeys: [
...(data?.droppedTagKeys ?? []),
...(data?.droppedFactorKeys ?? []),
],
};
}
/** What an import wrote, and the links it could not make. */
export function JsonImportResultView({ result }: { result: JsonImportResult }) {
const { t } = useTranslations();
// Same reasoning as the CSV card: the JSON route answers 200 with a per-
// concept count envelope, so a payload where every entry was refused used
// to render the success tick. The counts decide the tone.
const jsonOutcome = (r: JsonImportResult) =>
classifyWrittenOutcome({
written: r.measurements + r.moodEntries,
skipped: r.skipped,
});
const jsonMessage = (r: JsonImportResult) => {
const outcome = jsonOutcome(r);
if (outcome === "empty") {
return t("settings.sections.export.import.json.resultEmpty");
}
if (outcome === "failed") {
return t("settings.sections.export.import.json.resultNothing", {
skipped: r.skipped,
});
}
if (outcome === "partial") {
return t("settings.sections.export.import.json.resultSummary", {
measurements: r.measurements,
moods: r.moodEntries,
skipped: r.skipped,
});
}
return t("settings.sections.export.import.json.resultSuccess", {
measurements: r.measurements,
moods: r.moodEntries,
});
};
const outcome = classifyWrittenOutcome({
written: result.measurements + result.moodEntries,
skipped: result.skipped,
});
const message =
outcome === "empty"
? t("settings.sections.export.import.json.resultEmpty")
: outcome === "failed"
? t("settings.sections.export.import.json.resultNothing", {
skipped: result.skipped,
})
: outcome === "partial"
? t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})
: t("settings.sections.export.import.json.resultSuccess", {
measurements: result.measurements,
moods: result.moodEntries,
});
return (
<>
<WrittenOutcomeLine
outcome={outcome}
message={message}
testId="import-json-result"
/>
{result.droppedLinkKeys.length > 0 && (
<p
className="text-muted-foreground text-xs"
data-testid="import-json-dropped-links"
>
{t("settings.sections.export.import.json.droppedLinks", {
keys: result.droppedLinkKeys.join(", "),
})}
</p>
)}
</>
);
}
export function JsonImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
@@ -112,12 +157,7 @@ export function JsonImportCard() {
setError(t("settings.sections.export.import.json.failed"));
return;
}
const data = (await res.json()).data as JsonImportResult;
setResult({
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
});
setResult(readJsonImportResult((await res.json()).data ?? null));
} catch {
setError(t("settings.sections.export.import.json.failed"));
} finally {
@@ -180,13 +220,7 @@ export function JsonImportCard() {
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<WrittenOutcomeLine
outcome={jsonOutcome(result)}
message={jsonMessage(result)}
testId="import-json-result"
/>
)}
{result && <JsonImportResultView result={result} />}
{error && (
<p
role="alert"