diff --git a/messages/de.json b/messages/de.json index f1cfb2c16..55d67f5f4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index d7da7aa75..bfd7535dd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index f3e2c7dbc..f150bd1d9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 57333483f..50fdd82da 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 16e645775..aed593603 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 4a795f816..d959fa615 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts index dbdbe059d..a5ef639a6 100644 --- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts +++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts @@ -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, }, diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx index c87c600ba..4493fc219 100644 --- a/src/components/medications/intake-import-result.tsx +++ b/src/components/medications/intake-import-result.tsx @@ -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"), diff --git a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx index 9f54adce0..b35ca4fd8 100644 --- a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx +++ b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx @@ -91,7 +91,7 @@ describe("", () => { rowsRead: 3395, queued: 3387, refused: 8, - refusedByReason: [{ reason: "status_not_recorded", count: 8 }], + refusedByReason: [{ reason: "status_no_dose_information", count: 8 }], unmatchedMedications: [], ambiguousMedications: [], mirroredMedications: [], diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 6a11880a3..2fa7f0228 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -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", diff --git a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts index 9998cb3f2..01ba2bb4d 100644 --- a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts +++ b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts @@ -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 = { + 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"]); }); }); diff --git a/src/lib/medications/import/__tests__/auto-export-parse.test.ts b/src/lib/medications/import/__tests__/auto-export-parse.test.ts index 569d65cb0..0dbd8f5be 100644 --- a/src/lib/medications/import/__tests__/auto-export-parse.test.ts +++ b/src/lib/medications/import/__tests__/auto-export-parse.test.ts @@ -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", () => { diff --git a/src/lib/medications/import/__tests__/auto-export-plan.test.ts b/src/lib/medications/import/__tests__/auto-export-plan.test.ts index 9a14a3a8f..15db73415 100644 --- a/src/lib/medications/import/__tests__/auto-export-plan.test.ts +++ b/src/lib/medications/import/__tests__/auto-export-plan.test.ts @@ -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", () => { diff --git a/src/lib/medications/import/auto-export-format.ts b/src/lib/medications/import/auto-export-format.ts index 407eb4a4d..c5dca331e 100644 --- a/src/lib/medications/import/auto-export-format.ts +++ b/src/lib/medications/import/auto-export-format.ts @@ -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; + +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]; } /** diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts index 99d55f5e2..357d9ac76 100644 --- a/src/lib/medications/import/auto-export-parse.ts +++ b/src/lib/medications/import/auto-export-parse.ts @@ -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: { diff --git a/tests/integration/medication-dose-history-import.test.ts b/tests/integration/medication-dose-history-import.test.ts index ee85ce2cf..740c82890 100644 --- a/tests/integration/medication-dose-history-import.test.ts +++ b/tests/integration/medication-dose-history-import.test.ts @@ -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(); diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts index f5847ffde..85c031380 100644 --- a/tests/integration/medication-intake-import-job.test.ts +++ b/tests/integration/medication-intake-import-job.test.ts @@ -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);