Search code examples
kotlinjava-timelocaldateperioddate-difference

Adding Period to startDate doesn't produce endDate


I have two LocalDates declared as following:

val startDate = LocalDate.of(2019, 10, 31)  // 2019-10-31
val endDate = LocalDate.of(2019, 9, 30)     // 2019-09-30

Then I calculate the period between them using Period.between function:

val period = Period.between(startDate, endDate) // P-1M-1D

Here the period has the negative amount of months and days, which is expected given that endDate is earlier than startDate.

However when I add that period back to the startDate, the result I'm getting is not the endDate, but the date one day earlier:

val endDate1 = startDate.plus(period)  // 2019-09-29

So the question is, why doesn't the invariant

startDate.plus(Period.between(startDate, endDate)) == endDate

hold for these two dates?

Is it Period.between who returns an incorrect period, or LocalDate.plus who adds it incorrectly?


Solution

  • If you look how plus is implemented for LocalDate

    @Override
    public LocalDate plus(TemporalAmount amountToAdd) {
        if (amountToAdd instanceof Period) {
            Period periodToAdd = (Period) amountToAdd;
            return plusMonths(periodToAdd.toTotalMonths()).plusDays(periodToAdd.getDays());
        }
        ...
    }
    

    you'll see plusMonths(...) and plusDays(...) there.

    plusMonths handles cases when one month has 31 days, and the other has 30. So the following code will print 2019-09-30 instead of non-existent 2019-09-31

    println(startDate.plusMonths(period.months.toLong()))
    

    After that, subtracting one day results in 2019-09-29. This is the correct result, since 2019-09-29 and 2019-10-31 are 1 month 1 day apart

    The Period.between calculation is weird and in this case boils down to

        LocalDate end = LocalDate.from(endDateExclusive);
        long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();
        int days = end.day - this.day;
        long years = totalMonths / 12;
        int months = (int) (totalMonths % 12);  // safe
        return Period.of(Math.toIntExact(years), months, days);
    

    where getProlepticMonth is total number of months from 00-00-00. In this case, it's 1 month and 1 day.

    From my understanding, it's a bug in a Period.between and LocalDate#plus for negative periods interaction, since the following code has the same meaning

    val startDate = LocalDate.of(2019, 10, 31)
    val endDate = LocalDate.of(2019, 9, 30)
    val period = Period.between(endDate, startDate)
    
    println(endDate.plus(period))
    

    but it prints the correct 2019-10-31.

    The problem is that LocalDate#plusMonths normalises date to be always "correct". In the following code, you can see that after subtracting 1 month from 2019-10-31 the result is 2019-09-31 that is then normalised to 2019-10-30

    public LocalDate plusMonths(long monthsToAdd) {
        ...
        return resolvePreviousValid(newYear, newMonth, day);
    }
    
    private static LocalDate resolvePreviousValid(int year, int month, int day) {
        switch (month) {
            ...
            case 9:
            case 11:
                day = Math.min(day, 30);
                break;
        }
        return new LocalDate(year, month, day);
    }