3

I ran into interesting issue with the following requirement: Test if a process had run in the same day, if not run the process. The dates are stored as DataTimeOffset.

My original approach was to:

  1. Convert both values to UTC, because these dates could have been created in different time zones and have different offsets.
  2. View the Date value of each value. This is done after converting to UTC because the Date method ignores the offset.

Most scenarios this worked but I came across one case that the logic would fail. If one of the values had a time that was close to the previous/next day so that the when converting to UTC it would change the date. If the other value didn't have a time that also converted to the previous/next day then the date comparison failed.

So I ended up with the following logic to include that scenario:

public static bool SameDate(DateTimeOffset first, DateTimeOffset second)
{
    bool returnValue = false;
    DateTime firstAdjusted = first.ToUniversalTime().Date;
    DateTime secondAdjusted = second.ToUniversalTime().Date;

    // If date is now a day ahead after conversion, than add/deduct a day to other date if that date hasn't advanced
    if (first.Date < firstAdjusted.Date && second.Date == secondAdjusted.Date)
        secondAdjusted = secondAdjusted.Date.AddDays(1);
    if (first.Date > firstAdjusted.Date && second.Date == secondAdjusted.Date)
        secondAdjusted = secondAdjusted.Date.AddDays(-1);

    if (second.Date < secondAdjusted.Date && first.Date == firstAdjusted.Date)
        firstAdjusted = firstAdjusted.Date.AddDays(1);
    if (second.Date > secondAdjusted.Date && first.Date == firstAdjusted.Date)
        firstAdjusted = firstAdjusted.Date.AddDays(-1);

    if (DateTime.Compare(firstAdjusted, secondAdjusted) == 0)
        returnValue = true;

    return returnValue;
}

Here is the Unit Tests that were failing that now pass:

 [TestMethod()]
 public void SameDateTest()
 {
 DateTimeOffset current = DateTimeOffset.Now;
 DateTimeOffset first = current;
 DateTimeOffset second = current;

 // 23 hours later, next day, with negative offset (EST) -- First rolls over
 first = new DateTimeOffset(2014, 1, 1, 19, 0, 0, new TimeSpan(-5, 0, 0));
 second = new DateTimeOffset(2014, 1, 2, 18, 0, 0, new TimeSpan(-5, 0, 0));
 Assert.IsFalse(Common.SameDate(first, second));

 // 23 hours earlier, next day, with postive offset -- First rollovers
 first = new DateTimeOffset(2014, 1, 1, 4, 0, 0, new TimeSpan(5, 0, 0));
 second = new DateTimeOffset(2014, 1, 2, 5, 0, 0, new TimeSpan(5, 0, 0));
 Assert.IsFalse(Common.SameDate(first, second));

 // 23 hours later, next day, with negative offset (EST) -- Second rolls over
 first = new DateTimeOffset(2014, 1, 2, 18, 0, 0, new TimeSpan(-5, 0, 0));
 second = new DateTimeOffset(2014, 1, 1, 19, 0, 0, new TimeSpan(-5, 0, 0));
 Assert.IsFalse(Common.SameDate(first, second));

 // 23 hours earlier, next day, with postive offset -- Second rolls over
 first = new DateTimeOffset(2014, 1, 2, 5, 0, 0, new TimeSpan(5, 0, 0));
 second = new DateTimeOffset(2014, 1, 1, 4, 0, 0, new TimeSpan(5, 0, 0));
 Assert.IsFalse(Common.SameDate(first, second));
}

My gut feeling is that there is a cleaner approach than to increment/decrement based on the other value. Is there a better approach?

The primary criteria:

  1. Adjust the both dates to have the same offset.
  2. Return true only if both first and second dates occur in the same calendar day, not within 24 hours.
XikiryoX
  • 1,898
  • 1
  • 12
  • 33
