Files
portal/src/lib/schedule-utils.ts
T
NeilClack 3bb8e0189b Portal: per-license schedule service, shared validator, and key-only GET
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.
2026-09-29 10:23:10 -05:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}