mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
61a1592665
commit
9e6453c51d
8 files changed
+59
-52
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user