active_date_range 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +281 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +143 -0
- data/Guardfile +46 -0
- data/LICENSE +21 -0
- data/README.md +138 -0
- data/Rakefile +10 -0
- data/active_date_range.gemspec +34 -0
- data/bin/console +17 -0
- data/bin/setup +6 -0
- data/lib/active_date_range.rb +21 -0
- data/lib/active_date_range/core_ext/date.rb +10 -0
- data/lib/active_date_range/core_ext/integer.rb +12 -0
- data/lib/active_date_range/date_range.rb +293 -0
- data/lib/active_date_range/humanizer.rb +121 -0
- data/lib/active_date_range/i18n.rb +9 -0
- data/lib/active_date_range/locale/en.yml +38 -0
- data/lib/active_date_range/version.rb +5 -0
- metadata +151 -0
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,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,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,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
|