Thursday, February 25, 2010

Infragistics WebSchedule and Daylight Savings Time

I’m going to assume if you have found this article and are reading it that you are familiar with the Infragistics WebSchedule control, so I’m not going to spend a lot of space describing its general usage.  However I do need to provide some details about how we are using the product so that you can compare our architecture to yours in an effort to adopt this Daylight Savings Time (DST) solution.  And if things go really well, perhaps somebody can even offer improvements to what I have done, it is very much a first draft and could probably stand for quite a bit of refactoring.  As it is, there are multiple points on any page using the calendar controls that must be addressed and this is a great opportunity to introduce defects.
So, enough about what’s wrong with this idea, and on to the idea.  Well, first, on to The Problem

Appointments are stored in the database in UTC time.  The WebScheduleInfo class (WSI) provides a TimeZoneOffset property.  I can detect and set my current offset, and when viewing a calendar all data is shifted to the local time zone. However the IG classes have no awareness of DST, so the application must manage the offset at each interaction with the calendar.
Suppose I am looking at my calendar for a future day, a day that is across the DST boundary.  I have set the WSI TimeZoneOffset to what it needs to be for today.  The WSI doesn’t calculate the DST offsets so appointments are displayed an hour off.  When creating (or updating) an appointment for a date that is across the boundary, the UTC time will be calculated wrong because it is using the offset as of today.   
We also found that the IG controls’ handling of all-day appointments very frustrating, because it is sensitive to the duration value on the appointment.  While the UI accepts start date/time and end date/time, what is stored is the start and duration (in seconds).  When the UI controls attempt to display an all-day event, they look at the day of the start date/time, but they also calculate an end day in order to support multiple-day all-day events.  On the day that DST starts, clocks move from 1:59:59am to 3:00:00am, so that day is only 23 hours long.  On the DST end date, that day is 25 hours long as clocks move from 1:59:59am back to 1:00:00am.  This causes the calendar controls’ all-day event logic to really miss a beat.
So, we found it necessary to intervene at certain critical points.
First we needed a reliable way to know what offset should be applied.  .NET provides the class TimeZoneInfo.  Proper usage and understanding of this class is important for building scheduling applications.  Class TimeZoneInfo has a property BaseUtcOffset that represents the difference in hours between this time zone and GMT, but this property is also not aware of DST.  Instead the key is to use the method GetUtcOffset(DateTime d) which returns the offset as it will be on the date provided in that time zone.  So in the Eastern US time zone GetUtcOffset (“1/1/2010”) returns 5 but GetUtcOffset (“5/1/2010”) returns 4 because May 1st is within the DST period.  The UTC offset depends not only on where you are, but when you are – your frame of reference.  
In our basic calendar page, the user can view the schedule for any day of any year, and can create and edit appointments on any of those days (just like any other basic calendar I guess).  So let’s look at 2 basic functions: viewing appointments, and setting appointments (which covers editing and creating).  This led us to look at 4 page events: Page_Load, WebScheduleInfo1_ActivityUpdating(object sender, CancelableActivityEventArgs e), WebScheduleInfo1_ActivityAdding(object sender, CancelableActivityEventArgs e), and WebScheduleInfo1_DataBinding(object sender, EventArgs e).
In Page_load we simply set the base TimeZoneOffset for our user based on data we know about them (we have stored the time zone for the school the user is associated with, however your application may derive this information however it needs to).
The ActivityUpdating and ActivityAdding events are fired after the user has committed the changes entered in the IG pop-up page, but before the data has been committed to the database.  Class CancelableActivityEventArgs provides access to the Activity object (e.Activity) and we can modify it on the way to being saved.  So, what we need to do is adjust the StartDateTimeUtc property that has been calculated by the IG controls, and set the correct UTC time based on the user’s time zone DTS offset.  I have a utility method to do just that, in either direction:
public static DateTime adjustActivityTime(TimeZoneInfo tz, DateTime theDate, DateTime referenceDate, bool convertToLocal)
{
    // if convertToLocal then ADD the offset – theDate is UTC (in the US this is a negative number)
    // if !convertToLocal then use offset * -1 to subtract hours – theDate is Local Time

    int multiplier = 1;
    if (!convertToLocal) multiplier = -1;

    return theDate.AddHours(tz.GetUtcOffset(referenceDate).TotalHours * multiplier);
}
So in the ActivtyUpdating or ActivityAdding, we adjust for DST as follows:
e.Activity.StartDateTimeUtc = new SmartDate(CalendarUtility.adjustActivityTime(TIMEZONE, e.Activity.StartDateTime.Value, e.Activity.StartDateTime.Value, false));
In this example the frame of reference is the date of the activity itself.  That is, we want to store the UTC as it will be on that day.  The proper question we asked was “What will be the UTC time at 3pm on May 1st 2010” and that is what is stored.
For viewing appointments, we also need to adjust for DTS as we page through dates in the view control.  (As I write this article another way to do this has come to mind but I haven’t tried it so I am not going to write about it LOL.)  Let’s stick to this not-quite-elegant solution that has made it through our QA department.
In the data binding event we have access to the collection of activities that it intends to display, and we can again adjust the data.  We found it necessary to make 2 types of modifications to what the IG controls will display: we control the start and end dates for all-day events, and we adjust the start times on other events. 
public static void CorrectActivitiesForDTS(ActivitiesCollection activities, TimeZoneInfo tz)
{
    foreach (Infragistics.WebUI.WebSchedule.Activity a in activities)
   {
        if (a.AllDayEvent)
        {
            a.StartDateTime = new SmartDate(a.StartDateTimeUtc.Year, a.StartDateTimeUtc.Month, a.StartDateTimeUtc.Day, 0, 0, 0);
            int plusnumdays = ((int)(a.Duration.TotalSeconds / 86400)) - 1;
            a.EndDateTime = new Infragistics.WebUI.Shared.SmartDate(a.StartDateTimeUtc.Year, a.StartDateTimeUtc.Month, a.StartDateTimeUtc.Day + plusnumdays, 23, 59, 59);
                }
        else
        {
        a.StartDateTime = new SmartDate(adjustActivityTime(tz, a.StartDateTimeUtc.Value, a.StartDateTime.Value, true));
        }
    }
}

For all-day events we simply do not rely on IG’s calculations of the period.  Yes, it is only 2 days out of the year where this matters but this is simple and effective and easy to maintain.  For shorter appointments, we adjust the displayed start time for DST using the same method we used when adding the appointment, only now, we reverse the DST offset.  Also notice one subtle detail – the frame of reference date is the local date/time, not the UTC date/time.  This is crucial for appointments at or near midnight, or at least within the local offset of midnight UTC.  As with all-day events this is only important at or near the DST boundary date but our QA department loves to poke at scenarios just like that and I’m sure at some point, some user will need to schedule an activity for just that time slot.  Or not, but if they do we are ready to handle it!
 This gives us a simple piece of code in the DataBind event:
CalendarUtility.CorrectActivitiesForDST(WebScheduleInfo1.Activities, TIMEZONE);
In summary the handling of DST turned out to be not as complicated as it seemed in the beginning.  We went through a number of iterations before finalizing on this solution, which, as I said, can probably be improved, especially considering we have multiple pages where I have to handle these events.  A more comprehensive solution would be better, perhaps in the next sprint I can find some time to evolve this code.

2 comments:

  1. Hey, I wanted to thank you for this bit of code. It has helped me from redoing everything you have here.

    ReplyDelete
  2. Thanks, I hope it works out for you!

    ReplyDelete