mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four rulings instead of one. Taken and Skipped state a decision about a dose and each maps onto something already recorded here — a row with a take time, and a row marked skipped. That second mapping was checked rather than assumed: skipped is documented as a deliberate skip and held apart from autoMissed, which is the marker for a dose nobody ever acted on, and the compliance re-fold counts a skipped slot in its own column and never as taken. So a deliberate skip arrives as one. Not Interacted, Not Logged and Unspecified are absences rather than statements. Written as taken they would invent a dose; written as skipped they would invent a decision, because not answering a notification is not choosing not to take something; written as pending they would put outstanding doses from years ago in front of someone today. Snoozed describes what happened to a reminder. Notification Not Sent is about the software. None of the five becomes a row. All five are refused, but each under its own name, because "the file says nothing about this dose" and "this row is about a reminder" are different things and a person acts on them differently. Three reasons where there was one, each with its own sentence in six locales. An inactive medication's history imports and the medication stays inactive. Preserving what happened and putting a medication back on the active list are two acts, and only the first belongs to an import.
This commit is contained in:
1 parent
36d589e11b
commit
b627b92809
17 files changed
+216
-52
No files matched your search
@@ -127,7 +127,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
|
||||
rowsRead: 3,
|
||||
queued: 2,
|
||||
refused: 1,
|
||||
refusedByReason: [{ reason: "status_not_recorded", count: 1 }],
|
||||
refusedByReason: [{ reason: "status_no_dose_information", count: 1 }],
|
||||
unmatchedMedications: [],
|
||||
ambiguousMedications: [],
|
||||
mirroredMedications: [],
|
||||
@@ -170,7 +170,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
|
||||
total: 2,
|
||||
imported: 0,
|
||||
// Seeded, so the count at the end of the run covers the whole file.
|
||||
skippedByReason: { status_not_recorded: 1 },
|
||||
skippedByReason: { status_no_dose_information: 1 },
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
},
|
||||
|
||||
@@ -58,7 +58,13 @@ function useSkipReasonLabel(): (reason: string) => string {
|
||||
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_no_dose_information: t(
|
||||
"medications.import.reasonStatusNoDoseInformation",
|
||||
),
|
||||
status_reminder_event: t("medications.import.reasonStatusReminderEvent"),
|
||||
status_notification_not_sent: t(
|
||||
"medications.import.reasonStatusNotificationNotSent",
|
||||
),
|
||||
status_unknown: t("medications.import.reasonStatusUnknown"),
|
||||
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
|
||||
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("<DoseHistoryVerdictView>", () => {
|
||||
rowsRead: 3395,
|
||||
queued: 3387,
|
||||
refused: 8,
|
||||
refusedByReason: [{ reason: "status_not_recorded", count: 8 }],
|
||||
refusedByReason: [{ reason: "status_no_dose_information", count: 8 }],
|
||||
unmatchedMedications: [],
|
||||
ambiguousMedications: [],
|
||||
mirroredMedications: [],
|
||||
|
||||
@@ -78,7 +78,9 @@ export const MEDICATION_IMPORT_SKIP_REASONS = [
|
||||
"medication_not_found",
|
||||
"medication_ambiguous",
|
||||
"medication_is_mirrored",
|
||||
"status_not_recorded",
|
||||
"status_no_dose_information",
|
||||
"status_reminder_event",
|
||||
"status_notification_not_sent",
|
||||
"status_unknown",
|
||||
"missing_timestamp",
|
||||
"missing_timezone_offset",
|
||||
|
||||
@@ -21,9 +21,12 @@ import {
|
||||
AUTO_EXPORT_CSV_COLUMNS,
|
||||
AUTO_EXPORT_JSON_FIELDS,
|
||||
AUTO_EXPORT_STATUSES,
|
||||
AUTO_EXPORT_ACTIONABLE_STATUSES,
|
||||
AUTO_EXPORT_STATUS_DISPOSITIONS,
|
||||
} from "../auto-export-format";
|
||||
import { AUTO_EXPORT_REFUSAL_REASONS } from "../auto-export-parse";
|
||||
import {
|
||||
AUTO_EXPORT_REFUSAL_REASONS,
|
||||
parseAutoExportCsv,
|
||||
} from "../auto-export-parse";
|
||||
|
||||
describe("export column rulings", () => {
|
||||
/**
|
||||
@@ -106,14 +109,70 @@ describe("export column rulings", () => {
|
||||
});
|
||||
|
||||
describe("status vocabulary", () => {
|
||||
it("treats only a recorded decision as actionable", () => {
|
||||
for (const status of AUTO_EXPORT_ACTIONABLE_STATUSES) {
|
||||
expect(AUTO_EXPORT_STATUSES).toContain(status);
|
||||
/**
|
||||
* The ruling per documented status, written out.
|
||||
*
|
||||
* The file that prompted this work contains three of the seven, so the other
|
||||
* four are documented-but-unobserved — and an unobserved status is exactly the
|
||||
* one a later edit can quietly promote into a dose, because no fixture would
|
||||
* notice. Restating all seven means promoting any of them has to be typed
|
||||
* twice.
|
||||
*/
|
||||
const EXPECTED: Record<string, string> = {
|
||||
Taken: "dose_taken",
|
||||
Skipped: "dose_skipped",
|
||||
"Not Interacted": "no_dose_information",
|
||||
"Not Logged": "no_dose_information",
|
||||
Unspecified: "no_dose_information",
|
||||
Snoozed: "reminder_event",
|
||||
"Notification Not Sent": "app_event",
|
||||
};
|
||||
|
||||
it("holds the ruling each documented status was decided to have", () => {
|
||||
expect({ ...AUTO_EXPORT_STATUS_DISPOSITIONS }).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it("rules on every documented status, and on nothing else", () => {
|
||||
expect(Object.keys(AUTO_EXPORT_STATUS_DISPOSITIONS).sort()).toEqual(
|
||||
[...AUTO_EXPORT_STATUSES].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a row for exactly the two statuses that state a decision", () => {
|
||||
const header =
|
||||
"Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings\n";
|
||||
const now = Date.parse("2026-07-01T00:00:00.000Z");
|
||||
// One row per documented status, so every status is exercised — including
|
||||
// the four the reported file never contained.
|
||||
for (const status of AUTO_EXPORT_STATUSES) {
|
||||
const outcome = parseAutoExportCsv(
|
||||
`${header}2025-01-01 08:00:00 +1030,2025-01-01 08:00:00 +1030,Med,Med,1.0,1.0,count,${status},No,\n`,
|
||||
{ now },
|
||||
);
|
||||
const expectsDose =
|
||||
EXPECTED[status] === "dose_taken" ||
|
||||
EXPECTED[status] === "dose_skipped";
|
||||
expect(outcome.doses.length, `${status} → dose`).toBe(
|
||||
expectsDose ? 1 : 0,
|
||||
);
|
||||
if (expectsDose) {
|
||||
// A skip is a dose not taken; only `Taken` carries a take time. Neither
|
||||
// is inferred from the other.
|
||||
expect(outcome.doses[0].takenAt === null).toBe(
|
||||
EXPECTED[status] === "dose_skipped",
|
||||
);
|
||||
} else {
|
||||
// Refused, and under a reason of its own rather than a shared total.
|
||||
expect(outcome.refusals, `${status} → refusal`).toHaveLength(1);
|
||||
expect(outcome.refusals[0].reason).toBe(
|
||||
{
|
||||
no_dose_information: "status_no_dose_information",
|
||||
reminder_event: "status_reminder_event",
|
||||
app_event: "status_notification_not_sent",
|
||||
}[EXPECTED[status]],
|
||||
);
|
||||
}
|
||||
}
|
||||
// The other five documented values name the absence of a decision. Widening
|
||||
// this set means deciding what row to write for "nobody acted", which is a
|
||||
// compliance judgement the export never made.
|
||||
expect(AUTO_EXPORT_ACTIONABLE_STATUSES).toEqual(["Taken", "Skipped"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ describe("parseAutoExportCsv — the reported export", () => {
|
||||
// The file holds `Taken`, `Skipped` and `Not Interacted`. The first two are
|
||||
// decisions the person made; the eight `Not Interacted` rows are the absence
|
||||
// of one, and there is no honest dose to write for them.
|
||||
expect(reasonCounts(parseFixture())).toEqual({ status_not_recorded: 8 });
|
||||
expect(reasonCounts(parseFixture())).toEqual({
|
||||
status_no_dose_information: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the scheduled slot and the time taken apart", () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("planAutoExportImport — the reported export", () => {
|
||||
expect(
|
||||
plan.entries.length + totalMedicationImportSkips(plan.skippedByReason),
|
||||
).toBe(plan.rowsRead);
|
||||
expect(plan.skippedByReason).toEqual({ status_not_recorded: 8 });
|
||||
expect(plan.skippedByReason).toEqual({ status_no_dose_information: 8 });
|
||||
});
|
||||
|
||||
it("names the medications it could not match instead of reporting a number", () => {
|
||||
|
||||
@@ -98,37 +98,76 @@ export const AUTO_EXPORT_JSON_FIELDS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Every status the format documents.
|
||||
* What each documented status is, and therefore what the importer does with it.
|
||||
*
|
||||
* `Taken` and `Skipped` each name a decision the person made about a dose, and
|
||||
* HealthLog already models both — a taken row and a deliberate skip. The rest
|
||||
* name the absence of a decision: nobody acted, or the app never asked. There
|
||||
* is no honest row to write for those. Turning "nobody acted" into a missed
|
||||
* dose would be the importer inventing a compliance fact the export never
|
||||
* asserted, and writing them as still-pending would put outstanding doses from
|
||||
* 2023 back in front of the person today.
|
||||
* Seven values, and they are not seven shades of one thing — they divide into
|
||||
* four kinds, so they get four rulings rather than one blanket rule.
|
||||
*
|
||||
* `dose_taken` / `dose_skipped` — the two that state a decision the person made
|
||||
* about a dose. Both map one-to-one onto something HealthLog already records: a
|
||||
* row with a `takenAt`, and a row with `skipped = true`. That second mapping was
|
||||
* checked rather than assumed — `skipped` is documented as a deliberate user
|
||||
* skip, held apart from `autoMissed`, which is the marker for a dose nobody ever
|
||||
* acted on. So a deliberate skip in the export lands as a deliberate skip here,
|
||||
* and not as a miss.
|
||||
*
|
||||
* `no_dose_information` — `Not Interacted`, `Not Logged`, `Unspecified`. These
|
||||
* are absences, not statements. Nobody answered, or nothing was written down.
|
||||
* Writing them as taken would invent a dose; writing them as skipped would
|
||||
* invent a decision, because "did not answer the notification" is not "chose not
|
||||
* to take it"; writing them as still-pending would put outstanding doses from
|
||||
* years ago back in front of the person today. So they are counted, reported,
|
||||
* and not written. Absence reads as absence.
|
||||
*
|
||||
* `reminder_event` — `Snoozed` describes what happened to a reminder. A snoozed
|
||||
* reminder says nothing at all about whether the medication was taken.
|
||||
*
|
||||
* `app_event` — `Notification Not Sent` is a fact about the software, not about
|
||||
* the person. It belongs to no dose.
|
||||
*
|
||||
* The three non-dose kinds are all refused, but each under its own name, because
|
||||
* "the file says nothing about this dose" and "this row is about a reminder" are
|
||||
* different things and a person reading the result acts on them differently.
|
||||
*/
|
||||
export const AUTO_EXPORT_STATUS_DISPOSITIONS = {
|
||||
Taken: "dose_taken",
|
||||
Skipped: "dose_skipped",
|
||||
"Not Interacted": "no_dose_information",
|
||||
"Not Logged": "no_dose_information",
|
||||
Unspecified: "no_dose_information",
|
||||
Snoozed: "reminder_event",
|
||||
"Notification Not Sent": "app_event",
|
||||
} as const satisfies Record<string, AutoExportStatusDisposition>;
|
||||
|
||||
export type AutoExportStatusDisposition =
|
||||
| "dose_taken"
|
||||
| "dose_skipped"
|
||||
| "no_dose_information"
|
||||
| "reminder_event"
|
||||
| "app_event";
|
||||
|
||||
/** Every status the format documents, in the order the documentation lists it. */
|
||||
export const AUTO_EXPORT_STATUSES = [
|
||||
"Taken",
|
||||
"Skipped",
|
||||
"Not Interacted",
|
||||
"Notification Not Sent",
|
||||
"Snoozed",
|
||||
"Taken",
|
||||
"Skipped",
|
||||
"Not Logged",
|
||||
"Unspecified",
|
||||
] as const;
|
||||
|
||||
export type AutoExportStatus = (typeof AUTO_EXPORT_STATUSES)[number];
|
||||
|
||||
/** The two statuses that become a row. */
|
||||
export const AUTO_EXPORT_ACTIONABLE_STATUSES = ["Taken", "Skipped"] as const;
|
||||
|
||||
export function isAutoExportStatus(value: string): value is AutoExportStatus {
|
||||
return (AUTO_EXPORT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isActionableAutoExportStatus(value: string): boolean {
|
||||
return (AUTO_EXPORT_ACTIONABLE_STATUSES as readonly string[]).includes(value);
|
||||
/** The ruling for a documented status. */
|
||||
export function autoExportStatusDisposition(
|
||||
status: AutoExportStatus,
|
||||
): AutoExportStatusDisposition {
|
||||
return AUTO_EXPORT_STATUS_DISPOSITIONS[status];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,8 +19,8 @@ import { splitCsvRows } from "@/lib/import/csv-measurements";
|
||||
|
||||
import {
|
||||
AUTO_EXPORT_CSV_COLUMNS,
|
||||
autoExportStatusDisposition,
|
||||
formatAutoExportDose,
|
||||
isActionableAutoExportStatus,
|
||||
isAutoExportStatus,
|
||||
} from "./auto-export-format";
|
||||
import {
|
||||
@@ -60,8 +60,12 @@ export interface AutoExportDose {
|
||||
* 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",
|
||||
/** `Not Interacted` / `Not Logged` / `Unspecified` — an absence, not a dose. */
|
||||
"status_no_dose_information",
|
||||
/** `Snoozed` — something that happened to a reminder, not to a dose. */
|
||||
"status_reminder_event",
|
||||
/** `Notification Not Sent` — a fact about the app, not about the person. */
|
||||
"status_notification_not_sent",
|
||||
/** Status is not a value the format documents at all. */
|
||||
"status_unknown",
|
||||
/** `Date` is blank — there is no dose without an instant. */
|
||||
@@ -214,8 +218,19 @@ function readRow(input: RowInput, now: number): RowOutcome {
|
||||
if (!isAutoExportStatus(status)) {
|
||||
return { ok: false, reason: "status_unknown" };
|
||||
}
|
||||
if (!isActionableAutoExportStatus(status)) {
|
||||
return { ok: false, reason: "status_not_recorded" };
|
||||
// Four kinds, four answers. Only the two that state a decision about a dose
|
||||
// become a row; the others are refused under the name of what they actually
|
||||
// are, so the person is told "this row is about a reminder" rather than being
|
||||
// left to read a total.
|
||||
const disposition = autoExportStatusDisposition(status);
|
||||
if (disposition === "no_dose_information") {
|
||||
return { ok: false, reason: "status_no_dose_information" };
|
||||
}
|
||||
if (disposition === "reminder_event") {
|
||||
return { ok: false, reason: "status_reminder_event" };
|
||||
}
|
||||
if (disposition === "app_event") {
|
||||
return { ok: false, reason: "status_notification_not_sent" };
|
||||
}
|
||||
|
||||
const medicationName = input.medication.trim();
|
||||
@@ -262,7 +277,7 @@ function readRow(input: RowInput, now: number): RowOutcome {
|
||||
const scheduledDosage = readDosage(input.scheduledDosage);
|
||||
if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
|
||||
|
||||
const skipped = status === "Skipped";
|
||||
const skipped = disposition === "dose_skipped";
|
||||
return {
|
||||
ok: true,
|
||||
dose: {
|
||||
|
||||
Reference in new issue
Block a user