@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
JavaScript
/**
* 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
}
};