diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 51a1485b5..4836ac307 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 0a17ba5ca..6cb529d11 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -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 { // 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). diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 665925363..be95be3fc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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;