Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET

Dynamic Holiday Date Calculator

4.91/5 (51 votes)
4 Jan 2006MIT5 min read 1   2.9K  
A class to calculate what date the configured holidays fall on in different years.

Image 1

Introduction

This class returns all of the holidays that occur in a one-year period. The definition of each holiday is specified in an XML configuration file. This class can be used in any instance where you need to present a list of upcoming holidays for any specific date. Because all the holidays are defined in an XML file, the application can be customized for different cultures and specific different uses.

The problem

In our organization, we are building an application to help owners of small businesses manage their own marketing programs. One feature of this application is to remind the user of the upcoming holidays so that they can plan ahead for sales and promotions based on those dates. When we came up with this solution, our objective was to create a way to set up the holidays that we want the application to use, and not have to worry about it ever again. The difficulty of this is that many holidays fall on different dates on different years, and that the rules determining what dates they fall on are varied.

The solution

We decided that it would be ideal to store all of the rules for all of the different holidays in an XML file, and then write a class that could interpret those rules and calculate the correct date for each holiday. To do this we had to separate out the holidays into different types which were calculated similarly to each other. Below are the different types of holidays that we dealt with and a sample XML configuration for each:

  • Those that occur on the same month and same day each year. (E.g. Groundhog day is always on February 2nd.)
    XML
    <Holiday name="Groundhog Day">
        <Month>2</Month>
        <Day>2</Day>
    </Holiday>
  • Those that always occur on a specific weekday with a specific week within a specific month. (E.g. Mothers’ day is always the second Sunday in May.)
    XML
    <Holiday name="Mothers' Day">
        <Month>5</Month>
        <DayOfWeek>0</DayOfWeek>
        <WeekOfMonth>2</WeekOfMonth>
    </Holiday>
  • Those that always occur on the first weekday on or after a specified date. (E.g. Tax day is always the first weekday on or after April 15th.)
    XML
    <Holiday name="Tax Day">
        <WeekdayOnOrAfter>
            <Month>4</Month>
            <Day>15</Day>
        </WeekdayOnOrAfter>
    </Holiday>
  • Those that always occur a specified number of days before or after another holiday. (E.g. Good Friday is always two days before Easter Sunday.) Note that the Holiday attribute in DaysAfterHoliday must reference the name of another holiday that is defined in the XML file. If you’re defining a holiday as coming before another holiday, just use a negative number in the Days property:
    XML
    <Holiday name="Good Friday">
        <DaysAfterHoliday Holiday="Easter">
            <Days>-2</Days>
        </DaysAfterHoliday>
    </Holiday>
  • Those that occur on a specified date but only on certain years. (E.g. in the United States, Inauguration day occurs on January 20 every four years.) Note that to use the EveryXYears node you must also include a StartYear:
    XML
    <Holiday name="Inauguration Day">
        <Month>1</Month>
        <Day>20</Day>
        <EveryXYears>4</EveryXYears>
        <StartYear>1940</StartYear>
    </Holiday>
  • Those that occur on a specified weekday in the last full week of a specified month. (E.g. Administrative Professionals day occurs on the Wednesday of the last full week of April.)
    XML
    <Holiday name="Administrative Assistants' Day">
        <LastFullWeekOfMonth>
            <Month>4</Month>
            <DayOfWeek>3</DayOfWeek>
        </LastFullWeekOfMonth>
    </Holiday>
  • Easter - The algorithm for calculating the date of Easter Sunday is too different from any other holiday, so it is treated as its own holiday type. (Note: This returns the "Western" date for Easter. An implementation for the Eastern Orthodox Easter would be a good addition.)
    XML
    <Holiday name="Easter">
        <Easter />
    </Holiday>

It is certainly true that not every day observed by all the people of the world can fit into these rules. Our objective was to meet 99% of the need. Of course, you could modify or extend this class to add additional capabilities.

Using the class

The constructor for the HolidayCalculator class takes two parameters. The first is the DateTime that you want to begin your search for holidays on. The second is the path to your XML configuration file. The class has no public methods. Rather, it has a property called OrderedHolidays that returns an ArrayList of Holiday objects in date order. Each Holiday object has two properties: Date and Name.

Using the sample application

