diff --git a/src/lib/medications/compliance-rollups.ts b/src/lib/medications/compliance-rollups.ts index 9f7287c69..6684c709d 100644 --- a/src/lib/medications/compliance-rollups.ts +++ b/src/lib/medications/compliance-rollups.ts @@ -26,6 +26,7 @@ import { isValidTimezone, userDayKey, } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; /** * pg-boss queue name for the boot-time medication compliance backfill. @@ -65,30 +66,6 @@ export interface ComplianceBucket { */ type PrismaLike = typeof prisma | Prisma.TransactionClient; -/** - * Compute the wall-clock components of `date` interpreted in `tz`. - * Same shape as the cadence helper's `wallClockInTz`, lifted here so - * the compliance module doesn't depend on a sibling-folder helper. - */ -function wallClockInTz( - date: Date, - tz: string, -): { year: number; month: number; day: number } { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).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")), - }; -} - /** * UTC offset (in minutes) of `tz` at the given instant. Positive east * of UTC. Honours DST via `Intl.DateTimeFormat`. diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 34683770b..16b2053b3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -28,6 +28,7 @@ */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; export interface ScheduleLike { /** "HH:mm" 24h, user-tz reference (per existing schema). */ @@ -78,69 +79,9 @@ const WEEK_MS = 7 * DAY_MS; */ const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000; -interface WallClockParts { - year: number; - month: number; // 1-12 - day: number; - hour: number; - minute: number; - second: number; - weekday: number; // 0 = Sunday -} - -const WEEKDAY_MAP: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, -}; - -/** - * Compute the wall-clock parts of `date` interpreted in the user's - * timezone. When `tz` is omitted, falls back to the system-local - * representation so the legacy single-tz callers keep their existing - * shape (the v1.4.25 W19e callers were system-local by construction). - */ -function wallClockInTz(date: Date, tz: string | undefined): WallClockParts { - if (!tz) { - return { - year: date.getFullYear(), - month: date.getMonth() + 1, - day: date.getDate(), - hour: date.getHours(), - minute: date.getMinutes(), - second: date.getSeconds(), - weekday: date.getDay(), - }; - } - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - weekday: "short", - }).formatToParts(date); - const get = (type: Intl.DateTimeFormatPartTypes): string => - parts.find((p) => p.type === type)?.value ?? "0"; - let hour = Number(get("hour")); - if (hour === 24) hour = 0; - return { - year: Number(get("year")), - month: Number(get("month")), - day: Number(get("day")), - hour, - minute: Number(get("minute")), - second: Number(get("second")), - weekday: WEEKDAY_MAP[get("weekday")] ?? 0, - }; -} +// WallClockParts + wallClockInTz live in `@/lib/tz/wall-clock` as the +// canonical helper — see the file-level comment there for the v1.4.40 +// consolidation rationale. /** * Compute the UTC offset (in minutes) of `tz` at the given instant. diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts new file mode 100644 index 000000000..4ed3626b2 --- /dev/null +++ b/src/lib/tz/wall-clock.ts @@ -0,0 +1,84 @@ +/** + * Wall-clock-in-timezone helper shared by the medication scheduling + * + compliance-rollup paths. + * + * Decomposes a `Date` instant into the year/month/day/hour/minute/ + * second/weekday tuple an observer in `tz` would read off a wall + * clock at that moment. Honours DST because `Intl.DateTimeFormat` + * does. + * + * When `tz` is omitted the helper falls back to the host's system- + * local representation so the v1.4.25 W19e callers that pre-date the + * per-user timezone work keep their original shape. + * + * v1.4.40 W-GHOSTS consolidated two file-local copies + * (`compliance-rollups.ts` returning `{year,month,day}` only + + * `scheduling/cadence.ts` returning the full tuple) into this single + * canonical export. The cross-tz ±3 h guard hinges on these helpers + * staying in lock-step; keeping them as separate definitions made + * silent drift a real failure mode. + */ +export interface WallClockParts { + year: number; + /** 1–12 (January = 1, December = 12). */ + month: number; + /** 1–31. */ + day: number; + /** 0–23 (24 normalised to 0 to match Date.getHours() semantics). */ + hour: number; + minute: number; + second: number; + /** 0 = Sunday … 6 = Saturday. */ + weekday: number; +} + +const WEEKDAY_MAP: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +export function wallClockInTz( + date: Date, + tz: string | undefined, +): WallClockParts { + if (!tz) { + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + weekday: date.getDay(), + }; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? "0"; + let hour = Number(get("hour")); + if (hour === 24) hour = 0; + return { + year: Number(get("year")), + month: Number(get("month")), + day: Number(get("day")), + hour, + minute: Number(get("minute")), + second: Number(get("second")), + weekday: WEEKDAY_MAP[get("weekday")] ?? 0, + }; +}