From 085864e63a3b649de7975360001748628c6db0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 00:47:24 +0100 Subject: [PATCH] fix(medications): classify a plan's cadence the same for every range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 ++ src/lib/medications/scheduling/band-minter.ts | 26 ++++- ...medication-intake-dst-weekend-plan.test.ts | 100 ++++++++++++++++++ 3 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 tests/integration/medication-intake-dst-weekend-plan.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1096efb40..e28d48b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index f3d0d09c8..7b04b4aaa 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -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. diff --git a/tests/integration/medication-intake-dst-weekend-plan.test.ts b/tests/integration/medication-intake-dst-weekend-plan.test.ts new file mode 100644 index 000000000..d01782d88 --- /dev/null +++ b/tests/integration/medication-intake-dst-weekend-plan.test.ts @@ -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); + } + }, + ); +});