Class: ActiveDateRange::DateRange

Inherits:
Range
  • Object
show all
Defined in:
lib/active_date_range/date_range.rb

Overview

Provides a DateRange with parsing, calculations and query methods

Constant Summary collapse

SHORTHANDS =
{
  this_month: -> { DateRange.new(Time.zone.today.all_month) },
  prev_month: -> { DateRange.new(1.month.ago.to_date.all_month) },
  next_month: -> { DateRange.new(1.month.from_now.to_date.all_month) },
  this_quarter: -> { DateRange.new(Time.zone.today.all_quarter) },
  prev_quarter: -> { DateRange.new(3.months.ago.to_date.all_quarter) },
  next_quarter: -> { DateRange.new(3.months.from_now.to_date.all_quarter) },
  this_year: -> { DateRange.new(Time.zone.today.all_year) },
  prev_year: -> { DateRange.new(12.months.ago.to_date.all_year) },
  next_year: -> { DateRange.new(12.months.from_now.to_date.all_year) },
  this_week: -> { DateRange.new(Time.zone.today.all_week) },
  prev_week: -> { DateRange.new(1.week.ago.to_date.all_week) },
  next_week: -> { DateRange.new(1.week.from_now.to_date.all_week) }
}.freeze
RANGE_PART_REGEXP =
%r{\A(?<year>((1\d|2\d)\d\d))-?(?<month>0[1-9]|1[012])-?(?<day>[0-2]\d|3[01])?\z}

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(begin_date, end_date = nil) ⇒ DateRange

Initializes a new DateRange. Accepts both a begin and end date or a range of dates. Make sures the begin date is before the end date.

Raises:



101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/active_date_range/date_range.rb', line 101

def initialize(begin_date, end_date = nil)
  begin_date, end_date = begin_date.begin, begin_date.end if begin_date.kind_of?(Range)
  begin_date, end_date = begin_date.first, begin_date.last if begin_date.kind_of?(Array)
  begin_date = begin_date.to_date if begin_date.kind_of?(Time)
  end_date = end_date.to_date if end_date.kind_of?(Time)

  raise InvalidDateRange, "Date range invalid, begin should be a date" if begin_date && !begin_date.kind_of?(Date)
  raise InvalidDateRange, "Date range invalid, end should be a date" if end_date && !end_date.kind_of?(Date)
  raise InvalidDateRange, "Date range invalid, begin #{begin_date} is after end #{end_date}" if begin_date && end_date && begin_date > end_date

  super(begin_date, end_date)
end

Class Method Details

.from_date_and_duration(date, duration) ⇒ Object



62
63
64
65
# File 'lib/active_date_range/date_range.rb', line 62

def self.from_date_and_duration(date, duration)
  duration = 1.send(duration) if duration.kind_of?(Symbol)
  new(date, date + duration - 1.day)
end

.month(year, month) ⇒ Object

Creates a DateRange for a specific month.

DateRange.month(2026, 1) # => DateRange(2026-01-01..2026-01-31)


77
78
79
# File 'lib/active_date_range/date_range.rb', line 77

def self.month(year, month)
  new(Date.new(year, month, 1).all_month)
end

.parse(input) ⇒ Object

Parses a date range string to a DateRange instance. Valid formats are:

  • A relative shorthand: this_month, prev_month, next_month, etc.

  • A begin and end date: YYYYMMDD..YYYYMMDD

  • A begin and end month: YYYYMM..YYYYMM



33
34
35
36
37
38
39
40
41
42
# File 'lib/active_date_range/date_range.rb', line 33

def self.parse(input)
  return nil if input.nil?
  return DateRange.new(input) if input.kind_of?(Range)
  return SHORTHANDS[input.to_sym].call if SHORTHANDS.key?(input.to_sym)

  begin_date, end_date = input.split("..")
  raise InvalidDateRangeFormat, "#{input} doesn't have a begin..end format" if begin_date.blank? && end_date.blank?

  DateRange.new(parse_date(begin_date), parse_date(end_date, last: true))
end

.quarter(year, quarter) ⇒ Object

Creates a DateRange for a specific quarter (1-4).

