diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 9b36a8f23..44a509689 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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), diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index dbbe4133d..56e58c119 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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" }, }); diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index a4ad7410f..34832172c 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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; + data: Record; + }; + // 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 { + 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> } }; + }; + 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); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 5bff9ba7a..708bba268 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c200acf77..eb2df8097 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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({ diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 5da4e8ce8..fb399277e 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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"), + }), + }), + ); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 2bdbd06cd..a7f191f08 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { 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 / diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index bb144c361..36d1e1a69 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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` diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 07a4e9188..c9cc931ce 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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( diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index f7ec2ed2a..133bfd509 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -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" }, }); diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts index 648c151d0..dd463e097 100644 --- a/src/lib/jobs/__tests__/intake-auto-skip.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -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 { + 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 { diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 0f8ae02c6..a39279b62 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -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() - ` * - * 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 { 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(); + 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 }; } diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts new file mode 100644 index 000000000..7f98454f8 --- /dev/null +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -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(); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts b/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts index 291b76f80..cf02afc13 100644 --- a/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts +++ b/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts @@ -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 }; + 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 }; + 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 diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index cdd81e3e0..a7c95552a 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -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 diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index c7c1d1139..804b6086e 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -2,12 +2,11 @@ * v1.4.37 W4b — shared current-window status helper for medication * detail cards. * - * Both the generic `` (Ramipril etc.) and the GLP-1 - * variant (`` — 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 `` and the GLP-1 variant + * (``) 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: 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: 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( function isLastIntakeInCurrentWindow( 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( } /** - * 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( 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( * 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( options: { @@ -161,6 +245,8 @@ export function reduceCurrentWindowStatus( active: boolean; lastTakenAt: string | null; todayEventCount: number; + /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ + tz?: string; }, ): CurrentWindowStatus { const { @@ -171,6 +257,7 @@ export function reduceCurrentWindowStatus( active, lastTakenAt, todayEventCount, + tz = DEFAULT_TZ, } = options; if (!active) return { status: null, schedule: null }; @@ -189,7 +276,7 @@ export function reduceCurrentWindowStatus( // window today. if ( status === "in_window" && - isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin) + isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin, tz) ) { return best; }