From eada82f61ea4f1b332d9b1d6337187c513b4d453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:36:08 +0200 Subject: [PATCH] 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. --- .../migration.sql | 63 ++++++++++++++----- src/lib/export/restore-backup.ts | 40 ++++++------ .../__tests__/course-window.test.ts | 25 ++++++++ src/lib/medications/course-window.ts | 36 +++++++++++ src/lib/medications/courses.ts | 18 +++--- tests/integration/medication-courses.test.ts | 47 ++++++++++++++ 6 files changed, 184 insertions(+), 45 deletions(-) diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql index 58d2e8ebb..002628228 100644 --- a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -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; diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 0bfaa642a..0ac7e1a68 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -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) => ({ diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts index 292ffd117..f85f7ce91 100644 --- a/src/lib/medications/__tests__/course-window.test.ts +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -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(); + }); +}); diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts index 8b6ca8b5e..97f3fc997 100644 --- a/src/lib/medications/course-window.ts +++ b/src/lib/medications/course-window.ts @@ -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; +} diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 8ad078a2b..760c5baee 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -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, + } + ); } /** diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts index e99209ffa..dc9682456 100644 --- a/tests/integration/medication-courses.test.ts +++ b/tests/integration/medication-courses.test.ts @@ -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,