diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index ca2ec4e7a..8bd2587af 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -32,6 +32,7 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { NextRequest } from "next/server"; @@ -39,9 +40,7 @@ type RouteParams = { params: Promise<{ id: string }> }; /** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */ function hhmmToMinutes(value: string): number { - const [h, m] = value.split(":").map(Number); - if (!Number.isFinite(h) || !Number.isFinite(m)) return 0; - return h * 60 + m; + return hhmmToMinutesOrNull(value) ?? 0; } /** diff --git a/src/components/medications/scheduling/dose-window.ts b/src/components/medications/scheduling/dose-window.ts index c6caf558b..a468feb1b 100644 --- a/src/components/medications/scheduling/dose-window.ts +++ b/src/components/medications/scheduling/dose-window.ts @@ -21,6 +21,11 @@ */ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; + +// Re-exported so the existing `dose-window` consumers (and its unit suite) +// keep their import site while the implementation lives in the shared util. +export { hhmmToMinutes }; /** One persisted explicit window. Matches `doseWindowEntrySchema`. */ export interface DoseWindowEntry { @@ -40,12 +45,6 @@ export type DoseWindowScale = "intraday" | "dayScale"; const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; -/** Minutes-since-midnight for a regex-valid HH:mm. */ -export function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */ export function minutesToHhmm(total: number): string { const wrapped = ((total % 1440) + 1440) % 1440; diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 6cfa837be..d4d6bf0cf 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -46,6 +46,7 @@ import { occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; @@ -325,13 +326,6 @@ function applyTime(day: Date, hhmm: string, tz: string | undefined): Date { } /** Parse "HH:mm" to minutes-since-midnight, or null when malformed. */ -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} /** Snap a Date down to the user-local midnight. */ export function startOfLocalDay(d: Date, tz: string | undefined): Date { @@ -504,8 +498,8 @@ export function expandScheduleSlots( // the windowStart..windowEnd span so the pairing radius and the // chart cell keep their existing shape; an overnight window pushes // the end to the next day. - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); let spanMs = DAY_MS; if (startMin !== null && endMin !== null) { let span = endMin - startMin; diff --git a/src/lib/medications/scheduling/hhmm.ts b/src/lib/medications/scheduling/hhmm.ts new file mode 100644 index 000000000..c2aeaf415 --- /dev/null +++ b/src/lib/medications/scheduling/hhmm.ts @@ -0,0 +1,38 @@ +/** + * Shared `HH:mm` → minutes-since-midnight helpers. + * + * Consolidates the copies that had accumulated across the medication + * scheduling surfaces (the window validator, the dose-window geometry, the + * per-medication schedule reconciler, the slot-instant resolver, the + * recurrence engine, the cadence walker, and the worker helpers). Two + * variants, matching the two behaviours those copies actually relied on: + * + * - `hhmmToMinutes` assumes the literal was already regex-validated (the + * Zod window schema, a persisted schedule row) and does the arithmetic + * directly — a malformed literal yields `NaN`, which never reaches these + * callers; + * - `hhmmToMinutesOrNull` handles unvalidated input and returns `null` when + * the literal is not a well-formed numeric `HH:mm`, so a caller can decide + * the fallback (skip the time, treat as 0, …) explicitly. + * + * Pure string arithmetic, no imports — safe to pull into both server and + * client bundles. + */ + +/** Minutes-since-midnight for an already-validated `HH:mm` literal. */ +export function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + +/** + * Minutes-since-midnight, or `null` when the literal is not a well-formed + * numeric `HH:mm`. Use on any path that handles unvalidated schedule input. + */ +export function hhmmToMinutesOrNull(hhmm: string): number | null { + const [hStr, mStr] = hhmm.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return null; + return h * 60 + m; +} diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 5a6b9ae8b..f05322d56 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -60,6 +60,7 @@ 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 { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; /** * v1.7.0 — schedule-type discriminator on the canonical schedule. @@ -823,8 +824,8 @@ function graceWindowMs(schedule: CanonicalSchedule): number { if (schedule.reminderGraceMinutes !== null) { return schedule.reminderGraceMinutes * ONE_MINUTE_MS; } - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); if (startMin === null || endMin === null) { return DEFAULT_GRACE_MINUTES * ONE_MINUTE_MS; } @@ -834,14 +835,6 @@ function graceWindowMs(schedule: CanonicalSchedule): number { return span * ONE_MINUTE_MS; } -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} - // ──────────────────────────────────────────────────────────────────── // UTC date helpers (RRULE day-anchor + endsOn cap arithmetic) // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index d53fa52e2..56273fbd8 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -33,6 +33,7 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { localHmAsUtc } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -238,7 +239,7 @@ function canonicalSlotInstant( timeOfDay: string, userTz: string, ): Date { - const minutes = hhmmToMinutes(timeOfDay); + const minutes = hhmmToMinutesOrNull(timeOfDay); if (minutes === null) return occAt; const h = Math.floor(minutes / 60); const m = minutes % 60; @@ -267,8 +268,8 @@ function canonicalSlotInstant( * half-span behaviour unchanged. */ function snapToleranceMs(schedule: CanonicalSchedule): number { - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); let halfSpanMs: number; if (startMin === null || endMin === null) { halfSpanMs = DEFAULT_TOLERANCE_MS; @@ -281,7 +282,7 @@ function snapToleranceMs(schedule: CanonicalSchedule): number { let toleranceMs = halfSpanMs; const slots = effectiveTimesOfDay(schedule) - .map(hhmmToMinutes) + .map(hhmmToMinutesOrNull) .filter((m): m is number => m !== null) .sort((a, b) => a - b); if (slots.length > 1) { @@ -325,7 +326,7 @@ function graceToleranceMs(schedule: CanonicalSchedule): number { : ONE_MINUTE_MS; const slots = effectiveTimesOfDay(schedule) - .map(hhmmToMinutes) + .map(hhmmToMinutesOrNull) .filter((m): m is number => m !== null) .sort((a, b) => a - b); if (slots.length > 1) { @@ -353,11 +354,3 @@ function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] { ? schedule.timesOfDay : [schedule.windowStart]; } - -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index fa66b78ed..92e18ffa9 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -23,6 +23,7 @@ import { type ScheduleType, occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; /** * Minimal Prisma-shape projection used by the worker. Mirrors the @@ -122,11 +123,6 @@ export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null { return out.length > 0 ? out : null; } -function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** Build the canonical engine context from worker-loop state. */ export function buildRecurrenceContext(input: { medication: WorkerMedicationRow; diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index 7a6709c5c..b1da554a4 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; /** * v1.8.5 — the eight injection-site enum values, mirrored from the @@ -190,12 +191,6 @@ export const doseWindowEntrySchema = z "One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.", }); -/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ -export function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the