3

The user can filter a report by an initial and end date/time (timestamp). Supposing the current filters:

Initial: 2018-01-01 13:00:00

End: 2018-01-05 04:00:00

How to calculate in Java the total amount of hours and minutes that happened between 22:00 and 05:00 AM (of the next day) for all days inside the filtered period.

We are currently using Java 8 classes (LocalDateTime etc).

Expected result for the filters above: 27 hours and 0 minutes (and NOT 87 hours)!

Details:

From day 01 to day 02 we overlap the interested hour range (22h - 5h) so
for day 01 to day 02 we add 7 hours to the total amount.
From day 02 to day 03 we add another 7 hours.
From day 03 to day 04 we add another 7 hours.
From day 04 to day 05 we add 6 hours because the end filter finishes at 04:00 AM so we should not consider the last hour.

If the end timestamp was 2018-01-05 04:30:00 then the final result would be 27 hours and 30 minutes.

Also, the solution must take into account DST changes. We have the client timezone available to use in the operation so the solution might be to use the OffsetDateTime class. But I don't know how to properly handle DST in this scenario.

Luiz
  • 325
  • 7
  • 28
  • @LuCio My question is different. I don't want to know the total amount of hours. But only the total amount that is between a specific hour range (also respecting the timestamp range). Please, read the description carefully and pay attention to the provided example before commenting. – Luiz Jul 19 '18 at 20:25
  • 1
    Have you tried anything yourself? This doesn’t look so hard if you put a little research effort into it. – Joakim Danielson Jul 19 '18 at 20:32
  • Are you always going to have `Initial`, `End`, `Low Range`, and `High Range` values in full hours? – PM 77-1 Jul 19 '18 at 20:39
  • @JoakimDanielson The solution a came with is to iterate second by second and verify if we met the hour range and if is still between the timestamp period. But this not seems to be a good solution and I'm convinced that exists a better one. – Luiz Jul 19 '18 at 20:47
  • @PM77-1 Initial and End provided by the user are always a timestamp value. The hour range we use (22h to 5) is only a time value – Luiz Jul 19 '18 at 20:49
  • One fine part of this problem, is how do you handle DST? DST will typically add or remove an hour to/from the specified time range. So you need to ask the question. What do you want to do with about that? – kumesana Jul 19 '18 at 20:52
  • @kumesana Well said. We have the DST as a concern and also have the client timezone available to apply. But to find a proper answer I will update the question. – Luiz Jul 19 '18 at 20:56
  • OK, I don't understand the question. If you want only the hours, and not the days, then isn't the answer (for the inputs `2018-01-01 13:00:00` and `2018-01-05 04:00:00`) only 15 hours? Why do you expect 27? – Dawood ibn Kareem Jul 19 '18 at 20:59
  • @DawoodibnKareem Take a look in the last update. – Luiz Jul 19 '18 at 21:05
  • 1
    OffsetDateTime doesn't know about DST, it only knows the number of hours and minutes from GMT. You need a ZonedDateTime to be aware of when DST changes happen. While there are more efficient algorithms, I'd say that for a problem like that, you're better off starting with the initial timestamp, advancing it to the start time, advance to the end time, check whether you've reached end timestamp, if not advance again to start time, and handle all cases in this algorithm, until you've reached/passed end timestamp. – kumesana Jul 19 '18 at 21:06
  • Oh, I see. That's quite complex. I understand now; and I'll post an answer if I have time later. – Dawood ibn Kareem Jul 19 '18 at 21:10
  • What will be the result of: **22:15** - **22:16** of the same day? – PM 77-1 Jul 19 '18 at 21:36
  • @PM77-1I don't know if I understand correctly. But I think that the result of that subtraction is 1 min. – Luiz Jul 20 '18 at 15:19
  • To clarify: An offset-from-UTC is merely a number of hours, minutes, and seconds. Nothing more. In contrast, a time zone is *much* more. A time zone is a history of the past, present, and future changes to the offset used by the people of a particular region. So `ZonedDateTime` handles zone related anomalies such as DST, while `OffsetDateTime` does not. And `Instant` always represents a moment in UTC. Very different is `LocalDateTime` class, utterly irrelevant to the problem here. – Basil Bourque Jul 30 '18 at 04:18
  • 1
    Hmmm… 3 people starred the Question as a *Favorite*, yet only 1 up-vote before I added the second? – Basil Bourque Jul 30 '18 at 04:24

5 Answers5

3

After having by mistake thought this question is a duplicate @Luiz corrected me. Here comes my apology.

To compute the Duration with respect to DST I'm following this answer. The code also covers leap years.

