mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): never snap a taken intake onto a future dose slot
A taken intake whose write named (or snapped near) a slot still in the future was attributed to that slot and rendered as taken before its time — a late morning dose collapsing onto the evening slot of a twice-daily med. The slot resolver now takes an isTakenWrite flag and a now reference: a taken write skips any candidate slot more than a small clock-skew grace into the future, so it snaps to a current/past slot or keeps its own row. Pending sync echoes are unaffected and still map to future slots.
This commit is contained in:
1 parent
212e8c9c04
commit
3ec80adbd2
7 files changed
+148
-2
No files matched your search
@@ -19,7 +19,7 @@
|
||||
* cares about the Prisma `where` fragment derived from the new knob.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -391,6 +391,19 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the clock past the 07:00 / 19:00 CEST slots of 2026-06-15 so the
|
||||
// dose-safety future-slot guard treats these taken writes as landing on
|
||||
// a current/past slot. Otherwise the fixtures sit days ahead of the
|
||||
// real test clock and the guard refuses to snap a taken write forward.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
|
||||
@@ -159,6 +159,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// `takenAt`-only / defaulted-now write must not snap across the wide
|
||||
// ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: scheduledFor !== undefined,
|
||||
// Dose-safety: a taken write (any non-skip on this route) must never
|
||||
// snap forward onto a future slot — see resolve-slot-instant.ts.
|
||||
isTakenWrite: isExplicitTaken,
|
||||
});
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
|
||||
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -186,6 +186,13 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the clock past both fixture slots (07:00 / 19:00 CEST on
|
||||
// 2026-06-15) so the dose-safety future-slot guard treats these taken
|
||||
// writes as landing on a current/past slot, not a future one. Without
|
||||
// the pin these fixtures are dated days ahead of the real test clock
|
||||
// and the guard (correctly) refuses to snap a taken write forward.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
@@ -194,6 +201,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
|
||||
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
|
||||
// echo (no takenAt, skipped false) — must NOT clear takenAt.
|
||||
|
||||
@@ -238,6 +238,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// `takenAt`-only / defaulted-now write must not snap across the
|
||||
// wide ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Dose-safety: a taken write (has `takenAt`, not skipped) must never
|
||||
// snap forward onto a future slot. A pending sync echo (no `takenAt`)
|
||||
// legitimately maps to a future slot, so the guard stays off for it.
|
||||
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
|
||||
});
|
||||
|
||||
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
@@ -87,6 +87,70 @@ describe("resolveCanonicalSlotInstant — multi-time", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — taken write never snaps to a future slot (dose-safety)", () => {
|
||||
// Daily 07:00 / 19:00 med. A late morning dose taken at 15:00 CEST must
|
||||
// not snap forward onto the 19:00 slot (which is still in the future) and
|
||||
// render as "taken" before its time — the iOS v0.13 dose-safety report.
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
|
||||
]);
|
||||
// iOS posts the reminder-action write carrying the slot's `scheduledFor`
|
||||
// (19:00) while the dose is actually actioned earlier (17:00) — the
|
||||
// explicit instant names the future evening slot exactly.
|
||||
const slot1900Instant = new Date("2026-06-15T17:00:00.000Z"); // 19:00 CEST
|
||||
const beforeEvening = new Date("2026-06-15T15:00:00.000Z"); // 17:00 CEST
|
||||
|
||||
it("LEGACY (no flag): an explicit 19:00 write snaps to the 19:00 slot", () => {
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: slot1900Instant,
|
||||
});
|
||||
const slot1900 = localHmAsUtc(slot1900Instant, TZ, 19, 0);
|
||||
expect(result?.toISOString()).toBe(slot1900.toISOString());
|
||||
});
|
||||
|
||||
it("taken write naming the 19:00 slot, but actioned at 17:00, does NOT snap onto the future slot", () => {
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: slot1900Instant,
|
||||
isTakenWrite: true,
|
||||
now: beforeEvening,
|
||||
});
|
||||
// 19:00 is still in the future relative to now (17:00) → excluded;
|
||||
// 07:00 is 12h from the incoming instant → beyond tolerance → null.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("taken write AT the 19:00 slot still snaps (slot is not in the future)", () => {
|
||||
const evening = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: evening,
|
||||
isTakenWrite: true,
|
||||
now: evening,
|
||||
});
|
||||
const slot1900 = localHmAsUtc(evening, TZ, 19, 0);
|
||||
expect(result?.toISOString()).toBe(slot1900.toISOString());
|
||||
});
|
||||
|
||||
it("taken write a few minutes early still snaps onto its slot (skew grace)", () => {
|
||||
// 06:57 CEST, slot 07:00 — within the 5-minute forward grace.
|
||||
const earlyTake = new Date("2026-06-15T04:57:00.000Z");
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: earlyTake,
|
||||
isTakenWrite: true,
|
||||
now: earlyTake,
|
||||
});
|
||||
const slot0700 = localHmAsUtc(earlyTake, TZ, 7, 0);
|
||||
expect(result?.toISOString()).toBe(slot0700.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — defaulted-now midday (phantom morning dose)", () => {
|
||||
// The phantom morning-dose bug: a 07:00 / 19:00 med whose 07:00 slot got
|
||||
// auto-marked taken because a slot-less midday "taken now" write
|
||||
|
||||
@@ -39,6 +39,19 @@ const ONE_MINUTE_MS = 60_000;
|
||||
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
|
||||
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
||||
|
||||
/**
|
||||
* Forward grace for a TAKEN write (dose-safety, iOS v0.13 report).
|
||||
*
|
||||
* A `.taken` intake must never be attributed to a slot whose instant is in
|
||||
* the future — a dose the user has not yet taken would otherwise render as
|
||||
* "taken" before its time (a late morning dose snapping forward onto the
|
||||
* evening slot of a twice-daily med). We still allow a small forward window
|
||||
* so a dose taken a few minutes early, or with minor client/server clock
|
||||
* skew, snaps onto its own slot as intended. Anything further into the
|
||||
* future is rejected as a snap target and the write keeps its own row.
|
||||
*/
|
||||
const TAKEN_FORWARD_GRACE_MS = 5 * ONE_MINUTE_MS;
|
||||
|
||||
/**
|
||||
* Default snap tolerance when a schedule has no usable window span. ±2h
|
||||
* is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift
|
||||
@@ -90,6 +103,23 @@ export interface ResolveSlotInput {
|
||||
* keep the legacy snap behaviour unchanged.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
/**
|
||||
* Whether this write records a TAKEN dose (has a `takenAt`). When `true`,
|
||||
* the snap will never pick a slot whose instant is meaningfully in the
|
||||
* future (> `now` + `TAKEN_FORWARD_GRACE_MS`) — a taken dose can never be
|
||||
* attributed to a slot the user has not reached yet. A future slot is
|
||||
* simply not a candidate, so the write either snaps to a past/current
|
||||
* slot within tolerance or falls through to `null` (standalone row).
|
||||
*
|
||||
* Defaults to `false` so read-only / projection callers (UI display,
|
||||
* pending-row dedup) keep snapping to future slots as before.
|
||||
*/
|
||||
isTakenWrite?: boolean;
|
||||
/**
|
||||
* Reference "now" for the `isTakenWrite` future-slot guard. Defaults to
|
||||
* the current time; injectable for deterministic tests.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +137,8 @@ export function resolveCanonicalSlotInstant(
|
||||
): Date | null {
|
||||
const { medication, userTz, incoming } = input;
|
||||
const instantIsExplicit = input.instantIsExplicit ?? true;
|
||||
const isTakenWrite = input.isTakenWrite ?? false;
|
||||
const nowMs = (input.now ?? new Date()).getTime();
|
||||
const schedules = medication.schedules ?? [];
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
@@ -170,6 +202,15 @@ export function resolveCanonicalSlotInstant(
|
||||
// and keep the canonical engine only for DECIDING which slots exist
|
||||
// (PRN / cyclic / rrule / rolling gating).
|
||||
const slotInstant = canonicalSlotInstant(occ.at, occ.timeOfDay, userTz);
|
||||
|
||||
// Dose-safety: a taken write may never be attributed to a slot that is
|
||||
// still in the future (beyond a small clock-skew/early-dose grace). A
|
||||
// late morning dose must not snap forward onto the evening slot of a
|
||||
// twice-daily med and render as "taken" before its time.
|
||||
if (isTakenWrite && slotInstant.getTime() > nowMs + TAKEN_FORWARD_GRACE_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deltaMs = Math.abs(slotInstant.getTime() - incoming.getTime());
|
||||
if (deltaMs > tolerance) continue;
|
||||
if (best === null || deltaMs < best.deltaMs) {
|
||||
|
||||
@@ -322,6 +322,14 @@ export interface ResolveSlotForWriteInput {
|
||||
* Defaults to `true` to keep the legacy snap for callers that omit it.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
/**
|
||||
* Whether this write records a TAKEN dose. When `true`, the snap never
|
||||
* targets a future slot (dose-safety: a taken dose can't be attributed to
|
||||
* a slot the user hasn't reached). Defaults to `false`.
|
||||
*/
|
||||
isTakenWrite?: boolean;
|
||||
/** Reference "now" for the taken future-slot guard; defaults to current time. */
|
||||
now?: Date;
|
||||
/** Inject a Prisma client/tx in tests; defaults to the app client. */
|
||||
client?: PrismaLike;
|
||||
}
|
||||
@@ -375,5 +383,7 @@ export async function resolveSlotInstantForWrite(
|
||||
incoming: input.incoming,
|
||||
lastIntakeAt,
|
||||
instantIsExplicit: input.instantIsExplicit ?? true,
|
||||
isTakenWrite: input.isTakenWrite ?? false,
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user