mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(settings): map and check a lab or medication CSV before importing
Settings > Export & Import gains a card that reads the head of the file, suggests the column for each field from its header in eight languages, suggests the separator, decimal mark and date order, and lets every choice be corrected. The check lists each refused row by line and the markers the names land on; the import button names how many rows it will write. Templates for both kinds download from the sheet.
This commit is contained in:
1 parent
a84fb4b467
commit
121588e3dc
13 files changed
+1889
No files matched your search
@@ -14,6 +14,9 @@
|
||||
* (file/paste, client-side parseability guard).
|
||||
* 3. CSV import — `csv-import-card.tsx`
|
||||
* (file/paste, dry-run preview).
|
||||
* 4. Lab results and medications from a spreadsheet —
|
||||
* `record-csv-import-card.tsx` (v1.44, #1215; column mapping, check,
|
||||
* import).
|
||||
*
|
||||
* Both controls surface rate-limit (429) and error states cleanly and
|
||||
* announce progress to assistive tech via `aria-live` regions.
|
||||
@@ -28,6 +31,7 @@ import { HealthConnectImportCard } from "./health-connect-import-card";
|
||||
import { JsonImportCard } from "./json-import-card";
|
||||
import { CsvImportCard } from "./csv-import-card";
|
||||
import { DoseHistoryImportCard } from "./dose-history-import-card";
|
||||
import { RecordCsvImportCard } from "./record-csv-import-card";
|
||||
|
||||
export {
|
||||
EXAMPLE_IMPORT,
|
||||
@@ -55,6 +59,7 @@ export function ImportPanel() {
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
<DoseHistoryImportCard />
|
||||
<RecordCsvImportCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { apiFetch, ApiError } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
RECORD_IMPORT_TEMPLATES,
|
||||
fieldsFor,
|
||||
guessFormat,
|
||||
guessMapping,
|
||||
readHead,
|
||||
type FieldKey,
|
||||
type FileHead,
|
||||
type RecordImportKind,
|
||||
} from "@/lib/import/csv-mapping";
|
||||
import type { RecordImportResult } from "@/lib/validations/record-import";
|
||||
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
|
||||
/** The head of the file the mapping screen reads: enough for six rows. */
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type Delimiter = "comma" | "semicolon" | "tab";
|
||||
|
||||
function delimiterName(d: string): Delimiter {
|
||||
return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma";
|
||||
}
|
||||
|
||||
function downloadTemplate(kind: RecordImportKind) {
|
||||
const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-${kind}-template.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings > Export & Import: lab results and medications from a spreadsheet
|
||||
* (#1215). The card opens a sheet that reads the head of the file, suggests
|
||||
* the column for each field, lets the person correct every choice, checks
|
||||
* the whole file on the server (nothing written) and then imports it.
|
||||
*/
|
||||
export function RecordCsvImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState<RecordImportKind | null>(null);
|
||||
const [session, setSession] = useState(0);
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-records-csv"
|
||||
anchor="import-records-csv"
|
||||
icon={FileSpreadsheet}
|
||||
title={t("recordImport.title")}
|
||||
description={t("recordImport.description")}
|
||||
>
|
||||
<p className="text-sm">{t("recordImport.detail")}</p>
|
||||
<SettingsCardActions align="start">
|
||||
{(["labs", "medications"] as const).map((kind) => (
|
||||
<Button
|
||||
key={kind}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot={`record-import-open-${kind}`}
|
||||
onClick={() => {
|
||||
setSession((n) => n + 1);
|
||||
setOpen(kind);
|
||||
}}
|
||||
>
|
||||
{kind === "labs" ? (
|
||||
<FlaskConical className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Pill className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t(`recordImport.kind.${kind}`)}
|
||||
</Button>
|
||||
))}
|
||||
</SettingsCardActions>
|
||||
{open ? (
|
||||
<RecordCsvImportSheet
|
||||
key={session}
|
||||
kind={open}
|
||||
onOpenChange={(next) => !next && setOpen(null)}
|
||||
/>
|
||||
) : null}
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCsvImportSheet({
|
||||
kind,
|
||||
onOpenChange,
|
||||
}: {
|
||||
kind: RecordImportKind;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const fileId = useId();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [head, setHead] = useState<FileHead | null>(null);
|
||||
const [mapping, setMapping] = useState<Partial<Record<FieldKey, number>>>({});
|
||||
const [delimiter, setDelimiter] = useState<Delimiter>("comma");
|
||||
const [decimal, setDecimal] = useState<"dot" | "comma">("dot");
|
||||
const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso");
|
||||
const [header, setHeader] = useState(true);
|
||||
const [reminders, setReminders] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RecordImportResult | null>(null);
|
||||
const [done, setDone] = useState<RecordImportResult | null>(null);
|
||||
|
||||
const fields = fieldsFor(kind);
|
||||
const columns = useMemo(() => {
|
||||
if (!head) return [] as string[];
|
||||
const width = Math.max(...head.rows.map((r) => r.length), 0);
|
||||
return Array.from({ length: width }, (_, i) =>
|
||||
header && head.rows[0]?.[i]?.trim()
|
||||
? head.rows[0][i].trim()
|
||||
: t("recordImport.column", { n: i + 1 }),
|
||||
);
|
||||
}, [head, header, t]);
|
||||
const missing = fields.filter(
|
||||
(f) => f.required && mapping[f.key] === undefined,
|
||||
);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = e.target.files?.[0] ?? null;
|
||||
e.target.value = "";
|
||||
setCheck(null);
|
||||
setDone(null);
|
||||
setError(null);
|
||||
if (!picked) return;
|
||||
if (picked.size > MAX_FILE_BYTES) {
|
||||
setError(t("recordImport.error.tooLarge"));
|
||||
return;
|
||||
}
|
||||
const text = await picked.slice(0, HEAD_BYTES).text();
|
||||
const parsedHead = readHead(text);
|
||||
const guessed = guessMapping(parsedHead.rows[0] ?? [], kind);
|
||||
const format = guessFormat(
|
||||
parsedHead,
|
||||
guessed[kind === "labs" ? "date" : "startsOn"],
|
||||
);
|
||||
setFile(picked);
|
||||
setHead(parsedHead);
|
||||
setMapping(guessed);
|
||||
setDelimiter(delimiterName(parsedHead.delimiter));
|
||||
setDecimal(format.decimal);
|
||||
setDateOrder(format.dateOrder);
|
||||
}
|
||||
|
||||
async function send(dryRun: boolean) {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({
|
||||
dryRun: dryRun ? "1" : "0",
|
||||
header: header ? "1" : "0",
|
||||
delimiter,
|
||||
decimal,
|
||||
dateOrder,
|
||||
});
|
||||
if (kind === "medications") params.set("reminders", reminders ? "1" : "0");
|
||||
for (const f of fields) {
|
||||
const index = mapping[f.key];
|
||||
if (index !== undefined) params.set(f.key, String(index));
|
||||
}
|
||||
try {
|
||||
const result = await apiFetch<RecordImportResult>(
|
||||
`/api/import/${kind}-csv?${params}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
body: file,
|
||||
// A large file is read and written row by row; give it time.
|
||||
signal: AbortSignal.timeout(5 * 60_000),
|
||||
},
|
||||
);
|
||||
if (dryRun) setCheck(result);
|
||||
else setDone(result);
|
||||
} catch (err) {
|
||||
const status = err instanceof ApiError ? err.status : 0;
|
||||
setError(
|
||||
status === 429
|
||||
? t("recordImport.error.rateLimited")
|
||||
: status === 413
|
||||
? t("recordImport.error.tooLarge")
|
||||
: status === 422
|
||||
? t("recordImport.error.invalid")
|
||||
: t("recordImport.error.failed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const result = done ?? check;
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(`recordImport.sheetTitle.${kind}`)}
|
||||
description={t(`recordImport.sheetDescription.${kind}`)}
|
||||
footer={
|
||||
done ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("recordImport.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={!file || busy || missing.length > 0}
|
||||
data-slot="record-import-check"
|
||||
onClick={() => send(true)}
|
||||
>
|
||||
{t("recordImport.check")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={!check || busy || check.inserted === 0}
|
||||
data-slot="record-import-run"
|
||||
onClick={() => send(false)}
|
||||
>
|
||||
{check
|
||||
? t("recordImport.run", { count: fmt.integer(check.inserted) })
|
||||
: t("recordImport.runIdle")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="space-y-4"
|
||||
data-slot="record-import-sheet"
|
||||
data-kind={kind}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={fileId} className="sr-only">
|
||||
{t("recordImport.file")}
|
||||
</Label>
|
||||
<input
|
||||
id={fileId}
|
||||
type="file"
|
||||
accept=".csv,text/csv,text/plain"
|
||||
data-slot="record-import-file"
|
||||
className="text-sm"
|
||||
onChange={onFile}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="record-import-template"
|
||||
onClick={() => downloadTemplate(kind)}
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
{t("recordImport.template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{head ? (
|
||||
<>
|
||||
<fieldset
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
data-slot="record-import-mapping"
|
||||
>
|
||||
<legend className="mb-2 text-sm font-medium">
|
||||
{t("recordImport.mapping")}
|
||||
</legend>
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="grid gap-1.5">
|
||||
<Label htmlFor={`map-${f.key}`}>
|
||||
{t(`recordImport.field.${f.key}`)}
|
||||
{f.required ? ` ${t("recordImport.required")}` : ""}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`map-${f.key}`}
|
||||
data-slot={`record-import-map-${f.key}`}
|
||||
value={
|
||||
mapping[f.key] === undefined ? "" : String(mapping[f.key])
|
||||
}
|
||||
onChange={(e) => {
|
||||
setCheck(null);
|
||||
const v = e.target.value;
|
||||
setMapping((m) => {
|
||||
const next = { ...m };
|
||||
// One column feeds one field: taking it here frees it elsewhere.
|
||||
for (const k of Object.keys(next) as FieldKey[]) {
|
||||
if (v !== "" && next[k] === Number(v)) delete next[k];
|
||||
}
|
||||
if (v === "") delete next[f.key];
|
||||
else next[f.key] = Number(v);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t("recordImport.notInFile")}</option>
|
||||
{columns.map((name, i) => (
|
||||
<option key={i} value={String(i)}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="record-import-delimiter">
|
||||
{t("recordImport.delimiter")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="record-import-delimiter"
|
||||
value={delimiter}
|
||||
onChange={(e) => {
|
||||
setCheck(null);
|
||||
setDelimiter(e.target.value as Delimiter);
|
||||
}}
|
||||
>
|
||||
{(["comma", "semicolon", "tab"] as const).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{t(`recordImport.delimiterOption.${d}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="record-import-decimal">
|
||||
{t("recordImport.decimal")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="record-import-decimal"
|
||||
value={decimal}
|
||||
onChange={(e) => {
|
||||
setCheck(null);
|
||||
setDecimal(e.target.value as "dot" | "comma");
|
||||
}}
|
||||
>
|
||||
<option value="dot">
|
||||
{t("recordImport.decimalOption.dot")}
|
||||
</option>
|
||||
<option value="comma">
|
||||
{t("recordImport.decimalOption.comma")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="record-import-dates">
|
||||
{t("recordImport.dateOrder")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="record-import-dates"
|
||||
value={dateOrder}
|
||||
onChange={(e) => {
|
||||
setCheck(null);
|
||||
setDateOrder(e.target.value as "iso" | "dmy" | "mdy");
|
||||
}}
|
||||
>
|
||||
{(["iso", "dmy", "mdy"] as const).map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{t(`recordImport.dateOrderOption.${o}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="record-import-header">
|
||||
{t("recordImport.header")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="record-import-header"
|
||||
checked={header}
|
||||
onCheckedChange={(v) => {
|
||||
setCheck(null);
|
||||
setHeader(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{kind === "medications" ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="record-import-reminders">
|
||||
{t("recordImport.reminders")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="record-import-reminders"
|
||||
checked={reminders}
|
||||
onCheckedChange={(v) => {
|
||||
setCheck(null);
|
||||
setReminders(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
|
||||
data-slot="record-import-head"
|
||||
>
|
||||
<table className="w-full text-xs">
|
||||
<tbody className="divide-border divide-y">
|
||||
{head.rows.map((row, r) => (
|
||||
<tr
|
||||
key={r}
|
||||
className={
|
||||
r === 0 && header ? "bg-muted font-medium" : undefined
|
||||
}
|
||||
>
|
||||
{row.map((c, i) => (
|
||||
<td
|
||||
key={i}
|
||||
className="max-w-40 truncate p-2 whitespace-nowrap"
|
||||
>
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{missing.length > 0 && head ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="record-import-missing"
|
||||
>
|
||||
{t("recordImport.missing", {
|
||||
fields: missing
|
||||
.map((f) => t(`recordImport.field.${f.key}`))
|
||||
.join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="record-import-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<div
|
||||
className="space-y-3"
|
||||
data-slot={done ? "record-import-done" : "record-import-checked"}
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{done
|
||||
? t("recordImport.summaryDone", {
|
||||
inserted: fmt.integer(result.inserted),
|
||||
duplicates: fmt.integer(result.duplicates),
|
||||
failed: fmt.integer(result.failed),
|
||||
})
|
||||
: t("recordImport.summaryCheck", {
|
||||
rows: fmt.integer(result.rows),
|
||||
inserted: fmt.integer(result.inserted),
|
||||
duplicates: fmt.integer(result.duplicates),
|
||||
failed: fmt.integer(result.failed),
|
||||
})}
|
||||
</p>
|
||||
{result.markers &&
|
||||
(result.markers.created.length > 0 ||
|
||||
result.markers.matched.length > 0) ? (
|
||||
<div className="text-xs" data-slot="record-import-markers">
|
||||
{result.markers.matched.length > 0 ? (
|
||||
<p>
|
||||
<span className="text-muted-foreground">
|
||||
{t("recordImport.markersMatched")}{" "}
|
||||
</span>
|
||||
{result.markers.matched
|
||||
.map((m) =>
|
||||
m.name === m.marker
|
||||
? m.name
|
||||
: `${m.name} (${m.marker})`,
|
||||
)
|
||||
.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
{result.markers.created.length > 0 ? (
|
||||
<p>
|
||||
<span className="text-muted-foreground">
|
||||
{t("recordImport.markersCreated")}{" "}
|
||||
</span>
|
||||
{result.markers.created.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{result.errors.length > 0 ? (
|
||||
<ul
|
||||
className="max-h-48 divide-y overflow-y-auto overscroll-contain rounded-lg border text-xs"
|
||||
data-slot="record-import-errors"
|
||||
>
|
||||
{result.errors.map((e) => (
|
||||
<li
|
||||
key={`${e.line}-${e.reason}`}
|
||||
className="flex gap-3 p-2"
|
||||
data-slot="record-import-error-row"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||
{t("recordImport.line", { line: fmt.integer(e.line) })}
|
||||
</span>
|
||||
<span>
|
||||
{t(`recordImport.reason.${e.reason}`)}
|
||||
{e.field
|
||||
? ` (${t(`recordImport.field.${e.field}`)})`
|
||||
: ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{result.errorsTruncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.errorsTruncated")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user