.NET Working Day Calculator

kaufmed
CERTIFIED EXPERT
Published:
Calculating holidays and working days is a function that is often needed yet it is not one found within the Framework. This article presents one approach to building a working-day calculator for use in .NET.
Often times in development shops which focus on line-of-business software, the need for determining next and previous working days--that is, days which are neither weekends nor observed holidays--exists. Developers may need to know the next day that a banking institution will process a payment, or the next business day for their company, or the most recent working day that has already passed. None of these are available in libraries of the Framework. However, with a few lines of code we can create our own such library for determining these days.

The code samples and the Visual Studio project referenced throughout this article will be written in C#. It is left to the reader as an exercise to convert the code to Visual Basic .NET should they desire to do so. The code is not exotic, so it should be easily converted using one of the online conversion tools such as Telerik's Code Converter. The complete project will be linked at the end of this article.

The two primary elements of determining whether or not an arbitrary day is a working day are determining whether or not we are dealing with a weekend or a holiday. Weekends are easy:  That bit actually is built into the Framework via the DateTime structure's DayOfWeek property. How do we deal with holidays, then? This part we must write ourselves.

The first step in determining how to calculate holidays would be to determine what holidays do we actually care about? For simplicity, I am going to stick with those dates which are commonly observed within the United States (U.S.):
 
  • New Year's Day
  • Martin Luther King, Jr. Day
  • President's Day
  • Memorial Day
  • Independence Day
  • Labor Day
  • Columbus Day
  • Veteran's Day
  • Thanksgiving Day
  • Christmas Day

A couple of these days, depending on the company's holiday policy, may actually enjoy additional days over the actual observed day. This will be discussed later in the article. A quick search of the Internet will tell us on which day each of the above holidays fall. What we notice is that some holidays always fall on the same numeric day of the same month (e.g. Christmas is always the 25th of December), and some holidays fall on the an ordinal (e.g. Labor Day is the first Monday of September). The former will be easy to craft:  Just create a new instance of a DateTime structure, and pass it the numeric day. For the latter, though, we will need a way of calculating ordinals. First, let us define an enum structure to define what ordinals we care about:
 
namespace WorkingDayCalculator.Core.Enums
                      {
                          public enum Ordinal
                          {
                              // The library uses these values in mathematical calculations. The values
                              // that are set are important and should not be changed unless the developer
                              // is going to modify the math calculations which rely on these values.
                      
                              Last = -1,
                              First = 0,
                              Second = 1,
                              Third = 2,
                              Fourth = 3,
                              Fifth = 4,
                          }
                      }

Open in new window


We will use this enum in our calculations for holidays like Labor Day. We have the items Last and Fifth defined, but we won't be using them for our calculations. They are included for those individuals who might like to know what the last Friday of January is or what the fifth Monday of September is.

Next, we will define a couple of utility functions for calculating ordinal dates, making use of the enum above:
 
using System;
                      using WorkingDayCalculator.Core.Enums;
                      
                      namespace WorkingDayCalculator.Core
                      {
                          public static class DateHelper
                          {
                              public static DateTime GetOrdinalDate(int year, Month month, Ordinal ordinal, DayOfWeek dayOfWeek)
                              {
                                  DateTime ordinalDate;
                      
                                  if (ordinal == Ordinal.Last)
                                  {
                                      DateTime startDate = (new DateTime(year, (int)month + 1, 1)).AddDays(-1);
                                      int offsetFromDayOfWeek = GetPastOffsetFromTarget(startDate.DayOfWeek, dayOfWeek);
                      
                                      ordinalDate = startDate.AddDays(-1 * offsetFromDayOfWeek);
                                  }
                                  else
                                  {
                                      DateTime startDate = new DateTime(year, (int)month, 1);
                                      int offsetFromDayOfWeek = GetFutureOffsetFromTarget(startDate.DayOfWeek, dayOfWeek);
                                      int weeksToAdd = (int)ordinal * 7;
                      
                                      ordinalDate = startDate.AddDays(offsetFromDayOfWeek + weeksToAdd);
                      
                                      if (ordinalDate.Month != (int)month)
                                      {
                                          throw new ArgumentException("Not enough days in month to satisfy ordinal requirement.");
                                      }
                                  }
                      
                                  return ordinalDate;
                              }
                      
                              public static int GetFutureOffsetFromTarget(DayOfWeek day, DayOfWeek target)
                              {
                                  return ((int)(DayOfWeek.Saturday - day) + (int)target + 1) % 7;
                              }
                      
                              public static int GetPastOffsetFromTarget(DayOfWeek day, DayOfWeek target)
                              {
                                  return ((int)day + (int)DayOfWeek.Saturday - (int)target + 1) % 7;
                              }
                          }
                      }

Open in new window


