// 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[]; }; 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')}`; } /** 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)); } /** First sibling whose half-open range [start, end) intersects the input. */ export function findOverlap( input: { startMinutes: number; endMinutes: number }, siblings: BlockSibling[] ): BlockSibling | null { for (const sibling of siblings) { if (input.startMinutes < sibling.endMinutes && sibling.startMinutes < input.endMinutes) { 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 || endMinutes > MINUTES_PER_DAY || startMinutes >= endMinutes ) { return { ok: false, error: "Start time must be before end time. Overnight blocks aren't supported." }; } 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 }; }