diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 3c99a7b7f..f1fc4fc75 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -7,8 +7,17 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn(), + // v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing + // rows for schedules whose window opens today (covers daily + // meds with `daysOfWeek: null` whose reminder hasn't fired yet). + createMany: vi.fn(), + }, + medication: { + update: vi.fn(), + // v1.4.39 W-SERVER-FIX — today's projection reads active meds + + // their schedules to know what to backfill. + findMany: vi.fn(), }, - medication: { update: vi.fn() }, medicationComplianceRollup: { findFirst: vi.fn(), findMany: vi.fn(), @@ -65,6 +74,13 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); + // v1.4.39 W-SERVER-FIX — default the today-projection prisma reads + // to "no active medications" so existing compliance / POST tests + // exercise their original paths without an unmocked-call failure. + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 0, + } as never); // v1.4.39 W-MED — default the coverage probe to "uncovered" so the // legacy compliance test still exercises the live-fallback branch // and finds the legacy mocked intake events. @@ -123,6 +139,112 @@ describe("GET /api/medications/intake", () => { expect(body.data[0].status).toBe("pending"); }); + it("backfills missing today rows for daily meds (daysOfWeek=null)", async () => { + // v1.4.39 W-SERVER-FIX regression — the operator's Ramipril + // (Morgens) ships `daysOfWeek: null` in the DB ("every day" per + // schema). Pre-fix, the endpoint returned `[]` until the reminder + // worker entered RED phase. Post-fix, the GET projects the schedule + // and idempotently mints any missing rows so the iOS Dashboard + + // "Erfassen" sheet have an intake row to mark. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + id: "med-ramipril", + schedules: [ + { + id: "sched-morgens", + medicationId: "med-ramipril", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }, + ], + }, + ] as never); + // First findMany call (pre-backfill existence probe) returns empty; + // second call (post-backfill list) returns the freshly minted row. + vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([ + { + id: "e-new", + medicationId: "med-ramipril", + scheduledFor: new Date("2026-05-21T05:00:00.000Z"), + takenAt: null, + skipped: false, + medication: { id: "med-ramipril", snoozedUntil: null }, + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/medications/intake?scope=today"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ id: string; status: string }>; + }; + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toBe("e-new"); + expect(body.data[0].status).toBe("pending"); + expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1); + expect( + vi.mocked(prisma.medicationIntakeEvent.createMany).mock.calls[0][0], + ).toMatchObject({ + data: [ + { + userId: "user-1", + medicationId: "med-ramipril", + skipped: false, + source: "REMINDER", + }, + ], + }); + }); + + it("does not double-mint when today's rows already exist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const scheduledFor = new Date("2026-05-21T05:00:00.000Z"); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + id: "med-ramipril", + schedules: [ + { + id: "sched-morgens", + medicationId: "med-ramipril", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }, + ], + }, + ] as never); + // The existence probe sees a row for the exact projected slot, so + // createMany must not fire. + vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([ + { medicationId: "med-ramipril", scheduledFor }, + ] as never) + .mockResolvedValueOnce([ + { + id: "e-existing", + medicationId: "med-ramipril", + scheduledFor, + takenAt: null, + skipped: false, + medication: { id: "med-ramipril", snoozedUntil: null }, + }, + ] as never); + + const res = await GET( + new NextRequest("http://localhost/api/medications/intake?scope=today"), + ); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled(); + }); + it("returns compliance buckets for the last N days", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index d290166d9..ac2d53eb8 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -33,6 +33,7 @@ import { enqueueUserMedicationComplianceBackfill, type ComplianceBucket as RollupComplianceBucket, } from "@/lib/medications/compliance-rollups"; +import { expandTodayIntakes } from "@/lib/medication-schedule"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -85,6 +86,69 @@ export const GET = apiHandler(async (request: NextRequest) => { if (scope === "today") { const todayStart = startOfDayInTz(new Date(), userTz); const todayEnd = new Date(todayStart.getTime() + 86_400_000); + + // v1.4.39 W-SERVER-FIX — project pending intake rows for every + // active schedule whose window opens today, then idempotently + // backfill any missing rows. Pre-fix the endpoint returned `[]` + // for daily meds (`schedule.daysOfWeek = null` in the DB) until + // the reminder worker entered the RED phase at the end of the + // dose window — leaving the iOS Dashboard tile + the "Erfassen" + // sheet empty for the whole morning. The projection re-uses the + // canonical `parseScheduleRecurrence` (null/empty = every day) + // so the daysOfWeek semantics stay aligned across every reader. + const activeMedications = await prisma.medication.findMany({ + where: { userId: user.id, active: true }, + select: { + id: true, + schedules: { + select: { + id: true, + medicationId: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + }, + }, + }, + }); + const projected = expandTodayIntakes( + activeMedications.flatMap((m) => m.schedules), + new Date(), + userTz, + ); + + const existing = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { medicationId: true, scheduledFor: true }, + }); + const existingKey = new Set( + existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), + ); + const missing = projected.filter( + (p) => + !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), + ); + if (missing.length > 0) { + await prisma.medicationIntakeEvent.createMany({ + data: missing.map((m) => ({ + userId: user.id, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + takenAt: null, + skipped: false, + // `REMINDER` is the same source the reminder worker uses to + // mint RED-phase rows — semantically "the server projected + // this slot before the user logged anything". The IntakeSource + // enum has no separate `SCHEDULER` literal; reusing REMINDER + // keeps the doctor-report + analytics filters byte-stable. + source: "REMINDER", + })), + }); + } + const events = await prisma.medicationIntakeEvent.findMany({ where: { userId: user.id, @@ -96,7 +160,11 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "medications.intake.today" }, - meta: { count: events.length }, + meta: { + count: events.length, + projected: projected.length, + backfilled: missing.length, + }, }); return apiSuccess( diff --git a/src/lib/__tests__/medication-schedule.test.ts b/src/lib/__tests__/medication-schedule.test.ts index 9d6154486..8023f4b43 100644 --- a/src/lib/__tests__/medication-schedule.test.ts +++ b/src/lib/__tests__/medication-schedule.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + expandTodayIntakes, parseScheduleRecurrence, serializeScheduleRecurrence, + type TodayExpandableSchedule, } from "@/lib/medication-schedule"; describe("medication schedule recurrence", () => { @@ -52,3 +54,111 @@ describe("medication schedule recurrence", () => { ).toBe("i4;"); }); }); + +describe("expandTodayIntakes", () => { + // 2026-05-21 is a Thursday → weekday 4. Pin a reference instant + // safely inside the Europe/Berlin day so every test below shares + // the same "today" anchor regardless of the host clock. + const REFERENCE = new Date("2026-05-21T12:00:00Z"); + const TZ = "Europe/Berlin"; + + const daily: TodayExpandableSchedule = { + id: "sched-daily", + medicationId: "med-daily", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + + it("emits a slot for a schedule with daysOfWeek=null (every day)", () => { + // This is the bug the W-SERVER-FIX patch addresses: the operator's + // Ramipril (Morgens) + Metformin (Abends) both ship `daysOfWeek: + // null` in the DB ("every day" per the schema annotation) and the + // today-intake endpoint returned `[]`. With the fix, the projector + // emits one slot per schedule. + const slots = expandTodayIntakes([daily], REFERENCE, TZ); + expect(slots).toHaveLength(1); + expect(slots[0]).toMatchObject({ + scheduleId: "sched-daily", + medicationId: "med-daily", + }); + }); + + it('treats daysOfWeek="" (empty string) as every day', () => { + const slots = expandTodayIntakes( + [{ ...daily, daysOfWeek: "" }], + REFERENCE, + TZ, + ); + expect(slots).toHaveLength(1); + }); + + it("treats all-seven-days (0..6) as every day", () => { + const slots = expandTodayIntakes( + [{ ...daily, daysOfWeek: "0,1,2,3,4,5,6" }], + REFERENCE, + TZ, + ); + expect(slots).toHaveLength(1); + }); + + it("respects an explicit weekday filter that excludes today", () => { + // Thursday is weekday 4; "1" = Monday only → no slot today. + const slots = expandTodayIntakes( + [{ ...daily, daysOfWeek: "1" }], + REFERENCE, + TZ, + ); + expect(slots).toHaveLength(0); + }); + + it("includes a slot when the explicit weekday filter contains today", () => { + // Thursday = weekday 4; allow {2,4} → today matches. + const slots = expandTodayIntakes( + [{ ...daily, daysOfWeek: "2,4" }], + REFERENCE, + TZ, + ); + expect(slots).toHaveLength(1); + }); + + it("emits slots for two daysOfWeek-null schedules (Ramipril + Metformin shape)", () => { + // The operator's real fixture: morning + evening daily meds. + const morning: TodayExpandableSchedule = { + id: "sched-ramipril", + medicationId: "med-ramipril", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const evening: TodayExpandableSchedule = { + id: "sched-metformin", + medicationId: "med-metformin", + windowStart: "19:00", + windowEnd: "21:00", + daysOfWeek: null, + }; + const slots = expandTodayIntakes([morning, evening], REFERENCE, TZ); + expect(slots).toHaveLength(2); + expect(slots.map((s) => s.medicationId).sort()).toEqual([ + "med-metformin", + "med-ramipril", + ]); + }); + + it("anchors scheduledFor at the schedule's windowStart in the user tz", () => { + const slots = expandTodayIntakes([daily], REFERENCE, TZ); + expect(slots).toHaveLength(1); + const iso = slots[0].scheduledFor.toISOString(); + // 07:00 Europe/Berlin on 2026-05-21 (DST in effect → UTC+2) → 05:00Z. + expect(iso).toBe("2026-05-21T05:00:00.000Z"); + }); + + it("skips multi-week cadence (handled by the reminder worker)", () => { + const biweekly: TodayExpandableSchedule = { + ...daily, + daysOfWeek: "i2;1,3,5", + }; + expect(expandTodayIntakes([biweekly], REFERENCE, TZ)).toEqual([]); + }); +}); diff --git a/src/lib/medication-schedule.ts b/src/lib/medication-schedule.ts index 6d775be7b..e12976944 100644 --- a/src/lib/medication-schedule.ts +++ b/src/lib/medication-schedule.ts @@ -3,6 +3,41 @@ export interface ScheduleRecurrence { intervalWeeks: number; } +/** + * Minimal schedule shape consumed by `expandTodayIntakes`. Mirrors the + * relevant subset of the Prisma `MedicationSchedule` row (`id`, + * `windowStart`, `windowEnd`, `daysOfWeek`) plus the parent + * `medicationId` so the projected slots carry enough context for the + * intake-route caller to upsert into `MedicationIntakeEvent` without + * re-joining. + * + * `daysOfWeek` is the encoded string per `serializeScheduleRecurrence` + * (e.g. `null` for daily, `"1,3,5"` for Mon/Wed/Fri, `"i2;1"` for every + * other Monday). The DB schema column documents `null = daily` as the + * convention every reader honours. + */ +export interface TodayExpandableSchedule { + id: string; + medicationId: string; + /** "HH:mm" 24h, user-tz reference. */ + windowStart: string; + /** "HH:mm" 24h. May wrap midnight (`windowEnd < windowStart`). */ + windowEnd: string; + /** Encoded recurrence string per `serializeScheduleRecurrence`. */ + daysOfWeek: string | null; +} + +/** + * One projected dose slot for "today" in the user's timezone. The + * `scheduledFor` is the UTC instant that corresponds to the schedule's + * `windowStart` applied to today's local-day boundary in `timeZone`. + */ +export interface TodayExpandedSlot { + scheduleId: string; + medicationId: string; + scheduledFor: Date; +} + const ENCODED_PATTERN = /^i([1-4]);(.*)$/; function normalizeDays(days: number[]): number[] { @@ -55,3 +90,147 @@ export function serializeScheduleRecurrence( if (intervalWeeks === 1) return days.join(","); return `i${intervalWeeks};${days.join(",")}`; } + +/** + * Project the supplied schedules to the dose slots they would produce + * for "today" in the user's timezone. Treats `daysOfWeek` of `null`, + * `""`, or "no valid weekdays after normalization" as "every day" — the + * documented DB convention (`prisma/schema.prisma` annotates the column + * `null = daily`). Explicit weekday arrays (`"1,3,5"`) still filter as + * before, so a Monday-only schedule emits nothing on a Sunday. + * + * Used by `/api/medications/intake?scope=today` to backfill placeholder + * `MedicationIntakeEvent` rows for active medications whose reminder + * window has not yet opened — without this projection the endpoint + * returned `[]` for daily meds before the reminder worker entered the + * RED phase, leaving the iOS Dashboard tile + the "Erfassen" sheet + * empty for the whole morning. + * + * The function is pure (no DB access) so the test surface stays inside + * this file. The route owns the upsert. + */ +export function expandTodayIntakes( + schedules: TodayExpandableSchedule[], + reference: Date, + timeZone: string, +): TodayExpandedSlot[] { + const slots: TodayExpandedSlot[] = []; + const today = localDayParts(reference, timeZone); + + for (const schedule of schedules) { + const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); + + // Day-of-week filter. Empty array (the parsed shape for `null` / + // `""` / "no valid weekdays") means "every day" — no filter. + if ( + recurrence.daysOfWeek.length > 0 && + !recurrence.daysOfWeek.includes(today.weekday) + ) { + continue; + } + + // Multi-week cadence isn't anchored without a medication start + // date here, so `intervalWeeks > 1` is conservatively skipped from + // the today projection. The reminder worker still mints those rows + // when their window opens because it has the medication context; + // those meds are GLP-1-style weeklies which the iOS today list + // doesn't depend on as a primary surface. + if (recurrence.intervalWeeks > 1) continue; + + const [h, m] = schedule.windowStart.split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(m)) continue; + + const scheduledFor = instantAtLocalHm(today, h, m, timeZone); + slots.push({ + scheduleId: schedule.id, + medicationId: schedule.medicationId, + scheduledFor, + }); + } + + return slots; +} + +interface LocalDayParts { + year: number; + month: number; + day: number; + weekday: number; +} + +const WEEKDAY_MAP: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +function localDayParts(date: Date, timeZone: string): LocalDayParts { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + weekday: "short", + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? "0"; + return { + year: Number(get("year")), + month: Number(get("month")), + day: Number(get("day")), + weekday: WEEKDAY_MAP[get("weekday")] ?? 0, + }; +} + +function instantAtLocalHm( + day: LocalDayParts, + hour: number, + minute: number, + timeZone: string, +): Date { + // Two-pass solver — first guess assumes wall-clock equals UTC, then + // correct by the zone's offset at that approximate instant. Two + // iterations suffice for every IANA zone (the second pass adjusts + // across DST transitions). + let guess = new Date( + Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0), + ); + for (let i = 0; i < 2; i++) { + const offsetMin = tzOffsetMinutes(guess, timeZone); + guess = new Date( + Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0) - + offsetMin * 60_000, + ); + } + return guess; +} + +function tzOffsetMinutes(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): number => + Number(parts.find((p) => p.type === type)?.value ?? "0"); + let hour = get("hour"); + if (hour === 24) hour = 0; + const asIfUtc = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + hour, + get("minute"), + get("second"), + ); + return Math.round((asIfUtc - date.getTime()) / 60000); +}