How to divide a time period into multiple periods - ruby-on-rails

I have a time period in two variables, from start_date to end_date. Is there an easy way to split it into a smaller periods, rounding the values up.
Here's an example:
I have period from 15.01.2016 to 10.06.2016. I want to split it into months, so I will have six periods:
01.01.2016 - 31.01.2016
01.02.2016 - 31.02.2016
01.03.2016 - 31.03.2016
01.04.2016 - 31.04.2016
01.05.2016 - 31.05.2016
01.06.2016 - 31.06.2016
I want to include the time between 01.01.2016 and 15.01.2016 regardless of the fact that it is not in the original period.
I've been looking for some ideas, but at this time it seems that the only way is using the start date and iterating using DateAndTime::Calculations to determine the borders of intervals, until hitting the end date.

from = Date.parse('15.01.2016')
to = Date.parse('10.06.2016')
(from..to).group_by(&:month).map do |group|
group.last.first.beginning_of_month..group.last.last.end_of_month
end
# => [Fri, 01 Jan 2016..Sun, 31 Jan 2016,
# Mon, 01 Feb 2016..Mon, 29 Feb 2016,
# Tue, 01 Mar 2016..Thu, 31 Mar 2016,
# Fri, 01 Apr 2016..Sat, 30 Apr 2016,
# Sun, 01 May 2016..Tue, 31 May 2016,
# Wed, 01 Jun 2016..Thu, 30 Jun 2016]
Or, map dates to strings, if you need string representation:
(from..to).group_by(&:month).map do |group|
"#{group.last.first.beginning_of_month} - #{group.last.last.end_of_month}"
end
# => ["2016-01-01 - 2016-01-31",
# "2016-02-01 - 2016-02-29",
# "2016-03-01 - 2016-03-31",
# "2016-04-01 - 2016-04-30",
# "2016-05-01 - 2016-05-31",
# "2016-06-01 - 2016-06-30"]
To get precisely what you want, you can format the string representation of the dates:
(from..to).group_by(&:month).map do |group|
"#{group.last.first.beginning_of_month.strftime('%d-%m-%Y')} - #{group.last.last.end_of_month.strftime('%d-%m-%Y')}"
end
#=> ["01-01-2016 - 31-01-2016",
# "01-02-2016 - 29-02-2016",
# "01-03-2016 - 31-03-2016",
# "01-04-2016 - 30-04-2016",
# "01-05-2016 - 31-05-2016",
# "01-06-2016 - 30-06-2016"]
EDIT
To make sure ranges do not mix up because of different years, include it in grouping along with month:
from = Date.parse('15.01.2016')
to = Date.parse('10.02.2017')
(from..to).group_by {|a| [a.year, a.month]}.map do |group|
"#{group.last.first.beginning_of_month.strftime('%d-%m-%Y')} - #{group.last.last.end_of_month.strftime('%d-%m-%Y')}"
end
# => ["01-01-2016 - 31-01-2016",
# "01-02-2016 - 29-02-2016",
# "01-03-2016 - 31-03-2016",
# "01-04-2016 - 30-04-2016",
# "01-05-2016 - 31-05-2016",
# "01-06-2016 - 30-06-2016",
# "01-07-2016 - 31-07-2016",
# "01-08-2016 - 31-08-2016",
# "01-09-2016 - 30-09-2016",
# "01-10-2016 - 31-10-2016",
# "01-11-2016 - 30-11-2016",
# "01-12-2016 - 31-12-2016",
# "01-01-2017 - 31-01-2017",
# "01-02-2017 - 28-02-2017"]

