Class: Recurrence

Inherits:
Object
  • Object
show all
Includes:
Comparable
Defined in:
lib/recurable/recurrence.rb

Overview

Core model representing an iCal RRULE recurrence pattern.

Pure Ruby data class — no Rails dependencies. Handles RRULE string generation/parsing with named attributes and frequency comparison.

recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
recurrence.rrule   # => "FREQ=DAILY;INTERVAL=1"
recurrence.daily?  # => true

Constant Summary collapse

DAYS_OF_WEEK =

iCal BYDAY codes derived from Date::DAYNAMES, ordered Sunday–Saturday. Exposes class constants: Recurrence::SUNDAY => ‘SU’, Recurrence::MONDAY => ‘MO’, etc. ‘const_set` returns the value of the constant and map returns an array of the transformed values.

Date::DAYNAMES.map { |name| const_set(name.upcase, name[0, 2].upcase) }.freeze
FREQUENCIES =

Ordered by increasing frequency. Values are approximate period in days. Order used by Comparable#<=> for RruleAdapter strategy selection. Exposes class constants: Recurrence::YEARLY, Recurrence::DAILY, etc. and defines frequency predicates.

{
  'YEARLY' => 365,
  'MONTHLY' => 31,
  'WEEKLY' => 7,
  'DAILY' => 1,
  'HOURLY' => 1 / 24.0,
  'MINUTELY' => 1 / 24.0 / 60.0
}.each_key do |freq|
  const_set(freq, freq)
  define_method(:"#{freq.downcase}?") { freq == frequency }
end.freeze
FREQ_ORDER =
FREQUENCIES.keys.each_with_index.to_h.freeze
MONTHLY_OPTIONS =

Exposes class constants: Recurrence::MONTHLY_DATE => ‘DATE’, Recurrence::MONTHLY_NTH_DAY => ‘NTH_DAY’.

%w[DATE NTH_DAY].each { |opt| const_set("MONTHLY_#{opt}", opt) }.freeze
NTH_DAY_OF_MONTH =

Maps symbolic positions to iCal BYSETPOS integers. Positive 1–4 covers typical forward positions; negative -1/-2 covers “last” and “second to last” (deeper negatives are better expressed counting forward). A month has at most 5 of any single weekday.

{
  first: 1,
  second: 2,
  third: 3,
  fourth: 4,
  last: -1,
  second_to_last: -2
}.freeze
DATE_OF_MONTH_RANGE =

Positive = calendar date (1st–28th), negative = from end (-1 = last day, -2 = second to last). Capped at ±28 because February has 28 days in a common year.

((-28..-1).to_a + (1..28).to_a).freeze
HOUR_OF_DAY_RANGE =
0..23
MINUTE_OF_HOUR_RANGE =
SECOND_OF_MINUTE_RANGE = 0..59
MONTH_OF_YEAR_RANGE =
1..12
DAY_OF_YEAR_RANGE =
((-366..-1).to_a + (1..366).to_a).freeze
WEEK_OF_YEAR_RANGE =
((-53..-1).to_a + (1..53).to_a).freeze
BYDAY_PATTERN =
/\A[+-]?\d*(?:#{Regexp.union(DAYS_OF_WEEK).source})\z/
UNTIL_PATTERN =

Parses an RRULE UNTIL string (e.g. “20261231T235959Z”) into a Time object.

/\A(?<Y>\d{4})(?<m>\d{2})(?<d>\d{2})T(?<H>\d{2})(?<M>\d{2})(?<S>\d{2})Z\z/
ATTRIBUTES =
%i[
  by_day by_month_day by_set_pos count day_of_year frequency hour_of_day interval
  minute_of_hour month_of_year repeat_until
  second_of_minute week_of_year week_start
].freeze
ARRAY_ATTRIBUTES =
%i[
  by_day by_month_day by_set_pos day_of_year hour_of_day minute_of_hour
  month_of_year second_of_minute week_of_year
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(**attrs) ⇒ Recurrence

Returns a new instance of Recurrence.

Raises:

  • (ArgumentError)


19
20
21
22
23
24
# File 'lib/recurable/recurrence.rb', line 19

def initialize(**attrs)
  unknown = attrs.keys - ATTRIBUTES
  raise ArgumentError, "Unknown attribute(s): #{unknown.join(', ')}" if unknown.any?

  attrs.each { |attr, value| public_send(:"#{attr}=", value) }
end

Class Method Details

.from_rrule(rrule) ⇒ Object



87
88
89
# File 'lib/recurable/recurrence.rb', line 87

def from_rrule(rrule)
  new(**attributes_from(parse_components(rrule)))
end

Instance Method Details

#<=>(other) ⇒ Object



178
179
180
181
182
# File 'lib/recurable/recurrence.rb', line 178

def <=>(other)
  return super unless other.is_a?(self.class)

  FREQ_ORDER[frequency] <=> FREQ_ORDER[other.frequency]
end

#by_month_day_option?Boolean

Returns:

  • (Boolean)


175
# File 'lib/recurable/recurrence.rb', line 175

def by_month_day_option? = monthly_option == 'DATE'

#by_set_pos_option?Boolean

Returns:

  • (Boolean)


176
# File 'lib/recurable/recurrence.rb', line 176

def by_set_pos_option? = monthly_option == 'NTH_DAY'

#monthly_optionObject



168
169
170
171
172
173
# File 'lib/recurable/recurrence.rb', line 168

def monthly_option
  return unless frequency == 'MONTHLY'
  return 'NTH_DAY' if by_set_pos&.any? && by_day&.any?

  'DATE' if by_month_day&.any?
end

#repeat_until=(value) ⇒ Object



141
142
143
144
145
146
147
# File 'lib/recurable/recurrence.rb', line 141

def repeat_until=(value)
  @repeat_until = case value
                  when nil, '' then nil
                  when Time then value.utc
                  when String then parse_until(value)
                  end
end

#to_rruleObject



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/recurable/recurrence.rb', line 149

def to_rrule
  {
    'FREQ' => frequency,
    'INTERVAL' => interval,
    'COUNT' => non_blank(count),
    'UNTIL' => format_until(repeat_until),
    'BYDAY' => join_list(by_day),
    'BYMONTHDAY' => join_list(by_month_day),
    'BYMONTH' => join_list(month_of_year),
    'BYHOUR' => join_list(hour_of_day),
    'BYMINUTE' => join_list(minute_of_hour),
    'BYSECOND' => join_list(second_of_minute),
    'BYYEARDAY' => join_list(day_of_year),
    'BYWEEKNO' => join_list(week_of_year),
    'BYSETPOS' => join_list(by_set_pos),
    'WKST' => non_blank(week_start)
  }.filter_map { |k, v| "#{k}=#{v}" unless v.nil? }.join(';')
end