mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): keep a recorded miss from before the medication's creation
The history view treated every auto-miss anchored before the medication's creation as the today projector's placeholder and dropped it. That is true only on the creation day. History that arrives from before the creation, a restore of an older file for one, can carry real misses, and those vanished from the history and the rates. The creation stays the boundary of expected doses: an earlier one, the first recorded dose or the course start date, would read every day between it and the creation that no row names as missed, and an import carries takes and skips only, so those misses would be invented. Before it, every recorded take, skip and miss claims its slot. Only rows that record nothing are dropped: a pending row, and an auto-miss within the day before the creation, which is where the projector's placeholders sit. Refs #1028
This commit is contained in:
1 parent
f028d9c55b
commit
eafef5adeb
4 files changed
+107
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<typeof reconstructDoseHistory>) =>
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user