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`; }