UNPKG

@laxmandarji/schedule-date-calculator

Version:

A powerful JavaScript/TypeScript library for calculating eligible run dates based on complex scheduling rules. Perfect for job schedulers, task automation, and business calendar management.

993 lines (890 loc) 40.2 kB
/** * Schedule Date Calculator * Determines eligible run dates for a job based on scheduling parameters */ /** * Class representing a schedule configuration */ class ScheduleConfig { constructor(config = {}) { this.CALENDARS = config.CALENDARS || {}; this.WEEKDAYS_CALENDAR = config.WEEKDAYS_CALENDAR || null; this.WEEKDAYS = config.WEEKDAYS || []; this.WEEK_MONTH_RELATION = config.WEEK_MONTH_RELATION || "OR"; this.MONTH_CALENDAR = config.MONTH_CALENDAR || null; this.MONTHDAYS = config.MONTHDAYS || []; this.MONTHS = config.MONTHS || []; } /** * Changes the entire configuration with new values * @param {Object} config - Complete configuration object */ change(config = {}) { this.CALENDARS = config.CALENDARS || {}; this.WEEKDAYS_CALENDAR = config.WEEKDAYS_CALENDAR || null; this.WEEKDAYS = config.WEEKDAYS || []; this.WEEK_MONTH_RELATION = config.WEEK_MONTH_RELATION || "OR"; this.MONTH_CALENDAR = config.MONTH_CALENDAR || null; this.MONTHDAYS = config.MONTHDAYS || []; this.MONTHS = config.MONTHS || []; } /** * Updates the configuration with new values * @param {Object} newConfig - Partial or complete configuration object */ update(newConfig) { if (newConfig.CALENDARS) this.CALENDARS = newConfig.CALENDARS; if (newConfig.WEEKDAYS_CALENDAR) this.WEEKDAYS_CALENDAR = newConfig.WEEKDAYS_CALENDAR; if (newConfig.WEEKDAYS) this.WEEKDAYS = newConfig.WEEKDAYS; if (newConfig.WEEK_MONTH_RELATION) this.WEEK_MONTH_RELATION = newConfig.WEEK_MONTH_RELATION; if (newConfig.MONTH_CALENDAR) this.MONTH_CALENDAR = newConfig.MONTH_CALENDAR; if (newConfig.MONTHDAYS) this.MONTHDAYS = newConfig.MONTHDAYS; if (newConfig.MONTHS) this.MONTHS = newConfig.MONTHS; } /** * Adds or updates a calendar in the configuration * @param {string} calendarName - Name of the calendar * @param {Array} workdays - Array of working days (0-6) * @param {Array} holidays - Array of holidays in YYYY-MM-DD format */ addCalendar(calendarName, workdays = [1, 2, 3, 4, 5], holidays = []) { this.CALENDARS[calendarName] = { WORKDAYS: workdays, HOLIDAYS: holidays }; } /** * Determines if a specific date is eligible based on the configuration * @param {Date} date - Date to check * @return {boolean} Whether the date is eligible */ isDateEligible(date) { return isDateEligible(date, this, this.CALENDARS); } /** * Gets the eligible run dates for this configuration * @param {number} year - Year to get dates for * @return {Date[]|Object} Array of eligible dates or validation result */ getEligibleDates(year = new Date().getFullYear()) { return getEligibleRunDates(this, year); } /** * Validates the current configuration * @return {Object} Validation result */ validate() { return validateConfig(this); } } /** * Validates the scheduling configuration * @param {Object} config - Scheduling configuration object * @return {Object} Result object with isValid flag and any error messages */ function validateConfig(config) { const result = { isValid: true, errors: [], warnings: [] }; // Validate calendars if (config.CALENDARS) { if (typeof config.CALENDARS !== 'object') { result.isValid = false; result.errors.push('CALENDARS must be an object'); } else { for (const calName in config.CALENDARS) { const calendar = config.CALENDARS[calName]; // Validate workdays if (calendar.WORKDAYS) { if (!Array.isArray(calendar.WORKDAYS)) { result.isValid = false; result.errors.push(`WORKDAYS in calendar "${calName}" must be an array`); } else { for (const day of calendar.WORKDAYS) { if (!Number.isInteger(day) || day < 0 || day > 6) { result.isValid = false; result.errors.push(`Invalid workday value "${day}" in calendar "${calName}" (must be 0-6)`); } } } } // Validate holidays if (calendar.HOLIDAYS) { if (!Array.isArray(calendar.HOLIDAYS)) { result.isValid = false; result.errors.push(`HOLIDAYS in calendar "${calName}" must be an array`); } else { const dateRegex = /^\d{4}-\d{2}-\d{2}$/; for (const holiday of calendar.HOLIDAYS) { if (!dateRegex.test(holiday)) { result.isValid = false; result.errors.push(`Invalid holiday date format "${holiday}" in calendar "${calName}" (must be YYYY-MM-DD)`); } } } } } } } // Validate WEEKDAYS_CALENDAR and MONTH_CALENDAR references if (config.WEEKDAYS_CALENDAR && !config.CALENDARS?.[config.WEEKDAYS_CALENDAR]) { result.isValid = false; result.errors.push(`WEEKDAYS_CALENDAR "${config.WEEKDAYS_CALENDAR}" not found in CALENDARS`); } if (config.MONTH_CALENDAR && !config.CALENDARS?.[config.MONTH_CALENDAR]) { result.isValid = false; result.errors.push(`MONTH_CALENDAR "${config.MONTH_CALENDAR}" not found in CALENDARS`); } // Validate WEEKDAYS format if (config.WEEKDAYS) { if (!Array.isArray(config.WEEKDAYS)) { result.isValid = false; result.errors.push('WEEKDAYS must be an array'); } else { const weekdayRegex = /^[+<>]?\d+$|^-?\d+$|^-?D\d+$|^-?D\d+W\d+$/; for (const weekday of config.WEEKDAYS) { if (!weekdayRegex.test(weekday)) { result.isValid = false; result.errors.push(`Invalid weekday format "${weekday}"`); } } } } // Validate MONTHDAYS format if (config.MONTHDAYS) { if (!Array.isArray(config.MONTHDAYS)) { result.isValid = false; result.errors.push('MONTHDAYS must be an array'); } else { const monthdayRegex = /^[+<>]?\d+$|^-?\d+$|^-?D\d+$|^-?L\d+$|^-?WORKDAYS$/; for (const monthday of config.MONTHDAYS) { if (!monthdayRegex.test(monthday)) { result.isValid = false; result.errors.push(`Invalid monthday format "${monthday}"`); } } } } // Validate MONTHS format if (config.MONTHS) { if (!Array.isArray(config.MONTHS)) { result.isValid = false; result.errors.push('MONTHS must be an array'); } else { const validMonths = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC', 'ALL']; for (const month of config.MONTHS) { if (!validMonths.includes(month)) { result.isValid = false; result.errors.push(`Invalid month "${month}"`); } } } } // Validate WEEK_MONTH_RELATION if (config.WEEK_MONTH_RELATION && !['AND', 'OR'].includes(config.WEEK_MONTH_RELATION)) { result.isValid = false; result.errors.push('WEEK_MONTH_RELATION must be either "AND" or "OR"'); } // Add warnings for potential issues if (!config.CALENDARS || Object.keys(config.CALENDARS).length === 0) { result.warnings.push('No calendars defined - all days will be considered working days'); } if ((!config.WEEKDAYS || config.WEEKDAYS.length === 0) && (!config.MONTHDAYS || config.MONTHDAYS.length === 0)) { result.warnings.push('No weekdays or monthdays defined - job will run every day'); } return result; } /** * Parses business calendar definitions * @param {Object} calendarsConfig - Calendar configuration * @return {Object} Parsed calendars object */ function parseBusinessCalendars(calendarsConfig) { try { if (!calendarsConfig || typeof calendarsConfig !== 'object') return {}; const result = {}; for (const calName in calendarsConfig) { if (calendarsConfig.hasOwnProperty(calName)) { const calendar = calendarsConfig[calName]; if (calendar && typeof calendar === 'object') { // According to requirements document, WORKDAYS should be using JavaScript's format (0-6) // Default to Mon-Fri (1-5 in JavaScript format) result[calName] = { WORKDAYS: Array.isArray(calendar.WORKDAYS) ? calendar.WORKDAYS : [1, 2, 3, 4, 5], HOLIDAYS: Array.isArray(calendar.HOLIDAYS) ? calendar.HOLIDAYS : [], }; } } } return result; } catch (error) { console.error('Error parsing business calendars:', error); return {}; // Return empty object on error } } /** * Parses Control-M scheduling configuration and returns eligible run dates for a year * @param {Object} schedulingConfig - Scheduling configuration object * @param {number} year - Year to generate dates for (optional, defaults to current year) * @return {Date[]|Object} Array of dates when the job is eligible to run, or validation result if invalid */ function getEligibleRunDates(schedulingConfig, year = new Date().getFullYear()) { // Validate the configuration first const validationResult = validateConfig(schedulingConfig); if (!validationResult.isValid) { console.error('Invalid configuration:', validationResult.errors.join(', ')); return validationResult; // Return validation result instead of eligible dates } try { const eligibleDates = []; const startDate = new Date(year, 0, 1); // January 1st of specified year const endDate = new Date(year, 11, 31); // December 31st of specified year const calendars = parseBusinessCalendars(schedulingConfig.CALENDARS || {}); let currentDate = new Date(startDate); while (currentDate <= endDate) { if (isDateEligible(currentDate, schedulingConfig, calendars)) { eligibleDates.push(new Date(currentDate)); } currentDate.setDate(currentDate.getDate() + 1); } return eligibleDates; } catch (error) { console.error('Error processing eligible run dates:', error); return { isValid: false, errors: ['Runtime error: ' + error.message] }; } } /** * Determines if a specific date is eligible for job run based on scheduling rules * @param {Date} date - Date to check * @param {Object} config - Scheduling configuration * @param {Object} calendars - Parsed calendar definitions * @return {boolean} Whether the date is eligible */ function isDateEligible(date, config, calendars) { try { if (!config || Object.keys(config).length === 0) { return true; } // Check if the current month is in the MONTHS array (if specified) if (config.MONTHS && Array.isArray(config.MONTHS) && config.MONTHS.length > 0) { const monthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; const currentMonth = monthNames[date.getMonth()]; // Special case: if MONTHS includes "ALL", consider all months eligible if (!config.MONTHS.includes("ALL") && !config.MONTHS.includes(currentMonth)) { return false; // Not in the specified months, so the date is not eligible } } // If no weekdays or monthdays specified, return true after month check if ((!config.WEEKDAYS || config.WEEKDAYS.length === 0) && (!config.MONTHDAYS || config.MONTHDAYS.length === 0)) { return true; } const weekdaysResult = evaluateWeekdays(date, config, calendars); const monthdaysResult = evaluateMonthdays(date, config, calendars); // If only weekdays specified, return weekdaysResult if (config.WEEKDAYS && config.WEEKDAYS.length > 0 && (!config.MONTHDAYS || config.MONTHDAYS.length === 0)) { return weekdaysResult; } // If only monthdays specified, return monthdaysResult if (config.MONTHDAYS && config.MONTHDAYS.length > 0 && (!config.WEEKDAYS || config.WEEKDAYS.length === 0)) { return monthdaysResult; } // Both specified, use the relation if (config.WEEK_MONTH_RELATION === "AND") { return weekdaysResult && monthdaysResult; } else { // Default to "OR" return weekdaysResult || monthdaysResult; } } catch (error) { console.error('Error checking date eligibility:', error, 'for date:', formatDate(date)); return false; // Default to false on error } } /** * Optimized isWorkingDay function with caching * @param {Date} date - Date to check * @param {Object} calendar - The calendar object * @return {boolean} Whether the date is a working day */ const workingDayCache = new Map(); function isWorkingDay(date, calendar) { try { if (!calendar) return true; const dateStr = formatDate(date); const cacheKey = `${dateStr}-${calendar.WORKDAYS?.join(',')}-${calendar.HOLIDAYS?.join(',')}`; if (workingDayCache.has(cacheKey)) { return workingDayCache.get(cacheKey); } if (!calendar.WORKDAYS || !Array.isArray(calendar.WORKDAYS) || calendar.WORKDAYS.length === 0) { const result = !calendar.HOLIDAYS || !calendar.HOLIDAYS.includes(dateStr); workingDayCache.set(cacheKey, result); return result; } const jsDayOfWeek = date.getDay(); const result = calendar.WORKDAYS.includes(jsDayOfWeek) && (!calendar.HOLIDAYS || !calendar.HOLIDAYS.includes(dateStr)); workingDayCache.set(cacheKey, result); return result; } catch (error) { console.error('Error checking if working day:', error); return true; } } /** * Evaluates the WEEKDAYS configuration * @param {Date} date - Date to check * @param {Object} config - Scheduling configuration * @param {Object} calendars - Parsed calendar definitions * @return {boolean} Whether the date meets the WEEKDAYS criteria */ function evaluateWeekdays(date, config, calendars) { try { if (!config.WEEKDAYS || !Array.isArray(config.WEEKDAYS) || config.WEEKDAYS.length === 0) { return true; // No WEEKDAYS defined, so it's always true for this part } const weekdaysCalendarName = config.WEEKDAYS_CALENDAR; let weekdaysCalendar = null; // Handle missing calendar - all days are considered working days if (weekdaysCalendarName && calendars[weekdaysCalendarName]) { weekdaysCalendar = calendars[weekdaysCalendarName]; } const jsDayOfWeek = date.getDay(); // 0 (Sun) - 6 (Sat) const formattedDate = formatDate(date); // First check for exclusions (minus modifier) for (const weekdayRule of config.WEEKDAYS) { const ruleStr = String(weekdayRule).trim(); if (!ruleStr.startsWith('-')) continue; const value = ruleStr.substring(1); if (!isNaN(parseInt(value, 10))) { const dayOfWeek = parseInt(value, 10); if (dayOfWeek === jsDayOfWeek) { return false; // Date is explicitly excluded } } else if (value.toUpperCase().startsWith("D") && !value.includes("W")) { const dayOfWeekIndex = parseInt(value.substring(1), 10); if (!isNaN(dayOfWeekIndex) && dayOfWeekIndex > 0) { const nthWorkdayOfWeek = getNthWorkdayOfWeek(date, dayOfWeekIndex, weekdaysCalendar); if (nthWorkdayOfWeek && formatDate(nthWorkdayOfWeek) === formattedDate) { return false; // Date is explicitly excluded } } } else if (value.toUpperCase().startsWith("D") && value.includes("W")) { const parts = value.toUpperCase().match(/D(\d)W(\d)/); if (parts && parts.length === 3) { const dayOfWeek = parseInt(parts[1], 10); const weekOfMonth = parseInt(parts[2], 10); const currentWeekOfMonth = getWeekOfMonth(date); if (date.getDay() === dayOfWeek && currentWeekOfMonth === weekOfMonth) { return false; // Date is explicitly excluded } } } } // Then check for inclusions for (const weekdayRule of config.WEEKDAYS) { const ruleStr = String(weekdayRule).trim(); let shouldRun = false; let modifier = null; let value = ruleStr; if (["+", ">", "<"].includes(ruleStr[0])) { modifier = ruleStr[0]; value = ruleStr.substring(1); } else if (ruleStr[0] === '-') { continue; // Skip exclusion rules as they were handled above } if (!isNaN(parseInt(value, 10))) { const dayOfWeek = parseInt(value, 10); if (dayOfWeek === jsDayOfWeek) { shouldRun = weekdaysCalendar ? isWorkingDay(date, weekdaysCalendar) : true; } } else if (value.toUpperCase().startsWith("D") && !value.includes("W")) { const dayOfWeekIndex = parseInt(value.substring(1), 10); if (!isNaN(dayOfWeekIndex) && dayOfWeekIndex > 0) { const nthWorkdayOfWeek = getNthWorkdayOfWeek(date, dayOfWeekIndex, weekdaysCalendar); if (nthWorkdayOfWeek && formatDate(nthWorkdayOfWeek) === formattedDate) { shouldRun = true; } } } else if (value.toUpperCase().startsWith("D") && value.includes("W")) { const parts = value.toUpperCase().match(/D(\d)W(\d)/); if (parts && parts.length === 3) { const dayOfWeek = parseInt(parts[1], 10); const weekOfMonth = parseInt(parts[2], 10); const currentWeekOfMonth = getWeekOfMonth(date); if (date.getDay() === dayOfWeek && currentWeekOfMonth === weekOfMonth) { shouldRun = weekdaysCalendar ? isWorkingDay(date, weekdaysCalendar) : true; } } } if (modifier === "+") { if (parseInt(value, 10) === jsDayOfWeek) { return true; } continue; } else if (modifier === ">") { if (!shouldRun) { if (isWorkingDay(date, weekdaysCalendar)) { const targetDayOfMonth = parseInt(value, 10); const currentDayOfMonth = date.getDate(); const targetDate = new Date(date); if (currentDayOfMonth > targetDayOfMonth) { targetDate.setDate(targetDayOfMonth); if (!isWorkingDay(targetDate, weekdaysCalendar)) { const nextWorkingDay = getNextWorkingDay(targetDate, weekdaysCalendar); if (nextWorkingDay && formatDate(nextWorkingDay) === formatDate(date)) { return true; } } } } } else { return true; } } else if (modifier === "<") { if (!shouldRun) { if (isWorkingDay(date, weekdaysCalendar)) { const targetDayOfMonth = parseInt(value, 10); const currentDayOfMonth = date.getDate(); const targetDate = new Date(date); if (currentDayOfMonth < targetDayOfMonth) { targetDate.setDate(targetDayOfMonth); if (!isWorkingDay(targetDate, weekdaysCalendar)) { const previousWorkingDay = getPreviousWorkingDay(targetDate, weekdaysCalendar); if (previousWorkingDay && formatDate(previousWorkingDay) === formatDate(date)) { return true; } } } } } else { return true; } } else if (shouldRun) { return true; } } return false; } catch (error) { console.error('Error evaluating weekdays:', error, 'for date:', formatDate(date)); return false; // Default to false on error } } /** * Evaluates the MONTHDAYS configuration * @param {Date} date - Date to check * @param {Object} config - Scheduling configuration * @param {Object} calendars - Parsed calendar definitions * @return {boolean} Whether the date meets the MONTHDAYS criteria */ function evaluateMonthdays(date, config, calendars) { try { if (!config.MONTHDAYS || !Array.isArray(config.MONTHDAYS) || config.MONTHDAYS.length === 0) { return true; // No MONTHDAYS defined, so it's always true for this part } const monthCalendarName = config.MONTH_CALENDAR; let monthCalendar = null; // Handle missing calendar - all days are considered working days if (monthCalendarName && calendars[monthCalendarName]) { monthCalendar = calendars[monthCalendarName]; } const dayOfMonth = date.getDate(); const formattedDate = formatDate(date); // First check for exclusions (minus modifier) for (const monthdayRule of config.MONTHDAYS) { const ruleStr = String(monthdayRule).trim(); if (!ruleStr.startsWith('-')) continue; const value = ruleStr.substring(1); if (value === 'WORKDAYS') { if (isWorkingDay(date, monthCalendar)) { return false; // Exclude all working days } } else if (!isNaN(parseInt(value, 10))) { const dayOfMonthRule = parseInt(value, 10); if (dayOfMonthRule === dayOfMonth) { return false; // Date is explicitly excluded } } else if (value.toUpperCase().startsWith("D")) { const dayOfMonthIndex = parseInt(value.substring(1), 10); if (!isNaN(dayOfMonthIndex) && dayOfMonthIndex > 0) { const nthWorkdayOfMonth = getNthWorkdayOfMonth(date, dayOfMonthIndex, monthCalendar); if (nthWorkdayOfMonth && formatDate(nthWorkdayOfMonth) === formattedDate) { return false; // Date is explicitly excluded } } } else if (value.toUpperCase().startsWith("L")) { const lastWorkdayOffset = parseInt(value.substring(1), 10); if (!isNaN(lastWorkdayOffset) && lastWorkdayOffset > 0) { const lastNthWorkdayOfMonth = getLastNthWorkdayOfMonth(date, lastWorkdayOffset, monthCalendar); if (lastNthWorkdayOfMonth && formatDate(lastNthWorkdayOfMonth) === formattedDate) { return false; // Date is explicitly excluded } } } } // Then check for inclusions for (const monthdayRule of config.MONTHDAYS) { const ruleStr = String(monthdayRule).trim(); let shouldRun = false; let modifier = null; let value = ruleStr; if (["+", ">", "<"].includes(ruleStr[0])) { modifier = ruleStr[0]; value = ruleStr.substring(1); } else if (ruleStr[0] === '-') { continue; // Skip exclusion rules as they were handled above } // Handle WORKDAYS keyword if (value === 'WORKDAYS') { shouldRun = isWorkingDay(date, monthCalendar); } else if (!isNaN(parseInt(value, 10))) { const dayOfMonthRule = parseInt(value, 10); if (dayOfMonthRule === dayOfMonth) { shouldRun = monthCalendar ? isWorkingDay(date, monthCalendar) : true; } } else if (value.toUpperCase().startsWith("D")) { const dayOfMonthIndex = parseInt(value.substring(1), 10); if (!isNaN(dayOfMonthIndex) && dayOfMonthIndex > 0) { const nthWorkdayOfMonth = getNthWorkdayOfMonth(date, dayOfMonthIndex, monthCalendar); if (nthWorkdayOfMonth && formatDate(nthWorkdayOfMonth) === formattedDate) { shouldRun = true; } } } else if (value.toUpperCase().startsWith("L")) { const lastWorkdayOffset = parseInt(value.substring(1), 10); if (!isNaN(lastWorkdayOffset) && lastWorkdayOffset > 0) { const lastNthWorkdayOfMonth = getLastNthWorkdayOfMonth(date, lastWorkdayOffset, monthCalendar); if (lastNthWorkdayOfMonth && formatDate(lastNthWorkdayOfMonth) === formattedDate) { shouldRun = true; } } } if (modifier === "+") { if (parseInt(value, 10) === dayOfMonth) { return true; } continue; } else if (modifier === ">") { if (!shouldRun) { if (isWorkingDay(date, monthCalendar)) { const targetDayOfMonth = parseInt(value, 10); const currentDayOfMonth = date.getDate(); const targetDate = new Date(date); if (currentDayOfMonth > targetDayOfMonth) { targetDate.setDate(targetDayOfMonth); if (!isWorkingDay(targetDate, monthCalendar)) { const nextWorkingDay = getNextWorkingDay(targetDate, monthCalendar); if (nextWorkingDay && formatDate(nextWorkingDay) === formatDate(date)) { return true; } } } } } else { return true; } } else if (modifier === "<") { if (!shouldRun) { if (isWorkingDay(date, monthCalendar)) { const targetDayOfMonth = parseInt(value, 10); const currentDayOfMonth = date.getDate(); const targetDate = new Date(date); if (currentDayOfMonth < targetDayOfMonth) { targetDate.setDate(targetDayOfMonth); if (!isWorkingDay(targetDate, monthCalendar)) { const previousWorkingDay = getPreviousWorkingDay(targetDate, monthCalendar); if (previousWorkingDay && formatDate(previousWorkingDay) === formatDate(date)) { return true; } } } } } else { return true; } } else if (shouldRun) { return true; } } return false; } catch (error) { console.error('Error evaluating monthdays:', error, 'for date:', formatDate(date)); return false; // Default to false on error } } /** * Gets the Nth working day of the week for a given date and calendar * @param {Date} date - The reference date (to determine the week) * @param {number} n - The Nth working day (1-based) * @param {Object} calendar - The calendar object * @return {Date|null} The date of the Nth working day, or null if not found */ function getNthWorkdayOfWeek(date, n, calendar) { try { let count = 0; const startOfWeek = new Date(date); // Go to Monday (1) of the current week since our calendars are business-oriented while (startOfWeek.getDay() !== 1) { startOfWeek.setDate(startOfWeek.getDate() - 1); } // Look through the entire week for (let i = 0; i < 7; i++) { const dayToCheck = new Date(startOfWeek); dayToCheck.setDate(startOfWeek.getDate() + i); // Check if it's a working day according to the calendar if (calendar && isWorkingDay(dayToCheck, calendar)) { count++; if (count === n) { return dayToCheck; } } } return null; } catch (error) { console.error('Error getting Nth workday of week:', error); return null; } } /** * Gets the Nth working day of the month for a given date and calendar * @param {Date} date - The reference date (to determine the month) * @param {number} n - The Nth working day (1-based) * @param {Object} calendar - The calendar object * @return {Date|null} The date of the Nth working day, or null if not found */ function getNthWorkdayOfMonth(date, n, calendar) { try { if (n <= 0) return null; const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); const lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); // Pre-calculate month days array for better performance const monthDays = []; let currentDate = new Date(firstDayOfMonth); while (currentDate <= lastDayOfMonth) { if (!calendar || isWorkingDay(currentDate, calendar)) { monthDays.push(new Date(currentDate)); } currentDate.setDate(currentDate.getDate() + 1); } return monthDays.length >= n ? monthDays[n - 1] : null; } catch (error) { console.error('Error getting Nth workday of month:', error); return null; } } /** * Gets the Nth to last working day of the month with optimized calculation * @param {Date} date - The reference date (month) * @param {number} n - The Nth to last working day (1-based, 1 is last) * @param {Object} calendar - The calendar object * @return {Date|null} The date of the Nth to last working day, or null if not found */ function getLastNthWorkdayOfMonth(date, n, calendar) { try { if (n <= 0) return null; const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); const lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); // Pre-calculate month days array for better performance const monthDays = []; let currentDate = new Date(firstDayOfMonth); while (currentDate <= lastDayOfMonth) { if (!calendar || isWorkingDay(currentDate, calendar)) { monthDays.push(new Date(currentDate)); } currentDate.setDate(currentDate.getDate() + 1); } const index = monthDays.length - n; return index >= 0 ? monthDays[index] : null; } catch (error) { console.error('Error getting last Nth workday of month:', error); return null; } } /** * Gets the next working day from a given date * @param {Date} date - The starting date * @param {Object} calendar - The calendar object * @return {Date|null} The next working day, or null if not found within a reasonable range */ function getNextWorkingDay(date, calendar) { try { let nextDay = new Date(date); nextDay.setDate(nextDay.getDate() + 1); // Start from the next day for (let i = 0; i < 31; i++) { // Limit search to a month // If no calendar is defined, all days are considered working days if (!calendar || isWorkingDay(nextDay, calendar)) { return nextDay; } nextDay.setDate(nextDay.getDate() + 1); } return null; } catch (error) { console.error('Error getting next working day:', error); return null; } } /** * Gets the previous working day from a given date * @param {Date} date - The starting date * @param {Object} calendar - The calendar object * @return {Date|null} The previous working day, or null if not found within a reasonable range */ function getPreviousWorkingDay(date, calendar) { try { let previousDay = new Date(date); previousDay.setDate(previousDay.getDate() - 1); // Start from the previous day for (let i = 0; i < 31; i++) { // Limit search to a month // If no calendar is defined, all days are considered working days if (!calendar || isWorkingDay(previousDay, calendar)) { return previousDay; } previousDay.setDate(previousDay.getDate() - 1); } return null; } catch (error) { console.error('Error getting previous working day:', error); return null; } } /** * Formats a Date object as YYYY-MM-DD * @param {Date} date - The Date object to format * @return {string} The formatted date string */ function formatDate(date) { try { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } catch (error) { console.error('Error formatting date:', error); return 'Invalid date'; } } /** * Gets the week number of the month for a given date (1-based numbering) * * Example calendar visualization: * April 2024 * Su Mo Tu We Th Fr Sa * 1 2 3 4 5 6 (Week 1) * 7 8 9 10 11 12 13 (Week 2) * 14 15 16 17 18 19 20 (Week 3) * 21 22 23 24 25 26 27 (Week 4) * 28 29 30 (Week 5) * * How the calculation works: * 1. For a date like April 15: * - dayOfMonth = 15 * - firstDayOfWeek = 1 (April 1st is Monday) * - adjustedDayOfMonth = 15 + 1 - 1 = 15 * - Week = Math.ceil(15 / 7) = Math.ceil(2.14) = 3 * * 2. For a date like March 1: * - dayOfMonth = 1 * - firstDayOfWeek = 5 (March 1st is Friday) * - adjustedDayOfMonth = 1 + 5 - 1 = 5 * - Week = Math.ceil(5 / 7) = Math.ceil(0.71) = 1 * * The formula dayOfMonth + firstDayOfWeek - 1 creates a continuous count * of days including empty days at start of month. Dividing by 7 and * rounding up gives us the week number. The -1 prevents off-by-one * errors when month starts on Sunday. * * @param {Date} date - The date to get the week number for * @return {number} The week number of the month (1-based) */ function getWeekOfMonth(date) { try { // Get first day of the month (e.g., April 1) const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); // Get the day of month (1-31) const dayOfMonth = date.getDate(); // Get what day of week the month starts on (0=Sunday, 1=Monday, etc.) const firstDayOfWeek = firstDayOfMonth.getDay(); // Adjust day of month by adding offset from start of week // This accounts for partial weeks at the start of the month const adjustedDayOfMonth = dayOfMonth + firstDayOfWeek - 1; // Calculate week number by dividing adjusted day by 7 and rounding up // Math.ceil ensures partial weeks count as full weeks return Math.ceil(adjustedDayOfMonth / 7); } catch (error) { console.error('Error getting week of month:', error); return 1; // Default to first week on error } } /** * Formats an array of dates into a readable string array * @param {Date[]|Object} results - Array of dates or validation result object * @return {string[]} Array of formatted date strings or error messages */ function formatResults(results) { if (!Array.isArray(results) && results.errors) { return results.errors; } return results.map(date => formatDate(date)); } /** * Test the scheduler with a sample configuration */ function testScheduler() { try { const config = new ScheduleConfig({ CALENDARS: { MFD9H: { WORKDAYS: [2, 3,], // Mon-Fri in JavaScript format (1-5) HOLIDAYS: ["2025-04-01"] }, FEDRAL: { WORKDAYS: [2, 3, 4, 5, 6], // Mon-Sat in JavaScript format (1-6) HOLIDAYS: ["2025-02-01", "2024-12-25", "2024-11-25"] } }, MONTHS_CALENDAR: "FEDRAL", MONTHDAYS: ["-D1", "D3"], // 2nd workday of each week (Tuesday) MONTHS: ["APR"] // Only run in March }); const validationResult = config.validate(); if (!validationResult.isValid) { return validationResult.errors; } const eligibleDates = config.getEligibleDates(2025); const formattedDates = formatResults(eligibleDates); console.log("Eligible dates:", formattedDates); return formattedDates; } catch (error) { console.error('Error in test scheduler:', error); return ['Error in test scheduler: ' + error.message]; } } // Export using CommonJS module.exports = { ScheduleConfig, utils: { validateConfig, formatDate, getWeekOfMonth }, dateCalculators: { getEligibleRunDates, isDateEligible, isWorkingDay, getNthWorkdayOfWeek, getNthWorkdayOfMonth, getLastNthWorkdayOfMonth, getNextWorkingDay, getPreviousWorkingDay }, evaluators: { evaluateWeekdays, evaluateMonthdays } };