refactor(medications): consolidate the hhmm-to-minutes helpers into one shared util

Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 21:25:15 +02:00
1 parent 61a1592665
commit 9e6453c51d
8 files changed
+59 -52

No files matched your search

+2 -3
View File
@@ -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;
}
/**
@@ -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;
+3 -9
View File
@@ -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;
+38
View File
@@ -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;
}
+3 -10
View File
@@ -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)
// ────────────────────────────────────────────────────────────────────
@@ -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;
}
@@ -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;
+1 -6
View File
@@ -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