repeatable 0.2.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 85f2e7cec322abe280bc2d383c2eb3286685e9ce
4
- data.tar.gz: b9495fe2fc4044b096d98734ef8521aaff27439e
2
+ SHA256:
3
+ metadata.gz: 9f6976240c976a027e08e04dc46dff2a7a420894f089ae7fa8c9fded126bafc6
4
+ data.tar.gz: 4e0e0e4d45d72b543549904a9a6f4b041430b9808075ccb64a81583366a4e414
5
5
  SHA512:
6
- metadata.gz: eb4e21f11667e4ef609ee9af000a24a83010119cd53fe00c8068272db8fa250b623eeedc77f137dde683edb71d626401bae6cbf0813fba8f43d44e3a0ba93cce
7
- data.tar.gz: f6b85f9183fc1e052d5aaefcc6b57f7e52334cbc6685f91e5c930d3be33631af2229b9baa7e717e645d83f83f9b17eedeb9e75a70b42220b58dec677657590cf
6
+ metadata.gz: 2698038cb582f612642c1b2f9c595e3830ff42556fb89eef56d4bbf5e3c34396d318175699ecb74a2c589aefe861cc5c7e17a6bde0b4950b6939421fedcb1260
7
+ data.tar.gz: 6a98285ed9058b186651055e5afecae2b2abcb64ca0dbc488b92b76ff6a20da02d4ab1d96f204bd864b210a5d183fdff34db31b82d4b06595e0b7c16f2dc3dcf
@@ -0,0 +1,13 @@
1
+ # These commits should be ignored by git-blame, making it much easier to step through the history
2
+ # of a line without hitting formatting commits (esp. large, update-all-the-files sorts of commits).
3
+ #
4
+ # For git-blame to ignore these revisions, you'll need to tell it about this file.
5
+ #
6
+ # You can either pass it to the command each time:
7
+ # git blame --ignore-rev-file=.git-blame-ignore-revs -L 12,14 lib/repeatable/conversions.rb
8
+ #
9
+ # Or you can set it as a config value, which will be used by all git-blame commands henceforth:
10
+ # git config blame.ignoreRevsFile .git-blame-ignore-revs
11
+
12
+ # Initial style fix after introducing Standard
13
+ 80611e9be4fe12e6b9bdb937d37902c12ac3a53b
data/.gitignore CHANGED
@@ -1,7 +1,6 @@
1
1
  /*.gem
2
2
  /.bundle/
3
3
  /.yardoc
4
- /Gemfile.lock
5
4
  /_yardoc/
6
5
  /coverage/
7
6
  /doc/
data/.rspec CHANGED
@@ -1,2 +1 @@
1
- --format documentation
2
1
  --color
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see: https://github.com/testdouble/standard
2
+
3
+ default_ignores: true
data/.travis.yml CHANGED
@@ -1,8 +1,18 @@
1
1
  language: ruby
2
+ env:
3
+ global:
4
+ - COVERAGE=true
2
5
  rvm:
3
- - 2.1.0
4
- - 2.2.0
5
- addons:
6
- code_climate:
7
- repo_token:
8
- secure: fyZ7Ycc23fzfh8bBiqPUhlJGcIcnanZWqVc4nsKDJeE1G5ScW01+ot1g4miDWxo7w80gKRKP/PfoYf6lOQ1B5yJJCV0Z5fjoTW3y+EKksMuGNCFaWh8R73MIPlVtfOazGyI2t3l4zDWoik906wHHNQJFveMZawvZGrVMWF6YxKg=
6
+ - 2.5
7
+ - 2.6
8
+ - 2.7
9
+ - 3.0
10
+ before_install:
11
+ - yes | gem update --system --force
12
+ - gem install bundler
13
+ before_script:
14
+ - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
15
+ - chmod +x ./cc-test-reporter
16
+ - ./cc-test-reporter before-build
17
+ after_script:
18
+ - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
data/CHANGELOG.md ADDED
@@ -0,0 +1,105 @@
1
+ ## CHANGELOG
2
+
3
+ ### Unreleased
4
+
5
+ [Commits](https://github.com/molawson/repeatable/compare/v1.0.0...main)
6
+
7
+ ### 1.0.0 (2021-03-25)
8
+
9
+ Breaking Changes:
10
+
11
+ * Change `Expression::DayInMonth` to take negative numbers instead of special `:last` symbol (or string)
12
+ * Stop including `Conversions` in top level namespace (must now be accessed via `Repeatable::Conversions::Date()` or by calling `include Repeatable::Conversions` in whatever namespace(s) it's needed)
13
+ * Require Ruby 2.5.0 or greater
14
+
15
+ Changes:
16
+
17
+ * Flatten nested `Expression::Union` elements during initialization ([@cmoel][])
18
+ * Flatten nested `Expression::Intersection` elements during initialization
19
+
20
+ Chores:
21
+
22
+ * Add support for Ruby 2.5, 2.6, 2.7, 3.0
23
+ * Introduce [standard](https://github.com/testdouble/standard) for code formatting
24
+
25
+ [Commits](https://github.com/molawson/repeatable/compare/v0.6.0...v1.0.0)
26
+
27
+
28
+ ### 0.6.0 (2017-05-04)
29
+
30
+ Features:
31
+
32
+ * Add `Expression::Difference` for set differences between 2 schedules ([@danott][])
33
+ * Allow `Expression::DayInMonth` to take `:last` (or `'last'`) for its `day:` argument ([@PatrickLerner][])
34
+ * Allow `Expression::WeekdayInMonth` to take negative `count` argument for last, second-to-last, etc. of a given weekday ([@danielma][])
35
+
36
+ Bug Fixes:
37
+
38
+ * Fix `Expression::RangeInYear` to properly handle using `start_day` and `end_day` when `start_month == end_month` ([@danielma][])
39
+
40
+ [Commits](https://github.com/molawson/repeatable/compare/v0.5.0...v0.6.0)
41
+
42
+ ### 0.5.0 (2016-01-27)
43
+
44
+ Features:
45
+
46
+ * Add `Expression::Biweekly` for "every other week" recurrence
47
+
48
+ [Commits](https://github.com/molawson/repeatable/compare/v0.4.0...v0.5.0)
49
+
50
+ ### 0.4.0 (2015-06-29)
51
+
52
+ Features:
53
+
54
+ * Define equivalence `#==` for `Expression` and `Schedule` objects
55
+ * Define hash equality `#eql?` for `Expression::Date` objects
56
+ * Remove `ActiveSupport` dependency
57
+
58
+ [Commits](https://github.com/molawson/repeatable/compare/v0.3.0...v0.4.0)
59
+
60
+ ### 0.3.0 (2015-03-11)
61
+
62
+ Features:
63
+
64
+ * Ensure `end_date` on or after `start_date` for `Schedule#occurrences` ([@danott][])
65
+ * Consider any invalid argument to `Schedule.new` a `ParseError` ([@danott][])
66
+
67
+ [Commits](https://github.com/molawson/repeatable/compare/v0.2.1...v0.3.0)
68
+
69
+ ### 0.2.1 (2015-03-09)
70
+
71
+ Features:
72
+
73
+ * Add `ParseError` class for better error handling
74
+ * Extract `Parser` class from `Schedule`
75
+
76
+ Bug Fixes:
77
+
78
+ * Enable `Schedule` to take a hash with string keys
79
+
80
+ [Commits](https://github.com/molawson/repeatable/compare/v0.2.0...v0.2.1)
81
+
82
+ ### 0.2.0 (2015-03-03)
83
+
84
+ Features:
85
+
86
+ * Add `Schedule#to_h` and `Expression#to_h` methods
87
+ * Enable building a `Schedule` from composed `Expression` objects
88
+
89
+ Bug Fixes:
90
+
91
+ * Fix default case equality for `Expression::Base` to work with classes and instances
92
+
93
+ [Commits](https://github.com/molawson/repeatable/compare/v0.1.0...v0.2.0)
94
+
95
+ ### 0.1.0 (2015-02-23)
96
+
97
+ Initial Release
98
+
99
+ [Commits](https://github.com/molawson/repeatable/compare/531d40c...v0.1.0)
100
+
101
+
102
+ [@danott]: https://github.com/danott
103
+ [@PatrickLerner]: https://github.com/PatrickLerner
104
+ [@danielma]: https://github.com/danielma
105
+ [@cmoel]: https://github.com/cmoel
data/Gemfile CHANGED
@@ -1,7 +1,13 @@
1
- source 'https://rubygems.org'
1
+ source "https://rubygems.org"
2
2
 
3
3
  # Specify your gem's dependencies in repeatable.gemspec
4
4
  gemspec
5
5
 
6
- gem 'pry'
7
- gem 'codeclimate-test-reporter', group: :test, require: nil
6
+ gem "pry", "~> 0.13"
7
+ gem "rake", ">= 12.3.3"
8
+ gem "standard", "~> 1.0"
9
+
10
+ group :test do
11
+ gem "rspec", "~> 3.0"
12
+ gem "simplecov", "~> 0.18"
13
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,76 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ repeatable (1.0.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.2)
10
+ coderay (1.1.3)
11
+ diff-lcs (1.4.4)
12
+ docile (1.3.5)
13
+ method_source (1.0.0)
14
+ parallel (1.20.1)
15
+ parser (3.0.0.0)
16
+ ast (~> 2.4.1)
17
+ pry (0.14.0)
18
+ coderay (~> 1.1)
19
+ method_source (~> 1.0)
20
+ rainbow (3.0.0)
21
+ rake (13.0.3)
22
+ regexp_parser (2.1.1)
23
+ rexml (3.2.4)
24
+ rspec (3.10.0)
25
+ rspec-core (~> 3.10.0)
26
+ rspec-expectations (~> 3.10.0)
27
+ rspec-mocks (~> 3.10.0)
28
+ rspec-core (3.10.1)
29
+ rspec-support (~> 3.10.0)
30
+ rspec-expectations (3.10.1)
31
+ diff-lcs (>= 1.2.0, < 2.0)
32
+ rspec-support (~> 3.10.0)
33
+ rspec-mocks (3.10.2)
34
+ diff-lcs (>= 1.2.0, < 2.0)
35
+ rspec-support (~> 3.10.0)
36
+ rspec-support (3.10.2)
37
+ rubocop (1.11.0)
38
+ parallel (~> 1.10)
39
+ parser (>= 3.0.0.0)
40
+ rainbow (>= 2.2.2, < 4.0)
41
+ regexp_parser (>= 1.8, < 3.0)
42
+ rexml
43
+ rubocop-ast (>= 1.2.0, < 2.0)
44
+ ruby-progressbar (~> 1.7)
45
+ unicode-display_width (>= 1.4.0, < 3.0)
46
+ rubocop-ast (1.4.1)
47
+ parser (>= 2.7.1.5)
48
+ rubocop-performance (1.10.1)
49
+ rubocop (>= 0.90.0, < 2.0)
50
+ rubocop-ast (>= 0.4.0)
51
+ ruby-progressbar (1.11.0)
52
+ simplecov (0.21.2)
53
+ docile (~> 1.1)
54
+ simplecov-html (~> 0.11)
55
+ simplecov_json_formatter (~> 0.1)
56
+ simplecov-html (0.12.3)
57
+ simplecov_json_formatter (0.1.2)
58
+ standard (1.0.4)
59
+ rubocop (= 1.11.0)
60
+ rubocop-performance (= 1.10.1)
61
+ unicode-display_width (2.0.0)
62
+
63
+ PLATFORMS
64
+ x86_64-darwin-19
65
+ x86_64-linux
66
+
67
+ DEPENDENCIES
68
+ pry (~> 0.13)
69
+ rake (>= 12.3.3)
70
+ repeatable!
71
+ rspec (~> 3.0)
72
+ simplecov (~> 0.18)
73
+ standard (~> 1.0)
74
+
75
+ BUNDLED WITH
76
+ 2.2.14
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Repeatable
2
2
 
3
- [![Build Status](https://img.shields.io/travis/molawson/repeatable.svg)](https://travis-ci.org/molawson/repeatable)
4
- [![Code Climate](https://img.shields.io/codeclimate/github/molawson/repeatable.svg)](https://codeclimate.com/github/molawson/repeatable)
5
- [![Code Climate Coverage](https://img.shields.io/codeclimate/coverage/github/molawson/repeatable.svg)](https://codeclimate.com/github/molawson/repeatable)
3
+ [![Build Status](https://travis-ci.org/molawson/repeatable.svg?branch=main)](https://travis-ci.org/molawson/repeatable)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/73707efd5eeffd364c0d/maintainability)](https://codeclimate.com/github/molawson/repeatable/maintainability)
5
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/73707efd5eeffd364c0d/test_coverage)](https://codeclimate.com/github/molawson/repeatable/test_coverage)
6
6
 
7
7
  Ruby implementation of Martin Fowler's [Recurring Events for Calendars](http://martinfowler.com/apsupp/recurring.pdf) paper.
8
8
 
@@ -22,6 +22,10 @@ Or install it yourself as:
22
22
 
23
23
  $ gem install repeatable
24
24
 
25
+ ## Requirements
26
+
27
+ Because this gem relies heavily on required keyword arguments, especially to make dumping and parsing of schedules simpler, this code will only work on **Ruby 2.2** and higher.
28
+
25
29
  ## Usage
26
30
 
27
31
  ### Building a Schedule
@@ -33,9 +37,9 @@ You can create a schedule in one of two ways.
33
37
  Instantiate and compose each of the `Repeatable::Expression` objects manually.
34
38
 
35
39
  ```ruby
36
- second_monday = Repeatabe::Expression::WeekdayInMonth.new(weekday: 1, count: 2)
40
+ second_monday = Repeatable::Expression::WeekdayInMonth.new(weekday: 1, count: 2)
37
41
  oct_thru_dec = Repeatable::Expression::RangeInYear.new(start_month: 10, end_month: 12)
38
- intersection = Repeatable::Expresson::Intersection.new(second_monday, oct_thru_dec)
42
+ intersection = Repeatable::Expression::Intersection.new(second_monday, oct_thru_dec)
39
43
 
40
44
  schedule = Repeatable::Schedule.new(intersection)
41
45
  ```
@@ -49,7 +53,8 @@ Or describe the same structure with a `Hash`, and the gem will handle instantiat
49
53
  arg = {
50
54
  intersection: [
51
55
  { weekday_in_month: { weekday: 1, count: 2 } },
52
- { range_in_year: { start_month: 10, end_month: 12 } }
56
+ { range_in_year: { start_month: 10, end_month: 12 } },
57
+ { exact_date: { date: "2015-08-01" } }
53
58
  ]
54
59
  }
55
60
 
@@ -73,6 +78,10 @@ Repeatable::Expression::Union.new(expressions)
73
78
  { intersection: [] }
74
79
  Repeatable::Expression::Intersection.new(expressions)
75
80
 
81
+ # Date is part of the first set (`included`) but not part of the second set (`excluded`)
82
+ { difference: { included: expression, excluded: another_expression } }
83
+ Repeatable::Expression::Difference.new(included: expression, excluded: another_expression)
84
+
76
85
 
77
86
  # DATES
78
87
 
@@ -84,10 +93,22 @@ Repeatable::Expression::Weekday.new(weekday: 0)
84
93
  { weekday_in_month: { weekday: 1, count: 3 } }
85
94
  Repeatable::Expression::WeekdayInMonth.new(weekday: 1, count: 3)
86
95
 
96
+ # The last Thursday of every month
97
+ { weekday_in_month: { weekday: 4, count: -1 } }
98
+ Repeatable::Expression::WeekdayInMonth.new(weekday: 4, count: -1)
99
+
100
+ # Every other Monday, starting from December 1, 2015
101
+ { biweekly: { weekday: 1, start_date: '2015-12-01' } }
102
+ Repeatable::Expression::Biweekly.new(weekday: 1, start_date: Date.new(2015, 12, 1))
103
+
87
104
  # The 13th of every month
88
105
  { day_in_month: { day: 13 } }
89
106
  Repeatable::Expression::DayInMonth.new(day: 13)
90
107
 
108
+ # The last day of every month
109
+ { day_in_month: { day: -1 } }
110
+ Repeatable::Expression::DayInMonth.new(day: -1)
111
+
91
112
  # All days in October
92
113
  { range_in_year: { start_month: 10 } }
93
114
  Repeatable::Expression::RangeInYear.new(start_month: 10)
@@ -99,16 +120,38 @@ Repeatable::Expression::RangeInYear.new(start_month: 10, end_month: 12)
99
120
  # All days from October 1 through December 20
100
121
  { range_in_year: { start_month: 10, end_month: 12, start_day: 1, end_day: 20 } }
101
122
  Repeatable::Expression::RangeInYear.new(start_month: 10, end_month: 12, start_day: 1, end_day: 20)
123
+
124
+ # only December 21, 2012
125
+ { exact_date: { date: '2012-12-21' } }
126
+ Repeatable::Expression::ExactDate.new(date: Date.new(2012, 12, 21)
102
127
  ```
103
128
 
129
+ #### Schedule Errors
130
+
131
+ If something in the argument passed into `Repeatable::Schedule.new` can't be handled by the `Schedule` or `Parser` (e.g. an expression hash key that doesn't match an existing expression class), a `Repeatable::ParseError` will be raised with a (hopefully) helpful error message.
132
+
104
133
  ### Getting information from a Schedule
105
134
 
106
- Ask a schedule one of three questions.
135
+ Ask a schedule to do a number of things.
107
136
 
108
137
  ```ruby
109
138
  schedule.next_occurrence
110
139
  # => Date of next occurrence
111
140
 
141
+ # By default, it will find the next occurrence after Date.today.
142
+ # You can also specify a start date.
143
+ schedule.next_occurrence(Date.new(2015, 1, 1))
144
+ # => Date of next occurrence after Jan 1, 2015
145
+
146
+ # You also have the option of including the start date as a possible result.
147
+ schedule.next_occurrence(Date.new(2015, 1, 1), include_start: true)
148
+ # => Date of next occurrence on or after Jan 1, 2015
149
+
150
+ # By default, searches for the next occurrence are limited to the next 36,525 days (about 100 years).
151
+ # That limit can also be specified in number of days.
152
+ schedule.next_occurrence(limit: 365)
153
+ # => Date of next occurrence within the next 365 days
154
+
112
155
  schedule.occurrences(Date.new(2015, 1, 1), Date.new(2016, 6, 30))
113
156
  # => Dates of all occurrences between Jan 1, 2015 and June 30, 2016
114
157
 
@@ -117,7 +160,30 @@ schedule.include?(Date.new(2015, 10, 10))
117
160
 
118
161
  schedule.to_h
119
162
  # => Hash representation of the Schedule, which is useful for storage and
120
- # can be used to recreating an identical Schedule object at a later time
163
+ # can be used to recreate an identical Schedule object at a later time
164
+ ```
165
+
166
+ #### Equivalence
167
+
168
+ Both `Repeatable::Schedule` and all `Repeatable::Expression` classes have equivalence `#==` defined according to what's appropriate for each class, so regardless of the order of arguments passed to each, you can tell whether one object is equivalent to the other in terms of whether or not, when asked the same questions, you'd receive the same results from each.
169
+
170
+ ```ruby
171
+ Repeatable::Expression::DayInMonth.new(day: 1) == Repeatable::Expression::DayInMonth.new(day: 1)
172
+ # => true
173
+
174
+ first = Repeatable::Expression::DayInMonth.new(day: 1)
175
+ fifteenth = Repeatable::Expression::DayInMonth.new(day: 15)
176
+ first == fifteenth
177
+ # => false
178
+
179
+ union = Repeatable::Expression::Union.new(first, fifteenth)
180
+ another_union = Repeatable::Expression::Union.new(fifteenth, first)
181
+ union == another_union
182
+ # => true (order of Union and Intersection arguments doesn't their affect output)
183
+
184
+ Repeatable::Schedule.new(union) == Repeatable::Schedule.new(another_union)
185
+ # => true (their expressions are equivalent, so they'll produce the same results)
186
+
121
187
  ```
122
188
 
123
189
  ## Development
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
- require 'bundler/gem_tasks'
2
- require 'rspec/core/rake_task'
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task default: :spec
6
+ require "standard/rake"
7
+
8
+ task default: %i[spec standard]
data/lib/repeatable.rb CHANGED
@@ -1,26 +1,30 @@
1
- require 'date'
1
+ require "date"
2
2
 
3
- require 'repeatable/version'
4
-
5
- require 'repeatable/conversions'
6
- include Repeatable::Conversions
3
+ require "repeatable/version"
7
4
 
8
5
  module Repeatable
9
6
  end
10
7
 
11
- require 'repeatable/parse_error'
8
+ require "repeatable/parse_error"
9
+
10
+ require "repeatable/conversions"
11
+ require "repeatable/last_date_of_month"
12
12
 
13
- require 'repeatable/expression'
14
- require 'repeatable/expression/base'
13
+ require "repeatable/expression"
14
+ require "repeatable/expression/base"
15
15
 
16
- require 'repeatable/expression/weekday'
17
- require 'repeatable/expression/weekday_in_month'
18
- require 'repeatable/expression/day_in_month'
19
- require 'repeatable/expression/range_in_year'
16
+ require "repeatable/expression/date"
17
+ require "repeatable/expression/exact_date"
18
+ require "repeatable/expression/weekday"
19
+ require "repeatable/expression/biweekly"
20
+ require "repeatable/expression/weekday_in_month"
21
+ require "repeatable/expression/day_in_month"
22
+ require "repeatable/expression/range_in_year"
20
23
 
21
- require 'repeatable/expression/set'
22
- require 'repeatable/expression/union'
23
- require 'repeatable/expression/intersection'
24
+ require "repeatable/expression/set"
25
+ require "repeatable/expression/union"
26
+ require "repeatable/expression/intersection"
27
+ require "repeatable/expression/difference"
24
28
 
25
- require 'repeatable/schedule'
26
- require 'repeatable/parser'
29
+ require "repeatable/schedule"
30
+ require "repeatable/parser"
@@ -23,6 +23,31 @@ module Repeatable
23
23
  "Don't use Expression::Base directly. Subclasses must implement `#to_h`"
24
24
  )
25
25
  end
26
+
27
+ def union(other)
28
+ Union.new(self, other)
29
+ end
30
+ alias_method :+, :union
31
+ alias_method :|, :union
32
+
33
+ def intersection(other)
34
+ Intersection.new(self, other)
35
+ end
36
+ alias_method :&, :intersection
37
+
38
+ def difference(other)
39
+ Difference.new(included: self, excluded: other)
40
+ end
41
+ alias_method :-, :difference
42
+
43
+ private
44
+
45
+ def hash_key
46
+ self.class.name.split("::").last
47
+ .gsub(/(?<!\b)[A-Z]/) { "_#{Regexp.last_match[0]}" }
48
+ .downcase
49
+ .to_sym
50
+ end
26
51
  end
27
52
  end
28
53
  end
@@ -0,0 +1,28 @@
1
+ module Repeatable
2
+ module Expression
3
+ class Biweekly < Date
4
+ def initialize(weekday:, start_after: ::Date.today)
5
+ @weekday = weekday
6
+ @start_after = Conversions::Date(start_after)
7
+ end
8
+
9
+ def include?(date)
10
+ date >= start_after && (date - first_occurrence) % 14 == 0
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :weekday, :start_after
16
+
17
+ def first_occurrence
18
+ @first_occurrence ||= find_first_occurrence
19
+ end
20
+
21
+ def find_first_occurrence
22
+ days_away = weekday - start_after.wday
23
+ days_away += 7 if days_away <= 0
24
+ start_after + days_away
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module Repeatable
2
+ module Expression
3
+ class Date < Base
4
+ def to_h
5
+ Hash[hash_key, attributes]
6
+ end
7
+
8
+ def ==(other)
9
+ other.is_a?(self.class) && attributes == other.attributes
10
+ end
11
+
12
+ alias_method :eql?, :==
13
+
14
+ def hash
15
+ [attributes.values, self.class.name].hash
16
+ end
17
+
18
+ protected
19
+
20
+ def attributes
21
+ instance_variables.each_with_object({}) do |name, hash|
22
+ key = name.to_s.gsub(/^@/, "").to_sym
23
+ hash[key] = normalize_attribute_value(instance_variable_get(name))
24
+ end
25
+ end
26
+
27
+ def normalize_attribute_value(value)
28
+ case value
29
+ when ::Date
30
+ value.to_s
31
+ else
32
+ value
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,16 +1,18 @@
1
1
  module Repeatable
2
2
  module Expression
3
- class DayInMonth < Base
3
+ class DayInMonth < Date
4
+ include LastDateOfMonth
5
+
4
6
  def initialize(day:)
5
7
  @day = day
6
8
  end
7
9
 
8
10
  def include?(date)
9
- date.day == day
10
- end
11
-
12
- def to_h
13
- { day_in_month: { day: day } }
11
+ if day < 0
12
+ date - last_date_of_month(date) - 1 == day
13
+ else
14
+ date.day == day
15
+ end
14
16
  end
15
17
 
16
18
  private
@@ -0,0 +1,28 @@
1
+ module Repeatable
2
+ module Expression
3
+ class Difference < Base
4
+ def initialize(included:, excluded:)
5
+ @included = included
6
+ @excluded = excluded
7
+ end
8
+
9
+ def include?(date)
10
+ included.include?(date) && !excluded.include?(date)
11
+ end
12
+
13
+ def to_h
14
+ Hash[hash_key, {included: included.to_h, excluded: excluded.to_h}]
15
+ end
16
+
17
+ def ==(other)
18
+ other.is_a?(self.class) &&
19
+ included == other.included &&
20
+ excluded == other.excluded
21
+ end
22
+
23
+ protected
24
+
25
+ attr_reader :included, :excluded
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ module Repeatable
2
+ module Expression
3
+ class ExactDate < Date
4
+ def initialize(date:)
5
+ @date = Conversions::Date(date)
6
+ end
7
+
8
+ def include?(other_date)
9
+ date == other_date
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :date
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,11 @@
1
1
  module Repeatable
2
2
  module Expression
3
3
  class Intersection < Set
4
+ def initialize(*elements)
5
+ other_intersection, not_intersection = elements.partition { |e| e.is_a?(self.class) }
6
+ super(other_intersection.flat_map(&:elements) + not_intersection)
7
+ end
8
+
4
9
  def include?(date)
5
10
  elements.all? { |e| e.include?(date) }
6
11
  end
@@ -1,6 +1,6 @@
1
1
  module Repeatable
2
2
  module Expression
3
- class RangeInYear < Base
3
+ class RangeInYear < Date
4
4
  def initialize(start_month:, end_month: start_month, start_day: 0, end_day: 0)
5
5
  @start_month = start_month
6
6
  @end_month = end_month
@@ -9,15 +9,21 @@ module Repeatable
9
9
  end
10
10
 
11
11
  def include?(date)
12
- months_include?(date) || start_month_include?(date) || end_month_include?(date)
12
+ return true if months_include?(date)
13
+
14
+ if start_month == end_month
15
+ start_month_include?(date) && end_month_include?(date)
16
+ else
17
+ start_month_include?(date) || end_month_include?(date)
18
+ end
13
19
  end
14
20
 
15
21
  def to_h
16
- args = { start_month: start_month }
22
+ args = {start_month: start_month}
17
23
  args[:end_month] = end_month unless end_month == start_month
18
24
  args[:start_day] = start_day unless start_day.zero?
19
25
  args[:end_day] = end_day unless end_day.zero?
20
- { range_in_year: args }
26
+ {range_in_year: args}
21
27
  end
22
28
 
23
29
  private
@@ -1,24 +1,26 @@
1
1
  module Repeatable
2
2
  module Expression
3
3
  class Set < Base
4
+ attr_reader :elements
5
+
4
6
  def initialize(*elements)
5
- @elements = elements.flatten
7
+ @elements = elements.flatten.uniq
6
8
  end
7
9
 
8
10
  def <<(element)
9
- elements << element
11
+ elements << element unless elements.include?(element)
10
12
  self
11
13
  end
12
14
 
13
15
  def to_h
14
- hash = {}
15
- hash[self.class.name.demodulize.underscore.to_sym] = elements.map(&:to_h)
16
- hash
16
+ Hash[hash_key, elements.map(&:to_h)]
17
17
  end
18
18
 
19
- private
20
-
21
- attr_reader :elements
19
+ def ==(other)
20
+ other.is_a?(self.class) &&
21
+ elements.size == other.elements.size &&
22
+ other.elements.all? { |e| elements.include?(e) }
23
+ end
22
24
  end
23
25
  end
24
26
  end
@@ -1,6 +1,11 @@
1
1
  module Repeatable
2
2
  module Expression
3
3
  class Union < Set
4
+ def initialize(*elements)
5
+ other_unions, not_unions = elements.partition { |e| e.is_a?(self.class) }
6
+ super(other_unions.flat_map(&:elements) + not_unions)
7
+ end
8
+
4
9
  def include?(date)
5
10
  elements.any? { |e| e.include?(date) }
6
11
  end
@@ -1,6 +1,6 @@
1
1
  module Repeatable
2
2
  module Expression
3
- class Weekday < Base
3
+ class Weekday < Date
4
4
  def initialize(weekday:)
5
5
  @weekday = weekday
6
6
  end
@@ -9,10 +9,6 @@ module Repeatable
9
9
  date.wday == weekday
10
10
  end
11
11
 
12
- def to_h
13
- { weekday: { weekday: weekday } }
14
- end
15
-
16
12
  private
17
13
 
18
14
  attr_reader :weekday
@@ -1,6 +1,8 @@
1
1
  module Repeatable
2
2
  module Expression
3
- class WeekdayInMonth < Base
3
+ class WeekdayInMonth < Date
4
+ include LastDateOfMonth
5
+
4
6
  def initialize(weekday:, count:)
5
7
  @weekday = weekday
6
8
  @count = count
@@ -10,10 +12,6 @@ module Repeatable
10
12
  day_matches?(date) && week_matches?(date)
11
13
  end
12
14
 
13
- def to_h
14
- { weekday_in_month: { weekday: weekday, count: count } }
15
- end
16
-
17
15
  private
18
16
 
19
17
  attr_reader :weekday, :count
@@ -23,11 +21,27 @@ module Repeatable
23
21
  end
24
22
 
25
23
  def week_matches?(date)
26
- week_in_month(date.day) == count
24
+ if negative_count?
25
+ week_from_end(date) == count
26
+ else
27
+ week_from_beginning(date) == count
28
+ end
29
+ end
30
+
31
+ def week_from_beginning(date)
32
+ week_in_month(date.day - 1)
33
+ end
34
+
35
+ def week_from_end(date)
36
+ -week_in_month(last_date_of_month(date).day - date.day)
37
+ end
38
+
39
+ def week_in_month(zero_indexed_day)
40
+ (zero_indexed_day / 7) + 1
27
41
  end
28
42
 
29
- def week_in_month(day)
30
- ((day - 1) / 7) + 1
43
+ def negative_count?
44
+ count < 0
31
45
  end
32
46
  end
33
47
  end
@@ -0,0 +1,7 @@
1
+ module Repeatable
2
+ module LastDateOfMonth
3
+ def last_date_of_month(date)
4
+ ::Date.new(date.next_month.year, date.next_month.month, 1).prev_day
5
+ end
6
+ end
7
+ end
@@ -25,20 +25,35 @@ module Repeatable
25
25
  end
26
26
 
27
27
  def expression_for(key, value)
28
- klass = "repeatable/expression/#{key}".classify.safe_constantize
28
+ klass = expression_klass(key.to_s)
29
29
  case klass
30
30
  when nil
31
31
  fail(ParseError, "Unknown mapping: Can't map key '#{key.inspect}' to an expression class")
32
32
  when Repeatable::Expression::Set
33
33
  args = value.map { |hash| build_expression(hash) }
34
34
  klass.new(*args)
35
+ when Repeatable::Expression::Difference
36
+ value = symbolize_keys(value)
37
+ klass.new(
38
+ included: build_expression(value[:included]),
39
+ excluded: build_expression(value[:excluded])
40
+ )
35
41
  else
36
- klass.new(symbolize_keys(value))
42
+ klass.new(**symbolize_keys(value))
37
43
  end
38
44
  end
39
45
 
40
46
  def symbolize_keys(hash)
41
47
  hash.each_with_object({}) { |(k, v), a| a[k.to_sym] = v }
42
48
  end
49
+
50
+ def expression_klass(string)
51
+ camel_cased_string = string
52
+ .capitalize
53
+ .gsub(/(?:_)(?<word>[a-z\d]+)/i) { Regexp.last_match[:word].capitalize }
54
+ Repeatable::Expression.const_get(camel_cased_string)
55
+ rescue NameError => e
56
+ raise if e.name && e.name.to_s != camel_cased_string
57
+ end
43
58
  end
44
59
  end
@@ -1,5 +1,3 @@
1
- require 'active_support/core_ext/string/inflections'
2
-
3
1
  module Repeatable
4
2
  class Schedule
5
3
  def initialize(arg)
@@ -9,26 +7,34 @@ module Repeatable
9
7
  when Hash
10
8
  @expression = Parser.call(arg)
11
9
  else
12
- fail(ArgumentError, "Can't build a Repeatable::Schedule from #{arg.class}")
10
+ fail(ParseError, "Can't build a Repeatable::Schedule from #{arg.class}")
13
11
  end
14
12
  end
15
13
 
16
14
  def occurrences(start_date, end_date)
17
- start_date = Date(start_date)
18
- end_date = Date(end_date)
15
+ start_date = Conversions::Date(start_date)
16
+ end_date = Conversions::Date(end_date)
17
+
18
+ fail(ArgumentError, "end_date must be equal to or after start_date") if end_date < start_date
19
+
19
20
  (start_date..end_date).select { |date| include?(date) }
20
21
  end
21
22
 
22
- def next_occurrence(start_date = Date.today)
23
- date = Date(start_date)
24
- until include?(date)
23
+ def next_occurrence(start_date = Date.today, include_start: false, limit: 36525)
24
+ date = Conversions::Date(start_date)
25
+
26
+ return date if include_start && include?(date)
27
+
28
+ 1.step do |i|
25
29
  date = date.next_day
30
+
31
+ break date if include?(date)
32
+ break if i == limit.to_i
26
33
  end
27
- date
28
34
  end
29
35
 
30
36
  def include?(date = Date.today)
31
- date = Date(date)
37
+ date = Conversions::Date(date)
32
38
  expression.include?(date)
33
39
  end
34
40
 
@@ -36,7 +42,11 @@ module Repeatable
36
42
  expression.to_h
37
43
  end
38
44
 
39
- private
45
+ def ==(other)
46
+ other.is_a?(self.class) && expression == other.expression
47
+ end
48
+
49
+ protected
40
50
 
41
51
  attr_reader :expression
42
52
  end
@@ -1,3 +1,3 @@
1
1
  module Repeatable
2
- VERSION = '0.2.1'
2
+ VERSION = "1.0.0"
3
3
  end
data/repeatable.gemspec CHANGED
@@ -1,24 +1,19 @@
1
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path("../lib", __FILE__)
2
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require 'repeatable/version'
3
+ require "repeatable/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = 'repeatable'
7
- spec.version = Repeatable::VERSION
8
- spec.authors = ['Mo Lawson']
9
- spec.email = ['mo@molawson.com']
6
+ spec.name = "repeatable"
7
+ spec.version = Repeatable::VERSION
8
+ spec.authors = ["Mo Lawson"]
9
+ spec.email = ["mo@molawson.com"]
10
10
 
11
- spec.summary = 'Describe recurring event schedules and calculate their occurrences'
12
- spec.description = "Ruby implementation of Martin Fowler's 'Recurring Events for Calendars' paper."
13
- spec.homepage = 'https://github.com/molawson/repeatable'
14
- spec.license = 'MIT'
11
+ spec.summary = "Describe recurring event schedules and calculate their occurrences"
12
+ spec.description = "Ruby implementation of Martin Fowler's 'Recurring Events for Calendars' paper."
13
+ spec.homepage = "https://github.com/molawson/repeatable"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
15
16
 
16
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
- spec.require_paths = ['lib']
18
-
19
- spec.add_dependency 'activesupport', '>= 3.0'
20
-
21
- spec.add_development_dependency 'bundler', '~> 1.6'
22
- spec.add_development_dependency 'rake', '~> 10.0'
23
- spec.add_development_dependency 'rspec', '~> 3.0'
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.require_paths = ["lib"]
24
19
  end
metadata CHANGED
@@ -1,71 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: repeatable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mo Lawson
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-03-09 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: activesupport
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '3.0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '3.0'
27
- - !ruby/object:Gem::Dependency
28
- name: bundler
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '1.6'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '1.6'
41
- - !ruby/object:Gem::Dependency
42
- name: rake
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '10.0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '10.0'
55
- - !ruby/object:Gem::Dependency
56
- name: rspec
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - "~>"
60
- - !ruby/object:Gem::Version
61
- version: '3.0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - "~>"
67
- - !ruby/object:Gem::Version
68
- version: '3.0'
11
+ date: 2021-03-25 00:00:00.000000000 Z
12
+ dependencies: []
69
13
  description: Ruby implementation of Martin Fowler's 'Recurring Events for Calendars'
70
14
  paper.
71
15
  email:
@@ -74,10 +18,14 @@ executables: []
74
18
  extensions: []
75
19
  extra_rdoc_files: []
76
20
  files:
21
+ - ".git-blame-ignore-revs"
77
22
  - ".gitignore"
78
23
  - ".rspec"
24
+ - ".standard.yml"
79
25
  - ".travis.yml"
26
+ - CHANGELOG.md
80
27
  - Gemfile
28
+ - Gemfile.lock
81
29
  - LICENSE.txt
82
30
  - README.md
83
31
  - Rakefile
@@ -87,13 +35,18 @@ files:
87
35
  - lib/repeatable/conversions.rb
88
36
  - lib/repeatable/expression.rb
89
37
  - lib/repeatable/expression/base.rb
38
+ - lib/repeatable/expression/biweekly.rb
39
+ - lib/repeatable/expression/date.rb
90
40
  - lib/repeatable/expression/day_in_month.rb
41
+ - lib/repeatable/expression/difference.rb
42
+ - lib/repeatable/expression/exact_date.rb
91
43
  - lib/repeatable/expression/intersection.rb
92
44
  - lib/repeatable/expression/range_in_year.rb
93
45
  - lib/repeatable/expression/set.rb
94
46
  - lib/repeatable/expression/union.rb
95
47
  - lib/repeatable/expression/weekday.rb
96
48
  - lib/repeatable/expression/weekday_in_month.rb
49
+ - lib/repeatable/last_date_of_month.rb
97
50
  - lib/repeatable/parse_error.rb
98
51
  - lib/repeatable/parser.rb
99
52
  - lib/repeatable/schedule.rb
@@ -103,7 +56,7 @@ homepage: https://github.com/molawson/repeatable
103
56
  licenses:
104
57
  - MIT
105
58
  metadata: {}
106
- post_install_message:
59
+ post_install_message:
107
60
  rdoc_options: []
108
61
  require_paths:
109
62
  - lib
@@ -111,16 +64,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
111
64
  requirements:
112
65
  - - ">="
113
66
  - !ruby/object:Gem::Version
114
- version: '0'
67
+ version: 2.5.0
115
68
  required_rubygems_version: !ruby/object:Gem::Requirement
116
69
  requirements:
117
70
  - - ">="
118
71
  - !ruby/object:Gem::Version
119
72
  version: '0'
120
73
  requirements: []
121
- rubyforge_project:
122
- rubygems_version: 2.4.5
123
- signing_key:
74
+ rubygems_version: 3.1.4
75
+ signing_key:
124
76
  specification_version: 4
125
77
  summary: Describe recurring event schedules and calculate their occurrences
126
78
  test_files: []