user17931591
user17931591

Reputation:

Fractional month difference between 2 dates (Scala or Java)

I'm trying to find how many months are between 2 dates. My code is something like this right now

ChronoUnit.MONTHS.between(d1, d2)

The problem is that the result is a long. For example if the dates differ only in a few days I should get a result something like 0.34 instead of 0.

Also I need my code to account for the calendar, I cant assume each month has 31 days.

Diff between 1999-05-12 and 1999-08-24
Assuming all months have 31 days for simplicity

result = (19/31 + 31/31 + 31/31 + 24/31) = 2.793
According to the calendar we replace the 31s with the correct number of days for that specific year and month

Upvotes: 2

Views: 707

Answers (3)

Peter
Peter

Reputation: 1260

Sweeper's answer is great! However, for my use case I wasn't completely satisfied getting back fractions of a month when the start date and end date are exactly one month apart. For example:

  • 2024-03-04 2024-04-04 = 1.00322581
  • 2024-06-04 2024-07-04 = 0.99677419

Here are my test cases for the code below. Your definition of a month may vary from mine but please don't ding this answer over semantics :-)

String[][] tests = {
    {"2024-04-14", "2024-04-04"}, //<1 month (10 days or 0.333)
    {"2024-04-04", "2024-04-14"}, //<1 month (10 days or 0.333)
    {"2024-03-04", "2024-04-04"}, //1 month
    {"2024-03-04", "2025-04-04"}, //13 months
    {"2024-06-04", "2024-07-04"}, //1 month
    {"2024-11-28", "2025-02-28"}, //3 months
    {"2024-02-28", "2025-02-28"}, //12 months
    {"2024-02-29", "2025-02-28"}, //12 months (leap year)
    {"2024-11-30", "2025-02-28"}, //3 months
    {"2024-01-01", "2024-01-31"}, //<1 month
    {"2024-01-01", "2025-01-01"}, //12 months
    {"2024-01-01", "2025-12-01"}, //23 months
    {"2024-11-30", "2024-12-31"}, //1 month
    {"2024-09-30", "2024-12-31"}, //3 months
    {"2024-06-30", "2024-12-31"}, //6 months
    {"2024-03-01", "2024-04-15"}, //1+ months

    {"2024-03-31", "2024-04-01"}, //<1 month (1 day)
    {"2024-03-31", "2024-04-05"}, //<1 month (5 days)
    {"2024-03-31", "2024-04-15"}, //0.5 months (15 days)
    {"2024-03-31", "2024-04-30"}, //1 month
    {"2024-03-31", "2024-05-01"}, //1+ month

    {"2024-11-28", "2025-02-27"}, //<3 months
    {"2024-11-28", "2025-02-28"}, //3 months
    {"2024-11-29", "2025-02-28"}, //<3 months
    {"2024-11-30", "2025-02-28"}, //3 months
    {"2024-12-30", "2025-02-28"}, //<2 months

    {"2024-11-15", "2024-11-30"}, //0.5 months
    {"2024-11-30", "2024-11-15"}, //-0.5 months
    {"2024-11-15", "2025-11-30"}, //12.5 months
    {"2023-01-28", "2023-02-27"}, //<1 month

    {"2023-02-27", "2023-03-26"}, //<1 month
    {"2023-02-27", "2023-03-27"}, //1 month
    {"2023-02-27", "2023-03-28"}, //1+ month
    {"2023-02-27", "2023-03-29"}, //1+ month
    {"2023-02-27", "2023-03-30"}, //1+ month
    {"2023-02-27", "2023-03-31"}, //1+ month

    {"2023-02-28", "2023-03-30"}, //1+ month
    {"2023-02-28", "2023-03-31"}, //1 month

    {"2023-01-27", "2023-02-28"}, //>1 month
    {"2023-01-28", "2023-02-28"}, //1 month
    {"2023-01-29", "2023-02-28"}, //<1 month
};

UPDATED CODE

The following code has been validated using the above tests. It does not use Sweeper's code.