Included in the code for download is a console application that simply asks the user to provide a date, then lists all of the holidays occurring in the following 12-month period (see figure 1).

The code

Below is the complete HolidayCalculator class:

[Editor Note: Line breaks used to avoid scrolling.]

C#
using System.Collections;
using System.Xml;

namespace JayMuntzCom
{
  public class HolidayCalculator
  {
    #region Constructor
    /// <summary>
    /// Returns all of the holidays occuring in the year following the
    /// date that is passed in the constructor. Holidays are defined in
    /// an XML file.
    /// </summary>

    /// <param name="startDate">The starting date for
    /// returning holidays. All holidays for one year after this date
    /// are returned.</param>
    /// <param name="xmlPath">The path to the XML file
    /// that contains the holiday definitions.</param>
    public HolidayCalculator(System.DateTime startDate, string xmlPath)
    {
      this.startingDate = startDate;
      orderedHolidays = new ArrayList();
      xHolidays = new XmlDocument();
      xHolidays.Load(xmlPath);
      this.processXML();
    }
    #endregion

    #region Private Properties
    private ArrayList orderedHolidays;
    private XmlDocument xHolidays;
    private DateTime startingDate;
    #endregion

    #region Public Properties

    /// <summary>
    /// The holidays occuring after StartDate listed in
    /// chronological order;
    /// </summary>
    public ArrayList OrderedHolidays
    {
      get { return this.orderedHolidays; }
    }
    #endregion

    #region Private Methods


    /// <summary>

    /// Loops through the holidays defined in the XML configuration file,
    /// and adds the next occurance into the OrderHolidays collection if
    /// it occurs within one year.
    /// </summary>
    private void processXML()
    {
      foreach (XmlNode n in xHolidays.SelectNodes("/Holidays/Holiday"))
      {
        Holiday h = this.processNode(n);
        if (h.Date.Year > 1)
          this.orderedHolidays.Add(h);
      }
      orderedHolidays.Sort();
    }


    /// <summary>
    /// Processes a Holiday node from the XML configuration file.
    /// </summary>
    /// <param name="n">The Holdiay node to process.</param>

