From 903f0a9a22a4ec442e5ae15c91883eb2c182c640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 17:07:02 +0200 Subject: [PATCH] refactor(medications): drop the legacy today walker and its own zone solver expandTodayIntakes had no caller left once the today projection moved onto the recurrence engine and localHmAsUtc; it carried the last private wall-clock solver in the medication code. The recurrence engine now takes its offset and UTC-day helpers from the tz module instead of local copies. --- src/lib/__tests__/medication-schedule.test.ts | 110 ----------- src/lib/medication-schedule.ts | 179 ------------------ src/lib/medications/scheduling/recurrence.ts | 33 +--- 3 files changed, 7 insertions(+), 315 deletions(-) diff --git a/src/lib/__tests__/medication-schedule.test.ts b/src/lib/__tests__/medication-schedule.test.ts index 8023f4b43..9d6154486 100644 --- a/src/lib/__tests__/medication-schedule.test.ts +++ b/src/lib/__tests__/medication-schedule.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { - expandTodayIntakes, parseScheduleRecurrence, serializeScheduleRecurrence, - type TodayExpandableSchedule, } from "@/lib/medication-schedule"; describe("medication schedule recurrence", () => { @@ -54,111 +52,3 @@ 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 e12976944..6d775be7b 100644 --- a/src/lib/medication-schedule.ts +++ b/src/lib/medication-schedule.ts @@ -3,41 +3,6 @@ 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[] { @@ -90,147 +55,3 @@ 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); -} diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 75716adc9..b4a4792a6 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -4,10 +4,9 @@ * Single source of truth for "what dose slots does this schedule emit * between A and B?" The canonical engine is introduced in this release; * the v1.5.0 cut wires only the reminder worker (via - * `worker-helpers.ts`) through it. The today-projector - * (`expandTodayIntakes`), the cadence chart (`expandScheduleSlots`), - * and the form-level helpers continue on the legacy walker through - * v1.5.x and migrate per the read-flip plan. The medication card's + * `worker-helpers.ts`) through it. The today-projector, the cadence + * chart (`expandScheduleSlots`), and the form-level helpers continue on + * the legacy walker through v1.5.x and migrate per the read-flip plan. The medication card's * "next intake" line reads the server-computed `nextDueAt` (this engine * via `computeNextDueAt`) directly as of v1.8.4. * @@ -30,7 +29,7 @@ * are NULL and `oneShot` is false. Decodes the legacy * `daysOfWeek` string via `parseScheduleRecurrence` and emits * weekly slots. **Honours `intervalWeeks > 1` correctly** — - * the existing `expandTodayIntakes` skipped it (the legacy + * the retired today-projector walker skipped it (the legacy * bi-weekly worker bug R-3 finding 5 calls out); this engine * anchors the week phase to `startsOn ?? createdAt` and emits * on the matching weeks only. @@ -60,6 +59,8 @@ import { annotate } from "@/lib/logging/context"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { tzOffsetMinutes } from "@/lib/tz/format"; +import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; /** @@ -906,8 +907,7 @@ function startOfCivilWeek(day: Date): Date { /** * Materialise an "HH:mm" on a civil day in the user's IANA timezone, - * returning the corresponding UTC instant. DST-aware via the two-pass - * solver pattern from `medication-schedule.ts`: a wall-clock time that + * returning the corresponding UTC instant. DST-aware: a wall-clock time that * does not exist on a spring-forward day resolves forward (02:30 in * Europe/Berlin on 2026-03-29 becomes 03:30). * @@ -955,19 +955,6 @@ function applyTimeOfDayToCivilDay(day: Date, hhmm: string, tz: string): Date { return before; } -function tzOffsetMinutes(date: Date, tz: string): number { - const parts = wallClockInTz(date, tz); - const asIfUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ); - return Math.round((asIfUtc - date.getTime()) / 60_000); -} - function buildOccurrence( at: Date, timeOfDay: string, @@ -1011,12 +998,6 @@ function graceWindowMs(schedule: CanonicalSchedule): number { // UTC date helpers (RRULE day-anchor + endsOn cap arithmetic) // ──────────────────────────────────────────────────────────────────── -function startOfUtcDay(d: Date): Date { - return new Date( - Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0), - ); -} - function endOfUtcDay(d: Date): Date { return new Date( Date.UTC(