// 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 }; }