DateRange.quarter(2026, 4) # => DateRange(2026-10-01..2026-12-31)


84
85
86
87
# File 'lib/active_date_range/date_range.rb', line 84

def self.quarter(year, quarter)
  month = ((quarter - 1) * 3) + 1
  new(Date.new(year, month, 1).all_quarter)
end

.week(year, week) ⇒ Object

Creates a DateRange for a specific ISO week.

DateRange.week(2026, 1) # => DateRange for ISO week 1 of 2026


92
93
94
95
# File 'lib/active_date_range/date_range.rb', line 92

def self.week(year, week)
  date = Date.commercial(year, week, 1)
  new(date.all_week)
end

.year(year) ⇒ Object

Creates a DateRange for a full year.

DateRange.year(2026) # => DateRange(2026-01-01..2026-12-31)


70
71
72
# File 'lib/active_date_range/date_range.rb', line 70

def self.year(year)
  new(Date.new(year, 1, 1).all_year)
end

Instance Method Details

#+(other) ⇒ Object

Adds two date ranges together. Fails when the ranges are not subsequent.

Raises:



115
116
117
118
119
# File 'lib/active_date_range/date_range.rb', line 115

def +(other)
  raise InvalidAddition if self.end != (other.begin - 1.day)

  DateRange.new(self.begin, other.end)
end

#<=>(other) ⇒ Object

Sorts two date ranges by the begin date.



122
123
124
# File 'lib/active_date_range/date_range.rb', line 122

def <=>(other)
  self.begin <=> other.begin
end

#after?(date) ⇒ Boolean

Returns true when the date range is after the given date. Accepts both a Date and DateRange as input.

Returns:

  • (Boolean)


304
305
306
307
# File 'lib/active_date_range/date_range.rb', line 304

def after?(date)
  date = date.end if date.kind_of?(DateRange)
  self.begin.present? && self.begin.after?(date)
end

#before?(date) ⇒ Boolean

Returns true when the date range is before the given date. Accepts both a Date and DateRange as input.

Returns:

  • (Boolean)


297
298
299
300
# File 'lib/active_date_range/date_range.rb', line 297

def before?(date)
  date = date.begin if date.kind_of?(DateRange)
  self.end.present? && self.end.before?(date)
end

#begin_at_beginning_of_month?Boolean

Returns true when begin of the range is at the beginning of the month

Returns:

  • (Boolean)


180
181
182
183
184
# File 'lib/active_date_range/date_range.rb', line 180

def begin_at_beginning_of_month?
  memoize(:@begin_at_beginning_of_month) do
    self.begin.present? && self.begin.day == 1
  end
end

#begin_at_beginning_of_quarter?Boolean

Returns true when begin of the range is at the beginning of the quarter

Returns:

  • (Boolean)


187
188
189
190
191
# File 'lib/active_date_range/date_range.rb', line 187

def begin_at_beginning_of_quarter?
  memoize(:@begin_at_beginning_of_quarter) do
    self.begin.present? && begin_at_beginning_of_month? && [1, 4, 7, 10].include?(self.begin.month)
  end
end

#begin_at_beginning_of_week?Boolean

Returns true when begin of the range is at the beginning of the week

Returns:

  • (Boolean)


201
202
203
204
205
# File 'lib/active_date_range/date_range.rb', line 201

def begin_at_beginning_of_week?
  memoize(:@begin_at_beginning_of_week) do
    self.begin.present? && self.begin == self.begin.at_beginning_of_week
  end
end

#begin_at_beginning_of_year?Boolean

Returns true when begin of the range is at the beginning of the year

Returns:

  • (Boolean)


194
195
196
197
198
# File 'lib/active_date_range/date_range.rb', line 194

def begin_at_beginning_of_year?
  memoize(:@begin_at_beginning_of_year) do
    self.begin.present? && begin_at_beginning_of_month? && self.begin.month == 1
  end
end

#boundless?Boolean

Returns:

  • (Boolean)


126
127
128
# File 'lib/active_date_range/date_range.rb', line 126

def boundless?
  self.begin.nil? || self.end.nil?
end

#cap_begin(duration) ⇒ Object

Returns a new DateRange with the begin date capped to the given duration from the end date.

