Calculate how many n.months between two dates - ruby-on-rails

I want to calculate the distance between two dates by month:
Q: start_date + n.months >= end_dates, what is the variable n?
start_date = Date.parse('2021-01-31')
end_date = Date.parse('2021-02-28')
## start_date + 1.months = 2021-02-28
## The answer 1 month, which is more clearable for human using
## Don't want to find the answer like 28 days or 0.93 month. (30 day a month)
First I tried to let each month is 30.days, but the answer will have some bias on some special dates. Each month's date is different, and the date on End of Feb month is always the problem.
Then I tried to install gems like date_diff, time_difference..., but no methods can do this, most of the output is 28 days but not 1 month.
For simple way, I can easily do the iterated loop to find the n, like:
start_date = Date.parse('2021-01-31')
end_date = Date.parse('2021-02-28')
def calc_diff(start_date, end_date)
n = 0
while start_date + n.months < end_date
n += 1
end
n
end
Is there any better way to find the n months between two dates instead, but not use a loop?
Thank you.

My understanding of the question is consistent with the examples below. I have computed the difference between Date objects date1 and date2, where date2 >= date1.
require 'date'
def months_between(date1, date2)
12*(date2.yr - date1.yr) + date2.mon - date1.mon + date2.day > date1.day ? 1 : 0
end
months_between Date.new(2020, 1, 22), Date.new(2020, 3, 21) #=> 2
months_between Date.new(2020, 1, 22), Date.new(2020, 3, 22) #=> 2
months_between Date.new(2020, 1, 22), Date.new(2020, 3, 23) #=> 3
months_between Date.new(2020, 1, 22), Date.new(2021, 3, 21) #=> 14
months_between Date.new(2020, 1, 22), Date.new(2021, 3, 22) #=> 14
months_between Date.new(2020, 1, 22), Date.new(2021, 3, 23) #=> 15

# find minimum n so that `start_date + n.months >= end_dates`
def calc_diff(start_date, end_date)
diff = (end_date.yday - start_date.yday) / 30
return diff if start_date + diff.months >= end_date
diff + 1
end
calc_diff(Date.parse('2021-01-31'), Date.parse('2021-02-28')) # 1
calc_diff(Date.parse('2021-01-31'), Date.parse('2021-04-30')) # 3
calc_diff(Date.parse('2021-01-31'), Date.parse('2021-05-31')) # 4
calc_diff(Date.parse('2021-02-01'), Date.parse('2021-06-01')) # 4
calc_diff(Date.parse('2021-02-01'), Date.parse('2021-06-02')) # 5

