mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(coach): count GLP-1 injection days on the user's calendar
The snapshot cut the last injection, the dose history and the projected next injection on UTC days and compared getUTCDay() with the schedule's local weekday. An evening injection west of UTC landed on the next day, and east of UTC 'tomorrow' was today. Day keys now come from the user's zone, which the Coach snapshot already resolves and now passes in.
This commit is contained in:
1 parent
2dc73be233
commit
4ac05364fe
3 files changed
+81
-21
No files matched your search
@@ -278,3 +278,57 @@ describe("buildGlp1SnapshotBlock side-effect tags across locales", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Injection days are the user's calendar days. The snapshot used to cut
|
||||
* them on UTC days and compare `getUTCDay()` with the schedule's local
|
||||
* weekday, so an evening injection west of UTC was reported on the next
|
||||
* day and the projection counted from the wrong day.
|
||||
*/
|
||||
describe("buildGlp1SnapshotBlock injection days in the user's zone", () => {
|
||||
it("dates a Friday-evening injection in Los Angeles on Friday", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
fakeMedication({
|
||||
schedules: [
|
||||
{ daysOfWeek: "5", windowStart: "19:00", windowEnd: "21:00" },
|
||||
],
|
||||
// Friday 2026-09-25, 20:00 in Los Angeles — Saturday in UTC.
|
||||
intakeEvents: [
|
||||
{
|
||||
takenAt: new Date("2026-09-26T03:00:00.000Z"),
|
||||
injectionSite: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
// Monday 2026-09-28, 11:00 in Los Angeles.
|
||||
const now = new Date("2026-09-28T18:00:00.000Z");
|
||||
const out = await buildGlp1SnapshotBlock(
|
||||
"user-1",
|
||||
now,
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
const med = out?.medications[0];
|
||||
expect(med?.lastInjection?.date).toBe("2026-09-25");
|
||||
expect(med?.nextInjection).toEqual({ date: "2026-10-02", daysAway: 4 });
|
||||
});
|
||||
|
||||
it("names tomorrow from the local day east of UTC", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
fakeMedication({
|
||||
schedules: [{ daysOfWeek: null, windowStart: null, windowEnd: null }],
|
||||
}),
|
||||
]);
|
||||
// 2026-09-28 12:00 UTC is already 2026-09-29 02:00 on Kiritimati.
|
||||
const now = new Date("2026-09-28T12:00:00.000Z");
|
||||
const out = await buildGlp1SnapshotBlock(
|
||||
"user-1",
|
||||
now,
|
||||
"Pacific/Kiritimati",
|
||||
);
|
||||
expect(out?.medications[0].nextInjection).toEqual({
|
||||
date: "2026-09-30",
|
||||
daysAway: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
import { floorWhole } from "@/lib/medications/units-per-dose";
|
||||
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
|
||||
@@ -159,13 +160,20 @@ function weeksBetween(from: Date, to: Date): number {
|
||||
return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */
|
||||
function keyEpochDays(key: string): number {
|
||||
return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict next injection from cadence + last intake.
|
||||
*
|
||||
* Works on the user's calendar days throughout: the last injection's day,
|
||||
* today and the schedule's `daysOfWeek` are all local, so the projection
|
||||
* walks local day keys. Walking UTC days and testing `getUTCDay()` against
|
||||
* a local weekday named the wrong day for anyone whose injection time sits
|
||||
* on the other side of UTC midnight.
|
||||
*
|
||||
* - Weekly cadence with a single weekday: project to the next matching
|
||||
* weekday after the last intake (or after today if no last intake).
|
||||
* - Daily / unset cadence: next is tomorrow (the existing reminder
|
||||
@@ -176,26 +184,25 @@ function predictNextInjection(
|
||||
schedule: ScheduleEntry | null,
|
||||
lastInjection: LastInjection | null,
|
||||
now: Date,
|
||||
tz: string,
|
||||
): NextInjection | null {
|
||||
if (!schedule) return null;
|
||||
const todayKey = userDayKey(now, tz);
|
||||
if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) {
|
||||
const targetDow = schedule.daysOfWeek[0];
|
||||
const anchor = lastInjection
|
||||
? new Date(lastInjection.date + "T00:00:00Z")
|
||||
: now;
|
||||
// Project forward from the anchor (lastInjection if known, otherwise
|
||||
// today) until we land on the target weekday.
|
||||
const cursor = new Date(anchor.getTime());
|
||||
// today) until we land on the target weekday. The weekday of a
|
||||
// calendar date does not depend on any zone, so the key's UTC label
|
||||
// answers it.
|
||||
const anchorKey = lastInjection ? lastInjection.date : todayKey;
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
if (cursor.getUTCDay() === targetDow) {
|
||||
const key = shiftDateKey(anchorKey, i);
|
||||
if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) {
|
||||
const daysAway = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(cursor.getTime() - now.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
keyEpochDays(key) - keyEpochDays(todayKey),
|
||||
);
|
||||
return { date: isoDate(cursor), daysAway };
|
||||
return { date: key, daysAway };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,9 +210,7 @@ function predictNextInjection(
|
||||
// depending on what's already been logged. Surfacing "tomorrow" keeps
|
||||
// the Coach from making confident "today is your injection day"
|
||||
// claims on partial data.
|
||||
const tomorrow = new Date(now.getTime());
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
return { date: isoDate(tomorrow), daysAway: 1 };
|
||||
return { date: shiftDateKey(todayKey, 1), daysAway: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +221,7 @@ function predictNextInjection(
|
||||
export async function buildGlp1SnapshotBlock(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
): Promise<Glp1SnapshotBlock | null> {
|
||||
// Test environments mock parts of prisma (only `measurement` +
|
||||
// `moodEntry`) and leave `medication` undefined. Treat the absence
|
||||
@@ -295,7 +301,7 @@ export async function buildGlp1SnapshotBlock(
|
||||
return {
|
||||
value: dc.doseValue,
|
||||
unit: sanitizeForPrompt(dc.doseUnit, 20),
|
||||
effectiveFrom: isoDate(dc.effectiveFrom),
|
||||
effectiveFrom: userDayKey(dc.effectiveFrom, tz),
|
||||
note:
|
||||
decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200),
|
||||
};
|
||||
@@ -305,7 +311,7 @@ export async function buildGlp1SnapshotBlock(
|
||||
? {
|
||||
value: latestChange.doseValue,
|
||||
unit: sanitizeForPrompt(latestChange.doseUnit, 20),
|
||||
since: isoDate(latestChange.effectiveFrom),
|
||||
since: userDayKey(latestChange.effectiveFrom, tz),
|
||||
weeksOnDose: weeksBetween(latestChange.effectiveFrom, now),
|
||||
}
|
||||
: null;
|
||||
@@ -334,7 +340,7 @@ export async function buildGlp1SnapshotBlock(
|
||||
const lastInjection: LastInjection | null =
|
||||
last && last.takenAt
|
||||
? {
|
||||
date: isoDate(last.takenAt),
|
||||
date: userDayKey(last.takenAt, tz),
|
||||
site: last.injectionSite ?? null,
|
||||
weeksAgo: weeksBetween(last.takenAt, now),
|
||||
}
|
||||
@@ -344,7 +350,7 @@ export async function buildGlp1SnapshotBlock(
|
||||
// information but no injection is predicted as due.
|
||||
const nextInjection = isRecordOnly(med)
|
||||
? null
|
||||
: predictNextInjection(schedule, lastInjection, now);
|
||||
: predictNextInjection(schedule, lastInjection, now, tz);
|
||||
|
||||
// Inventory math over the per-item entities (v1.16.10 — the same
|
||||
// rows the Bestand tab and the consumption hook move).
|
||||
|
||||
@@ -744,7 +744,7 @@ async function buildCoachSnapshotImpl(
|
||||
: null;
|
||||
const glp1BlockPromise = excludesMedications
|
||||
? null
|
||||
: buildGlp1SnapshotBlock(userId, now);
|
||||
: buildGlp1SnapshotBlock(userId, now, userTz);
|
||||
const derivedBlockPromise = derivedActive
|
||||
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
|
||||
: null;
|
||||
|
||||
Reference in new issue
Block a user