fat_period 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []