Best way to count days in period spitted by month - ruby-on-rails

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)"]

Related

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"

How to divide a time period into multiple periods

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.

How to covert the date string range into datatime data type

I need to convert the following raw string (date range) into ruby datetime datetype.
How to finish it on Rails ?
raw string
"2014 April/July 24-1"
convert to ruby datetime variable
start_date = 2014-04-24
end_date = 2014-07-01
raw string
"2015 April 06-20"
convert to ruby datetime variable
start_date = 2015-04-06
end_date = 2015-04-20
This may help
# In order to generate
# year = 2014
# months = "April/July"
# days = "24-1"
/(?<year>\d{4})\s*(?<months>\w+\/\w+)\s*(?<days>\d{1,2}\-\d{1,2})/ =~ "2014 April/July 24-1"
date1 = "#{year} #{months.split('/')[0]} #{days.split('-')[0]}"
date2 = "#{year} #{months.split('/')[1]} #{days.split('-')[1]}"
start_date = Date.strptime(date1, "%Y %b %d") #=> Thu, 24 Apr 2014
end_date = Date.strptime(date2, "%Y %b %d") #=> Tue, 01 Jul 2014

How to convert the string 2012 , December, 15 to a Datatime object

I will get the String "2012" then "December" at first time
In the following get the string 1,15, 30
I should convert them into Datetime object
2012-12-01
2012-12-15
2012-12-30
How to do it ?
Date.strptime('2012 December', '%Y %B')
#=> Sat, 01 Dec 2012
date = Date.strptime('2012 December 10', '%Y %B %d')
#=> Mon, 10 Dec 2012
date.strftime("%Y-%m-%d")
#=> "2012-12-10"
You have this string:
[1] pry(main)> str = "2012 December 1,15, 30"
=> "2012 December 1,15, 30"
And you want to get three dates from it (1st, 15th and 30th of December. First at all, get the year, month and days from this string:
[2] pry(main)> m = /(?'year'\d*)\s(?'month'[a-zA-Z]*)(?'days'[\d, ]*)/.match str
=> #<MatchData
"2012 December 1,15, 30"
year:"2012"
month:"December"
days:" 1,15, 30">
Then split the days to and array and iterate on it to convert the days with year and month to Date object (you can use Date.strptime as shivam showed above or Time.parse):
[10] pry(main)> require 'time'
=> true
[11] pry(main)> m[:days].split(',').collect {|day| Time.parse "#{m[:year]} #{m[:month]} #{day}"}
=> [2012-12-01 00:00:00 +0100,
2012-12-15 00:00:00 +0100,
2012-12-30 00:00:00 +0100]

(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.

Resources