    /// <returns></returns>
    private Holiday processNode(XmlNode n)
    {
      Holiday h = new Holiday();
      h.Name = n.Attributes["name"].Value.ToString();
      ArrayList childNodes = new ArrayList();
      foreach (XmlNode o in n.ChildNodes)
      {
        childNodes.Add(o.Name.ToString());
      }
      if (childNodes.Contains("WeekOfMonth"))
      {
        int m = Int32.Parse(
                  n.SelectSingleNode("./Month").InnerXml.ToString());
        int w = Int32.Parse(
                  n.SelectSingleNode("./WeekOfMonth").InnerXml.ToString());
        int wd = Int32.Parse(
                  n.SelectSingleNode("./DayOfWeek").InnerXml.ToString());
        h.Date = this.getDateByMonthWeekWeekday(m,w,wd,this.startingDate);
      }
      else if (childNodes.Contains("DayOfWeekOnOrAfter"))
      {
        int dow =
          Int32.Parse(n.SelectSingleNode("./DayOfWeekOnOrAfter/DayOfWeek").
                                                       InnerXml.ToString());
        if (dow > 6 || dow < 0)
          throw new Exception("DOW is greater than 6");
        int m =
          Int32.Parse(n.SelectSingleNode("./DayOfWeekOnOrAfter/Month").
                                                       InnerXml.ToString());
        int d =
          Int32.Parse(n.SelectSingleNode("./DayOfWeekOnOrAfter/Day").
                                                       InnerXml.ToString());
        h.Date = this.getDateByWeekdayOnOrAfter(dow,m,d, this.startingDate);
      }
      else if (childNodes.Contains("WeekdayOnOrAfter"))
      {
        int m =
          Int32.Parse(n.SelectSingleNode("./WeekdayOnOrAfter/Month").
                                                       InnerXml.ToString());
        int d =
          Int32.Parse(n.SelectSingleNode("./WeekdayOnOrAfter/Day").
                                                       InnerXml.ToString());
        DateTime dt = new DateTime(this.startingDate.Year, m, d);

        if (dt < this.startingDate)
          dt = dt.AddYears(1);
        while(dt.DayOfWeek.Equals(DayOfWeek.Saturday) ||
                                      dt.DayOfWeek.Equals(DayOfWeek.Sunday))
        {
          dt = dt.AddDays(1);
        }
        h.Date =dt;
      }
      else if (childNodes.Contains("LastFullWeekOfMonth"))
      {
        int m =
          Int32.Parse(n.SelectSingleNode("./LastFullWeekOfMonth/Month").
                                                     InnerXml.ToString());
        int weekday =
          Int32.Parse(n.SelectSingleNode("./LastFullWeekOfMonth/DayOfWeek").
                                                        InnerXml.ToString());
        DateTime dt = this.getDateByMonthWeekWeekday(m,5,weekday,
                                                      this.startingDate);

        if (dt.AddDays(6-weekday).Month == m)
          h.Date = dt;
        else
          h.Date = dt.AddDays(-7);
      }
      else if (childNodes.Contains("DaysAfterHoliday"))
      {
        XmlNode basis =
          xHolidays.SelectSingleNode("/Holidays/Holiday[@name='" +
             n.SelectSingleNode("./DaysAfterHoliday").Attributes["Holiday"].
                                                     Value.ToString() + "']");
        Holiday bHoliday = this.processNode(basis);
        int days =
          Int32.Parse(
             n.SelectSingleNode("./DaysAfterHoliday/Days").InnerXml.ToString());
        h.Date = bHoliday.Date.AddDays(days);
      }
      else if (childNodes.Contains("Easter"))
      {
        h.Date = this.easter();
      }
      else
      {
        if (childNodes.Contains("Month") && childNodes.Contains("Day"))
        {
          int m =
            Int32.Parse(n.SelectSingleNode("./Month").InnerXml.ToString());
          int d =
            Int32.Parse(n.SelectSingleNode("./Day").InnerXml.ToString());
          DateTime dt = new DateTime(this.startingDate.Year, m, d);
          if (dt < this.startingDate)
          {
            dt = dt.AddYears(1);
          }
          if (childNodes.Contains("EveryXYears"))
          {
            int yearMult =
              Int32.Parse(
                n.SelectSingleNode("./EveryXYears").InnerXml.ToString());
            int startYear =
                Int32.Parse(
                  n.SelectSingleNode("./StartYear").InnerXml.ToString());
            if (((dt.Year - startYear) % yearMult) == 0)
            {
              h.Date = dt;
            }
          }
          else
          {
            h.Date = dt;
          }
        }
      }
      return h;
    }


    /// <summary>

    /// Determines the next occurance of Easter (western Christian).
    /// </summary>
    /// <returns></returns>
    private DateTime easter()
    {
      DateTime workDate = this.getFirstDayOfMonth(this.startingDate);
      int y = workDate.Year;
      if (workDate.Month > 4)
        y = y+1;
      return this.easter(y);
    }


    /// <summary>
    /// Determines the occurance of Easter in the given year. If the
    /// result comes before StartDate, recalculates for the following
    /// year.
    /// </summary>

    /// <param name="y"></param>
    /// <returns></returns>
    private DateTime easter(int y)
    {
      int a=y%19;
      int b=y/100;
      int c=y%100;
      int d=b/4;
      int e=b%4;
      int f=(b+8)/25;
      int g=(b-f+1)/3;
      int h=(19*a+b-d-g+15)%30;
      int i=c/4;
      int k=c%4;
      int l=(32+2*e+2*i-h-k)%7;
      int m=(a+11*h+22*l)/451;
      int easterMonth =(h+l-7*m+114)/31;
      int  p=(h+l-7*m+114)%31;
      int easterDay=p+1;
      DateTime est = new DateTime(y,easterMonth,easterDay);
      if (est < this.startingDate)
        return this.easter(y+1);
      else
        return new DateTime(y,easterMonth,easterDay);
    }

    /// <summary>
    /// Gets the next occurance of a weekday after
    /// a given month and day in the
    /// year after StartDate.
    /// </summary>

    /// <param name="weekday">The day of the
    /// week (0=Sunday).</param>
    /// <param name="m">The Month</param>
    /// <param name="d">Day</param>

