Portal: add reusable 12-hour TimeInput component

TimeInput displays and accepts AM/PM times, offers the 5-minute options as
suggestions, and allows typing any minute 00-59; its value model is a 24h
HH:MM string. Adds isomorphic parseTimeInput/hmToMinutes/fiveMinuteOptions
helpers and unit + component tests for them.
This commit is contained in:
NeilClack
2026-09-29 08:53:17 -05:00
parent 6c89a89050
commit 35558b0652
4 changed files with 274 additions and 0 deletions
+56
View File
@@ -45,6 +45,62 @@ export function toHm(minutes: number): string {
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/**
* Parse a 24-hour `HH:MM` string into minutes from midnight. Returns null when
* the input is not a valid 24-hour time.
*/
export function hmToMinutes(hm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hm.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
export type TimeInputResult = { ok: true; minutes: number } | { ok: false; error: string };
/**
* Parse a user-typed 12-hour clock time into minutes from midnight. Tolerant of
* "7:05 pm", "7:05PM", "7pm", "7:7", "12:00 am". A period is optional; when
* present the hour must be 1-12, so "13:00 PM" is rejected. Minutes may be any
* value 00-59, not just the 5-minute steps.
*/
export function parseTimeInput(raw: string): TimeInputResult {
const text = raw.trim().toLowerCase();
if (!text) return { ok: false, error: 'Enter a time.' };
const match = /^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm|a|p)?$/.exec(text);
if (!match) return { ok: false, error: 'Enter a time like 7:05 PM.' };
const hour = Number(match[1]);
const minute = match[2] === undefined ? 0 : Number(match[2]);
const period = match[3];
if (minute > 59) return { ok: false, error: 'Minutes must be 00-59.' };
if (period) {
if (hour < 1 || hour > 12) {
return { ok: false, error: 'Hour must be 1-12 when AM/PM is given.' };
}
const isPm = period === 'pm' || period === 'p';
const hours24 = (hour % 12) + (isPm ? 12 : 0);
return { ok: true, minutes: hours24 * 60 + minute };
}
if (hour > 23) return { ok: false, error: 'Hour must be 0-23.' };
return { ok: true, minutes: hour * 60 + minute };
}
/** The 5-minute clock options (00:00, 00:05, ..., 23:55) in play order. */
export function fiveMinuteOptions(): number[] {
const options: number[] = [];
for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += 5) {
options.push(minutes);
}
return options;
}
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
export function snapMinutes(minutes: number): number {
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;