1

We need to floor a time to the nearest arbitrary interval (represented by e.g. a Timespan or a Duration).

Assume for an example that we need to floor it to the nearest ten minutes. e.g. 13:02 becomes 13:00 and 14:12 becomes 14:10

Without using Nodatime you could do something like this:

// Floor
long ticks = date.Ticks / span.Ticks;
return new DateTime( ticks * span.Ticks );

Which will use the ticks of a timespan to floor a datetime to a specific time.

It seems NodaTime exposes some complexity we hadn't considered before. You can write a function like this:

public static Instant FloorBy(this Instant time, Duration duration)
=> time.Minus(Duration.FromTicks(time.ToUnixTimeTicks() % duration.BclCompatibleTicks));

But that implementation doesn't seem correct. "Floor to nearest ten minutes" seems to be dependent on timezone/offset of the time. While might be 13:02 in UTC, in Nepal which has an offset of +05:45, the time would be 18:47.

This means that in UTC, flooring to the nearest ten minutes, would mean subtracting two minutes, while in Nepal, it would mean subtracting seven minutes.

I feel like I should be able to round a ZonedDateTime or an OffsetDateTime by an arbitrary timespan somehow. I can get close by writing a function like this

public static OffsetDateTime FloorToNearestTenMinutes(this OffsetDateTime time)
{
    return time
        .Minus(Duration.FromMinutes(time.Minute % 10))
        .Minus(Duration.FromSeconds(time.Second));
}

but that doesn't allow me to specify an arbitrary duration, as the OffsetDateTime has no concept of ticks.

How do I round an Instant/ZonedDateTime/OffsetDateTime correctly, with an arbitrary interval, taking into account time zones?

Gustav Wengel
  • 345
  • 3
  • 11

3 Answers3

1

For OffsetDateTime, I'd advise you to write a Func<LocalTime, LocalTime> which is effectively an "adjuster" in Noda Time terminology. You can then just use the With method:

// This could be a static field somewhere - or a method, so you can use
// a method group conversion.
Func<LocalTime, LocalTime> adjuster =>
    new LocalTime(time.Hour, time.Minute - time.Minute % 10, 0);

// The With method applies the adjuster to just the time portion,
// keeping the date and offset the same.
OffsetDateTime rounded = originalOffsetDateTime.With(adjuster);

Note that this only works because your rounding will never change the date. If you need a version that can change date as well (e.g. rounding 23:58 to 00:00 of the next day) then you'd need to get the new LocalDateTime and construct a new OffsetDateTime with that LocalDateTime and the original offset. We don't have a convenience method for that, but it's just a matter of calling the constructor.

ZonedDateTime is fundamentally trickier due to the reasons you've given. Right now, Nepal doesn't observe DST - but it might do so in the future. Rounding near the DST boundary could take you into an ambiguous or even skipped time, potentially. That's why we don't provide a similar With method for ZonedDateTime. (In your case it isn't likely, although it's historically possibly... with date adjusters you could easily end up in this situation.)

What you could do is:

  • Call ZonedDateTime.ToOffsetDateTime
  • Round the OffsetDateTime as above
  • Call OffsetDateTime.InZone(zone) to get back to a ZonedDateTime

You could then check that the offset of the resulting ZonedDateTime is the same as the original, if you wanted to detect weird cases - but you'd then need to decide what to actually do about them. The behaviour is fairly reasonable though - if you start with a ZonedDateTime with a time portion of (say) 01:47, you'll end up with a ZonedDateTime in the same time zone from 7 minutes earlier. It's possible that wouldn't be 01:40, if a transition occurred within the last 7 minutes... but I suspect you don't actually need to worry about it.

Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
1

I ended up taking some stuff from Jon Skeets answer and rolling my own Rounder that takes in an arbitrary Duration to round with. (Which was one of the key things I needed, which is also why I'm not accepting that answer).

Per Jons suggestion I convert the Instant to an OffsetDateTime and apply the rounder, which takes in an arbitrary duration. Example and implementation is below:

// Example of usage
public void Example()
{
    Instant instant = SystemClock.Instance.GetCurrentInstant();
    OffsetDateTime offsetDateTime = instant.WithOffset(Offset.Zero);
    var transformedOffsetDateTime = offsetDateTime.With(t => RoundToDuration(t, Duration.FromMinutes(15)));
    var transformedInstant = transformedOffsetDateTime.ToInstant();
}

// Rounding function, note that it at most truncates to midnight at the day.
public static LocalTime RoundToDuration(LocalTime timeToTransform, Duration durationToRoundBy)
{
    var ticksInDuration = durationToRoundBy.BclCompatibleTicks;
    var ticksInDay = timeToTransform.TickOfDay;
    var ticksAfterRounding = ticksInDay % ticksInDuration;
    var period = Period.FromTicks(ticksAfterRounding);

    var transformedTime = timeToTransform.Minus(period);
    return transformedTime;
}

Gustav Wengel
  • 345
  • 3
  • 11
0

For anyone interested here is my implementation, which correctly accounts for the occasions we cross a day, and always rounds up (rather than floors):

public static class RoundingExtensions
{
    private static readonly Duration OneDay = Duration.FromDays(1);

    public static LocalTime RoundUpToDuration(this LocalTime localDateTime, Duration duration)
    {
        if (duration <= Duration.Zero) return localDateTime;

        var ticksInDuration = duration.BclCompatibleTicks;
        var ticksInDay = localDateTime.TickOfDay;
        var ticksAfterRounding = ticksInDay % ticksInDuration;
        if (ticksAfterRounding == 0) return localDateTime;

        // Create period to add ticks to get to next rounding.
        var period = Period.FromTicks(ticksInDuration - ticksAfterRounding);
        return localDateTime.Plus(period);
    }

    public static OffsetDateTime RoundUpToDuration(this OffsetDateTime offsetDateTime, Duration duration)
    {
        if (duration <= Duration.Zero) return offsetDateTime;
        
        var result = offsetDateTime.With(t => RoundUpToDuration(t, duration));
        if (OffsetDateTime.Comparer.Instant.Compare(offsetDateTime, result) > 0) result = result.Plus(OneDay);
        return result;
    }

    public static ZonedDateTime RoundUpToDuration(this ZonedDateTime zonedDateTime, Duration duration)
    {
        if (duration <= Duration.Zero) return zonedDateTime;

        var odt = zonedDateTime.ToOffsetDateTime().RoundUpToDuration(duration);
        return odt.InZone(zonedDateTime.Zone);
    } 
}
thargy
  • 161
  • 2
  • 4