EDIT:
@XiCoN JFS pointed out some bugs. I reviewed and tested my code. I posted the test class here. I tried to cover every case I could think of.

This is my solution:

public class Durations {

  public static Duration getSumOfHoursOnDays(ZonedDateTime dateTimeFrom, ZonedDateTime dateTimeTo, LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    Duration result = Duration.of(0, ChronoUnit.HOURS);

    Duration hoursOnFirstDay = getHoursOnFirstDay(dateTimeFrom, dateTimeTo, dailyTimeFrom, dailyTimeTo);
    result = result.plus(hoursOnFirstDay);

    long daysBetween = ChronoUnit.DAYS.between(dateTimeFrom.truncatedTo(ChronoUnit.DAYS), dateTimeTo.truncatedTo(ChronoUnit.DAYS));
    if (daysBetween > 0) {
      for (int i = 1; i < daysBetween; i++) {
        ZonedDateTime day = dateTimeFrom.plusDays(i);
        Duration hoursOnDay = getHoursOnDay(day, dailyTimeFrom, dailyTimeTo);
        result = result.plus(hoursOnDay);
      }

      Duration hoursOnLastDay = getHoursOnLastDay(dateTimeFrom, dateTimeTo, dailyTimeFrom, dailyTimeTo);
      result = result.plus(hoursOnLastDay);
    }

    return result;
  }

  protected static Duration getHoursOnFirstDay(ZonedDateTime dateTimeFrom, ZonedDateTime dateTimeTo, LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    ZonedDateTime dateTimeToOnFirstDay = dateTimeTo.truncatedTo(ChronoUnit.DAYS).isAfter(dateTimeFrom.truncatedTo(ChronoUnit.DAYS)) ?
      dateTimeFrom.plusDays(1).withHour(0) :
      dateTimeTo;

    return getHoursOnDay(dateTimeFrom, dateTimeToOnFirstDay, dailyTimeFrom, dailyTimeTo);
  }

  protected static Duration getHoursOnLastDay(ZonedDateTime dateTimeFrom, ZonedDateTime dateTimeTo, LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    return dateTimeTo.truncatedTo(ChronoUnit.DAYS).isAfter(dateTimeFrom.truncatedTo(ChronoUnit.DAYS)) ?
      getHoursOnDay(dateTimeTo.withHour(0), dateTimeTo, dailyTimeFrom, dailyTimeTo) :
      Duration.ofHours(0);
  }

  protected static Duration getHoursOnDay(ZonedDateTime day, LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    ZonedDateTime zoneTimeFrom = day.with(dailyTimeFrom);
    ZonedDateTime zoneTimeTo = day.with(dailyTimeTo);
    return zoneTimeFrom.isBefore(zoneTimeTo) ?
      Duration.between(zoneTimeFrom, zoneTimeTo) :
      Duration.between(day.withHour(0), zoneTimeTo).plus(Duration.between(zoneTimeFrom, day.plusDays(1).withHour(0)));
  }

  protected static Duration getHoursOnDay(ZonedDateTime dateTimeFrom, ZonedDateTime dateTimeTo, LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    ZonedDateTime dailyDateTimeFrom = dateTimeFrom.with(dailyTimeFrom);
    ZonedDateTime dailyDateTimeTo = dateTimeFrom.with(dailyTimeTo);

    if (dailyDateTimeFrom.isBefore(dailyDateTimeTo)) {
      if (dailyDateTimeFrom.isAfter(dateTimeTo) || dailyDateTimeTo.isBefore(dateTimeFrom)) {
        return Duration.ofHours(0);
      }

      ZonedDateTime from = dateTimeFrom.isAfter(dailyDateTimeFrom) ?
        dateTimeFrom :
        dailyDateTimeFrom;

      ZonedDateTime to = dateTimeTo.isBefore(dailyDateTimeTo) ?
        dateTimeTo :
        dailyDateTimeTo;

      return Duration.between(from, to);
    }

    Duration result = Duration.ofHours(0);

    ZonedDateTime to = dateTimeTo.isBefore(dailyDateTimeTo) ?
      dateTimeTo :
      dailyDateTimeTo;
    if (dateTimeFrom.isBefore(dailyDateTimeTo)) {
      result = result.plus(Duration.between(dateTimeFrom, to));
    }
    ZonedDateTime from = dateTimeFrom.isAfter(dailyDateTimeFrom) ?
      dateTimeFrom :
      dailyDateTimeFrom;
    if (from.isBefore(dateTimeTo)) {
      result = result.plus(Duration.between(from, dateTimeTo));
    }
    return result;
  }
}

