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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 14:41:33 +02:00
1 parent 311ac65f81
commit faeb770cbb
5 files changed
+271 -35

No files matched your search

+23 -1
View File
@@ -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({
+58 -23
View File
@@ -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<string, Date[]>();
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,
+76 -7
View File
@@ -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;
@@ -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
});
});
+40 -4
View File
@@ -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;