Files
HealthLog/src/lib/medications/import/auto-export-parse.ts
T
Marc-André Bombeck 11e6402b5a feat(medications): read a dose-history export directly
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
2026-07-26 16:37:10 +02:00

453 lines
15 KiB
TypeScript

/**
* The medication dose-history export, from file text to per-row facts.
*
* Pure and synchronous: no database, no clock beyond an injectable `now`.
*
* Every row is either a dose or a named refusal. There is no third outcome, and
* nothing is dropped between the two: a test asserts that
* `doses.length + refusals.length` equals the number of data rows read.
*
* Only the CSV shape carries a dose history. The documented JSON shape is read
* too, and refused, for the reason set out in {@link parseAutoExportJson} — a
* refusal that says why is worth more than a lossy import.
*/
import {
ENTRY_INSTANT_FAR_PAST,
isPlausibleEntryInstant,
} from "@/lib/validations/entry-instant";
import { splitCsvRows } from "@/lib/import/csv-measurements";
import {
AUTO_EXPORT_CSV_COLUMNS,
formatAutoExportDose,
isActionableAutoExportStatus,
isAutoExportStatus,
} from "./auto-export-format";
import {
parseAutoExportInstant,
type AutoExportInstantFailure,
} from "./auto-export-instant";
/** One dose the file states, in HealthLog's own terms. */
export interface AutoExportDose {
/** 1-based source line, so a refusal can be pointed at. */
line: number;
/** The name as written, kept verbatim for the unmatched-medication report. */
medicationName: string;
/** The export's optional second name for the same medication. */
nickname: string | null;
/**
* The slot this dose belongs to. The file's `Scheduled Date` when it states
* one; otherwise the actual instant, which is how HealthLog already anchors
* an ad-hoc dose that belongs to no slot.
*/
scheduledFor: Date;
/** When the dose was actually taken. `null` for a deliberate skip. */
takenAt: Date | null;
skipped: boolean;
/** Per-dose override, set only where the file records a real deviation. */
doseTaken: string | null;
/** The UTC offset the file stated for the actual instant, in minutes. */
offsetMinutes: number;
/** True when the row came from a medication archived in the source app. */
fromArchivedMedication: boolean;
/** True when the file named no slot, so this dose is ad-hoc. */
adHoc: boolean;
}
/**
* Why a row did not become a dose. Every one of these is surfaced with a count;
* none of them is a silent drop.
*/
export const AUTO_EXPORT_REFUSAL_REASONS = [
/** Status names no decision about the dose (`Not Interacted`, `Snoozed`, …). */
"status_not_recorded",
/** Status is not a value the format documents at all. */
"status_unknown",
/** `Date` is blank — there is no dose without an instant. */
"missing_timestamp",
/** A timestamp shaped right but carrying no UTC offset. */
"missing_timezone_offset",
/** A timestamp that does not match the documented shape. */
"unreadable_timestamp",
/** A timestamp naming no real instant, or one outside plausible capture. */
"implausible_timestamp",
/** `Medication` and `Nickname` both blank — the row names no medication. */
"missing_medication",
/** A dosage cell that is not a number. */
"unreadable_dosage",
/** A CSV line with fewer cells than the header declared. */
"unreadable_row",
] as const;
export type AutoExportRefusalReason =
(typeof AUTO_EXPORT_REFUSAL_REASONS)[number];
export interface AutoExportRefusal {
line: number;
reason: AutoExportRefusalReason;
}
/** A file-level problem: nothing can be read at all. */
export const AUTO_EXPORT_FATAL_REASONS = [
"empty_file",
"missing_required_columns",
"unreadable_json",
"json_not_an_array",
/** The JSON shape records no time a dose was actually taken. */
"json_carries_no_intake_time",
] as const;
export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
export interface AutoExportParseOutcome {
fatal?: { reason: AutoExportFatalReason; detail?: string };
doses: AutoExportDose[];
refusals: AutoExportRefusal[];
/** Data rows read, refusals included. `doses + refusals` must equal it. */
rowsRead: number;
/**
* Header cells the importer has no verdict for. Named rather than dropped: a
* column nobody mentions is the defect this format work exists to remove.
*/
unknownColumns: string[];
/**
* Rows whose `Codings` cell held something. Counted and left unread — the CSV
* shape gives that column no documented encoding, and guessing one would be
* the importer inventing a medication identity.
*/
csvCodingsIgnored: number;
/** Rows that came from a medication the source app has archived. */
fromArchivedMedications: number;
}
/**
* The past floor for an imported dose.
*
* HealthLog's own intake surfaces clamp a backdated dose to five years, which
* would refuse most of a real export — the reported file starts in 2023 and a
* longer history is ordinary. Importing years of history is the entire point of
* this route, so the floor is the absolute 1900 bound the shared validator
* carries and only the future side stays tight.
*/
const IMPORT_PAST_FLOOR_MS = Date.now() - ENTRY_INSTANT_FAR_PAST.getTime();
function refusalForInstantFailure(
failure: AutoExportInstantFailure,
): AutoExportRefusalReason {
switch (failure) {
case "absent":
return "missing_timestamp";
case "missing_offset":
return "missing_timezone_offset";
case "out_of_range":
return "implausible_timestamp";
case "unreadable":
return "unreadable_timestamp";
}
}
/** `Yes` / `true` / `1`; anything else, blank included, reads as not archived. */
function readArchivedFlag(raw: string | null | undefined): boolean {
const value = (raw ?? "").trim().toLowerCase();
return value === "yes" || value === "true" || value === "1";
}
type DosageRead =
| { ok: true; amount: number | null }
| { ok: false; reason: "unreadable_dosage" };
/** A blank dosage cell states nothing; a non-numeric one is a refusal. */
function readDosage(raw: string): DosageRead {
const value = raw.trim();
if (value.length === 0) return { ok: true, amount: null };
if (!/^-?\d+(\.\d+)?$/.test(value)) {
return { ok: false, reason: "unreadable_dosage" };
}
const amount = Number(value);
return Number.isFinite(amount)
? { ok: true, amount }
: { ok: false, reason: "unreadable_dosage" };
}
/**
* The per-dose override, or `null`.
*
* Written only where the file records an actual amount that DIFFERS from the
* amount the schedule expected — the only case where the dose taken is a fact
* the schedule does not already carry. `doseTaken` is documented as "NULL = the
* configured medication / schedule dose applies", so restating an unremarkable
* amount on every one of three thousand rows would be noise dressed as
* information. Where the file states no scheduled amount there is nothing to
* compare against, and absence stays absence.
*/
function resolveDoseOverride(
actual: number | null,
scheduled: number | null,
unit: string,
): string | null {
if (actual === null || scheduled === null) return null;
if (actual === scheduled) return null;
return formatAutoExportDose(actual, unit);
}
interface RowInput {
line: number;
actual: string;
scheduled: string;
medication: string;
nickname: string;
dosage: string;
scheduledDosage: string;
unit: string;
status: string;
archived: string;
}
type RowOutcome =
| { ok: true; dose: AutoExportDose }
| { ok: false; reason: AutoExportRefusalReason };
/** The one place a row becomes a dose. */
function readRow(input: RowInput, now: number): RowOutcome {
const status = input.status.trim();
if (!isAutoExportStatus(status)) {
return { ok: false, reason: "status_unknown" };
}
if (!isActionableAutoExportStatus(status)) {
return { ok: false, reason: "status_not_recorded" };
}
const medicationName = input.medication.trim();
const nickname = input.nickname.trim();
if (medicationName.length === 0 && nickname.length === 0) {
return { ok: false, reason: "missing_medication" };
}
const actual = parseAutoExportInstant(input.actual);
if (!actual.ok) {
return { ok: false, reason: refusalForInstantFailure(actual.failure) };
}
// A blank `Scheduled Date` is not a defect: the export writes it for a dose
// logged outside any slot. HealthLog anchors exactly that case with
// `scheduledFor === takenAt`, so the absence maps onto an existing notion
// instead of being invented into one.
const scheduledRead = parseAutoExportInstant(input.scheduled);
let scheduledFor: Date;
let adHoc: boolean;
if (scheduledRead.ok) {
scheduledFor = scheduledRead.instant;
adHoc = false;
} else if (scheduledRead.failure === "absent") {
scheduledFor = actual.instant;
adHoc = true;
} else {
return {
ok: false,
reason: refusalForInstantFailure(scheduledRead.failure),
};
}
for (const instant of [actual.instant, scheduledFor]) {
if (
!isPlausibleEntryInstant(instant, { now, maxAgeMs: IMPORT_PAST_FLOOR_MS })
) {
return { ok: false, reason: "implausible_timestamp" };
}
}
const dosage = readDosage(input.dosage);
if (!dosage.ok) return { ok: false, reason: dosage.reason };
const scheduledDosage = readDosage(input.scheduledDosage);
if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
const skipped = status === "Skipped";
return {
ok: true,
dose: {
line: input.line,
medicationName: medicationName.length > 0 ? medicationName : nickname,
nickname: nickname.length > 0 ? nickname : null,
scheduledFor,
// A skip is a dose that was deliberately not taken. The file's `Date` on
// such a row is when the person pressed skip, which is not a take, so
// `takenAt` stays null exactly as every other skip path writes it.
takenAt: skipped ? null : actual.instant,
skipped,
doseTaken: skipped
? null
: resolveDoseOverride(
dosage.amount,
scheduledDosage.amount,
input.unit,
),
offsetMinutes: actual.offsetMinutes,
fromArchivedMedication: readArchivedFlag(input.archived),
adHoc,
},
};
}
const CSV_HEADER_LOOKUP = new Map<string, string>(
AUTO_EXPORT_CSV_COLUMNS.map((column) => [column.toLowerCase(), column]),
);
/** `Date`, `Medication` and `Status` are the file's irreducible content. */
const CSV_REQUIRED_COLUMNS = ["Date", "Medication", "Status"] as const;
export interface AutoExportParseOptions {
/** Clock anchor for the plausibility bound. Injectable for tests. */
now?: number;
}
const EMPTY_OUTCOME: Omit<AutoExportParseOutcome, "fatal"> = {
doses: [],
refusals: [],
rowsRead: 0,
unknownColumns: [],
csvCodingsIgnored: 0,
fromArchivedMedications: 0,
};
/** Parse the ten-column CSV export. */
export function parseAutoExportCsv(
text: string,
options: AutoExportParseOptions = {},
): AutoExportParseOutcome {
const now = options.now ?? Date.now();
const rows = splitCsvRows(text);
if (rows.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
const header = rows[0];
const index = new Map<string, number>();
const unknownColumns: string[] = [];
header.forEach((cell, position) => {
const trimmed = cell.trim();
const canonical = CSV_HEADER_LOOKUP.get(trimmed.toLowerCase());
if (canonical) {
if (!index.has(canonical)) index.set(canonical, position);
return;
}
if (trimmed.length > 0) unknownColumns.push(trimmed);
});
// A slimmer export missing any of the other seven columns still imports, with
// the facts those columns carry honestly absent.
const missing = CSV_REQUIRED_COLUMNS.filter((column) => !index.has(column));
if (missing.length > 0) {
return {
...EMPTY_OUTCOME,
unknownColumns,
fatal: {
reason: "missing_required_columns",
detail: missing.join(", "),
},
};
}
const cellAt = (row: string[], column: string): string => {
const position = index.get(column);
if (position === undefined) return "";
return row[position] ?? "";
};
const doses: AutoExportDose[] = [];
const refusals: AutoExportRefusal[] = [];
let csvCodingsIgnored = 0;
let fromArchivedMedications = 0;
for (let i = 1; i < rows.length; i += 1) {
const row = rows[i];
const line = i + 1;
// A row shorter than the header has lost cells to a malformed quote or a
// hand edit, so the alignment is no longer trustworthy and no cell of it
// can be read at face value.
if (row.length < header.length) {
refusals.push({ line, reason: "unreadable_row" });
continue;
}
if (cellAt(row, "Codings").trim().length > 0) csvCodingsIgnored += 1;
if (readArchivedFlag(cellAt(row, "Archived"))) fromArchivedMedications += 1;
const outcome = readRow(
{
line,
actual: cellAt(row, "Date"),
scheduled: cellAt(row, "Scheduled Date"),
medication: cellAt(row, "Medication"),
nickname: cellAt(row, "Nickname"),
dosage: cellAt(row, "Dosage"),
scheduledDosage: cellAt(row, "Scheduled Dosage"),
unit: cellAt(row, "Unit"),
status: cellAt(row, "Status"),
archived: cellAt(row, "Archived"),
},
now,
);
if (outcome.ok) doses.push(outcome.dose);
else refusals.push({ line, reason: outcome.reason });
}
return {
doses,
refusals,
rowsRead: rows.length - 1,
unknownColumns,
csvCodingsIgnored,
fromArchivedMedications,
};
}
/**
* Read the documented JSON export — and refuse it, deliberately.
*
* The two shapes are not two encodings of the same thing. The CSV is a dose
* log: `Date` is when a dose was actually taken and `Scheduled Date` is the slot
* it belonged to, side by side. The documented JSON carries `scheduledDate`,
* `status`, `dosage`, and the parent medication's `start` / `end` / `form` — and
* no field for the time a dose was actually taken.
*
* So a `Taken` dose from the JSON shape could only be written by putting the
* scheduled time in `takenAt`, which asserts every dose was taken to the minute
* it was due. That is the exact flattening this whole path exists to undo: it
* manufactures a spotless on-time history out of a file that never claimed one,
* and no later correction can recover what was overwritten. A refusal that names
* the missing field leaves the person able to export the CSV, which has it.
*
* The shape is still parsed far enough to tell a JSON medication export apart
* from unreadable text, so the answer can be that sentence rather than a
* generic failure.
*/
export function parseAutoExportJson(text: string): AutoExportParseOutcome {
let decoded: unknown;
try {
decoded = JSON.parse(text);
} catch {
return { ...EMPTY_OUTCOME, fatal: { reason: "unreadable_json" } };
}
// The export wraps the array under a key in some of its output modes; both
// the bare array and a single-key envelope are recognised.
let list: unknown = decoded;
if (!Array.isArray(list) && typeof list === "object" && list !== null) {
const nested = Object.values(list as Record<string, unknown>).find(
(value) => Array.isArray(value),
);
if (nested) list = nested;
}
if (!Array.isArray(list)) {
return { ...EMPTY_OUTCOME, fatal: { reason: "json_not_an_array" } };
}
if (list.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
return {
...EMPTY_OUTCOME,
rowsRead: list.length,
fatal: { reason: "json_carries_no_intake_time" },
};
}