mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
5765059737
commit
085864e63a
3 files changed
+127
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user