'How to work with gas days in a C# project?

A gas day is defined as a time range of 24 hours which starts at 5:00 UTC and ends at 5:00 UTC of the following day during the European standard time. During the European daylight saving time, it start at 4:00 UTC and ends at 4:00 UTC of the following day (see Wikipedia, or ACER for an English explanation).

I need to work with gas days in an application in order to do the following things:

  1. Get the current gas day for any given UTC timestamp. Example: "2022-03-22 05:00:00" (UTC) should become "2022-03-22 00:00:00" (gas day).
  2. Add and/or subtract a timespan (days, hours etc.) from a specific gas day to get a new timestamp which also takes DST into account. For example, this means that if I subtract 7 days from the timestamp "2022-03-29 04:00:00" (UTC) (which equals the gas day "2022-03-29), I want to get the timestamp "2022-03-22 05:00:00" (UTC).

This feels to me as if "gas day" should be available as something similar to a time zone which I can then work with in my application, but attempting to do this using DateTime or DateTimeOffset leaves me completely clueless about what I am supposed to do to make this work.

Could anyone point me into the right direction on what I have to do to allow me to do the calculations I explained above? Is there maybe a library which makes this a bit easier to do? I already looked into, for example, NodaTime, but I couldn't really find anything in its documentation that would make it easier for me to solve this task.



Solution 1:[1]

I'll use a slightly more precise definition: "Gas Days" (or rather "Gas Time" since there's a time component here) are based on Germany's local time zone, but shifted 6 hours such that 06:00 in Germany is 00:00 in Gas Time.

As you discovered in your own experimentation, a custom time zone approach isn't as easy to implement for this, since the transitions all occur with respect to Germany's local date and time, even if the gas shift pushes the value into a different date. (It may be possible with NodaTime, but not with TimeZoneInfo.)

With that in mind, you'll need to do all your conversions and operations in German time, and then shift them to Gas Time. Some extension methods can be useful here. Explanatory comments are inline the code.

public static class GasTimeExtensions
{
    private static readonly TimeZoneInfo GermanyTimeZone =
        TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
        // (use "W. Europe Standard Time" for .NET < 6 on Windows)

    private static readonly TimeSpan GasTimeOffset = TimeSpan.FromHours(6);

    /// <summary>
    /// Adjusts the provided <paramref name="dateTimeOffset"/> to Gas Time.
    /// </summary>
    /// <param name="dateTimeOffset">The value to adjust.</param>
    /// <returns>The adjusted value.</returns>
    public static DateTimeOffset AsGasTime(this DateTimeOffset dateTimeOffset)
    {
        // Convert to Germany's local time.
        var germanyTime = TimeZoneInfo.ConvertTime(dateTimeOffset, GermanyTimeZone);
        
        // Shift for Gas Time.
        return germanyTime.ToOffset(germanyTime.Offset - GasTimeOffset);
    }

    /// <summary>
    /// Adjusts the provided <paramref name="dateTime"/> to Gas Time.
    /// </summary>
    /// <param name="dateTime">The value to adjust.</param>
    /// <returns>The adjusted value.</returns>
    public static DateTime AsGasTime(this DateTime dateTime)
    {
        // Always go through a DateTimeOffset to ensure conversions and adjustments are applied.
        return dateTime.ToGasDateTimeOffset().DateTime;
    }

    /// <summary>
    /// Adjusts the provided <paramref name="dateTime"/> to Gas Time,
    /// and returns the result as a <see cref="DateTimeOffset"/>.
    /// </summary>
    /// <param name="dateTime">The value to adjust.</param>
    /// <returns>The adjusted value as a <see cref="DateTimeOffset"/>.</returns>
    public static DateTimeOffset ToGasDateTimeOffset(this DateTime dateTime)
    {
        if (dateTime.Kind != DateTimeKind.Unspecified)
        {
            // UTC and Local kinds will get their correct offset in the DTO constructor.
            return new DateTimeOffset(dateTime).AsGasTime();
        }
        
        // Treat the incoming value as already in gas time - we just need the offset applied.
        // However, we also need to account for values that might be during DST transitions.
        var germanyDateTime = dateTime + GasTimeOffset;
        if (GermanyTimeZone.IsInvalidTime(germanyDateTime))
        {
            // In the DST spring-forward gap, advance the clock forward.
            // This should only happen if the data was bad to begin with.
            germanyDateTime = germanyDateTime.AddHours(1);
        }

        // In the DST fall-back overlap, choose the offset of the *first* occurence,
        // which is the same as the offset before the transition.
        // Otherwise, we're not in a transition, just get the offset.
        var germanyOffset = GermanyTimeZone.GetUtcOffset(
            GermanyTimeZone.IsAmbiguousTime(germanyDateTime)
                ? germanyDateTime.AddHours(-1)
                : germanyDateTime);

        // Construct the Germany DTO, shift to gas time, and return.
        var germanyDateTimeOffset = new DateTimeOffset(germanyDateTime, germanyOffset);
        return germanyDateTimeOffset.ToOffset(germanyOffset - GasTimeOffset);
    }

    /// <summary>
    /// Add a number of calendar days to the provided <paramref name="dateTimeOffset"/>, with respect to Gas Time.
    /// </summary>
    /// <remarks>
    /// A day in Gas Time is not necessarily 24 hours, because some days may contain a German DST transition.
    /// </remarks>
    /// <param name="dateTimeOffset">The value to add to.</param>
    /// <param name="daysToAdd">The number of calendar days to add.</param>
    /// <returns>The result of the operation, as a <see cref="DateTimeOffset"/> in Gas Time.</returns>
    public static DateTimeOffset AddGasDays(this DateTimeOffset dateTimeOffset, int daysToAdd)
    {
        // Add calendar days (with respect to gas time) - not necessarily 24 hours.
        return dateTimeOffset.AsGasTime().DateTime.AddDays(daysToAdd).ToGasDateTimeOffset();
    }
    
