Stefan
Stefan

Reputation: 9329

Ruby: Create range of dates

I'm looking for an elegant way to make a range of datetimes, e.g.:

def DateRange(start_time, end_time, period)
  ...
end

>> results = DateRange(DateTime.new(2013,10,10,12), DateTime.new(2013,10,10,14), :hourly)
>> puts results
2013-10-10:12:00:00
2013-10-10:13:00:00
2013-10-10:14:00:00

The step should be configurable, e.g. hourly, daily, monthly.

I'd like times to be inclusive, i.e. include end_time.

Additional requirements are:

Is there an elegant solution?

Upvotes: 31

Views: 43672

Answers (9)

dancow
dancow

Reputation: 3388

Using @CaptainPete's base, I modified it to use the ActiveSupport::DateTime#advance call. The difference comes into effect when the time intervals are non-uniform, such as `:month" and ":year"

require 'active_support/all'
class RailsDateRange < Range
  # step is similar to DateTime#advance argument
  def every(step, &block)
    c_time = self.begin.to_datetime
    finish_time = self.end.to_datetime
    foo_compare = self.exclude_end? ? :< : :<=

    arr = []
    while c_time.send( foo_compare, finish_time) do 
      arr << c_time
      c_time = c_time.advance(step)
    end

    return arr
  end
end

# Convenience method
def RailsDateRange(range)
  RailsDateRange.new(range.begin, range.end, range.exclude_end?)
end

My method also returns an Array. For comparison's sake, I altered @CaptainPete's answer to also return an array:

By hour

RailsDateRange((4.years.ago)..Time.now).every(years: 1)
=> [Tue, 13 Oct 2009 11:30:07 -0400,
 Wed, 13 Oct 2010 11:30:07 -0400,
 Thu, 13 Oct 2011 11:30:07 -0400,
 Sat, 13 Oct 2012 11:30:07 -0400,
 Sun, 13 Oct 2013 11:30:07 -0400]


DateRange((4.years.ago)..Time.now).every(1.year)
=> [2009-10-13 11:30:07 -0400,
 2010-10-13 17:30:07 -0400,
 2011-10-13 23:30:07 -0400,
 2012-10-13 05:30:07 -0400,
 2013-10-13 11:30:07 -0400]

By month

RailsDateRange((5.months.ago)..Time.now).every(months: 1)
=> [Mon, 13 May 2013 11:31:55 -0400,
 Thu, 13 Jun 2013 11:31:55 -0400,
 Sat, 13 Jul 2013 11:31:55 -0400,
 Tue, 13 Aug 2013 11:31:55 -0400,
 Fri, 13 Sep 2013 11:31:55 -0400,
 Sun, 13 Oct 2013 11:31:55 -0400]

DateRange((5.months.ago)..Time.now).every(1.month)
=> [2013-05-13 11:31:55 -0400,
 2013-06-12 11:31:55 -0400,
 2013-07-12 11:31:55 -0400,
 2013-08-11 11:31:55 -0400,
 2013-09-10 11:31:55 -0400,
 2013-10-10 11:31:55 -0400]

By year

RailsDateRange((4.years.ago)..Time.now).every(years: 1)

=> [Tue, 13 Oct 2009 11:30:07 -0400,
 Wed, 13 Oct 2010 11:30:07 -0400,
 Thu, 13 Oct 2011 11:30:07 -0400,
 Sat, 13 Oct 2012 11:30:07 -0400,
 Sun, 13 Oct 2013 11:30:07 -0400]

DateRange((4.years.ago)..Time.now).every(1.year)

=> [2009-10-13 11:30:07 -0400,
 2010-10-13 17:30:07 -0400,
 2011-10-13 23:30:07 -0400,
 2012-10-13 05:30:07 -0400,
 2013-10-13 11:30:07 -0400]

Upvotes: 32

konung
konung

Reputation: 7038

Ruby standard library date has a built-in method step that we can use to achieve this:

require 'date'
array_of_datetimes = DateTime.parse("2021-09-01 13:00:00")
                            .step(DateTime.parse("2021-09-21 13:00:00"), (1.0/24))
                            .to_a

Let's break it down so it's easier to read

require 'date'

start_date = DateTime.parse("2021-09-01 13:00:00")
end_date = DateTime.parse("2021-09-21 13:00:00")

# Date#step expects a number representing a day. 
# Let's convert into an hour
period = 1.0/24 

array_of_datestimes = start_date.step(end_date, period).to_a

Output of puts array_of_datetime

....
2021-09-03T17:00:00+00:00
2021-09-03T18:00:00+00:00
2021-09-03T19:00:00+00:00
2021-09-03T20:00:00+00:00
2021-09-03T21:00:00+00:00
2021-09-03T22:00:00+00:00
....

P.S. Original question is from 2013, so I tested this using docker image ruby:2.0.0-slim. Ruby 2.0.0 was released in 2012 , and it works fine.

Upvotes: 3

steenslag
steenslag

Reputation: 80065

An hour is 1/24th of a day, so you could do

d1 = DateTime.now
d2 = d1 + 1
d1.step(d2, 1/24r){|d| p d}

1/24r is a Rational, more exact than a Float.

Upvotes: 4

Darkside
Darkside

Reputation: 649

Another solution is to use uniq method. Consider examples:

date_range = (Date.parse('2019-01-05')..Date.parse('2019-03-01'))
date_range.uniq { |d| d.month }
# => [Sat, 05 Jan 2019, Fri, 01 Feb 2019]
date_range.uniq { |d| d.cweek }
# => [Sat, 05 Jan 2019, Mon, 07 Jan 2019, Mon, 14 Jan 2019, Mon, 21 Jan 2019, Mon, 28 Jan 2019, Mon, 04 Feb 2019, Mon, 11 Feb 2019, Mon, 18 Feb 2019, Mon, 25 Feb 2019]

