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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
import {
|
||||
RECORD_IMPORT_TEMPLATES,
|
||||
guessFormat,
|
||||
guessMapping,
|
||||
readHead,
|
||||
} from "../csv-mapping";
|
||||
import { normaliseLabRow } from "../labs-csv";
|
||||
import { normaliseMedicationRow } from "../medications-csv";
|
||||
|
||||
describe("guessMapping", () => {
|
||||
it("reads English and German headers, required fields first", () => {
|
||||
expect(
|
||||
guessMapping(
|
||||
["Analyte", "Result", "Unit", "Date", "Reference range"],
|
||||
"labs",
|
||||
),
|
||||
).toEqual({
|
||||
analyte: 0,
|
||||
value: 1,
|
||||
unit: 2,
|
||||
date: 3,
|
||||
referenceRange: 4,
|
||||
});
|
||||
expect(
|
||||
guessMapping(
|
||||
["Datum", "Laborwert", "Wert", "Einheit", "Referenzbereich"],
|
||||
"labs",
|
||||
),
|
||||
).toMatchObject({
|
||||
date: 0,
|
||||
analyte: 1,
|
||||
value: 2,
|
||||
unit: 3,
|
||||
referenceRange: 4,
|
||||
});
|
||||
expect(
|
||||
guessMapping(
|
||||
["Medikament", "Dosis", "Uhrzeiten", "Bei Bedarf", "Beginn", "Ende"],
|
||||
"medications",
|
||||
),
|
||||
).toEqual({
|
||||
name: 0,
|
||||
dose: 1,
|
||||
times: 2,
|
||||
asNeeded: 3,
|
||||
startsOn: 4,
|
||||
endsOn: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a column to one field only and leaves unknown headers alone", () => {
|
||||
const m = guessMapping(["name", "foo", "bar"], "labs");
|
||||
expect(m).toEqual({ analyte: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("guessFormat", () => {
|
||||
it("sees a German file", () => {
|
||||
const head = readHead("Analyt;Wert;Datum\nHbA1c;5,4;01.03.2025\n");
|
||||
expect(head.delimiter).toBe(";");
|
||||
expect(guessFormat(head, 2)).toEqual({
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
});
|
||||
});
|
||||
it("sees a US file by a day above twelve", () => {
|
||||
const head = readHead("a,b,date\nx,5.4,03/15/2025\n");
|
||||
expect(guessFormat(head, 2)).toEqual({ decimal: "dot", dateOrder: "mdy" });
|
||||
});
|
||||
it("sees ISO", () => {
|
||||
expect(guessFormat(readHead("a,date\nx,2025-03-01\n"), 1).dateOrder).toBe(
|
||||
"iso",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the templates", () => {
|
||||
it("import cleanly with the mapping the screen suggests", () => {
|
||||
const labs = readHead(RECORD_IMPORT_TEMPLATES.labs, 10);
|
||||
const labMap = guessMapping(labs.rows[0], "labs");
|
||||
const labQuery = labsCsvQuerySchema.parse(
|
||||
Object.fromEntries(
|
||||
Object.entries(labMap).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
);
|
||||
for (const [i, fields] of labs.rows.slice(1).entries()) {
|
||||
expect(normaliseLabRow({ line: i + 2, fields }, labQuery, "UTC").ok).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
const meds = readHead(RECORD_IMPORT_TEMPLATES.medications, 10);
|
||||
const medMap = guessMapping(meds.rows[0], "medications");
|
||||
const medQuery = medicationsCsvQuerySchema.parse(
|
||||
Object.fromEntries(
|
||||
Object.entries(medMap).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
);
|
||||
for (const [i, fields] of meds.rows.slice(1).entries()) {
|
||||
expect(
|
||||
normaliseMedicationRow({ line: i + 2, fields }, medQuery, "UTC").ok,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* The client half of the CSV record imports (#1215): reading the head of a
|
||||
* file for the mapping screen, suggesting which column feeds which field and
|
||||
* how the file writes numbers and dates, and the downloadable templates.
|
||||
*
|
||||
* Suggestions only. The person sees every choice on the mapping screen and
|
||||
* the server checks every row against what was chosen; nothing here decides
|
||||
* a value.
|
||||
*/
|
||||
import { CsvTokenizer, type CsvDelimiter } from "@/lib/import/csv-stream";
|
||||
|
||||
export type RecordImportKind = "labs" | "medications";
|
||||
|
||||
export const LAB_FIELDS = [
|
||||
{ key: "analyte", required: true },
|
||||
{ key: "value", required: true },
|
||||
{ key: "unit", required: false },
|
||||
{ key: "date", required: true },
|
||||
{ key: "time", required: false },
|
||||
{ key: "referenceRange", required: false },
|
||||
{ key: "referenceLow", required: false },
|
||||
{ key: "referenceHigh", required: false },
|
||||
{ key: "panel", required: false },
|
||||
{ key: "note", required: false },
|
||||
] as const;
|
||||
|
||||
export const MEDICATION_FIELDS = [
|
||||
{ key: "name", required: true },
|
||||
{ key: "dose", required: true },
|
||||
{ key: "times", required: false },
|
||||
{ key: "asNeeded", required: false },
|
||||
{ key: "startsOn", required: false },
|
||||
{ key: "endsOn", required: false },
|
||||
] as const;
|
||||
|
||||
export type FieldKey =
|
||||
| (typeof LAB_FIELDS)[number]["key"]
|
||||
| (typeof MEDICATION_FIELDS)[number]["key"];
|
||||
|
||||
export function fieldsFor(kind: RecordImportKind) {
|
||||
return kind === "labs" ? LAB_FIELDS : MEDICATION_FIELDS;
|
||||
}
|
||||
|
||||
/** Header words per field, in the languages HealthLog speaks, folded. */
|
||||
const SYNONYMS: Record<FieldKey, string[]> = {
|
||||
analyte: [
|
||||
"analyte",
|
||||
"marker",
|
||||
"biomarker",
|
||||
"test",
|
||||
"parameter",
|
||||
"analyt",
|
||||
"laborwert",
|
||||
"wert name",
|
||||
"name",
|
||||
"prueba",
|
||||
"analyse",
|
||||
"esame",
|
||||
"badanie",
|
||||
],
|
||||
value: [
|
||||
"value",
|
||||
"result",
|
||||
"wert",
|
||||
"ergebnis",
|
||||
"resultado",
|
||||
"valor",
|
||||
"valeur",
|
||||
"resultat",
|
||||
"valore",
|
||||
"risultato",
|
||||
"wynik",
|
||||
"waarde",
|
||||
"uitslag",
|
||||
"결과",
|
||||
"값",
|
||||
],
|
||||
unit: [
|
||||
"unit",
|
||||
"units",
|
||||
"einheit",
|
||||
"unidad",
|
||||
"unite",
|
||||
"unita",
|
||||
"jednostka",
|
||||
"eenheid",
|
||||
"단위",
|
||||
],
|
||||
date: [
|
||||
"date",
|
||||
"datum",
|
||||
"fecha",
|
||||
"data",
|
||||
"day",
|
||||
"tag",
|
||||
"taken",
|
||||
"taken at",
|
||||
"collected",
|
||||
"abnahme",
|
||||
"날짜",
|
||||
],
|
||||
time: [
|
||||
"time",
|
||||
"uhrzeit",
|
||||
"zeit",
|
||||
"hora",
|
||||
"heure",
|
||||
"ora",
|
||||
"godzina",
|
||||
"tijd",
|
||||
"시간",
|
||||
],
|
||||
referenceRange: [
|
||||
"reference",
|
||||
"reference range",
|
||||
"range",
|
||||
"referenz",
|
||||
"referenzbereich",
|
||||
"normbereich",
|
||||
"norm",
|
||||
"rango",
|
||||
"reference interval",
|
||||
"intervalle",
|
||||
"riferimento",
|
||||
"zakres",
|
||||
"referentie",
|
||||
],
|
||||
referenceLow: ["low", "min", "lower", "untergrenze", "von", "ref low"],
|
||||
referenceHigh: ["high", "max", "upper", "obergrenze", "bis", "ref high"],
|
||||
panel: ["panel", "group", "gruppe", "profil", "category", "kategorie"],
|
||||
note: [
|
||||
"note",
|
||||
"notes",
|
||||
"comment",
|
||||
"kommentar",
|
||||
"notiz",
|
||||
"bemerkung",
|
||||
"nota",
|
||||
"remarque",
|
||||
"uwagi",
|
||||
"opmerking",
|
||||
],
|
||||
name: [
|
||||
"name",
|
||||
"medication",
|
||||
"medicine",
|
||||
"drug",
|
||||
"medikament",
|
||||
"arznei",
|
||||
"präparat",
|
||||
"praparat",
|
||||
"wirkstoff",
|
||||
"medicamento",
|
||||
"medicament",
|
||||
"farmaco",
|
||||
"lek",
|
||||
"medicijn",
|
||||
"약",
|
||||
],
|
||||
dose: [
|
||||
"dose",
|
||||
"dosage",
|
||||
"dosis",
|
||||
"dosierung",
|
||||
"strength",
|
||||
"stärke",
|
||||
"starke",
|
||||
"dosage",
|
||||
"dawka",
|
||||
"용량",
|
||||
],
|
||||
times: [
|
||||
"times",
|
||||
"time",
|
||||
"schedule",
|
||||
"uhrzeit",
|
||||
"uhrzeiten",
|
||||
"einnahme",
|
||||
"zeiten",
|
||||
"horario",
|
||||
"heures",
|
||||
"orari",
|
||||
"godziny",
|
||||
"tijden",
|
||||
"시간",
|
||||
],
|
||||
asNeeded: [
|
||||
"as needed",
|
||||
"prn",
|
||||
"bei bedarf",
|
||||
"bedarf",
|
||||
"a demanda",
|
||||
"si besoin",
|
||||
"al bisogno",
|
||||
"w razie potrzeby",
|
||||
"zo nodig",
|
||||
"필요시",
|
||||
],
|
||||
startsOn: [
|
||||
"start",
|
||||
"starts",
|
||||
"start date",
|
||||
"begin",
|
||||
"beginn",
|
||||
"seit",
|
||||
"from",
|
||||
"von",
|
||||
"inicio",
|
||||
"debut",
|
||||
"inizio",
|
||||
"od",
|
||||
"begin datum",
|
||||
],
|
||||
endsOn: [
|
||||
"end",
|
||||
"ends",
|
||||
"end date",
|
||||
"until",
|
||||
"bis",
|
||||
"ende",
|
||||
"fin",
|
||||
"fine",
|
||||
"do",
|
||||
"einddatum",
|
||||
],
|
||||
};
|
||||
|
||||
function fold(text: string): string {
|
||||
return text
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[_\-./()]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a column per field from the header names. Exact synonym first, then
|
||||
* a header that starts with one; a column is offered to one field at most,
|
||||
* and the required fields choose first.
|
||||
*/
|
||||
export function guessMapping(
|
||||
headers: readonly string[],
|
||||
kind: RecordImportKind,
|
||||
): Partial<Record<FieldKey, number>> {
|
||||
const folded = headers.map(fold);
|
||||
const taken = new Set<number>();
|
||||
const out: Partial<Record<FieldKey, number>> = {};
|
||||
const order = [...fieldsFor(kind)].sort(
|
||||
(a, b) => Number(b.required) - Number(a.required),
|
||||
);
|
||||
for (const pass of ["exact", "prefix"] as const) {
|
||||
for (const { key } of order) {
|
||||
if (out[key] !== undefined) continue;
|
||||
const words = SYNONYMS[key];
|
||||
const index = folded.findIndex(
|
||||
(h, i) =>
|
||||
!taken.has(i) &&
|
||||
words.some((w) =>
|
||||
pass === "exact" ? h === w : h.startsWith(`${w} `),
|
||||
),
|
||||
);
|
||||
if (index >= 0) {
|
||||
out[key] = index;
|
||||
taken.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FileHead {
|
||||
delimiter: CsvDelimiter;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/** The first `maxRows` records of a file's head text. */
|
||||
export function readHead(text: string, maxRows = 6): FileHead {
|
||||
const tokenizer = new CsvTokenizer("auto");
|
||||
const rows: string[][] = [];
|
||||
for (const record of [...tokenizer.push(text), ...tokenizer.end()]) {
|
||||
rows.push(record.fields);
|
||||
if (rows.length >= maxRows) break;
|
||||
}
|
||||
return { delimiter: tokenizer.detectedDelimiter ?? ",", rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* How the sample writes numbers and dates. A semicolon file almost always
|
||||
* writes a decimal comma; a sample cell like `5,4` says so outright. The date
|
||||
* order follows the first date-shaped cell: ISO, then a first number above 12
|
||||
* (day first) or a second above 12 (month first), then the separator habit
|
||||
* (`.` is day first, `/` alone is left to the person: it stays ISO-default
|
||||
* `dmy` only when a dot is seen).
|
||||
*/
|
||||
export function guessFormat(
|
||||
head: FileHead,
|
||||
dateColumn: number | undefined,
|
||||
): { decimal: "dot" | "comma"; dateOrder: "iso" | "dmy" | "mdy" } {
|
||||
const data = head.rows.slice(1);
|
||||
const cells = data.flat();
|
||||
const commaNumber = cells.some((c) => /^-?\d+,\d{1,2}$/.test(c.trim()));
|
||||
const decimal = head.delimiter === ";" || commaNumber ? "comma" : "dot";
|
||||
|
||||
let dateOrder: "iso" | "dmy" | "mdy" = "iso";
|
||||
const dates = data
|
||||
.map((r) => (dateColumn === undefined ? "" : (r[dateColumn] ?? "").trim()))
|
||||
.filter(Boolean);
|
||||
for (const d of dates) {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
|
||||
dateOrder = "iso";
|
||||
break;
|
||||
}
|
||||
const m = /^(\d{1,2})([./-])(\d{1,2})\2\d{4}/.exec(d);
|
||||
if (!m) continue;
|
||||
if (Number(m[1]) > 12) {
|
||||
dateOrder = "dmy";
|
||||
break;
|
||||
}
|
||||
if (Number(m[3]) > 12) {
|
||||
dateOrder = "mdy";
|
||||
break;
|
||||
}
|
||||
dateOrder = m[2] === "." ? "dmy" : "mdy";
|
||||
}
|
||||
return { decimal, dateOrder };
|
||||
}
|
||||
|
||||
export const RECORD_IMPORT_TEMPLATES: Record<RecordImportKind, string> = {
|
||||
labs: [
|
||||
"analyte,value,unit,date,time,reference range,panel,note",
|
||||
"HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,",
|
||||
"Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,",
|
||||
"Urine protein,negative,,2025-03-01,,,Urine,",
|
||||
"",
|
||||
].join("\n"),
|
||||
medications: [
|
||||
"name,dose,times,as needed,start,end",
|
||||
"Ramipril,5 mg,08:00,,2025-01-01,",
|
||||
"Metformin,500 mg,08:00 20:00,,,",
|
||||
"Ibuprofen,400 mg,,yes,,",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
Reference in new issue
Block a user