The main challenge was to cope with day times where the time from is after the time to. An example is given in the question: from 22, to 5.
In such cases there can be two time ranges within a day: 22 - 24 / 0 - 5. Both time ranges must be evaluated on its own as there can be a DST change within.
But there can be also one time range like in case of: from 22, to 0. Regarding one day this results in the time range: 22 - 24.

I covered those and many other cases in the linked test class. It took me some time to get it work for all test cases. So I hope it serves somebody.

LuCio
  • 5,055
  • 2
  • 18
  • 34
  • 1
    Nice OOP-approach. I noticed a bug however: With the range 2017-3-25 22:00 until 2017-10-29 05:00 there is a DST-change at 2017-3-26 +1 and 2017-10-29 -1 which would result in 1526 hours. Your code equates to 1527 hours. – XiCoN JFS Jul 20 '18 at 15:31
  • I'm sorry to inform you that your bug is still not resolved. If you add a day (2017-10-30 05:00) it should result into 1526 + 7 = 1533. Your code equates to 1532. Take a look at my answer, I solve the DST-problem with `ZoneRules`. – XiCoN JFS Jul 20 '18 at 16:50
2

For understanding this solution, you should be familiar with bit fields and bit masks. If not, please consider researching.

Here is a quick example:

Bit field: 1 0 1 1 1 0  AND
Bit mask : 1 1 0 1 0 1
______________________
Result   : 1 0 0 1 0 0

With this approach you can display actual hours of a day as bit fields and desired hours as a bit mask.

Our 24 bit mask (24 hours) from 22pm to 5am will look like this(MSB last):

23 22 21 20 19 18 17 16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
 1  1  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  1  1  1  1  1

In hex this represents 0xC0001F.

EDIT:

My last approach was bugged when calculating calendar-days vs hours in certain ranges. Turns out I had to draw down the problem to a matrix to identify a pattern.

CD\HR <24 =24 >24 <48 =48 >48 <72 =72 >72 <96 =96 >96 
   0   x
   1   x   x   x   x
   2           x   x   x   x   x
   3                       x   x   x   x   x
   4                                   x   x   x   x

 CD = Calendar Days => 2017-01-01 23:00 until 2017-01-02 00:00 is one CD
 HR  = Hour Range

The goal is to create a bitField for the startDate and endDate. For every other day we just add the amount of bits in the 24 bitMask. The bitField is always the range from startTime to endTime. If startTime > endTime then we flip them and the calculated bitField as well.

Three conditions decide wether a calendar day needs to be ignored when applying a bitMask to it:

calendarDays > 0

bitField != 0

startTime > endTime

This will calculate the hours in a range with a 24 bit mask taking possible DST into account:

private double getHoursInRange(ZonedDateTime startDate, ZonedDateTime endDate, int bitMask) {
    if (!startDate.isBefore(endDate)) throw new InputMismatchException("endDate is before or equal startDate");
    ZoneRules rules = startDate.getZone().getRules();
    int offset = 0;
    ZoneOffsetTransition zoneOffsetTransition = rules.nextTransition(startDate.toInstant());
    while (true) {
        Instant transitionInstant = zoneOffsetTransition.getInstant();
        if (transitionInstant.isBefore(endDate.toInstant()) || transitionInstant.equals(endDate.toInstant())) {
            ZoneOffset offsetAfter = zoneOffsetTransition.getOffsetAfter();
            offset += offsetAfter.getTotalSeconds() == 3600 ? -1 : 1;
            zoneOffsetTransition = rules.nextTransition(transitionInstant);
        } else {
            break;
        }
    }
    long calendarDays = Duration.between(startDate.withHour(0), endDate.withHour(23)).toDays();
    int startTime = startDate.getHour();
    int endTime = endDate.getHour();
    int bitField = 0;
    for (int o = startTime < endTime ? startTime : endTime; startTime < endTime ? o < endTime : o < startTime; o++) {
        bitField = bitField | (1 << o);
    }
    if (startTime > endTime) {
        bitField = ~bitField;
    }
    if (calendarDays > 0 && bitField != 0 && startTime > endTime) {
        calendarDays = calendarDays - 1;
    }
    double hoursInRange = calendarDays * Integer.bitCount(bitMask);
    hoursInRange += Integer.bitCount(bitField & bitMask);
    hoursInRange += offset;
    return hoursInRange;
}

If you want to check for minutes as well you can add following line:

hoursInRange += (endDate.getMinute() - startDate.getMinute()) / 60.0;

Or seconds:

hoursInRange += (endDate.getSecond() - startDate.getSecond()) / 3600.0;