This approach avoids the need to examine every day in the date range and returns an array of strings in the desired format.
Date#>> shifts the date forward by the number of months given by the argument. Here I've shifted dates by one month (e.g., Date.parse("14-01-2016") >> 1 #=> #<Date: 2016-02-14...>). If the last day of the following month is greater than the last day of the given month, the last day of the following month is returned (e.g., Date.parse("30-01-2016") >> 1 #=> #<Date: 2016-02-29...>).
Code
require 'datetime'
def convert(sdate, edate)
dfirst = Date.strptime(sdate, "%d.%m.%Y")
dlast = Date.strptime(edate, "%d.%m.%Y")
nmonths = 12*dlast.year + dlast.month - 12*dfirst.year - dfirst.month + 1
dlast = dfirst.end_of_month
nmonths.times.map { |i|
"%s - %s" % [(dfirst >> i).strftime("01.%m.%Y"), (dlast >> i).strftime("%d.%m.%Y")]}
end
Example
convert "15.01.2016", "10.06.2016"
#=> ["01.01.2016 - 31.01.2016", "01.02.2016 - 29.02.2016",
# "01.03.2016 - 31.03.2016", "01.04.2016 - 30.04.2016",
# "01.05.2016 - 31.05.2016", "01.06.2016 - 30.06.2016"]
Explanation
The steps are as follows.
sdate = "15.01.2016"
edate = "10.06.2016"
dfirst = Date.strptime(sdate, "%d.%m.%Y")
#=> #<Date: 2016-01-15 ((2457403j,0s,0n),+0s,2299161j)>
dlast = Date.strptime(edate, "%d.%m.%Y")
#=> #<Date: 2016-06-10 ((2457550j,0s,0n),+0s,2299161j)>
nmonths = 12*dlast.year + dlast.month - 12*dfirst.year - dfirst.month + 1
#=> 12*24192 + 6 - 12*24192 - 1 + 1
#=> 6
Change dlast to last day of first month.
dlast = d.first.end_of_month
#=> #<Date: 2016-01-31 ((2457419j,0s,0n),+0s,2299161j)>
Map each month to the desired format.
nmonths.times.map { |i|
"%s - %s" % [(dfirst >> i).strftime("01.%m.%Y"), (dlast >> i).strftime("%d.%m.%Y")]}
#=> ["01.01.2016 - 31.01.2016", "01.02.2016 - 29.02.2016",
# "01.03.2016 - 31.03.2016", "01.04.2016 - 30.04.2016",
# "01.05.2016 - 31.05.2016", "01.06.2016 - 30.06.2016"]