Thanks for #Cary's and #Lam's answer.
Here is my answer to find the n month.
# try to find the minimum n month between start_date and target_date
def calc_diff(start_date, target_date)
months_diff = (target_date.year * 12 + target_date.month) - (start_date.year * 12 + start_date.month)
## need to check the end of month because some special case
## start date: 2020-01-31 ; end date 2020-06-30
## the minimum n month must be 5
## another special case of Feb must consider (test case 15)
if start_date.day > target_date.day && !((start_date == start_date.end_of_month || target_date.month == 2) && (target_date == target_date.end_of_month))
months_diff = months_diff - 1
end
puts months_diff # it will show the minimum whole n month
# the target_date will between inside
# (start_date + months_diff.months) <= target_date < (start_date + (months_diff + 1).months)
(start_date + months_diff.months)..(start_date + (months_diff + 1).months)
end
The Test Cases:
## test case 1
## 6/15 - 7/15 => n = 5
calc_diff(Date.parse('2020-01-15'), Date.parse('2020-06-19'))
## test case 2
## 7/15 - 8/15 => n = 6
calc_diff(Date.parse('2020-01-15'), Date.parse('2020-07-15'))
## test case 3
## 5/15 - 6/15 => n = 4
calc_diff(Date.parse('2020-01-15'), Date.parse('2020-06-01'))
## test case 4 (special case)
## 6/30 - 7/31 => n = 5
calc_diff(Date.parse('2020-01-31'), Date.parse('2020-06-30'))
## test case 5
## 7/30 - 8/30 => n = 4
calc_diff(Date.parse('2020-04-30'), Date.parse('2020-07-31'))
## test case 6
## 6/30 - 7/30 => n = 2
calc_diff(Date.parse('2020-04-30'), Date.parse('2020-06-30'))
## test case 7
## 5/31 - 6/30 => n = 4
calc_diff(Date.parse('2020-01-31'), Date.parse('2020-05-31'))
## test case 8
## 2/29 - 3/31 => n = 1
calc_diff(Date.parse('2020-01-31'), Date.parse('2020-02-29'))
## test case 9
## 6/29 - 7/29 => n = 4
calc_diff(Date.parse('2020-02-29'), Date.parse('2020-06-30'))
## test case 10
## 7/29 - 8/29 => n = 5
calc_diff(Date.parse('2020-02-29'), Date.parse('2020-07-31'))
## test case 11
## 1/31 - 2/29 => n = 0
calc_diff(Date.parse('2020-01-31'), Date.parse('2020-02-28'))
## test case 12
## 2/29 - 3/31 => n = 1
calc_diff(Date.parse('2020-01-31'), Date.parse('2020-03-01'))
## test case 13
## 1/17 - 2/17 => n = 0
calc_diff(Date.parse('2020-01-17'), Date.parse('2020-01-17'))
## test case 14
## 1/17 - 2/17 => n = 0
calc_diff(Date.parse('2020-01-17'), Date.parse('2020-01-18'))
## test case 15 (special case)
## 1/30 - 2/29 => n = 1
calc_diff(Date.parse('2019-12-30'), Date.parse('2020-02-28'))
## test case 16
## 2/29 - 3/30 => n = 2
calc_diff(Date.parse('2019-12-30'), Date.parse('2020-02-29'))

Related

how to convert integer(months) into human friendly string?

I need to translate a number of months, integer based, into a human friendly string containing information about years. its not easy to explain so I will just provide examples. inputs and outputs I want are:
input: 19
output: "1 year, 7 months"
input: 24
output: "2 years"
input: 26
output: "2 years, 2 months"
do you know any out of the box solutions? if not, how would you implement it yourself?
input = 26
year, month = input.divmod(12)
if month.eql? 0 and year > 1
puts "#{year} years"
elsif month.eql? 0 and year.eql? 1
puts "#{year} year"
elsif year > 1
puts "#{year} years, #{month} month"
else
puts "#{year} year, #{month} month"
end
Output
2 years, 2 month
Since this question is tagged with Ruby on Rails, ActiveSupport extensions are available, so this works too:
number_of_months = 19 # for example
ActiveSupport::Duration.build(number_of_months.months).inspect.gsub(' and',', ')
Edit
I just noticed that there was a bug in ActiveSupport::Duration version 6.0.2.2 that was fixed sometime prior to version 6.1.0.alpha that caused rounding errors for certain values of number_of_months.
Just for fun:
num.divmod(12).then { |y, m| [y, m].zip ['years', 'months'] }
.reject{ |e| e.first.zero? }
.each{ |e| e.last.delete_suffix!('s') if e.first == 1 }
.join(' ')
.tap{ |res| res.replace('0 months') if res.empty? }
Samples:
[11, 12, 13, 23, 24, 25, 26].each { |n| p [n, n.divmod(12).then.......] } # pseudo
[11, "11 months"]
[12, "1 year"]
[13, "1 year 1 month"]
[23, "1 year 11 months"]
[24, "2 years"]
[25, "2 years 1 month"]
[26, "2 years 2 months"]
def pluralize(num, string)
[num, (num == 1 ? string : "#{string}s")] * ' '
end
def humanize_months(months)
months = input % 12
years = input / 12
text = []
text << pluralize(years, 'year') if years > 0
text << pluralize(months, 'month') if months > 0
text * ', '
end

