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

@@ -66,14 +66,25 @@ CREATE INDEX IF NOT EXISTS "medication_courses_medication_id_starts_on_idx"
CREATE INDEX IF NOT EXISTS "medication_courses_user_id_idx"
ON "medication_courses"("user_id");
-- Backfill: one course for every medication that has a window today. A course
-- needs a start, and a medication with only an end has always been read as
-- running from its creation day, so that is its start (or the end day, when
-- the end lies before the creation). A medication with
-- neither keeps no course: chronic since creation stays the absence of
-- courses. The medication columns are not touched, so the data is preserved
-- either way. The id is derived from the medication id, which makes a re-run
-- a no-op through ON CONFLICT.
-- Backfill: one course for every medication that has a window today.
--
-- * Start and end, in order: the course is that window.
-- * Only a start: an open course from that day.
-- * Only an end: the app has always read it as running since the day the
-- medication was created, on the person's clock, so that is the start;
-- the end day itself when it lies before the creation (a backdated
-- record). The creation day is taken in the account's time zone, the
-- zone every reader of the window uses, falling back to the app default
-- (Europe/Berlin) when the stored zone is unknown to Postgres.
-- * Start after end (a row written before the API refused it): every
-- reader has treated that window as empty and ended, with no dose
-- expected. It becomes a one-day course on the end day, which keeps it
-- ended and never invents a stretch of expected doses a swap would.
--
-- A medication with neither keeps no course: chronic since creation stays the
-- absence of courses. The medication columns are not touched, so the data is
-- preserved either way. The id is derived from the medication id, which makes
-- a re-run a no-op through ON CONFLICT.
--
-- Reversal: dropping the table loses nothing while every medication has at
-- most one course (its columns still hold it). Once a second course exists, a
@@ -82,15 +93,33 @@ CREATE INDEX IF NOT EXISTS "medication_courses_user_id_idx"
INSERT INTO "medication_courses"
("id", "medication_id", "user_id", "starts_on", "ends_on", "created_at", "updated_at")
SELECT
'mc_' || m."id",
m."id",
m."user_id",
-- An end-only window that ends before the creation day (a backdated
-- record) starts on its end day instead, so no course ends before it starts.
COALESCE(m."starts_on", LEAST(m."created_at"::date, m."ends_on")),
m."ends_on",
'mc_' || w."id",
w."id",
w."user_id",
CASE
WHEN w."starts_on" IS NOT NULL AND w."ends_on" IS NOT NULL
AND w."starts_on" > w."ends_on" THEN w."ends_on"
WHEN w."starts_on" IS NOT NULL THEN w."starts_on"
ELSE LEAST(w."created_day", w."ends_on")
END,
w."ends_on",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "medications" m
WHERE m."starts_on" IS NOT NULL OR m."ends_on" IS NOT NULL
FROM (
SELECT
m."id",
m."user_id",
m."starts_on",
m."ends_on",
(m."created_at" AT TIME ZONE 'UTC' AT TIME ZONE
CASE
WHEN EXISTS (SELECT 1 FROM pg_timezone_names z WHERE z."name" = u."timezone")
THEN u."timezone"
ELSE 'Europe/Berlin'
END
)::date AS "created_day"
FROM "medications" m
JOIN "users" u ON u."id" = m."user_id"
WHERE m."starts_on" IS NOT NULL OR m."ends_on" IS NOT NULL
) w
ON CONFLICT ("id") DO NOTHING;
+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,
}
);
}
/**
@@ -165,6 +165,53 @@ describe("migration 0368 backfill", () => {
expect(await courses(endOnly.id)).toEqual([["2026-02-10", "2026-04-01"]]);
expect(await courses(backdated.id)).toEqual([["2026-01-15", "2026-01-15"]]);
expect(await courses(chronic.id)).toEqual([]);
// Start after end (an older row): a one-day course on the end day.
// Created in a far-east zone: the creation day is the account's day,
// not the UTC one. An unknown stored zone falls back to the default.
const prismaKiri = getPrismaClient();
for (const [id, tz] of [
["kiri-user", "Pacific/Kiritimati"],
["mars-user", "Mars/Olympus_Mons"],
] as const) {
await prismaKiri.user.create({
data: { id, username: id, email: `${id}@example.test`, timezone: tz },
});
}
const late = new Date("2026-02-10T12:00:00.000Z"); // Feb 11 at UTC+14
const kiri = await prismaKiri.medication.create({
data: {
userId: "kiri-user",
name: "k",
dose: "1",
createdAt: late,
endsOn: D("2026-04-01"),
},
});
const mars = await prismaKiri.medication.create({
data: {
userId: "mars-user",
name: "m",
dose: "1",
createdAt: new Date("2026-02-10T23:30:00.000Z"),
endsOn: D("2026-04-01"),
},
});
const inverted = await prismaKiri.medication.create({
data: {
...base,
name: "inverted",
startsOn: D("2026-05-10"),
endsOn: D("2026-05-01"),
},
});
await prismaKiri.medicationCourse.deleteMany({});
await prismaKiri.$executeRawUnsafe(backfill);
expect(await courses(kiri.id)).toEqual([["2026-02-11", "2026-04-01"]]);
// Europe/Berlin: 23:30 UTC on Feb 10 is Feb 11.
expect(await courses(mars.id)).toEqual([["2026-02-11", "2026-04-01"]]);
expect(await courses(inverted.id)).toEqual([["2026-05-01", "2026-05-01"]]);
expect(await prismaKiri.$executeRawUnsafe(backfill)).toBe(0);
// Data-preserving: the medication columns are untouched.
expect(await window(endOnly.id)).toEqual({
startsOn: null,