mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): stop defaulted-now intake from auto-taking a far slot
A slot-less "taken now" write (no client scheduledFor) defaulted its instant to now/takenAt and snapped across the wide ±halfGap window onto the nearest scheduled slot. For a 07:00 / 19:00 med a midday write captured the 07:00 morning slot and stamped it taken — a phantom morning dose. The web fix only threaded the displayed slot on the card; any client posting takenAt-only (iOS bulk/sync) still tripped it server-side. Thread an instantIsExplicit flag through resolveSlotInstantForWrite into the canonical resolver. An explicit scheduledFor keeps the full ±halfGap snap unchanged. A defaulted-now write snaps only inside the tight dose-grace window and otherwise resolves to null (PRN), so it records a standalone taken-now row instead of back-filling a far slot. Both intake call sites pass scheduledFor !== undefined; takenAt alone is not explicit. Add resolver unit coverage for midday defaulted-now writes (07:00/19:00 → null at 11:00/12:00/13:00, explicit 07:00 still snaps) and a DB-backed regression that a takenAt-only midday write leaves the 07:00 slot untaken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7affbd0a11
commit
6871e8ea77
6 files changed
+205
-1
No files matched your search
@@ -155,6 +155,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// `takenAt`-only / defaulted-now write must not snap across the wide
|
||||
// ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: scheduledFor !== undefined,
|
||||
});
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
|
||||
@@ -234,6 +234,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// `takenAt`-only / defaulted-now write must not snap across the
|
||||
// wide ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
});
|
||||
|
||||
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
@@ -87,6 +87,62 @@ describe("resolveCanonicalSlotInstant — multi-time", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// (no client `scheduledFor`) snapped across the ±6h half-gap window onto
|
||||
// the 07:00 slot. A DEFAULTED-now write (`instantIsExplicit: false`)
|
||||
// must NOT reach a far slot — only the tight dose-grace window snaps,
|
||||
// and 11:00 / 12:00 / 13:00 are well outside it, so the result is `null`
|
||||
// (PRN standalone row).
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
|
||||
]);
|
||||
|
||||
for (const [label, hourUtc] of [
|
||||
["11:00", 9],
|
||||
["12:00", 10],
|
||||
["13:00", 11],
|
||||
] as const) {
|
||||
it(`returns null for a defaulted-now ${label}-local write`, () => {
|
||||
// CEST = UTC+2, so 11:00 local = 09:00Z, etc.
|
||||
const incoming = new Date(
|
||||
`2026-06-15T${String(hourUtc).padStart(2, "0")}:00:00.000Z`,
|
||||
);
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming,
|
||||
instantIsExplicit: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
it("still snaps an EXPLICIT scheduledFor:07:00 write to the 07:00 slot", () => {
|
||||
const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming,
|
||||
instantIsExplicit: true,
|
||||
});
|
||||
const canonical = localHmAsUtc(incoming, TZ, 7, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
|
||||
it("defaults to explicit snap when the flag is omitted (legacy behaviour)", () => {
|
||||
const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming,
|
||||
});
|
||||
const canonical = localHmAsUtc(incoming, TZ, 7, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — multi-time wide window", () => {
|
||||
// The LIVE twice-daily-Ramipril regression: 08:00 / 20:00 doses with a
|
||||
// WIDE schedule window (08:00–22:00 = 14h span → ±7h half-span). The
|
||||
|
||||
@@ -72,6 +72,24 @@ export interface ResolveSlotInput {
|
||||
* rolling schedules; pass `null` when unknown / not loaded.
|
||||
*/
|
||||
lastIntakeAt?: Date | null;
|
||||
/**
|
||||
* Whether the client sent an explicit `scheduledFor` for this write.
|
||||
*
|
||||
* `true` — the instant names a real dose slot; keep the full ±halfGap
|
||||
* snap so iOS-vs-server minute drift still collapses onto one row.
|
||||
*
|
||||
* `false` — the instant is a DEFAULTED `now` / `takenAt` (the client
|
||||
* sent no `scheduledFor`). A slot-less "taken now" write must NOT snap
|
||||
* across the wide ±halfGap window onto a far-away slot (the phantom
|
||||
* morning-dose bug: a midday write capturing the 07:00 slot of a
|
||||
* 07:00 / 19:00 med). Resolve only when the instant falls inside the
|
||||
* tight dose-grace window of a slot; otherwise return `null` (PRN) so
|
||||
* it records as a standalone "taken now" row.
|
||||
*
|
||||
* Defaults to `true` (explicit) so callers that don't thread the flag
|
||||
* keep the legacy snap behaviour unchanged.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +106,7 @@ export function resolveCanonicalSlotInstant(
|
||||
input: ResolveSlotInput,
|
||||
): Date | null {
|
||||
const { medication, userTz, incoming } = input;
|
||||
const instantIsExplicit = input.instantIsExplicit ?? true;
|
||||
const schedules = medication.schedules ?? [];
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
@@ -117,7 +136,13 @@ export function resolveCanonicalSlotInstant(
|
||||
windowEnd,
|
||||
ctx,
|
||||
);
|
||||
const tolerance = snapToleranceMs(canonical);
|
||||
// Explicit `scheduledFor` writes keep the full ±halfGap snap. A
|
||||
// defaulted-`now` write (no client `scheduledFor`) uses only the tight
|
||||
// dose-grace window, so a slot-less midday "taken now" cannot reach a
|
||||
// far-away morning/evening slot — it falls through to `null` (PRN).
|
||||
const tolerance = instantIsExplicit
|
||||
? snapToleranceMs(canonical)
|
||||
: graceToleranceMs(canonical);
|
||||
|
||||
for (const occ of occurrences) {
|
||||
// Only consider slots that land on the SAME local calendar day as
|
||||
@@ -235,6 +260,44 @@ function snapToleranceMs(schedule: CanonicalSchedule): number {
|
||||
return Math.max(toleranceMs, ONE_MINUTE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tight snap tolerance for a DEFAULTED-`now` write (client sent no
|
||||
* `scheduledFor`). Only a write that lands inside the slot's dose-grace
|
||||
* window may collapse onto that slot; anything further out is treated as
|
||||
* unscheduled (PRN) and keeps its own "taken now" row.
|
||||
*
|
||||
* Uses the schedule's `reminderGraceMinutes` when set, capped at half the
|
||||
* minimum inter-slot gap so two distinct same-day doses can never share a
|
||||
* capture zone (mirrors `snapToleranceMs`). Floors at the minute-drift
|
||||
* floor so a tight window still absorbs sub-minute drift, but never widens
|
||||
* to the ±halfGap window the explicit path uses.
|
||||
*/
|
||||
function graceToleranceMs(schedule: CanonicalSchedule): number {
|
||||
const graceMin = schedule.reminderGraceMinutes;
|
||||
let toleranceMs =
|
||||
graceMin !== null && Number.isFinite(graceMin) && graceMin > 0
|
||||
? graceMin * ONE_MINUTE_MS
|
||||
: ONE_MINUTE_MS;
|
||||
|
||||
const slots = effectiveTimesOfDay(schedule)
|
||||
.map(hhmmToMinutes)
|
||||
.filter((m): m is number => m !== null)
|
||||
.sort((a, b) => a - b);
|
||||
if (slots.length > 1) {
|
||||
let minGapMin = Infinity;
|
||||
for (let i = 1; i < slots.length; i++) {
|
||||
minGapMin = Math.min(minGapMin, slots[i] - slots[i - 1]);
|
||||
}
|
||||
const wrapGap = slots[0] + 24 * 60 - slots[slots.length - 1];
|
||||
minGapMin = Math.min(minGapMin, wrapGap);
|
||||
if (Number.isFinite(minGapMin) && minGapMin > 0) {
|
||||
toleranceMs = Math.min(toleranceMs, (minGapMin / 2) * ONE_MINUTE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(toleranceMs, ONE_MINUTE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective dose times for tolerance purposes — mirrors the recurrence
|
||||
* engine's `effectiveTimesOfDay`: the explicit `timesOfDay` when set,
|
||||
|
||||
@@ -314,6 +314,14 @@ export interface ResolveSlotForWriteInput {
|
||||
* client omitted `scheduledFor`.
|
||||
*/
|
||||
incoming: Date;
|
||||
/**
|
||||
* Whether the client sent an explicit `scheduledFor`. `false` means
|
||||
* `incoming` is a defaulted `now` / `takenAt` — the resolver then only
|
||||
* snaps inside the tight dose-grace window and otherwise returns `null`
|
||||
* (PRN), so a slot-less midday "taken now" cannot back-fill a far slot.
|
||||
* Defaults to `true` to keep the legacy snap for callers that omit it.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
/** Inject a Prisma client/tx in tests; defaults to the app client. */
|
||||
client?: PrismaLike;
|
||||
}
|
||||
@@ -366,5 +374,6 @@ export async function resolveSlotInstantForWrite(
|
||||
userTz: input.userTz,
|
||||
incoming: input.incoming,
|
||||
lastIntakeAt,
|
||||
instantIsExplicit: input.instantIsExplicit ?? true,
|
||||
});
|
||||
}
|
||||
@@ -419,6 +419,74 @@ describe("intake write path — multi-dose wide window does not collapse", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("intake write path — defaulted-now midday does not back-fill the morning slot", () => {
|
||||
// The phantom morning-dose regression, end-to-end through the DB-backed
|
||||
// resolver: a bulk/sync entry that carries ONLY `takenAt` (no client
|
||||
// `scheduledFor`) around midday must NOT snap onto the 07:00 slot of a
|
||||
// 07:00 / 19:00 med and stamp it taken. With `instantIsExplicit:false`
|
||||
// the resolver returns null (PRN) so the morning slot stays untouched.
|
||||
it("a takenAt-only 12:30 write resolves to null and leaves the 07:00 slot untaken", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
|
||||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||||
|
||||
// Server-minted pending 07:00 reminder row — the row the phantom take
|
||||
// would have stamped.
|
||||
const pending = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot0700,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk/sync entry: no `scheduledFor`, only `takenAt` at 12:30 local
|
||||
// (10:30Z in CET). This mirrors `intake/bulk` defaulting the instant
|
||||
// to `takenAt` with `instantIsExplicit: entry.scheduledFor !== undefined`.
|
||||
const middayTakenAt = new Date("2026-03-10T11:30:00.000Z"); // 12:30 CET
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
userTz: "Europe/Berlin",
|
||||
incoming: middayTakenAt,
|
||||
instantIsExplicit: false,
|
||||
client: prisma,
|
||||
});
|
||||
|
||||
// The defaulted-now write does NOT snap onto the far 07:00 slot.
|
||||
expect(canonicalSlot).toBeNull();
|
||||
|
||||
// The 07:00 reminder row stays untaken — no phantom morning dose.
|
||||
const morning = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||||
where: { id: pending.id },
|
||||
});
|
||||
expect(morning.takenAt).toBeNull();
|
||||
expect(morning.skipped).toBe(false);
|
||||
expect(morning.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("an EXPLICIT scheduledFor:07:00 write still snaps onto the 07:00 slot", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||||
|
||||
const explicitWrite = new Date("2026-03-10T06:00:00.000Z"); // 07:00 CET
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
userTz: "Europe/Berlin",
|
||||
incoming: explicitWrite,
|
||||
instantIsExplicit: true,
|
||||
client: prisma,
|
||||
});
|
||||
expect(canonicalSlot?.toISOString()).toBe(slot0700.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("intake-slot-dedup — boot discovery scoping", () => {
|
||||
it("returns 0 when no user holds a duplicate pair", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
Reference in new issue
Block a user