Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Scheduling Future Dates

4.69/5 (55 votes)
1 Oct 2008CPOL5 min read 1   722  
Implement repetitive tasks at consistent intervals
Scheduler

Introduction

A few months back, I had a need to develop a service that performed a specific task on a regular basis. Not being content with a merely fulfilling the requirements, I decided that a more comprehensive (and infinitely more re-usable) solution was called for. This article discusses that solution.

The Problem

At it's face, the problem was a simple one - implement a method for predicting the date/time of a scheduled event. However, once I started defining the possible variations, it became apparent that more work would be required than initially planned. Some of the questions that presented themselves were:

  1. What intervals do I want to support?
  2. In the case of schedules that involved periods of days, weeks, or months, what time of day should an event be triggered?

The Solution

First, I made the decision to ignore seconds and milliseconds because when you're dealing with something that's on a repetitive schedule over a long period of time, you're usually dealing with rather loose timing constraints - one second one way or the other isn't going to hurt anything. Besides, you need to avoid bogging down the CPU with needless time checks every 5 milliseconds, so I think it's an acceptable trade-off. Given the open nature of the code, feel free to change this aspect if you desire.

Next, I had to come up with some usable "modes", so I decided on the following:

  • Hourly
  • Daily
  • Weekly
  • First day of the month
  • Last day of the month
  • Specific day of the month
  • Specific time interval

I know, some of them seem redundant, but hey use what you need, and discard the rest - grin.

Then, I decided that I would accept a date from which to determine a future date, based on the specified mode. At that point I came up with the prototype for the required method. Finally, I created a class that contains nothing but static methods. This frees us of the requirement to create an instance of the object before using its methods. All of the methods are public, but there are two that are especially helpful.

C#
public static DateTime CalculateNextTriggerTime(DateTime currentTime, 
						     TimeSpan processTime, 
						     ScheduleMode scheduleMode)

This method accepts the date from which we will be calculating the future date, a TimeSpan object that is used in various ways depending on which mode you use, and the Schedule mode itself. This is where the explanation of how it works gets a little tricky. By default, if you pass in a TimeSpan that's zeroed out (new TimeSpan(0,0,0,0,0)), the processTime will have no affect on the caluclated future time. With the exception of the SpecificInterval mode, the time of day for all future dates will be set to the top of the hour for the Hourly mode, and midnight for the remaining modes. The processTime value is used to modify those calculated future dates. Once again, the seconds and milliseconds properties are ignored. Each case in the switch statement is shown below, and I've decided that the code is commented well enough to not require additional descriptive text.

ScheduleMode.Hourly

The processTime.Minutes property is added to the calculated future date (remember, it defaults to the top of the hour). This allows you to specify an hourly schedule that happens at irregular times (like at hour:15, or hour:30).

C#
case ScheduleMode.Hourly:
{
    // if the time is earlier than the specified minutes
    if (nextTime.Minute < processTime.Minutes)
    {
        // simply add the difference
        nextTime = nextTime.AddMinutes(
                           processTime.Minutes - nextTime.Minute);
    }
    else
    {
        // subtract the minutes, seconds, and milliseconds - this allows
        // us to determine what the last hour was
        nextTime = nextTime.Subtract(new TimeSpan(0, 0,
                            nextTime.Minute,
                            nextTime.Second,
                            nextTime.Millisecond));

        // add an hour and the number of minutes
        nextTime = nextTime.Add(new TimeSpan(0, 1,
                            processTime.Minutes,
                                                                     0, 0));
    }
}
break;

ScheduleMode.Daily

The processTime.Hours and .Minutes properties are used to specify the time of the day at which you want to calculate. The .Days property is ignored.

C#
case ScheduleMode.Daily:
{
    // subtract the hour, minutes, seconds, and milliseconds
              // (essentially, makes it midnight of the current day)
    nextTime = nextTime.Subtract(new TimeSpan(0,
                        nextTime.Hour,
                        nextTime.Minute,
                        nextTime.Second,
                        nextTime.Millisecond));

    // add a day, and the number of hours:minutes after midnight
    nextTime = nextTime.Add(new TimeSpan(1, processTime.Hours,
                        processTime.Minutes, 0, 0));
}
break;

ScheduleMode.Weekly

The processTime.Days property specifies the numeric day of the week on which to calculate, while the .Hours and .Minutes properties are used to specify the time of the day.

