active_date_range 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Guardfile ADDED
@@ -0,0 +1,46 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec features) \
6
+ # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
7
+
8
+ ## Note: if you are using the `directories` clause above and you are not
9
+ ## watching the project directory ('.'), then you will want to move
10
+ ## the Guardfile to a watched dir and symlink it back, e.g.
11
+ #
12
+ # $ mkdir config
13
+ # $ mv Guardfile config/
14
+ # $ ln -s config/Guardfile .
15
+ #
16
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
17
+
18
+ clearing :on
19
+
20
+ guard :minitest do
21
+ # with Minitest::Unit
22
+ watch(%r{^test/(.*)\/?test_(.*)\.rb$})
23
+ watch(%r{^test/(.*)\/?(.*)_test\.rb$})
24
+ watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" }
25
+ watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" }
26
+ watch(%r{^test/test_helper\.rb$}) { 'test' }
27
+
28
+ # with Minitest::Spec
29
+ # watch(%r{^spec/(.*)_spec\.rb$})
30
+ # watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
31
+ # watch(%r{^spec/spec_helper\.rb$}) { 'spec' }
32
+
33
+ # Rails 4
34
+ # watch(%r{^app/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" }
35
+ # watch(%r{^app/controllers/application_controller\.rb$}) { 'test/controllers' }
36
+ # watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" }
37
+ # watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" }
38
+ # watch(%r{^lib/(.+)\.rb$}) { |m| "test/lib/#{m[1]}_test.rb" }
39
+ # watch(%r{^test/.+_test\.rb$})
40
+ # watch(%r{^test/test_helper\.rb$}) { 'test' }
41
+
42
+ # Rails < 4
43
+ # watch(%r{^app/controllers/(.*)\.rb$}) { |m| "test/functional/#{m[1]}_test.rb" }
44
+ # watch(%r{^app/helpers/(.*)\.rb$}) { |m| "test/helpers/#{m[1]}_test.rb" }
45
+ # watch(%r{^app/models/(.*)\.rb$}) { |m| "test/unit/#{m[1]}_test.rb" }
46
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Moneybird
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Active Date Range
2
+
3
+ `ActiveDateRange` provides a range of dates with a powerful API to manipulate and use date ranges in your software. Date ranges are commonly used in reporting tools, but can be of use in many situation where you need for example filtering or reporting.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'active_date_range'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install active_date_range
20
+
21
+ ## Usage
22
+
23
+ ### Initialize a new date range
24
+
25
+ Initialize a new range:
26
+
27
+ ```ruby
28
+ ActiveDateRange::DateRange.new(Date.new(2021, 1, 1), Date.new(2021, 12, 31))
29
+ ActiveDateRange::DateRange.new(Date.new(2021, 1, 1)..Date.new(2021, 12, 31))
30
+ ```
31
+
32
+ You can also use shorthands to initialize a range relative to today. Shorthands are available for `this`, `prev` and `next` for the ranges `month`, `quarter` and `year`:
33
+
34
+ ```ruby
35
+ ActiveDateRange::DateRange.this_month
36
+ ActiveDateRange::DateRange.this_year
37
+ ActiveDateRange::DateRange.this_quarter
38
+ ActiveDateRange::DateRange.prev_month
39
+ ActiveDateRange::DateRange.prev_year
40
+ ActiveDateRange::DateRange.prev_quarter
41
+ ActiveDateRange::DateRange.next_month
42
+ ActiveDateRange::DateRange.next_year
43
+ ActiveDateRange::DateRange.next_quarter
44
+ ```
45
+
46
+ The third option is to use parse:
47
+
48
+ ```ruby
49
+ ActiveDateRange::DateRange.parse('202101..202112')
50
+ ActiveDateRange::DateRange.parse('20210101..20210115')
51
+ ```
52
+
53
+ Parse accepts three formats: `YYYYMM..YYYYMM`, `YYYYMMDD..YYYYMMDD` and any short hand like `this_month`.
54
+
55
+ ### The date range instance
56
+
57
+ A `DateRange` object is an extension of a regular `Range` object. You can use [all methods available on `Range`](https://ruby-doc.org/core-3.0.0/Range.html):
58
+
59
+ ```ruby
60
+ date_range = ActiveDateRange::DateRange.parse('202101..202112')
61
+ date_range.begin # => Date.new(2021, 1, 1)
62
+ date_range.end # => Date.new(2021, 12, 31)
63
+ date_range.cover?(Date.new(2021, 2, 1)) # => true
64
+ ```
65
+
66
+ `DateRange` adds extra methods to work with date ranges:
67
+
68
+ ```ruby
69
+ date_range.days # => 365
70
+ date_range.months # => 12
71
+ date_range.quarters # => 4
72
+ date_range.years # => 1
73
+ date_range.one_month? # => false
74
+ date_range.one_year? # => true
75
+ date_range.full_year? # => true
76
+ date_range.same_year? # => true
77
+ date_range.before?(Date.new(2022, 1, 1)) # => true
78
+ date_range.after?(ActiveDateRange::DateRange.parse('202001..202012')) # => true
79
+ date_range.granularity # => :year
80
+ date_range.to_param # => "202101..202112"
81
+ date_range.to_param(relative: true) # => "this_year"
82
+ ```
83
+
84
+ You can also do calculations with the ranges:
85
+
86
+ ```ruby
87
+ date_range.previous # => DateRange.parse('202001..202012')
88
+ date_range.previous(2) # => DateRange.parse('201901..202012')
89
+ date_range.next # => DateRange.parse('202201..202212')
90
+ date_range + DateRange.parse('202201..202202') # => DateRange.parse('202101..202202')
91
+ date_range.in_groups_of(:month) # => [DateRange.parse('202101..202101'), ..., DateRange.parse('202112..202112')]
92
+ ```
93
+
94
+ And lastly you can call `.humanize` to get a localizable human representation of the range for in the user interface:
95
+
96
+ ```ruby
97
+ date_range.humanize # => '2021'
98
+ date_range.humanize(format: :explicit) # => 'January 1st, 2021 - December 31st 2021'
99
+ ```
100
+
101
+ See [active_date_range/locale/en.yml](https://github.com/moneybird/active-date-range/blob/main/lib/active_date_range/locale/en.yml) for all the I18n keys you need to translate for your application.
102
+
103
+ ### Usage example
104
+
105
+ Use the shorthands to link to a specific period:
106
+
107
+ ```ruby
108
+ <%= link_to "Show report for #{DateRange.this_year.humanize}", report_url(period: DateRange.this_year.to_param(relative: true)) %>
109
+ ```
110
+
111
+ Because we use `to_params(relative: true)`, the user gets a bookmarkable URL which always points to the current year. If you need the URL to be bookmarkable and always point to the same period, remove `relative: true`.
112
+
113
+ In your controller, use the parameter in your queries:
114
+
115
+ ```ruby
116
+ def report
117
+ @period = DateRange.parse(params[:period])
118
+ @data = SomeModel.where(date: @period)
119
+ end
120
+ ```
121
+
122
+ In the report view, use the period object to render previous and next links:
123
+
124
+ ```ruby
125
+ <%= link_to "Next period", report_url(period: @period.next) %>
126
+ <%= link_to "Previous period", report_url(period: @period.previous) %>
127
+ ```
128
+
129
+ ## Development
130
+
131
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
132
+
133
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
134
+
135
+ ## Contributing
136
+
137
+ Bug reports and pull requests are welcome on GitHub at https://github.com/moneybird/active-date-range.
138
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,34 @@
1
+ require_relative 'lib/active_date_range/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "active_date_range"
5
+ spec.version = ActiveDateRange::VERSION
6
+ spec.authors = ["Edwin Vlieg"]
7
+ spec.email = ["edwin@moneybird.com"]
8
+
9
+ spec.summary = "DateRange for ActiveSupport"
10
+ spec.description = "ActiveDateRange provides a range of dates with a powerful API to manipulate and use date ranges in your software."
11
+ spec.homepage = "https://github.com/moneybird/active-date-range"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = "https://github.com/moneybird/active-date-range"
16
+ spec.metadata["changelog_uri"] = "https://github.com/moneybird/active-date-range/CHANGELOG.md"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "activesupport", "~> 6.1"
28
+ spec.add_dependency "i18n"
29
+
30
+ spec.add_development_dependency "rubocop"
31
+ spec.add_development_dependency "rubocop-packaging"
32
+ spec.add_development_dependency "rubocop-performance"
33
+ spec.add_development_dependency "rubocop-rails"
34
+ end
data/bin/console ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "active_support"
5
+ require "active_date_range"
6
+
7
+ Time.zone = "UTC"
8
+
9
+ # You can add fixtures and/or initialization code here to make experimenting
10
+ # with your gem easier. You can also use a different console, if you like.
11
+
12
+ # (If you use this, don't forget to add pry to your Gemfile!)
13
+ require "pry"
14
+ Pry.start
15
+
16
+ # require "irb"
17
+ # IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require "active_support"
4
+ require "active_support/core_ext/array"
5
+ require "active_support/core_ext/time"
6
+ require "active_support/core_ext/date"
7
+ require "active_support/core_ext/integer"
8
+ require "active_date_range/core_ext/integer"
9
+ require "active_date_range/core_ext/date"
10
+
11
+ require "active_date_range/version"
12
+ require "active_date_range/date_range"
13
+ require "active_date_range/humanizer"
14
+
15
+ module ActiveDateRange
16
+ class Error < StandardError; end
17
+ class InvalidDateRange < Error; end
18
+ class InvalidAddition < Error; end
19
+ class InvalidDateRangeFormat < Error; end
20
+ class UnknownGranularity < Error; end
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Date
4
+ # Returns the number of the quarter for this date
5
+ #
6
+ # Date.new(2021, 1, 1).quarter # => 1
7
+ def quarter
8
+ (month / 3.0).ceil
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Integer
4
+ # Returns Duration instance matching the number of quarters provided
5
+ #
6
+ # 2.quarters # => 6 months
7
+ def quarters
8
+ self * 3.months
9
+ end
10
+
11
+ alias quarter quarters
12
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDateRange
4
+ # Provides a <tt>DateRange</tt> with parsing, calculations and query methods
5
+ class DateRange < Range
6
+ SHORTHANDS = {
7
+ this_month: -> { DateRange.new(Time.zone.today.all_month) },
8
+ prev_month: -> { DateRange.new(1.month.ago.to_date.all_month) },
9
+ next_month: -> { DateRange.new(1.month.from_now.to_date.all_month) },
10
+ this_quarter: -> { DateRange.new(Time.zone.today.all_quarter) },
11
+ prev_quarter: -> { DateRange.new(3.months.ago.to_date.all_quarter) },
12
+ next_quarter: -> { DateRange.new(3.months.from_now.to_date.all_quarter) },
13
+ this_year: -> { DateRange.new(Time.zone.today.all_year) },
14
+ prev_year: -> { DateRange.new(12.months.ago.to_date.all_year) },
15
+ next_year: -> { DateRange.new(12.months.from_now.to_date.all_year) }
16
+ }.freeze
17
+
18
+ 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}
19
+
20
+ class << self
21
+ SHORTHANDS.each do |method, range|
22
+ define_method(method, range)
23
+ end
24
+ end
25
+
26
+ # Parses a date range string to a <tt>DateRange</tt> instance. Valid formats are:
27
+ # - A relative shorthand: <tt>this_month</tt>, <tt>prev_month</tt>, <tt>next_month</tt>, etc.
28
+ # - A begin and end date: <tt>YYYYMMDD..YYYYMMDD</tt>
29
+ # - A begin and end month: <tt>YYYYMM..YYYYMM</tt>
30
+ def self.parse(input)
31
+ return DateRange.new(input) if input.kind_of?(Range)
32
+ return SHORTHANDS[input.to_sym].call if SHORTHANDS.key?(input.to_sym)
33
+
34
+ begin_date, end_date = input.split("..")
35
+ raise InvalidDateRangeFormat, "#{input} doesn't have a begin..end format" unless begin_date && end_date
36
+
37
+ DateRange.new(parse_date(begin_date), parse_date(end_date, last: true))
38
+ end
39
+
40
+ def self.parse_date(input, last: false)
41
+ match_data = input.match(RANGE_PART_REGEXP)
42
+ raise InvalidDateRangeFormat, "#{input} isn't a valid date format YYYYMMDD or YYYYMM" unless match_data
43
+
44
+ date = Date.new(
45
+ match_data[:year].to_i,
46
+ match_data[:month].to_i,
47
+ match_data[:day]&.to_i || 1
48
+ )
49
+ return date.at_end_of_month if match_data[:day].nil? && last
50
+
51
+ date
52
+ rescue Date::Error
53
+ raise InvalidDateRangeFormat
54
+ end
55
+
56
+ private_class_method :parse_date
57
+
58
+ # Initializes a new DateRange. Accepts both a begin and end date or a range of dates.
59
+ # Make sures the begin date is before the end date.
60
+ def initialize(begin_date, end_date = nil)
61
+ begin_date, end_date = begin_date.begin, begin_date.end if begin_date.kind_of?(Range)
62
+ begin_date = begin_date.to_date if begin_date.kind_of?(Time)
63
+ end_date = end_date.to_date if end_date.kind_of?(Time)
64
+
65
+ raise InvalidDateRange, "Date range invalid, begin should be a date" unless begin_date.kind_of?(Date)
66
+ raise InvalidDateRange, "Date range invalid, end should be a date" unless end_date.kind_of?(Date)
67
+ raise InvalidDateRange, "Date range invalid, begin #{begin_date} is after end #{end_date}" if begin_date > end_date
68
+
69
+ super(begin_date, end_date)
70
+ end
71
+
72
+ # Adds two date ranges together. Fails when the ranges are not subsequent.
73
+ def +(other)
74
+ raise InvalidAddition if self.end != (other.begin - 1.day)
75
+
76
+ DateRange.new(self.begin, other.end)
77
+ end
78
+
79
+ # Sorts two date ranges by the begin date.
80
+ def <=>(other)
81
+ self.begin <=> other.begin
82
+ end
83
+
84
+ # Returns the number of days in the range
85
+ def days
86
+ @days ||= (self.end - self.begin).to_i + 1
87
+ end
88
+
89
+ # Returns the number of months in the range or nil when range is no full month
90
+ def months
91
+ return nil unless full_month?
92
+
93
+ ((self.end.year - self.begin.year) * 12) + (self.end.month - self.begin.month + 1)
94
+ end
95
+
96
+ # Returns the number of quarters in the range or nil when range is no full quarter
97
+ def quarters
98
+ return nil unless full_quarter?
99
+
100
+ months / 3
101
+ end
102
+
103
+ # Returns the number of years on the range or nil when range is no full year
104
+ def years
105
+ return nil unless full_year?
106
+
107
+ months / 12
108
+ end
109
+
110
+ # Returns true when begin of the range is at the beginning of the month
111
+ def begin_at_beginning_of_month?
112
+ self.begin.day == 1
113
+ end
114
+
115
+ # Returns true when begin of the range is at the beginning of the quarter
116
+ def begin_at_beginning_of_quarter?
117
+ begin_at_beginning_of_month? && [1, 4, 7, 10].include?(self.begin.month)
118
+ end
119
+
120
+ # Returns true when begin of the range is at the beginning of the year
121
+ def begin_at_beginning_of_year?
122
+ begin_at_beginning_of_month? && self.begin.month == 1
123
+ end
124
+
125
+ # Returns true when the range is exactly one month long
126
+ def one_month?
127
+ (28..31).cover?(days) &&
128
+ begin_at_beginning_of_month? &&
129
+ self.end == self.begin.at_end_of_month
130
+ end
131
+
132
+ # Returns true when the range is exactly one quarter long
133
+ def one_quarter?
134
+ (90..92).cover?(days) &&
135
+ begin_at_beginning_of_quarter? &&
136
+ self.end == self.begin.at_end_of_quarter
137
+ end
138
+
139
+ # Returns true when the range is exactly one year long
140
+ def one_year?
141
+ (365..366).cover?(days) &&
142
+ begin_at_beginning_of_year? &&
143
+ self.end == self.begin.at_end_of_year
144
+ end
145
+
146
+ # Returns true when the range is exactly one or more months long
147
+ def full_month?
148
+ begin_at_beginning_of_month? && self.end == self.end.at_end_of_month
149
+ end
150
+
151
+ alias :full_months? :full_month?
152
+
153
+ # Returns true when the range is exactly one or more quarters long
154
+ def full_quarter?
155
+ begin_at_beginning_of_quarter? && self.end == self.end.at_end_of_quarter
156
+ end
157
+
158
+ alias :full_quarters? :full_quarter?
159
+
160
+ # Returns true when the range is exactly one or more years long
161
+ def full_year?
162
+ begin_at_beginning_of_year? && self.end == self.end.at_end_of_year
163
+ end
164
+
165
+ alias :full_years? :full_year?
166
+
167
+ # Returns true when begin and end are in the same year
168
+ def same_year?
169
+ self.begin.year == self.end.year
170
+ end
171
+
172
+ # Returns true when the date range is before the given date. Accepts both a <tt>Date</tt>
173
+ # and <tt>DateRange</tt> as input.
174
+ def before?(date)
175
+ date = date.begin if date.kind_of?(DateRange)
176
+ self.end.before?(date)
177
+ end
178
+
179
+ # Returns true when the date range is after the given date. Accepts both a <tt>Date</tt>
180
+ # and <tt>DateRange</tt> as input.
181
+ def after?(date)
182
+ date = date.end if date.kind_of?(DateRange)
183
+ self.begin.after?(date)
184
+ end
185
+
186
+ # Returns the granularity of the range. Returns either <tt>:year</tt>, <tt>:quarter</tt> or
187
+ # <tt>:month</tt> based on if the range has exactly this length.
188
+ #
189
+ # DateRange.this_month.granularity # => :month
190
+ # DateRange.this_quarter.granularity # => :quarter
191
+ # DateRange.this_year.granularity # => :year
192
+ def granularity
193
+ if one_year?
194
+ :year
195
+ elsif one_quarter?
196
+ :quarter
197
+ elsif one_month?
198
+ :month
199
+ end
200
+ end
201
+
202
+ # Returns a string representation of the date range relative to today. For example
203
+ # a range of 2021-01-01..2021-12-31 will return `this_year` when the current date
204
+ # is somewhere in 2021.
205
+ def relative_param
206
+ @relative_param ||= SHORTHANDS
207
+ .select { |key, _| key.end_with?(granularity.to_s) }
208
+ .find { |key, range| self == range.call }
209
+ &.first
210
+ &.to_s
211
+ end
212
+
213
+ # Returns a param representation of the date range. When `relative` is true,
214
+ # the `relative_param` is returned when available. This allows for easy bookmarking of
215
+ # URL's that always return the current month/quarter/year for the end user.
216
+ #
217
+ # When `relative` is false, a `YYYYMMDD..YYYYMMDD` or `YYYYMM..YYYYMM` format is
218
+ # returned. The output of `to_param` is compatible with the `parse` method.
219
+ #
220
+ # DateRange.parse("202001..202001").to_param # => "202001..202001"
221
+ # DateRange.parse("20200101..20200115").to_param # => "20200101..20200115"
222
+ # DateRange.parse("202001..202001").to_param(relative: true) # => "this_month"
223
+ def to_param(relative: true)
224
+ if relative && relative_param
225
+ relative_param
226
+ else
227
+ format = full_month? ? "%Y%m" : "%Y%m%d"
228
+ "#{self.begin.strftime(format)}..#{self.end.strftime(format)}"
229
+ end
230
+ end
231
+
232
+ # Returns a Range with begin and end as DateTime instances.
233
+ def to_datetime_range
234
+ Range.new(self.begin.to_datetime.at_beginning_of_day, self.end.to_datetime.at_end_of_day)
235
+ end
236
+
237
+ def to_s
238
+ "#{self.begin.strftime('%Y%m%d')}..#{self.end.strftime('%Y%m%d')}"
239
+ end
240
+
241
+ # Returns the period previous to the current period. `periods` can be raised to return more
242
+ # than 1 previous period.
243
+ #
244
+ # DateRange.this_month.previous # => DateRange.prev_month
245
+ # DateRange.this_month.previous(2) # => DateRange.prev_month.previous + DateRange.prev_month
246
+ def previous(periods = 1)
247
+ if granularity
248
+ DateRange.new(self.begin - periods.send(granularity), self.begin - 1.day)
249
+ elsif full_month?
250
+ DateRange.new(in_groups_of(:month).first.previous(periods * months).begin, self.begin - 1.day)
251
+ else
252
+ DateRange.new((self.begin - (periods * days).days).at_beginning_of_month, self.begin - 1.day)
253
+ end
254
+ end
255
+
256
+ # Returns the period next to the current period. `periods` can be raised to return more
257
+ # than 1 next period.
258
+ #
259
+ # DateRange.this_month.next # => DateRange.next_month
260
+ # DateRange.this_month.next(2) # => DateRange.next_month + DateRange.next_month.next
261
+ def next(periods = 1)
262
+ if granularity
263
+ DateRange.new(self.end + 1.day, (self.end + periods.send(granularity)).at_end_of_month)
264
+ else
265
+ DateRange.new(self.end + 1.day, (self.end + days.days).at_end_of_month)
266
+ end
267
+ end
268
+
269
+ # Returns an array with date ranges containing full months/quarters/years in the current range.
270
+ # Comes in handy when you need to have columns by month for a given range:
271
+ # `DateRange.this_year.in_groups_of(:months)`
272
+ #
273
+ # Always returns full months/quarters/years, from the first to the last day of the period.
274
+ # The first and last item in the array can have a partial month/quarter/year, depending on
275
+ # the date range.
276
+ #
277
+ # DateRange.parse("202101..202103").in_groups_of(:month) # => [DateRange.parse("202001..202001"), DateRange.parse("202002..202002"), DateRange.parse("202003..202003")]
278
+ # DateRange.parse("202101..202106").in_groups_of(:month, amount: 2) # => [DateRange.parse("202001..202002"), DateRange.parse("202003..202004"), DateRange.parse("202005..202006")]
279
+ def in_groups_of(granularity, amount: 1)
280
+ raise UnknownGranularity, "Unknown granularity #{granularity}. Valid are: month, quarter and year" unless %w[month quarter year].include?(granularity.to_s)
281
+
282
+ group_by { |d| [d.year, d.send(granularity)] }
283
+ .map { |_, group| DateRange.new(group.first..group.last) }
284
+ .in_groups_of(amount)
285
+ .map { |group| group.inject(:+) }
286
+ end
287
+
288
+ # Returns a human readable format for the date range. See DateRange::Humanizer for options.
289
+ def humanize(format: :short)
290
+ Humanizer.new(self, format: format).humanize
291
+ end
292
+ end
293
+ end