The GetOrdinalDate method will take in the different parts of a date as well as an Ordinal value, and it will calculate the date that matches that criteria. It does this by making use of the other two helper methods:  GetFutureOffsetFromTarget and GetPastOffsetFromTarget. What each of these methods do is perform a mathematical calculation of the difference in days between the current day of week (parameter) and the target date we are trying to reach. This difference is then added to the startDate to arrive at the result date. It is important to be aware of the fact that we are relying on the integer values of the DayOfWeek enum within our mathematical calculations. It is unlikely that Microsoft would change the values of the enum members, but we could certainly define our own enum to get around this concern. Also, if the Ordinal value that we passed in is Last, then our start date is figured by beginning with the first day of the next month, and then subtracting one day. Otherwise, we simply start with the first day of the target month.

Now that we know how to calculate specific numeric holidays, and now that we have a handy utility function for calculating ordinal dates, we can focus on each individual holiday. Searching the Internet, we can find the rules for each holiday. Then it's simply a matter of writing each of those rules in code. Let us look at New Year's Day and Labor Day as examples:
 
public static DateTime GetNewYearsDay(int year, HolidayOptions options = null)
                      {
                          DateTime holiday = new DateTime(year, (int)Holiday.NewYearsDayMonth, 1);
                       
                          holiday = AdjustIfObserved(holiday, options, Holidays.NewYearsDay);
                       
                          return holiday;
                      }
                      
                      public static DateTime GetLaborDay(int year, HolidayOptions options = null)
                      {
                          DateTime holiday = DateHelper.GetOrdinalDate(year, Holiday.LaborDayMonth, Ordinal.First, DayOfWeek.Monday);
                       
                          holiday = AdjustIfObserved(holiday, options, Holidays.LaborDay);
                       
                          return holiday;
                      }

Open in new window


In the above you can see that because New Year's Day is a specific numeric day, we simply pass that day value (i.e. 1) to the DateTime constructor. For Labor Day, however, we need to determine an ordinal value. We pass in the an Ordinal value of First since that is what the rule for Labor Day specifies. The remaining holidays are calculated in a similar fashion following either of these two approaches.

We next need to define methods that will allow us to test whether or not a date falls on a holiday. For the holidays themselves, we can define additional helper methods. Taking New Year's Day as an example:
 
public static bool IsNewYearsDay(DateTime date, HolidayOptions options = null)
                      {
                          DateTime holiday = GetNewYearsDay(date.Year, options);
                      
                          return (holiday == date.Date);
                      }

Open in new window


...we can see that we call the GetNewYearsDay method that we defined above, and we take the date it returns and compare it to the incoming date. If the date is identical, then we are dealing with New Year's Day. We define similar methods for the remaining holidays. We can then define an additional helper method:
 
public static bool IsHoliday(DateTime date, HolidayOptions options = null)
                      {
                          HolidayOptions safeOptions = options ?? new HolidayOptions();
                          Holidays holidaysToCheck = safeOptions.HolidaysToCheck;
                      
                          // Month check saves us a call to the function if we know that we're not in
                          //   the correct month anyway.
                      
                          // It's possible that we may have holidays that are checked for more frequently.
                          //   We could rearrange these in order of precedence/frequency, but that's probably
                          //   a micro-optimization if anything.
                      
                          return (date.Month == (int)Holiday.ChristmasDayMonth && holidaysToCheck.HasFlag(Holidays.ChristmasDay) && IsChristmasDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.ColumbusDayMonth && holidaysToCheck.HasFlag(Holidays.ColumbusDay) && IsColumbusDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.IndependenceDayMonth && holidaysToCheck.HasFlag(Holidays.IndependenceDay) && IsIndependenceDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.LaborDayMonth && holidaysToCheck.HasFlag(Holidays.LaborDay) && IsLaborDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.MartinLutherKingDayMonth && holidaysToCheck.HasFlag(Holidays.MartinLutherKingDay) && IsMartinLutherKingDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.MemorialDayMonth && holidaysToCheck.HasFlag(Holidays.MemorialDay) && IsMemorialDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.NewYearsDayMonth && holidaysToCheck.HasFlag(Holidays.NewYearsDay) && IsNewYearsDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.PresidentsDayMonth && holidaysToCheck.HasFlag(Holidays.PresidentsDay) && IsPresidentsDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.ThanksgivingDayMonth && holidaysToCheck.HasFlag(Holidays.ThanksgivingDay) && IsThanksgivingDay(date, safeOptions)) ||
                                  (date.Month == (int)Holiday.VeteransDayMonth && holidaysToCheck.HasFlag(Holidays.VeteransDay) && IsVeteransDay(date, safeOptions));
                      }

Open in new window


...that will check the incoming date against each defined holiday. We can order these methods however we like; short-circuiting in C# will ensure that once we find a match we will exit the conditional test. The month check at the start of each condition is a simple test to bypass going into the IsxxxxxDay methods needlessly. All of these helpers will be combined into our working day calculator.

We defined methods to calculate ordinal dates within a month, and we defined methods to calculate individual holidays. Next we will use these methods to define our working day calculators. We know that working days will not be weekends, and we know that working days will not be holidays. Using what we have already defined, we could construct methods as follows:
 
