fix(medications): classify a plan's cadence the same for every range

realisedFamily probed for the closest two slots up to range.to only, so
the answer moved with the range. A Saturday + Sunday plan started the
weekend before the autumn DST change, read around its Saturday dose: the
±24 h range ends at 06:00 UTC on the Sunday, whose 08:00 slot is at 07:00
UTC after the change. Cut off, the closest slots were six days apart
(weekly); the wide re-mint saw the 25 h gap (daily). The two passes
disagreed, no band survived, and the dose was filed as unscheduled, so a
second backdated dose within a minute came back as the first one (200).
Probe the same reach on both sides of the range's middle.
This commit is contained in:
Marc-André Bombeck committed 2026-10-26 00:47:24 +01:00
1 parent 5765059737
commit 085864e63a
3 files changed
+127 -5

No files matched your search

+6
View File
@@ -283,6 +283,12 @@
an hour early, so a late evening dose was missing from it; on the day
they go forward it reached an hour into tomorrow. It now spans the real
local day, as the dashboard already did.
- **A dose logged for a two-day plan lands on its slot in the week the
clocks go back.** A plan with doses on two neighbouring weekdays, started
shortly before the change, could file a later dose as unscheduled, and
logging it again gave back the previous one. Whether a plan counts as
daily or weekly no longer depends on the stretch of days it is read
through.
### Security
+21 -5
View File
@@ -476,18 +476,34 @@ function buildOneShotBands(
* Mon/Thu `daysOfWeek` med as daily ±60min). Probe a window padded to ≥16
* weeks so a sparse cadence yields ≥2 slots and a real gap is observable; the
* minimum consecutive gap decides the scale.
*
* The probe reaches the same distance on both sides of the range's middle.
* It used to end at `range.to`, so the answer depended on where the range
* stopped: a Saturday + Sunday plan starting the weekend before the autumn
* DST change, read around its second Saturday dose, has its Sunday slot at
* 07:00 UTC while the ±24 h range ends at 06:00. With that slot cut off the
* closest two slots were six days apart (weekly); the wider re-mint saw the
* 25 h Saturday-to-Sunday gap (daily), the two passes disagreed, and the dose
* was filed as unscheduled. A cadence's family is a property of the plan, not
* of the window it is read through.
*/
function realisedFamily(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
range: { from: Date; to: Date },
): "daily" | "weekly" {
const probeDays = Math.max(
Math.ceil((range.to.getTime() - range.from.getTime()) / DAY_MS),
16 * 7,
const reach =
Math.max(
Math.ceil((range.to.getTime() - range.from.getTime()) / DAY_MS),
16 * 7,
) * DAY_MS;
const middle = (range.from.getTime() + range.to.getTime()) / 2;
const slots = occurrencesBetween(
schedule,
new Date(middle - reach),
new Date(middle + reach),
ctx,
);
const probeFrom = new Date(range.to.getTime() - probeDays * DAY_MS);
const slots = occurrencesBetween(schedule, probeFrom, range.to, ctx);
if (slots.length < 2) {
// Too few slots to measure a gap. Fall back to the field-shape signal:
// an explicit rolling/weekly/monthly shape is day-scale, else daily.
@@ -0,0 +1,100 @@
/**
* A Saturday + Sunday plan files each of its doses on its own slot in the
* week of the autumn DST change.
*
* The plan starts the Sunday before the change, so its realised slots are
* that Sunday, the Saturday before the change and the Sunday of the change.
* Read around the Saturday dose, the ±24 h range ended at 06:00 UTC on the
* Sunday while that Sunday's 08:00 slot is 07:00 UTC after the change. The
* family probe ended at the range's end, so one pass called the plan weekly
* and the wider one daily; the dose fell between them, was filed as
* unscheduled, and the 60-second duplicate guard answered it with the
* previous Sunday's row (200 instead of 201). The family is now read from a
* window centred on the range, the same for both passes.
*/
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { POST as createMedication } from "@/app/api/medications/route";
import { POST as recordIntake } from "@/app/api/medications/[id]/intake/route";
import { callRoute } from "../kit/call-route";
import { makeSession, makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
beforeAll(async () => {
await truncateAllTables(prisma);
});
afterEach(() => {
vi.useRealTimers();
});
afterAll(async () => {
await truncateAllTables(prisma);
});
describe("backdated doses on a weekend plan across the autumn DST change", () => {
it.each(["FREQ=WEEKLY;BYDAY=SA,SU", "FREQ=DAILY"])(
"%s: every dose lands on its own slot",
async (rrule) => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const auth = await makeSession(user);
vi.useFakeTimers({ toFake: ["Date"] });
// 00:25 on Monday 2026-10-26 in Berlin.
vi.setSystemTime(new Date("2026-10-25T23:25:00.000Z"));
const created = await callRoute<{ id: string }>(createMedication, {
method: "POST",
route: "/api/medications",
body: {
name: `weekend ${rrule}`,
dose: "5 mg",
startsOn: "2026-10-18",
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule,
},
],
},
auth,
});
expect(created.status).toBe(201);
const id = created.data.id;
await prisma.medication.update({
where: { id },
data: { createdAt: new Date("2026-10-17T22:25:00.000Z") },
});
// 08:00 local on Sunday 18th (CEST), Saturday 24th (CEST) and Sunday
// 25th (CET, after the change).
for (const slot of [
"2026-10-18T06:00:00.000Z",
"2026-10-24T06:00:00.000Z",
"2026-10-25T07:00:00.000Z",
]) {
const res = await callRoute<{ scheduledFor: string }>(recordIntake, {
method: "POST",
route: "/api/medications/{id}/intake",
params: { id },
body: { skipped: false, scheduledFor: slot, takenAt: slot },
auth,
});
expect(res.status, `the dose at ${slot} is written`).toBe(201);
expect(res.data.scheduledFor).toBe(slot);
}
},
);
});