private static double getMonthsBetween(LocalDate start, LocalDate end) {


  //Check if the start date is after the end date. Swap dates as needed
    boolean negate = false;
    if (start.isAfter(end)){
        negate = true;
        LocalDate t = start;
        start = end;
        end = t;
    }


  //Check if start/end dates fall on the last of the month
    boolean startIsLastDayInMonth = start.getDayOfMonth() == start.lengthOfMonth();
    boolean endIsLastDayInMonth = end.getDayOfMonth() == end.lengthOfMonth();


  //Calulate months between the 2 dates using Java's built-in ChronoUnit
  //Note that the ChronoUnit "rounds down" the interval between the dates.
    long m = ChronoUnit.MONTHS.between(start, end);


  //When the 2 dates fall on the same day in the month, and the dates aren't
  //on the last day of the month, simply return the value returned by the
  //ChronoUnit class
    int startDay = start.getDayOfMonth();
    int endDay = end.getDayOfMonth();
    if (startDay==endDay){

        if (startIsLastDayInMonth && !endIsLastDayInMonth ||
            !startIsLastDayInMonth && endIsLastDayInMonth){
            //Example: 2024-11-28 2025-02-28
        }
        else{
            return m;
        }
    }


  //If we're still here, compute fractions
    double fraction = 0.0;
    if (m==0 && start.getMonthValue()==end.getMonthValue()){

      //Simply compare the days of the month
        fraction = (end.getDayOfMonth()-start.getDayOfMonth())/(double)end.lengthOfMonth();

    }
    else{

      //Create new end date using the original end date. Adjust the day
      //of the month to match the start date. The new date will be either
      //before or after the original end date.
        int maxDays = LocalDate.of(end.getYear(), end.getMonthValue(), 1).lengthOfMonth();
        LocalDate e2 = LocalDate.of(end.getYear(), end.getMonthValue(), Math.min(start.getDayOfMonth(), maxDays));

        if (start.getDayOfMonth()>maxDays){

          //Create new date a few days after the end of the month
            LocalDate d = e2.plusDays(start.getDayOfMonth()-maxDays);

          //Calulate months between the start date and the new date
            m = ChronoUnit.MONTHS.between(start, d);

          //Calculate fraction
            if (startIsLastDayInMonth && endIsLastDayInMonth){}
            else{

                if (!startIsLastDayInMonth){
                    fraction = -((start.lengthOfMonth()-start.getDayOfMonth())/(double)start.lengthOfMonth());
                }
                else{
                    fraction = -(1-((end.getDayOfMonth())/(double)maxDays));
                }
            }
        }
        else{

          //Calulate months between the start date and the new end date
            m = ChronoUnit.MONTHS.between(start, e2);

          //Calculate fraction
            if (e2.isAfter(end)){

              //subtract from e2
                int n = e2.getDayOfMonth()-end.getDayOfMonth();
                double f = (double)n/(double)end.lengthOfMonth();

                if (m==0){
                    fraction = 1-f;
                }
                else{
                    fraction = -f;
                }

            }
            else if (e2.isBefore(end)){

              //add from e2
                fraction = (end.getDayOfMonth()-start.getDayOfMonth())/(double)end.lengthOfMonth();

            }
        }
    }


  //Add months and fractions
    double diff = fraction+(double)m;


  //When the 2 dates fall on the the last day of the month, round up
    if (startIsLastDayInMonth && endIsLastDayInMonth){
        diff = Math.round(diff);
    }


  //Return diff
    return negate ? -diff : diff;
}