How to get start and end date from quarters?

I am building a rails 5 app.
I need to be able to get the dates that is from a current quarter. With that I mean the user will provide me with a selected quarter (1 to 4) and I will convert that number to a start and end date for that selected quarter. How can I do that?
This is how I tried it but it is good?
def quarter_date(quarter, year)
if quarter == 1
where(date_at: Time.parse("01-01-#{year}")..Time.parse("01-03-#{year}"))
elsif quarter == 2
where(date_at: Time.parse("01-04-#{year}")..Time.parse("01-06-#{year}"))
elsif quarter == 3
where(date_at: Time.parse("01-07-#{year}")..Time.parse("01-09-#{year}"))
elsif quarter == 4
where(date_at: Time.parse("01-10-#{year}")..Time.parse("01-12-#{year}"))
end
end
You mean something like this?
require 'date'
today = Date.today
=> #<Date: 2018-07-02 ((2458302j,0s,0n),+0s,2299161j)>
year = today.year
=> 2018
input = 3
start_date = Date.new(2018, input * 3 - 2, 1)
=> #<Date: 2018-07-01 ((2458301j,0s,0n),+0s,2299161j)>
end_date = Date.new(2018, input * 3 + 1, 1) - 1
=> #<Date: 2018-09-30 ((2458392j,0s,0n),+0s,2299161j)>
It returns the start and end dates for the given quarter of current year.
Update
Updated with method from your attempt:
def quarter_date_range(quarter, year)
start_date = Time.parse("#{year}-#{quarter * 3 - 2}-1")
end_date = (start_date + 2.months).end_of_month
where(date_at: start_date..end_date)
end

Count working hours between two datetimes in Ruby

