mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
311ac65f81
commit
faeb770cbb
5 files changed
+271
-35
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user