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.