mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(medications): align the slot lifecycle with the band model end to end
The auto-miss cron stamped every pending row missed a flat day after its slot even though weekly schedules stay takeable for days, touched tombstones because it never filtered deleted rows, and a verdict stuck even after the dose was taken. The cron now derives its cutoff from the schedule's window reach, skips deleted rows and bumps the sync counter, and a take clears the auto-miss flag. Editing a schedule tombstones the open pending rows of the old anchors and reconciles a stale window onto the new times. The bulk write path attributes takes through the same band engine as the single-intake routes and accepts forceSlotInstant per entry. Configured dose windows now reach every compliance call site through one shared schedule select, and the card window pill takes a timezone, treats a degenerate window as a point and counts dose times instead of schedule rows.
This commit is contained in:
1 parent
fb6e494f64
commit
0a4bf34a2b
16 files changed
+1380
-214
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
@@ -258,7 +259,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
});
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
@@ -1004,7 +1005,9 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
@@ -176,6 +179,153 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("tombstones open pending rows (today + future) on a schedule replace", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
|
||||
.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
// Only live, never-acted rows from today's local day-start forward —
|
||||
// taken / skipped / auto-missed history must survive a schedule edit.
|
||||
expect(arg.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
});
|
||||
expect(
|
||||
(arg.where.scheduledFor as { gte: Date }).gte,
|
||||
).toBeInstanceOf(Date);
|
||||
expect(arg.data.deletedAt).toBeInstanceOf(Date);
|
||||
expect(arg.data.syncVersion).toEqual({ increment: 1 });
|
||||
});
|
||||
|
||||
it("does NOT touch intake rows when the body carries no schedules array", async () => {
|
||||
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — window / timesOfDay consistency", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function createdSchedule(): Record<string, unknown> {
|
||||
const calls = vi.mocked(prisma.medication.update).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const arg = calls[calls.length - 1][0] as unknown as {
|
||||
data: { schedules: { create: Array<Record<string, unknown>> } };
|
||||
};
|
||||
return arg.data.schedules.create[0];
|
||||
}
|
||||
|
||||
it("pulls a stale window to the min/max of new timesOfDay", async () => {
|
||||
// The client changed only the dose times and echoed the old window
|
||||
// back; 21:00 falls outside [08:00, 09:00].
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["07:00", "21:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("07:00");
|
||||
expect(created.windowEnd).toBe("21:00");
|
||||
});
|
||||
|
||||
it("keeps a window that already covers every time byte-identical", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "06:00",
|
||||
windowEnd: "22:00",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("06:00");
|
||||
expect(created.windowEnd).toBe("22:00");
|
||||
});
|
||||
|
||||
it("keeps an overnight window whose times sit inside the wrap", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "22:00",
|
||||
windowEnd: "02:00",
|
||||
timesOfDay: ["23:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("22:00");
|
||||
expect(created.windowEnd).toBe("02:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
type CadenceEngineContext,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { complianceChips } from "@/lib/medications/scheduling/compliance";
|
||||
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
|
||||
import {
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
@@ -52,7 +55,9 @@ export const GET = apiHandler(
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
|
||||
@@ -19,10 +19,49 @@ import {
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
|
||||
function hhmmToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a schedule's `windowStart` / `windowEnd` with its effective
|
||||
* `timesOfDay`. The schedule editor can change only the dose times while the
|
||||
* client echoes the previous window back; a time outside the window then
|
||||
* breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
|
||||
* (the window-status pill, the reminder phases, the legacy compliance
|
||||
* walker). When ANY time falls outside the window — including the overnight
|
||||
* `windowEnd < windowStart` shape, checked with wrap-around membership —
|
||||
* the window is pulled to the min/max of the times so the persisted row is
|
||||
* self-consistent. A window that already covers every time is left
|
||||
* byte-identical (explicitly wider windows are a feature).
|
||||
*/
|
||||
function reconcileScheduleWindow(
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
timesOfDay: string[],
|
||||
): { windowStart: string; windowEnd: string } {
|
||||
if (timesOfDay.length === 0) return { windowStart, windowEnd };
|
||||
const startMins = hhmmToMinutes(windowStart);
|
||||
const endMins = hhmmToMinutes(windowEnd);
|
||||
const overnight = endMins < startMins;
|
||||
const inWindow = (t: number): boolean =>
|
||||
overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
|
||||
const sorted = [...timesOfDay].sort(
|
||||
(a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
|
||||
);
|
||||
const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
|
||||
if (allInside) return { windowStart, windowEnd };
|
||||
return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -239,6 +278,31 @@ export const PUT = apiHandler(
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules) {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
// auto-miss later, and depress compliance. Tombstone today's and
|
||||
// future open pending rows (never an actioned row — `takenAt`,
|
||||
// `skipped`, and `autoMissed` are user-visible history) and bump
|
||||
// `syncVersion` so delta-sync clients drop them. The projector and
|
||||
// the reminder worker re-mint the anchors for the new times on their
|
||||
// next pass — the route does not pre-create them.
|
||||
const { start: todayStart } = getUserTodayBounds(
|
||||
new Date(),
|
||||
user.timezone || "Europe/Berlin",
|
||||
);
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: todayStart },
|
||||
},
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
@@ -292,9 +356,19 @@ export const PUT = apiHandler(
|
||||
? s.timesOfDay
|
||||
: [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
return {
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
|
||||
@@ -66,7 +66,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
// Explicit timezone keeps the band/slot fixtures below host-TZ-stable:
|
||||
// the engine expands occurrences in the user's zone, so an unset value
|
||||
// would silently track the machine's clock.
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
@@ -480,6 +488,45 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors a pending echo through the canonical snap (band engine is taken-only)", async () => {
|
||||
// A pending echo (no takenAt) has nothing to attribute by — it must
|
||||
// keep the anchor snap that binds it to the projector-minted slot row.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-pending",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-pending",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
// no takenAt → pending echo onto the (still pending) slot row
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
// The echo converges onto the canonical slot row (here a no-op update
|
||||
// path through the shared upsert), never a standalone insert.
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the idempotencyKey replay contract: a re-submission reports duplicate, not updated", async () => {
|
||||
// The replay pre-check fires BEFORE the convergence probe so a re-sent
|
||||
// entry keeps its historical `duplicate` status.
|
||||
@@ -513,3 +560,193 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
|
||||
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.20 — taken writes attribute through the window-band engine
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () => {
|
||||
const SCHEDULED_MED = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("attributes a takenAt-only entry to its slot by band membership", async () => {
|
||||
// 19:30 Berlin sits inside the 19:00 slot's ±60 min on-time band — the
|
||||
// take must converge onto the slot's pending REMINDER row even though
|
||||
// the client named no scheduledFor (the legacy snap refused to bind a
|
||||
// defaulted anchor to a slot; the band engine binds by the take itself).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-evening",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T17:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-evening",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T17:30:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
updated: number;
|
||||
inserted: number;
|
||||
entries: Array<{ status: string; id?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.inserted).toBe(0);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(body.data.entries[0].id).toBe("row-evening");
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forceSlotInstant pins an off-window take onto the named real slot", async () => {
|
||||
// 14:00 Berlin falls in no band; the client pins the take onto the
|
||||
// morning slot anchor (05:00Z = 07:00 Berlin).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-morning",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-morning",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T12:00:00.000Z",
|
||||
forceSlotInstant: "2026-06-15T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string; id?: string }> };
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(body.data.entries[0].id).toBe("row-morning");
|
||||
});
|
||||
|
||||
it("marks an entry skipped when forceSlotInstant names no real slot", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T12:00:00.000Z",
|
||||
forceSlotInstant: "2026-06-15T12:34:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{ status: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.entries[0].status).toBe("skipped");
|
||||
expect(body.data.entries[0].reason).toBe("force_slot_invalid");
|
||||
expect(body.data.skipped[0]).toEqual({
|
||||
index: 0,
|
||||
reason: "force_slot_invalid",
|
||||
});
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("files an out-of-band take as ad-hoc (scheduledFor = takenAt)", async () => {
|
||||
// 14:00 Berlin, no band, no pin → standalone row anchored on the take.
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
|
||||
id: "row-adhoc",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inserted: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
scheduledFor: new Date("2026-06-15T12:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-15T12:00:00.000Z"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@
|
||||
* idempotencyKey?, — Medication.intake_events.idempotency_key
|
||||
* (existing UNIQUE column; serves as the
|
||||
* per-entry dedup hint)
|
||||
* forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
|
||||
* named real scheduled slot (server-validated
|
||||
* against the band anchors)
|
||||
* }
|
||||
*
|
||||
* Response (always 200): processed / inserted / duplicates / skipped /
|
||||
@@ -49,6 +52,8 @@ import {
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
@@ -79,6 +84,17 @@ const bulkEntrySchema = z.object({
|
||||
// entry skipped (`injection_site_not_allowed`) without failing the
|
||||
// whole batch, matching the bulk endpoint's per-entry contract.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
|
||||
// intake route: pin a taken entry onto a named real scheduled slot
|
||||
// instead of the default window-band attribution. Validated server-side
|
||||
// against the band anchors; an instant that is not a slot marks the
|
||||
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
|
||||
// Ignored on non-taken entries (a pending echo / skip carries no take
|
||||
// to attribute).
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
@@ -228,32 +244,6 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const incomingScheduledFor =
|
||||
entry.scheduledFor ?? entry.takenAt ?? new Date();
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
|
||||
// reminder action here (source API); without this it inserted a
|
||||
// SECOND row for a slot already carrying the projector/worker's
|
||||
// pending REMINDER row (the unique key includes `source`, and the
|
||||
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
|
||||
// canonical slot instant and update the existing row in place.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
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,
|
||||
// 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,
|
||||
});
|
||||
|
||||
// The `scheduledFor` instant the row actually lands on — set by the
|
||||
// branch that performs the write so the rollup recompute below keys
|
||||
// the same day the stored row anchors to.
|
||||
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// C2 — classify the incoming write. An offline-sync replay echoes a
|
||||
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
|
||||
// user may already have actioned; that echo must NEVER clear a
|
||||
@@ -262,6 +252,67 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
// v1.15.20 — taken writes attribute through the window-band engine
|
||||
// (the SAME minter the read ledger + the compliance % consume), so a
|
||||
// bulk-synced take binds to a slot identically to the single-route
|
||||
// write. The optional `forceSlotInstant` pins an off-window take onto
|
||||
// a named real slot. Pending echoes and skips carry no take to
|
||||
// attribute by, so they keep the canonical anchor snap that binds
|
||||
// them to the projector/worker-minted slot row.
|
||||
let canonicalSlot: Date | null;
|
||||
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
slotInstant: entry.forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
// Mirrors the single route's 422 (`medications.intake.force_slot
|
||||
// .invalid`) as a per-entry skip so one bad pin never fails the
|
||||
// batch.
|
||||
const reason = "force_slot_invalid";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
} else if (isExplicitTaken) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: entry.takenAt as Date,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
} else {
|
||||
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
|
||||
// iOS posts the reminder actions here (source API); without this
|
||||
// they inserted a SECOND row for a slot already carrying the
|
||||
// projector/worker's pending REMINDER row (the unique key includes
|
||||
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
|
||||
// minute). Snap onto the canonical slot instant and update the
|
||||
// existing row in place.
|
||||
canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// defaulted-now write must not snap across the wide ±halfGap
|
||||
// window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Pending echoes / skips never record a take; a pending sync
|
||||
// echo legitimately maps to a future slot, so the taken
|
||||
// future-slot guard stays off here.
|
||||
isTakenWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
// The `scheduledFor` instant the row actually lands on — set by the
|
||||
// branch that performs the write so the rollup recompute below keys
|
||||
// the same day the stored row anchors to.
|
||||
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// v1.8.5 — resolve + validate the optional per-entry injection
|
||||
// site. A disallowed site marks the entry skipped without failing
|
||||
// the batch (per-entry contract); a non-injection / tracking-off /
|
||||
|
||||
@@ -368,6 +368,35 @@ export interface ComplianceSchedule {
|
||||
doseWindows?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance
|
||||
* computation. Every call site that loads schedules for
|
||||
* `calculateCompliance` / the cadence helpers must use this constant instead
|
||||
* of hand-rolling a field list (or an unbounded `include: { schedules:
|
||||
* true }`), so a future schedule column that the engine consumes — the way
|
||||
* `doseWindows` joined in v1.15.18 — reaches every surface the moment it is
|
||||
* added here. Covers the full {@link ComplianceSchedule} shape plus `id`
|
||||
* (engine-internal labelling / diagnostics).
|
||||
*/
|
||||
export const SCHEDULE_COMPLIANCE_SELECT = {
|
||||
id: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
// The configurable per-dose on-time windows. Selecting them everywhere is
|
||||
// the point: a user-configured "07:00–09:00" band must shape the rate on
|
||||
// the dashboard pillar, the insights features, the BP-status gate and the
|
||||
// report exactly as it shapes the medication detail page.
|
||||
doseWindows: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 SB-SCHED-2 — medication-level context the canonical engine
|
||||
* needs to expand expected slots. When supplied, `calculateCompliance`
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
@@ -262,7 +263,9 @@ export async function generateBloodPressureStatusForUser(
|
||||
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
});
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
@@ -153,7 +154,9 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
/**
|
||||
* v1.4.46 — coverage for the hourly intake-auto-miss helper.
|
||||
*
|
||||
* v1.15.9 — the terminal state is now `auto_missed = true`, NOT
|
||||
* `skipped = true`. A forgotten dose is a real MISS that must count against
|
||||
* adherence; flipping `skipped` (which the compliance engine excludes from
|
||||
* the denominator) silently inflated the rate. The helper runs a single
|
||||
* `updateMany` with the `skipped = false AND auto_missed = false AND
|
||||
* takenAt IS NULL AND scheduledFor < cutoff` gate, where `cutoff = now - 24 h`.
|
||||
* v1.15.9 — the terminal state is `auto_missed = true`, NOT `skipped =
|
||||
* true`. A forgotten dose is a real MISS that must count against adherence;
|
||||
* flipping `skipped` (which the compliance engine excludes from the
|
||||
* denominator) silently inflated the rate.
|
||||
*
|
||||
* v1.15.20 — the cutoff is cadence-aware and the flip is tombstone-safe.
|
||||
* These tests pin:
|
||||
* * cron + queue contract (constants don't drift),
|
||||
* * cutoff is `now - 24 h` exactly (off-by-one safety),
|
||||
* * Prisma `updateMany` is called with the spec'd shape
|
||||
* (`auto_missed = true`, not `skipped = true`),
|
||||
* * the 24 h floor for minute-scale cadences (off-by-one safety at the
|
||||
* boundary, strict `<`),
|
||||
* * the day-scale (weekly / rolling) delay — a pending injectable slot
|
||||
* inside its band tail (on-time + overdue reach) is NOT flipped, one
|
||||
* past the tail IS,
|
||||
* * configured `doseWindows` on a daily cadence extend the delay past
|
||||
* the floor,
|
||||
* * tombstoned rows (`deletedAt`) are never flipped,
|
||||
* * the update stamps `auto_missed = true` + a `syncVersion` increment,
|
||||
* * a user-skipped row is left untouched (a deliberate pause is never
|
||||
* reclassified as a miss),
|
||||
* * idempotency-by-construction (a second pass finds zero candidates).
|
||||
@@ -23,58 +29,98 @@ import {
|
||||
INTAKE_AUTO_SKIP_CRON,
|
||||
INTAKE_AUTO_SKIP_GRACE_HOURS,
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
medicationAutoMissDelayMs,
|
||||
runIntakeAutoSkipPass,
|
||||
} from "../intake-auto-skip";
|
||||
|
||||
interface FakeIntakeRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
autoMissed: boolean;
|
||||
deletedAt: Date | null;
|
||||
syncVersion: number;
|
||||
}
|
||||
|
||||
interface FakeUpdateManyWhere {
|
||||
interface FakeScheduleRow {
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
doseWindows: unknown;
|
||||
}
|
||||
|
||||
interface FakeMedication {
|
||||
id: string;
|
||||
schedules: FakeScheduleRow[];
|
||||
}
|
||||
|
||||
interface FakeWhere {
|
||||
skipped?: boolean;
|
||||
autoMissed?: boolean;
|
||||
takenAt?: null;
|
||||
deletedAt?: null;
|
||||
medicationId?: { in: string[] };
|
||||
scheduledFor?: { lt: Date };
|
||||
}
|
||||
|
||||
function makeFakePrisma(state: { rows: FakeIntakeRow[] }) {
|
||||
function matches(row: FakeIntakeRow, where: FakeWhere): boolean {
|
||||
if (where.skipped !== undefined && row.skipped !== where.skipped)
|
||||
return false;
|
||||
if (where.autoMissed !== undefined && row.autoMissed !== where.autoMissed)
|
||||
return false;
|
||||
if (where.takenAt === null && row.takenAt !== null) return false;
|
||||
if (where.deletedAt === null && row.deletedAt !== null) return false;
|
||||
if (
|
||||
where.medicationId !== undefined &&
|
||||
!where.medicationId.in.includes(row.medicationId)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
where.scheduledFor?.lt !== undefined &&
|
||||
row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime()
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeFakePrisma(state: {
|
||||
rows: FakeIntakeRow[];
|
||||
medications: FakeMedication[];
|
||||
}) {
|
||||
return {
|
||||
medicationIntakeEvent: {
|
||||
groupBy: vi.fn(async ({ where }: { where: FakeWhere }) => {
|
||||
const ids = new Set(
|
||||
state.rows.filter((r) => matches(r, where)).map((r) => r.medicationId),
|
||||
);
|
||||
return [...ids].map((medicationId) => ({ medicationId }));
|
||||
}),
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: FakeUpdateManyWhere;
|
||||
data: { autoMissed: boolean };
|
||||
where: FakeWhere;
|
||||
data: { autoMissed: boolean; syncVersion: { increment: number } };
|
||||
}) => {
|
||||
let count = 0;
|
||||
for (const row of state.rows) {
|
||||
if (where.skipped !== undefined && row.skipped !== where.skipped)
|
||||
continue;
|
||||
if (
|
||||
where.autoMissed !== undefined &&
|
||||
row.autoMissed !== where.autoMissed
|
||||
)
|
||||
continue;
|
||||
if (where.takenAt === null && row.takenAt !== null) continue;
|
||||
if (
|
||||
where.scheduledFor?.lt !== undefined &&
|
||||
row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime()
|
||||
)
|
||||
continue;
|
||||
if (!matches(row, where)) continue;
|
||||
row.autoMissed = data.autoMissed;
|
||||
row.syncVersion += data.syncVersion.increment;
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
),
|
||||
},
|
||||
medication: {
|
||||
findMany: vi.fn(async ({ where }: { where: { id: { in: string[] } } }) =>
|
||||
state.medications.filter((m) => where.id.in.includes(m.id)),
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
@@ -83,6 +129,51 @@ const NOW_MS = NOW.getTime();
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
const DAILY_MED: FakeMedication = {
|
||||
id: "med-daily",
|
||||
schedules: [{ rrule: "FREQ=DAILY", rollingIntervalDays: null, doseWindows: null }],
|
||||
};
|
||||
const WEEKLY_MED: FakeMedication = {
|
||||
id: "med-weekly",
|
||||
schedules: [
|
||||
{ rrule: "FREQ=WEEKLY;BYDAY=MO", rollingIntervalDays: null, doseWindows: null },
|
||||
],
|
||||
};
|
||||
const ROLLING_MED: FakeMedication = {
|
||||
id: "med-rolling",
|
||||
schedules: [{ rrule: null, rollingIntervalDays: 7, doseWindows: null }],
|
||||
};
|
||||
const WINDOWED_DAILY_MED: FakeMedication = {
|
||||
id: "med-windowed",
|
||||
schedules: [
|
||||
{
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "10:00" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function pendingRow(
|
||||
id: string,
|
||||
medicationId: string,
|
||||
scheduledFor: Date,
|
||||
overrides: Partial<FakeIntakeRow> = {},
|
||||
): FakeIntakeRow {
|
||||
return {
|
||||
id,
|
||||
userId: "u1",
|
||||
medicationId,
|
||||
scheduledFor,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
deletedAt: null,
|
||||
syncVersion: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("intake-auto-skip — schedule contract", () => {
|
||||
it("cron fires hourly at :05 to dodge the :00 reminder-check tick", () => {
|
||||
expect(INTAKE_AUTO_SKIP_CRON).toBe("5 * * * *");
|
||||
@@ -92,118 +183,184 @@ describe("intake-auto-skip — schedule contract", () => {
|
||||
expect(INTAKE_AUTO_SKIP_QUEUE).toBe("intake-auto-skip");
|
||||
});
|
||||
|
||||
it("grace window is exactly 24 hours", () => {
|
||||
it("grace floor is exactly 24 hours", () => {
|
||||
expect(INTAKE_AUTO_SKIP_GRACE_HOURS).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medicationAutoMissDelayMs", () => {
|
||||
it("keeps the 24 h floor for a default daily cadence", () => {
|
||||
expect(medicationAutoMissDelayMs(DAILY_MED.schedules)).toBe(24 * HOUR_MS);
|
||||
});
|
||||
|
||||
it("derives the day-scale band reach for a weekly rrule (5 d + DST pad)", () => {
|
||||
expect(medicationAutoMissDelayMs(WEEKLY_MED.schedules)).toBe(
|
||||
5 * DAY_MS + HOUR_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the day-scale band reach for a rolling cadence", () => {
|
||||
expect(medicationAutoMissDelayMs(ROLLING_MED.schedules)).toBe(
|
||||
5 * DAY_MS + HOUR_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a ≤1-day rolling interval as minute-scale (24 h floor)", () => {
|
||||
expect(
|
||||
medicationAutoMissDelayMs([
|
||||
{ rrule: null, rollingIntervalDays: 1, doseWindows: null },
|
||||
]),
|
||||
).toBe(24 * HOUR_MS);
|
||||
});
|
||||
|
||||
it("extends a daily cadence with configured doseWindows past the floor", () => {
|
||||
// A configured window end can sit up to a local day past the slot
|
||||
// anchor: a day + the default overdue tail + the DST pad.
|
||||
expect(medicationAutoMissDelayMs(WINDOWED_DAILY_MED.schedules)).toBe(
|
||||
DAY_MS + 180 * 60 * 1000 + HOUR_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("a mixed-cadence medication waits for its widest schedule", () => {
|
||||
expect(
|
||||
medicationAutoMissDelayMs([
|
||||
...DAILY_MED.schedules,
|
||||
...WEEKLY_MED.schedules,
|
||||
]),
|
||||
).toBe(5 * DAY_MS + HOUR_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runIntakeAutoSkipPass", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("flips pending rows older than 24 h to auto_missed=true (NOT skipped)", async () => {
|
||||
it("flips daily pending rows older than 24 h to auto_missed (NOT skipped)", async () => {
|
||||
const rows: FakeIntakeRow[] = [
|
||||
// Stale + unmarked → must flip.
|
||||
{
|
||||
id: "i-stale-1",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - 30 * HOUR_MS),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
// Stale + unmarked (different user) → must flip.
|
||||
{
|
||||
id: "i-stale-2",
|
||||
pendingRow("i-stale-1", "med-daily", new Date(NOW_MS - 30 * HOUR_MS)),
|
||||
pendingRow("i-stale-2", "med-daily", new Date(NOW_MS - 48 * HOUR_MS), {
|
||||
userId: "u2",
|
||||
scheduledFor: new Date(NOW_MS - 48 * HOUR_MS),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
// Recent (< 24 h) → must stay pending.
|
||||
{
|
||||
id: "i-recent",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - 6 * HOUR_MS),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
// Already taken → must stay.
|
||||
{
|
||||
id: "i-taken",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - 36 * HOUR_MS),
|
||||
}),
|
||||
pendingRow("i-recent", "med-daily", new Date(NOW_MS - 6 * HOUR_MS)),
|
||||
pendingRow("i-taken", "med-daily", new Date(NOW_MS - 36 * HOUR_MS), {
|
||||
takenAt: new Date(NOW_MS - 30 * HOUR_MS),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
// Already manually skipped → must stay a deliberate skip, never
|
||||
// reclassified as an auto-miss.
|
||||
{
|
||||
id: "i-already-skipped",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - 48 * HOUR_MS),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
},
|
||||
}),
|
||||
pendingRow(
|
||||
"i-already-skipped",
|
||||
"med-daily",
|
||||
new Date(NOW_MS - 48 * HOUR_MS),
|
||||
{ skipped: true },
|
||||
),
|
||||
];
|
||||
const state = { rows };
|
||||
const state = { rows, medications: [DAILY_MED] };
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
|
||||
expect(result.skippedCount).toBe(2);
|
||||
expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS);
|
||||
expect(state.rows.find((r) => r.id === "i-stale-1")?.autoMissed).toBe(true);
|
||||
expect(state.rows.find((r) => r.id === "i-stale-2")?.autoMissed).toBe(true);
|
||||
expect(state.rows.find((r) => r.id === "i-recent")?.autoMissed).toBe(false);
|
||||
expect(state.rows.find((r) => r.id === "i-taken")?.autoMissed).toBe(false);
|
||||
expect(rows.find((r) => r.id === "i-stale-1")?.autoMissed).toBe(true);
|
||||
expect(rows.find((r) => r.id === "i-stale-2")?.autoMissed).toBe(true);
|
||||
expect(rows.find((r) => r.id === "i-recent")?.autoMissed).toBe(false);
|
||||
expect(rows.find((r) => r.id === "i-taken")?.autoMissed).toBe(false);
|
||||
// The forgotten doses are NOT marked as a user skip.
|
||||
expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(false);
|
||||
expect(rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(false);
|
||||
// A deliberate user skip is left exactly as it was.
|
||||
expect(rows.find((r) => r.id === "i-already-skipped")?.skipped).toBe(true);
|
||||
expect(
|
||||
state.rows.find((r) => r.id === "i-already-skipped")?.skipped,
|
||||
).toBe(true);
|
||||
expect(
|
||||
state.rows.find((r) => r.id === "i-already-skipped")?.autoMissed,
|
||||
rows.find((r) => r.id === "i-already-skipped")?.autoMissed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("calls prisma.updateMany with the spec'd where + data shape", async () => {
|
||||
const state = { rows: [] as FakeIntakeRow[] };
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
it("bumps syncVersion on every flipped row", async () => {
|
||||
const rows = [
|
||||
pendingRow("i-sync", "med-daily", new Date(NOW_MS - 30 * HOUR_MS)),
|
||||
];
|
||||
const prisma = makeFakePrisma({ rows, medications: [DAILY_MED] });
|
||||
await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(rows[0].syncVersion).toBe(1);
|
||||
});
|
||||
|
||||
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
takenAt: null,
|
||||
scheduledFor: { lt: new Date(NOW_MS - DAY_MS) },
|
||||
},
|
||||
data: { autoMissed: true },
|
||||
it("never flips a tombstoned row", async () => {
|
||||
const rows = [
|
||||
pendingRow("i-tombstoned", "med-daily", new Date(NOW_MS - 72 * HOUR_MS), {
|
||||
deletedAt: new Date(NOW_MS - 50 * HOUR_MS),
|
||||
}),
|
||||
];
|
||||
const prisma = makeFakePrisma({ rows, medications: [DAILY_MED] });
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(rows[0].autoMissed).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a weekly slot inside its band tail pending (no 24 h stamp)", async () => {
|
||||
// 3 days past the anchor: past the legacy 24 h grace but inside the
|
||||
// day-scale on-time + overdue reach (5 days) — the ledger still shows
|
||||
// the dose takeable, so the cron must not contradict it.
|
||||
const rows = [
|
||||
pendingRow("i-weekly-tail", "med-weekly", new Date(NOW_MS - 3 * DAY_MS)),
|
||||
];
|
||||
const prisma = makeFakePrisma({ rows, medications: [WEEKLY_MED] });
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(rows[0].autoMissed).toBe(false);
|
||||
});
|
||||
|
||||
it("flips a weekly slot once its band tail has fully passed", async () => {
|
||||
const rows = [
|
||||
pendingRow("i-weekly-gone", "med-weekly", new Date(NOW_MS - 6 * DAY_MS)),
|
||||
];
|
||||
const prisma = makeFakePrisma({ rows, medications: [WEEKLY_MED] });
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(1);
|
||||
expect(rows[0].autoMissed).toBe(true);
|
||||
});
|
||||
|
||||
it("applies per-medication cutoffs independently in one pass", async () => {
|
||||
const rows = [
|
||||
pendingRow("i-daily-old", "med-daily", new Date(NOW_MS - 30 * HOUR_MS)),
|
||||
pendingRow("i-rolling-tail", "med-rolling", new Date(NOW_MS - 3 * DAY_MS)),
|
||||
pendingRow("i-rolling-gone", "med-rolling", new Date(NOW_MS - 8 * DAY_MS)),
|
||||
];
|
||||
const prisma = makeFakePrisma({
|
||||
rows,
|
||||
medications: [DAILY_MED, ROLLING_MED],
|
||||
});
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(2);
|
||||
expect(rows.find((r) => r.id === "i-daily-old")?.autoMissed).toBe(true);
|
||||
expect(rows.find((r) => r.id === "i-rolling-tail")?.autoMissed).toBe(false);
|
||||
expect(rows.find((r) => r.id === "i-rolling-gone")?.autoMissed).toBe(true);
|
||||
});
|
||||
|
||||
it("issues zero writes when no candidate rows exist", async () => {
|
||||
const prisma = makeFakePrisma({ rows: [], medications: [DAILY_MED] });
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("groups medications with the same delay into one updateMany", async () => {
|
||||
const rows = [
|
||||
pendingRow("i-w1", "med-weekly", new Date(NOW_MS - 6 * DAY_MS)),
|
||||
pendingRow("i-w2", "med-rolling", new Date(NOW_MS - 6 * DAY_MS)),
|
||||
];
|
||||
const prisma = makeFakePrisma({
|
||||
rows,
|
||||
medications: [WEEKLY_MED, ROLLING_MED],
|
||||
});
|
||||
await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
// Both medications derive the identical day-scale delay → one batch.
|
||||
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is idempotent — a second pass on the same state finds zero candidates", async () => {
|
||||
const rows: FakeIntakeRow[] = [
|
||||
{
|
||||
id: "i-stale",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - 30 * HOUR_MS),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
const rows = [
|
||||
pendingRow("i-stale", "med-daily", new Date(NOW_MS - 30 * HOUR_MS)),
|
||||
];
|
||||
const state = { rows };
|
||||
const prisma = makeFakePrisma(state);
|
||||
const prisma = makeFakePrisma({ rows, medications: [DAILY_MED] });
|
||||
|
||||
const first = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(first.skippedCount).toBe(1);
|
||||
@@ -213,34 +370,24 @@ describe("runIntakeAutoSkipPass", () => {
|
||||
});
|
||||
|
||||
it("does NOT flip rows exactly at the 24 h boundary (uses strict <)", async () => {
|
||||
// The SQL gate is `scheduledFor < NOW - 24h` — a row whose
|
||||
// The SQL gate is `scheduledFor < NOW - delay` — a row whose
|
||||
// `scheduledFor` lands at the cutoff instant must stay pending so
|
||||
// the user still has the closing minute of the window to mark it.
|
||||
const rows: FakeIntakeRow[] = [
|
||||
{
|
||||
id: "i-boundary",
|
||||
userId: "u1",
|
||||
scheduledFor: new Date(NOW_MS - DAY_MS),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
const rows = [
|
||||
pendingRow("i-boundary", "med-daily", new Date(NOW_MS - DAY_MS)),
|
||||
];
|
||||
const state = { rows };
|
||||
const prisma = makeFakePrisma(state);
|
||||
const prisma = makeFakePrisma({ rows, medications: [DAILY_MED] });
|
||||
|
||||
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(state.rows[0].autoMissed).toBe(false);
|
||||
expect(rows[0].autoMissed).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Date.now() when no nowMs override is supplied", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
try {
|
||||
const state = { rows: [] as FakeIntakeRow[] };
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const prisma = makeFakePrisma({ rows: [], medications: [] });
|
||||
const result = await runIntakeAutoSkipPass(prisma);
|
||||
expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS);
|
||||
} finally {
|
||||
|
||||
@@ -15,17 +15,30 @@
|
||||
* `auto_missed` slot to a `missed` status (counts against the rate) while a
|
||||
* `skipped` row stays excluded (a deliberate drug holiday).
|
||||
*
|
||||
* Rule: set `auto_missed = true` for every `MedicationIntakeEvent` where:
|
||||
* v1.15.20 — the cutoff is cadence-aware instead of a flat 24 h. The band
|
||||
* model gives a day-scale cadence (weekly / rolling injectables) an overdue
|
||||
* tail of `weeklyOnTimeDays + weeklyOverdueDays` (5 days by default — the
|
||||
* clinical 4-day rule plus the ±1-day on-time band), so stamping the miss
|
||||
* 24 h after the anchor contradicted the dose-history ledger, which still
|
||||
* showed the slot as takeable. The pass now derives ONE conservative
|
||||
* auto-miss delay per medication from its schedule family (no per-row band
|
||||
* mint): `cutoff = scheduledFor + on-time reach + overdue reach` of the
|
||||
* family, floored at the legacy 24 h grace. The same pass also restricts
|
||||
* the flip to live rows (`deletedAt: null` — a tombstoned row must never be
|
||||
* resurrected as a miss) and bumps `syncVersion` so sync clients see the
|
||||
* transition.
|
||||
*
|
||||
* Rule: set `auto_missed = true` for every live `MedicationIntakeEvent`
|
||||
* where:
|
||||
* * `skipped = false` AND
|
||||
* * `auto_missed = false` AND
|
||||
* * `takenAt IS NULL` AND
|
||||
* * `scheduledFor < NOW() - INTERVAL '24 hours'`
|
||||
* * `deletedAt IS NULL` AND
|
||||
* * `scheduledFor < NOW() - <per-medication auto-miss delay>`
|
||||
*
|
||||
* The 24 h grace window is intentional: a slightly late mark (user took the
|
||||
* The 24 h floor is intentional: a slightly late mark (user took the
|
||||
* morning dose at noon when the schedule was 09:00) shouldn't be flipped
|
||||
* before the user has had a full day to record it. Anything older than 24 h
|
||||
* is reliably forgotten — the compliance engine needs a terminal state to
|
||||
* count it as a real miss.
|
||||
* before the user has had a full day to record it.
|
||||
*
|
||||
* Idempotent by construction: the second pass within the same hour finds
|
||||
* zero candidate rows because the first pass already flipped them. Safe to
|
||||
@@ -37,6 +50,9 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
|
||||
export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
|
||||
|
||||
/**
|
||||
@@ -47,12 +63,25 @@ export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
|
||||
export const INTAKE_AUTO_SKIP_CRON = "5 * * * *";
|
||||
|
||||
/**
|
||||
* Grace window before auto-skip kicks in. 24 h matches the spec and
|
||||
* Grace floor before auto-skip kicks in. 24 h matches the spec and
|
||||
* the user's real-world cadence (late entries up to a full day after
|
||||
* the schedule are still legitimate).
|
||||
* the schedule are still legitimate). Day-scale cadences extend past
|
||||
* this floor via the band-model reach (see `medicationAutoMissDelayMs`).
|
||||
*/
|
||||
export const INTAKE_AUTO_SKIP_GRACE_HOURS = 24;
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Padding added on top of a derived day-scale reach so a DST transition
|
||||
* inside the multi-day tail (the band minter mints its bounds on calendar
|
||||
* days, which can shift an hour across a transition) can never flip a slot
|
||||
* that the ledger still shows as takeable.
|
||||
*/
|
||||
const DST_PAD_MS = HOUR_MS;
|
||||
|
||||
export interface IntakeAutoSkipPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
@@ -60,15 +89,88 @@ export interface IntakeAutoSkipPayload {
|
||||
export interface IntakeAutoSkipResult {
|
||||
/** Rows that flipped from pending to `auto_missed = true` on this pass. */
|
||||
skippedCount: number;
|
||||
/** Cutoff timestamp the pass applied — useful for the audit trail. */
|
||||
/**
|
||||
* Base (24 h floor) cutoff the pass applied — useful for the audit trail.
|
||||
* Day-scale medications use an earlier per-medication cutoff derived from
|
||||
* their band reach.
|
||||
*/
|
||||
cutoff: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one auto-skip pass. Returns the flipped row count + the cutoff
|
||||
* The narrow schedule projection the delay derivation reads. Mirrors the
|
||||
* field-shape family signal the band minter falls back to when too few
|
||||
* slots exist to measure a realised gap — the cron must not mint bands per
|
||||
* row, so the conservative field-shape classification is the right tool.
|
||||
*/
|
||||
interface AutoMissScheduleRow {
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
doseWindows: unknown;
|
||||
}
|
||||
|
||||
/** True when the schedule's field shape describes a day-scale cadence. */
|
||||
function isDayScaleSchedule(schedule: AutoMissScheduleRow): boolean {
|
||||
if (
|
||||
schedule.rollingIntervalDays !== null &&
|
||||
schedule.rollingIntervalDays >= 2
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(schedule.rrule ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative auto-miss delay for one medication, derived ONCE per
|
||||
* medication (never per row): the maximum band reach across its schedules.
|
||||
*
|
||||
* - day-scale (weekly / rolling): `weeklyOnTimeDays + weeklyOverdueDays`
|
||||
* days past the anchor (5 days by default) + a DST pad;
|
||||
* - minute-scale with configured `doseWindows`: the explicit window end
|
||||
* can sit up to a local day past the slot anchor (e.g. an 00:00 anchor
|
||||
* with a 23:59 window end), so the reach is a day + the overdue tail;
|
||||
* - minute-scale default: the legacy 24 h grace already dominates the
|
||||
* default ±1 h + 3 h tail.
|
||||
*
|
||||
* A mixed-cadence medication (daily oral + weekly injection on one row)
|
||||
* waits for the widest schedule — conservative by design: stamping a miss
|
||||
* late is recoverable noise, stamping it while the ledger still shows the
|
||||
* slot takeable is a contradiction.
|
||||
*/
|
||||
export function medicationAutoMissDelayMs(
|
||||
schedules: AutoMissScheduleRow[],
|
||||
): number {
|
||||
const floorMs = INTAKE_AUTO_SKIP_GRACE_HOURS * HOUR_MS;
|
||||
let delayMs = floorMs;
|
||||
for (const schedule of schedules) {
|
||||
if (isDayScaleSchedule(schedule)) {
|
||||
const reachMs =
|
||||
(DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays +
|
||||
DOSE_WINDOW_DEFAULTS.weeklyOverdueDays) *
|
||||
DAY_MS +
|
||||
DST_PAD_MS;
|
||||
delayMs = Math.max(delayMs, reachMs);
|
||||
} else if (normaliseDoseWindows(schedule.doseWindows) !== null) {
|
||||
const reachMs =
|
||||
DAY_MS +
|
||||
DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes * MINUTE_MS +
|
||||
DST_PAD_MS;
|
||||
delayMs = Math.max(delayMs, reachMs);
|
||||
}
|
||||
}
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one auto-skip pass. Returns the flipped row count + the base cutoff
|
||||
* the pass used. Pure function over the injected Prisma client so
|
||||
* unit tests can drive it with an in-memory fake.
|
||||
*
|
||||
* Efficiency: one `groupBy` finds the medications that carry ANY candidate
|
||||
* row past the 24 h floor; one schedules read derives each medication's
|
||||
* delay; one `updateMany` per distinct delay (in practice ≤ 3 groups)
|
||||
* flips the rows. No per-row work.
|
||||
*
|
||||
* `nowMs` is injectable for the same testing reason — the production
|
||||
* caller passes `Date.now()`; tests pass a frozen instant so the
|
||||
* cutoff arithmetic is deterministic.
|
||||
@@ -78,19 +180,64 @@ export async function runIntakeAutoSkipPass(
|
||||
options: { nowMs?: number } = {},
|
||||
): Promise<IntakeAutoSkipResult> {
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const cutoff = new Date(
|
||||
nowMs - INTAKE_AUTO_SKIP_GRACE_HOURS * 60 * 60 * 1000,
|
||||
const baseCutoff = new Date(
|
||||
nowMs - INTAKE_AUTO_SKIP_GRACE_HOURS * HOUR_MS,
|
||||
);
|
||||
|
||||
const { count } = await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
takenAt: null,
|
||||
scheduledFor: { lt: cutoff },
|
||||
const pendingWhere = {
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
takenAt: null,
|
||||
// A tombstoned row is gone for every read surface; flipping it would
|
||||
// leak a phantom miss back into the sync stream.
|
||||
deletedAt: null,
|
||||
} as const;
|
||||
|
||||
// Medications carrying at least one candidate row past the 24 h floor.
|
||||
// Day-scale rows inside their band tail are filtered out per-medication
|
||||
// below; everything younger than the floor is never a candidate.
|
||||
const candidates = await prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { ...pendingWhere, scheduledFor: { lt: baseCutoff } },
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return { skippedCount: 0, cutoff: baseCutoff };
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { id: { in: candidates.map((c) => c.medicationId) } },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
select: { rrule: true, rollingIntervalDays: true, doseWindows: true },
|
||||
},
|
||||
},
|
||||
data: { autoMissed: true },
|
||||
});
|
||||
|
||||
return { skippedCount: count, cutoff };
|
||||
// Group medications by their derived delay so one `updateMany` covers
|
||||
// each distinct cutoff instead of one query per medication.
|
||||
const medsByDelay = new Map<number, string[]>();
|
||||
for (const medication of medications) {
|
||||
const delayMs = medicationAutoMissDelayMs(medication.schedules);
|
||||
const group = medsByDelay.get(delayMs) ?? [];
|
||||
group.push(medication.id);
|
||||
medsByDelay.set(delayMs, group);
|
||||
}
|
||||
|
||||
let skippedCount = 0;
|
||||
for (const [delayMs, medicationIds] of medsByDelay) {
|
||||
const { count } = await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
...pendingWhere,
|
||||
medicationId: { in: medicationIds },
|
||||
scheduledFor: { lt: new Date(nowMs - delayMs) },
|
||||
},
|
||||
// `syncVersion` bumps so delta-sync clients pick up the terminal
|
||||
// state instead of holding a stale pending row forever.
|
||||
data: { autoMissed: true, syncVersion: { increment: 1 } },
|
||||
});
|
||||
skippedCount += count;
|
||||
}
|
||||
|
||||
return { skippedCount, cutoff: baseCutoff };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v1.15.20 — coverage for the shared card window-status helper.
|
||||
*
|
||||
* Pins the three model fixes:
|
||||
* - the wall-clock conversion honours an explicit IANA timezone (the
|
||||
* Berlin default stays byte-stable for the legacy call sites);
|
||||
* - a degenerate `windowStart === windowEnd` schedule reads as a POINT
|
||||
* window widened by the default daily on-time half-width — not as a
|
||||
* 24 h overnight band that kept the pill in `in_window` all day;
|
||||
* - `countPassedSchedules` counts `timesOfDay` doses, not schedule rows,
|
||||
* so a two-dose row keeps the overdue pill up until BOTH doses are
|
||||
* covered by today's intake events.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
toZonedDate,
|
||||
} from "../window-status";
|
||||
|
||||
/** Build a local wall-clock Date (what the cards pass as `nowBerlin`). */
|
||||
function localClock(hours: number, minutes = 0): Date {
|
||||
// Wednesday, June 10 2026 — weekday-agnostic fixtures (no daysOfWeek).
|
||||
return new Date(2026, 5, 10, hours, minutes, 0, 0);
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 240,
|
||||
active: true,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
};
|
||||
|
||||
describe("toZonedDate", () => {
|
||||
it("shifts an instant into the requested timezone's wall clock", () => {
|
||||
const instant = new Date("2026-06-10T12:00:00Z");
|
||||
expect(toZonedDate(instant, "Europe/Berlin").getHours()).toBe(14); // CEST
|
||||
expect(toZonedDate(instant, "America/New_York").getHours()).toBe(8); // EDT
|
||||
});
|
||||
|
||||
it("toBerlinDate stays the Berlin alias for legacy call sites", () => {
|
||||
const instant = new Date("2026-06-10T12:00:00Z");
|
||||
expect(toBerlinDate(instant).getTime()).toBe(
|
||||
toZonedDate(instant, "Europe/Berlin").getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — timezone threading", () => {
|
||||
it("compares lastTakenAt in the supplied timezone, not hardcoded Berlin", () => {
|
||||
// 08:30 local in New York = 12:30 UTC. With tz=America/New_York the
|
||||
// last intake falls inside the 08:00–09:00 window and the in_window
|
||||
// pill is suppressed; under the Berlin default the same instant reads
|
||||
// as 14:30 local (outside the window) and the pill would show.
|
||||
const schedules = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
const nowLocal = localClock(8, 45);
|
||||
|
||||
const withUserTz = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules,
|
||||
nowBerlin: nowLocal,
|
||||
lastTakenAt: "2026-06-10T12:30:00Z",
|
||||
todayEventCount: 1,
|
||||
tz: "America/New_York",
|
||||
});
|
||||
expect(withUserTz.status).toBeNull();
|
||||
|
||||
const withDefaultTz = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules,
|
||||
nowBerlin: nowLocal,
|
||||
lastTakenAt: "2026-06-10T12:30:00Z",
|
||||
todayEventCount: 1,
|
||||
});
|
||||
expect(withDefaultTz.status).toBe("in_window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
const pointSchedule = [
|
||||
{ windowStart: "08:00", windowEnd: "08:00", daysOfWeek: null },
|
||||
];
|
||||
|
||||
it("does NOT read as in_window all day (the old 24 h overnight bug)", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(15, 0),
|
||||
});
|
||||
// 15:00 is hours past the widened 07:00–09:00 point window and past
|
||||
// the late + missed thresholds → no pill at all.
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
|
||||
it("is in_window inside the default ±60 min widening", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(8, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
});
|
||||
|
||||
it("turns late shortly after the widened window end", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(10, 0),
|
||||
});
|
||||
// Window end 09:00 + 60 min past → late tier.
|
||||
expect(res.status).toBe("late");
|
||||
});
|
||||
|
||||
it("keeps a genuine overnight window on the wrap-around path", () => {
|
||||
const overnight = [
|
||||
{ windowStart: "22:00", windowEnd: "02:00", daysOfWeek: null },
|
||||
];
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: overnight,
|
||||
nowBerlin: localClock(23, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () => {
|
||||
// One schedule row, two dose times, window long past at 22:00.
|
||||
const twoDoseSchedule = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "20:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps the overdue pill when only one of two doses is covered", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 0),
|
||||
todayEventCount: 1,
|
||||
});
|
||||
// One event covers one dose; the second passed dose is uncovered →
|
||||
// the late pill must stay up. (Pre-v1.15.20 the row counted as ONE
|
||||
// expected dose and the pill vanished here.)
|
||||
expect(res.status).toBe("late");
|
||||
});
|
||||
|
||||
it("suppresses the pill once both doses are covered", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 0),
|
||||
todayEventCount: 2,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
|
||||
it("a schedule without timesOfDay keeps counting as one dose", () => {
|
||||
const legacy = [
|
||||
{ windowStart: "08:00", windowEnd: "20:00", daysOfWeek: null },
|
||||
];
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: legacy,
|
||||
nowBerlin: localClock(21, 0),
|
||||
todayEventCount: 1,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,50 @@ describe("applyCanonicalSlotWrite — M2 inventory transition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCanonicalSlotWrite — auto-miss reset", () => {
|
||||
it("writes autoMissed:false when a take lands on an existing slot row", async () => {
|
||||
// The hourly auto-miss cron stamped the slot; a late user take must
|
||||
// clear the flag or the compliance engine keeps counting the recorded
|
||||
// dose as a miss.
|
||||
const autoMissed = row({ id: "missed-1", source: "REMINDER" });
|
||||
const client = makeClient({
|
||||
findManyResult: [[autoMissed]],
|
||||
updateImpl: async (id) =>
|
||||
row({ id, takenAt: new Date("2026-06-16T09:00:00Z") }),
|
||||
});
|
||||
await applyCanonicalSlotWrite({
|
||||
...BASE,
|
||||
client: client as unknown as UpsertClient,
|
||||
takenAt: new Date("2026-06-16T09:00:00Z"),
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
});
|
||||
const updateArg = vi.mocked(client.medicationIntakeEvent.update).mock
|
||||
.calls[0][0] as unknown as { data: Record<string, unknown> };
|
||||
expect(updateArg.data.autoMissed).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT touch autoMissed on a write without a takenAt (skip)", async () => {
|
||||
const pending = row({ id: "pend-skip", source: "REMINDER" });
|
||||
const client = makeClient({
|
||||
findManyResult: [[pending]],
|
||||
updateImpl: async (id) => row({ id, skipped: true }),
|
||||
});
|
||||
await applyCanonicalSlotWrite({
|
||||
...BASE,
|
||||
client: client as unknown as UpsertClient,
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
isExplicitTaken: false,
|
||||
isExplicitSkip: true,
|
||||
});
|
||||
const updateArg = vi.mocked(client.medicationIntakeEvent.update).mock
|
||||
.calls[0][0] as unknown as { data: Record<string, unknown> };
|
||||
expect("autoMissed" in updateArg.data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCanonicalSlotWrite — H1 deterministic selection", () => {
|
||||
it("updates the ACTIONED row, not the pending one, when both exist", async () => {
|
||||
// Pending REMINDER row created first, taken WEB row created later. The
|
||||
|
||||
@@ -269,6 +269,12 @@ async function applyToExisting(
|
||||
data: {
|
||||
takenAt,
|
||||
skipped,
|
||||
// A recorded dose is no longer an auto-miss. The hourly auto-miss cron
|
||||
// stamps `autoMissed: true` on a never-acted slot; when the user later
|
||||
// records the take (late entry, offline sync catching up), the flag
|
||||
// must reset or the compliance engine keeps counting the now-taken
|
||||
// dose as a miss.
|
||||
...(takenAt !== null && { autoMissed: false }),
|
||||
syncVersion: { increment: 1 },
|
||||
idempotencyKey: idempotencyKey ?? existing.idempotencyKey ?? null,
|
||||
// v1.8.5 — write the site only when this write carries a resolved
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* v1.4.37 W4b — shared current-window status helper for medication
|
||||
* detail cards.
|
||||
*
|
||||
* Both the generic `<MedicationCard>` (Ramipril etc.) and the GLP-1
|
||||
* variant (`<Glp1MedicationCard>` — Mounjaro etc.) need to paint the
|
||||
* same coloured "take now / overdue / very overdue" pill at the top of
|
||||
* their detail surface. The logic was historically inlined in the
|
||||
* generic card only, which produced the visible asymmetry Marc reported
|
||||
* during the v1.4.37 UX audit (audit item 11).
|
||||
* Both the generic `<MedicationCard>` and the GLP-1 variant
|
||||
* (`<Glp1MedicationCard>`) need to paint the same coloured "take now /
|
||||
* overdue / very overdue" pill at the top of their detail surface. The
|
||||
* logic was historically inlined in the generic card only, which produced
|
||||
* a visible asymmetry between the two variants (v1.4.37 UX audit item 11).
|
||||
*
|
||||
* Lifting the schedule-window math into one helper guarantees both card
|
||||
* variants stay byte-equivalent for the status row — same thresholds,
|
||||
@@ -17,13 +16,32 @@
|
||||
* `windowStart` / `windowEnd` / `daysOfWeek` fields as the generic
|
||||
* schedule, so the helper accepts any schedule that satisfies the
|
||||
* minimal `ScheduleWindowInput` shape.
|
||||
*
|
||||
* v1.15.20 — three model fixes:
|
||||
* - the wall-clock conversion takes the user's IANA timezone (default
|
||||
* `Europe/Berlin` so existing call sites compile + behave unchanged;
|
||||
* threading the real user timezone through the cards is the follow-up);
|
||||
* - a degenerate `windowStart === windowEnd` schedule (a single dose
|
||||
* time echoed into both fields) is a POINT window widened by the
|
||||
* default daily on-time half-width — it previously fell into the
|
||||
* overnight branch and read as a 24 h always-in-window band;
|
||||
* - `countPassedSchedules` counts the schedule's `timesOfDay` doses, not
|
||||
* schedule rows, so a two-dose row ("08:00" + "20:00") expects two
|
||||
* intake events before the overdue pill is suppressed.
|
||||
*/
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
|
||||
export interface ScheduleWindowInput {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
/**
|
||||
* v1.15.20 — first-class dose times. Optional so legacy fixtures and the
|
||||
* existing card call sites compile unchanged; when absent the schedule
|
||||
* row counts as ONE expected dose (the pre-v1.15.20 behaviour).
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
}
|
||||
|
||||
export type MedicationWindowStatus = "in_window" | "late" | "very_late" | null;
|
||||
@@ -33,15 +51,26 @@ export interface CurrentWindowStatus<Schedule extends ScheduleWindowInput> {
|
||||
schedule: Schedule | null;
|
||||
}
|
||||
|
||||
/** Fallback timezone until every call site threads the user's own. */
|
||||
const DEFAULT_TZ = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Convert a `Date` from its underlying UTC instant into the wall-clock
|
||||
* value in Europe/Berlin so the per-minute arithmetic below treats the
|
||||
* user's day boundary correctly. Intentionally en-US — this is not a
|
||||
* user-facing format string, only a parseable round-trip to shift the
|
||||
* value into Berlin local time.
|
||||
* value in the given IANA timezone so the per-minute arithmetic below
|
||||
* treats the user's day boundary correctly. Intentionally en-US — this is
|
||||
* not a user-facing format string, only a parseable round-trip to shift
|
||||
* the value into the target local time.
|
||||
*/
|
||||
export function toZonedDate(date: Date, tz: string = DEFAULT_TZ): Date {
|
||||
return new Date(date.toLocaleString("en-US", { timeZone: tz }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias kept for the existing card call sites; new code should call
|
||||
* `toZonedDate(date, userTz)` with the user's real timezone.
|
||||
*/
|
||||
export function toBerlinDate(date: Date): Date {
|
||||
return new Date(date.toLocaleString("en-US", { timeZone: "Europe/Berlin" }));
|
||||
return toZonedDate(date, DEFAULT_TZ);
|
||||
}
|
||||
|
||||
export function parseTimeToMinutes(value: string): number {
|
||||
@@ -59,18 +88,49 @@ export function parseTimeToMinutes(value: string): number {
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
interface WindowBounds {
|
||||
startMins: number;
|
||||
/** May exceed 24 h × 60 for an overnight window (end wrapped past midnight). */
|
||||
endMins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a schedule's window into minute-of-day bounds with two special
|
||||
* shapes handled:
|
||||
*
|
||||
* - `end === start` — a degenerate POINT window (a single dose time echoed
|
||||
* into both fields). Previously this fell into the overnight branch and
|
||||
* became a 24 h always-in-window band; it now widens symmetrically by
|
||||
* the default daily on-time half-width, clamped to the same local day
|
||||
* so the overnight arithmetic downstream stays untouched.
|
||||
* - `end < start` — a genuine overnight window; the end wraps past
|
||||
* midnight (`endMins > 1440`), matching the historical behaviour.
|
||||
*/
|
||||
function resolveWindowBounds(schedule: ScheduleWindowInput): WindowBounds {
|
||||
let startMins = parseTimeToMinutes(schedule.windowStart);
|
||||
let endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
|
||||
if (endMins === startMins) {
|
||||
const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes;
|
||||
startMins = Math.max(0, startMins - half);
|
||||
endMins = Math.min(24 * 60 - 1, endMins + half);
|
||||
} else if (endMins < startMins) {
|
||||
endMins += 24 * 60;
|
||||
}
|
||||
|
||||
return { startMins, endMins };
|
||||
}
|
||||
|
||||
function getWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
schedule: Schedule,
|
||||
nowBerlin: Date,
|
||||
nowLocal: Date,
|
||||
lateMinutes: number,
|
||||
missedMinutes: number,
|
||||
): MedicationWindowStatus {
|
||||
const nowMins = nowBerlin.getHours() * 60 + nowBerlin.getMinutes();
|
||||
const startMins = parseTimeToMinutes(schedule.windowStart);
|
||||
let endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
const { startMins, endMins } = resolveWindowBounds(schedule);
|
||||
|
||||
// Handle overnight windows
|
||||
if (endMins <= startMins) endMins += 24 * 60;
|
||||
const adjustedNow =
|
||||
nowMins < startMins && endMins > 24 * 60 ? nowMins + 24 * 60 : nowMins;
|
||||
|
||||
@@ -89,27 +149,26 @@ function getWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
function isLastIntakeInCurrentWindow<Schedule extends ScheduleWindowInput>(
|
||||
lastTakenAt: string | null,
|
||||
schedule: Schedule,
|
||||
nowBerlin: Date,
|
||||
nowLocal: Date,
|
||||
tz: string,
|
||||
): boolean {
|
||||
if (!lastTakenAt) return false;
|
||||
|
||||
const intake = toBerlinDate(new Date(lastTakenAt));
|
||||
const intake = toZonedDate(new Date(lastTakenAt), tz);
|
||||
|
||||
// Must be same calendar day
|
||||
if (
|
||||
intake.getFullYear() !== nowBerlin.getFullYear() ||
|
||||
intake.getMonth() !== nowBerlin.getMonth() ||
|
||||
intake.getDate() !== nowBerlin.getDate()
|
||||
intake.getFullYear() !== nowLocal.getFullYear() ||
|
||||
intake.getMonth() !== nowLocal.getMonth() ||
|
||||
intake.getDate() !== nowLocal.getDate()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intakeMins = intake.getHours() * 60 + intake.getMinutes();
|
||||
const startMins = parseTimeToMinutes(schedule.windowStart);
|
||||
let endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
const { startMins, endMins } = resolveWindowBounds(schedule);
|
||||
|
||||
// Handle overnight windows
|
||||
if (endMins <= startMins) endMins += 24 * 60;
|
||||
const adjustedIntake =
|
||||
intakeMins < startMins && endMins > 24 * 60
|
||||
? intakeMins + 24 * 60
|
||||
@@ -119,27 +178,46 @@ function isLastIntakeInCurrentWindow<Schedule extends ScheduleWindowInput>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many of the supplied schedules are past their window today,
|
||||
* honouring the recurrence's allowed days-of-week. Used by both cards
|
||||
* to suppress "overdue" pills once the user has already covered every
|
||||
* passed schedule with intake events for the day.
|
||||
* Count how many doses are past their window today, honouring the
|
||||
* recurrence's allowed days-of-week. Used by both cards to suppress
|
||||
* "overdue" pills once the user has already covered every passed dose
|
||||
* with intake events for the day.
|
||||
*
|
||||
* v1.15.20 — counts `timesOfDay` entries, not schedule rows: a schedule
|
||||
* row carrying two dose times expects TWO intake events once its window
|
||||
* passes. A row without `timesOfDay` (legacy shape) keeps counting as one.
|
||||
* The passed check stays anchored on the (degenerate-resolved) raw window
|
||||
* end of the same local day, matching the historical comparison — an
|
||||
* overnight window therefore never reads as "passed" within the same local
|
||||
* day, exactly as before.
|
||||
*/
|
||||
function countPassedSchedules<Schedule extends ScheduleWindowInput>(
|
||||
schedules: Schedule[],
|
||||
nowBerlin: Date,
|
||||
nowLocal: Date,
|
||||
): number {
|
||||
return schedules.filter((s) => {
|
||||
return schedules.reduce((sum, s) => {
|
||||
const recurrence = parseScheduleRecurrence(s.daysOfWeek);
|
||||
if (
|
||||
recurrence.daysOfWeek.length > 0 &&
|
||||
!recurrence.daysOfWeek.includes(nowBerlin.getDay())
|
||||
!recurrence.daysOfWeek.includes(nowLocal.getDay())
|
||||
) {
|
||||
return false;
|
||||
return sum;
|
||||
}
|
||||
const endMins = parseTimeToMinutes(s.windowEnd);
|
||||
const nowMins = nowBerlin.getHours() * 60 + nowBerlin.getMinutes();
|
||||
return nowMins > endMins;
|
||||
}).length;
|
||||
// Same-day end bound: a wrapped overnight end (> 1440) keeps the raw
|
||||
// minute value so the comparison below matches the historical
|
||||
// behaviour; the degenerate point window resolves to its widened end.
|
||||
const rawStart = parseTimeToMinutes(s.windowStart);
|
||||
const rawEnd = parseTimeToMinutes(s.windowEnd);
|
||||
const endMins =
|
||||
rawEnd === rawStart
|
||||
? Math.min(24 * 60 - 1, rawEnd + DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes)
|
||||
: rawEnd;
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
if (nowMins <= endMins) return sum;
|
||||
const doseCount =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
|
||||
return sum + doseCount;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +229,12 @@ function countPassedSchedules<Schedule extends ScheduleWindowInput>(
|
||||
* Priority: in_window > late > very_late. Suppresses an `in_window`
|
||||
* status when the last intake already falls inside the window so the
|
||||
* pill doesn't nag after the user took the dose.
|
||||
*
|
||||
* `nowBerlin` is the wall-clock "now" already shifted into the user's
|
||||
* timezone (via `toZonedDate`); `tz` must name the same timezone so the
|
||||
* last-intake comparison shifts `lastTakenAt` identically. The name and
|
||||
* the default are the legacy Berlin contract — callers that serve other
|
||||
* timezones pass their own.
|
||||
*/
|
||||
export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
options: {
|
||||
@@ -161,6 +245,8 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
active: boolean;
|
||||
lastTakenAt: string | null;
|
||||
todayEventCount: number;
|
||||
/** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */
|
||||
tz?: string;
|
||||
},
|
||||
): CurrentWindowStatus<Schedule> {
|
||||
const {
|
||||
@@ -171,6 +257,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
active,
|
||||
lastTakenAt,
|
||||
todayEventCount,
|
||||
tz = DEFAULT_TZ,
|
||||
} = options;
|
||||
|
||||
if (!active) return { status: null, schedule: null };
|
||||
@@ -189,7 +276,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
// window today.
|
||||
if (
|
||||
status === "in_window" &&
|
||||
isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin)
|
||||
isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin, tz)
|
||||
) {
|
||||
return best;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user