test(medications): prove the reported export lands, row for row

The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 16:37:10 +02:00
1 parent 11e6402b5a
commit 34a220421c
20 files changed
+5322 -25

No files matched your search

@@ -55,18 +55,64 @@ function useSkipReasonLabel(): (reason: string) => string {
const labels: Record<string, string> = {
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
already_recorded: t("medications.import.reasonAlreadyRecorded"),
medication_not_found: t("medications.import.reasonMedicationNotFound"),
medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
status_not_recorded: t("medications.import.reasonStatusNotRecorded"),
status_unknown: t("medications.import.reasonStatusUnknown"),
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
unreadable_timestamp: t("medications.import.reasonUnreadableTimestamp"),
implausible_timestamp: t("medications.import.reasonImplausibleTimestamp"),
missing_medication: t("medications.import.reasonMissingMedication"),
unreadable_dosage: t("medications.import.reasonUnreadableDosage"),
unreadable_row: t("medications.import.reasonUnreadableRow"),
};
const fallback = t("medications.import.reasonUnknown");
return (reason: string) => labels[reason] ?? fallback;
}
/**
* The grouped skip lines on their own, without the outcome sentence above them.
*
* Split out because a dry-run preview needs exactly these lines and must NOT
* borrow the outcome sentence: a preview has written nothing, so classifying it
* by the counts it WOULD produce would put a success tick on a run that never
* happened. The reason vocabulary is shared; the verdict is not.
*/
export function IntakeImportSkipGroups({
groups,
testId = "intake-import-skip-groups",
}: {
groups: readonly IntakeImportSkipGroup[];
testId?: string;
}) {
const { tCount } = useTranslations();
const labelFor = useSkipReasonLabel();
if (groups.length === 0) return null;
return (
<ul
data-testid={testId}
className="text-muted-foreground space-y-0.5 text-xs"
>
{groups.map((group) => (
<li key={group.reason}>
{tCount("medications.import.skipGroup", group.count, {
count: group.count,
reason: labelFor(group.reason),
})}
</li>
))}
</ul>
);
}
export function IntakeImportResultView({
result,
}: {
result: IntakeImportResultState;
}) {
const { t, tCount } = useTranslations();
const labelFor = useSkipReasonLabel();
const { t } = useTranslations();
if (result.kind === "notice") {
return (
@@ -106,21 +152,7 @@ export function IntakeImportResultView({
message={message}
testId="intake-import-outcome"
/>
{result.skipReasons.length > 0 && (
<ul
data-testid="intake-import-skip-groups"
className="text-muted-foreground space-y-0.5 text-xs"
>
{result.skipReasons.map((group) => (
<li key={group.reason}>
{tCount("medications.import.skipGroup", group.count, {
count: group.count,
reason: labelFor(group.reason),
})}
</li>
))}
</ul>
)}
<IntakeImportSkipGroups groups={result.skipReasons} />
</div>
);
}
@@ -48,6 +48,10 @@ const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
path: "src/components/medications/intake-import-dialog.tsx",
through: /IntakeImportResultView/,
},
{
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
through: /IntakeImportResultView/,
},
];
/** The success affordance: the tick component and the success colour token. */
@@ -0,0 +1,94 @@
"use client";
/**
* What the dose-history importer does with every field of the export.
*
* Visible before a file is chosen, not discovered afterwards. A column read and
* silently dropped is indistinguishable, from the outside, from a column read and
* used — which is how a person ends up believing an importer honoured something
* it never looked at.
*
* The rulings come from `AUTO_EXPORT_COLUMN_RULINGS`, so the parser and this list
* cannot drift: a structural test asserts every column the format defines has
* exactly one ruling, and the sentence for each ruling is a literal `t()` call
* below so the i18n call-site guard sees all of them.
*/
import { AUTO_EXPORT_COLUMN_RULINGS } from "@/lib/medications/import/auto-export-format";
import { useTranslations } from "@/lib/i18n/context";
/** One literal `t()` per note key. A computed key would be invisible to the guard. */
function useColumnNote(): (noteKey: string) => string {
const { t } = useTranslations();
const notes: Record<string, string> = {
date: t("settings.sections.export.import.doseHistory.column.date"),
scheduledDate: t(
"settings.sections.export.import.doseHistory.column.scheduledDate",
),
medication: t(
"settings.sections.export.import.doseHistory.column.medication",
),
nickname: t("settings.sections.export.import.doseHistory.column.nickname"),
dosage: t("settings.sections.export.import.doseHistory.column.dosage"),
scheduledDosage: t(
"settings.sections.export.import.doseHistory.column.scheduledDosage",
),
unit: t("settings.sections.export.import.doseHistory.column.unit"),
status: t("settings.sections.export.import.doseHistory.column.status"),
archived: t("settings.sections.export.import.doseHistory.column.archived"),
codings: t("settings.sections.export.import.doseHistory.column.codings"),
form: t("settings.sections.export.import.doseHistory.column.form"),
start: t("settings.sections.export.import.doseHistory.column.start"),
end: t("settings.sections.export.import.doseHistory.column.end"),
};
return (noteKey: string) => notes[noteKey] ?? "";
}
function useVerdictLabel(): (verdict: string) => string {
const { t } = useTranslations();
const labels: Record<string, string> = {
honoured: t("settings.sections.export.import.doseHistory.verdict.honoured"),
reported: t("settings.sections.export.import.doseHistory.verdict.reported"),
ignored: t("settings.sections.export.import.doseHistory.verdict.ignored"),
};
return (verdict: string) => labels[verdict] ?? verdict;
}
export function DoseHistoryColumnRulings() {
const { t } = useTranslations();
const noteFor = useColumnNote();
const verdictFor = useVerdictLabel();
return (
<details
className="group border-border rounded-md border px-3 py-2"
data-testid="dose-history-columns"
>
<summary className="cursor-pointer text-xs font-medium">
{t("settings.sections.export.import.doseHistory.columnsHeading")}
</summary>
<dl className="mt-2 space-y-2">
{AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => (
<div key={ruling.column} data-testid="dose-history-column-ruling">
<dt className="text-foreground flex flex-wrap items-baseline gap-x-2 text-xs">
<code className="font-mono">{ruling.column}</code>
<span
className="text-muted-foreground text-[0.6875rem] uppercase"
data-verdict={ruling.verdict}
>
{verdictFor(ruling.verdict)}
</span>
{ruling.jsonOnly && (
<span className="text-muted-foreground text-[0.6875rem]">
{t("settings.sections.export.import.doseHistory.jsonOnly")}
</span>
)}
</dt>
<dd className="text-muted-foreground text-xs">
{noteFor(ruling.noteKey)}
</dd>
</div>
))}
</dl>
</details>
);
}
@@ -0,0 +1,398 @@
"use client";
/**
* Import a dose history exported from another medication tracker.
*
* Account-wide, because such a file covers a whole regimen — the per-medication
* import dialog on a medication's own page stays what it is, a way to add dates
* and times for that one medication.
*
* Preview first. The dry run returns the entire verdict — how many rows land, how
* many are refused and under which reason, which medication names match nothing
* on the record — before a single row is written. Importing years of history
* unseen is not something to ask of anybody.
*
* The outcome renders through `IntakeImportResultView`, the same component the
* per-medication dialog uses, so a run that wrote nothing cannot carry a success
* tick here either and the skip reasons are grouped with a count rather than
* repeated per row.
*/
import { useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Loader2, PillBottle, Upload } from "lucide-react";
import {
IntakeImportResultView,
IntakeImportSkipGroups,
type IntakeImportResultState,
} from "@/components/medications/intake-import-result";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { DoseHistoryColumnRulings } from "./dose-history-columns";
import { ImportCardShell } from "./import-card-shell";
import { MAX_PASTE_CHARS } from "./constants";
/** The file-level verdict, exactly as the route returns it. */
interface DoseHistoryFileVerdict {
rowsRead: number;
queued: number;
refused: number;
refusedByReason: Array<{ reason: string; count: number }>;
unmatchedMedications: string[];
ambiguousMedications: string[];
mirroredMedications: string[];
unknownColumns: string[];
codingsNotRead: number;
fromArchivedMedications: number;
}
interface DoseHistoryKickoff {
dryRun: boolean;
jobId: string | null;
statusUrl: string | null;
file: DoseHistoryFileVerdict;
}
/**
* Plain language per machine-readable file-level refusal. Literal `t()` calls so
* the i18n call-site guard sees every one.
*/
function useFatalMessage(): (code: string | undefined) => string {
const { t } = useTranslations();
const messages: Record<string, string> = {
empty_file: t(
"settings.sections.export.import.doseHistory.fatal.emptyFile",
),
missing_required_columns: t(
"settings.sections.export.import.doseHistory.fatal.missingColumns",
),
unreadable_json: t(
"settings.sections.export.import.doseHistory.fatal.unreadableJson",
),
json_not_an_array: t(
"settings.sections.export.import.doseHistory.fatal.jsonNotAnArray",
),
json_carries_no_intake_time: t(
"settings.sections.export.import.doseHistory.fatal.jsonNoIntakeTime",
),
too_many_rows: t(
"settings.sections.export.import.doseHistory.fatal.tooManyRows",
),
};
const fallback = t("settings.sections.export.import.doseHistory.failed");
return (code: string | undefined) =>
(code === undefined ? undefined : messages[code]) ?? fallback;
}
/** `true` when the pasted or loaded text looks like the JSON shape. */
function looksLikeJson(text: string): boolean {
const head = text.trimStart().charAt(0);
return head === "[" || head === "{";
}
export function DoseHistoryImportCard() {
const { t } = useTranslations();
const fatalMessageFor = useFatalMessage();
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaId = useId();
const queryClient = useQueryClient();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verdict, setVerdict] = useState<DoseHistoryFileVerdict | null>(null);
const [previewOnly, setPreviewOnly] = useState(false);
const [outcome, setOutcome] = useState<IntakeImportResultState | null>(null);
async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setError(null);
setVerdict(null);
setOutcome(null);
try {
setText(await file.text());
} catch {
setError(t("settings.sections.export.import.doseHistory.readFailed"));
}
}
async function send(dryRun: boolean) {
setError(null);
setVerdict(null);
setOutcome(null);
if (text.trim().length === 0) {
setError(t("settings.sections.export.import.doseHistory.empty"));
return;
}
setBusy(true);
try {
const res = await apiFetchRaw(
`/api/medications/intake/dose-history-import${dryRun ? "?dryRun=1" : ""}`,
{
method: "POST",
headers: {
"Content-Type": looksLikeJson(text)
? "application/json"
: "text/csv",
},
credentials: "include",
body: text,
},
);
if (res.status === 429) {
setError(t("settings.sections.export.import.doseHistory.rateLimited"));
return;
}
if (res.status === 413) {
setError(t("settings.sections.export.import.doseHistory.tooLarge"));
return;
}
if (res.status === 409) {
setError(t("settings.sections.export.import.doseHistory.inProgress"));
return;
}
if (res.status === 503) {
setError(
t("settings.sections.export.import.doseHistory.workerUnavailable"),
);
return;
}
if (res.status === 422 || res.status === 415) {
const body = await res.json().catch(() => null);
const code =
typeof body?.meta?.errorCode === "string"
? body.meta.errorCode
: undefined;
setError(fatalMessageFor(code));
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.doseHistory.failed"));
return;
}
const data = (await res.json()).data as DoseHistoryKickoff;
setVerdict(data.file);
setPreviewOnly(data.dryRun);
if (data.dryRun || !data.statusUrl) return;
const result = await waitForMedicationIntakeImport(data.statusUrl);
setOutcome({
kind: "outcome",
imported: result.imported,
skipped: result.skipped,
skipReasons: result.skipReasons,
});
void invalidateMedicationReads(queryClient);
} catch (err) {
// A failed job carries a sanitised reason; anything else is a transport
// failure with nothing to add. Either way the run did not write, and the
// card says so rather than leaving the last verdict on screen.
setError(
err instanceof MedicationIntakeImportError && err.message.length > 0
? err.message
: t("settings.sections.export.import.doseHistory.failed"),
);
setVerdict(null);
} finally {
setBusy(false);
}
}
return (
<ImportCardShell
testId="import-card-dose-history"
icon={PillBottle}
title={t("settings.sections.export.import.doseHistory.title")}
description={t("settings.sections.export.import.doseHistory.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.doseHistory.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-dose-history-textarea"
value={text}
onChange={(event) => setText(event.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="Date,Scheduled Date,Medication,…"
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,text/csv,application/json"
className="sr-only"
aria-label={t(
"settings.sections.export.import.doseHistory.fileInputLabel",
)}
onChange={onFileChange}
/>
<DoseHistoryColumnRulings />
<div aria-live="polite" className="space-y-2">
{verdict && (
<div
data-testid="dose-history-verdict"
className="space-y-1 text-xs"
data-preview={previewOnly ? "true" : "false"}
>
<p className="text-foreground">
{previewOnly
? t("settings.sections.export.import.doseHistory.previewLine", {
rows: verdict.rowsRead,
queued: verdict.queued,
refused: verdict.refused,
})
: t(
"settings.sections.export.import.doseHistory.submittedLine",
{
rows: verdict.rowsRead,
queued: verdict.queued,
},
)}
</p>
{/* The reason lines, never the outcome sentence: a preview has
written nothing, and classifying it by the counts it would
produce would put a success tick on a run that never ran. */}
<IntakeImportSkipGroups
groups={verdict.refusedByReason}
testId="dose-history-refusal-groups"
/>
{verdict.unmatchedMedications.length > 0 && (
<p
className="text-muted-foreground"
data-testid="dose-history-unmatched"
>
{t("settings.sections.export.import.doseHistory.unmatched", {
names: verdict.unmatchedMedications.join(", "),
})}
</p>
)}
{verdict.ambiguousMedications.length > 0 && (
<p className="text-muted-foreground">
{t("settings.sections.export.import.doseHistory.ambiguous", {
names: verdict.ambiguousMedications.join(", "),
})}
</p>
)}
{verdict.mirroredMedications.length > 0 && (
<p className="text-muted-foreground">
{t("settings.sections.export.import.doseHistory.mirrored", {
names: verdict.mirroredMedications.join(", "),
})}
</p>
)}
{verdict.unknownColumns.length > 0 && (
<p className="text-muted-foreground">
{t(
"settings.sections.export.import.doseHistory.unknownColumns",
{ names: verdict.unknownColumns.join(", ") },
)}
</p>
)}
{verdict.codingsNotRead > 0 && (
<p className="text-muted-foreground">
{t(
"settings.sections.export.import.doseHistory.codingsNotRead",
{
count: verdict.codingsNotRead,
},
)}
</p>
)}
{verdict.fromArchivedMedications > 0 && (
<p
className="text-muted-foreground"
data-testid="dose-history-archived"
>
{t("settings.sections.export.import.doseHistory.archivedRows", {
count: verdict.fromArchivedMedications,
})}
</p>
)}
</div>
)}
{outcome && <IntakeImportResultView result={outcome} />}
{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"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-dose-history-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.doseHistory.uploadFile")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(true)}
data-testid="import-dose-history-preview"
>
{busy && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.import.doseHistory.preview")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(false)}
data-testid="import-action-dose-history"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PillBottle className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.doseHistory.import")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { AppleHealthImportCard } from "./apple-health-import-card";
import { JsonImportCard } from "./json-import-card";
import { CsvImportCard } from "./csv-import-card";
import { DoseHistoryImportCard } from "./dose-history-import-card";
export {
EXAMPLE_IMPORT,
@@ -50,6 +51,7 @@ export function ImportPanel() {
<AppleHealthImportCard />
<JsonImportCard />
<CsvImportCard />
<DoseHistoryImportCard />
</div>
</section>
);