As mentioned in this thread: Is there a standard for inclusive/exclusive ends of time intervals? We should prefer inclusive start dates and exclusive end dates.
We can split a DateRange into days, weeks, months or years with the following tail recursive method:
class DateRange
def initialize(since_date, until_date)
#since_date = since_date
#until_date = until_date
end
def split_to(timespan)
return [] if #since_date == #until_date
end_date = [#since_date.send("next_#{timespan.to_s.singularize}"), #until_date].min
[DateRange.new(#since_date, end_date)] + DateRange.new(end_date, #until_date).split_to(timespan)
end
end
This method takes :days, :weeks, :months or :years as a parameter. Notice that we rely on activesupport.

Related

Best way to count days in period spitted by month

Need to example how to calculate the count of days in a period splited by month.
For example:
Wed, 25 Nov 2020 : Tue, 15 Dec 2020 => [6 (nov), 15(dec)]
Thank you!
This would be a job for tally_by, but that is not added to Ruby (yet?).
tally works too:
require 'date'
range = Date.parse("Wed, 25 Nov 2020") .. Date.parse("Tue, 15 Dec 2020")
p month_counts = range.map{|d| Date::ABBR_MONTHNAMES[d.month] }.tally
# => {"Nov"=>6, "Dec"=>15}
date1 = Date.new(2020, 11, 25)
date2 = Date.new(2020, 12, 15)
(date1..date2).group_by { |date| [date.year, date.month] }
.map { |(year, month), dates| ["#{year}/#{month}", dates.length] }
=> [["2020/11", 6], ["2020/12", 15]]
What about the interval is so long that you have same months but of different years? I've added years because of this case.
This works in pure ruby too, you just need require 'date'
Code
require 'date'
def count_days_by_month(str)
Range.new(*str.split(/ +: +/).
map { |s| Date.strptime(s, '%a, %d %b %Y') }).
slice_when { |d1,d2| d1.month != d2.month }.
with_object({}) do |a,h|
day1 = a.first
h[[day1.year, Date::ABBR_MONTHNAMES[day1.month]]] = a.size
end
end
See Range::new, Date::strptime and Enumerable#slice_when.
Examples
count_days_by_month "Wed, 25 Nov 2020 : Tue, 15 Dec 2020"
#=> {[2020, "Nov"]=>6, [2020, "Dec"]=>15}
count_days_by_month "Wed, 25 Nov 2020 : Tue, 15 Dec 2021"
#=> {[2020, "Nov"]=>6, [2020, "Dec"]=>31, [2021, "Jan"]=>31,
# ...
# [2021, "Nov"]=>30, [2021, "Dec"]=>15}
Explanation
For the first example the steps are as follows.
str = "Wed, 25 Nov 2020 : Tue, 15 Dec 2020"
b = str.split(/ +: +/)
#=> ["Wed, 25 Nov 2020", "Tue, 15 Dec 2020"]
c = b.map { |s| Date.strptime(s, '%a, %d %b %Y') }
#=> [#<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>,
# #<Date: 2020-12-15 ((2459199j,0s,0n),+0s,2299161j)>]
d = Range.new(*c)
#=> #<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>..
# #<Date: 2020-12-15 ((2459199j,0s,0n),+0s,2299161j)>
e = d.slice_when { |d1,d2| d1.month != d2.month }
#=> #<Enumerator: #<Enumerator::Generator:0x00007fb1058abb10>:each>
We can see the elements generated by this enumerator by converting it to an array.
e.to_a
#=> [[#<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>,
# #<Date: 2020-11-26 ((2459180j,0s,0n),+0s,2299161j)>,
# ...
# #<Date: 2020-11-30 ((2459184j,0s,0n),+0s,2299161j)>],
# [#<Date: 2020-12-01 ((2459185j,0s,0n),+0s,2299161j)>,
# #<Date: 2020-12-02 ((2459186j,0s,0n),+0s,2299161j)>,
# ...
# #<Date: 2020-12-15 ((2459199j,0s,0n),+0s,2299161j)>]]
Continuing,
f = e.with_object({})
#=> #<Enumerator: #<Enumerator: #<Enumerator::Generator:0x00007fb1058abb10>
# :each>:with_object({})>
f.each do |a,h|
day1 = a.first
h[[day1.year, Date::ABBR_MONTHNAMES[day1.month]]] = a.size
end
#=> {[2020, "Nov"]=>6, [2020, "Dec"]=>15}
The first element generated by f and passed to the block, and the block variables are assign values by the rules of array decomposition:
a,h = f.next
#=> [[#<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>,
# #<Date: 2020-11-26 ((2459180j,0s,0n),+0s,2299161j)>,
# ...
# #<Date: 2020-11-30 ((2459184j,0s,0n),+0s,2299161j)>],
# {}]
a #=> [#<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>,
# #<Date: 2020-11-26 ((2459180j,0s,0n),+0s,2299161j)>,
# ...
# #<Date: 2020-11-30 ((2459184j,0s,0n),+0s,2299161j)>],
h #=> {}
Key-value pairs will be added to h over the course of the calculations. See Enumerator#next. The block calculation is now performed.
day1 = a.first
#=> #<Date: 2020-11-25 ((2459179j,0s,0n),+0s,2299161j)>
g = day1.year
#=> 2020
i = day1.month
#=> 11
j = Date::ABBR_MONTHNAMES[day1.month]
#=> "Nov"
k = a.size
#=> 6
h[[g,j]] = k
#=> 6
resulting in:
h #=> {[2020, "Nov"]=>6}
The remaining steps are similar.
I would start by breaking the whole period into periods for each month.
Since Ruby has ranges, I'd write a helper method that takes a date range and yields month-ranges:
def each_month(range)
return enum_for(__method__, range) unless block_given?
date = range.begin.beginning_of_month
loop do
from = date.clamp(range)
to = (date.end_of_month).clamp(range)
yield from..to
date = date.next_month
break unless range.cover?(date)
end
end
clamp ensures that the range's bounds are taken into account when calculating each month's range. For Ruby version prior to 2.7 you have to pass the bounds separately:
from = date.clamp(range.begin, range.end)
to = (date.end_of_month).clamp(range.begin, range.end)
Example usage:
from = '25 Nov 2020'.to_date
to = '13 Jan 2021'.to_date
each_month(from..to).to_a
#=> [
# Wed, 25 Nov 2020..Mon, 30 Nov 2020
# Tue, 01 Dec 2020..Thu, 31 Dec 2020
# Fri, 01 Jan 2021..Wed, 13 Jan 2021
# ]
Now all we need is a way to count the days in each month-range: (e.g. via jd)
def days(range)
range.end.jd - range.begin.jd + 1
end
and some formatting:
each_month(from..to).map { |r| format('%d (%s)', days(r), r.begin.strftime('%b')) }
#=> ["6 (Nov)", "31 (Dec)", "13 (Jan)"]

ruby date format from date A to date B

I just wonder how the date period can be written in Ruby?
date_a = Time.at() # <= new
date_b = Time.at() # <= old
I'd love to have something like:
September 1 - 30, 2016
Also it needs to be considered the year.
(Ex. if it's in January,
It should be December 25 2016 - January 25 2017)
You could do something like this
def format_dates(*dates)
date1, date2 = dates.sort
return "#{date1.strftime("%B %d %Y")} if date1 == date2
if date1.year == date2.year
if date1.month == date2.month
"#{date1.strftime("%B")} #{date1.day} - #{date2.day}, #{date1.year}"
else
"#{date1.strftime("%B %d")} - #{date2.strftime("%B %d")}, #{date1.year}"
end
else
"#{date1.strftime("%B %d %Y")} - #{date2.strftime("%B %d %Y")}"
end
end
p format_dates(Date.parse('25/12/2016'), Date.parse('25/01/2017'))
# => "December 25 2016 - January 25 2017"
p format_dates(Date.parse('25/12/2016'), Date.parse('25/01/2016'))
# => "January 25 - December 25, 2016"

Ruby: Calculate time difference between 2 times

I want to calculate the difference between 2 times.
start_time: 22:00 (Rails interprets this as 2015-12-31 22:00:00 +0100)
second_time: 02:00 (Rails interprets this as 2015-12-31 02:00:00 +0100). The second time is 4 hours later, so in the next day. Is there a way to calculate this difference?
I can not simply do this: second_time - first_time, because this gives me a difference of 22 hours instead of 4 hours.
Edit:
Some background information:
A job is starting at 22:00 and ending the next day at 02:00. Because i fill in the form of this job only times, this times for the above 2 values are 2015-12-31 22:00:00 +0100 and 2015-12-31 02:00:00 +0100. I don't want the user to fill in the time including the date. The real difference between the times should be 4 hours.
So what i actually want is calculate the difference between 22:00 and 02:00 (in the next day).
I don't understand why you think it should return 4 hours or why it does return 22 hours. 20 hours would be correct for your example:
require 'time'
a = Time.parse('2015-12-31 22:00:00 +0100')
b = Time.parse('2015-12-31 02:00:00 +0100')
a - b
#=> 72000.0 # difference in seconds
(a - b) / 3600
#=> 20.0 # difference in hours
Update: It seems like you are dealing only with the time portion and not with the actual date. And I assume the maximum difference you will have to deal with is 24 hours:
def time_difference(time_a, time_b)
difference = time_b - time_a
if difference > 0
difference
else
24 * 3600 + difference
end
end
a = Time.parse('2015-12-31 22:00:00 +0100')
b = Time.parse('2015-12-31 02:00:00 +0100')
time_difference(a, b) / 3600
# => 4 # hours
a = Time.parse('2015-12-31 02:00:00 +0100')
b = Time.parse('2015-12-31 22:00:00 +0100')
time_difference(a, b) / 3600
# => 20 # hours
Old question but I did a nice method to deal with it:
def time(start,ending)
if start != ending
medidas=["year","month","day","hour","minute","second"]
array=[1970,1,1,0,0,0]
text = ""
Time.at(ending-start).utc.to_a.take(6).reverse.each_with_index do |k,i|
text = "#{text} #{I18n.translate medidas[i].to_sym, count: k-array[i]}"
end
text = text.strip.squish
pos = text.rindex(" ",(text.rindex(" ")-1))
unless pos.nil?
text = text.insert(pos," and")
end
text = text.strip.squish #This shouldn't be needed but just in case
else
"0 seconds"
end
end
Then in config/locales/en.yml I added:
en:
año:
zero: ''
one: '1 year'
other: '%{count} years'
mes:
zero: ''
one: '1 month'
other: '%{count} months'
dia:
zero: ''
one: '1 day'
other: '%{count} days'
hora:
zero: ''
one: '1 hour'
other: '%{count} hours'
minuto:
zero: ''
one: '1 minute'
other: '%{count} minutes'
segundo:
zero: ''
one: '1 second'
other: '%{count} seconds'
So for example when you call:
start = Time.now
ending = start + (60*60)
time(start,ending)
=> "1 hour"
ending = start + (60*60*28)
time(start,ending)
=> "1 day and 4 hours"
ending = start + (53*60*5874)
time(start,ending)
=> "7 months 4 days 4 hours and 42 minutes"
Hope it's useful
I'd write it thusly (before adding data checks), in an attempt to make it self-documenting:
require 'time'
DT_FMT = '%Y-%m-%d %H:%M:%S %z'
SECONDS_PER_DAY = 24*60*60
def hours_elapsed(start_str, finish_str)
start = DateTime.strptime(start_str, DT_FMT).to_time
finish = DateTime.strptime(finish_str, DT_FMT).to_time
finish = same_time_tomorrow(finish) if finish < start
(finish-start)/3600
end
def same_time_tomorrow(time)
time + SECONDS_PER_DAY
end
hours_elapsed '2015-12-31 22:00:00 +0100',
'2015-12-31 02:00:00 +0100'
#=> 4.0
hours_elapsed '2015-12-31 02:00:00 +0100',
'2015-12-31 22:00:00 +0100'
#=> 20.0
It may be better for the arguments of hours_elapsed to be strings containing hours and minutes only, in which case we might rename the method as well. time_elapsed("18:00", "2:30") is an example of how this method might be invoked.
MINUTES_PER_DAY = 24*60
def time_elapsed(start_str, finish_str)
start_mins = time_str_to_minutes(start_str)
finish_mins = time_str_to_minutes(finish_str)
finish_mins += MINUTES_PER_DAY if
finish_mins < start_mins
(finish_mins-start_mins).divmod(60)
end
def time_str_to_minutes(str)
hrs, mins = str.split(':').map(&:to_i)
60 * hrs + mins
end
time_elapsed("8:00", "17:30")
#=> [9, 30]
time_elapsed("18:00", "2:30")
#=> [8, 30]

(Time.now.utc.to_date + 1.month + 15.days) != (Time.now.utc.to_date + 15.days + 1.month)

How's this possible?
Time.now.utc.to_date + 1.month + 15.days #=> Mon, 01 Dec 2014
Time.now.utc.to_date + 15.days + 1.month #=> Sun, 30 Nov 2014
Has anyone seen it?
/edit
I guess I asked the question a wrong way. How do you guys explain this then?
Time.now.utc.to_date + (15.days + 1.month) #=> Mon, 08 Dec 2014
Time.now.utc.to_date + (1.month + 15.days) #=> Tue, 09 Dec 2014
(15.days + 1.month) #=> 3888000
(1.month + 15.days) #=> 3888000
First let see Integer#month, it returns an instance of ActiveSupport::Duration. At the rails console:
~/rails/rfinan (1296000):1 > elapsed = 1.month
=> 2592000
~/rails/rfinan (1296000):1 > elapsed.value
=> 2592000
~/rails/rfinan (1296000):1 > elapsed.parts
=> [[:months,1]]
~/rails/rfinan (1296000):1 > elapsed.is_a? ActiveSupport::Duration
=> true
It's time for the method: ActiveSupport::Duration#+
~/rails/rfinan (1296000):1 > sum1 = 1.month + 15.days
=> 3888000
~/rails/rfinan (1296000):1 > sum2 = 15.days + 1.month
=> 3888000
~/rails/rfinan (1296000):1 > sum1.value
=> 3888000
~/rails/rfinan (1296000):1 > sum1.parts
=> [[:months,1],[:days,15]]
~/rails/rfinan (1296000):1 > sum2.value
=> 3888000
~/rails/rfinan (1296000):1 > sum2.parts
=> [[:days,15],[:months,1]]
~/rails/rfinan (1296000):1 > sum1 == sum2
=> true
~/rails/rfinan (1296000):1 > sum1.value == sum2.value
=> true
~/rails/rfinan (1296000):1 > sum1.parts == sum2.parts
=> false
Now Date#+, the ActiveSupport version.
def plus_with_duration(other) #:nodoc:
if ActiveSupport::Duration === other
other.since(self)
else
plus_without_duration(other)
end
end
alias_method :plus_without_duration, :+
alias_method :+, :plus_with_duration
That means: if I send :+ to a Date instance with a ActiveSupport::Duration instance as parameter, it calls ActiveSupport::Duration#since, and the last one calls ActiveSupport::Duration#sum, that injects over the date instance, and calls Date#advance on each of the parts of duration instance:
def sum(sign, time = ::Time.current) #:nodoc:
parts.inject(time) do |t,(type,number)|
if t.acts_like?(:time) || t.acts_like?(:date)
if type == :seconds
t.since(sign * number)
else
t.advance(type => sign * number)
end
else
raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
end
end
end
Remmember sum1.parts != sum2.parts?, sum send advance to the date instance orderly. Let see what means Date#advance
def advance(options)
options = options.dup
d = self
d = d >> options.delete(:years) * 12 if options[:years]
d = d >> options.delete(:months) if options[:months]
d = d + options.delete(:weeks) * 7 if options[:weeks]
d = d + options.delete(:days) if options[:days]
d
end
When advance recive month: 1 it calls Date#>> from stdlib, that work diferently of ActiveSupport::Duration#+. At irb:
~ (main) > Date.new(2014,10,31) >> 1
=> #<Date: 2014-11-30 ((2456992j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 2
=> #<Date: 2014-12-31 ((2457023j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 3
=> #<Date: 2015-01-31 ((2457054j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 4
=> #<Date: 2015-02-28 ((2457082j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 5
=> #<Date: 2015-03-31 ((2457113j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 12
=> #<Date: 2015-10-31 ((2457327j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 1200
=> #<Date: 2114-10-31 ((2493486j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 12000
=> #<Date: 3014-10-31 ((2822204j,0s,0n),+0s,2299161j)>
It's clear that Date#>> don't add days, add months and keep the day number. if the day isn't valid for the target month, it fixes it. Adding a fix number of months doesn't fixes the number of days added, because depend on the start date.
Now we can say that Date#+ is not the same of ActiveSupport::Duration#+, and we know why.
The anwer is Date#+ called with an ActiveSupport::Duration instance (say duration) doesn't care about duration.value, it uses duration.parts, which are different in each case.
October has 31 days, November does not. This means that it depends a little on how you calculate the 31st + 1 Month.
For the first example:
Now + 1 Month = 16-Nov
16-Nov + 15 days = 1-Dec
For the second example:
Now + 15 days = 31-Oct
31-Oct + 1 Month = 30-Nov
October has 31 days. When you add 15 days to Oct 16 you get Oct 31. Adding a month carries you to the same date on the next month - Nov. 31, but there is no Nov. 31 so it takes you to Nov 30.
If instead you add the month first, that carries you to Nov 16. Then adding 15 days carries you to Dec 01.
When you do:
(15.days + 1.month) #=> 3888000
(1.month + 15.days) #=> 3888000
You are not operating dates, you are operating seconds (Rails Numeric < Object). To prove, let's convert it back to days:
> 3888000 / 60 / 60 / 24
=> 45
45 = 30 + 15. So we know that, when operating seconds, or days, the compiler interprets 1.month as 30 days by default when operating Numerics. See numerics reference:
http://api.rubyonrails.org/classes/Numeric.html#method-i-seconds
As you can see in the link above, when you operate Dates with Numerics, rails calls the advance(options) method which is responsible for executing correct Date operations. See advance definition on github:
https://github.com/rails/rails/blob/ffc273577a795bb41068bfc2a1bb575ec51a9712/activesupport/lib/active_support/core_ext/time/calculations.rb#L99
Also, when operating dates using Time.now.utc.to_date + (1.month + 15.days) the + () function will actually call the advance(options) method like this:
(Time.now.utc.to_date.advance(month:1)).advance(days:15) #fistCase
when you use Time.now.utc.to_date + (15.days + 1.month), what will be called is this:
(Time.now.utc.to_date.advance(days:15)).advance(month:1) #secondCase
So, lets test #firstCase:
oct16 = Date.new(2014, 10, 16)
> oct16 + (1.month + 15.days)
=> Mon, 01 Dec 2014
> (oct16.advance(months:1)).advance(days:15)
=> Mon, 01 Dec 2014
The #firstCase conclusion is, it calls advance(month:1) resulting Nov-16, then it calls .advance(days:15) on Nov-16 and goes to Dez-01
Let's check the #secondCase:
> oct16 + (15.days + 1.month)
=> Sun, 30 Nov 2014
> (oct16.advance(days:15)).advance(months:1)
=> Sun, 30 Nov 2014
The #secondCase conclusion is, it calls advance(days:15), which results in Oct-31, than it calls advance(months: 1) on the last result, which would give us Nov-31, but wait! Nov-31 does not exist! So the interpreter is smart enough to understand that, since you were on the last day of the month(Oct-31), when you add 1.month, or advance(months:1), you are asking him to take you to the last day of the next month, in that case Nov-30.
That's the convention.

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