    /// <summary>
    /// Add a number of calendar days to the provided <paramref name="dateTime"/>, with respect to Gas Time.
    /// </summary>
    /// <remarks>
    /// A day in Gas Time is not necessarily 24 hours, because some days may contain a German DST transition.
    /// </remarks>
    /// <param name="dateTime">The value to add to.</param>
    /// <param name="daysToAdd">The number of calendar days to add.</param>
    /// <returns>The result of the operation, as a <see cref="DateTime"/> in Gas Time.</returns>
    public static DateTime AddGasDays(this DateTime dateTime, int daysToAdd)
    {
        // Add calendar days (with respect to gas time) - not necessarily 24 hours.
        return dateTime.AsGasTime().AddDays(daysToAdd).AsGasTime();
    }
}

Some usage examples:

  • Convert a specific timestamp

    var test = DateTimeOffset.Parse("2022-03-22T05:00:00Z").AsGasTime();
    Console.WriteLine($"{test:yyyy-MM-ddTHH:mm:sszzz} in Gas Time is 
    {test.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC.");
    

    Output:

    2022-03-22T00:00:00-05:00 in Gas Time is 2022-03-22T05:00:00Z UTC.
    
  • Get the current gas timestamp

    var now = DateTimeOffset.UtcNow.AsGasTime();
    Console.WriteLine($"It is now {now:yyyy-MM-ddTHH:mm:sszzz} in Gas Time ({now.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC).");
    

    Output:

    It is now 2022-05-10T14:31:56-04:00 in Gas Time (2022-05-10T18:31:56Z UTC).
    
  • Adding absolute time (hours, minutes, seconds, etc.)

    var start = DateTimeOffset.Parse("2022-03-26T05:00:00Z").AsGasTime();
    var end = start.AddHours(24 * 7).AsGasTime(); // add and correct any offset change
    Console.WriteLine($"Starting at {start:yyyy-MM-ddTHH:mm:sszzz} Gas Time ({start.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC),");
    Console.WriteLine($"7 x 24hr intervals later is {end:yyyy-MM-ddTHH:mm:sszzz} Gas Time ({end.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC).");
    

    Output:

    Starting at 2022-03-26T00:00:00-05:00 Gas Time (2022-03-26T05:00:00Z UTC),
    7 x 24hr intervals later is 2022-04-02T01:00:00-04:00 Gas Time (2022-04-02T05:00:00Z UTC).
    
  • Add or subtract calendar days (not strictly 24 hour days due to DST transitions)

    var start = DateTimeOffset.Parse("2022-03-26T05:00:00Z").AsGasTime();
    var end = start.AddGasDays(7); // add and correct any offset change
    Console.WriteLine($"Starting at {start:yyyy-MM-ddTHH:mm:sszzz} Gas Time ({start.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC),");
    Console.WriteLine($"7 calendar days later is {end:yyyy-MM-ddTHH:mm:sszzz} Gas Time ({end.UtcDateTime:yyyy-MM-ddTHH:mm:ssZ} UTC).");
    

    Output:

    Starting at 2022-03-26T00:00:00-05:00 Gas Time (2022-03-26T05:00:00Z UTC),
    7 calendar days later is 2022-04-02T00:00:00-04:00 Gas Time (2022-04-02T04:00:00Z UTC).
    

Note in those last two examples, I picked a different date than yours, to demonstrate crossing a DST transition.

Solution 2:[2]

You can create a custom Time Zone that matches the rules of Gas Day.

Since the rules you mention are based off another time zone, it's easy enough to pull in the rules for the other time zone, and base yours off of that:

static TimeZoneInfo CreateGasDayTimezone()
{
    // Use CET adjustment rules for daylight saving time
    var cet = TimeZoneInfo.FindSystemTimeZoneById("Central Europe Standard Time");
    var cetAdjustmentRules = cet.GetAdjustmentRules();

    // Create a new timezone offset by 5 hours off UTC, using CET for DST
    var gasday = TimeZoneInfo.CreateCustomTimeZone(
        "Europe/Gas_Day",
        TimeSpan.FromHours(-5),
        "Gas Day", 
        "Gas Day (Standard)", 
        "Gas Day (Daylight)", 
        cetAdjustmentRules);

    return gasday;
}

Using the new time zone is easy enough:

var date = new DateTime(2020, 1, 1);
var gasdayZone = CreateGasDayTimezone();
var dateAsGasDay = TimeZoneInfo.ConvertTimeFromUtc(date, gasdayZone);
Console.WriteLine(date + " is " + dateAsGasDay + " in gas day");

Solution 3:[3]

Here is a quick solution that implements your logic for a gas day into a C# Extension Method attached to the System.DateTime type. This can also be attached to DateTimeOffset with similar logic if you want to use that type.

Here is the code:

public static class SpecialDateExtensions
{
    public static DateTime GetGasDay(this DateTime current)
    {
        var datePart = current.Date;
        var gasDayThreshold = datePart.AddHours(5);
        return current > gasDayThreshold ? datePart : datePart.AddDays(-1);
    }
}

static void Main(string[] args)
{
    // usage 
    var now = DateTime.Now;
    var gasday = now.GetGasDay();
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Anon Coward
Solution 3 TylerH