3

Given 2 dates, how can I calculate the difference between them in Year Month Day, Hour Minute Second format, as per ISO 8601 Durations?

I've only found Java libraries that can give the difference in days or smaller.

Given that months and years have irregular numbers of days, I'm not sure how to figure out the difference in months and years.

Even Duration.between() is close, but it gives the result in hours minutes seconds:

ZonedDateTime event1 = ZonedDateTime.of(2022, 2, 2, 2, 2, 2, 0, ZoneId.of("UTC-7"));
ZonedDateTime event2 = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC-7"));
//ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC-7"));
Duration duration = Duration.between(event1, event2);
Log.d("Duration ISO-8601: ", duration.toString());


Output:    PT25533H57M58S

Which is 25533 hours, 57 minutes, 58 seconds.

I'd like to see something like:

__ years, __ months, __ days, 9 hours, 57 minutes, 58 seconds
Lajos Arpad
  • 64,414
  • 37
  • 100
  • 175
Gimme the 411
  • 994
  • 9
  • 25
  • 3
    You want to use the Period class, not Duration class - https://docs.oracle.com/javase/tutorial/datetime/iso/period.html – OldProgrammer May 20 '22 at 15:15
  • 1
    @OldProgrammer Or the ThreeTen Extra combo: [PeriodDuration](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/PeriodDuration.html). Or if you don’t want the external dependency, then both a `Period` and a `Duration`. – Ole V.V. May 20 '22 at 15:34
  • 1
    You may want to look at [my answer here](https://stackoverflow.com/a/43976799/5772882). – Ole V.V. May 20 '22 at 19:04

3 Answers3

2

You can create a custom class that will maintain Period and Duration fields (credits to @Ole V.V. since he mentioned it earlier in the comments).

Here is an example of such a class implementation, which exposes a static method between(), that expects two arguments of type LocalDateTime.

Methods like getYears() and getHours() will delegate the call to Period and Duration objects.

class DateTimeSlot {
    private Period period;
    private Duration duration;
    
    private DateTimeSlot(Period period, Duration duration) {
        this.period = period;
        this.duration = duration;
    }
    
    public int getYears() {
        return period.getYears();
    }
    
    public int getMonth() {
        return period.getMonths();
    }
    
    public int getDays() {
        return period.getDays();
    }
    
    public int getHours() {
        return duration.toHoursPart(); // this method can be safely used instead `toHours()` because `between()` implementation guerantees that duration will be less than 24 hours
    }
    
    public int getMinutes() {
        return duration.toMinutesPart();
    }
    
    public int getSeconds() {
        return (int) (duration.getSeconds() % 60);
    }
    
    public static DateTimeSlot between(LocalDateTime from, LocalDateTime to) {
        if (from.isAfter(to) || from.equals(to)) {
            throw new IllegalArgumentException();
        }
        
        Duration duration;
        Period period;
        
        if (from.toLocalTime().isBefore(to.toLocalTime())) {
            duration = Duration.between(from.toLocalTime(), to.toLocalTime());
            period = Period.between(from.toLocalDate(), to.toLocalDate());
        } else {
            duration = Duration.between(to.withHour(from.getHour())
                .withMinute(from.getMinute())
                .withSecond(from.getSecond())
                .minusDays(1), to);                    // apply shift one day back
            period = Period.between(from.toLocalDate()
                .plusDays(1), to.toLocalDate());       // apply shift one day forward (to compensate the previous shift)
        }
        return new DateTimeSlot(period, duration);
    }
    
    @Override
    public String toString() {
        return String.format("%s years, %s months, %s days, %s hours, %s minutes, %s seconds",
            getYears(), getMonth(), getDays(), getHours(), getMinutes(), getSeconds());
    }
}

main() - demo

public static void main(String[] args) {
    LocalDateTime now = LocalDateTime.of(2022, 5, 20, 18, 0, 18);
    LocalDateTime withTimeBefore = LocalDateTime.of(2020, 12, 31, 15, 9, 27);
    LocalDateTime withTimeAfter = LocalDateTime.of(2020, 12, 31, 22, 50, 48);
    
    DateTimeSlot slot1 = DateTimeSlot.between(withTimeBefore, now);
    DateTimeSlot slot2 = DateTimeSlot.between(withTimeAfter, now);
    
    System.out.println(slot1);
    System.out.println(slot2);
}

Output

1 years, 4 months, 20 days, 2 hours, 50 minutes, 51 seconds  // between 2020-12-31T15:09:27 and 2022-05-20T18:00:18
1 years, 4 months, 19 days, 19 hours, 9 minutes, 30 seconds  // between 2020-12-31T22:50:48 and 2022-05-20T18:00:18
Alexander Ivanchenko
  • 25,667
  • 5
  • 22
  • 46
1

I believe I have solved it:

ZonedDateTime event1 = ZonedDateTime.of(2022, 2, 2, 2, 2, 2, 0, ZoneId.of("UTC-7"));
ZonedDateTime event2 = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC-7"));
Duration duration = Duration.between(event1, event2);
Period period = Period.between(event1.toLocalDate(), event2.toLocalDate());
Log.d("Duration ISO-8601: ", period.toString() + duration.toString());

This prints:

P2Y10M30DPT25533H57M58S

Which is 2 years, 10 months, 30 days, 25533 hours, 57 minutes, 58 seconds.

When extracting the individual values (which is what I need), using modulus fixes the problem with hours:

 String.valueOf(duration.toHours() % 24)

And if there's any problem with minutes and seconds, % 60 will fix it.

Gimme the 411
  • 994
  • 9
  • 25
  • 2
    Note that currently that has an extra "P" in the middle which you don't want... – Jon Skeet May 20 '22 at 16:04
  • 1
    If on Java 9 or later, you can just use `duration.toHoursPart()` to get only the hours within any day (so 21 hours in your example). Similarly `toMinutesPart()` etc. – Ole V.V. May 20 '22 at 16:43
  • 1
    Your solution will probably produce funny results around transitions to and from summer time (DST). – Ole V.V. May 21 '22 at 05:26
1

My library Time4J has support for calculating and printing such durations. Example using your input:

    PlainTimestamp event1 = PlainTimestamp.of(2022, 2, 2, 2, 2, 2);
    PlainTimestamp event2 = PlainTimestamp.of(2025, 1, 1, 0, 0, 0);
    TZID tzid = ZonalOffset.ofHours(OffsetSign.BEHIND_UTC, 7);

    Duration<IsoUnit> duration =
        Duration.in(
                Timezone.of(tzid), 
                CalendarUnit.YEARS,
                CalendarUnit.MONTHS, 
                CalendarUnit.DAYS,
                ClockUnit.HOURS, 
                ClockUnit.MINUTES, 
                ClockUnit.SECONDS)
            .between(event1, event2);
    System.out.println(PrettyTime.of(Locale.US).print(duration));
    // 2 years, 10 months, 29 days, 21 hours, 57 minutes, and 58 seconds

A big advantage is here good internationalization of the output including language-dependent plural rules, see also javadoc.

You can also use instants/moments as input and then transform to local timestamps like this way if your input changes (some transformation examples):

    tzid = () -> "America/Chicago"; // probably better than your fixed offset
    event1 = TemporalType.LOCAL_DATE_TIME.translate(LocalDateTime.of(2022, 2, 2, 2, 2, 2));
    event2 = Moment.nowInSystemTime().toZonalTimestamp(tzid);
Meno Hochschild
  • 42,708
  • 7
  • 104
  • 126
  • Thanks, @Meno, for providing this good answer (and for making your connection to the library clear from the outset). The OP mentioned ISO 8601. Can your library print as `P2Y10M29DT21H57M58S` (or some other ISO 8601 variant)? – Ole V.V. May 21 '22 at 17:27
  • 1
    @OleV.V. Simply use [duration.toString()](http://time4j.net/javadoc-en/net/time4j/Duration.html#toStringISO--). General static parsing methods for ISO-representations also exist, like [parsePeriod(String)](http://time4j.net/javadoc-en/net/time4j/Duration.html#parsePeriod-java.lang.String-). – Meno Hochschild May 21 '22 at 22:32