Tested with a quick unit test:

@Test
public void test() {
    ZoneId london = ZoneId.of("Europe/London");
    LocalDateTime startDate = LocalDateTime.of(2018, 1, 1, 13, 0, 0, 0);
    LocalDateTime endDate = LocalDateTime.of(2018, 1, 5, 4, 0, 0, 0);
    ZonedDateTime from = startDate.atZone(london);
    ZonedDateTime to = endDate.atZone(london);
    double hours = getHoursInRange(from, to, 0xC0001F);
    double allHours = getHoursInRange(from, to, 0xFFFFFF);
    assertEquals(27, hours, 0);
    assertEquals(87, allHours, 0);
}
XiCoN JFS
  • 628
  • 3
  • 12
  • It's a good solution but doesn't take into account DST changes. – Luiz Jul 20 '18 at 11:56
  • @Luiz take a look, I updated my origininal answer quite a bit and made sure the algo is correct in terms of DST and any given range. – XiCoN JFS Jul 22 '18 at 01:30
  • 1
    This solution is the most interesting, thanks for posting it. However, the solution posted by @LuCio is the one I can most easily adapt to also count half hours or any period in minutes. Suppose the end timestamp is `2018-01-05 04:30:00`, in that case the total amount of hours would be 27h and 30min. I think the title of my question was misleading so I will change it. Thanks anyway. – Luiz Jul 22 '18 at 22:42
  • @Luiz, I had fun solving this puzzle. Minutes can be calculated as well though, I posted the code as addition because your requirement was kinda misleading. – XiCoN JFS Jul 22 '18 at 23:05
2

I've already given an answer. But the already given answers are rather complex. I think they aren't easy to understand. So I wasn't satisfied by these answers and was wondering if there can be a simple solution, easy to understand.

I think, I found one. The approach is to define a date-time range (as mentioned by OP) and to stream over it's units and filter the appropriate ones.

Here is my DateTimeRange:

public class DateTimeRange {

    private final ZonedDateTime from;
    private final ZonedDateTime to;

    DateTimeRange(ZonedDateTime from, ZonedDateTime to) {
        this.from = from;
        this.to = to;
    }

    public static DateTimeRange of(LocalDateTime from, LocalDateTime to, ZoneId zoneId) {
        Objects.requireNonNull(from);
        Objects.requireNonNull(to);
        Objects.requireNonNull(zoneId);

        return new DateTimeRange(ZonedDateTime.of(from, zoneId), ZonedDateTime.of(to, zoneId));
    }
    public Stream<ZonedDateTime> streamOn(ChronoUnit unit) {
        Objects.requireNonNull(unit);

        ZonedDateTimeSpliterator zonedDateTimeSpliterator = new ZonedDateTimeSpliterator(from, to, unit);
        return StreamSupport.stream(zonedDateTimeSpliterator, false);
    }

    static class ZonedDateTimeSpliterator implements Spliterator<ZonedDateTime> {

        private final ChronoUnit unit;

        private ZonedDateTime current;
        private ZonedDateTime to;

        ZonedDateTimeSpliterator(ZonedDateTime from, ZonedDateTime to, ChronoUnit unit) {
            this.current = from.truncatedTo(unit);
            this.to = to.truncatedTo(unit);
            this.unit = unit;
        }

        @Override
        public boolean tryAdvance(Consumer<? super ZonedDateTime> action) {
            boolean canAdvance = current.isBefore(to);

            if (canAdvance) {
                action.accept(current);
                current = current.plus(1, unit);
            }

            return canAdvance;
        }

        @Override
        public Spliterator<ZonedDateTime> trySplit() {
            long halfSize = estimateSize() / 2;
            if (halfSize == 0) {
                return null;
            }

            ZonedDateTime splittedFrom = current.plus(halfSize, unit);
            ZonedDateTime splittedTo = to;
            to = splittedFrom;

            return new ZonedDateTimeSpliterator(splittedFrom, splittedTo, unit);
        }

        @Override
        public long estimateSize() {
            return unit.between(current, to);
        }

        @Override
        public Comparator<? super ZonedDateTime> getComparator() {
            // sorted in natural order
            return null;
        }

        @Override
        public int characteristics() {
            return Spliterator.NONNULL | Spliterator.IMMUTABLE | Spliterator.SIZED | Spliterator.SUBSIZED | Spliterator.ORDERED | Spliterator.SORTED | Spliterator.DISTINCT;
        }

    }

}

And this is the adapted Durations class:

public class Durations {

