mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
510d4b1d04
commit
eada82f61e
6 files changed
+184
-45
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user