Note that this approach respects range min and max

Upvotes: 5

captainpete
captainpete

Reputation: 6222

Add duration support to Range#step

module RangeWithStepTime
  def step(step_size = 1, &block)
    return to_enum(:step, step_size) unless block_given?

    # Defer to Range for steps other than durations on times
    return super unless step_size.kind_of? ActiveSupport::Duration

    # Advance through time using steps
    time = self.begin
    op = exclude_end? ? :< : :<=
    while time.send(op, self.end)
      yield time
      time = step_size.parts.inject(time) { |t, (type, number)| t.advance(type => number) }
    end

    self
  end
end

Range.prepend(RangeWithStepTime)

This approach affords

  • Implicit support for preserving time-zone
  • Adds duration support to the already-present Range#step method (no need for a sub-class, or convenience methods on Object, though that was still fun)
  • Supports multi-part durations like 1.hour + 3.seconds in step_size

This adds support for our duration to Range using the existing API. It allows you to use a regular range in the style that we expect to simply "just work".

# Now the question's invocation becomes even
# simpler and more flexible

step = 2.months + 4.days + 22.3.seconds
( Time.now .. 7.months.from_now ).step(step) do |time|
  puts "It's #{time} (#{time.to_f})"
end

# It's 2013-10-17 13:25:07 +1100 (1381976707.275407)
# It's 2013-12-21 13:25:29 +1100 (1387592729.575407)
# It's 2014-02-25 13:25:51 +1100 (1393295151.8754072)
# It's 2014-04-29 13:26:14 +1000 (1398741974.1754072)

The previous approach

...was to add an #every using a DateRange < Range class + DateRange "constructor" on Object, then convert the times to integers internally, stepping through them in step seconds. This didn't work for time zones originally. Support for time zones was added but then another issue was found with the fact some step durations are dynamic (eg 1.month).

Reading Rubinius' Range implementation it became clear how someone might add support for ActiveSupport::Duration; so the approach was rewritten. Much thanks to Dan Nguyen for the #advance tip and debugging around this, and to Rubinius' implementation of Range#step for being beautifully written :D

Update 2015-08-14

  • This patch was not merged into Rails/ActiveSupport. You should stick to for loops using #advance. If you're getting can't iterate from Time or something like that, then use this patch, or just avoid using Range.

  • Updated patch to reflect Rails 4+ prepend style over alias_method_chain.

Upvotes: 15

xxjjnn
xxjjnn

Reputation: 15239

If you want to have n values evenly spread between two dates you can

n = 8
beginning = Time.now - 1.day
ending = Time.now
diff = ending - beginning
result = []
(n).times.each do | x |
  result << (beginning + ((x*diff)/(n-1)).seconds)
end
result

which gives

2015-05-20 16:20:23 +0100
2015-05-20 19:46:05 +0100
2015-05-20 23:11:48 +0100
2015-05-21 02:37:31 +0100
2015-05-21 06:03:14 +0100
2015-05-21 09:28:57 +0100
2015-05-21 12:54:40 +0100
2015-05-21 16:20:23 +0100

Upvotes: 3

Kevin Monk
Kevin Monk

Reputation: 1454

The Ice Cube - Ruby Date Recurrence Library would help here or look at their implementation? It has successfully covered every example in the RFC 5545 spec.

schedule = IceCube::Schedule.new(2013,10,10,12)
schedule.add_recurrence_rule IceCube::Rule.hourly.until(Time.new(2013,10,10,14))
schedule.all_occurrences
# => [
#  [0] 2013-10-10 12:00:00 +0100,
#  [1] 2013-10-10 13:00:00 +0100,
#  [2] 2013-10-10 14:00:00 +0100
#]

Upvotes: 2

user401093
user401093

Reputation: 186

You can use DateTime.parse on the start and end times to get a lock on the iterations you need to populate the array. For instance;

#Seconds
((DateTime.parse(@startdt) - DateTime.now) * 24 * 60 * 60).to_i.abs

#Minutes
((DateTime.parse(@startdt) - DateTime.now) * 24 * 60).to_i.abs

and so on. Once you have these values, you can loop through populating the array on whatever slice of time you want. I agree with @fotanus though, you probably shouldn't need to materialize an array for this, but I don't know what your goal is in doing so so I really can't say.

Upvotes: 2

kwarrick
kwarrick

Reputation: 6200

No rounding errors, a Range calls the .succ method to enumerate the sequence, which is not what you want.

Not a one-liner but, a short helper function will suffice:

def datetime_sequence(start, stop, step)
  dates = [start]
  while dates.last < (stop - step)
    dates << (dates.last + step)
  end 
  return dates
end 

datetime_sequence(DateTime.now, DateTime.now + 1.day, 1.hour)

# [Mon, 30 Sep 2013 08:28:38 -0400, Mon, 30 Sep 2013 09:28:38 -0400, ...]

Note, however, this could be wildly inefficient memory-wise for large ranges.


Alternatively, you can use seconds since the epoch:

start = DateTime.now
stop  = DateTime.now + 1.day
(start.to_i..stop.to_i).step(1.hour)

# => #<Enumerator: 1380545483..1380631883:step(3600 seconds)>

You'll have a range of integers, but you can convert back to a DateTime easily:

Time.at(i).to_datetime

Upvotes: 19

Related Questions