I want to count "morning" and "evening" working hours between two datetimes in Ruby.
"Morning" working hours starts at 06:00:00 and ends at 21:59:59
"Evening" working hours starts at 22:00:00 and ends at 05:59:59
For example, employee started work at 2014-06-04 21:45 and completed at 2014-06-05 05:45, it means, in summary should be:
0.25 "morning" working hours
7.75 "evening" working hours
Total 8 working hours
This could be done using the biz gem:
morning_schedule = Biz::Schedule.new do |config|
config.hours = {
mon: {'06:00' => '22:00'},
tue: {'06:00' => '22:00'},
wed: {'06:00' => '22:00'},
thu: {'06:00' => '22:00'},
fri: {'06:00' => '22:00'}
}
end
evening_schedule = Biz::Schedule.new do |config|
config.hours = {
mon: {'00:00' => '06:00', '22:00' => '24:00'},
tue: {'00:00' => '06:00', '22:00' => '24:00'},
wed: {'00:00' => '06:00', '22:00' => '24:00'},
thu: {'00:00' => '06:00', '22:00' => '24:00'},
fri: {'00:00' => '06:00', '22:00' => '24:00'}
}
end
# morning hours worked
morning_schedule.within(
Time.new(2014, 6, 4, 21, 45),
Time.new(2014, 6, 5, 5, 45)
)
# evening hours worked
evening_schedule.within(
Time.new(2014, 6, 4, 21, 45),
Time.new(2014, 6, 5, 5, 45)
)
With the duration of each working time type determined, subsequent calculations, such as with salary coefficients, can be performed.
You can use the working_hours gem that does this kind of computing : https://github.com/intrepidd/working_hours
Here's an ugly implementation (should work for any range of time):
def time_test(tstr1, tstr2)
t1 = Time.parse(tstr1)
t2 = Time.parse(tstr2)
morning_seconds = 0
evening_seconds = 0
while t1 < t2 do
# if we are starting during morning hours...
if t1.hour >= 6 && t1.hour < 22
# this sets the end of morning hours for the current day...
tend = t1.change(hour: 22)
# but our actual morning_end should be the lesser of the range end time
# and the end of current days' working hours
morning_end = tend < t2 ? tend : t2
# increment our seconds with the difference between start time and morning end
morning_seconds += morning_end - t1
# set our start time to morning_end. if morning_end == t2, then we'll exit
# the while loop, else we'll process some evening hours
t1 = morning_end
# if we are starting during evening hours (at end of day)
elsif t1.hour >= 22
# same as above, except that the potential end time needs to increment the day
# as well as the hour
tend = t1.tomorrow.change(hour: 6)
evening_end = tend < t2 ? tend : t2
evening_seconds += evening_end - t1
t1 = evening_end
# if we are starting during evening hours (at beginning of day)
elsif t1.hour < 6
tend = t1.change(hour: 6)
evening_end = tend < t2 ? tend : t2
evening_seconds += evening_end - t1
t1 = evening_end
end
end
{
morning: morning_seconds / 3600,
evening: evening_seconds / 3600,
total: (morning_seconds + evening_seconds) / 3600
}
end
time_test('2014-06-04 21:45', '2014-06-05 05:45')
# => {:morning=>0.25, :evening=>7.75, :total=>8.0}
And here is a slightly prettier recursive way:
def time_test(tstr1, tstr2)
_t1 = Time.parse(tstr1)
t2 = Time.parse(tstr2)
runner = lambda do |t1, ms, es|
return {
morning: ms / 3600, evening: es / 3600, total: (ms + es) / 3600
} if t1 >= t2
if t1.hour >= 6 && t1.hour < 22
tend = t1.change(hour: 22)
morning_end = tend < t2 ? tend : t2
runner.call(morning_end, ms + (morning_end - t1), es)
elsif t1.hour >= 22
tend = t1.tomorrow.change(hour: 6)
evening_end = tend < t2 ? tend : t2
runner.call(evening_end, ms, es + (evening_end - t1))
elsif t1.hour < 6
tend = t1.change(hour: 6)
evening_end = tend < t2 ? tend : t2
runner.call(evening_end, ms, es + (evening_end - t1))
end
end
runner.call(_t1, 0, 0)
end

How to determine the nth weekday for a given month?

This snippet of code determines the date for the nth weekday of a given month.
Example: for the 2nd Tuesday of December 2013:
>> nth_weekday(2013,11,2,2)
=> Tue Nov 12 00:00:00 UTC 2013
Last Sunday of December 2013:
>> nth_weekday(2013,12,'last',0)
=> Sun Dec 29 00:00:00 UTC 2013
I was not able to find working code for this question so I'm sharing my own.
If you are using Rails, you can do this.
def nth_weekday(year, month, n, wday)
first_day = DateTime.new(year, month, 1)
arr = (first_day..(first_day.end_of_month)).to_a.select {|d| d.wday == wday }
n == 'last' ? arr.last : arr[n - 1]
end
> n = nth_weekday(2013,11,2,2)
# => Tue, 12 Nov 2013 00:00:00 +0000
You can use:
require 'date'
class Date
#~ class DateError < ArgumentError; end
#Get the third monday in march 2008: new_by_mday( 2008, 3, 1, 3)
#
#Based on http://forum.ruby-portal.de/viewtopic.php?f=1&t=6157
def self.new_by_mday(year, month, weekday, nr)
raise( ArgumentError, "No number for weekday/nr") unless weekday.respond_to?(:between?) and nr.respond_to?(:between?)
raise( ArgumentError, "Number not in Range 1..5: #{nr}") unless nr.between?(1,5)
raise( ArgumentError, "Weekday not between 0 (Sunday)and 6 (Saturday): #{nr}") unless weekday.between?(0,6)
day = (weekday-Date.new(year, month, 1).wday)%7 + (nr-1)*7 + 1
if nr == 5
lastday = (Date.new(year, (month)%12+1, 1)-1).day # each december has the same no. of days
raise "There are not 5 weekdays with number #{weekday} in month #{month}" if day > lastday
end
Date.new(year, month, day)
end
end
p Date.new_by_mday(2013,11,2,2)
This is also available in a gem:
gem "date_tools", "~> 0.1.0"
require 'date_tools/date_creator'
p Date.new_by_mday(2013,11,2,2)
# nth can be 1..4 or 'last'
def nth_weekday(year,month,nth,week_day)
first_date = Time.gm(year,month,1)
last_date = month < 12 ? Time.gm(year,month+1)-1.day : Time.gm(year+1,1)-1.day
date = nil
if nth.class == Fixnum and nth > 0 and nth < 5
date = first_date
nth_counter = 0
while date <= last_date
nth_counter += 1 if date.wday == week_day
nth_counter == nth ? break : date += 1.day
end
elsif nth == 'last'
date = last_date
while date >= first_date
date.wday == week_day ? break : date -= 1.day
end
else
raise 'Error: nth_weekday called with out of range parameters'
end
return date
end

