mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user