110 lines
3.3 KiB
TypeScript
110 lines
3.3 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[];
|
||
};
|
||
|
||
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 };
|
||
}
|