Ruby: given a date find the next 2nd or 4th Tuesday

I can't seem to find an elegant way to do this...
Given a date how can I find the next Tuesday that is either the 2nd or the 4th Tuesday of the calendar month?
For example:
Given 2012-10-19 then return 2012-10-23
or
Given 2012-10-31 then return 2012-11-13
October November
Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr Sa
1 2 3 4 5 6 1 2 3
7 8 9 10 11 12 13 4 5 6 7 8 9 10
14 15 16 17 18 19 20 11 12 13 14 15 16 17
21 22 23 24 25 26 27 18 19 20 21 22 23 24
28 29 30 31 25 26 27 28 29 30
Scroll to the bottom if you just want to see what the end result can look like..
Using code snippets from some date processing work I've done recently in ruby 1.9.3.
Some upgrades to DateTime:
require 'date'
class DateTime
ALL_DAYS = [ 'sunday', 'monday', 'tuesday',
'wednesday', 'thursday', 'friday', 'saturday' ]
def next_week
self + (7 - self.wday)
end
def next_wday (n)
n > self.wday ? self + (n - self.wday) : self.next_week.next_day(n)
end
def nth_wday (n, i)
current = self.next_wday(n)
while (i > 0)
current = current.next_wday(n)
i = i - 1
end
current
end
def first_of_month
self - self.mday + 1
end
def last_of_month
self.first_of_month.next_month - 1
end
end
method_missing Tricks:
I have also supplemented the class with some method missing tricks to map calls from next_tuesday to next_wday(2) andnth_tuesday(2)tonth_wday(2, 2)`, which makes the next snippet easier on the eyes.
class DateTime
# ...
def method_missing (sym, *args, &block)
day = sym.to_s.gsub(/^(next|nth)_(?<day>[a-zA-Z]+)$/i, '\k<day>')
dindex = ALL_DAYS.include?(day) ? ALL_DAYS.index(day.downcase) : nil
if (sym =~ /^next_[a-zA-Z]+$/i) && dindex
self.send(:next_wday, dindex)
elsif (sym =~ /^nth_[a-zA-Z]+$/i) && dindex
self.send(:nth_wday, dindex, args[0])
else
super(sym, *args, &block)
end
end
def respond_to? (sym)
day = sym.to_s.gsub(/^(next|nth)_(?<day>[a-zA-Z]+)$/i, '\k<day>')
(((sym =~ /^next_[a-zA-Z]+$/i) || (sym =~ /^nth_[a-zA-Z]+$/i)) && ALL_DAYS.include?(day)) || super(sym)
end
end
Example:
Given a date:
today = DateTime.now
second_tuesday = (today.first_of_month - 1).nth_tuesday(2)
fourth_tuesday = (today.first_of_month - 1).nth_tuesday(4)
if today == second_tuesday
puts "Today is the second tuesday of this month!"
elsif today == fourth_tuesday
puts "Today is the fourth tuesday of this month!"
else
puts "Today is not interesting."
end
You could also edit method_missing to handle calls such as :second_tuesday_of_this_month, :fourth_tuesday_of_this_month, etc. I'll post the code here if I decide to write it at a later date.
Take a look at Chronic or Tickle, both are gems for parsing complex times and dates. Tickle in particular will parse recurring times (I think it uses Chronic as well).
Check out this gem, you might be able to figure out your answer
https://github.com/mojombo/chronic/
Since you already use Rails, you don't need the includes, but this works in pure Ruby as well for reference.
require 'rubygems'
require 'active_support/core_ext'
d = DateTime.parse('2012-10-19')
result = nil
valid_weeks = [d.beginning_of_month.cweek + 1, d.beginning_of_month.cweek + 3]
if valid_weeks.include?(d.next_week(:tuesday).cweek)
result = d.next_week(:tuesday)
else
result = d.next_week.next_week(:tuesday)
end
puts result
I think you should probably use a library if you're needing to branch out into more interesting logic, but if what you've described is all you need, the code below should help
SECONDS_PER_DAY = 60 * 60 * 24
def find_tuesday_datenight(now)
tuesdays = [*-31..62].map { |i| now + (SECONDS_PER_DAY * i) }
.select { |d| d.tuesday? }
.group_by { |d| d.month }
[tuesdays[now.month][1], tuesdays[now.month][-2], tuesdays[(now.month + 1) % 12][1]]
.find {|d| d.yday > now.yday }
end
Loop through the last month and next month, grab the tuesdays, group by month, take the 2nd and the 2nd last tuesday of the current month (If you actually do want the 4th tuesday, just change -2 to 3) and the 2nd tuesday of the next month and then choose the first one after the provided date.
Here's some tests, 4 tuesdays in month, 5 tuesdays in month, random, and your examples:
[[2013, 5, 1], [2013, 12, 1], [2012, 10, 1], [2012, 10, 19], [2012, 10, 31]].each do |t|
puts "#{t} => #{find_tuesday_datenight(Time.new *t)}"
end
which produces
[2013, 5, 1] => 2013-05-14 00:00:00 +0800
[2013, 12, 1] => 2013-12-10 00:00:00 +0800
[2012, 10, 1] => 2012-10-09 00:00:00 +0800
[2012, 10, 19] => 2012-10-23 00:00:00 +0800
[2012, 10, 31] => 2012-11-13 00:00:00 +0800
I'm sure it could be simplified, and I'd love to hear some suggestions :) (way too late &tired to even bother figuring out what the actual range should be for valid dates i.e. smaller than -31..62)
so here is the code that will resolve a weekday for a given week in a month (what you asked for with little sugar). You should not have problems if you are running inside rails framework. Otherwise make sure you have active_support gem installed. Method name is stupid so feel free to change it :)
usage: get_next_day_of_week(some_date, :friday, 1)
require 'rubygems'
require 'active_support/core_ext'
def get_next_day_of_week(date, day_name, count)
next_date = date + (-date.days_to_week_start(day_name.to_sym) % 7)
while (next_date.mday / 7) != count - 1 do
next_date = next_date + 7
end
next_date
end
I use the following to calculate Microsoft's patch Tuesday date. It was adapted from some C# code.
require 'date'
#find nth iteration of given day (day specified in 'weekday' variable)
findnthday = 2
#Ruby wday number (days are numbered 0-7 beginning with Sunday)
weekday = 2
today = Time.now
todayM = today.month
todayY = today.year
StrtMonth = DateTime.new(todayY,todayM ,1)
while StrtMonth.wday != weekday do
StrtMonth = StrtMonth + 1;
end
PatchTuesday = StrtMonth + (7 * (findnthday - 1))

Resources