fat_period 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 06d57a27d2bdf83d78c5d0a02b470af0a9f59d69
4
+ data.tar.gz: 7beb9253ef8d2020d24d41dbb30c9b92d402cbbc
5
+ SHA512:
6
+ metadata.gz: 8936394c6426abb4048f95f2e2930dcafa822bb273831df12e02d26f100a6d6778581bf2e00b665e34b37fc327b242fd85cfa17bd24a8524d87142256cdd0a03
7
+ data.tar.gz: e2fd7548fa3f8134b4fd3e79b8ff91aff97d30348cf3d5556faa6c4564adab292518df8748532fa3d3858a444ac4f2c210c7f4e99aecee6e2c32572626f02440
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.0
5
+ before_install: gem install bundler -v 1.14.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in fat_period.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # FatPeriod
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/fat_period`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'fat_period'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install fat_period
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ 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).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Daniel E. Doherty/fat_period.
36
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'fat_period'
5
+ require 'pry'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+ @dd1 = Date.parse('2016-01-31')
10
+ @dd2 = Date.parse('2016-01-30')
11
+ @dd3 = Date.parse('2016-01-29')
12
+
13
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'fat_period/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'fat_period'
8
+ spec.version = FatPeriod::VERSION
9
+ spec.authors = ['Daniel E. Doherty']
10
+ spec.email = ['ded-law@ddoherty.net']
11
+
12
+ spec.summary = %q{Implements a Period class as a Range of Dates.}
13
+ spec.homepage = 'https://github.com/ddoherty03/fat_period'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ # Don't install any executables.
19
+ # spec.bindir = 'bin'
20
+ # spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_development_dependency 'bundler', '~> 1.14'
24
+ spec.add_development_dependency 'rake', '~> 10.0'
25
+ spec.add_development_dependency 'rspec', '~> 3.0'
26
+ spec.add_development_dependency 'pry'
27
+ spec.add_development_dependency 'pry-doc'
28
+ spec.add_development_dependency 'pry-byebug'
29
+
30
+ spec.add_runtime_dependency 'fat_core', '~> 4.0', '>= 4.1'
31
+ end
@@ -0,0 +1,31 @@
1
+ require 'date'
2
+
3
+ module FatPeriod
4
+ # An extension of Date for methods useful with respect to FatPeriod::Periods.
5
+ module Date
6
+ # Return the Period of the given chunk size that contains this Date. Chunk
7
+ # can be one of :year, :half, :quarter, :bimonth, :month, :semimonth,
8
+ # :biweek, :week, or :day.
9
+ #
10
+ # @example
11
+ # date = Date.parse('2015-06-13')
12
+ # date.expand_to_period(:week) #=> Period(2015-06-08..2015-06-14)
13
+ # date.expand_to_period(:semimonth) #=> Period(2015-06-01..2015-06-15)
14
+ # date.expand_to_period(:quarter) #=> Period(2015-04-01..2015-06-30)
15
+ #
16
+ # @param chunk [Symbol] one of :year, :half, :quarter, :bimonth, :month,
17
+ # :semimonth, :biweek, :week, or :day
18
+ # @return [Period] Period of size `chunk` containing self
19
+ def expand_to_period(chunk)
20
+ require 'fat_period'
21
+ Period.new(beginning_of_chunk(chunk), end_of_chunk(chunk))
22
+ end
23
+ end
24
+ end
25
+
26
+ # An extension Date for methods useful with respect to FatPeriod::Periods.
27
+ class Date
28
+ include FatPeriod::Date
29
+ # @!parse include FatPeriod::Date
30
+ # @!parse extend FatPeriod::Date::ClassMethods
31
+ end
@@ -0,0 +1,785 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'fat_core/date'
4
+ require 'fat_core/range'
5
+ require 'fat_core/string'
6
+
7
+ class Period
8
+ # Return the first Date of the Period
9
+ #
10
+ # @return [Date]
11
+ attr_reader :first
12
+
13
+ # Return the last Date of the Period
14
+ #
15
+ # @return [Date]
16
+ attr_reader :last
17
+
18
+ # @group Construction
19
+ #
20
+ # Return a new Period from the Date `first` to the Date `last` inclusive. Both
21
+ # parameters can be either a Date object or a String that can be parsed as a
22
+ # valid Date with `Date.parse`.
23
+ #
24
+ # @param first [Date, String] first date of Period
25
+ # @param last [Date, String] last date of Period
26
+ # @raise [ArgumentError] if string is not parseable as a Date or
27
+ # @raise [ArgumentError] if first date is later than last date
28
+ # @return [Period]
29
+ def initialize(first, last)
30
+ if first.is_a?(Date)
31
+ @first = first
32
+ elsif first.respond_to?(:to_s)
33
+ begin
34
+ @first = Date.parse(first.to_s)
35
+ rescue ArgumentError => ex
36
+ if ex.message =~ /invalid date/
37
+ raise ArgumentError, "you gave an invalid date '#{first}'"
38
+ else
39
+ raise
40
+ end
41
+ end
42
+ else
43
+ raise ArgumentError, 'use Date or String to initialize Period'
44
+ end
45
+
46
+ if last.is_a?(Date)
47
+ @last = last
48
+ elsif last.respond_to?(:to_s)
49
+ begin
50
+ @last = Date.parse(last.to_s)
51
+ rescue ArgumentError => ex
52
+ if ex.message =~ /invalid date/
53
+ raise ArgumentError, "you gave an invalid date '#{last}'"
54
+ else
55
+ raise
56
+ end
57
+ end
58
+ else
59
+ raise ArgumentError, 'use Date or String to initialize Period'
60
+ end
61
+ if @first > @last
62
+ raise ArgumentError, "Period's first date is later than its last date"
63
+ end
64
+ end
65
+
66
+ # These need to come after initialize is defined
67
+
68
+ # Period from commercial beginning of time to today
69
+ TO_DATE = Period.new(Date::BOT, Date.current)
70
+
71
+ # Period from commercial beginning of time to commercial end of time.
72
+ FOREVER = Period.new(Date::BOT, Date::EOT)
73
+
74
+ # @group Conversion
75
+
76
+ # Convert this Period to a Range.
77
+ #
78
+ # @return [Range]
79
+ def to_range
80
+ (first..last)
81
+ end
82
+
83
+ # Return a string representing this Period using compact format for years,
84
+ # halves, quarters, or months that represent a whole period; otherwise, just
85
+ # format the period as 'YYYY-MM-DD to YYYY-MM-DD'.
86
+ #
87
+ # @example
88
+ # Period.new('2016-01-01', '2016-03-31') #=> '2016-1Q'
89
+ # Period.new('2016-01-01', '2016-12-31') #=> '2016'
90
+ # Period.new('2016-01-01', '2016-11-30') #=> '2016-01-01 to 2016-11-30'
91
+ #
92
+ # @return [String] concise representation of Period
93
+ def to_s
94
+ if first.beginning_of_year? && last.end_of_year? && first.year == last.year
95
+ first.year.to_s
96
+ elsif first.beginning_of_half? &&
97
+ last.end_of_half? &&
98
+ first.year == last.year &&
99
+ first.half == last.half
100
+ "#{first.year}-#{first.half}H"
101
+ elsif first.beginning_of_quarter? &&
102
+ last.end_of_quarter? &&
103
+ first.year == last.year &&
104
+ first.quarter == last.quarter
105
+ "#{first.year}-#{first.quarter}Q"
106
+ elsif first.beginning_of_month? &&
107
+ last.end_of_month? &&
108
+ first.year == last.year &&
109
+ first.month == last.month
110
+ "#{first.year}-%02d" % first.month
111
+ else
112
+ "#{first.iso} to #{last.iso}"
113
+ end
114
+ end
115
+
116
+ # A concise way to print out Periods for inspection as
117
+ # 'Period(YYYY-MM-DD..YYYY-MM-DD)'.
118
+ #
119
+ # @return [String]
120
+ def inspect
121
+ "Period(#{first.iso}..#{last.iso})"
122
+ end
123
+
124
+ # Allow erb documents can directly interpolate ranges
125
+ def tex_quote
126
+ "#{first.iso}--#{last.iso}"
127
+ end
128
+
129
+
130
+ include Comparable
131
+
132
+ # @group Comparison
133
+ #
134
+ # Comparable base: periods are compared by first, then by last and are equal
135
+ # only if their first and last dates are equal. Sorting will be by first date,
136
+ # then last, so periods starting on the same date will sort from smallest to
137
+ # largest.
138
+ #
139
+ # @param other [Period] @return [Integer] -1 if self < other; 0 if self ==
140
+ # other; 1 if self > other
141
+ def <=>(other)
142
+ return nil unless other.is_a?(Period)
143
+ [first, last] <=> [other.first, other.last]
144
+ end
145
+
146
+ # Comparable does not include this.
147
+ def !=(other)
148
+ !(self == other)
149
+ end
150
+
151
+ # Return whether this Period contains the given date.
152
+ #
153
+ # @param date [Date] date to test
154
+ # @return [Boolean] is the given date within this Period?
155
+ def contains?(date)
156
+ date = date.to_date if date.respond_to?(:to_date)
157
+ raise ArgumentError, 'argument must be a Date' unless date.is_a?(Date)
158
+ to_range.cover?(date)
159
+ end
160
+ alias === contains?
161
+
162
+ include Enumerable
163
+
164
+ # @group Enumeration
165
+
166
+ # Yield each day in this Period.
167
+ def each
168
+ d = first
169
+ while d <= last
170
+ yield d
171
+ d += 1.day
172
+ end
173
+ end
174
+
175
+ # Return an Array of the days in the Period that are trading days on the NYSE.
176
+ # See FatCore::Date for how trading days are determined.
177
+ #
178
+ # @return [Array<Date>] trading days in this period
179
+ def trading_days
180
+ select(&:nyse_workday?)
181
+ end
182
+
183
+ # @group Size
184
+
185
+ # Return the number of days in the period
186
+ def size
187
+ (last - first + 1).to_i
188
+ end
189
+ alias length size
190
+ alias days size
191
+
192
+ # Return the fractional number of months in the period. By default, use the
193
+ # average number of days in a month, but allow the user to override the
194
+ # assumption with a parameter.
195
+ def months(days_in_month = 30.436875)
196
+ (days / days_in_month.to_f).to_f
197
+ end
198
+
199
+ # Return the fractional number of years in the period. By default, use the
200
+ # average number of days in a year, but allow the user to override the
201
+ # assumption with a parameter.
202
+ def years(days_in_year = 365.2425)
203
+ (days / days_in_year.to_f).to_f
204
+ end
205
+
206
+ # @group Parsing
207
+ #
208
+ # Return a period based on two date specs passed as strings (see
209
+ # `FatCore::Date.parse_spec`), a 'from' and a 'to' spec. The returned period
210
+ # begins on the first day of the period given as the `from` spec and ends on
211
+ # the last day given as the `to` spec. If the to spec is not given or is nil,
212
+ # the from spec is used for both the from- and to-spec.
213
+ #
214
+ # @example
215
+ # Period.parse('2014-11').inspect #=> Period('2014-11-01..2014-11-30')
216
+ # Period.parse('2014-11', '2015-3Q').inspect #=> Period('2014-11-01..2015-09-30')
217
+ # # Assuming this executes in December, 2014
218
+ # Period.parse('last_month', 'this_month').inspect #=> Period('2014-11-01..2014-12-31')
219
+ #
220
+ # @param from [String] spec ala FatCore::Date.parse_spec
221
+ # @param to [String] spec ala FatCore::Date.parse_spec
222
+ # @return [Period] from beginning of `from` to end of `to`
223
+ def self.parse(from, to = nil)
224
+ raise ArgumentError, 'Period.parse missing argument' unless from
225
+ to ||= from
226
+ first = Date.parse_spec(from, :from)
227
+ second = Date.parse_spec(to, :to)
228
+ Period.new(first, second) if first && second
229
+ end
230
+
231
+ # Return a period as in `Period.parse` from a String phrase in which the from
232
+ # spec is introduced with 'from' and, optionally, the to spec is introduced
233
+ # with 'to'. A phrase with only a to spec is treated the same as one with
234
+ # only a from spec. If neither 'from' nor 'to' appear in phrase, treat the
235
+ # whole string as a from spec.
236
+ #
237
+ # @example
238
+ # Period.parse_phrase('from 2014-11 to 2015-3Q') #=> Period('2014-11-01..2015-09-30')
239
+ # Period.parse_phrase('from 2014-11') #=> Period('2014-11-01..2014-11-30')
240
+ # Period.parse_phrase('from 2015-3Q') #=> Period('2015-09-01..2015-12-31')
241
+ # Period.parse_phrase('to 2015-3Q') #=> Period('2015-09-01..2015-12-31')
242
+ # Period.parse_phrase('2015-3Q') #=> Period('2015-09-01..2015-12-31')
243
+ #
244
+ # @param phrase [String] with 'from <spec> to <spec>'
245
+ # @return [Period] translated from phrase
246
+ def self.parse_phrase(phrase)
247
+ phrase = phrase.clean
248
+ if phrase =~ /\Afrom (.*) to (.*)\z/
249
+ from_phrase = $1
250
+ to_phrase = $2
251
+ elsif phrase =~ /\Afrom (.*)\z/
252
+ from_phrase = $1
253
+ to_phrase = nil
254
+ elsif phrase =~ /\Ato (.*)\z/
255
+ from_phrase = $1
256
+ else
257
+ from_phrase = phrase
258
+ to_phrase = nil
259
+ end
260
+ parse(from_phrase, to_phrase)
261
+ end
262
+
263
+ # Possibly useful class method to take an array of periods and join all the
264
+ # contiguous ones, then return an array of the disjoint periods not
265
+ # contiguous to one another. An array of periods with no gaps should return
266
+ # an array of only one period spanning all the given periods.
267
+ #
268
+ # Return an array of periods that represent the concatenation of all
269
+ # adjacent periods in the given periods.
270
+ # def self.meld_periods(*periods)
271
+ # melded_periods = []
272
+ # while (this_period = periods.pop)
273
+ # melded_periods.each do |mp|
274
+ # if mp.overlaps?(this_period)
275
+ # melded_periods.delete(mp)
276
+ # melded_periods << mp.union(this_period)
277
+ # break
278
+ # elsif mp.contiguous?(this_period)
279
+ # melded_periods.delete(mp)
280
+ # melded_periods << mp.join(this_period)
281
+ # break
282
+ # end
283
+ # end
284
+ # end
285
+ # melded_periods
286
+ # end
287
+ #
288
+ # @group Chunking
289
+ #
290
+
291
+ # An Array of the valid Symbols for calendar chunks plus the Symbol :irregular
292
+ # for other periods.
293
+ CHUNKS = [
294
+ :day, :week, :biweek, :semimonth, :month, :bimonth,
295
+ :quarter, :half, :year, :irregular
296
+ ]
297
+
298
+ # An Array of Ranges for the number of days that can be covered by each chunk.
299
+ CHUNK_RANGE = {
300
+ day: (1..1), week: (7..7), biweek: (14..14), semimonth: (15..16),
301
+ month: (28..31), bimonth: (59..62), quarter: (90..92),
302
+ half: (180..183), year: (365..366)
303
+ }
304
+
305
+ # Return the chunk symbol represented by this period if it covers a single
306
+ # calendar period; otherwise return :irregular.
307
+ #
308
+ # @example
309
+ # Period.new('2016-02-01', '2016-02-29').chunk_sym #=> :month
310
+ # Period.new('2016-02-01', '2016-02-28').chunk_sym #=> :irregular
311
+ # Period.new('2016-02-01', '2017-02-28').chunk_sym #=> :irregular
312
+ # Period.new('2016-01-01', '2016-03-31').chunk_sym #=> :quarter
313
+ # Period.new('2016-01-02', '2016-04-01').chunk_sym #=> :irregular
314
+ #
315
+ # @return [Symbol]
316
+ def chunk_sym
317
+ if first.beginning_of_year? && last.end_of_year? &&
318
+ CHUNK_RANGE[:year].cover?(size)
319
+ :year
320
+ elsif first.beginning_of_half? && last.end_of_half? &&
321
+ CHUNK_RANGE[:half].cover?(size)
322
+ :half
323
+ elsif first.beginning_of_quarter? && last.end_of_quarter? &&
324
+ CHUNK_RANGE[:quarter].cover?(size)
325
+ :quarter
326
+ elsif first.beginning_of_bimonth? && last.end_of_bimonth? &&
327
+ CHUNK_RANGE[:bimonth].cover?(size)
328
+ :bimonth
329
+ elsif first.beginning_of_month? && last.end_of_month? &&
330
+ CHUNK_RANGE[:month].cover?(size)
331
+ :month
332
+ elsif first.beginning_of_semimonth? && last.end_of_semimonth &&
333
+ CHUNK_RANGE[:semimonth].cover?(size)
334
+ :semimonth
335
+ elsif first.beginning_of_biweek? && last.end_of_biweek? &&
336
+ CHUNK_RANGE[:biweek].cover?(size)
337
+ :biweek
338
+ elsif first.beginning_of_week? && last.end_of_week? &&
339
+ CHUNK_RANGE[:week].cover?(size)
340
+ :week
341
+ elsif first == last
342
+ :day
343
+ else
344
+ :irregular
345
+ end
346
+ end
347
+
348
+ # Return a string name for this period based solely on the number of days in
349
+ # the period. Any period sufficiently close to 30 days will return the string
350
+ # 'Month', and any period sufficiently close to 90 days will return 'Quarter'.
351
+ # However for the shorter periods, periods less than month, no tolerance is
352
+ # applied. The amount of tolerance for the longer periods can be controlled
353
+ # with the `tolerance_pct` parameter, which default to 10%. If no calendar
354
+ # period corresponds to the length of the period, return 'Period'.
355
+ #
356
+ # @example
357
+ # Period.new('2015-05-15', '2015-06-17').chunk_name #=> 'Month' (within 10%)
358
+ # Period.new('2015-05-15', '2015-06-17').chunk_name(8) #=> 'Period' (but not 8%)
359
+ #
360
+ # @param tolerance_pct [Numeric] long period tolerance as a percent, 10 by default
361
+ # @return [String] the name for this period based solely on the number of days
362
+ # in the period.
363
+ def chunk_name(tolerance_pct = 10)
364
+ case Period.days_to_chunk(length, tolerance_pct)
365
+ when :year
366
+ 'Year'
367
+ when :half
368
+ 'Half'
369
+ when :quarter
370
+ 'Quarter'
371
+ when :bimonth
372
+ 'Bimonth'
373
+ when :month
374
+ 'Month'
375
+ when :semimonth
376
+ 'Semimonth'
377
+ when :biweek
378
+ 'Biweek'
379
+ when :week
380
+ 'Week'
381
+ when :day
382
+ 'Day'
383
+ else
384
+ 'Period'
385
+ end
386
+ end
387
+
388
+ # Return the chunk symbol represented by the number of days given, but allow a
389
+ # deviation from the minimum and maximum number of days for periods larger
390
+ # than bimonths. The default tolerance is +/-10%, but that can be adjusted. The
391
+ # reason for allowing a bit of tolerance for the larger periods is that
392
+ # financial statements meant to cover a given calendar period are often short
393
+ # or long by a few days due to such things as weekends, holidays, or
394
+ # accounting convenience. For example, a bank might issuer "monthly"
395
+ # statements approximately every 30 days, but issue them earlier or later to
396
+ # avoid having the closing date fall on a weekend or holiday. We still want to
397
+ # be able to recognize them as "monthly", even though the period covered might
398
+ # be a few days shorter or longer than any possible calendar month. You can
399
+ # eliminate this "fudge factor" by setting the `tolerance_pct` to zero. If
400
+ # the number of days corresponds to none of the defined calendar periods,
401
+ # return the symbol `:irregular`.
402
+ #
403
+ # @example
404
+ # Period.days_to_chunk(360) #=> :year
405
+ # Period.days_to_chunk(360, 0) #=> :irregular
406
+ # Period.days_to_chunk(88) #=> :quarter
407
+ # Period.days_to_chunk(88, 0) #=> :irregular
408
+ #
409
+ # @param days [Integer] the number of days in the period under test
410
+ # @param tolerance_pct [Numberic] the percent deviation allowed, e.g. 10 => 10%
411
+ # @return [Symbol] symbol for the period corresponding to days number of days
412
+ def self.days_to_chunk(days, tolerance_pct = 10)
413
+ result = :irregular
414
+ CHUNK_RANGE.each_pair do |chunk, rng|
415
+ if [:semimonth, :biweek, :week, :day].include?(chunk)
416
+ # Be strict for shorter periods.
417
+ if rng.cover?(days)
418
+ result = chunk
419
+ break
420
+ end
421
+ else
422
+ # Allow some tolerance for longer periods.
423
+ min = (rng.first * ((100.0 - tolerance_pct) / 100.0)).floor
424
+ max = (rng.last * ((100.0 + tolerance_pct) / 100.0)).floor
425
+ if (min..max).cover?(days)
426
+ result = chunk
427
+ break
428
+ end
429
+ end
430
+ end
431
+ result
432
+ end
433
+
434
+ # Return an array of Periods wholly-contained within self in chunks of size,
435
+ # defaulting to monthly chunks. Partial chunks at the beginning and end of
436
+ # self are not included unless `partial_first` or `partial_last`,
437
+ # respectively, are set true. The last chunk can be made to extend beyond the
438
+ # end of self to make it a whole chunk if `round_up_last` is set true, in
439
+ # which case, partial_last is ignored.
440
+ #
441
+ # @example
442
+ # Period.parse('2015').chunks(size: :month) #=>
443
+ # [Period(2015-01-01..2015-01-31),
444
+ # Period(2015-02-01..2015-02-28),
445
+ # Period(2015-03-01..2015-03-31),
446
+ # Period(2015-04-01..2015-04-30),
447
+ # Period(2015-05-01..2015-05-31),
448
+ # Period(2015-06-01..2015-06-30),
449
+ # Period(2015-07-01..2015-07-31),
450
+ # Period(2015-08-01..2015-08-31),
451
+ # Period(2015-09-01..2015-09-30),
452
+ # Period(2015-10-01..2015-10-31),
453
+ # Period(2015-11-01..2015-11-30),
454
+ # Period(2015-12-01..2015-12-31)
455
+ # ]
456
+ #
457
+ # Period.parse('2015').chunks(size: :week) #=>
458
+ # [Period(2015-01-05..2015-01-11), # Note that first week starts after Jan 1.
459
+ # Period(2015-01-12..2015-01-18),
460
+ # Period(2015-01-19..2015-01-25),
461
+ # Period(2015-01-26..2015-02-01),
462
+ # ...
463
+ # Period(2015-12-07..2015-12-13),
464
+ # Period(2015-12-14..2015-12-20),
465
+ # Period(2015-12-21..2015-12-27)] # Note that last week ends before Dec 31
466
+ #
467
+ # Period.parse('2015').chunks(size: :week, partial_first: true, partial_last: true) #=>
468
+ # [Period(2015-01-01..2015-01-04), # Note the partial week starting Jan 1
469
+ # Period(2015-01-05..2015-01-11),
470
+ # Period(2015-01-12..2015-01-18),
471
+ # Period(2015-01-19..2015-01-25),
472
+ # Period(2015-01-26..2015-02-01),
473
+ # ...
474
+ # Period(2015-12-07..2015-12-13),
475
+ # Period(2015-12-14..2015-12-20),
476
+ # Period(2015-12-21..2015-12-27)
477
+ # Period(2015-12-28..2015-12-31) # Note partial week ending Dec 31
478
+ # ]
479
+ #
480
+ # Period.parse('2015').chunks(size: :week, partial_first: true, round_up_last: true) #=>
481
+ # [Period(2015-01-01..2015-01-04), # Note the partial week starting Jan 1
482
+ # Period(2015-01-05..2015-01-11),
483
+ # Period(2015-01-12..2015-01-18),
484
+ # Period(2015-01-19..2015-01-25),
485
+ # Period(2015-01-26..2015-02-01),
486
+ # ...
487
+ # Period(2015-12-07..2015-12-13),
488
+ # Period(2015-12-14..2015-12-20),
489
+ # Period(2015-12-21..2015-12-27)
490
+ # Period(2015-12-28..2016-01-03) # Note full week extending beyond self
491
+ # ]
492
+ #
493
+ # @raise ArgumentError if size of chunks is larger than self or if an invalid
494
+ # chunk size.
495
+ # @param size [Symbol] a chunk symbol, :year, :half. :quarter, etc.
496
+ # @param partial_first [Boolean] allow a period less than a full :size period
497
+ # as the first period in the returned array.
498
+ # @param partial_last [Boolean] allow a period less than a full :size period
499
+ # as the last period in the returned array.
500
+ # @param round_up_last [Boolean] allow the last period in the returned array
501
+ # to extend beyond the end of self.
502
+ # @return [Array<Period>] periods that subdivide self into chunks of size, `size`
503
+ def chunks(size: :month, partial_first: false, partial_last: false,
504
+ round_up_last: false)
505
+ size = size.to_sym
506
+ unless CHUNKS.include?(size)
507
+ raise ArgumentError, "unknown chunk size '#{size}'"
508
+ end
509
+ if CHUNK_RANGE[size].first > length
510
+ if partial_first || partial_last
511
+ return [self]
512
+ else
513
+ raise ArgumentError, "any #{size} is longer than this period's #{length} days"
514
+ end
515
+ end
516
+ result = []
517
+ chunk_start = first.dup
518
+ while chunk_start <= last
519
+ case size
520
+ when :year
521
+ unless partial_first
522
+ chunk_start += 1.day until chunk_start.beginning_of_year?
523
+ end
524
+ chunk_end = chunk_start.end_of_year
525
+ when :half
526
+ unless partial_first
527
+ chunk_start += 1.day until chunk_start.beginning_of_half?
528
+ end
529
+ chunk_end = chunk_start.end_of_half
530
+ when :quarter
531
+ unless partial_first
532
+ chunk_start += 1.day until chunk_start.beginning_of_quarter?
533
+ end
534
+ chunk_end = chunk_start.end_of_quarter
535
+ when :bimonth
536
+ unless partial_first
537
+ chunk_start += 1.day until chunk_start.beginning_of_bimonth?
538
+ end
539
+ chunk_end = (chunk_start.end_of_month + 1.day).end_of_month
540
+ when :month
541
+ unless partial_first
542
+ chunk_start += 1.day until chunk_start.beginning_of_month?
543
+ end
544
+ chunk_end = chunk_start.end_of_month
545
+ when :semimonth
546
+ unless partial_first
547
+ chunk_start += 1.day until chunk_start.beginning_of_semimonth?
548
+ end
549
+ chunk_end = chunk_start.end_of_semimonth
550
+ when :biweek
551
+ unless partial_first
552
+ chunk_start += 1.day until chunk_start.beginning_of_biweek?
553
+ end
554
+ chunk_end = chunk_start.end_of_biweek
555
+ when :week
556
+ unless partial_first
557
+ chunk_start += 1.day until chunk_start.beginning_of_week?
558
+ end
559
+ chunk_end = chunk_start.end_of_week
560
+ when :day
561
+ chunk_end = chunk_start
562
+ else
563
+ raise ArgumentError, "invalid chunk size '#{size}'"
564
+ end
565
+ if chunk_end <= last
566
+ result << Period.new(chunk_start, chunk_end)
567
+ elsif round_up_last
568
+ result << Period.new(chunk_start, chunk_end)
569
+ elsif partial_last
570
+ result << Period.new(chunk_start, last)
571
+ else
572
+ break
573
+ end
574
+ chunk_start = result.last.last + 1.day
575
+ end
576
+ result
577
+ end
578
+
579
+ # @group Set operations
580
+
581
+ # Is this period contained wholly within or coincident with `other`?
582
+ #
583
+ # @example
584
+ # Period.parse('2015-2Q').subset_of?(Period.parse('2015')) #=> true
585
+ # Period.parse('2015-2Q').subset_of?(Period.parse('2015-2Q')) #=> true
586
+ # Period.parse('2015-2Q').subset_of?(Period.parse('2015-02')) #=> false
587
+ #
588
+ # @param other [Period] other Period
589
+ # @return [Boolean] self within or coincident with `other`?
590
+ def subset_of?(other)
591
+ to_range.subset_of?(other.to_range)
592
+ end
593
+
594
+ # Is this period contained wholly within but not coincident with `other`?
595
+ #
596
+ # @example
597
+ # Period.parse('2015-2Q').proper_subset_of?(Period.parse('2015')) #=> true
598
+ # Period.parse('2015-2Q').proper_subset_of?(Period.parse('2015-2Q')) #=> false
599
+ # Period.parse('2015-2Q').proper_subset_of?(Period.parse('2015-02')) #=> false
600
+ #
601
+ # @param other [Period] other Period
602
+ # @return [Boolean] self within `other`?
603
+ def proper_subset_of?(other)
604
+ to_range.proper_subset_of?(other.to_range)
605
+ end
606
+
607
+ # Does this period wholly contain or is coincident with `other`?
608
+ #
609
+ # @example
610
+ # Period.parse('2015').superset_of?(Period.parse('2015-2Q')) #=> true
611
+ # Period.parse('2015-2Q').superset_of?(Period.parse('2015-2Q')) #=> true
612
+ # Period.parse('2015-02').superset_of?(Period.parse('2015-2Q')) #=> false
613
+ #
614
+ # @param other [Period] other Period
615
+ # @return [Boolean] self contains or coincident with `other`?
616
+ def superset_of?(other)
617
+ to_range.superset_of?(other.to_range)
618
+ end
619
+
620
+ # Does this period wholly contain but not coincident with `other`?
621
+ #
622
+ # @example
623
+ # Period.parse('2015').proper_superset_of?(Period.parse('2015-2Q')) #=> true
624
+ # Period.parse('2015-2Q').proper_superset_of?(Period.parse('2015-2Q')) #=> false
625
+ # Period.parse('2015-02').proper_superset_of?(Period.parse('2015-2Q')) #=> false
626
+ #
627
+ # @param other [Period] other Period
628
+ # @return [Boolean] self contains `other`?
629
+ def proper_superset_of?(other)
630
+ to_range.proper_superset_of?(other.to_range)
631
+ end
632
+
633
+ # Return the Period that is the intersection of self with `other` or nil if
634
+ # there is no intersection.
635
+ #
636
+ # @example
637
+ # Period.parse('2015-3Q') & Period.parse('2015-2Q') #=> nil
638
+ # Period.parse('2015') & Period.parse('2015-2Q') #=> Period(2015-2Q)
639
+ # pp1 = Period.parse_phrase('from 2015 to 2015-3Q') #=> Period(2015-01-01..2015-09-30)
640
+ # pp2 = Period.parse_phrase('from 2015-2H') #=> Period(2015-07-01..2015-12-31)
641
+ # pp1 & pp2 #=> Period(2015-07-01..2015-09-30)
642
+ #
643
+ # @param other [Period] other Period
644
+ # @return [Period, nil] self intersect `other`?
645
+ def intersection(other)
646
+ result = to_range.intersection(other.to_range)
647
+ if result.nil?
648
+ nil
649
+ else
650
+ Period.new(result.first, result.last)
651
+ end
652
+ end
653
+ alias & intersection
654
+ alias narrow_to intersection
655
+
656
+ # Return the Period that is the union of self with `other` or nil if
657
+ # they neither overlap nor are contiguous
658
+ #
659
+ # @example
660
+ # Period.parse('2015-3Q') + Period.parse('2015-2Q') #=> Period(2015-04-01..2015-09-30)
661
+ # Period.parse('2015') + Period.parse('2015-2Q') #=> Period(2015-01-01..2015-12-31)
662
+ # Period.parse('2015') + Period.parse('2017') #=> nil
663
+ # pp1 = Period.parse_phrase('from 2015-4Q to 2016-1H') #=> Period(2015-10-01-2015..2015-12-31)
664
+ # pp2 = Period.parse_phrase('from 2015-3Q to 2015-11') #=> Period(2015-07-01-2015..2015-11-30)
665
+ # pp1 + pp2 #=> Period(2015-10-01..2015-11-30)
666
+ #
667
+ # @param other [Period] other Period
668
+ # @return [Period, nil] self union `other`?
669
+ def union(other)
670
+ result = to_range.union(other.to_range)
671
+ return nil if result.nil?
672
+ Period.new(result.first, result.last)
673
+ end
674
+ alias + union
675
+
676
+ # Return an array of periods that are this period excluding any overlap with
677
+ # other. If there is no overlap, return an array with a period equal to self
678
+ # as the sole member.
679
+ #
680
+ # @example
681
+ # Period.parse('2015-1Q') - Period.parse('2015-02')
682
+ # #=> [Period(2015-01-01..2015-01-31), Period(2015-03-01..2015-03-31)]
683
+ # Period.parse('2015-2Q') - Period.parse('2015-02')
684
+ # #=> [Period(2015-04-01..2015-06-30)]
685
+ #
686
+ # @param other [Period] the other period to exclude from self
687
+ # @return [Array<Period>] self less the part of other that overlaps
688
+ def difference(other)
689
+ ranges = to_range.difference(other.to_range)
690
+ ranges.each.map { |r| Period.new(r.first, r.last) }
691
+ end
692
+ alias - difference
693
+
694
+ # Return whether this period overlaps the `other` period. To overlap, the
695
+ # periods must have at least one day in common.
696
+ #
697
+ # @example
698
+ # Period.parse('2012').overlaps?(Period.parse('2016')) #=> false
699
+ # Period.parse('2016-32W').overlaps?(Period.parse('2016')) #=> true
700
+ # pp1 = Period.new('2016-03-12', '2016-03-15')
701
+ # pp2 = Period.new('2016-03-16', '2016-03-25')
702
+ # pp1.overlaps?(pp2) #=> false (being contiguous is not overlapping)
703
+ #
704
+ # @param other [Period] the other period to test for overlap
705
+ # @return [Boolean] does self overlap with other?
706
+ def overlaps?(other)
707
+ to_range.overlaps?(other.to_range)
708
+ end
709
+
710
+ # Return whether any of the given periods overlap any other.
711
+ #
712
+ # @example
713
+ # pds = []
714
+ # pds << Period.parse('2015-1H')
715
+ # pds << Period.parse('2016-2H')
716
+ # pds << Period.parse('2015-04')
717
+ # Period.overlaps_among?(pds) #=> true
718
+ #
719
+ # @param periods [Array<Period>] periods to test for overlaps
720
+ # @return [Boolean] true if any one of periods overlaps another
721
+ def self.overlaps_among?(periods)
722
+ Range.overlaps_among?(periods.map(&:to_range))
723
+ end
724
+
725
+ # Return whether any of the given periods overlap any other but only if the
726
+ # overlaps occur within the self; overlaps outside self are ignored.
727
+ #
728
+ # @example
729
+ # pds = []
730
+ # pds << Period.parse('2015-1H')
731
+ # pds << Period.parse('2016-2H')
732
+ # pds << Period.parse('2015-04')
733
+ # yr2015 = Period.parse('2015')
734
+ # yr2016 = Period.parse('2016')
735
+ # yr2015.overlaps_among?(pds) #=> true
736
+ # yr2016.overlaps_among?(pds) #=> false (overlap is in 2015)
737
+ #
738
+ # @param periods [Array<Period>] periods to test for overlaps
739
+ # @return [Boolean] true if any one of periods overlaps another
740
+ def overlaps_among?(periods)
741
+ to_range.overlaps_among?(periods.map(&:to_range))
742
+ end
743
+
744
+ # Return whether the given periods "span" self, that is, do they collectively
745
+ # cover all of self with no overlaps and no gaps?
746
+ #
747
+ # @example
748
+ # ppds = []
749
+ # ppds << Period.parse('2016-1Q')
750
+ # ppds << Period.parse('2016-2Q')
751
+ # ppds << Period.parse('2016-2H')
752
+ # Period.parse('2016').spanned_by?(ppds) #=> true
753
+ #
754
+ # # There's a bit of the year at the beginning that isn't covered by
755
+ # # one of these weeks:
756
+ # ppds = Period.parse('2016').chunks(size: :week)
757
+ # Period.parse('2016').spanned_by?(ppds) #=> false
758
+ #
759
+ # @param periods [Array<Period>] periods to test for spanning self
760
+ # @return [Boolean] do periods span self?
761
+ def spanned_by?(periods)
762
+ to_range.spanned_by?(periods.map(&:to_range))
763
+ end
764
+
765
+ # Return an Array of Periods representing the gaps within self not covered by
766
+ # the Array of Periods `periods`. Overlaps among the periods do not affect
767
+ # the result nor do gaps outside the range of self. Ordering among the
768
+ # `periods` does not matter.
769
+ #
770
+ # @example
771
+ # some_qs = []
772
+ # some_qs << Period.parse('2015-1Q')
773
+ # some_qs << Period.parse('2015-3Q')
774
+ # some_qs << Period.parse('2015-11')
775
+ # some_qs << Period.parse('2015-12')
776
+ # Period.parse('2015').gaps(some_qs) #=>
777
+ # [Period(2015-04-01..2015-06-30), Period(2015-10-01..2015-10-31)]
778
+ #
779
+ # @param periods [Array<Period>] periods to examine for coverage of self
780
+ # @return [Array<Periods>] periods that are not covered by `periods`
781
+ def gaps(periods)
782
+ to_range.gaps(periods.map(&:to_range))
783
+ .map { |r| Period.new(r.first, r.last) }
784
+ end
785
+ end
@@ -0,0 +1,3 @@
1
+ module FatPeriod
2
+ VERSION = '1.0.0'.freeze
3
+ end
data/lib/fat_period.rb ADDED
@@ -0,0 +1,3 @@
1
+ require 'fat_period/version'
2
+ require 'fat_period/date'
3
+ require 'fat_period/period'
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fat_period
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel E. Doherty
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-05-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.14'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.14'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry-doc
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry-byebug
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: fat_core
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '4.0'
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '4.1'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '4.0'
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '4.1'
117
+ description:
118
+ email:
119
+ - ded-law@ddoherty.net
120
+ executables: []
121
+ extensions: []
122
+ extra_rdoc_files: []
123
+ files:
124
+ - ".gitignore"
125
+ - ".rspec"
126
+ - ".travis.yml"
127
+ - Gemfile
128
+ - README.md
129
+ - Rakefile
130
+ - bin/console
131
+ - bin/setup
132
+ - fat_period.gemspec
133
+ - lib/fat_period.rb
134
+ - lib/fat_period/date.rb
135
+ - lib/fat_period/period.rb
136
+ - lib/fat_period/version.rb
137
+ homepage: https://github.com/ddoherty03/fat_period
138
+ licenses: []
139
+ metadata: {}
140
+ post_install_message:
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ required_ruby_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ required_rubygems_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ requirements: []
155
+ rubyforge_project:
156
+ rubygems_version: 2.5.2
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: Implements a Period class as a Range of Dates.
160
+ test_files: []