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:
Marc-André Bombeck committed 2026-06-10 13:34:26 +02:00
1 parent fb6e494f64
commit 0a4bf34a2b
16 files changed
+1380 -214

No files matched your search

+4 -1
View File
@@ -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),
+4 -1
View File
@@ -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);
+76 -2
View File
@@ -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"),
}),
}),
);
});
});
+77 -26
View File
@@ -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 /
+29
View File
@@ -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`
+4 -1
View File
@@ -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" },
});
+269 -122
View File
@@ -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 {
+167 -20
View File
@@ -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
+124 -37
View File
@@ -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;
}