// 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 }; // ─── Schedule-wide validation (daily + weekly, midnight spillover) ───── export type ScheduleType = 'daily' | 'weekly'; export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const; export type WeekDay = (typeof WEEK_DAYS)[number]; /** A Block as seen by the validator. `day` is null for daily schedules. */ export type ValidationBlock = { id?: string; day?: string | null; startMinutes: number; endMinutes: number; proclamationIds?: string[]; }; /** Block length in minutes, counting a wrap past midnight as the next day. */ function blockLinearLength(block: { startMinutes: number; endMinutes: number }): number { return block.endMinutes > block.startMinutes ? block.endMinutes - block.startMinutes : block.endMinutes - block.startMinutes + MINUTES_PER_DAY; } /** Linear half-open interval `[start, end)` relative to `dayIndex`. */ function dayInterval( block: { startMinutes: number; endMinutes: number }, dayIndex: number ): [number, number] { const base = dayIndex * MINUTES_PER_DAY; const start = base + block.startMinutes; return [start, start + blockLinearLength(block)]; } function isCrossing(block: { startMinutes: number; endMinutes: number }): boolean { return block.endMinutes < block.startMinutes; } function blocksForDay(blocks: ValidationBlock[], dayIndex: number): ValidationBlock[] { const day = WEEK_DAYS[dayIndex]; return blocks.filter((b) => (b.day ?? null) === day); } /** * First overlapping pair of Blocks in a schedule, or null. Implements the * contract's overlap rules: touching boundaries are fine; a midnight-crossing * Block spills into the next day (weekly Sunday→Monday) and must not overlap the * next day's early Blocks. */ export function findScheduleOverlap( type: ScheduleType, blocks: ValidationBlock[] ): { a: ValidationBlock; b: ValidationBlock } | null { if (type === 'daily') { // Daily is a single day list that wraps onto itself: a crossing Block // spills into the next day's copy of the same list. First check the // same-day intervals, then every crossing Block against the next day. const intervals = blocks.map((b) => ({ block: b, interval: dayInterval(b, 0) })); const sameDay = firstPairOverlap(intervals); if (sameDay) return sameDay; for (const crossing of blocks.filter(isCrossing)) { // The crossing Block's post-midnight tail, expressed in this day's // 0-based frame (it spans [0, endMinutes)). const spill: IntervalEntry = { block: crossing, interval: [0, crossing.endMinutes] }; for (const { block, interval } of intervals) { if (block === crossing) continue; if (segmentsIntersect(spill.interval, interval)) { return { a: crossing, b: block }; } } } return null; } for (let d = 0; d < WEEK_DAYS.length; d++) { const dayBlocks = blocksForDay(blocks, d); const intervals = dayBlocks.map((b) => ({ block: b, interval: dayInterval(b, d) })); // Same-day overlaps (a crossing Block's late segment vs the same day). const pair = firstPairOverlap(intervals); if (pair) return pair; // Spillover from the previous day's crossing Blocks into this day's early // Blocks. All spills start at midnight, so their union is [0, max(end)). const prevIndex = (d - 1 + WEEK_DAYS.length) % WEEK_DAYS.length; const prevCrossing = blocksForDay(blocks, prevIndex).filter(isCrossing); for (const prev of prevCrossing) { const spill: [number, number] = [d * MINUTES_PER_DAY, d * MINUTES_PER_DAY + prev.endMinutes]; for (const { block, interval } of intervals) { if (segmentsIntersect(spill, interval)) { return { a: prev, b: block }; } } } } return null; } type IntervalEntry = { block: ValidationBlock; interval: [number, number] }; function firstPairOverlap( entries: IntervalEntry[] ): { a: ValidationBlock; b: ValidationBlock } | null { for (let i = 0; i < entries.length; i++) { for (let j = i + 1; j < entries.length; j++) { if (segmentsIntersect(entries[i].interval, entries[j].interval)) { return { a: entries[i].block, b: entries[j].block }; } } } return null; } /** Validate one Block's shape (time range + at least one Proclamation). */ export function validateBlockShape(block: ValidationBlock): ValidationResult { const { startMinutes, endMinutes, proclamationIds } = block; 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.' }; } if (startMinutes === endMinutes) { return { ok: false, error: "Start and end time can't be the same." }; } if (block.day != null && !WEEK_DAYS.includes(block.day as WeekDay)) { return { ok: false, error: 'Pick a valid day of the week.' }; } if (proclamationIds !== undefined) { if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) { return { ok: false, error: 'Select at least one proclamation.' }; } } return { ok: true }; } /** * Validate a whole schedule. The server is authoritative; the client calls the * same function for instant feedback. */ export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]): ValidationResult { for (const block of blocks) { const shape = validateBlockShape(block); if (!shape.ok) return shape; } const overlap = findScheduleOverlap(type, blocks); if (overlap) { return { ok: false, error: overlapError(overlap.a, overlap.b) }; } return { ok: true }; } /** * Validate a candidate Block against the schedule's other Blocks (which must * exclude the candidate when editing). */ export function validateBlockAgainstSchedule( type: ScheduleType, candidate: ValidationBlock, siblings: ValidationBlock[] ): ValidationResult { const shape = validateBlockShape(candidate); if (!shape.ok) return shape; const overlap = findScheduleOverlap(type, [...siblings, candidate]); if (overlap) { return { ok: false, error: overlapError(overlap.a, overlap.b) }; } return { ok: true }; } function overlapError(a: ValidationBlock, b: ValidationBlock): string { return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`; } 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 }; }