mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): surface the first rolling dose at the start date
Rolling schedules anchored next-due on start + N days when no intake had been logged yet, which skipped the dose due AT the start date and suppressed its reminder (the worker shares this engine). The +N cadence now begins only after the first intake is logged: with no intake the first dose is due at startsOn (or createdAt). A past start date with no intake surfaces the dose as due/overdue rather than rolling it forward — nextOccurrenceAfter floors the rolling search to the start of the user's day so an overdue or due-earlier-today dose stays visible. The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected.
This commit is contained in:
1 parent
eea6385bc5
commit
b3f61d0c75
3 files changed
+278
-21
No files matched your search
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* v1.8.5 — `computeNextDueAt` tests, focused on the rolling-cadence
|
||||
* first-dose semantics.
|
||||
*
|
||||
* The server computes `nextDueAt` by asking the canonical engine for the
|
||||
* earliest next slot across a medication's schedules. The medically
|
||||
* important case: a rolling course whose `startsOn` is earlier today with
|
||||
* no intake logged must surface its start dose as DUE/OVERDUE ("take
|
||||
* now"), not roll it forward by N days (which also suppressed its
|
||||
* reminder, since the worker shares the engine).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeNextDueAt } from "../next-due";
|
||||
import type {
|
||||
WorkerMedicationRow,
|
||||
WorkerScheduleRow,
|
||||
} from "../worker-helpers";
|
||||
|
||||
function d(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
function makeMedication(
|
||||
overrides: Partial<WorkerMedicationRow> = {},
|
||||
): WorkerMedicationRow {
|
||||
return {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: d("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRollingSchedule(
|
||||
overrides: Partial<WorkerScheduleRow> = {},
|
||||
): WorkerScheduleRow {
|
||||
return {
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BERLIN = "Europe/Berlin";
|
||||
|
||||
function berlinDay(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: BERLIN,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
describe("computeNextDueAt — rolling first dose", () => {
|
||||
it("surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => {
|
||||
const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past
|
||||
const now = d("2026-06-10T12:00:00Z"); // noon, after the slot
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication({ startsOn }),
|
||||
schedules: [makeRollingSchedule()],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getTime()).toBeLessThan(now.getTime()); // overdue
|
||||
expect(berlinDay(next!)).toBe("2026-06-10");
|
||||
});
|
||||
|
||||
it("returns the future first dose AT startsOn (no intake) without adding N", () => {
|
||||
const startsOn = d("2026-06-12T00:00:00Z");
|
||||
const now = d("2026-06-10T12:00:00Z");
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication({ startsOn }),
|
||||
schedules: [makeRollingSchedule()],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(berlinDay(next!)).toBe("2026-06-12");
|
||||
});
|
||||
|
||||
it("re-anchors on lastIntakeAt + N once an intake exists", () => {
|
||||
const now = d("2026-06-10T12:00:00Z");
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication({ startsOn: d("2026-06-01T00:00:00Z") }),
|
||||
schedules: [makeRollingSchedule()],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-06-09T08:00:00Z"),
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(berlinDay(next!)).toBe("2026-06-16"); // 2026-06-09 + 7d
|
||||
});
|
||||
|
||||
it("returns null when no schedules", () => {
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication(),
|
||||
schedules: [],
|
||||
now: d("2026-06-10T12:00:00Z"),
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -279,9 +279,12 @@ describe("occurrencesBetween — rolling", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=T-2d emits one at T+5d", () => {
|
||||
it("rollingIntervalDays=7 + lastIntakeAt=null + future startsOn → first dose due AT startsOn (not startsOn+N)", () => {
|
||||
// v1.8.5 — with no intake yet the FIRST rolling dose lands ON the
|
||||
// start date, not start + N. A future startsOn therefore emits its
|
||||
// slot at startsOn itself.
|
||||
const NOW = d("2026-06-10T12:00:00Z");
|
||||
const startsOn = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
const startsOn = new Date(NOW.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const schedule = makeSchedule({
|
||||
rollingIntervalDays: 7,
|
||||
timesOfDay: ["08:00"],
|
||||
@@ -294,7 +297,7 @@ describe("occurrencesBetween — rolling", () => {
|
||||
const to = new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||
const slots = occurrencesBetween(schedule, from, to, ctx);
|
||||
expect(slots).toHaveLength(1);
|
||||
const expectedDay = new Date(startsOn.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
// The slot is the start dose itself (startsOn), NOT startsOn + 7d.
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
@@ -308,10 +311,36 @@ describe("occurrencesBetween — rolling", () => {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(expectedDay),
|
||||
}).format(startsOn),
|
||||
);
|
||||
});
|
||||
|
||||
it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=today → first dose due today", () => {
|
||||
// Fresh rolling course starting today: the start dose is due today,
|
||||
// not seven days out.
|
||||
const startsOn = d("2026-06-10T00:00:00Z");
|
||||
const schedule = makeSchedule({
|
||||
rollingIntervalDays: 7,
|
||||
timesOfDay: ["08:00"],
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
lastIntakeAt: null,
|
||||
medication: { startsOn },
|
||||
});
|
||||
const from = d("2026-06-10T00:00:00Z");
|
||||
const to = d("2026-06-24T23:59:59Z");
|
||||
const slots = occurrencesBetween(schedule, from, to, ctx);
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(slots[0].at),
|
||||
).toBe("2026-06-10");
|
||||
});
|
||||
|
||||
it("rolling terminates when endsOn falls before next-due", () => {
|
||||
const NOW = d("2026-06-10T12:00:00Z");
|
||||
const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
@@ -332,6 +361,84 @@ describe("occurrencesBetween — rolling", () => {
|
||||
);
|
||||
expect(slots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("nextOccurrenceAfter surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => {
|
||||
// v1.8.5 — a rolling course whose startsOn is earlier today with no
|
||||
// intake logged yet must surface the start dose as DUE/OVERDUE, not
|
||||
// roll it forward by N. The strict `> now` filter previously dropped
|
||||
// it; the rolling branch now floors to the start of the user's day.
|
||||
const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past
|
||||
const NOW = d("2026-06-10T12:00:00Z"); // noon — after the 08:00 slot
|
||||
const schedule = makeSchedule({
|
||||
rollingIntervalDays: 7,
|
||||
timesOfDay: ["08:00"],
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
lastIntakeAt: null,
|
||||
medication: { startsOn },
|
||||
});
|
||||
const next = nextOccurrenceAfter(schedule, NOW, ctx);
|
||||
expect(next).not.toBeNull();
|
||||
// The surfaced slot is today's start dose (08:00 Berlin), already in
|
||||
// the past relative to NOW — i.e. overdue, "take now".
|
||||
expect(next!.at.getTime()).toBeLessThan(NOW.getTime());
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(next!.at),
|
||||
).toBe("2026-06-10");
|
||||
});
|
||||
|
||||
it("nextOccurrenceAfter returns the future first dose AT startsOn (no intake) without adding N", () => {
|
||||
const NOW = d("2026-06-10T12:00:00Z");
|
||||
const startsOn = d("2026-06-12T00:00:00Z"); // 2 days out
|
||||
const schedule = makeSchedule({
|
||||
rollingIntervalDays: 7,
|
||||
timesOfDay: ["08:00"],
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
lastIntakeAt: null,
|
||||
medication: { startsOn },
|
||||
});
|
||||
const next = nextOccurrenceAfter(schedule, NOW, ctx);
|
||||
expect(next).not.toBeNull();
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(next!.at),
|
||||
).toBe("2026-06-12");
|
||||
});
|
||||
|
||||
it("nextOccurrenceAfter re-anchors on lastIntakeAt + N after an intake", () => {
|
||||
// The v1.8.4 re-anchor must stay: once an intake exists, the next
|
||||
// slot is lastIntakeAt + N, NOT the start date.
|
||||
const NOW = d("2026-06-10T12:00:00Z");
|
||||
const lastIntake = d("2026-06-09T08:00:00Z");
|
||||
const schedule = makeSchedule({
|
||||
rollingIntervalDays: 7,
|
||||
timesOfDay: ["08:00"],
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
lastIntakeAt: lastIntake,
|
||||
medication: { startsOn: d("2026-06-01T00:00:00Z") },
|
||||
});
|
||||
const next = nextOccurrenceAfter(schedule, NOW, ctx);
|
||||
expect(next).not.toBeNull();
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(next!.at),
|
||||
).toBe("2026-06-16"); // 2026-06-09 + 7d
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
* 1. **One-shot** (`medication.oneShot === true`) — single slot at
|
||||
* the anchor date, one per `timesOfDay` entry.
|
||||
* 2. **Rolling** (`schedule.rollingIntervalDays !== null`) — next
|
||||
* slot is `lastIntakeAt + N days` (or `startsOn + N days` if
|
||||
* the user has never logged an intake). Only the immediately
|
||||
* next slot is emitted; further slots depend on the user
|
||||
* actually logging the intake.
|
||||
* slot is `lastIntakeAt + N days`. With no intake yet the FIRST
|
||||
* dose is due AT `startsOn ?? createdAt` (not `+ N`); the `+ N`
|
||||
* cadence only begins once the first intake is logged. Only the
|
||||
* immediately next slot is emitted; further slots depend on the
|
||||
* user actually logging the intake.
|
||||
* 3. **RRULE** (`schedule.rrule !== null`) — RFC 5545 expansion
|
||||
* via the `rrule` npm lib. Day-anchored dates are returned in
|
||||
* UTC by the library; each `timesOfDay` entry is then applied
|
||||
@@ -248,15 +249,19 @@ export function nextOccurrenceAfter(
|
||||
}
|
||||
|
||||
// Rolling is also single-slot (only emits the immediately next).
|
||||
//
|
||||
// v1.8.5 — floor the search window to the start of the user's current
|
||||
// day rather than the strict `after` instant. A rolling dose whose slot
|
||||
// lands earlier today (a past `startsOn` with no intake, or an overdue
|
||||
// `lastIntakeAt + N`) is DUE/OVERDUE and must surface as "take now",
|
||||
// not be skipped by a strict `> now` filter. Flooring to start-of-day
|
||||
// keeps a slot the user has not yet acted on visible through the rest
|
||||
// of its due day instead of silently rolling it forward by N.
|
||||
if (schedule.rollingIntervalDays !== null) {
|
||||
const slots = expandRolling(
|
||||
schedule,
|
||||
ctx,
|
||||
new Date(after.getTime() + 1),
|
||||
limit,
|
||||
);
|
||||
const dayFloor = startOfDayInTz(after, ctx.timeZone);
|
||||
const slots = expandRolling(schedule, ctx, dayFloor, limit);
|
||||
for (const s of slots) {
|
||||
if (s.at.getTime() <= after.getTime()) continue;
|
||||
if (s.at.getTime() < dayFloor.getTime()) continue;
|
||||
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
|
||||
return s;
|
||||
}
|
||||
@@ -334,9 +339,21 @@ function expandOneShot(
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rolling cadence — "every N days from last intake". Emits one
|
||||
* occurrence at `(lastIntakeAt ?? startsOn ?? createdAt) + N days`,
|
||||
* applied at the first `timesOfDay` entry (or `windowStart`).
|
||||
* Rolling cadence — "every N days from last intake".
|
||||
*
|
||||
* Anchoring (v1.8.5 — medically-correct first dose):
|
||||
* - After an intake → next slot is `lastIntakeAt + N days` (the
|
||||
* v1.8.4 re-anchor). Logging a dose pushes the next one out by N.
|
||||
* - No intake yet → the FIRST dose is due AT `startsOn ?? createdAt`,
|
||||
* NOT `start + N`. A rolling course's first dose lands on the start
|
||||
* date; the `+ N` cadence only kicks in once the first intake is
|
||||
* logged. The pre-v1.8.5 engine emitted `start + N` here, which
|
||||
* silently skipped the start-date dose (and suppressed its reminder,
|
||||
* since the worker shares this engine).
|
||||
*
|
||||
* A past `startsOn` with no intake therefore surfaces the start dose as
|
||||
* DUE/OVERDUE ("take now") via `nextOccurrenceAfter`'s rolling branch,
|
||||
* rather than rolling it forward.
|
||||
*
|
||||
* Design choice: only the FIRST time-of-day entry is emitted even
|
||||
* when multiple are configured. The rolling semantic — "every N days
|
||||
@@ -358,10 +375,13 @@ function expandRolling(
|
||||
): Occurrence[] {
|
||||
const n = schedule.rollingIntervalDays;
|
||||
if (n === null || n <= 0) return [];
|
||||
const anchor =
|
||||
ctx.lastIntakeAt ?? ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
|
||||
const nextDue = new Date(anchor.getTime() + n * DAY_MS);
|
||||
// After an intake the next dose is N days out. With no intake yet the
|
||||
// FIRST dose is the start date itself (no `+ N`) — see the doc comment.
|
||||
const nextDue =
|
||||
ctx.lastIntakeAt !== null
|
||||
? new Date(ctx.lastIntakeAt.getTime() + n * DAY_MS)
|
||||
: ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
|
||||
// endsOn cap.
|
||||
if (
|
||||
@@ -560,6 +580,16 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in
|
||||
* the user's IANA timezone. Used by the rolling-cadence next-due search
|
||||
* to floor the window to the start of the user's current day so an
|
||||
* overdue / due-earlier-today rolling dose still surfaces.
|
||||
*/
|
||||
function startOfDayInTz(instant: Date, tz: string): Date {
|
||||
return applyTimeOfDayToDate(instant, "00:00", tz);
|
||||
}
|
||||
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const parts = wallClockInTz(date, tz);
|
||||
const asIfUtc = Date.UTC(
|
||||
|
||||
Reference in new issue
Block a user