using System;
                      
                      namespace WorkingDayCalculator.Core
                      {
                          public static class WorkingDay
                          {
                              public static DateTime GetNextWorkingDay(DateTime date, HolidayOptions options = null)
                              {
                                  return GetNextWorkingDay(date, Holiday.IsHoliday, options);
                              }
                      
                              public static DateTime GetNextWorkingDay(DateTime date, Func<DateTime, HolidayOptions, bool> holidayCalculator, HolidayOptions options = null)
                              {
                                  DateTime workingDay = GetWorkingDayUsingOffset(date, 1, holidayCalculator, options);
                      
                                  return workingDay;
                              }
                      
                              public static DateTime GetPreviousWorkingDay(DateTime date, HolidayOptions options = null)
                              {
                                  return GetPreviousWorkingDay(date, Holiday.IsHoliday, options);
                              }
                      
                              public static DateTime GetPreviousWorkingDay(DateTime date, Func<DateTime, HolidayOptions, bool> holidayCalculator, HolidayOptions options = null)
                              {
                                  DateTime workingDay = GetWorkingDayUsingOffset(date, -1, holidayCalculator, options);
                      
                                  return workingDay;
                              }
                      
                              private static DateTime GetWorkingDayUsingOffset(DateTime date, int offset, Func<DateTime, HolidayOptions, bool> holidayCalculator, HolidayOptions options = null)
                              {
                                  DateTime workingDay = date.AddDays(offset);
                      
                                  while (workingDay.DayOfWeek == DayOfWeek.Saturday || workingDay.DayOfWeek == DayOfWeek.Sunday || holidayCalculator(workingDay, options))
                                  {
                                      workingDay = workingDay.AddDays(offset);
                                  }
                      
                                  return workingDay;
                              }
                          }
                      }

Open in new window


We essentially have two methods:  GetNextWorkingDay and GetPreviousWorkingDay. Each of these call the helper method GetWorkingDayUsingOffset which does the "heavy lifting" of calculating the target working day. Within this helper method, we check the value of workingDay to determine if it is a weekend or a holiday. The holiday check is performed by way of a delegate. This delegate allows us, should we desire, to alter the way holidays are determined. This is the reason for the overloads for GetNextWorkingDay and GetPreviousWorkingDay. Each of the overloads also takes a delegate. So while we have defined a class for all of the U.S. holidays, we could define a second class which dealt with the holidays of another country, or perhaps we have two classes where one class is federal holidays and one class is state holidays. The delegate gives us the flexibility to alter how the GetWorkingDayUsingOffset completes its job.

You probably noticed the HolidayOptions parameter on the GetNextWorkingDay and GetPreviousWorkingDay methods. The intent of this class is to store options that can further affect how we calculate holidays.

Some companies will observe holidays which fall on a weekend on either the previous Friday or the subsequent Monday. Depending on how we set the Observance property, holidays will be adjusted to match this property's value. For example, if we set the value to ObservanceType.PostWeekend and we are trying to determine the next working day after December 23, 2016, because Christmas falls on a Sunday, and because we have a value of PostWeekend, then Monday the 26th will be seen as a holiday, resulting in Tuesday the 27th being returned as the next working day.

Also, the HolidaysToCheck property presents a bit flag whereby we can skip over certain holidays when performing our holiday check. One could define all the known holidays in one class, and then he could simply create a bit mask to eliminate those holidays for which he did not want to check. It probably makes more sense to classify one's holidays by commonality, but the flexibility of having the bit flag is there for those who would prefer that approach.

I mentioned earlier that some holidays may actually be observed across multiple days. My organization, for example, treats Thanksgiving Day as a holiday as well as the Friday after. To account for this in the code above, one could define an additional holiday named DayAfterThanksgiving, for example. It would work like any other holiday we have defined. This would be the simplest way to handle such days. A more complicated way would be to modify the HolidayOptions class to include a setting that could be evaluated during the holiday calculations. Such an approach, however, would require additional coding to one or more of our classes.

As you can see, we can create a working day calculator with just a few classes. In the approach outlined by this article, we have added some additional flexibility that will allow us to extend the calculator to cover other countries' holidays, or to allow us to vary our holiday calculations based on differences between states or jurisdictions. With these classes, we can now determine dates such as when the next bank processing day will be or what day an employee will be off for a holiday.

The complete solution, including unit tests, can be downloaded from the following link:

https://filedb.experts-exchange.com/incoming/ee-stuff/8443-WorkingDayCalculator.zip
3
15,257 Views
kaufmed
CERTIFIED EXPERT

Comments (2)

Jim HornSQL Server Data Dude
CERTIFIED EXPERT
Most Valuable Expert 2013
Author of the Year 2015

Commented:
Very well written, and I can see how this can be very useful.  Voted Yes.
Most Valuable Expert 2011
Author of the Year 2014

Commented:

Have a question about something in this article? You can receive help directly from the article author. Sign up for a free trial to get started.