mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user