  public static Duration getSumOfHoursOnDays(ZoneId zoneId, LocalDateTime dateTimeFrom, LocalDateTime dateTimeTo, LocalTime dailyTimeFrom,
    LocalTime dailyTimeTo) {
    return getDuration(zoneId, dateTimeFrom, dateTimeTo, dailyTimeFrom, dailyTimeTo, ChronoUnit.HOURS);
  }

  public static Duration getDuration(ZoneId zoneId, LocalDateTime dateTimeFrom, LocalDateTime dateTimeTo, LocalTime dailyTimeFrom,
    LocalTime dailyTimeTo, ChronoUnit precision) {
    long count = DateTimeRange.of(dateTimeFrom, dateTimeTo, zoneId)
      .streamOn(precision)
      .filter(getFilter(dailyTimeFrom, dailyTimeTo))
      .count();
    return Duration.of(count, precision);
  }

  protected static Predicate<? super ZonedDateTime> getFilter(LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    return dailyTimeFrom.isBefore(dailyTimeTo) ?
      filterFromTo(dailyTimeFrom, dailyTimeTo) :
      filterToFrom(dailyTimeFrom, dailyTimeTo);
  }

  protected static Predicate<? super ZonedDateTime> filterFromTo(LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    return zdt -> {
      LocalTime time = zdt.toLocalTime();
      return (time.equals(dailyTimeFrom) || time.isAfter(dailyTimeFrom)) && time.isBefore(dailyTimeTo);
    };
  }

  protected static Predicate<? super ZonedDateTime> filterToFrom(LocalTime dailyTimeFrom, LocalTime dailyTimeTo) {
    return zdt -> {
      LocalTime time = zdt.toLocalTime();
      return (time.equals(dailyTimeFrom) || time.isAfter(dailyTimeFrom)) || (time.isBefore(dailyTimeTo));
    };
  }

}

As the Stream-Interface is well known this approach should be easier to understand. Moreover it's simple to use it with other ChronoUnits. Applying the Stream-Interface makes it easy to compute other date-time based values. Moreover this follows the example of Java 9 LocalDate.datesUntil.

While being more easier to understand this solution will not be as fast like the both mentioned earlier. I think as long as nobody streams at nano precision over years it should be acceptable ;)


My resource links:

LuCio
  • 5,055
  • 2
  • 18
  • 34
  • Great solution. But I don't get it how can I adapt to include partial hours. The same example I provided in XiCoN answer: if the end timestamp is `2018-01-05 04:30:00` the result should be 27 hours and 30 min (or 1650 min). Your solution still states 1620 minutes. I tried to change the `ChronoUnit` to MINUTES, this works if I use `toHours()` in the resulting duration (outputs 1650) instead of `toMinutes()`, which is wrong... – Luiz Jul 23 '18 at 17:18
  • @Luiz I updated my answer. Using the method `getDuration()` it works. See the updated DurationsTest class. I added the test-case `testQuestion2()`. The result is a `Duration`with 1650 minutes. Actually it is the same code. `getDuration()` is only a generalized method which can be additionally parametrized with the `ChronoUnit`to use. – LuCio Jul 23 '18 at 18:09
  • I was waiting for you to answer my comment. Although this solution being the more slow, I think it is the most easy to understand and concise. Also, performance is not a major concern for this use case. I will use this solution, thank you @LuCio. – Luiz Jul 24 '18 at 11:25
  • A extensive test coverage was a big plus! ;) – Luiz Jul 24 '18 at 11:26
  • @Luiz Only thanks to the tests I was able to implement this new approach. Otherwise it would be rather a prove of concept. Now it should be ready to use. – LuCio Jul 24 '18 at 11:37
  • To adapt your solution to an use case where we don't need to worry about DST it's enough to change your implementation to use only a `LocalDateTime`? From my tests changing in every place the `ZonedDateTime` to `LocalDateTime` seems to work fine but maybe I'm forgetting something... – Luiz Jul 26 '18 at 18:48
  • @Luiz What does "_not worry about DST_" mean? You don't want to take DST into account? A `LocalDateTime`misses more than just the awareness of DST-changes. So I cannot say if `LocalDateTime` will work for you. I suggest you to read [this answer](https://stackoverflow.com/a/32443004/2838289) and to decide upon this if `LocalDateTime` is suitable for you. – LuCio Jul 26 '18 at 20:27
1

tl;dr

  • Don’t work too hard. Use the Interval class found in ThreeTen-Extra project to represent and compare spans-of-time tied to the timeline.
  • Switch between zoned time in ZonedDateTime and UTC time in Instant. Same moment, different wall-clock time.

ThreeTen-Extra project

