125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
export const MINUTES_PER_WEEK = 7 * 24 * 60;
|
|
const MAX_WINDOWS = 50;
|
|
|
|
export function groupScheduleStatus(windowCount: number) {
|
|
if (windowCount <= 0) return "Unrestricted";
|
|
return `${windowCount} ${windowCount === 1 ? "window" : "windows"}`;
|
|
}
|
|
|
|
export interface WeeklyAccessWindow {
|
|
startMinuteOfWeek: number;
|
|
endMinuteOfWeek: number;
|
|
}
|
|
|
|
interface ScheduleDecision {
|
|
allowed: boolean;
|
|
nextWindow: { start: Date; end: Date } | null;
|
|
}
|
|
|
|
function normalizedMinute(value: number) {
|
|
return ((value % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK;
|
|
}
|
|
|
|
function validWindow(window: WeeklyAccessWindow) {
|
|
return Number.isInteger(window.startMinuteOfWeek)
|
|
&& Number.isInteger(window.endMinuteOfWeek)
|
|
&& window.startMinuteOfWeek >= 0
|
|
&& window.startMinuteOfWeek < MINUTES_PER_WEEK
|
|
&& window.endMinuteOfWeek >= 0
|
|
&& window.endMinuteOfWeek < MINUTES_PER_WEEK
|
|
&& window.startMinuteOfWeek !== window.endMinuteOfWeek;
|
|
}
|
|
|
|
function segments(window: WeeklyAccessWindow) {
|
|
return window.endMinuteOfWeek > window.startMinuteOfWeek
|
|
? [[window.startMinuteOfWeek, window.endMinuteOfWeek] as const]
|
|
: [
|
|
[window.startMinuteOfWeek, MINUTES_PER_WEEK] as const,
|
|
[0, window.endMinuteOfWeek] as const,
|
|
];
|
|
}
|
|
|
|
export function validateScheduleWindows(windows: WeeklyAccessWindow[]) {
|
|
if (windows.length > MAX_WINDOWS || windows.some((window) => !validWindow(window))) return null;
|
|
for (let left = 0; left < windows.length; left += 1) {
|
|
for (let right = left + 1; right < windows.length; right += 1) {
|
|
const overlaps = segments(windows[left]!).some(([leftStart, leftEnd]) =>
|
|
segments(windows[right]!).some(([rightStart, rightEnd]) =>
|
|
leftStart < rightEnd && rightStart < leftEnd));
|
|
if (overlaps) return null;
|
|
}
|
|
}
|
|
return [...windows].sort((left, right) => left.startMinuteOfWeek - right.startMinuteOfWeek);
|
|
}
|
|
|
|
export function parseScheduleWindows(formData: FormData) {
|
|
const starts = formData.getAll("startMinuteOfWeek").map(String);
|
|
const ends = formData.getAll("endMinuteOfWeek").map(String);
|
|
if (starts.length !== ends.length) return null;
|
|
const decimalInteger = /^(0|[1-9]\d*)$/;
|
|
if (starts.some((value) => !decimalInteger.test(value)) || ends.some((value) => !decimalInteger.test(value))) {
|
|
return null;
|
|
}
|
|
return validateScheduleWindows(starts.map((start, index) => ({
|
|
startMinuteOfWeek: Number(start),
|
|
endMinuteOfWeek: Number(ends[index]),
|
|
})));
|
|
}
|
|
|
|
function utcWeekStart(now: Date) {
|
|
const dayFromMonday = (now.getUTCDay() + 6) % 7;
|
|
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - dayFromMonday);
|
|
}
|
|
|
|
function windowDuration(window: WeeklyAccessWindow) {
|
|
return normalizedMinute(window.endMinuteOfWeek - window.startMinuteOfWeek);
|
|
}
|
|
|
|
export function evaluateGroupSchedule(windows: WeeklyAccessWindow[], now: Date): ScheduleDecision {
|
|
if (!windows.length) return { allowed: true, nextWindow: null };
|
|
const valid = validateScheduleWindows(windows);
|
|
if (!valid || !Number.isFinite(now.getTime())) return { allowed: false, nextWindow: null };
|
|
|
|
const weekStart = utcWeekStart(now);
|
|
const occurrences = valid.flatMap((window) => [-1, 0, 1].map((weekOffset) => {
|
|
const start = new Date(weekStart + (weekOffset * MINUTES_PER_WEEK + window.startMinuteOfWeek) * 60_000);
|
|
const end = new Date(start.getTime() + windowDuration(window) * 60_000);
|
|
return { start, end };
|
|
}));
|
|
if (occurrences.some(({ start, end }) => now >= start && now < end)) {
|
|
return { allowed: true, nextWindow: null };
|
|
}
|
|
const nextWindow = occurrences
|
|
.filter(({ start }) => start > now)
|
|
.sort((left, right) => left.start.getTime() - right.start.getTime())[0] ?? null;
|
|
return { allowed: false, nextWindow };
|
|
}
|
|
|
|
export function localWindowToUtc(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
|
return {
|
|
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek + browserOffsetMinutes),
|
|
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek + browserOffsetMinutes),
|
|
};
|
|
}
|
|
|
|
export function utcWindowToLocal(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
|
return {
|
|
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek - browserOffsetMinutes),
|
|
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek - browserOffsetMinutes),
|
|
};
|
|
}
|
|
|
|
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
|
|
|
export function formatWeeklyMinute(minuteOfWeek: number) {
|
|
const minute = normalizedMinute(minuteOfWeek);
|
|
const day = WEEKDAYS[Math.floor(minute / (24 * 60))];
|
|
const hour = Math.floor((minute % (24 * 60)) / 60);
|
|
const minuteOfHour = minute % 60;
|
|
return `${day} ${String(hour).padStart(2, "0")}:${String(minuteOfHour).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function formatScheduleInstant(value: Date) {
|
|
return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
}
|