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:
Marc-André Bombeck committed 2026-10-21 22:19:16 +02:00
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);
}
});
});
+345
View File
@@ -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"),
};