Files
portal/src/lib/schedule-utils.ts
T

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