Seems the other Answers are trying to recreate classes that have already been written. See the ThreeTen-Extra project for classes that add functionality to the java.time framework built into Java 8 and later.

Specifically, we can use Interval – A pair of Instant objects, a date-time range in UTC. This class offers methods for comparison such as overlaps, intersection, and so on.

Start with your two inputs, the date and time. Parse these as LocalDateTime because you neglected to mention a time zone. Replace SPACE in the middle with a T to comply with ISO 8601 standard format.

LocalDateTime ldtStart = LocalDateTime.parse( "2018-01-01T13:00:00" );
LocalDateTime ldtStop = LocalDateTime.parse( "2018-01-05T04:00:00" );

A LocalDateTime does not represent a moment, as it lacks any concept of time zone or offset-from-UTC. Without that context, it has no real meaning. Do you mean starting at 1 PM in Kolkata India, Paris France, or Montréal Canada? Those are three very different moments.

So assign a time zone, a ZoneId, to get a ZonedDateTime.

Specify a proper time zone name in the format of continent/region, such as America/Montreal, Africa/Casablanca, or Pacific/Auckland. Never use the 3-4 letter abbreviation such as EST or IST as they are not true time zones, not standardized, and not even unique(!).

ZoneId z = ZoneId.of( "America/Montreal" );
ZonedDateTime zdtStart = ldtStart.atZone( z );
ZonedDateTime zdtStop = ldtStop.atZone( z );

Use the org.threeten.extra.Interval class to represent our span-of-time tied to the timeline. This class represents a pair of Instant objects. An Instant is in UTC, by definition. We are working in a zoned date-time. What to do? Adjust into UTC by extracting a Instant from our ZonedDateTime. You can think conceptually as a ZonedDateTime consisting of a Instant and a ZoneId. The Instant and the ZonedDateTime both represent the same simultaneous moment, the same point on the timeline. Only the wall-clock time is different.

Interval interval = Interval.of( zdtStart.toInstant() , zdtStop.toInstant() );

Your frame of reference is the dates along our interval as perceived in the specified time zone. So extract LocalDate, a date-only value, from each ZonedDateTime.

LocalDate ldStart = zdtStart.toLocalDate();
LocalDate ldStop = zdtStop.toLocalDate();

We are going to loop by date, incrementing a day at a time. So copy that date into an incrementing variable.

LocalDate localDate = ldStart;

Specify your time-of-day pair that we are targeting.

LocalTime timeStart = LocalTime.of( 22 , 0 );
LocalTime timeStop = LocalTime.of( 5 , 0 );

Set up a Map to store our results. We map each date (date as seen in our zone) to a Interval, the pair of Instant objects that represent how much of the 10 PM to 5 AM target zone starting on that date is covered by our input interval.

long initialCapacity = ( ChronoUnit.DAYS.between( ldStart , ldtStop ) + 1 );
Map< LocalDate, Interval > dateToIntervalMap = new HashMap<>( ( int ) initialCapacity );

Loop on each date until past the end of our input interval.

while ( ! localDate.isAfter( ldStop ) ) {

Take each date, one by one, and apply our target time-of-day values to determine a moment in our time zone.

I don't know how to properly handle DST

The core purpose of the ZonedDateTime class is to handle anomalies such as DST found in various zones at various points in time. DST is not the only such anomaly; politicians around the world have shown a curious proclivity for redefining their time zones.

When we apply a time zone to a given date and time-of-day, if that time-of-day on that date in that zone is not valid (such as a Daylight Saving Time DST cut-over), the ZonedDateTime class automatically adjusts as needed. Be sure to read the documentation to make sure you understand and agree with its adjustment logic.

    ZonedDateTime zdtTargetStart = localDate.atTime( timeStart ).atZone( z );
    ZonedDateTime zdtTargetStop = localDate.plusDays( 1 ).atTime( timeStop ).atZone( z );

Make an interval of our target range of time, our target Interval.

    Interval target = Interval.of( zdtTargetStart.toInstant() , zdtTargetStop.toInstant() );

Test to see if target interval overlaps with our input interval. Of course we expect this to be the case at the beginning, by definition of our problem. But at the end, on the last date, that may not be the case. On the last date, our input interval may end before the next target time-of-day occurs. Indeed, that is precisely what we see with our inputs given in the Question (see output below).

Use the intersection method to produce an Interval that represent the span-of-time in common between our target interval and our input interval.

    Interval intersection;
    if ( interval.overlaps( target ) ) {
        intersection = interval.intersection( target );
    } else {
        ZonedDateTime emptyInterval = localDate.atTime( timeStart ).atZone( z );   // Better than NULL I suppose.
        intersection = Interval.of( emptyInterval.toInstant() , emptyInterval.toInstant() );
    }

Store the resulting intersection interval in our map, assigned to the date on which we are looping.

    dateToIntervalMap.put( localDate , intersection );
    // Setup the next loop.
    localDate = localDate.plusDays( 1 );
}