Josh
  • 8,219
  • 13
  • 76
  • 123
  • 3
    What you've done is a sure sign that the approach is poor. From my experience, the best way to deal with date/times is to store them as UTC or with a specific offset. Any "math" work goes away and now you can convert to/from using the standard datetime API for UI display, etc. – Trevor Ash Nov 23 '14 at 19:18
  • They are stored with offsets, but the offsets aren't always the same. For example we normally store the offset for the Eastern Time zone, but depending on Daylight Savings, it could either be -4 or -5. – Josh Nov 23 '14 at 19:30
  • In your example here with the Eastern Time zone with and without Daylight Savings, this should actually be represented as two different time zones: EST (UTC-0500) in winter and EDT (UTC-0400) in summer. – Svein Fidjestøl Nov 23 '14 at 19:42
  • I have -5 because the date occurs in the Winter so it would be EST. I don't have EDT represented. – Josh Nov 23 '14 at 19:45
  • "Most scenarios this worked but I came across one case that the logic would fail. If one of the values had a time that was close to the previous/next day so that the when converting to UTC it would change the date." --> if the value is already of type DateTimeOffset, you don't need to convert them to UTC before you compare. The offset values will be evaluated as part of the comparison. Can you give a concrete example of a failure case? – Zoomzoom Nov 25 '14 at 03:00
  • The problem is when you want to compare just the Date value. The Date method ignores the offset. So if you have values with different offsets, then it will not be comparing them adjusted to UTC. That is why you need to convert first then you can use the Date method. – Josh Nov 25 '14 at 15:00
  • Why has this been down-voted as a question? The OP's first attempt shows basic understanding of the problem domain and he is dealing with a subtlety that catches everyone whose application is used across time zones. His first draft solution isn't good but if it *was* good he wouldn't be looking for input on SO. It is far from a stupid question. – Peter Wone Dec 02 '14 at 23:57

5 Answers5

4

Adjust the one of the dates for the difference in both dates:

public static bool SameDate(DateTimeOffset first, DateTimeOffset second)
{
    bool returnValue = false;
    DateTime firstAdjusted = first.ToUniversalTime().Date;
    DateTime secondAdjusted = second.ToUniversalTime().Date;

    // calculate the total diference between the dates   
    int diff = first.Date.CompareTo(firstAdjusted) - second.Date.CompareTo(secondAdjusted);
    // the firstAdjusted date is corected for the difference in BOTH dates.
    firstAdjusted = firstAdjusted.AddDays(diff);

    if (DateTime.Compare(firstAdjusted, secondAdjusted) == 0)
        returnValue = true;

    return returnValue;
}

In this function I am asuming that the offset will never be more than 24 hours. IE the difference between a date and it's adjusted date will not be two or more days. If this is not the case, then you can use time span comparison.

Community
  • 1
  • 1
