Add schedule validation and service layer
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user