Done with the business logic. Now we can report the results.

// Report
System.out.println( "interval: " + interval + " = " + zdtStart + "/" + zdtStop );
int nthDate = 0;

We use the java.time.Duration class to track the elapsed time contained in each intersection interval.

Duration totalDuration = Duration.ZERO;

We have to go through some extra work to set up our reporting loop chronologically. The Map::keySet method does not necessarily return results in the order we desire.

List< LocalDate > dates = new ArrayList<>( dateToIntervalMap.keySet() );
Collections.sort( dates );
List< LocalDate > keys = List.copyOf( dates );
for ( LocalDate date : keys ) {
    nthDate++;
    Interval i = dateToIntervalMap.get( date );
    Instant startInstant = i.getStart();
    Instant stopInstant = i.getEnd();
    Duration d = Duration.between( startInstant , stopInstant );
    totalDuration = totalDuration.plus( d );
    ZonedDateTime start = startInstant.atZone( z );
    ZonedDateTime stop = stopInstant.atZone( z );
    System.out.println( "Day # " + nthDate + " = " + date + " ➙ " + i + " = " + start + "/" + stop + " = " + d );
}

Report the total time contained in all our intersection intervals. The String generated by our Duration object uses standard ISO 8601 duration format. The P marks the beginning, while the T separates any years-months-days from any hours-minutes-seconds.

System.out.println("Total duration: " + totalDuration);

For convenience, let’s see that same code again, all in one block.

LocalDateTime ldtStart = LocalDateTime.parse( "2018-01-01T13:00:00" );
LocalDateTime ldtStop = LocalDateTime.parse( "2018-01-05T04:00:00" );

ZoneId z = ZoneId.of( "America/Montreal" );
ZonedDateTime zdtStart = ldtStart.atZone( z );
ZonedDateTime zdtStop = ldtStop.atZone( z );

Interval interval = Interval.of( zdtStart.toInstant() , zdtStop.toInstant() );

LocalDate ldStart = zdtStart.toLocalDate();
LocalDate ldStop = zdtStop.toLocalDate();
LocalDate localDate = ldStart;

LocalTime timeStart = LocalTime.of( 22 , 0 );
LocalTime timeStop = LocalTime.of( 5 , 0 );

long initialCapacity = ( ChronoUnit.DAYS.between( ldStart , ldtStop ) + 1 );
Map< LocalDate, Interval > dateToIntervalMap = new HashMap<>( ( int ) initialCapacity );

while ( ! localDate.isAfter( ldStop ) ) {
    ZonedDateTime zdtTargetStart = localDate.atTime( timeStart ).atZone( z );
    ZonedDateTime zdtTargetStop = localDate.plusDays( 1 ).atTime( timeStop ).atZone( z );
    Interval target = Interval.of( zdtTargetStart.toInstant() , zdtTargetStop.toInstant() );
    Interval intersection;
    if ( interval.overlaps( target ) ) {
        intersection = interval.intersection( target );
    } else {
        ZonedDateTime emptyInterval = localDate.atTime( timeStart ).atZone( z );   // Better than NULL I suppose.
        intersection = Interval.of( emptyInterval.toInstant() , emptyInterval.toInstant() );
    }
    dateToIntervalMap.put( localDate , intersection );
    // Setup the next loop.
    localDate = localDate.plusDays( 1 );
}

// Report
System.out.println( "interval: " + interval + " = " + zdtStart + "/" + zdtStop );
int nthDate = 0;
Duration totalDuration = Duration.ZERO;
List< LocalDate > dates = new ArrayList<>( dateToIntervalMap.keySet() );
Collections.sort( dates );
List< LocalDate > keys = List.copyOf( dates );
for ( LocalDate date : keys ) {
    nthDate++;
    Interval i = dateToIntervalMap.get( date );
    Instant startInstant = i.getStart();
    Instant stopInstant = i.getEnd();
    Duration d = Duration.between( startInstant , stopInstant );
    totalDuration = totalDuration.plus( d );
    ZonedDateTime start = startInstant.atZone( z );
    ZonedDateTime stop = stopInstant.atZone( z );
    System.out.println( "Day # " + nthDate + " = " + date + " ➙ " + i + " = " + start + "/" + stop + " = " + d );
}
System.out.println("Total duration: " + totalDuration);

