mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(medications): record the viewed dose by scheduled time instead of snapping to now
The medication cards show a single actionable dose — the open/overdue
window, or the server's next-due slot. The take / skip button POSTed
`{ skipped }` only, so the route defaulted `scheduledFor` to `now()` and
snapped to whichever slot was nearest. For a twice-daily 07:00 / 19:00
medication a morning tap (before the 13:00 midpoint) recorded the 07:00
dose, and a non-deliberate morning tap mis-recorded the morning dose.
Thread the displayed dose's canonical slot instant onto the POST so the
server resolves the exact dose the user is looking at, independent of the
wall-clock. The card derives the instant from its current-window status
(the window's start time today) or the next-due instant; a PRN dose with
no slot omits `scheduledFor` and keeps the legacy now-snap path. The route
already accepts `scheduledFor` and snaps it through the canonical slot
resolver, so no server change is needed — the cards now always send it.
Add a unit suite for the slot resolver and the POST body, and an
integration test proving a morning `scheduledFor` records the 07:00 slot
and an evening one records 19:00, driven by the supplied instant rather
than the clock, with marking one dose never marking the other.
(cherry picked from commit 438832cd965574da1b5dabf6567f7bffa26e36f3)
This commit is contained in:
1 parent
11f10df39e
commit
72c93108d7
8 files changed
+577
-6
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* v1.12.3 — per-dose intake targeting via the supplied `scheduledFor`.
|
||||
*
|
||||
* Closes the "morning auto-taken" bug: the web medication card now threads
|
||||
* the displayed dose's canonical slot instant onto the intake POST as
|
||||
* `scheduledFor`, so the server records THAT slot rather than snapping
|
||||
* "now" to the nearest one. These tests exercise the exact server path the
|
||||
* route runs for a supplied `scheduledFor` — `resolveSlotInstantForWrite`
|
||||
* (the canonical snap) feeding `applyCanonicalSlotWrite` — on real Postgres,
|
||||
* for a twice-daily 07:00 / 19:00 medication.
|
||||
*
|
||||
* Proven invariants:
|
||||
* 1. A `scheduledFor` in the morning capture zone resolves + writes the
|
||||
* 07:00 slot; one in the evening zone resolves + writes the 19:00 slot
|
||||
* — driven by the SUPPLIED instant, never the wall-clock.
|
||||
* 2. Marking the morning dose does NOT mark the evening dose (the evening
|
||||
* slot stays pending) and vice-versa.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const TEST_USER_ID = "user-intake-scheduledfor-targeting";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "intake-sf-targeting",
|
||||
email: "intake-sf-targeting@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Fixed non-DST-boundary weekday so the slot instants are deterministic.
|
||||
const DAY = new Date("2026-03-10T12:00:00.000Z");
|
||||
|
||||
async function createTwiceDailyMed(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Bisoprolol",
|
||||
dose: "2.5mg",
|
||||
active: true,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
/** Mimic the route: snap the supplied `scheduledFor`, then upsert the slot. */
|
||||
async function recordViaSchedule(medId: string, scheduledFor: Date) {
|
||||
const prisma = getPrismaClient();
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
userTz: "Europe/Berlin",
|
||||
incoming: scheduledFor,
|
||||
});
|
||||
expect(canonicalSlot).not.toBeNull();
|
||||
const takenAt = new Date(); // "now" — the recording moment, NOT the slot.
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
canonicalSlot: canonicalSlot!,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
});
|
||||
return { canonicalSlot: canonicalSlot!, row: applied.row };
|
||||
}
|
||||
|
||||
describe("intake scheduledFor targeting — record the viewed dose", () => {
|
||||
it("a morning scheduledFor records the 07:00 slot, regardless of wall-clock", async () => {
|
||||
const medId = await createTwiceDailyMed();
|
||||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||||
|
||||
const { canonicalSlot, row } = await recordViaSchedule(medId, slot0700);
|
||||
|
||||
expect(canonicalSlot.getTime()).toBe(slot0700.getTime());
|
||||
expect(row.scheduledFor.getTime()).toBe(slot0700.getTime());
|
||||
expect(row.takenAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("an evening scheduledFor records the 19:00 slot, regardless of wall-clock", async () => {
|
||||
const medId = await createTwiceDailyMed();
|
||||
const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
|
||||
|
||||
const { canonicalSlot, row } = await recordViaSchedule(medId, slot1900);
|
||||
|
||||
expect(canonicalSlot.getTime()).toBe(slot1900.getTime());
|
||||
expect(row.scheduledFor.getTime()).toBe(slot1900.getTime());
|
||||
expect(row.takenAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marking the morning dose does not mark the evening dose (and vice-versa)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||||
const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
|
||||
|
||||
// Record ONLY the morning dose by supplying its slot instant.
|
||||
await recordViaSchedule(medId, slot0700);
|
||||
|
||||
const morning = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
|
||||
});
|
||||
const evening = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
|
||||
});
|
||||
|
||||
// Exactly one taken morning row; the evening slot holds no taken row.
|
||||
expect(morning).toHaveLength(1);
|
||||
expect(morning[0]?.takenAt).not.toBeNull();
|
||||
expect(evening.filter((e) => e.takenAt !== null)).toHaveLength(0);
|
||||
|
||||
// Now record the evening dose — the morning row stays as it was.
|
||||
await recordViaSchedule(medId, slot1900);
|
||||
|
||||
const morningAfter = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
|
||||
});
|
||||
const eveningAfter = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
|
||||
});
|
||||
expect(morningAfter).toHaveLength(1);
|
||||
expect(morningAfter[0]?.takenAt).not.toBeNull();
|
||||
expect(eveningAfter).toHaveLength(1);
|
||||
expect(eveningAfter[0]?.takenAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user