mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user