frenchie
frenchie

Reputation: 51937

Calculating start of day and start of month in UTC

I have users that can be in different timezones and I'm looking to determine the UTC value of the beginning of their days and their months. Inside an object, I have a method that attempts to do that; it looks like this:

private void SetUserStartTimesUTC()
{
    DateTime TheNow = DateTime.UtcNow.ConvertUTCTimeToUserTime(this.UserTimezoneID);

    DateTime TheUserStartDateUserTime = TheNow.Date;
    DateTime TheUserStartMonthUserTime = new DateTime(TheNow.Year, TheNow.Month, 1);
    DateTime TheUserEndMonthUserTime = TheUserStartMonthUserTime.AddMonths(1);

    this.UserStartOfDayUTC = TheUserStartDateUserTime.ConvertUserTimeToUTCTime(this.UserTimezoneID);
    this.UserStartOfMonthUTC = TheUserStartMonthUserTime.ConvertUserTimeToUTCTime(this.UserTimezoneID);
    this.UserEndOfMonthUTC = TheUserEndMonthUserTime.ConvertUserTimeToUTCTime(this.UserTimezoneID);
}

And this method depends on two other extension methods that do the conversions between a user's time and UTC time

public static DateTime ConvertUserTimeToUTCTime(this DateTime TheUserTime, string TheTimezoneID)
{
    TimeZoneInfo TheTZ = TimeZoneInfo.FindSystemTimeZoneById(TheTimezoneID);
    DateTime TheUTCTime = new DateTime();

    if (TheTZ != null)
    {
        DateTime UserTime = new DateTime(TheUserTime.Year, TheUserTime.Month, TheUserTime.Day, TheUserTime.Hour, TheUserTime.Minute, TheUserTime.Second);
        TheUTCTime = TimeZoneInfo.ConvertTimeToUtc(UserTime, TheTZ);
    }

    return TheUTCTime;
}

public static DateTime ConvertUTCTimeToUserTime(this DateTime TheUTCTime, string TheTimezoneID)
{
    TimeZoneInfo TheTZ = TimeZoneInfo.FindSystemTimeZoneById(TheTimezoneID);
    DateTime TheUserTime = new DateTime();

    if (TheTZ != null)
    {
        DateTime UTCTime = new DateTime(TheUTCTime.Year, TheUTCTime.Month, TheUTCTime.Day, TheUTCTime.Hour, TheUTCTime.Minute, 0, DateTimeKind.Utc);
        TheUserTime = TimeZoneInfo.ConvertTime(UTCTime, TheTZ);
    }

    return TheUserTime;
}

Now I've been dealing with timezone issues for a while and I know that timezone issues can introduce off-by-one bugs that can be hard to detect.

Does my implementation of timezones seem to be correct or is there an edge case that will create some sort of off-by-one bug?

Thanks for your suggestions.

Upvotes: 0

Views: 4349

Answers (1)

Jon Skeet
Jon Skeet

Reputation: 1500535

Your methods seem needlessly complicated, to be honest.

Why would you have a parameter called TheUTCTime and then create a UTC version of it? Shouldn't it already have a Kind of UTC? Even if it didn't, you would be better off using DateTime.SpecifyKind - currently when converting one way you wipe out the seconds, whereas converting the other way you don't... in both cases you wipe out any sub-second values.

Also:

  • TimeZoneInfo.FindSystemTimeZoneById never returns null
  • Returning new DateTime() (i.e. January 1st 0001 AD) if the time zone can't be found seems like a really poor way of indicating an error
  • There's no need to have a local variable in your conversion methods; just return the result of calling ConvertTime directly
  • Your "end of month" is really "start of the next month"; that may be what you want, but it's not clear.

Personally I would strongly advise you to avoid the BCL DateTime for all of this entirely. I'm entirely biased being the main author, but I'd at least hope that you'd find Noda Time more pleasant to work with... it separates out the idea of "date with no time component", "time with no date component", "local date and time with no specific time zone" and "date and time in a particular time zone"... so the type system helps you to only do sensible things.

EDIT: If you really have to do this within the BCL types, I'd write it like this:

private void SetUserStartTimesUTC()
{
    DateTime nowUtc = DateTime.UtcNow;
    TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(UserTimeZoneID);

    // User-local values, all with a Kind of Unspecified.
    DateTime now = TimeZoneInfo.ConvertTime(nowUtc, zone);
    DateTime today = now.Date;
    DateTime startOfThisMonth = todayUser.AddDays(1 - today.Day);
    DateTime startOfNextMonth = startOfThisMonth.AddMonths(1);

    // Now convert back to UTC... see note below
    UserStartOfDayUTC = TimeZoneInfo.ConvertTimeToUtc(today, zone);
    UserStartOfMonthUTC = TimeZoneInfo.ConvertTimeToUtc(startOfThisMonth, zone);
    UserEndOfMonthUTC = TimeZoneInfo.ConvertTimeToUtc(startOfNextMonth, zone);
}

The extension methods you've added really don't provide much benefit, as you can see.

Now, the code mentions a "note" - you're currently always assuming that midnight always exists and is unambiguous. That's not true in all time zones. For example, in Brazil, on daylight saving changes forward, the time skips from midnight to 1am - so midnight itself is invalid, basically.

In Noda Time we fix this by having DateTimeZone.AtStartOfDay(LocalDate) but it's not as easy with the BCL.

For comparison, the equivalent Noda Time code would look like this:

private void SetUserStartTimesUTC()
{
    // clock would be a dependency; you *could* use SystemClock.Instance.Now,
    // but the code would be much more testable if you injected it.
    Instant now = clock.Now;

    // You can choose to use TZDB or the BCL time zones
    DateTimeZone zone = zoneProvider.FindSystemTimeZoneById(UserTimeZoneID);

    LocalDateTime userLocalNow = now.InZone(zone);

    LocalDate today = userLocalNow.Date;
    LocalDate startOfThisMonth = today.PlusDays(1 - today.Day);
    LocalDate startOfNextMonth = startOfThisMonth.PlusMonths(1);

    UserStartOfDayUTC = zone.AtStartOfDay(today);
    UserStartOfMonthUTC = zone.AtStartOfDay(startOfThisMonth);
    UserEndOfMonthUTC = zone.AtStartOfDay(startOfNextMonth);
}

... where the properties would be of type ZonedDateTime (which remembers the time zone). You could change them to be of type Instant (which is just a point in time) if you want, just chaining a ToInstant call for each property setter.

Upvotes: 5

Related Questions