C#
case ScheduleMode.Weekly:
{
    int daysToAdd = 0;
    // get the number of the week day

    int dayNumber = (int)nextTime.DayOfWeek;
    // if the process day isn't today (should only happen when the
              // service starts)
    if (dayNumber == processTime.Days)
    {
        // add 7 days
        daysToAdd = 7;
    }
    else
    {
        // determine where in the week we are
        // if the day number is greater than than the specified day
        if (dayNumber > processTime.Days)
        {
            // subtract the day number from 7 to get the number
                                 // of days to add
            daysToAdd = 7 - dayNumber;
        }
        else
        {
            // otherwise, subtract the day number from the
                                 // specified day
            daysToAdd = processTime.Days - dayNumber;
        }
    }
    // add the days
    nextTime = nextTime.AddDays(daysToAdd);

    // get rid of the seconds/milliseconds
    nextTime = nextTime.Subtract(new TimeSpan(0, nextTime.Hour,
                            nextTime.Minute,
                            nextTime.Second,
                            nextTime.Millisecond));

    // add the specified time of day
    nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
                        processTime.Minutes, 0, 0));
}
break;

ScheduleMode.FirstDayOfMonth

The processTime.Hours and .Minutes properties are used to specify the time of the day. The .Days property is ignored.

C#
case ScheduleMode.FirstDayOfMonth:
{
    // detrmine how many days in the month
    int daysThisMonth = DaysInMonth(nextTime);

    // for ease of typing
    int today = nextTime.Day;

    // if today is the first day of the month
    if (today == 1)
    {
        // simply add the number of days in the month
        nextTime = nextTime.AddDays(daysThisMonth);
    }
    else
    {
        // otherwise, add the remaining days in the month
        nextTime = nextTime.AddDays((daysThisMonth - today) + 1);
    }

    // get rid of the seconds/milliseconds
    nextTime = nextTime.Subtract(new TimeSpan(0,
                        nextTime.Hour,
                        nextTime.Minute,
                        nextTime.Second,
                        nextTime.Millisecond));

    // add the specified time of day
    nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
                        processTime.Minutes, 0, 0));
}
break;

ScheduleMode.LastDayOfMonth

The processTime.Hours and .Minutes properties are used to specify the time of the day. The .Days property is ignored.

C#
case ScheduleMode.LastDayOfMonth:
{
    // determine how many days in the month
    int daysThisMonth = DaysInMonth(nextTime);

    // for ease of typing
    int today = nextTime.Day;

    // if this is the last day of the month
    if (today == daysThisMonth)
    {
        // add the number of days for the next month
        int daysNextMonth = DaysInMonth(nextTime.AddDays(1));
        nextTime = nextTime.AddDays(daysNextMonth);
    }
    else
    {
        // otherwise, add the remaining days for this month
        nextTime = nextTime.AddDays(daysThisMonth - today);
    }

    // get rid of the seconds/milliseconds
    nextTime = nextTime.Subtract(new TimeSpan(0,
                        nextTime.Hour,
                        nextTime.Minute,
                        nextTime.Second,
                        nextTime.Millisecond));

    // add the specified time of day
    nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
                        processTime.Minutes, 0, 0));
}
break;

ScheduleMode.DayOfMonth

The processTime.Hours and .Minutes properties are used to specify the time of the day. The .Days property is ignored.

C#
case ScheduleMode.DayOfMonth:
{
    // account for leap year
    // assume we don't have a leap day
    int leapDay = 0;

    // if it's february, and a leap year and the day is 29
    if (nextTime.Month == 2 && !IsLeapYear(nextTime) &&
                  processTime.Days == 29)
    {
        // we have a leap day
        leapDay = 1;
    }

    int daysToAdd = 0;
    // if the current day is earlier than the desired day
    if (nextTime.Day < processTime.Days)
    {
        // add the difference (less the leap day)
        daysToAdd = processTime.Days - nextTime.Day - leapDay;
    }
    else
    {
        // otherwise, add the days not yet consumed (less the leap day)
        daysToAdd = (DaysInMonth(nextTime) - nextTime.Day) +
                          processTime.Days - leapDay;
    }

    // add the calculated days
    nextTime = nextTime.AddDays(daysToAdd);

    // get rid of the seconds/milliseconds
    nextTime = nextTime.Subtract(new TimeSpan(0,
                        nextTime.Hour,
                        nextTime.Minute,
                        nextTime.Second,
                        nextTime.Millisecond));

    // add the specified time of day
    nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
                        processTime.Minutes, 0, 0));
}
break;

ScheduleMode.SpecificInterval

The processTime object is the entire basis for calculated the future date.

C#
case ScheduleMode.SpecificInterval:
{
    // if we're past the 30-second mark, add a minute to the current time
    if (nextTime.Second >= 30)
    {
        nextTime = nextTime.AddSeconds(60 - nextTime.Second);
    }

    // since we don't care about seconds or milliseconds, zero these items out
    nextTime = nextTime.Subtract(new TimeSpan(0, 0, 0,
                        nextTime.Second,
                        nextTime.Millisecond));

    // now, we add the process time
    nextTime = nextTime.Add(processTime);
}
break;

