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.
218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
// Isomorphic, dependency-free schedule helpers. Used by the server service
|
||
// layer, the v2 serializers and the client-side Block modal (for live
|
||
// pre-validation), so it must not import Prisma or any server-only code.
|
||
|
||
export const MAX_BLOCKS = 4;
|
||
export const MINUTES_PER_DAY = 24 * 60;
|
||
export const SNAP_MINUTES = 15;
|
||
export const MINUTE_STEP = 5;
|
||
|
||
export type BlockSibling = {
|
||
id?: string;
|
||
startMinutes: number;
|
||
endMinutes: number;
|
||
};
|
||
|
||
export type BlockInput = {
|
||
startMinutes: number;
|
||
endMinutes: number;
|
||
proclamationIds: string[];
|
||
/** When true the kiosk enables dynamic audio for the block's duration. */
|
||
dynamicAudio?: boolean;
|
||
};
|
||
|
||
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||
|
||
function normalizeMinutes(minutes: number): number {
|
||
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
||
}
|
||
|
||
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
|
||
export function formatTime(minutes: number): string {
|
||
const normalized = normalizeMinutes(minutes);
|
||
const hours24 = Math.floor(normalized / 60);
|
||
const mins = normalized % 60;
|
||
const period = hours24 < 12 ? 'AM' : 'PM';
|
||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
|
||
}
|
||
|
||
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
|
||
export function toHm(minutes: number): string {
|
||
const normalized = normalizeMinutes(minutes);
|
||
const hours = Math.floor(normalized / 60);
|
||
const mins = normalized % 60;
|
||
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;
|
||
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
|
||
}
|
||
|
||
/** A Block whose end clock time is at or before its start wraps past midnight. */
|
||
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
|
||
return block.endMinutes <= block.startMinutes;
|
||
}
|
||
|
||
/** Block length in minutes, counting a wrap past midnight as the next day. */
|
||
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
|
||
if (block.endMinutes > block.startMinutes) {
|
||
return block.endMinutes - block.startMinutes;
|
||
}
|
||
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
|
||
}
|
||
|
||
/**
|
||
* Split a Block into the linear half-open `[start, end)` segments it occupies
|
||
* within the 24h day. An overnight block produces two segments (start→midnight
|
||
* and midnight→end); a zero-length block produces none.
|
||
*/
|
||
export function blockSegments(block: {
|
||
startMinutes: number;
|
||
endMinutes: number;
|
||
}): [number, number][] {
|
||
if (block.startMinutes < block.endMinutes) {
|
||
return [[block.startMinutes, block.endMinutes]];
|
||
}
|
||
if (block.startMinutes > block.endMinutes) {
|
||
return [
|
||
[block.startMinutes, MINUTES_PER_DAY],
|
||
[0, block.endMinutes]
|
||
];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
|
||
return a[0] < b[1] && b[0] < a[1];
|
||
}
|
||
|
||
/**
|
||
* First sibling whose occupied time intersects the input. Works for overnight
|
||
* (midnight-crossing) blocks by comparing their linear segments; ranges that
|
||
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
|
||
*/
|
||
export function findOverlap(
|
||
input: { startMinutes: number; endMinutes: number },
|
||
siblings: BlockSibling[]
|
||
): BlockSibling | null {
|
||
const inputSegments = blockSegments(input);
|
||
for (const sibling of siblings) {
|
||
const siblingSegments = blockSegments(sibling);
|
||
for (const segment of inputSegments) {
|
||
for (const other of siblingSegments) {
|
||
if (segmentsIntersect(segment, other)) return sibling;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
|
||
* the Block being edited. `isCreate` enables the 4-block maximum (an update
|
||
* never increases the count).
|
||
*/
|
||
export function validateBlock(
|
||
input: BlockInput,
|
||
siblings: BlockSibling[],
|
||
opts: { isCreate?: boolean } = {}
|
||
): ValidationResult {
|
||
const { startMinutes, endMinutes, proclamationIds } = input;
|
||
|
||
if (
|
||
!Number.isInteger(startMinutes) ||
|
||
!Number.isInteger(endMinutes) ||
|
||
startMinutes < 0 ||
|
||
startMinutes > MINUTES_PER_DAY ||
|
||
endMinutes < 0 ||
|
||
endMinutes > MINUTES_PER_DAY
|
||
) {
|
||
return { ok: false, error: 'Enter a valid start and end time.' };
|
||
}
|
||
|
||
// Equal clock times would be a zero- or full-length block; both are
|
||
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
|
||
if (startMinutes === endMinutes) {
|
||
return { ok: false, error: "Start and end time can't be the same." };
|
||
}
|
||
|
||
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||
return { ok: false, error: 'Select at least one proclamation.' };
|
||
}
|
||
|
||
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
|
||
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
|
||
}
|
||
|
||
const overlap = findOverlap(input, siblings);
|
||
if (overlap) {
|
||
return {
|
||
ok: false,
|
||
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
|
||
overlap.endMinutes
|
||
)} block.`
|
||
};
|
||
}
|
||
|
||
return { ok: true };
|
||
}
|