recurable 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
+ SHA256:
3
+ metadata.gz: 4934fe3e2307e356b7cd8a4ec8be35c0ca8369d38dbf0c2fa883403f062c1b95
4
+ data.tar.gz: 51dfeca41d7311251e0b8e691a5237ccb241a5e3197da03d523f12d0e821156b
5
+ SHA512:
6
+ metadata.gz: d1cff6b649fdbc7fba5dc7e644dc0ac0c6a7be9daa43d29b47d23a8eac04a4e99edbdd0b40c4f138d07e125ffe34e9d331fbe7464d9c4ceaf2d614284709af0d
7
+ data.tar.gz: 2614f0cdb426943ad623c7d797f304dae23566c41af691a014dcd7af9820b7b7669f6781c98dd4a09072690ce9c058344d535de1c417ed80c5ded46e98fbb08c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Rosenberg, Matt Lewis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,311 @@
1
+ # Recurable
2
+
3
+ iCal RRULE recurrence library for Ruby with optional Rails/ActiveRecord integration. Full [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10) RRULE support.
4
+
5
+ ## Quick Start: Standalone
6
+
7
+ No Rails required. Just ActiveModel and ActiveSupport.
8
+
9
+ ```ruby
10
+ require 'recurable/recurrence'
11
+
12
+ # Build a recurrence from attributes
13
+ recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
14
+ recurrence.to_rrule # => "FREQ=DAILY;INTERVAL=1"
15
+
16
+ # Parse an existing RRULE string
17
+ recurrence = Recurrence.from_rrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
18
+ recurrence.frequency # => "WEEKLY"
19
+ recurrence.interval # => 2
20
+ recurrence.by_day # => ["MO", "WE", "FR"]
21
+
22
+ # Frequency predicates
23
+ recurrence.weekly? # => true
24
+ recurrence.daily? # => false
25
+
26
+ # Frequency comparison (YEARLY < MONTHLY < ... < MINUTELY)
27
+ yearly = Recurrence.new(frequency: 'YEARLY')
28
+ monthly = Recurrence.new(frequency: 'MONTHLY')
29
+ yearly < monthly # => true
30
+ ```
31
+
32
+ ## Quick Start: With Rails
33
+
34
+ Add the gem, then prepend the `Recurable` concern on any model with an `rrule` string column:
35
+
36
+ ```ruby
37
+ class Plan < ApplicationRecord
38
+ include Recurable
39
+ end
40
+
41
+ plan = Plan.new
42
+ plan.frequency = 'MONTHLY'
43
+ plan.interval = 3
44
+ plan.by_month_day = [15]
45
+ plan.rrule # => #<Recurrence> with to_rrule "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15"
46
+ plan.valid? # validates both the model and the recurrence
47
+ plan.monthly? # => true
48
+ plan.humanize_recurrence # => "every 3 months on the 15th"
49
+
50
+ # Time projection
51
+ plan.recurrence_times(
52
+ project_from: Time.zone.local(2026, 1, 1),
53
+ project_to: Time.zone.local(2026, 7, 1)
54
+ )
55
+
56
+ # Boundary queries
57
+ plan.last_recurrence_time_before(Time.zone.now, dt_start_at: plan.created_at)
58
+ plan.next_recurrence_time_after(Time.zone.now, dt_start_at: plan.created_at)
59
+ ```
60
+
61
+ ## Installation
62
+
63
+ **Standalone** (no Rails):
64
+
65
+ ```ruby
66
+ gem 'recurable'
67
+
68
+ # Then in your code:
69
+ require 'recurable/recurrence'
70
+ ```
71
+
72
+ **With Rails**:
73
+
74
+ ```ruby
75
+ gem 'recurable'
76
+
77
+ # Then in your code:
78
+ require 'recurable'
79
+ ```
80
+
81
+ Requires ActiveRecord >= 7.1 for `serialize` with `default:` keyword support.
82
+
83
+ ## Recurrence Attributes
84
+
85
+ `Recurrence` is a pure Ruby data class with named attributes mapping to [RFC 5545 RRULE components](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10):
86
+
87
+ | Attribute | Type | RRULE Component | Example | Description |
88
+ |-----------|------|-----------------|---------|-------------|
89
+ | `frequency` | String | `FREQ` | `"MONTHLY"` | Every month |
90
+ | `interval` | Integer | `INTERVAL` | `3` | Every 3rd frequency period |
91
+ | `by_day` | Array\<String\> | `BYDAY` | `["+2MO"]` | The second Monday |
92
+ | `by_day` | | | `["MO", "WE", "FR"]` | Monday, Wednesday, and Friday |
93
+ | `by_month_day` | Array\<Integer\> | `BYMONTHDAY` | `[1, 15]` | The 1st and 15th of the month |
94
+ | `by_set_pos` | Array\<Integer\> | `BYSETPOS` | `[-1]` | The last occurrence in the set |
95
+ | `count` | Integer | `COUNT` | `10` | Stop after 10 occurrences |
96
+ | `repeat_until` | Time (UTC) | `UNTIL` | `Time.utc(2026, 12, 31)` | Stop after December 31, 2026 |
97
+ | `hour_of_day` | Array\<Integer\> | `BYHOUR` | `[9, 17]` | At 9 AM and 5 PM |
98
+ | `minute_of_hour` | Array\<Integer\> | `BYMINUTE` | `[0, 30]` | At :00 and :30 past the hour |
99
+ | `second_of_minute` | Array\<Integer\> | `BYSECOND` | `[0, 30]` | At :00 and :30 past the minute |
100
+ | `month_of_year` | Array\<Integer\> | `BYMONTH` | `[1, 6]` | In January and June |
101
+ | `day_of_year` | Array\<Integer\> | `BYYEARDAY` | `[1, -1]` | First and last day of the year |
102
+ | `week_of_year` | Array\<Integer\> | `BYWEEKNO` | `[1, 52]` | Weeks 1 and 52 |
103
+ | `week_start` | String | `WKST` | `"MO"` | Weeks start on Monday |
104
+
105
+ All array attributes accept scalars (auto-wrapped) or arrays. `nil` and `[]` are normalized to `nil`.
106
+
107
+ ```ruby
108
+ recurrence = Recurrence.new
109
+ recurrence.by_day = 'MO' # => stored as ["MO"]
110
+ recurrence.by_day = %w[MO FR] # => stored as ["MO", "FR"]
111
+ recurrence.by_day = [] # => stored as nil
112
+ ```
113
+
114
+ ### Monthly Recurrence Options
115
+
116
+ Monthly recurrences support two modes, determined by which attributes are set:
117
+
118
+ ```ruby
119
+ # By date: "the 15th of every month"
120
+ Recurrence.new(frequency: 'MONTHLY', interval: 1, by_month_day: [15])
121
+ .monthly_option # => "DATE"
122
+
123
+ # By nth weekday: "the last Friday of every month"
124
+ Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
125
+ .monthly_option # => "NTH_DAY"
126
+ ```
127
+
128
+ ### RRULE Generation & Parsing
129
+
130
+ ```ruby
131
+ # Generate: attributes → RRULE string
132
+ recurrence = Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
133
+ recurrence.to_rrule # => "FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1"
134
+
135
+ # Parse: RRULE string → Recurrence
136
+ parsed = Recurrence.from_rrule('FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1')
137
+ parsed.by_day # => ["FR"]
138
+ parsed.by_set_pos # => [-1]
139
+
140
+ # Round-trip
141
+ parsed.to_rrule == recurrence.to_rrule # => true
142
+ ```
143
+
144
+ ### COUNT and UNTIL
145
+
146
+ `COUNT` and `UNTIL` are mutually exclusive per RFC 5545. The `Recurable` concern validates this:
147
+
148
+ ```ruby
149
+ # Limit by count
150
+ Recurrence.new(frequency: 'DAILY', interval: 1, count: 10)
151
+ .to_rrule # => "FREQ=DAILY;INTERVAL=1;COUNT=10"
152
+
153
+ # Limit by end date (stored as UTC)
154
+ Recurrence.new(frequency: 'DAILY', interval: 1, repeat_until: Time.utc(2026, 12, 31, 23, 59, 59))
155
+ .to_rrule # => "FREQ=DAILY;INTERVAL=1;UNTIL=20261231T235959Z"
156
+ ```
157
+
158
+ ## Time Projection
159
+
160
+ `RruleUtils` is an includable module for DST-aware time projection. Any object with a `recurrence` method returning a `Recurrence` can include it. The `Recurable` concern includes it automatically.
161
+
162
+ ```ruby
163
+ # Project occurrences in a date range
164
+ model.recurrence_times(
165
+ project_from: Time.zone.local(2026, 1, 1),
166
+ project_to: Time.zone.local(2026, 2, 1),
167
+ dt_start_at: model.created_at # optional; defaults to project_from
168
+ )
169
+
170
+ # Find the last occurrence before a boundary
171
+ model.last_recurrence_time_before(Time.zone.now, dt_start_at: model.created_at)
172
+
173
+ # Find the next occurrence after a boundary
174
+ model.next_recurrence_time_after(Time.zone.now, dt_start_at: model.created_at)
175
+
176
+ # Human-readable description
177
+ model.humanize_recurrence # => "every 3 months on the 15th"
178
+ ```
179
+
180
+ Time projection delegates to the [rrule](https://github.com/square/ruby-rrule) gem with timezone-aware DST handling.
181
+
182
+ ### DST Boundary Behavior
183
+
184
+ Daily and sub-daily recurrences behave differently across DST transitions.
185
+
186
+ **Spring forward** — On March 12, 2023 in `America/New_York`, clocks jump from 2:00 AM EST to 3:00 AM EDT.
187
+
188
+ A daily recurrence at 1:00 PM is unaffected — the wall-clock time stays consistent across the boundary:
189
+
190
+ ```
191
+ Mar 11 1:00 PM EST
192
+ Mar 12 1:00 PM EDT ← DST transition happened earlier this day, but 1 PM still fires
193
+ Mar 13 1:00 PM EDT
194
+ ```
195
+
196
+ An hourly recurrence skips the non-existent 2:00 AM hour, producing 23 unique hours:
197
+
198
+ ```
199
+ 12:00 AM EST
200
+ 1:00 AM EST
201
+ 3:00 AM EDT ← 2:00 AM doesn't exist, jumps straight to 3:00 AM
202
+ 4:00 AM EDT
203
+ ...
204
+ 11:00 PM EDT
205
+ ```
206
+
207
+ **Fall back** — On November 5, 2023, clocks fall back from 2:00 AM EDT to 1:00 AM EST. The 1:00 AM hour occurs twice, but the duplicate is removed:
208
+
209
+ ```
210
+ 12:00 AM EDT
211
+ 1:00 AM EDT
212
+ 1:00 AM EST ← duplicate wall-clock hour, removed by .uniq
213
+ 2:00 AM EST
214
+ 3:00 AM EST
215
+ ...
216
+ 11:00 PM EST
217
+ ```
218
+
219
+ This produces 24 unique wall-clock hours despite the repeated 1:00 AM.
220
+
221
+ Try it yourself:
222
+
223
+ ```ruby
224
+ Time.use_zone('America/New_York') do
225
+ spring_forward = Time.zone.local(2023, 3, 12)
226
+
227
+ # Daily at 1 PM: fires every day regardless of DST
228
+ daily = Recurrence.new(frequency: 'DAILY', interval: 1)
229
+ model = Struct.new(:recurrence).new(daily).extend(RruleUtils)
230
+ model.recurrence_times(
231
+ project_from: Time.zone.local(2023, 3, 11, 13),
232
+ project_to: Time.zone.local(2023, 3, 13, 13),
233
+ dt_start_at: Time.zone.local(2023, 3, 11, 13)
234
+ ).map { |t| t.strftime('%b %d %l:%M %p %Z') }
235
+ # => ["Mar 11 1:00 PM EST", "Mar 12 1:00 PM EDT", "Mar 13 1:00 PM EDT"]
236
+
237
+ # Hourly on spring-forward day: 23 unique hours, 2 AM is skipped
238
+ hourly = Recurrence.new(frequency: 'HOURLY', interval: 1)
239
+ model = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
240
+ hours = model.recurrence_times(
241
+ project_from: spring_forward.beginning_of_day,
242
+ project_to: spring_forward.end_of_day,
243
+ dt_start_at: spring_forward
244
+ )
245
+ hours.size # => 23
246
+
247
+ # Hourly on fall-back day: 24 unique hours, duplicate 1 AM removed
248
+ fall_back = Time.zone.local(2023, 11, 5)
249
+ model = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
250
+ hours = model.recurrence_times(
251
+ project_from: fall_back.beginning_of_day,
252
+ project_to: fall_back.end_of_day,
253
+ dt_start_at: Time.zone.local(2023, 11, 1)
254
+ )
255
+ hours.size # => 24
256
+ end
257
+ ```
258
+
259
+ ## The Recurable Concern
260
+
261
+ `Recurable` is an `ActiveSupport::Concern` included by ActiveRecord models with an `rrule` string column:
262
+
263
+ ```ruby
264
+ class Plan < ApplicationRecord
265
+ include Recurable
266
+ end
267
+ ```
268
+
269
+ It provides:
270
+
271
+ 1. **Serialization** — `serialize :rrule, RecurrenceSerializer` transparently converts between DB strings and `Recurrence` objects
272
+ 2. **Delegation** — all `Recurrence` attributes and frequency predicates are delegated to the `rrule` object
273
+ 3. **Validation** — recurrence attributes are validated with appropriate constraints (frequency inclusion, interval positivity, array value ranges, BYDAY pattern matching, COUNT/UNTIL mutual exclusivity)
274
+ 4. **Time projection** — includes `RruleUtils` for `recurrence_times`, `last_recurrence_time_before`, `next_recurrence_time_after`, and `humanize_recurrence`
275
+
276
+ ## Supported Frequencies
277
+
278
+ | Frequency | Period | Example |
279
+ |-----------|--------|---------|
280
+ | `YEARLY` | ~365 days | `FREQ=YEARLY;INTERVAL=1;BYMONTH=1,6` |
281
+ | `MONTHLY` | ~31 days | `FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15` |
282
+ | `WEEKLY` | 7 days | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR` |
283
+ | `DAILY` | 1 day | `FREQ=DAILY;INTERVAL=1` |
284
+ | `HOURLY` | 1 hour | `FREQ=HOURLY;INTERVAL=4;BYMINUTE=0,30` |
285
+ | `MINUTELY` | 1 minute | `FREQ=MINUTELY;INTERVAL=15` |
286
+
287
+ ## Constants
288
+
289
+ `Recurrence` exposes named constants for use in validations and logic:
290
+
291
+ ```ruby
292
+ Recurrence::DAILY # => "DAILY"
293
+ Recurrence::MONDAY # => "MO"
294
+ Recurrence::SUNDAY # => "SU"
295
+ Recurrence::MONTHLY_DATE # => "DATE"
296
+ Recurrence::MONTHLY_NTH_DAY # => "NTH_DAY"
297
+ Recurrence::FREQUENCIES # => {"YEARLY"=>365, "MONTHLY"=>31, ...}
298
+ Recurrence::DAYS_OF_WEEK # => ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
299
+ Recurrence::NTH_DAY_OF_MONTH # => {first: 1, second: 2, ..., last: -1}
300
+ ```
301
+
302
+ ## Requirements
303
+
304
+ - Ruby >= 3.3
305
+ - ActiveModel >= 7.1
306
+ - ActiveSupport >= 7.1
307
+ - ActiveRecord >= 7.1 _(only if using the Recurable concern)_
308
+
309
+ ## License
310
+
311
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ class ArrayInclusionValidator < ActiveModel::EachValidator
6
+ def validate_each(record, attribute, value)
7
+ allowed = options[:in]
8
+ Array(value).each do |element|
9
+ valid = allowed.is_a?(Regexp) ? allowed.match?(element) : allowed.include?(element)
10
+ record.errors.add(attribute, "contains invalid value: #{element}") unless valid
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'version'
5
+
6
+ # Core model representing an iCal RRULE recurrence pattern.
7
+ #
8
+ # Pure Ruby data class — no Rails dependencies. Handles RRULE string
9
+ # generation/parsing with named attributes and frequency comparison.
10
+ #
11
+ # recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
12
+ # recurrence.rrule # => "FREQ=DAILY;INTERVAL=1"
13
+ # recurrence.daily? # => true
14
+ #
15
+ class Recurrence
16
+ # Provides `<`, `<=`, `>`, `>=`, `==`, `between?`, and `clamp` by defining `<=>`.
17
+ include Comparable
18
+
19
+ def initialize(**attrs)
20
+ unknown = attrs.keys - ATTRIBUTES
21
+ raise ArgumentError, "Unknown attribute(s): #{unknown.join(', ')}" if unknown.any?
22
+
23
+ attrs.each { |attr, value| public_send(:"#{attr}=", value) }
24
+ end
25
+
26
+ # iCal BYDAY codes derived from Date::DAYNAMES, ordered Sunday–Saturday.
27
+ # Exposes class constants: Recurrence::SUNDAY => 'SU', Recurrence::MONDAY => 'MO', etc.
28
+ # `const_set` returns the value of the constant and map returns an array of the transformed values.
29
+ DAYS_OF_WEEK = Date::DAYNAMES.map { |name| const_set(name.upcase, name[0, 2].upcase) }.freeze
30
+ # Ordered by increasing frequency. Values are approximate period in days.
31
+ # Order used by Comparable#<=> for RruleAdapter strategy selection.
32
+ # Exposes class constants: Recurrence::YEARLY, Recurrence::DAILY, etc. and defines frequency predicates.
33
+ FREQUENCIES = {
34
+ 'YEARLY' => 365,
35
+ 'MONTHLY' => 31,
36
+ 'WEEKLY' => 7,
37
+ 'DAILY' => 1,
38
+ 'HOURLY' => 1 / 24.0,
39
+ 'MINUTELY' => 1 / 24.0 / 60.0
40
+ }.each_key do |freq|
41
+ const_set(freq, freq)
42
+ define_method(:"#{freq.downcase}?") { freq == frequency }
43
+ end.freeze
44
+ FREQ_ORDER = FREQUENCIES.keys.each_with_index.to_h.freeze
45
+
46
+ # Exposes class constants: Recurrence::MONTHLY_DATE => 'DATE', Recurrence::MONTHLY_NTH_DAY => 'NTH_DAY'.
47
+ MONTHLY_OPTIONS = %w[DATE NTH_DAY].each { |opt| const_set("MONTHLY_#{opt}", opt) }.freeze
48
+ # Maps symbolic positions to iCal BYSETPOS integers. Positive 1–4 covers typical forward
49
+ # positions; negative -1/-2 covers "last" and "second to last" (deeper negatives are better
50
+ # expressed counting forward). A month has at most 5 of any single weekday.
51
+ NTH_DAY_OF_MONTH = {
52
+ first: 1,
53
+ second: 2,
54
+ third: 3,
55
+ fourth: 4,
56
+ last: -1,
57
+ second_to_last: -2
58
+ }.freeze
59
+
60
+ # Positive = calendar date (1st–28th), negative = from end (-1 = last day, -2 = second to last).
61
+ # Capped at ±28 because February has 28 days in a common year.
62
+ DATE_OF_MONTH_RANGE = ((-28..-1).to_a + (1..28).to_a).freeze
63
+ HOUR_OF_DAY_RANGE = 0..23
64
+ MINUTE_OF_HOUR_RANGE = SECOND_OF_MINUTE_RANGE = 0..59
65
+ MONTH_OF_YEAR_RANGE = 1..12
66
+ DAY_OF_YEAR_RANGE = ((-366..-1).to_a + (1..366).to_a).freeze
67
+ WEEK_OF_YEAR_RANGE = ((-53..-1).to_a + (1..53).to_a).freeze
68
+ BYDAY_PATTERN = /\A[+-]?\d*(?:#{Regexp.union(DAYS_OF_WEEK).source})\z/
69
+ # Parses an RRULE UNTIL string (e.g. "20261231T235959Z") into a Time object.
70
+ UNTIL_PATTERN = /\A(?<Y>\d{4})(?<m>\d{2})(?<d>\d{2})T(?<H>\d{2})(?<M>\d{2})(?<S>\d{2})Z\z/
71
+
72
+ ATTRIBUTES = %i[
73
+ by_day by_month_day by_set_pos count day_of_year frequency hour_of_day interval
74
+ minute_of_hour month_of_year repeat_until
75
+ second_of_minute week_of_year week_start
76
+ ].freeze
77
+
78
+ ARRAY_ATTRIBUTES = %i[
79
+ by_day by_month_day by_set_pos day_of_year hour_of_day minute_of_hour
80
+ month_of_year second_of_minute week_of_year
81
+ ].freeze
82
+
83
+ attr_accessor(*(ATTRIBUTES - ARRAY_ATTRIBUTES - %i[repeat_until]))
84
+ attr_reader :repeat_until, *ARRAY_ATTRIBUTES
85
+
86
+ class << self
87
+ def from_rrule(rrule)
88
+ new(**attributes_from(parse_components(rrule)))
89
+ end
90
+
91
+ private
92
+
93
+ # Parses "FREQ=DAILY;INTERVAL=1;BYDAY=MO…" into {"FREQ"=>"DAILY", "BYDAY"=>"MO", …}
94
+ def parse_components(rrule)
95
+ rrule.split(';').each_with_object({}) do |pair, hash|
96
+ next if pair.strip.empty?
97
+
98
+ key, value = pair.split('=', 2)
99
+ hash[key] = value
100
+ end
101
+ end
102
+
103
+ def attributes_from(components)
104
+ {
105
+ by_day: split_list(components['BYDAY']),
106
+ by_month_day: split_int_list(components['BYMONTHDAY']),
107
+ by_set_pos: split_int_list(components['BYSETPOS']),
108
+ count: components['COUNT']&.to_i,
109
+ day_of_year: split_int_list(components['BYYEARDAY']),
110
+ frequency: components['FREQ'],
111
+ hour_of_day: split_int_list(components['BYHOUR']),
112
+ interval: components['INTERVAL']&.to_i || 1,
113
+ minute_of_hour: split_int_list(components['BYMINUTE']),
114
+ month_of_year: split_int_list(components['BYMONTH']),
115
+ repeat_until: components['UNTIL'],
116
+ second_of_minute: split_int_list(components['BYSECOND']),
117
+ week_of_year: split_int_list(components['BYWEEKNO']),
118
+ week_start: components['WKST']
119
+ }
120
+ end
121
+
122
+ def split_list(csv)
123
+ return unless csv
124
+
125
+ list = csv.split(',')
126
+ list unless list.empty?
127
+ end
128
+
129
+ def split_int_list(csv)
130
+ split_list(csv)&.map(&:to_i)
131
+ end
132
+ end
133
+
134
+ ARRAY_ATTRIBUTES.each do |attr|
135
+ define_method(:"#{attr}=") do |value|
136
+ coerced = Array(value)
137
+ instance_variable_set(:"@#{attr}", coerced.empty? ? nil : coerced)
138
+ end
139
+ end
140
+
141
+ def repeat_until=(value)
142
+ @repeat_until = case value
143
+ when nil, '' then nil
144
+ when Time then value.utc
145
+ when String then parse_until(value)
146
+ end
147
+ end
148
+
149
+ def to_rrule
150
+ {
151
+ 'FREQ' => frequency,
152
+ 'INTERVAL' => interval,
153
+ 'COUNT' => non_blank(count),
154
+ 'UNTIL' => format_until(repeat_until),
155
+ 'BYDAY' => join_list(by_day),
156
+ 'BYMONTHDAY' => join_list(by_month_day),
157
+ 'BYMONTH' => join_list(month_of_year),
158
+ 'BYHOUR' => join_list(hour_of_day),
159
+ 'BYMINUTE' => join_list(minute_of_hour),
160
+ 'BYSECOND' => join_list(second_of_minute),
161
+ 'BYYEARDAY' => join_list(day_of_year),
162
+ 'BYWEEKNO' => join_list(week_of_year),
163
+ 'BYSETPOS' => join_list(by_set_pos),
164
+ 'WKST' => non_blank(week_start)
165
+ }.filter_map { |k, v| "#{k}=#{v}" unless v.nil? }.join(';')
166
+ end
167
+
168
+ def monthly_option
169
+ return unless frequency == 'MONTHLY'
170
+ return 'NTH_DAY' if by_set_pos&.any? && by_day&.any?
171
+
172
+ 'DATE' if by_month_day&.any?
173
+ end
174
+
175
+ def by_month_day_option? = monthly_option == 'DATE'
176
+ def by_set_pos_option? = monthly_option == 'NTH_DAY'
177
+
178
+ def <=>(other)
179
+ return super unless other.is_a?(self.class)
180
+
181
+ FREQ_ORDER[frequency] <=> FREQ_ORDER[other.frequency]
182
+ end
183
+
184
+ private
185
+
186
+ def non_blank(value)
187
+ value unless value.nil? || value.to_s.strip.empty?
188
+ end
189
+
190
+ def join_list(array)
191
+ non_blank(array&.join(','))
192
+ end
193
+
194
+ def format_until(time)
195
+ time&.utc&.strftime('%Y%m%dT%H%M%SZ')
196
+ end
197
+
198
+ def parse_until(value)
199
+ return unless (match = non_blank(value)&.match(UNTIL_PATTERN))
200
+
201
+ Time.utc(match[:Y], match[:m], match[:d], match[:H], match[:M], match[:S])
202
+ end
203
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'recurrence'
4
+
5
+ # Serializes between RRULE strings and Recurrence objects. A Recurrence fully
6
+ # represents an RRULE — it can be constructed from the string and stored back as one.
7
+ class RecurrenceSerializer
8
+ def self.load(rrule)
9
+ Recurrence.from_rrule(rrule) if rrule.present?
10
+ end
11
+
12
+ def self.dump(recurrence_instance) = recurrence_instance&.to_rrule
13
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time'
4
+ require 'active_support/core_ext/time/zones'
5
+ require 'rrule'
6
+
7
+ module RruleUtils
8
+ SUB_DAILY_NOUNS = { 'HOURLY' => 'hour', 'MINUTELY' => 'minute' }.freeze
9
+
10
+ def recurrence_times(project_from:, project_to:, dt_start_at: nil)
11
+ dt_start_at ||= project_from
12
+ RRule::Rule.new(recurrence.to_rrule, dtstart: dt_start_at, tzid: Time.zone.tzinfo.identifier)
13
+ .between(project_from, project_to)
14
+ .uniq
15
+ end
16
+
17
+ def last_recurrence_time_before(before, dt_start_at:)
18
+ project_from = (Recurrence::FREQUENCIES[recurrence.frequency] * recurrence.interval).days.ago(before)
19
+ recurrence_times(project_from:, project_to: before, dt_start_at:).last
20
+ end
21
+
22
+ def next_recurrence_time_after(after, dt_start_at:)
23
+ project_to = (Recurrence::FREQUENCIES[recurrence.frequency] * recurrence.interval).days.since(after)
24
+ recurrence_times(project_from: after, project_to:, dt_start_at:).first
25
+ end
26
+
27
+ def humanize_recurrence
28
+ noun = SUB_DAILY_NOUNS[recurrence.frequency]
29
+ return RRule::Rule.new(recurrence.to_rrule).humanize unless noun
30
+
31
+ "every #{recurrence.interval == 1 ? noun : "#{recurrence.interval} #{noun}s"}"
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recurable
4
+ VERSION = '1.0.0'
5
+ end
data/lib/recurable.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ require_relative 'recurable/version'
6
+ require_relative 'recurable/recurrence'
7
+ require_relative 'recurable/rrule_utils'
8
+ require_relative 'recurable/array_inclusion_validator'
9
+ require_relative 'recurable/recurrence_serializer'
10
+ # A concern to be included by ActiveRecord models that persist an rrule string to the database. This concern gives them
11
+ # access to a Recurrence object (instead of an rrule string) which encapsulates user-friendly getters and
12
+ # setters for the rrule, as well as validation, display, and projection concerns.
13
+ module Recurable
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ include RruleUtils
18
+
19
+ # This concern should only be used with a model that has an `rrule` string column.
20
+ serialize :rrule, coder: RecurrenceSerializer, default: Recurrence.new(frequency: 'DAILY', interval: 1)
21
+ alias_attribute :recurrence, :rrule
22
+
23
+ delegate(*Recurrence::ATTRIBUTES.flat_map { |attr| [attr, :"#{attr}="] },
24
+ *Recurrence::FREQUENCIES.each_key.map { |freq| :"#{freq.downcase}?" },
25
+ :by_month_day_option?, :by_set_pos_option?,
26
+ to: :rrule)
27
+
28
+ validates :by_day, array_inclusion: { in: Recurrence::BYDAY_PATTERN }, allow_blank: true
29
+ validates :by_month_day, array_inclusion: { in: Recurrence::DATE_OF_MONTH_RANGE }, if: :by_month_day_option?
30
+ validates :by_set_pos, array_inclusion: { in: Recurrence::NTH_DAY_OF_MONTH.values }, if: :by_set_pos_option?
31
+ validates :count, numericality: { only_integer: true, greater_than: 0 }, allow_blank: true
32
+ validates :day_of_year, array_inclusion: { in: Recurrence::DAY_OF_YEAR_RANGE }, allow_blank: true
33
+ validates :frequency, presence: true, inclusion: { in: Recurrence::FREQUENCIES.keys }
34
+ validates :hour_of_day, array_inclusion: { in: Recurrence::HOUR_OF_DAY_RANGE }, allow_blank: true
35
+ validates :interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
36
+ validates :minute_of_hour, array_inclusion: { in: Recurrence::MINUTE_OF_HOUR_RANGE }, allow_blank: true
37
+ validates :month_of_year, array_inclusion: { in: Recurrence::MONTH_OF_YEAR_RANGE }, allow_blank: true
38
+ validates :second_of_minute, array_inclusion: { in: Recurrence::SECOND_OF_MINUTE_RANGE }, allow_blank: true
39
+ validates :week_of_year, array_inclusion: { in: Recurrence::WEEK_OF_YEAR_RANGE }, allow_blank: true
40
+ validates :week_start, inclusion: { in: Recurrence::DAYS_OF_WEEK }, allow_blank: true
41
+ validate :count_and_until_mutually_exclusive
42
+ end
43
+
44
+ private
45
+
46
+ def count_and_until_mutually_exclusive
47
+ errors.add(:base, 'COUNT and UNTIL are mutually exclusive') if count.present? && repeat_until.present?
48
+ end
49
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: recurable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Aaron Rosenberg
8
+ - Matt Lewis
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '7'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9'
53
+ - !ruby/object:Gem::Dependency
54
+ name: rrule
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.5'
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '0.5'
67
+ description: |
68
+ Provides Recurrence, RruleUtils, and RecurrenceSerializer as a standalone library
69
+ for working with iCal RRULE recurrence patterns (full RFC 5545 support). Optionally
70
+ integrates with Rails via the Recurable concern for transparent ActiveRecord
71
+ serialization. Handles DST-safe projection for all frequencies from minutely through yearly.
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/recurable.rb
79
+ - lib/recurable/array_inclusion_validator.rb
80
+ - lib/recurable/recurrence.rb
81
+ - lib/recurable/recurrence_serializer.rb
82
+ - lib/recurable/rrule_utils.rb
83
+ - lib/recurable/version.rb
84
+ homepage: https://github.com/agrberg/recurable
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ rubygems_mfa_required: 'true'
89
+ source_code_uri: https://github.com/agrberg/recurable
90
+ bug_tracker_uri: https://github.com/agrberg/recurable/issues
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.3'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.6.9
106
+ specification_version: 4
107
+ summary: iCal RRULE recurrence library with optional Rails integration
108
+ test_files: []