Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Correct way to calculate recurring dates in C#

In my project I need to calculate dates for repeating events. At the beginning I just have a start date/time and the information how this event must repeat:

Every Day
Every Week
Every 2 Weeks
Every 3 Weeks
Every Month
Every 2 Months
...

What is the right way to do this? It should work correctly with different time zones and day saving time settings. I think I should just add days/weeks/month to the local DateTime and then convert it to UTC. But I'm not sure about this. What happens if I add a few days and this will be the time when we need to adjust our clocks forward one hour. In this case this time will not exists.

Below is the code I wrote, but I'm not sure that it works correctly in every case:

private static List<DateTime> FindOccurrences(DateTime localStart, Repeat repeat, int occurrences)
{
    var result = new List<DateTime> { localStart };
    switch (repeat)
    {
        case Repeat.Daily:
            for (int i = 1; i <= occurrences; i++)
                result.Add(localStart.AddDays(i));
            break;
        case Repeat.Every2Weeks:
            for (int i = 1; i <= occurrences; i++)
                result.Add(localStart.AddDays((7 * 2) * i));
            break;
        ...
    }
    return result;
}

public List<Event> CreateRepeating(string timeZone, Repeat repeat, int repeatEnds, DateTime localStart, int eventDuration)
{
    var events = new List<Event>();
    var occurrences = FindOccurrences(localStart, repeat, repeatEnds);
    foreach (var occurrence in occurrences)
    {
        var item = new Event();
        item.Start = occurrence.ToUtcTime(timeZone);
        item.End = occurrence.ToUtcTime(timeZone).AddMinutes(eventDuration);
        events.Add(item);
    }
    return events;
}

PS: All dates are stored in UTC format in the database.

like image 734
user1613797 Avatar asked Oct 14 '14 14:10

user1613797


2 Answers

Scheduling or calculating the dates of future events, especially recurring events, is a very complex subject. I've written about this a few times, though from the perspective of other languages (See: 1, 2, 3, 4).

I'm afraid this is too broad of a subject to give you the exact code to run. The details will be very specific to your application. But here are some tips.

In general:

  • Use UTC only for the projected moment in time that a single instance of the event is to occur.

  • Store the actual event in local time. Store the time zone id also.

  • Do not store the time zone offset. That should be looked up for each occurrence individually.

  • Project upcoming occurrence(s) of the event as UTC so you know how when to perform an action based on the event (or whatever makes sense for your scenario).

  • Decide what to do for daylight saving time, when an occurrence falls into a spring-forward gap, or a fall-back overlap. Your needs may vary, but a common strategy is to jump ahead of the spring gap, and choose the first occurrence in the fall. If you're not sure what I mean, refer to the dst tag wiki.

  • Think carefully about how to handle dates near the end of a month. Not all months have the same number of days, and calendar math is difficult. dt.AddMonths(1).AddMonths(1) is not necessarily the same as dt.AddMonths(2).

  • Stay on top of time zone data updates. Nobody can predict the future, and the governments of the world like to change things!

  • You need to retain the original local-time values of the schedule, so that you can re-project the UTC values of the occurrences. You should do this either periodically, or whenever you apply a time zone update (if you're tracking them manually). The timezone tag wiki has details about the different time zone databases and how they are updated.

  • Consider using Noda Time and IANA/TZDB time zones. They are much more suited for this type of work than the built in types and time zones Microsoft provides.

  • Be careful to avoid using the local time zone. You should have no calls to DateTime.Now, ToLocalTime, or ToUniversalTime. Remember, the local time zone is based on the machine where the code is running, and that should not impact the behavior of your code. Read more in The Case Against DateTime.Now.

  • If you are doing all of this to just kick off a scheduled job, you should probably take a look at a pre-canned solution, such as Quartz.NET. It is free, open source, highly functional, and covers a lot of edge cases you may not have thought about.

like image 144
Matt Johnson-Pint Avatar answered Sep 20 '22 05:09

Matt Johnson-Pint


Partial answer. I'd rather change the implementation to

  public enum TimePeriod {
    None = 0,
    Day = 1,
    // Just week, no "two weeks, three weeks etc."  
    Week = 2,
    Month = 3
  }

  public static class Occurrencies {
    // May be you want to convert it to method extension: "this DateTime from" 
    public static IEnumerable<DateTime> FindInterval(DateTime from,
                                                     TimePeriod period, 
                                                     int count) {
      while (true) {
        switch (period) {
          case TimePeriod.Day:
            from = from.AddDays(count); 

            break;  
          case TimePeriod.Week:
            from = from.AddDays(7 * count);    

            break;
          case TimePeriod.Month:
            from = from.AddMonths(count);    

            break;  
        } 

        yield return from;
      }
    }
  }

Use:

  // Get 5 2-week intervals 
  List<DateTime> intervals2Weeks = Occurrencies
    .FindInterval(DateTime.Now, TimePeriod.Week, 2)
    .Take(5)
    .ToList();

  // Get 11 3-month intervals 
  List<DateTime> intervals3Months = Occurrencies
    .FindInterval(DateTime.Now, TimePeriod.Month, 3)
    .Take(11)
    .ToList();
like image 41
Dmitry Bychenko Avatar answered Sep 21 '22 05:09

Dmitry Bychenko