mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "kein Medikament im Bestand passt zum Namen in der Datei",
|
||||
"reasonMedicationAmbiguous": "der Name in der Datei passt auf mehr als ein Medikament",
|
||||
"reasonMedicationIsMirrored": "das Medikament ist eine schreibgeschützte Kopie aus einer anderen App",
|
||||
"reasonStatusNotRecorded": "die Datei hält zu dieser Dosis keine Entscheidung fest",
|
||||
"reasonStatusUnknown": "der Status in der Datei gehört nicht zum dokumentierten Format",
|
||||
"reasonMissingTimestamp": "die Zeile nennt keine Zeit",
|
||||
"reasonMissingOffset": "die Zeit trägt keinen UTC-Offset, der Zeitpunkt ist damit unbekannt",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "die Zeit nennt keinen echten Zeitpunkt oder liegt in der Zukunft",
|
||||
"reasonMissingMedication": "die Zeile nennt kein Medikament",
|
||||
"reasonUnreadableDosage": "die Dosis ist keine Zahl",
|
||||
"reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile"
|
||||
"reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile",
|
||||
"reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen",
|
||||
"reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis",
|
||||
"reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "no medication on record matches the name in the file",
|
||||
"reasonMedicationAmbiguous": "the name in the file matches more than one medication on record",
|
||||
"reasonMedicationIsMirrored": "the medication is a read-only copy of another app's list",
|
||||
"reasonStatusNotRecorded": "the file records no decision about that dose",
|
||||
"reasonStatusUnknown": "the status in the file is not one the format defines",
|
||||
"reasonMissingTimestamp": "the row states no time",
|
||||
"reasonMissingOffset": "the time carries no UTC offset, so the instant is unknown",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "the time names no real instant, or lies in the future",
|
||||
"reasonMissingMedication": "the row names no medication",
|
||||
"reasonUnreadableDosage": "the dosage is not a number",
|
||||
"reasonUnreadableRow": "the row has fewer columns than the header"
|
||||
"reasonUnreadableRow": "the row has fewer columns than the header",
|
||||
"reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged",
|
||||
"reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose",
|
||||
"reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "ningún medicamento registrado coincide con el nombre del archivo",
|
||||
"reasonMedicationAmbiguous": "el nombre del archivo coincide con más de un medicamento",
|
||||
"reasonMedicationIsMirrored": "el medicamento es una copia de solo lectura de otra aplicación",
|
||||
"reasonStatusNotRecorded": "el archivo no registra ninguna decisión sobre esa dosis",
|
||||
"reasonStatusUnknown": "el estado del archivo no es uno de los que define el formato",
|
||||
"reasonMissingTimestamp": "la fila no indica ninguna hora",
|
||||
"reasonMissingOffset": "la hora no lleva desplazamiento UTC, así que el instante es desconocido",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "la hora no nombra un instante real, o está en el futuro",
|
||||
"reasonMissingMedication": "la fila no nombra ningún medicamento",
|
||||
"reasonUnreadableDosage": "la dosis no es un número",
|
||||
"reasonUnreadableRow": "la fila tiene menos columnas que la cabecera"
|
||||
"reasonUnreadableRow": "la fila tiene menos columnas que la cabecera",
|
||||
"reasonStatusNoDoseInformation": "el archivo no registra nada sobre esa dosis: nadie respondió y nada se anotó",
|
||||
"reasonStatusReminderEvent": "la fila trata de un recordatorio postergado, que no dice nada sobre la dosis",
|
||||
"reasonStatusNotificationNotSent": "la fila registra que no se envió ninguna notificación, lo cual habla de la aplicación y no de la dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "aucun médicament enregistré ne correspond au nom du fichier",
|
||||
"reasonMedicationAmbiguous": "le nom du fichier correspond à plusieurs médicaments",
|
||||
"reasonMedicationIsMirrored": "le médicament est une copie en lecture seule provenant d'une autre application",
|
||||
"reasonStatusNotRecorded": "le fichier ne consigne aucune décision sur cette dose",
|
||||
"reasonStatusUnknown": "le statut du fichier ne fait pas partie de ceux que définit le format",
|
||||
"reasonMissingTimestamp": "la ligne n'indique aucune heure",
|
||||
"reasonMissingOffset": "l'heure ne porte pas de décalage UTC, l'instant est donc inconnu",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "l'heure ne désigne aucun instant réel, ou se situe dans le futur",
|
||||
"reasonMissingMedication": "la ligne ne nomme aucun médicament",
|
||||
"reasonUnreadableDosage": "la dose n'est pas un nombre",
|
||||
"reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête"
|
||||
"reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête",
|
||||
"reasonStatusNoDoseInformation": "le fichier ne consigne rien sur cette dose : personne n'a répondu et rien n'a été noté",
|
||||
"reasonStatusReminderEvent": "la ligne concerne un rappel reporté, ce qui ne dit rien de la dose",
|
||||
"reasonStatusNotificationNotSent": "la ligne consigne qu'aucune notification n'a été envoyée, ce qui parle de l'application et non de la dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "nessun farmaco registrato corrisponde al nome nel file",
|
||||
"reasonMedicationAmbiguous": "il nome nel file corrisponde a più di un farmaco",
|
||||
"reasonMedicationIsMirrored": "il farmaco è una copia in sola lettura da un'altra app",
|
||||
"reasonStatusNotRecorded": "il file non registra alcuna decisione su quella dose",
|
||||
"reasonStatusUnknown": "lo stato nel file non è uno di quelli definiti dal formato",
|
||||
"reasonMissingTimestamp": "la riga non indica alcun orario",
|
||||
"reasonMissingOffset": "l'orario non porta un offset UTC, quindi l'istante è ignoto",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "l'orario non indica un istante reale, o è nel futuro",
|
||||
"reasonMissingMedication": "la riga non nomina alcun farmaco",
|
||||
"reasonUnreadableDosage": "il dosaggio non è un numero",
|
||||
"reasonUnreadableRow": "la riga ha meno colonne dell'intestazione"
|
||||
"reasonUnreadableRow": "la riga ha meno colonne dell'intestazione",
|
||||
"reasonStatusNoDoseInformation": "il file non registra nulla su quella dose: nessuno ha risposto e niente è stato annotato",
|
||||
"reasonStatusReminderEvent": "la riga riguarda un promemoria postposto, che non dice nulla sulla dose",
|
||||
"reasonStatusNotificationNotSent": "la riga registra che non è stata inviata alcuna notifica, il che riguarda l'app e non la dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
+4
-2
@@ -1921,7 +1921,6 @@
|
||||
"reasonMedicationNotFound": "żaden zapisany lek nie odpowiada nazwie z pliku",
|
||||
"reasonMedicationAmbiguous": "nazwa z pliku odpowiada więcej niż jednemu lekowi",
|
||||
"reasonMedicationIsMirrored": "lek jest kopią tylko do odczytu z innej aplikacji",
|
||||
"reasonStatusNotRecorded": "plik nie zapisuje żadnej decyzji o tej dawce",
|
||||
"reasonStatusUnknown": "status w pliku nie należy do tych, które definiuje format",
|
||||
"reasonMissingTimestamp": "wiersz nie podaje godziny",
|
||||
"reasonMissingOffset": "godzina nie ma przesunięcia UTC, więc moment jest nieznany",
|
||||
@@ -1929,7 +1928,10 @@
|
||||
"reasonImplausibleTimestamp": "godzina nie wskazuje rzeczywistego momentu albo leży w przyszłości",
|
||||
"reasonMissingMedication": "wiersz nie nazywa żadnego leku",
|
||||
"reasonUnreadableDosage": "dawka nie jest liczbą",
|
||||
"reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek"
|
||||
"reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek",
|
||||
"reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane",
|
||||
"reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce",
|
||||
"reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("dose-history import — the reported export end to end", () => {
|
||||
skipped: 8,
|
||||
// The eight `Not Interacted` rows: the file states no decision about those
|
||||
// doses, so there is nothing honest to write, and the count says which.
|
||||
skipReasons: [{ reason: "status_not_recorded", count: 8 }],
|
||||
skipReasons: [{ reason: "status_no_dose_information", count: 8 }],
|
||||
});
|
||||
// Every row of the file is accounted for by one of the two numbers.
|
||||
expect(result!.imported + result!.skipped).toBe(3395);
|
||||
@@ -223,7 +223,7 @@ describe("dose-history import — the reported export end to end", () => {
|
||||
skipped: 3395,
|
||||
skipReasons: [
|
||||
{ reason: "already_recorded", count: 3387 },
|
||||
{ reason: "status_not_recorded", count: 8 },
|
||||
{ reason: "status_no_dose_information", count: 8 },
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
@@ -252,6 +252,29 @@ describe("dose-history import — the reported export end to end", () => {
|
||||
).resolves.toBe(2);
|
||||
}, 240_000);
|
||||
|
||||
it("imports an inactive medication's history without putting it back on the active list", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedRegimen();
|
||||
// Standing in for a medication archived in the source app: the person has
|
||||
// stopped taking it here too. Importing the history it names is one act;
|
||||
// making the medication current again is a different one, and only the first
|
||||
// was asked for. Someone re-adding it later should find the history waiting,
|
||||
// not find it already re-added on their behalf.
|
||||
await prisma.medication.updateMany({
|
||||
where: { userId: user.id },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
const { jobId } = await enqueue(user.id);
|
||||
const result = await processMedicationIntakeImportJob(jobId);
|
||||
|
||||
expect(result!.imported).toBe(3387);
|
||||
const stillInactive = await prisma.medication.count({
|
||||
where: { userId: user.id, active: false },
|
||||
});
|
||||
expect(stillInactive).toBe(16);
|
||||
}, 240_000);
|
||||
|
||||
it("re-folds the compliance rollup for every medication and day it touched", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedRegimen();
|
||||
|
||||
@@ -167,7 +167,13 @@ describe("medication intake import job — concurrent retry", () => {
|
||||
total: 4,
|
||||
imported: 3,
|
||||
skippedByReason: { duplicate_in_file: 1 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21"],
|
||||
// A job can now cover a whole regimen, so a day whose rollup needs
|
||||
// re-folding is addressed by medication AND day — two medications touched
|
||||
// on one day are two rollup rows, which a bare day key cannot name.
|
||||
touchedDays: [
|
||||
`${medication.id} 2026-07-20`,
|
||||
`${medication.id} 2026-07-21`,
|
||||
],
|
||||
rollupProcessed: 2,
|
||||
});
|
||||
expect(audits).toHaveLength(1);
|
||||
|
||||
Reference in new issue
Block a user