From faeb770cbb6879c9497827cc05f3ca460dcc7022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 14:41:33 +0200 Subject: [PATCH] fix(medications): advance the next dose past already-resolved slots The card's next dose did not move on after a dose was logged: the server's next-due and the open-cycle descriptor both picked the next slot purely by clock time, ignoring whether that slot was already taken, skipped, or missed. For a twice-daily med whose remaining slots today were all logged, the card kept surfacing a present/past resolved slot instead of advancing to the next genuinely-open one. Skip resolved slots in both paths: computeNextDueAt and buildCurrentCycle now walk occurrences forward and return the first slot no intake has resolved (taken / skipped / auto-missed), so a med whose slots today are all logged advances to tomorrow's first open slot. The list and detail routes read the acted-on slots near now and thread them in; both walks are bounded so a logged-ahead history cannot spin. The card's compliance/next-dose queries already ride the ["medications"] invalidation prefix, so logging a dose refreshes the card promptly once the server value advances. --- src/app/api/medications/[id]/route.ts | 24 +++++- src/app/api/medications/route.ts | 81 +++++++++++++----- src/lib/analytics/compliance.ts | 83 +++++++++++++++++-- .../scheduling/__tests__/next-due.test.ts | 74 +++++++++++++++++ src/lib/medications/scheduling/next-due.ts | 44 +++++++++- 5 files changed, 271 insertions(+), 35 deletions(-) diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index ae5837a94..8c33a61fb 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -64,6 +64,27 @@ export const GET = apiHandler( select: { takenAt: true }, }) : null; + // v1.15.10 — slots the user has already acted on near now, so the + // next-due search skips them and advances to the next genuinely-open + // slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the + // slots adjacent to now. + const now = new Date(); + const resolvedFrom = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + const resolvedRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + medicationId: id, + deletedAt: null, + scheduledFor: { gte: resolvedFrom, lte: resolvedTo }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + select: { scheduledFor: true }, + }); const nextDue = computeNextDueAt({ medication: { id: medication.id, @@ -73,9 +94,10 @@ export const GET = apiHandler( createdAt: medication.createdAt, }, schedules: medication.schedules, - now: new Date(), + now, userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, + resolvedSlots: resolvedRows.map((r) => r.scheduledFor), }); annotate({ diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 844535a7b..4a470f920 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -32,29 +32,63 @@ async function buildMedicationsList( userTz, ); - const [medications, latestIntakes, todayEvents] = await Promise.all([ - prisma.medication.findMany({ - where: { userId }, - include: { schedules: true }, - orderBy: { createdAt: "desc" }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the last-taken map. - where: { userId, deletedAt: null, skipped: false, takenAt: { not: null } }, - _max: { takenAt: true }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the today-count map. - where: { - userId, - deletedAt: null, - scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, - }, - _count: { id: true }, - }), - ]); + // v1.15.10 — the slots the user has already acted on near "now", so the + // next-due search can skip them. A resolved slot is a taken, deliberately + // skipped, or cron-auto-missed row. Bound the window to [today-start, + // today-end + 2d] — the next-due lookahead only needs the slots adjacent to + // now, and a tight window keeps the read cheap. The 60s list cache covers + // the rest. + const resolvedWindowEnd = new Date(todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000); + + const [medications, latestIntakes, todayEvents, resolvedEvents] = + await Promise.all([ + prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the last-taken map. + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + }, + _max: { takenAt: true }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the today-count map. + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + }, + _count: { id: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStartUtc, lte: resolvedWindowEnd }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + select: { medicationId: true, scheduledFor: true }, + }), + ]); + + const resolvedSlotsByMedId = new Map(); + for (const e of resolvedEvents) { + const list = resolvedSlotsByMedId.get(e.medicationId); + if (list) list.push(e.scheduledFor); + else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + } const lastTakenAtByMedicationId = Object.fromEntries( latestIntakes.map((entry) => [ @@ -101,6 +135,7 @@ async function buildMedicationsList( now, userTz, lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, + resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], }); return { ...m, diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 96ceaeaf8..de336999b 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -502,6 +502,21 @@ function toCanonicalSchedule( export interface ComplianceIntakeInstant { takenAt: Date | null; skipped: boolean; + /** + * v1.15.10 — the dose slot this intake resolves (the canonical snapped + * instant, byte-identical to the engine occurrence's `.at`). Optional so + * every legacy caller / fixture that omits it keeps working; when present + * the open-dose selection in {@link buildCurrentCycle} uses it to skip a + * slot the user has already acted on (taken / skipped / auto-missed) so the + * card advances to the next genuinely-open slot rather than re-surfacing a + * resolved past dose. + */ + scheduledFor?: Date; + /** + * v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot + * is excluded from the open-dose search the same way a take / skip is. + */ + autoMissed?: boolean; } function intakeInstantsAtOrBefore( @@ -845,6 +860,44 @@ export interface ComplianceDisplay { * selection: a window holding zero expected (closed) doses means the * percentage rows are vacuous and the card should show a neutral state. */ +/** + * v1.15.10 — half-window an intake may sit from a slot's canonical instant + * and still count as "resolving" that slot. The intake write paths snap + * `scheduledFor` to the exact engine slot instant, so an exact (or + * sub-minute) match is the common case; the ±6h tolerance also catches an + * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) + * whose snapped slot is the 07:00 row but whose raw instant we compare + * defensively. It is the same ±half-gap radius the cadence pairer uses for a + * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still + * matches its one slot. + */ +const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + +/** + * v1.15.10 — true when an intake event has already resolved the slot at + * `slotAt`. A resolved slot is one the user took, deliberately skipped, or + * the auto-miss cron flagged — any of which means the card must NOT keep + * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` + * first (the canonical, exact path) and falls back to the take/skip instant + * within the resolve radius for rows that predate the snap or drifted. + */ +function slotIsResolved( + slotAt: Date, + intakes: ComplianceIntakeInstant[], +): boolean { + const slot = slotAt.getTime(); + for (const e of intakes) { + const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; + if (!isResolved) continue; + const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; + if (!ref) continue; + if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { + return true; + } + } + return false; +} + export function buildCurrentCycle( schedules: ComplianceSchedule[], ctx: ComplianceMedicationContext, @@ -854,8 +907,9 @@ export function buildCurrentCycle( ): CurrentCycle { const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle"); const DAY_MS = 24 * 60 * 60 * 1000; + const allIntakes = intakes ?? []; const lastIntakeAt = - lastNonSkippedTakenAt(intakes ?? []) ?? ctx.lastIntakeAt; + lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt; // The open cycle's due instant + grace, picked as the SOONEST across every // schedule. Two paths: @@ -893,12 +947,27 @@ export function buildCurrentCycle( consider(anchor, new Date(anchor.getTime() + graceMs)); continue; } - const occ = nextOccurrenceAfter( - canonical, - new Date(now.getTime() - 1), - recurrenceCtx, - ); - if (occ) consider(occ.at, occ.graceUntil); + // v1.15.10 — advance past slots the user has already resolved. The + // engine's `nextOccurrenceAfter` is purely time-anchored, so for a + // twice-daily med it surfaces today's 19:00 slot in the afternoon even + // after the user logged that dose early — the card then sticks on a + // resolved past/present slot ("träge"). Walk occurrences forward and pick + // the first one no intake has resolved (taken / skipped / auto-missed), so + // a med whose remaining slots today are all logged advances to tomorrow's + // first open slot. Bounded so a fully-logged-ahead history can't spin. + let after = new Date(now.getTime() - 1); + let picked: Occurrence | null = null; + for (let step = 0; step < 64; step++) { + const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); + if (!occ) break; + if (!slotIsResolved(occ.at, allIntakes)) { + picked = occ; + break; + } + // This slot is resolved — step strictly past it and keep looking. + after = new Date(occ.at.getTime()); + } + if (picked) consider(picked.at, picked.graceUntil); } const hasClosedCycles = expectedShort > 0; diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index c80bcadd2..fcfd6420d 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -118,3 +118,77 @@ describe("computeNextDueAt — rolling first dose", () => { expect(next).toBeNull(); }); }); + +/** + * v1.15.10 BUG 2 — next-due must advance past slots the user has already + * resolved (taken / skipped / auto-missed) and surface the next genuinely + * OPEN slot, not re-surface a present/past resolved slot ("träge"). + * + * Twice-daily med, 07:00 + 19:00 Berlin. In June Berlin is UTC+2, so the + * canonical slots are 05:00 UTC (07:00) and 17:00 UTC (19:00). + */ +describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { + function twiceDaily(): WorkerScheduleRow { + return { + id: "sched-2x", + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + + it("advances to tomorrow's 07:00 when both of today's slots are resolved", () => { + // Afternoon: 07:00 already past + logged, 19:00 logged early. Both today's + // slots resolved → next must be tomorrow 07:00, not today 19:00. + const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin, before the 19:00 slot + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [twiceDaily()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T15:30:00Z"), + resolvedSlots: [ + d("2026-06-10T05:00:00Z"), // today 07:00 Berlin + d("2026-06-10T17:00:00Z"), // today 19:00 Berlin + ], + }); + expect(next).not.toBeNull(); + expect(berlinDay(next!)).toBe("2026-06-11"); + // 07:00 Berlin = 05:00 UTC. + expect(next!.toISOString()).toBe("2026-06-11T05:00:00.000Z"); + }); + + it("surfaces today's 19:00 when only the 07:00 slot is resolved", () => { + const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [twiceDaily()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T07:13:00Z"), + resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00 + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 + }); + + it("keeps the legacy purely-time-anchored next-due when no resolvedSlots passed", () => { + const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin → next slot is 19:00 + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [twiceDaily()], + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 + }); +}); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index ac58ebedb..c561f1907 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -18,24 +18,60 @@ import { } from "@/lib/medications/scheduling/worker-helpers"; import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence"; +/** + * v1.15.10 — radius an intake may sit from a slot's canonical instant and + * still count as resolving it. Intake writes snap `scheduledFor` to the exact + * engine slot instant, so the match is normally exact; the ±6h tolerance is + * the same defensive half-gap the compliance open-dose search uses (a 12h-gap + * twice-daily med splits cleanly at ±6h). + */ +const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + export function computeNextDueAt(input: { medication: WorkerMedicationRow; schedules: WorkerScheduleRow[]; now: Date; userTz: string; lastIntakeAt: Date | null; + /** + * v1.15.10 — slot instants the user has already acted on (taken / skipped / + * auto-missed). The next-due search skips any occurrence that matches one of + * these so a twice-daily med whose remaining slots today are all logged + * advances to the next genuinely-open slot (tomorrow's first dose) instead + * of re-surfacing a resolved present/past slot. Omit for the legacy + * purely-time-anchored next-due. + */ + resolvedSlots?: Date[]; }): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; + const resolved = input.resolvedSlots ?? []; + const isResolved = (slotAt: Date): boolean => { + const slot = slotAt.getTime(); + for (const r of resolved) { + if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; + } + return false; + }; + const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); let earliest: Date | null = null; for (const schedule of schedules) { const canonical = buildCanonicalSchedule(schedule); - const next = nextOccurrenceAfter(canonical, now, ctx); - if (next === null) continue; - if (earliest === null || next.at.getTime() < earliest.getTime()) { - earliest = next.at; + // Walk forward past slots the user has already resolved. Bounded so a + // fully-logged-ahead history can't spin. + let after = now; + for (let step = 0; step < 64; step++) { + const next = nextOccurrenceAfter(canonical, after, ctx); + if (next === null) break; + if (!isResolved(next.at)) { + if (earliest === null || next.at.getTime() < earliest.getTime()) { + earliest = next.at; + } + break; + } + after = new Date(next.at.getTime()); } } return earliest;