mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -139,6 +139,99 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
expect(onRecorded).toHaveBeenCalledWith("evt-99", false);
|
||||
});
|
||||
|
||||
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// The morning (07:00) slot the card is showing — Berlin 07:00 = 05:00 UTC.
|
||||
const morningSlot = new Date("2026-06-05T05:00:00.000Z");
|
||||
|
||||
await runRecordIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
scheduledFor: morningSlot,
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
setIntakeLoading: vi.fn(),
|
||||
undoIntake: vi.fn(),
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse((init as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
skipped: false,
|
||||
scheduledFor: "2026-06-05T05:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const morningSlot = new Date("2026-06-05T05:00:00.000Z"); // 07:00 Berlin
|
||||
const eveningSlot = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
|
||||
|
||||
await runRecordIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
scheduledFor: morningSlot,
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
setIntakeLoading: vi.fn(),
|
||||
undoIntake: vi.fn(),
|
||||
});
|
||||
await runRecordIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
scheduledFor: eveningSlot,
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
setIntakeLoading: vi.fn(),
|
||||
undoIntake: vi.fn(),
|
||||
});
|
||||
|
||||
const firstBody = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
);
|
||||
const secondBody = JSON.parse(
|
||||
(fetchMock.mock.calls[1][1] as RequestInit).body as string,
|
||||
);
|
||||
// Each call carries its OWN slot — marking one dose never targets the
|
||||
// other.
|
||||
expect(firstBody.scheduledFor).toBe("2026-06-05T05:00:00.000Z");
|
||||
expect(secondBody.scheduledFor).toBe("2026-06-05T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await runRecordIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
scheduledFor: null,
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
setIntakeLoading: vi.fn(),
|
||||
undoIntake: vi.fn(),
|
||||
});
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
);
|
||||
expect(body).toEqual({ skipped: false });
|
||||
expect(body).not.toHaveProperty("scheduledFor");
|
||||
});
|
||||
|
||||
it("omits the Undo action when the POST body carries no event id", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
|
||||
/**
|
||||
* v1.12.3 — the card-side slot resolver that closes the
|
||||
* "morning auto-taken" intake bug. The medication card shows ONE
|
||||
* actionable dose; its "Genommen" button must record THAT dose, not let
|
||||
* the server snap "now" to the nearest slot. This resolver derives the
|
||||
* displayed dose's slot instant from the card's current-window status (the
|
||||
* open/overdue window's `windowStart` today) or the server's `nextDueAt`.
|
||||
*/
|
||||
describe("resolveDisplayedSlotInstant", () => {
|
||||
// A point in the late morning, Berlin (CEST = UTC+2 in June). 09:00 Berlin.
|
||||
const morningNow = new Date("2026-06-05T07:00:00.000Z");
|
||||
|
||||
it("targets the open window's slot on today's calendar day", () => {
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "in_window",
|
||||
schedule: { windowStart: "07:00", windowEnd: "19:00" },
|
||||
},
|
||||
nextDueAt: null,
|
||||
now: morningNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
expect(slot).not.toBeNull();
|
||||
// 07:00 Berlin on 2026-06-05 is 05:00 UTC (CEST = +2).
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
|
||||
});
|
||||
|
||||
it("targets the morning window when the card surfaces the morning dose, regardless of a later wall-clock", () => {
|
||||
// Even if the user taps in the afternoon, the morning window's slot is
|
||||
// what the card surfaced (e.g. an overdue 07:00 dose), so the resolved
|
||||
// instant must still be the 07:00 slot — NOT a now-snap.
|
||||
const afternoonNow = new Date("2026-06-05T13:00:00.000Z"); // 15:00 Berlin
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "very_late",
|
||||
schedule: { windowStart: "07:00", windowEnd: "08:00" },
|
||||
},
|
||||
nextDueAt: null,
|
||||
now: afternoonNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
|
||||
});
|
||||
|
||||
it("targets the evening window's slot when the card surfaces the evening dose", () => {
|
||||
const eveningNow = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "in_window",
|
||||
schedule: { windowStart: "19:00", windowEnd: "20:00" },
|
||||
},
|
||||
nextDueAt: null,
|
||||
now: eveningNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// 19:00 Berlin on 2026-06-05 is 17:00 UTC.
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to the server's next-due instant when no window is currently actionable", () => {
|
||||
const nextDueAt = "2026-06-06T05:00:00.000Z";
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: { status: null, schedule: null },
|
||||
nextDueAt,
|
||||
now: morningNow,
|
||||
});
|
||||
expect(slot!.toISOString()).toBe(nextDueAt);
|
||||
});
|
||||
|
||||
it("prefers the open window over next-due (the user acts on the dose in front of them)", () => {
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "in_window",
|
||||
schedule: { windowStart: "07:00", windowEnd: "19:00" },
|
||||
},
|
||||
nextDueAt: "2026-06-06T05:00:00.000Z",
|
||||
now: morningNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null for a PRN dose (no current window, no next-due) so the server keeps the now-snap path", () => {
|
||||
expect(
|
||||
resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: { status: null, schedule: null },
|
||||
nextDueAt: null,
|
||||
now: morningNow,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a malformed next-due rather than an Invalid Date", () => {
|
||||
expect(
|
||||
resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: { status: null, schedule: null },
|
||||
nextDueAt: "not-a-date",
|
||||
now: morningNow,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("handles a standard-time (CET = UTC+1) winter slot", () => {
|
||||
const winterNow = new Date("2026-01-15T06:00:00.000Z"); // 07:00 Berlin (CET)
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "in_window",
|
||||
schedule: { windowStart: "07:00", windowEnd: "19:00" },
|
||||
},
|
||||
nextDueAt: null,
|
||||
now: winterNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// 07:00 Berlin on 2026-01-15 is 06:00 UTC (CET = +1).
|
||||
expect(slot!.toISOString()).toBe("2026-01-15T06:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.12.3 — resolve the canonical slot instant of the dose a medication
|
||||
* card is currently surfacing, so the card's "Genommen" / "Skip" buttons
|
||||
* record THAT dose rather than letting the server snap "now" to the
|
||||
* nearest slot.
|
||||
*
|
||||
* Background: the medication cards show a single actionable dose — either
|
||||
* the current/overdue window (when `currentWindowStatus` resolves to
|
||||
* in_window / late / very_late) or the next-due dose (`nextDueAt`). The
|
||||
* intake POST historically carried `{ skipped }` only, so the server
|
||||
* 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) therefore marked the 07:00 dose even when the user
|
||||
* meant a different slot, and a non-deliberate morning tap mis-recorded the
|
||||
* morning dose. Threading the displayed dose's slot instant onto the POST
|
||||
* lets the server resolve the exact dose the user is looking at,
|
||||
* deterministically and independently of the wall-clock.
|
||||
*
|
||||
* The returned instant is fed to the intake route's `scheduledFor`, which
|
||||
* runs it through `resolveSlotInstantForWrite` (the canonical ±tolerance
|
||||
* snap). It only needs to fall inside the target slot's capture zone, so a
|
||||
* sub-minute DST-offset slop is harmless — the snap collapses it onto the
|
||||
* exact canonical slot row.
|
||||
*/
|
||||
|
||||
export interface DisplayedSlotSchedule {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
}
|
||||
|
||||
export interface ResolveDisplayedSlotInstantInput {
|
||||
/**
|
||||
* The card's current-window status. When its `status` is non-null the
|
||||
* card is surfacing that schedule's window today (in_window / late /
|
||||
* very_late), so the recorded dose is that window's slot today.
|
||||
*/
|
||||
currentWindowStatus: {
|
||||
status: "in_window" | "late" | "very_late" | null;
|
||||
schedule: DisplayedSlotSchedule | null;
|
||||
};
|
||||
/**
|
||||
* The server-computed next-due instant (`computeNextDueAt`, the canonical
|
||||
* recurrence engine). The card renders this when no window is currently
|
||||
* actionable; it is already a canonical slot instant.
|
||||
*/
|
||||
nextDueAt: string | null | undefined;
|
||||
/** Wall-clock reference (injected for testability). */
|
||||
now: Date;
|
||||
/**
|
||||
* IANA timezone the card's day boundary is reasoned in. The cards model
|
||||
* the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
|
||||
* to that; passing the user's actual zone keeps the slot on the right
|
||||
* calendar day across the rare cross-midnight edge.
|
||||
*/
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a UTC `Date` for `HH:MM` on `now`'s calendar day in `timeZone`.
|
||||
*
|
||||
* Computes the zone's offset at `now` from `Intl` parts and applies it, so
|
||||
* the result is the instant that reads as `HH:MM` local on today's date.
|
||||
* Exactness to the minute is not required downstream (the server snap has a
|
||||
* multi-hour tolerance), but this is correct to the minute regardless.
|
||||
*/
|
||||
function localHmTodayAsUtc(
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
hour: number,
|
||||
minute: number,
|
||||
): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
|
||||
const get = (type: string) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
// Intl renders 24:xx for midnight in some engines; normalise to 0.
|
||||
const localHour = get("hour") % 24;
|
||||
|
||||
const localNowAsUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
localHour,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
const offsetMs =
|
||||
Math.round((localNowAsUtc - now.getTime()) / 60_000) * 60_000;
|
||||
|
||||
const localTargetAsUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return new Date(localTargetAsUtc - offsetMs);
|
||||
}
|
||||
|
||||
function parseHm(value: string): { hour: number; minute: number } | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(value);
|
||||
if (!m) return null;
|
||||
const hour = Number(m[1]);
|
||||
const minute = Number(m[2]);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the displayed dose's `scheduledFor` instant, or `null` when the
|
||||
* card cannot identify a specific slot (no current window AND no next-due —
|
||||
* e.g. a PRN medication). A `null` return preserves the legacy server
|
||||
* behaviour: the POST omits `scheduledFor` and the route handles the
|
||||
* unscheduled / PRN insert path.
|
||||
*/
|
||||
export function resolveDisplayedSlotInstant(
|
||||
input: ResolveDisplayedSlotInstantInput,
|
||||
): Date | null {
|
||||
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
|
||||
input;
|
||||
|
||||
// The card surfaces the current/overdue window — record that window's
|
||||
// slot on today's calendar day.
|
||||
if (currentWindowStatus.status && currentWindowStatus.schedule) {
|
||||
const hm = parseHm(currentWindowStatus.schedule.windowStart);
|
||||
if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
|
||||
}
|
||||
|
||||
// Otherwise the card surfaces the next-due dose, already a canonical
|
||||
// slot instant from the server.
|
||||
if (nextDueAt) {
|
||||
const d = new Date(nextDueAt);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
interface MedicationIntakeActionsProps {
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/**
|
||||
* Record the displayed dose. The card binds the second `scheduledFor`
|
||||
* argument to the slot it is currently showing (v1.12.3), so the buttons
|
||||
* here just forward the skip flag.
|
||||
*/
|
||||
onRecordIntake: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
/**
|
||||
@@ -309,6 +310,16 @@ export function Glp1MedicationCard({
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
});
|
||||
|
||||
// v1.12.3 — slot instant of the injection this card is showing (the
|
||||
// open/overdue window, else the server's next-due). Threaded onto the
|
||||
// take / skip POST so the server records THIS dose deterministically
|
||||
// rather than snapping "now" to the nearest slot.
|
||||
const displayedSlot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus,
|
||||
nextDueAt: medication.nextDueAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
@@ -490,7 +501,7 @@ export function Glp1MedicationCard({
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={recordIntake}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -242,6 +243,17 @@ export function MedicationCard({
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
});
|
||||
|
||||
// v1.12.3 — the slot instant of the dose this card is currently showing
|
||||
// (the open/overdue window, else the server's next-due). Threaded onto
|
||||
// the take / skip POST so the server records THIS dose rather than
|
||||
// snapping "now" to the nearest slot — a morning tap on a 07:00 / 19:00
|
||||
// medication used to mis-record the 07:00 dose.
|
||||
const displayedSlot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus,
|
||||
nextDueAt: medication.nextDueAt,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
@@ -399,7 +411,7 @@ export function MedicationCard({
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={recordIntake}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -48,8 +48,14 @@ interface UseMedicationIntakeParams {
|
||||
interface UseMedicationIntakeResult {
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/** Record a take (skipped=false) or skip (skipped=true). */
|
||||
recordIntake: (skipped: boolean) => Promise<void>;
|
||||
/**
|
||||
* Record a take (skipped=false) or skip (skipped=true). The optional
|
||||
* `scheduledFor` is the slot instant of the dose the card is displaying;
|
||||
* when supplied the server marks THAT slot instead of snapping "now" to
|
||||
* the nearest one (v1.12.3). Omit it (or pass null) for an unscheduled /
|
||||
* PRN dose to keep the legacy now-snap path.
|
||||
*/
|
||||
recordIntake: (skipped: boolean, scheduledFor?: Date | null) => Promise<void>;
|
||||
/** Reverse a just-recorded intake via the soft-delete route. */
|
||||
undoIntake: (eventId: string) => Promise<void>;
|
||||
}
|
||||
@@ -64,6 +70,14 @@ interface UseMedicationIntakeResult {
|
||||
export async function runRecordIntake(deps: {
|
||||
medication: MedicationIntakeIdentity;
|
||||
skipped: boolean;
|
||||
/**
|
||||
* v1.12.3 — slot instant of the dose the card is showing. Threaded onto
|
||||
* the POST so the server marks the displayed dose deterministically
|
||||
* instead of snapping "now" to the nearest slot (a morning tap on a
|
||||
* 07:00 / 19:00 med no longer mis-records the 07:00 dose). `null` /
|
||||
* undefined keeps the legacy now-snap (unscheduled / PRN doses).
|
||||
*/
|
||||
scheduledFor?: Date | null;
|
||||
t: Translator;
|
||||
queryClient: QueryClient;
|
||||
setIntakeLoading: (value: string | null) => void;
|
||||
@@ -73,6 +87,7 @@ export async function runRecordIntake(deps: {
|
||||
const {
|
||||
medication,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
t,
|
||||
queryClient,
|
||||
setIntakeLoading,
|
||||
@@ -85,7 +100,15 @@ export async function runRecordIntake(deps: {
|
||||
const res = await fetch(`/api/medications/${medication.id}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skipped }),
|
||||
// The route's Zod schema accepts an ISO `scheduledFor`; the server
|
||||
// snaps it to the canonical slot. Only send it when the card
|
||||
// identified the displayed dose's slot — otherwise omit so the
|
||||
// PRN / unscheduled now-snap path is preserved.
|
||||
body: JSON.stringify(
|
||||
scheduledFor
|
||||
? { skipped, scheduledFor: scheduledFor.toISOString() }
|
||||
: { skipped },
|
||||
),
|
||||
});
|
||||
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so the
|
||||
// user believed the dose was logged when it was not. Surface the failure
|
||||
@@ -170,10 +193,11 @@ export function useMedicationIntake({
|
||||
const undoIntake = (eventId: string) =>
|
||||
runUndoIntake({ medication, eventId, t, queryClient });
|
||||
|
||||
const recordIntake = (skipped: boolean) =>
|
||||
const recordIntake = (skipped: boolean, scheduledFor?: Date | null) =>
|
||||
runRecordIntake({
|
||||
medication,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
t,
|
||||
queryClient,
|
||||
setIntakeLoading,
|
||||
|
||||
@@ -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