mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(import): add CSV measurement import with per-row status and dry-run
A self-hoster migrating from a spreadsheet or a meter export overwhelmingly has CSV, which neither the JSON nor the Apple Health import covers. Add `POST /api/import/csv` reusing the existing write loop and rollup re-fold. The documented header is `type,value,unit,measuredAt[,glucoseContext,notes, externalId]`, order-independent. measuredAt must carry an explicit ISO-8601 offset — a row without one is skipped rather than guessed — and is bounded by the shared entry-instant validator. Glucose accepts mmol/L (converted to canonical mg/dL) and weight accepts lb (converted to kg); any other non-canonical unit is skipped, never mis-stored. An optional externalId column makes a re-upload idempotent; without it a re-upload duplicates. The response carries a per-row status envelope so a 2000-row file with three bad rows imports 1997 and names the three. `?dryRun=1` validates and previews the outcome without writing. The parse, validation, and unit conversion live in a pure module so they are unit-tested without a database. Settings -> Export & Import gains a third CSV card mirroring the JSON and Apple Health cards, with upload, paste, preview, and a downloadable example. The new route is documented in the OpenAPI contract.
This commit is contained in:
1 parent
82e2d7dfb1
commit
fbdcb31202
15 files changed
+1666
-2
No files matched your search
@@ -27,7 +27,13 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ImportPanel, EXAMPLE_IMPORT, parseImportJson } from "../import-panel";
|
||||
import {
|
||||
ImportPanel,
|
||||
EXAMPLE_IMPORT,
|
||||
EXAMPLE_CSV,
|
||||
parseImportJson,
|
||||
} from "../import-panel";
|
||||
import { parseCsvMeasurements } from "@/lib/import/csv-measurements";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -46,6 +52,7 @@ describe("<ImportPanel> — SSR smoke", () => {
|
||||
expect(html).toContain('id="settings-section-import-title"');
|
||||
expect(html).toContain('data-testid="import-card-apple-health"');
|
||||
expect(html).toContain('data-testid="import-card-json"');
|
||||
expect(html).toContain('data-testid="import-card-csv"');
|
||||
// Raw i18n keys never leak past the provider.
|
||||
expect(html).not.toContain("settings.sections.export.import.");
|
||||
});
|
||||
@@ -58,6 +65,11 @@ describe("<ImportPanel> — SSR smoke", () => {
|
||||
"import-json-choose-file",
|
||||
"import-json-download-example",
|
||||
"import-action-json",
|
||||
"import-csv-textarea",
|
||||
"import-csv-choose-file",
|
||||
"import-csv-download-example",
|
||||
"import-csv-preview",
|
||||
"import-action-csv",
|
||||
]) {
|
||||
expect(html).toContain(`data-testid="${id}"`);
|
||||
}
|
||||
@@ -112,6 +124,19 @@ describe("EXAMPLE_IMPORT payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXAMPLE_CSV", () => {
|
||||
it("parses entirely to ok rows (example must not drift from the schema)", () => {
|
||||
// Pin the clock well past the fixture timestamps so the entry-instant
|
||||
// bound never rejects the example as future-dated.
|
||||
const out = parseCsvMeasurements(EXAMPLE_CSV, {
|
||||
now: new Date("2026-06-01T00:00:00Z").getTime(),
|
||||
});
|
||||
expect(out.fatal).toBeUndefined();
|
||||
expect(out.rows.length).toBeGreaterThan(0);
|
||||
expect(out.rows.every((r) => r.status === "ok")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImportJson guard", () => {
|
||||
it("rejects an unparseable string before any network call", () => {
|
||||
const result = parseImportJson("{ not json");
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
@@ -68,6 +69,7 @@ export function ImportPanel() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppleHealthImportCard />
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -630,3 +632,254 @@ function JsonImportCard() {
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── CSV import ───────────────────────────
|
||||
|
||||
/**
|
||||
* Downloadable CSV example. Header is order-independent server-side, but the
|
||||
* example pins the documented order so the docs + the route schema never
|
||||
* drift. Exported so a test can assert it stays a valid header.
|
||||
*/
|
||||
export const EXAMPLE_CSV = [
|
||||
"type,value,unit,measuredAt,glucoseContext,notes,externalId",
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
|
||||
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
|
||||
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
|
||||
].join("\n");
|
||||
|
||||
interface CsvRowResult {
|
||||
line: number;
|
||||
status: "inserted" | "updated" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface CsvImportResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
dryRun: boolean;
|
||||
rows: CsvRowResult[];
|
||||
}
|
||||
|
||||
function CsvImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CsvImportResult | null>(null);
|
||||
const textareaId = useId();
|
||||
|
||||
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExample() {
|
||||
const blob = new Blob([EXAMPLE_CSV], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-import-example.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function send(dryRun: boolean) {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (text.trim().length === 0) {
|
||||
setError(t("settings.sections.export.import.csv.empty"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw(`/api/import/csv${dryRun ? "?dryRun=1" : ""}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
credentials: "include",
|
||||
body: text,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.csv.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setError(t("settings.sections.export.import.csv.tooLarge"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 422) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setError(
|
||||
body?.error ??
|
||||
t("settings.sections.export.import.csv.invalidPayload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()).data as CsvImportResult;
|
||||
setResult(data);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
icon={FileSpreadsheet}
|
||||
title={t("settings.sections.export.import.csv.title")}
|
||||
description={t("settings.sections.export.import.csv.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.csv.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-csv-textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
placeholder="type,value,unit,measuredAt,…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="sr-only"
|
||||
aria-label={t("settings.sections.export.import.csv.fileInputLabel")}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.csv.schemaHint")}{" "}
|
||||
<a
|
||||
href="/docs/integrations/data-import"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.export.import.csv.docsLink")}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<p className="text-foreground flex items-start gap-2 text-xs">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{result.dryRun
|
||||
? t("settings.sections.export.import.csv.previewSummary", {
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
: t("settings.sections.export.import.csv.resultSummary", {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{errorRows.length > 0 && (
|
||||
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
|
||||
{errorRows.slice(0, 50).map((r) => (
|
||||
<li key={r.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: r.line,
|
||||
reason: r.reason ?? "skipped",
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-csv-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadExample}
|
||||
data-testid="import-csv-download-example"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.downloadExample")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(true)}
|
||||
data-testid="import-csv-preview"
|
||||
>
|
||||
{t("settings.sections.export.import.csv.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(false)}
|
||||
data-testid="import-action-csv"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user