Finally, before returning the newly calculated future date, the seconds and milliseconds are once again zeroed out, just to make sure the date/time is pure.

C#
// and subtract the seconds and milliseconds, just in case they were
// specified in the process time
nextTime = nextTime.Subtract(new TimeSpan(0, 0, 0,
                        nextTime.Second,
                        nextTime.Millisecond));

The next most useful function is how we determine equality. I was really kind of surprised (and even dismayed) at how unreasonably difficult it is to determine date equality. First, I simply tried comparing two DateTime objects, but if I ever got an "equal" state, it was by mere chance. Next, I tried comparing the Ticks property from the two DateTimes. Again, any "equal" state that resulted was just dumb luck. After trying to figure out what the hell was wrong - it struck me.

The internal representation of a DateTime is a floating point number. That's when I came up with my ultimate solution - convert each date to a 64-bit integer and compare the integers. Here are the methods:

C#
public static Int64 ConvertToInt64(DateTime date)
{
	string dateStr = date.ToString("yyyyMMddHHmm");
	Int64 dateValue = Convert.ToInt64(dateStr);
	return dateValue;
}


public static DateCompareState CompareDates(DateTime now, DateTime target)
{
	DateCompareState state = DateCompareState.Equal;

	now = now.Subtract(new TimeSpan(0, 0, 0, now.Second, now.Millisecond));
	target = target.Subtract(new TimeSpan(0, 0, 0, target.Second, target.Millisecond));

	Int64 nowValue = ConvertToInt64(now);
	Int64 targetValue = ConvertToInt64(target);

	if (nowValue < targetValue)
	{
		state = DateCompareState.Earlier;
	}
	else
	{
		if (nowValue > targetValue)
		{
			state = DateCompareState.Later;
		}
	}

	return state;
}

I used the DateTime.ToString() method to create a custom string representation of the date, converted the string to a Int64, and came up with numbers that could be reliably compared. Remember, we don't have to worry about seconds or milliseconds in this class, so a Int64 will always be big enough. If a need ever arises to require seconds and milliseconds, I could always switch to a decimal type.

The Sample Application

The sample application has two listboxes sitting side-by-side. The left-most one allows you to fire off a thread that calculates a future date 2 minutes in the future. You have to click the Start button to make it go, and it keeps going until you close the application. The right-most listbox displays examples of using all of the scheduling modes, and is populated immediately upon staring the application.

A BackgroundWorker object is used to continuously schedule an event, and is a perfect example of how you probably want to implement it in your own code. Here's the DoWork function (since it's fully commented, I won't bother with additional text about it in this article):

C#
private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
	// cast the sender to a BackgroundWorker object so we can access its properties
	BackgroundWorker worker = sender as BackgroundWorker;

	// set the current date/time
	DateTime nextTime = DateTime.Now;

	// set the interval - I do this because I see no value at all in re-allocating 
	// memory over and over again if it's not needed. For this demo, we use two 
	// minutes so we're not waiting for the next millenia to occur.
	TimeSpan interval = new TimeSpan(0, 0, m_triggerMinutes, 0, 0);

	// set our mode here to relieve typing elsewhere
	ScheduleMode mode = ScheduleMode.SpecificInterval;

	// we need to know when the comparison state changes
	DateCompareState compareState;

	// these variables allow us to remain responsive to the UI, and reduce load 
	// on the CPU by checking for a date/time match every second. If this code 
	// was being run in a service, you could probably afford to set sleepTime to 
	// 1000 which would further reduce CPU load.
	int tick = 0;
	int sleepTime = 250;
	int checkAt = 1000;

	// process until the thread has been cancelled
	while (!worker.CancellationPending)
	{

		// calculate our next trigger time
		nextTime = DateScheduler.CalculateNextTriggerTime(nextTime, interval,
                      mode);
		tick = 0;

		// check the time until the thread ghas been cancelled
		while (!worker.CancellationPending)
		{

			// if it's time to compare the times
			if (tick % checkAt == 0)
			{

				// compare the time
				compareState =  DateScheduler.CompareDates(
                                        DateTime.Now, nextTime);

				// set our tick monitor to 0
				tick = 0;

				// if the dates are equal, break out of this while loop
				if (compareState == DateCompareState.Equal)
				{
					break;
				}
			}

			// sleep
			Thread.Sleep(sleepTime);

			// and keep track of how long we slept
			tick += sleepTime;
		}

		// Perform your scheduled task (for this demo, we simply update the 
		// list box in the form)
		if (!worker.CancellationPending)
		{
			worker.ReportProgress(0);
		}
	}
}

As you can see, the source code is fairly well documented and there's several examples of using the class, so go forth and schedule to your hearts content.

History

10/01/2008: Original article.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)