    /// <returns></returns>
    private DateTime getDateByWeekdayOnOrAfter(int weekday,
                              int m, int d, DateTime startDate)
    {
      DateTime workDate = this.getFirstDayOfMonth(startDate);
      while (workDate.Month != m)
      {
        workDate = workDate.AddMonths(1);
      }
      workDate = workDate.AddDays(d-1);

      while (weekday != (int)(workDate.DayOfWeek))
      {
        workDate = workDate.AddDays(1);
      }

      //It's possible the resulting date is before
      //the specified starting date.
      //If so we'll calculate again for the next year.
      if (workDate < this.startingDate)
        return this.getDateByWeekdayOnOrAfter(weekday,m,d,
                                         startDate.AddYears(1));
      else
        return workDate;
    }

    /// <summary>
    /// Gets the n'th instance of a day-of-week
    /// in the given month after StartDate
    /// </summary>
    /// <param name="month">The month the
    /// Holiday falls on.</param>

    /// <param name="week">The instance of
    /// weekday that the Holiday
    /// falls on (5=last instance in the month).</param>
    /// <param name="weekday">The day of
    /// the week that the Holiday falls
    /// on.</param>
    /// <returns></returns>

    private DateTime getDateByMonthWeekWeekday(int month, int week,
                                     int weekday, DateTime startDate)
    {
      DateTime workDate = this.getFirstDayOfMonth(startDate);
      while (workDate.Month != month)
      {
        workDate = workDate.AddMonths(1);
      }
      while ((int)workDate.DayOfWeek != weekday)
      {
        workDate = workDate.AddDays(1);
      }

      DateTime result;
      if (week == 1)
      {
        result =  workDate;
      }
      else
      {
        int addDays = (week*7)-7;
        int day = workDate.Day + addDays;
        if (day > DateTime.DaysInMonth(workDate.Year,
                                               workDate.Month))
        {
          day = day-7;
        }
        result = new  DateTime(workDate.Year,workDate.Month,day);
      }

      //It's possible the resulting date is
      //before the specified starting date.
      //If so we'll calculate again for the next year.
      if (result >= this.startingDate)
        return result;
      else
        return this.getDateByMonthWeekWeekday(month,week,
                                  weekday,startDate.AddYears(1));


    }

    /// <summary>
    /// Returns the first day of the month for the specified date.
    /// </summary>
    /// <param name="dt"></param>
    /// <returns></returns>

    private DateTime getFirstDayOfMonth(DateTime dt)
    {
      return new DateTime(dt.Year, dt.Month, 1);
    }
    #endregion

    #region Holiday Object
    public class Holiday : IComparable
    {
      public System.DateTime Date;
      public string Name;

      #region IComparable Members

      public int CompareTo(object obj)
      {
        if (obj is Holiday)
        {
          Holiday h = (Holiday)obj;
          return this.Date.CompareTo(h.Date);
        }
        throw new ArgumentException("Object is not a Holiday");
      }
      #endregion
    }
    #endregion
  }
}

Conclusion

I did a fair amount of searching online for an example of determining the dates of holidays without any luck. This surprised me since my gut tells me that this problem must come up frequently. Perhaps the perfect solution (much better than mine) is out there and I missed it. However, I think I may have also discovered why nobody has posted the solution to this problem before.

I'm certain that I have overlooked certain holidays and the methods for calculating them. I wrote this class specifically to meet the needs of the specific application I'm working on. For that reason, there's a good chance that it will not perfectly meet the needs of others. I suppose I have learnt that this is a tougher problem than I originally thought. I'm very interested in feedback related to what I've presented here. I'm particularly interested to know if this class (or just the ideas contained within) meets any needs that are out there. Also, if this problem has been written about before, I'd be interested to know about it.

Acknowledgements

Marcos J. Montes’s The American Secular Holiday Calendar was an invaluable resource for both getting a list of typical American holidays and for the algorithm for calculating the date of Easter.

History

  • 1st January, 2006
    • Changed "DateTime.Parse()" statements to "new DateTime()" to enable the code to work correctly under any System.Globalization.CultureInfo setting.
  • 17th September, 2005
    • Article submitted.

License

This article, along with any associated source code and files, is licensed under The MIT License