ORIGINAL CODE (doesn't quite work)

For the sake of the comments, here's the original code that had issues. It called Sweeper's code for most use cases.

public static double getMonthsBetween(LocalDate startLocal, LocalDate endLocal) {

    boolean startIsLastDayInMonth = startLocal.getDayOfMonth() == startLocal.lengthOfMonth();
    boolean endIsLastDayInMonth = endLocal.getDayOfMonth() == endLocal.lengthOfMonth();


  //When the 2 dates fall on the same day in the month, don't return fractions
    int startDay = startLocal.getDayOfMonth();
    int endDay = endLocal.getDayOfMonth();
    if (startDay==endDay){

        if (startIsLastDayInMonth && !endIsLastDayInMonth ||
            !startIsLastDayInMonth && endIsLastDayInMonth){

            //Example: 2024-11-28 2025-02-28
        }
        else{
            return ChronoUnit.MONTHS.between(startLocal, endLocal);
        }
    }


  //If we're still here, get fractional month difference between the two
  //dates using @Sweeper awesome code!
    double diff = monthsBetween(startLocal, endLocal);


  //When the 2 dates fall on the the last day of the month, round up
    if (startIsLastDayInMonth && endIsLastDayInMonth){
        diff = Math.round(diff);
    }


    return diff;
}
public static double monthsBetween(LocalDate start, LocalDate end) {
    if (start.isAfter(end)) throw new IllegalArgumentException("Start must be before end!");

    var lastDayOfStartMonth = start.with(TemporalAdjusters.lastDayOfMonth());
    var firstDayOfEndMonth = end.with(TemporalAdjusters.firstDayOfMonth());
    var startMonthLength = (double)start.lengthOfMonth();
    var endMonthLength = (double)end.lengthOfMonth();
    if (lastDayOfStartMonth.isAfter(firstDayOfEndMonth)) { // same month
        return ChronoUnit.DAYS.between(start, end) / startMonthLength;
    }
    long months = ChronoUnit.MONTHS.between(lastDayOfStartMonth, firstDayOfEndMonth);
    double startFraction = ChronoUnit.DAYS.between(start, lastDayOfStartMonth.plusDays(1)) / startMonthLength;
    double endFraction = ChronoUnit.DAYS.between(firstDayOfEndMonth, end) / endMonthLength;
    return months + startFraction + endFraction;
}

Upvotes: 0

Sweeper
Sweeper

Reputation: 270995

Here is my solution:

public static double monthsBetween(LocalDate start, LocalDate end) {
    if (start.isAfter(end)) throw new IllegalArgumentException("Start must be before end!");

    var lastDayOfStartMonth = start.with(TemporalAdjusters.lastDayOfMonth());
    var firstDayOfEndMonth = end.with(TemporalAdjusters.firstDayOfMonth());
    var startMonthLength = (double)start.lengthOfMonth();
    var endMonthLength = (double)end.lengthOfMonth();
    if (lastDayOfStartMonth.isAfter(firstDayOfEndMonth)) { // same month
        return ChronoUnit.DAYS.between(start, end) / startMonthLength;
    }
    long months = ChronoUnit.MONTHS.between(lastDayOfStartMonth, firstDayOfEndMonth);
    double startFraction = ChronoUnit.DAYS.between(start, lastDayOfStartMonth.plusDays(1)) / startMonthLength;
    double endFraction = ChronoUnit.DAYS.between(firstDayOfEndMonth, end) / endMonthLength;
    return months + startFraction + endFraction;
}

The idea is that you find the last day of start's month (lastDayOfStartMonth), and the first day of end's month (firstDayOfEndMonth) using temporal adjusters. These two dates are very important. The number you want is the sum of:

  • the fractional number of a month between start and lastDayOfStartMonth
  • the whole number of months between lastDayOfStartMonth and firstDayOfEndMonth.
  • the fractional number of a month between firstDayOfEndMonth and end.

Then there is the edge case of when both dates are within the same month, which is easy to handle.

By using this definition, we preserve the useful property of:

For all local dates a, b, c where a < b < c, monthsBetween(a,b) + monthsBetween(b,c) == monthsBetween(a, c).

Also, the number of months between two start-of-months are always a whole number.

Note that in the first calculation, you have to add one day to lastDayOfStartMonth, because ChronoUnit.between treats the upper bound as exclusive, but we actually want to count it as one day here.

Upvotes: 5

Alexander Ivanchenko
Alexander Ivanchenko

Reputation: 28968

To approach this problem, you need to consider the following cases:

  • dates belong to the same year and month;

  • dates belong to different year and/or month;

  • dates are invalid.

When dates belong to the same year and month, then the result would be the difference in days between the two dates divided by the number of days in this month, which can be found using LocalDate.lengthOfMonth().

In the general case, the range of dates can be split into three parts:

  • two fractional parts at the beginning and at the end of the given range of dates (both could be evaluated using the approach for the simplest case when both data belong to the same year/month)
  • the whole part, we can use ChronoUnit.MONTHS.between() to calculate it.

Here's how implementation might look like (d1 - inclusive, d2 - exclusive):

public static double getFractionalMonthDiff(LocalDate d1, LocalDate d2) {
    if (d1.isAfter(d2)) throw new IllegalArgumentException(); // or return a value like -1
    
    if (d1.getYear() == d2.getYear() && d1.getMonth() == d2.getMonth()) { // dates belong to same month and year
        
        return getFractionalPartOfMonth(d2.getDayOfMonth() - d1.getDayOfMonth(), d1.lengthOfMonth());
    }
    
    int monthLen1 = d1.lengthOfMonth();
    
    return getFractionalPartOfMonth(monthLen1 - (d1.getDayOfMonth() - 1), monthLen1) // from the given day of month of the First Date d1 Inclusive to the Last day of month
        + getFractionalPartOfMonth(d2.getDayOfMonth() - 1, d2.lengthOfMonth())       // from the First day of month to given day of month of the Second Date d2 Exclusive (for that reason 1 day was subtracted, and similarly on the previous line 1 day was added)
        + ChronoUnit.MONTHS.between(d1.withDayOfMonth(monthLen1), d2.withDayOfMonth(1));
}

public static double getFractionalPartOfMonth(int daysInterval, int monthLength) {
    return daysInterval / (double) monthLength;
}

Upvotes: 0

Related Questions