fix(medications): read a pinned dose the same as the dose on its own

Automatic matching counts a dose taken up to an hour before a slot's
on-time window as on time. A dose pinned to the slot was judged from the
window's start instead, so the same dose read on time unpinned and late
once pinned. A pinned dose now uses the same early grace, and the
confirmation says so plainly: the dose counts as if it had been logged for
the slot, on time or late depending on when it was taken.

Refs #1028
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 19:54:37 +02:00
1 parent eafef5adeb
commit 47c7c71802
9 files changed
+50 -35

No files matched your search

@@ -15,7 +15,11 @@
*/
import { describe, expect, it } from "vitest";
import { buildSlotBands, type SlotWindowInput } from "../attribution";
import {
attributeIntakeToSlot,
buildSlotBands,
type SlotWindowInput,
} from "../attribution";
import {
reconstructDoseHistory,
suggestNearestSlot,
@@ -284,28 +288,36 @@ describe("reconstructDoseHistory", () => {
expect(morning?.pinned).toBe(true);
});
// The pin confirm dialog promises exactly this split: on time inside the
// slot's on-time window (also ahead of the slot's clock time), late
// otherwise.
it("an early pin inside the on-time window reads on time, one before it reads late", () => {
const early = reconstructDoseHistory(
bands,
[intake({ takenAt: at(6, 15), scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
);
expect(early.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_on_time",
);
const tooEarly = reconstructDoseHistory(
bands,
[intake({ takenAt: at(5, 30), scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
);
expect(tooEarly.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_late",
);
// A pinned take reads exactly as the same take attributed on its own:
// on time from the early grace before the on-time window through the
// window's end, late otherwise. Pinning never changes the timing.
it("a pinned early take reads the same as the unpinned take", () => {
for (const [h, m] of [
[6, 15],
[5, 30],
[4, 30],
] as const) {
const takenAt = at(h, m);
const pinned = reconstructDoseHistory(
bands,
[intake({ takenAt, scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
).find((r) => r.timeOfDay === "07:00")?.status;
const unpinned = attributeIntakeToSlot(takenAt, bands);
const expected =
unpinned?.band.timeOfDay === "07:00"
? unpinned.status === "on_time"
? "taken_on_time"
: "taken_late"
: // Outside every capture band on its own: a pin can only make
// it late, never on time.
"taken_late";
expect(pinned, `${h}:${m}`).toBe(expected);
}
// 05:30 lies in the early grace (on time either way), 04:30 before it.
expect(attributeIntakeToSlot(at(5, 30), bands)?.status).toBe("on_time");
expect(attributeIntakeToSlot(at(4, 30), bands)).toBeNull();
});
it("a pin inside the late tail reads taken_late, never flattered", () => {
@@ -22,8 +22,9 @@
* `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like
* a skip, NOT by takenAt-band membership, so a pin outside the late tail
* never degrades back to ad-hoc. Status is `taken_late` unless the
* takenAt happens to sit inside the slot's on-time band — a pin can
* never flatter the timing;
* takenAt sits where the take would read on time on its own (the
* on-time band plus its bounded early grace) — a pin never changes the
* timing;
* - a RELEASED pin (v1.16.0 — "Zuordnung lösen" persists `USER_PIN` with
* `scheduledFor === takenAt`) is a deliberately ad-hoc take: it never
* anchor-binds (not even when its instant sits within epsilon of a slot
@@ -237,8 +238,9 @@ export function reconstructDoseHistory(
// v1.15.20 — pinned takes bind by their stored slot anchor, NOT by
// takenAt-band membership: the whole point of a pin is that the take sits
// outside (or past the tail of) the band it belongs to. Status never
// flatters: taken_late, unless the takenAt happens to sit inside the
// slot's own on-time band anyway. A pin whose slot is gone (schedule
// flatters: taken_late, unless the takenAt sits where band attribution
// would read it on time anyway, the early grace included, so the same
// take reads the same pinned or not. A pin whose slot is gone (schedule
// changed) or already claimed falls through to ad-hoc so nothing vanishes.
for (const i of pinnedTaken) {
// v1.16.0 — a released pin ("Zuordnung lösen") persists USER_PIN with
@@ -254,7 +256,8 @@ export function reconstructDoseHistory(
if (band && !claim.has(band)) {
const t = (i.takenAt as Date).getTime();
const onTime =
t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime();
t >= (band.earlyStart ?? band.onTimeStart).getTime() &&
t <= band.onTimeEnd.getTime();
claim.set(band, {
intake: i,
status: onTime ? "taken_on_time" : "taken_late",