DateRange.parse("202101..202512").cap_begin(2.years) # => DateRange(2024-01-01..2025-12-31)


470
471
472
473
474
# File 'lib/active_date_range/date_range.rb', line 470

def cap_begin(duration)
  return self if boundless? || !exceeds?(duration)

  DateRange.new(self.end - duration + 1.day, self.end)
end

#cap_end(duration) ⇒ Object

Returns a new DateRange with the end date capped to the given duration from the begin date.

DateRange.parse("202101..202512").cap_end(2.years) # => DateRange(2021-01-01..2022-12-31)


461
462
463
464
465
# File 'lib/active_date_range/date_range.rb', line 461

def cap_end(duration)
  return self if boundless? || !exceeds?(duration)

  DateRange.new(self.begin, self.begin + duration - 1.day)
end

#current?Boolean Also known as: this_month?, this_quarter?, this_year?

Returns:

  • (Boolean)


285
286
287
288
289
# File 'lib/active_date_range/date_range.rb', line 285

def current?
  memoize(:@current) do
    cover?(Time.zone.today)
  end
end

#daysObject

Returns the number of days in the range



131
132
133
134
135
# File 'lib/active_date_range/date_range.rb', line 131

def days
  return if boundless?

  @days ||= (self.end - self.begin).to_i + 1
end

#exceeds?(limit) ⇒ Boolean

Returns:

  • (Boolean)


454
455
456
# File 'lib/active_date_range/date_range.rb', line 454

def exceeds?(limit)
  self.days > limit.in_days.ceil
end

#full_month?Boolean Also known as: full_months?

Returns true when the range is exactly one or more months long

Returns:

  • (Boolean)


243
244
245
246
247
# File 'lib/active_date_range/date_range.rb', line 243

def full_month?
  memoize(:@full_month) do
    begin_at_beginning_of_month? && self.end.present? && self.end == self.end.at_end_of_month
  end
end

#full_quarter?Boolean Also known as: full_quarters?

Returns true when the range is exactly one or more quarters long

Returns:

  • (Boolean)


252
253
254
255
256
# File 'lib/active_date_range/date_range.rb', line 252

def full_quarter?
  memoize(:@full_quarter) do
    begin_at_beginning_of_quarter? && self.end.present? && self.end == self.end.at_end_of_quarter
  end
end

#full_week?Boolean Also known as: full_weeks?

Returns true when the range is exactly one or more weeks long

Returns:

  • (Boolean)


270
271
272
273
274
# File 'lib/active_date_range/date_range.rb', line 270

def full_week?
  memoize(:@full_week) do
    begin_at_beginning_of_week? && self.end.present? && self.end == self.end.at_end_of_week
  end
end

#full_year?Boolean Also known as: full_years?

Returns true when the range is exactly one or more years long

Returns:

  • (Boolean)


261
262
263
264
265
# File 'lib/active_date_range/date_range.rb', line 261

def full_year?
  memoize(:@full_year) do
    begin_at_beginning_of_year? && self.end.present? && self.end == self.end.at_end_of_year
  end
end

#granularityObject

Returns the granularity of the range. Returns either :year, :quarter or :month based on if the range has exactly this length.

DateRange.this_month.granularity    # => :month
DateRange.this_quarter.granularity  # => :quarter
DateRange.this_year.granularity     # => :year


315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/active_date_range/date_range.rb', line 315

def granularity
  memoize(:@granularity) do
    if one_year?
      :year
    elsif one_quarter?
      :quarter
    elsif one_month?
      :month
    elsif one_week?
      :week
    end
  end
end

#humanize(format: :short) ⇒ Object

Returns a human readable format for the date range. See DateRange::Humanizer for options.



432
433
434
# File 'lib/active_date_range/date_range.rb', line 432

def humanize(format: :short)
  Humanizer.new(self, format: format).humanize
end

#in_groups_of(granularity, amount: 1) ⇒ Object