When run, we get this output. We:

  • Get full seven hours for most days
  • Drop to six hours on the penultimate date
  • Finally see zero hours on the last date as our input interval ends before the that day’s start time occurs.

interval: 2018-01-01T18:00:00Z/2018-01-05T09:00:00Z = 2018-01-01T13:00-05:00[America/Montreal]/2018-01-05T04:00-05:00[America/Montreal]

Day # 1 = 2018-01-01 ➙ 2018-01-02T03:00:00Z/2018-01-02T10:00:00Z = 2018-01-01T22:00-05:00[America/Montreal]/2018-01-02T05:00-05:00[America/Montreal] = PT7H

Day # 2 = 2018-01-02 ➙ 2018-01-03T03:00:00Z/2018-01-03T10:00:00Z = 2018-01-02T22:00-05:00[America/Montreal]/2018-01-03T05:00-05:00[America/Montreal] = PT7H

Day # 3 = 2018-01-03 ➙ 2018-01-04T03:00:00Z/2018-01-04T10:00:00Z = 2018-01-03T22:00-05:00[America/Montreal]/2018-01-04T05:00-05:00[America/Montreal] = PT7H

Day # 4 = 2018-01-04 ➙ 2018-01-05T03:00:00Z/2018-01-05T09:00:00Z = 2018-01-04T22:00-05:00[America/Montreal]/2018-01-05T04:00-05:00[America/Montreal] = PT6H

Day # 5 = 2018-01-05 ➙ 2018-01-06T03:00:00Z/2018-01-06T03:00:00Z = 2018-01-05T22:00-05:00[America/Montreal]/2018-01-05T22:00-05:00[America/Montreal] = PT0S

Total duration: PT27H


About java.time

The java.time framework is built into Java 8 and later. These classes supplant the troublesome old legacy date-time classes such as java.util.Date, Calendar, & SimpleDateFormat.

The Joda-Time project, now in maintenance mode, advises migration to the java.time classes.

To learn more, see the Oracle Tutorial. And search Stack Overflow for many examples and explanations. Specification is JSR 310.

You may exchange java.time objects directly with your database. Use a JDBC driver compliant with JDBC 4.2 or later. No need for strings, no need for java.sql.* classes.

Where to obtain the java.time classes?

The ThreeTen-Extra project extends java.time with additional classes. This project is a proving ground for possible future additions to java.time. You may find some useful classes here such as Interval, YearWeek, YearQuarter, and more.

Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154
  • What a great solution and explanation, thank you! In my opinion, the most easy to understand. And thanks for sharing the ThreeTen-Extra as well, I didn't know about this project. – Luiz Jul 30 '18 at 16:37
  • This solution does not work with the following case: Initial = `2018-07-21 00:00:00` -- End = `2018-07-30 15:10:00` the result should be 68 hours and the method returns 63 hours. The first 5 hours of the first day is not taken into account. – Luiz Jul 31 '18 at 18:56
  • @Luiz You did not define rules for each end of the input interval. So I went by the zoned dates, each date targeting the starting time you defined (10 PM). If you want to consider the fragment from the day *before* your input interval, hack the target start time in an additional step. Tip: This mixup shows why you should learn to **write a specification** rather than just an example. Not only will it help to avoid these ambiguities, writing the decision rules in plain prose will lead you down the path to the algorithms to write in your code. – Basil Bourque Jul 31 '18 at 19:47
-1

i tried this:

LocalDateTime startDate = LocalDateTime.now().minusDays(1);
LocalDateTime endDate = LocalDateTime.now();

long numberOfHours = Duration.between(startDate, endDate).toHours();

and worked as expected

Output: 24 hours

OR you can joda, the above code will be like:

DateTime startDate = new DateTime().minusDays(1);
DateTime endDate = new DateTime();
Hours hours = Hours.hoursBetween(startDate, endDate);
int numberOfHours = hours.getHours();
  • 1
    I am afraid that if you apply your solution to the example times in the question, you will get exactly the 87 hours that were explicitly **not** asked for. Please read the question again. – Ole V.V. Jul 20 '18 at 15:21
  • FYI, the [*Joda-Time*](http://www.joda.org/joda-time/) project is now in [maintenance mode](https://en.wikipedia.org/wiki/Maintenance_mode), with the team advising migration to the [*java.time*](http://docs.oracle.com/javase/10/docs/api/java/time/package-summary.html) classes. See [Tutorial by Oracle](https://docs.oracle.com/javase/tutorial/datetime/TOC.html). – Basil Bourque Jul 30 '18 at 01:48