martijn
  • 1,417
  • 1
  • 16
  • 26
  • I think you nailed it. I need to run it through a few more Unit Tests to confirm and then I can accept your answer. BTW, I don't think the offset could be greater than 24 because, because offset range from UTC is between -12 hours to 14 hours ( http://en.wikipedia.org/wiki/List_of_UTC_time_offsets ) – Josh Dec 01 '14 at 20:54
  • Subtracting `CompareTo` return values is plain wrong – [the only thing specified](http://msdn.microsoft.com/en-us/library/5ata5aya.aspx) about the return value is the sign (negative/zero/positive), not the absolute value. – Mormegil Dec 01 '14 at 21:59
  • True, but what that means is that the CompareTo function makes no statement about the size of the difference, only that there is a difference. The results will always be -1,0 or 1. – martijn Dec 02 '14 at 08:20
  • Negative, that’s the point: CompareTo might return -10, or Int32.MaxValue, or (more probably) something resembling `this - value`. (It obviously _usually_ returns -1/0/1, but it would be within the spec to do something completely different.) – Mormegil Dec 03 '14 at 15:47
  • But that is mostly nitpicking, since the code could be rewritten with the same logic, only with corrected implementation. My bigger issue with this solution is that it returns false for two representations of the _exact same time instant_ (just represented in different timezones), which is obviously incorrect. (See my answer.) – Mormegil Dec 03 '14 at 16:00
  • I'll give you the usage, maybe that would help clarify how I'm using this function. I have a Windows Service that polls every 10 minutes to determine if a "daily" job should run. It should run at 2AM ET (depending on polling it could run at 2:10AM). So I need to compare current date with last run (both are datetimeoffset values). Since it can only run once a day, I need to check if it has run today, if not than run. I'm using this SameDate function to determine if the program should run. If you think there is a better solution, I can post this as a separate question since this is answered. – Josh Dec 05 '14 at 14:46
3

The general methodology you describe (convert to common time-zone then compare date portion) is reasonable. The problem here is actually one of deciding on the frame of reference. You have arbitrarily chosen UTC as your frame of reference. At first gloss it doesn't matter so long as they are compared in the same time zone, but as you have found this can put them on either side of a day boundary.

I think you need to refine your specification. Ask yourself which of the following you are trying to determine.

  • Whether the values occur on the same calendar day for a specified time zone.
  • Whether the values are no more than 12 hours apart (+/- 12hrs is a 24hr period).
  • Whether the values are no more than 24 hours apart.

It might also be something else. The definition as implemented (but rejected by you) is "Whether the values occur on the same UTC calendar day".

Peter Wone
  • 17,965
  • 12
  • 82
  • 134
  • I'm looking to test for the same calendar day with both dates having the same time zone or offset. If converting to ET is easier than UTC, that works for me. Most often the values will be ET with an offset of -4 or -5 depending on Daylight Savings. – Josh Nov 26 '14 at 00:31
2

First of all, you need to clear up some confusion what the program should do exactly. For two general timestamps in two general time zones (two DateTimeOffset instances without specific limitations), there is no such concept as “the calendar day”. Each time zone has its own calendar day. For instance, we could have two instances of DateTimeOffset, named first and second, and they have different offsets. Let’s visualize the time axis, and mark the specific time instants to which the DateTimeOffset instances refer with * and the calendar day in the respective time zone (i.e. the interval between 0:00 and 23:59 in the specific timezone) with |__|. It could look like this:

first:  ........|___________________*__|.......
second: ...|______*_______________|............

When in the timezone of first, the second event happened during the same calendar day (between 2–3 am). When in the timezone of second, the first event happened during the following calendar day (between 1–2 am).

So it is obvious the question needs clarification and probably a bit of scope limitation. Are those really generic timezones, or are they timezones of the same place, differing potentially only in the daylight saving time? In that case, why don’t you just ignore the timezone? E.g. it does not matter that on November 2nd 2014, between 00:10 and 23:50, the UTC offset has changed (EDT->ET) and the two instants are separated by more than 24 hrs of time: new DateTimeOffset(2014, 11, 02, 00, 10, 00, new TimeSpan(-4, 0, 0)).Date == new DateTimeOffset(2014, 11, 02, 23, 50, 00, new TimeSpan(-5, 0, 0)).Date. Basically, this is what martijn tries to do, but in a very complicated way. When you would try just

public static bool SameDateSimple(DateTimeOffset first, DateTimeOffset second)
{
    return first.Date == second.Date;
}

it would work for all your abovementioned unit tests. And, also, this is what most humans would call “the same calendar day” when it is guaranteed the two instances refer to times at a single place.

Or, if you are really comparing two “random” timezones, you have to choose your reference timezone. It could be UTC as you tried initially. Or, it might be more logical from a human standpoint to use the first timezone as the reference (you could also choose the second one, it would give different results, but both variants are “equally good”):

public static bool SameDateGeneral(DateTimeOffset first, DateTimeOffset second)
{
    DateTime secondAdjusted = second.ToOffset(first.Offset).Date;
    return first.Date == secondAdjusted.Date;
}

This does not work for some of the abovementioned tests, but is more general in the sense it works “correctly” (in some sense) for two random timezones: If you try first = new DateTimeOffset(2014, 1, 2, 0, 30, 0, new TimeSpan(5, 0, 0)), second = new DateTimeOffset(2014, 1, 1, 23, 30, 0, new TimeSpan(4, 0, 0)), the simple SameDateSimple returns false (as does martijn’s), even though these two instances refer to the exact same moment in time (both are 2014-01-01 19:30:00Z). SameDateGeneral returns true here correctly.

Mormegil
  • 7,955
  • 4
  • 42
  • 77
  • Currently the two scenarios that we would see in our system is one that both DateTimeOffsets are Eastern Time (-4 or -5 depending on time of year). The other is that one date is ET and the other is UTC. I wanted to guard against a future scenario when we have a data center that is not in ET, which could introduce an offset that is not ET or UTC. – Josh Dec 03 '14 at 15:08
0

First off, you have an error in your UnitTest.

    [TestMethod()]
    public void SameDateTest()
    {
        DateTimeOffset current = DateTimeOffset.Now;
        DateTimeOffset first = current;
        DateTimeOffset second = current;

        // 23 hours later, next day, with negative offset (EST) -- First rolls over
        first = new DateTimeOffset( 2014, 1, 1, 19, 0, 0, new TimeSpan( -5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 2, 18, 0, 0, new TimeSpan( -5, 0, 0 ) );
        Assert.IsTrue( DateTimeComparison.Program.SameDate( first, second ) );

        // 23 hours earlier, next day, with positive offset -- First rollovers
        first = new DateTimeOffset( 2014, 1, 1, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 2, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
        Assert.IsFalse( DateTimeComparison.Program.SameDate( first, second ) );

        // 23 hours later, next day, with negative offset (EST) -- Second rolls over
        first = new DateTimeOffset( 2014, 1, 2, 18, 0, 0, new TimeSpan( -5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 1, 19, 0, 0, new TimeSpan( -5, 0, 0 ) );
        Assert.IsTrue( DateTimeComparison.Program.SameDate( first, second ) );

        // 23 hours earlier, next day, with positive offset -- Second rolls over
        first = new DateTimeOffset( 2014, 1, 2, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 1, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
        Assert.IsFalse( DateTimeComparison.Program.SameDate( first, second ) );
    }

This is the corrected test. Your first test should return "True", as should your third posted tests. Those DateTimeOffsets being compared are on the same UTC date. Only test case two and four should return "False", as those DateTimeOffsets are in fact on 2 different dates.

Second, you can simplify your SameDate() function to this:

    public static bool SameDate( DateTimeOffset first, DateTimeOffset second )
    {
        bool returnValue = false;
        DateTime firstAdjusted = first.UtcDateTime;
        DateTime secondAdjusted = second.UtcDateTime;

        if( firstAdjusted.Date == secondAdjusted.Date )
            returnValue = true;

        return returnValue;
    }

As all you are interested in is if first.Date and second.Date are actually on the same UTC date, this will get the job done without an extra cast/conversion to UTC.

Third, you can test your test cases using this complete program:

using System;

namespace DateTimeComparison
{
   public class Program
   {
       static void Main( string[] args )
       {
           DateTimeOffset current = DateTimeOffset.Now;
           DateTimeOffset first = current;
           DateTimeOffset second = current;

           // 23 hours later, next day, with negative offset (EST) -- First rolls over
           first = new DateTimeOffset( 2014, 1, 1, 19, 0, 0, new TimeSpan( -5, 0, 0 ) );
           second = new DateTimeOffset( 2014, 1, 2, 18, 0, 0, new TimeSpan( -5, 0, 0 ) );
           if( false == SameDate( first, second ) ) {
               Console.WriteLine( "Different day values!" );
           } else {
               Console.WriteLine( "Same day value!" );
           }

           // --Comment is wrong -- 23 hours earlier, next day, with positive offset -- First rollovers
           first = new DateTimeOffset( 2014, 1, 1, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
           second = new DateTimeOffset( 2014, 1, 2, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
           if( false == SameDate( first, second ) ) {
               Console.WriteLine( "Different day values!" );
           } else {
               Console.WriteLine( "Same day value!" );
           }

           // 23 hours later, next day, with negative offset (EST) -- Second rolls over
           first = new DateTimeOffset( 2014, 1, 2, 18, 0, 0, new TimeSpan( -5, 0, 0 ) );
           second = new DateTimeOffset( 2014, 1, 1, 19, 0, 0, new TimeSpan( -5, 0, 0 ) );
           if( false == SameDate( first, second ) ) {
               Console.WriteLine( "Different day values!" );
           } else {
               Console.WriteLine( "Same day value!" );
           }


           // --Comment is wrong --  23 hours earlier, next day, with positive offset -- Second rolls over
           first = new DateTimeOffset( 2014, 1, 2, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
           second = new DateTimeOffset( 2014, 1, 1, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
           if( false == SameDate( first, second ) ) {
               Console.WriteLine( "Different day values!" );
           } else {
               Console.WriteLine( "Same day value!" );
           }
       }

       public static bool SameDate( DateTimeOffset first, DateTimeOffset second )
       {
           bool returnValue = false;
           DateTime firstAdjusted = first.UtcDateTime;
           DateTime secondAdjusted = second.UtcDateTime;

           if( firstAdjusted.Date == secondAdjusted.Date )
               returnValue = true;

           return returnValue;
       }          
    }
}

Set a break point wherever you like and run this short program in the debugger. This will show you that test case 2 and test case 4 are in fact more than 2 days apart by UTC time and therefore should expect false. Furthermore, it will show test case 1 and test case 3 are on the same UTC date and should expect true from a properly functioning SameDate().

If you want your second and fourth test cases to be 23 hours apart on the same date, then for test case two, you should use:

        // 23 hours earlier, next day, with positive offset -- First rollovers
        first = new DateTimeOffset( 2014, 1, 2, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 1, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
        Assert.IsTrue( DateTimeComparison.Program.SameDate( first, second ) );

And for test case four, you should use:

        // 23 hours earlier, next day, with positive offset -- Second rolls over
        first = new DateTimeOffset( 2014, 1, 2, 5, 0, 0, new TimeSpan( 5, 0, 0 ) );
        second = new DateTimeOffset( 2014, 1, 3, 4, 0, 0, new TimeSpan( 5, 0, 0 ) );
        Assert.IsTrue( DateTimeComparison.Program.SameDate( first, second ) );
StarPilot
  • 2,246
  • 1
  • 16
  • 18
  • You misunderstood the criteria, all four Unit Tests should be false. I want the method to return true only if both dates occur in the same calendar day, adjusting both dates to use the same offset. Because of that your simplified code doesn't work – Josh Nov 28 '14 at 15:22
  • But your test cases ARE NOT on the same date. A simple debug run demonstrates that with your test data, as is. Because of that, your test cases cannot pass with the given data. You have to adjust your test case data accordingly if you want all four test cases to pass `Assert.IsFalse()`. Your test case data is bugged, if that is your intention. Fix your "bugged" test case data and your code will pass as desired. – StarPilot Dec 01 '14 at 23:37
  • That is, they are not on the same date in UTC. If you are using that for your frame of reference, your unit tests are wrong. If you want to use a different frame of reference, such as EDT or EST, then you should convert all the datetimeoffsets to that frame of reference and then compare. The frame of reference matters when all you are chasing is the dates of two datetimes. Because one tick difference between the two datetimes/datetimeoffsets matters if it the the difference between midnight versus midnight-1 tick. – StarPilot Dec 01 '14 at 23:54
0

What about this function:

public static bool SameDate(DateTimeOffset first, DateTimeOffset second)
{
    return Math.Abs((first - second).TotalDays) < 1;
}

You can substract two dates (DateTimeOffset is smart and knows the timezone) and it will give you a range, a timespan. Then you can check if this range is +- 1 day.

Simon Mourier
  • 132,049
  • 21
  • 248
  • 298
  • Simon, the method should return true if both dates (adjusted to the same offset) occur in the same calendar day, not within 24 hours. – Josh Nov 28 '14 at 15:21
  • 1
    The concept of some absolute "same calenday day" has no meaning. If you need a 12 hours interval, change 1 to 0.5. – Simon Mourier Nov 28 '14 at 15:58
  • If you schedule a task in Windows Task Scheduler to run a program daily at 2AM, it is performing a calculation to see if it is the same calendar day or different day when it gets to 2AM. Not sure what you mean by "absolute", but a calendar day has meaning in that context. The same would apply to recurring daily calendar appointments in Outlook or other calendar programs. – Josh Nov 28 '14 at 16:11