Returns an array with date ranges containing full months/quarters/years in the current range. Comes in handy when you need to have columns by month for a given range: ‘DateRange.this_year.in_groups_of(:months)`

Always returns full months/quarters/years, from the first to the last day of the period. The first and last item in the array can have a partial month/quarter/year, depending on the date range.

DateRange.parse("202101..202103").in_groups_of(:month) # => [DateRange.parse("202001..202001"), DateRange.parse("202002..202002"), DateRange.parse("202003..202003")]
DateRange.parse("202101..202106").in_groups_of(:month, amount: 2) # => [DateRange.parse("202001..202002"), DateRange.parse("202003..202004"), DateRange.parse("202005..202006")]


421
422
423
424
425
426
427
428
429
# File 'lib/active_date_range/date_range.rb', line 421

def in_groups_of(granularity, amount: 1)
  raise BoundlessRangeError, "Can't group date range without a begin." if self.begin.nil?

  if boundless?
    grouped_collection(granularity, amount: amount)
  else
    grouped_collection(granularity, amount: amount).to_a
  end
end

#include?(other) ⇒ Boolean

Returns:

  • (Boolean)


442
443
444
# File 'lib/active_date_range/date_range.rb', line 442

def include?(other)
  cover?(other)
end

#intersection(other) ⇒ Object

Returns the intersection of the current and the other date range



437
438
439
440
# File 'lib/active_date_range/date_range.rb', line 437

def intersection(other)
  intersection = self.to_a.intersection(other.to_a).sort
  DateRange.new(intersection) if intersection.any?
end

#monthsObject

Returns the number of months in the range or nil when range is no full month



152
153
154
155
156
# File 'lib/active_date_range/date_range.rb', line 152

def months
  return nil unless full_month?

  ((self.end.year - self.begin.year) * 12) + (self.end.month - self.begin.month + 1)
end

#next(periods = 1) ⇒ Object

Returns the period next to the current period. periods can be raised to return more than 1 next period.

DateRange.this_month.next # => DateRange.next_month
DateRange.this_month.next(2) # => DateRange.next_month + DateRange.next_month.next


396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/active_date_range/date_range.rb', line 396

def next(periods = 1)
  raise BoundlessRangeError, "Can't calculate next for boundless range" if boundless?

  end_date = if granularity
    self.end + periods.send(granularity)
  elsif full_month?
    in_groups_of(:month).last.next(periods * months).end
  else
    self.end + (periods * days).days
  end
  end_date = end_date.at_end_of_month if full_month?

  DateRange.new(self.end + 1.day, end_date)
end

#one_month?Boolean

Returns true when the range is exactly one month long

Returns:

  • (Boolean)


208
209
210
211
212
213
214
# File 'lib/active_date_range/date_range.rb', line 208

def one_month?
  memoize(:@one_month) do
    (28..31).cover?(days) &&
      begin_at_beginning_of_month? &&
      self.end == self.begin.at_end_of_month
  end
end

#one_quarter?Boolean

Returns true when the range is exactly one quarter long

Returns:

  • (Boolean)


217
218
219
220
221
222
223
# File 'lib/active_date_range/date_range.rb', line 217

def one_quarter?
  memoize(:@one_quarter) do
    (90..92).cover?(days) &&
      begin_at_beginning_of_quarter? &&
      self.end == self.begin.at_end_of_quarter
  end
end

#one_week?Boolean

Returns:

  • (Boolean)


234
235
236
237
238
239
240
# File 'lib/active_date_range/date_range.rb', line 234

def one_week?
  memoize(:@one_week) do
    days == 7 &&
      begin_at_beginning_of_week? &&
      self.end == self.begin.at_end_of_week
  end
end

#one_year?Boolean

Returns true when the range is exactly one year long

Returns:

  • (Boolean)


226
227
228
229
230
231
232
# File 'lib/active_date_range/date_range.rb', line 226

def one_year?
  memoize(:@one_year) do
    (365..366).cover?(days) &&
      begin_at_beginning_of_year? &&
      self.end == self.begin.at_end_of_year
  end
end

#previous(periods = 1) ⇒ Object

Returns the period previous to the current period. periods can be raised to return more than 1 previous period.

DateRange.this_month.previous # => DateRange.prev_month
DateRange.this_month.previous(2) # => DateRange.prev_month.previous + DateRange.prev_month


375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/active_date_range/date_range.rb', line 375

def previous(periods = 1)
  raise BoundlessRangeError, "Can't calculate previous for boundless range" if boundless?

  begin_date = if granularity
    self.begin - periods.send(granularity)
  elsif full_month?
    in_groups_of(:month).first.previous(periods * months).begin
  else
    (self.begin - (periods * days).days)
  end

  begin_date = begin_date.at_beginning_of_month if full_month?

  DateRange.new(begin_date, self.begin - 1.day)
end

#quartersObject

Returns the number of quarters in the range or nil when range is no full quarter



159
160
161
162
163
# File 'lib/active_date_range/date_range.rb', line 159

def quarters
  return nil unless full_quarter?

  months / 3
end

#relative_paramObject

Returns a string representation of the date range relative to today. For example a range of 2021-01-01..2021-12-31 will return this_year when the current date is somewhere in 2021.



332
333
334
335
336
337
338
339
340
# File 'lib/active_date_range/date_range.rb', line 332

def relative_param
  memoize(:@relative_param) do
    SHORTHANDS
      .select { |key, _| key.end_with?(granularity.to_s) }
      .find { |key, range| self == range.call }
      &.first
      &.to_s
  end
end

#same_year?Boolean

Returns true when begin and end are in the same year

Returns:

  • (Boolean)


279
280
281
282
283
# File 'lib/active_date_range/date_range.rb', line 279

def same_year?
  memoize(:@same_year) do
    !boundless? && self.begin.year == self.end.year
  end
end

#sizeObject Also known as: length

Returns the duration of the range as an ActiveSupport::Duration, compatible with validates_length_of. Use Duration values for the constraint:

validates_length_of :period, maximum: 10.years
validates_length_of :period, maximum: 6.months
validates_length_of :period, maximum: 30.days


143
144
145
146
147
# File 'lib/active_date_range/date_range.rb', line 143

def size
  return Float::INFINITY if boundless?

  days.days
end

#stretch_to_end_of_monthObject



446
447
448
449
450
451
452
# File 'lib/active_date_range/date_range.rb', line 446

def stretch_to_end_of_month
  return self if self.end.present? && self.end == self.end.at_end_of_month

  side_to_stretch = boundless? ? self.begin : self.end

  DateRange.new(self.begin, side_to_stretch.at_end_of_month)
end

#to_datetime_rangeObject

Returns a Range with begin and end as DateTime instances.



362
363
364
# File 'lib/active_date_range/date_range.rb', line 362

def to_datetime_range
  Range.new(self.begin.to_datetime.at_beginning_of_day, self.end.to_datetime.at_end_of_day)
end

#to_param(relative: true) ⇒ Object

Returns a param representation of the date range. When relative is true, the relative_param is returned when available. This allows for easy bookmarking of URL’s that always return the current month/quarter/year for the end user.

When relative is false, a YYYYMMDD..YYYYMMDD or YYYYMM..YYYYMM format is returned. The output of to_param is compatible with the parse method.

DateRange.parse("202001..202001").to_param                  # => "202001..202001"
DateRange.parse("20200101..20200115").to_param              # => "20200101..20200115"
DateRange.parse("202001..202001").to_param(relative: true)  # => "this_month"


352
353
354
355
356
357
358
359
# File 'lib/active_date_range/date_range.rb', line 352

def to_param(relative: true)
  if relative && relative_param
    relative_param
  else
    format = full_month? ? "%Y%m" : "%Y%m%d"
    "#{self.begin&.strftime(format)}..#{self.end&.strftime(format)}"
  end
end

#to_sObject



366
367
368
# File 'lib/active_date_range/date_range.rb', line 366

def to_s
  "#{self.begin.strftime('%Y%m%d')}..#{self.end.strftime('%Y%m%d')}"
end

#weeksObject

Returns the number of weeks on the range or nil when range is no full week



173
174
175
176
177
# File 'lib/active_date_range/date_range.rb', line 173

def weeks
  return nil unless full_week?

  days / 7
end

#yearsObject

Returns the number of years on the range or nil when range is no full year



166
167
168
169
170
# File 'lib/active_date_range/date_range.rb', line 166

def years
  return nil unless full_year?

  months / 12
end