diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 32f386400..b407f67b8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5985,13 +5985,15 @@ paths: schema: description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a - recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned. + recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own + placeholder for such a slot is not returned. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded - dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned. + row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for + such a slot is not returned. - in: query name: to schema: @@ -43266,8 +43268,8 @@ components: minimum: 0 maximum: 9007199254740991 description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the - medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before - the medication existed are never missed." + medication's `createdAt` counts only when a recorded row (a take, a skip, or a miss carried in with restored + history) claims it, so the days before the medication existed are never missed." taken: type: integer minimum: 0 diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index ac5196362..56eb094ce 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -657,3 +657,55 @@ describe("reconstructDoseHistory — slots before the medication existed (#1028) expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); }); }); + +/** + * #1028 — history recorded before the medication was created (an import, a + * restore from an older file). Only what the history records counts: a slot + * no row names stays absent rather than being invented as missed, and a miss + * the history does record is not hidden. The projector's placeholders on the + * creation day are the one pre-creation auto-miss that records nothing. + */ +describe("reconstructDoseHistory — history from before the creation (#1028)", () => { + // Created two days after `day`, so both of `day`'s slots predate it. + const createdAt = new Date(at(12, 0).getTime() + 2 * 24 * 60 * MIN); + const statusRows = (rows: ReturnType) => + rows.map((r) => [r.kind, r.timeOfDay, r.status, r.intake?.id ?? null]); + + it("keeps a carried-in auto-miss as the miss it records", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) }), + intake({ id: "missed", scheduledFor: at(19, 0), autoMissed: true }), + ], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([ + ["slot", "07:00", "taken_on_time", "taken"], + ["slot", "19:00", "missed", "missed"], + ]); + }); + + it("never invents a miss for a slot the history does not name", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([ + ["slot", "07:00", "taken_on_time", "taken"], + ]); + }); + + it("still drops a pending row carried in from before the creation", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "pending", scheduledFor: at(19, 0) })], + nowEvening, + createdAt, + ); + expect(rows).toEqual([]); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index f496350a1..357c679bf 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -32,11 +32,9 @@ * - each slot is claimed by at most one intake (first/best wins); extra * intakes near a filled slot fall through to ad-hoc; * - a slot anchored before `expectedFrom` (the medication's creation) was - * never expected: it appears only when a recorded dose claims it (a - * take by band or pin, or a skip). A pending or auto-missed row on such - * a slot is the placeholder the today projector mints for every slot of - * the creation day, not a user action, so it is dropped rather than - * read as a miss or an off-schedule row (#1028). + * never expected: it appears only when a recorded row claims it (#1028). + * See `expectedFrom` below for why the boundary is the creation and + * which pre-creation rows count as recorded. * * Pure / synchronous / instant-based — the caller mints the bands DST-correctly * via `localHmAsUtc` and supplies the per-dose window. @@ -131,6 +129,13 @@ export interface DoseHistoryRow { nearestSlot?: NearestSlotContext; } +/** + * How far before a medication's creation the today projector's placeholders + * can sit: it mints the creation's local day only, which starts less than a + * day before the creation. + */ +const PLACEHOLDER_REACH_MS = 24 * 60 * 60 * 1000; + /** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */ const ANCHOR_EPSILON_MS = 60_000; @@ -143,15 +148,45 @@ export function reconstructDoseHistory( intakes: HistoryIntake[], now: Date, /** - * The instant from which slots are expected, the medication's creation. - * Bands anchored earlier appear only when a recorded dose claims them. - * `null` treats every band as expected (a caller that already minted from - * the creation instant, or a pure-math fixture). + * The instant from which slots are expected: the medication's creation. + * `null` treats every band as expected (a pure-math fixture). + * + * Before it, a slot appears only when a recorded row claims it. Rows get + * there three ways: a dose logged for earlier on the creation day, history + * imported from another app, and history restored from a backup. The + * boundary is chosen so that nothing is invented and nothing recorded is + * hidden: + * + * - An earlier boundary (the first recorded row, or the course start + * date) would read every slot between it and the creation that no row + * names as missed. Nothing supports that reading: an import carries + * takes and skips only (reminder and "no dose information" rows are + * dropped on the way in), a partial or single back-dated entry names a + * few days, and a course start says when the person began, not that + * the unlogged days were missed. Those misses would be invented. + * - With the creation as the boundary, every recorded take (by its + * window or its pin) and skip still claims its slot, and a recorded + * miss does too: an auto-miss anchored more than a day before the + * creation came in with the history (a restore), and it keeps its + * slot as missed. + * - The rows that record nothing are dropped: a pending row, and an + * auto-miss within the day before the creation. The today projector + * mints a placeholder for every slot of the creation day, and a + * placeholder anchored before the creation is the only pre-creation + * auto-miss the server itself can have produced; the day's start is + * less than 24 hours before the creation, which bounds them. */ expectedFrom: Date | null, ): DoseHistoryRow[] { const expectedFromMs = expectedFrom?.getTime() ?? -Infinity; const beforeExpected = (at: Date): boolean => at.getTime() < expectedFromMs; + // A pre-creation row that records nothing: pending, or an auto-miss inside + // the creation day's placeholder reach (see `expectedFrom`). + const recordsNothing = (i: HistoryIntake): boolean => { + if (i.skipped || !beforeExpected(i.scheduledFor)) return false; + if (!i.autoMissed) return true; + return i.scheduledFor.getTime() >= expectedFromMs - PLACEHOLDER_REACH_MS; + }; // Per-band claim: the intake attributed to it + the resolved status. const claim = new Map< SlotBand, @@ -170,9 +205,9 @@ export function reconstructDoseHistory( const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned); for (const i of anchored) { - // A pending or auto-missed row before the medication existed records - // nothing: drop it whether or not its slot was minted. - if (!i.skipped && beforeExpected(i.scheduledFor)) continue; + // A pre-creation placeholder records nothing: drop it whether or not + // its slot was minted. + if (recordsNothing(i)) continue; const band = nearestAnchorBand(i.scheduledFor, bands); if (band && !claim.has(band)) { // Status is time-aware for a pending row: the projector / reminder diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 8d42e217c..3390b68a2 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -571,7 +571,7 @@ export const complianceResult = z .int() .nonnegative() .describe( - "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before the medication existed are never missed.", + "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it, so the days before the medication existed are never missed.", ), taken: z.number().int().nonnegative(), skipped: z @@ -911,7 +911,7 @@ export const doseHistoryQuery = z.object({ .datetime({ offset: true }) .optional() .describe( - "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned.", + "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned.", ), to: z.iso .datetime({ offset: true })