Search code examples
ruby-on-railsruby

Convert difference between 2 dates into iso8601 duration in Ruby (and Rails if needed)


I would like to convert a couple (start_at, end_at) into an iso 8601 duration, with only 1 periodicity set.

For example:

  1. 2023/03/01 : 2023/03/10 => P9D (9 days) and not P1W2D (1 week 2 days)
  2. 2023/03/31 : 2023/04/28 => P4W
  3. 2023/03/31 : 2023/04/29 => P29D (and not P4W1D)
  4. 2023/03/31 : 2023/04/30 => P1M (because 03/31 + 1.month = 04/30)
  5. 2024/02/29 : 2025/02/28 => P1Y (because 2024 is a leap year)
  6. 2024/02/29 : 2024/03/29 => P1M (because it's the same day, the next month)

In case of ambiguity, I always want the bigger period to win.

For example:

  • 2023/01/31 : 2023/02/28 => P1M & P4W would be both correct, but I prefer P1M because month > week

I know it's really specific, but maybe someone has an idea on how to proceed? This seems like a problem people has already solved. The only resources I've found on the internet were converting a duration (in seconds) into iso 8601, but it can't work for some of the special cases described before (for example, the 4th one).


Solution

  • Days

    I would start by calculating the difference in (whole) days between the two dates:

    d = (to_date - from_date).to_i
    

    If everything else fails, we'd resort to this as PdD.

    Weeks

    Weeks are always 7 days, regardless of months or leap years. We can determine a week-difference via divmod:

    w, r = d.divmod(7)
    

    If the remainder r is zero, the two dates are exactly w weeks apart, or PwW.

    Months

    Due to leap years and months having different number of days, I'd take a different approach for the remaining two cases. I would calculate the approximate number of whole months between the dates via: (30.436875 is the average number of days in a month when including leap years)

    m = (d / 30.436875).round
    

    And then check if this value happens to be an exact match:

    from_date.next_month(m) == to_date
    

    If so, we have PmM.

    Years

    For years, I'd take a similar approach:

    y = m / 12
    

    and check via:

    from_date.next_year(y) == to_date
    

    Which gives PyY if true.

    Conclusion

    Everything in one method: (pure Ruby, no Rails)

    def duration(from_date, to_date)
      d = (to_date - from_date).to_i  # number of days
      m = (d / 30.436875).round       # number of months
      y = m / 12                      # number of years
      w, r = d.divmod(7)              # number of weeks
    
      return "P#{y}Y" if from_date.next_year(y) == to_date
      return "P#{m}M" if from_date.next_month(m) == to_date
      return "P#{w}W" if r == 0
    
      "P#{d}D"
    end
    

    Examples:

    duration(Date.parse('2023/03/01'), Date.parse('2023/03/10')) #=> "P9D"
    duration(Date.parse('2023/03/31'), Date.parse('2023/04/28')) #=> "P4W"
    duration(Date.parse('2023/03/31'), Date.parse('2023/04/29')) #=> "P29D"
    duration(Date.parse('2023/03/31'), Date.parse('2023/04/30')) #=> "P1M"
    duration(Date.parse('2024/02/29'), Date.parse('2025/02/28')) #=> "P1Y"
    duration(Date.parse('2024/02/29'), Date.parse('2024/03/29')) #=> "P1M"
    duration(Date.parse('2023/01/31'), Date.parse('2023/02/28')) #=> "P1M"