Derive a backfilled course in the account's time zone

The backfill and the restore of an older file read the creation day of
an end-only window in UTC while every reader of the window uses the
account's zone; both now use the account's zone (the default zone when
the stored one is unknown to Postgres). A window whose start lies after
its end, which the API refuses but older rows hold, becomes a one-day
course on the end day: every reader treated it as empty and ended, and
a swap would have invented expected doses. One shared rule
(courseFromWindow) serves the restore and the course writer.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:36:08 +02:00
1 parent 510d4b1d04
commit eada82f61e
6 files changed
+184 -45

No files matched your search

+22 -18
View File
@@ -27,9 +27,10 @@
* replacement is still one transaction, so a failure part-way leaves the
* account exactly as it was.
*/
import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format";
import {
courseFromWindow,
dateOfDayKey,
dayKeyOfDate,
projectCourseWindow,
} from "@/lib/medications/course-window";
import { Buffer } from "node:buffer";
@@ -1088,6 +1089,18 @@ export async function restoreBackup(
});
}
// v1.40 (#1024) — the zone a derived course's creation day is read
// in: the file's own account settings, else the account's stored one.
const restoreTz = validTimezoneOr(
payload.accountSettings?.timezone ??
(
await tx.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
})
)?.timezone,
DEFAULT_TIMEZONE,
);
let medicationIndex = 0;
reportSection("medications");
const restoreStartedAt = new Date();
@@ -1326,23 +1339,14 @@ export async function restoreBackup(
note: c.note ?? null,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
}))
: created.startsOn || created.endsOn
? [
{
startsOn:
created.startsOn ??
(created.endsOn &&
created.endsOn.getTime() <
dateOfDayKey(
dayKeyOfDate(created.createdAt),
).getTime()
? created.endsOn
: dateOfDayKey(dayKeyOfDate(created.createdAt))),
endsOn: created.endsOn,
note: null,
},
]
: [];
: (() => {
const derived = courseFromWindow(
created.startsOn,
created.endsOn,
userDayKey(created.createdAt, restoreTz),
);
return derived ? [{ ...derived, note: null }] : [];
})();
if (courseRows.length > 0) {
await tx.medicationCourse.createMany({
data: courseRows.map((c) => ({
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
canStartCourse,
courseFromWindow,
courseStatusOn,
dateOfDayKey,
isInsideCourses,
@@ -160,3 +161,27 @@ describe("previousCourseEndedOn and canStartCourse", () => {
);
});
});
describe("courseFromWindow (the backfill and restore rule)", () => {
it("covers every window shape", () => {
expect(
courseFromWindow(d("2026-03-01"), d("2026-03-07"), "2026-02-10"),
).toEqual(course("2026-03-01", "2026-03-07"));
expect(courseFromWindow(d("2026-03-01"), null, "2026-02-10")).toEqual(
course("2026-03-01", null),
);
// Only an end: from the creation day on the person's clock.
expect(courseFromWindow(null, d("2026-04-01"), "2026-02-11")).toEqual(
course("2026-02-11", "2026-04-01"),
);
// A backdated end before the creation: from the end day.
expect(courseFromWindow(null, d("2026-01-15"), "2026-02-11")).toEqual(
course("2026-01-15", "2026-01-15"),
);
// Start after end: a one-day course on the end day, still ended.
expect(
courseFromWindow(d("2026-05-10"), d("2026-05-01"), "2026-02-11"),
).toEqual(course("2026-05-01", "2026-05-01"));
expect(courseFromWindow(null, null, "2026-02-11")).toBeNull();
});
});
+36
View File
@@ -150,3 +150,39 @@ export function canStartCourse(args: {
(c) => courseStatusOn(c, args.todayKey) === "ENDED",
);
}
/**
* The one course a medication's own window describes, for a medication that
* has no course rows (the migration's backfill, a restore of a file written
* before courses, the first course write on such a row). The same rules as
* the 0368 backfill:
*
* - start and end in order: that window;
* - only a start: open from that day;
* - only an end: from the creation day on the person's clock (`createdKey`),
* or from the end day when that lies earlier;
* - start after end (refused by the API, but older rows exist): every
* reader treated it as empty and ended, so a one-day course on the end
* day, which stays ended and invents no stretch of expected doses;
* - neither: no course (chronic since creation).
*/
export function courseFromWindow(
startsOn: Date | null,
endsOn: Date | null,
createdKey: string,
): CourseSpan | null {
if (startsOn && endsOn) {
return startsOn.getTime() > endsOn.getTime()
? { startsOn: endsOn, endsOn }
: { startsOn, endsOn };
}
if (startsOn) return { startsOn, endsOn: null };
if (endsOn) {
const endKey = dayKeyOfDate(endsOn);
return {
startsOn: dateOfDayKey(endKey < createdKey ? endKey : createdKey),
endsOn,
};
}
return null;
}
+8 -10
View File
@@ -15,6 +15,7 @@ import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import {
canStartCourse,
courseFromWindow,
courseStatusOn,
dateOfDayKey,
dayKeyOfDate,
@@ -302,16 +303,13 @@ function windowAsCourse(
createdAt: Date,
timeZone: string,
): { startsOn: Date; endsOn: Date | null } {
if (startsOn) return { startsOn, endsOn };
if (latest) return { startsOn: latest.startsOn, endsOn };
const creationKey = userDayKey(createdAt, timeZone);
const endKey = endsOn ? dayKeyOfDate(endsOn) : null;
return {
startsOn: dateOfDayKey(
endKey && endKey < creationKey ? endKey : creationKey,
),
endsOn,
};
if (!startsOn && latest) return { startsOn: latest.startsOn, endsOn };
return (
courseFromWindow(startsOn, endsOn, userDayKey(createdAt, timeZone)) ?? {
startsOn: dateOfDayKey(userDayKey(createdAt, timeZone)),
endsOn,
}
);
}
/**