Implements the shared validateSchedule validator (daily + weekly, midnight spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a per-License-Key schedule service (get/set type, CRUD blocks, weekly prefill with confirmation), the content-hash version/ETag serializer, the new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null), and additive evolution of the legacy GET /api/v2/schedule. Adds validator, serializer (against docs/fixtures), endpoint ETag/304, and service tests.
399 lines
13 KiB
TypeScript
399 lines
13 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 };
|
||
|
||
// ─── 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 };
|
||
}
|