From 2edcd2148bb639cb041575bbae5f4c8e0325cebd Mon Sep 17 00:00:00 2001 From: Nicolas Marlier Date: Tue, 23 Aug 2016 12:47:23 +0200 Subject: [PATCH 01/29] Support BYSETPOS for MONTHLY AND YEARLY freq --- .gitignore | 3 + lib/ice_cube.rb | 3 + lib/ice_cube/parsers/ical_parser.rb | 2 +- lib/ice_cube/rules/monthly_rule.rb | 1 + lib/ice_cube/rules/yearly_rule.rb | 1 + lib/ice_cube/time_util.rb | 36 +++++++- lib/ice_cube/validated_rule.rb | 3 +- .../validations/monthly_by_set_pos.rb | 87 ++++++++++++++++++ lib/ice_cube/validations/yearly_by_set_pos.rb | 89 +++++++++++++++++++ spec/examples/by_set_pos_spec.rb | 29 ++++++ spec/examples/from_ical_spec.rb | 7 +- 11 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 lib/ice_cube/validations/monthly_by_set_pos.rb create mode 100644 lib/ice_cube/validations/yearly_by_set_pos.rb create mode 100644 spec/examples/by_set_pos_spec.rb diff --git a/.gitignore b/.gitignore index 8eb3b06b..64d30ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ /spec/reports/ /tmp/ +# rubymine +.idea + # rspec failure tracking .rspec_status diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index eb301660..29b5600d 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,9 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :MonthlyBySetPos, 'ice_cube/validations/monthly_by_set_pos' + autoload :YearlyBySetPos, 'ice_cube/validations/yearly_by_set_pos' + autoload :HourOfDay, "ice_cube/validations/hour_of_day" autoload :MonthOfYear, "ice_cube/validations/month_of_year" autoload :MinuteOfHour, "ice_cube/validations/minute_of_hour" diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 429bd84c..3b00fa1a 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -75,7 +75,7 @@ def self.rule_from_ical(ical) when "BYYEARDAY" validations[:day_of_year] = value.split(",").map(&:to_i) when "BYSETPOS" - # noop + params[:validations][:by_set_pos] = value.split(',').collect(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/rules/monthly_rule.rb b/lib/ice_cube/rules/monthly_rule.rb index 6aadf5e7..9decdbca 100644 --- a/lib/ice_cube/rules/monthly_rule.rb +++ b/lib/ice_cube/rules/monthly_rule.rb @@ -10,6 +10,7 @@ class MonthlyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::MonthlyInterval + include Validations::MonthlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/yearly_rule.rb b/lib/ice_cube/rules/yearly_rule.rb index d92148c1..0ebb3057 100644 --- a/lib/ice_cube/rules/yearly_rule.rb +++ b/lib/ice_cube/rules/yearly_rule.rb @@ -10,6 +10,7 @@ class YearlyRule < ValidatedRule include Validations::DayOfYear include Validations::YearlyInterval + include Validations::YearlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index a18f7758..30e9fe7a 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -1,5 +1,7 @@ -require "date" -require "time" +require 'date' +require 'time' +require 'active_support' +require 'active_support/core_ext' module IceCube module TimeUtil @@ -193,6 +195,36 @@ def self.which_occurrence_in_month(time, wday) [nth_occurrence_of_weekday, this_weekday_in_month_count] end + # Use Activesupport CoreExt functions to manipulate time + def self.start_of_month time + time.beginning_of_month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.end_of_month time + time.end_of_month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.start_of_year time + time.beginning_of_year + end + + # Use Activesupport CoreExt functions to manipulate time + def self.end_of_year time + time.end_of_year + end + + # Use Activesupport CoreExt functions to manipulate time + def self.previous_month time + time - 1.month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.previous_year time + time - 1.year + end + # Get the days in the month for +time def self.days_in_month(time) date = Date.new(time.year, time.month, 1) diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index 068356ea..faa5ee95 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -18,7 +18,8 @@ class ValidatedRule < Rule :base_sec, :base_min, :base_day, :base_hour, :base_month, :base_wday, :day_of_year, :second_of_minute, :minute_of_hour, :day_of_month, :hour_of_day, :month_of_year, :day_of_week, - :interval + :interval, + :by_set_pos ] attr_reader :validations diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb new file mode 100644 index 00000000..dc87deaf --- /dev/null +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -0,0 +1,87 @@ +module IceCube + + module Validations::MonthlyBySetPos + + def by_set_pos(*by_set_pos) + return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Fixnum) + + unless by_set_pos.nil? || by_set_pos.is_a?(Array) + raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" + end + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (set_pos >= -366 && set_pos <= -1) || + (set_pos <= 366 && set_pos >= 1) + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, by_set_pos && [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, schedule) + start_of_month = TimeUtil.start_of_month step_time + end_of_month = TimeUtil.end_of_month step_time + + + new_schedule = IceCube::Schedule.new(TimeUtil.previous_month(step_time)) do |s| + s.add_recurrence_rule IceCube::Rule.from_hash(rule.to_hash.reject{|k, v| [:by_set_pos, :count, :until].include? k}) + end + + puts step_time + occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) + p occurrences + index = occurrences.index(step_time) + if index == nil + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + end + + end + +end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb new file mode 100644 index 00000000..af629117 --- /dev/null +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -0,0 +1,89 @@ +module IceCube + + module Validations::YearlyBySetPos + + def by_set_pos(*by_set_pos) + return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Fixnum) + + unless by_set_pos.nil? || by_set_pos.is_a?(Array) + raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" + end + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (set_pos >= -366 && set_pos <= -1) || + (set_pos <= 366 && set_pos >= 1) + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, by_set_pos && [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, schedule) + start_of_year = TimeUtil.start_of_year step_time + end_of_year = TimeUtil.end_of_year step_time + + + new_schedule = IceCube::Schedule.new(TimeUtil.previous_year(step_time)) do |s| + s.add_recurrence_rule IceCube::Rule.from_hash(rule.to_hash.reject{|k, v| [:by_set_pos, :count, :until].include? k}) + end + + occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) + + index = occurrences.index(step_time) + if index == nil + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + + + + end + + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + end + + end + +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb new file mode 100644 index 00000000..c9d0a124 --- /dev/null +++ b/spec/examples/by_set_pos_spec.rb @@ -0,0 +1,29 @@ +require File.dirname(__FILE__) + '/../spec_helper' + +module IceCube + + describe MonthlyRule, 'BYSETPOS' do + it 'should behave correctly' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))).to eq([ + Time.new(2015,6,24,12,0,0), + Time.new(2015,7,22,12,0,0), + Time.new(2015,8,26,12,0,0), + Time.new(2015,9,23,12,0,0) + ]) + end + + end + + describe YearlyRule, 'BYSETPOS' do + it 'should behave correctly' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1" + schedule.start_time = Time.new(1966,7,5) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))).to eq([ + Time.new(2015, 7, 31), + Time.new(2016, 7, 31) + ]) + end + end +end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2ab66c3c..74236611 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -84,7 +84,12 @@ module IceCube expect(rule).to eq(IceCube::Rule.weekly(2, :monday)) end - it "should return no occurrences after daily interval with count is over" do + it 'should be able to parse by_set_pos start (BYSETPOS)' do + rule = IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-1,1") + rule.should == IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1]) + end + + it 'should return no occurrences after daily interval with count is over' do schedule = IceCube::Schedule.new(Time.now) schedule.add_recurrence_rule(IceCube::Rule.from_ical("FREQ=DAILY;COUNT=5")) expect(schedule.occurrences_between(Time.now + (IceCube::ONE_DAY * 7), Time.now + (IceCube::ONE_DAY * 14)).count).to eq(0) From 13f14c9f52f3fe93edfd58e3d76e23a5d8cef9b4 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Mon, 30 Jul 2018 16:03:35 -0400 Subject: [PATCH 02/29] Modernize BYSETPOS commit A few small updates to Nicolas Marlier's BYSETPOS support added in PR #349 --- lib/ice_cube/validations/monthly_by_set_pos.rb | 4 +--- lib/ice_cube/validations/yearly_by_set_pos.rb | 2 +- spec/examples/from_ical_spec.rb | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index dc87deaf..3bc887be 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -3,7 +3,7 @@ module IceCube module Validations::MonthlyBySetPos def by_set_pos(*by_set_pos) - return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Fixnum) + return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Integer) unless by_set_pos.nil? || by_set_pos.is_a?(Array) raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" @@ -48,9 +48,7 @@ def validate(step_time, schedule) s.add_recurrence_rule IceCube::Rule.from_hash(rule.to_hash.reject{|k, v| [:by_set_pos, :count, :until].include? k}) end - puts step_time occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) - p occurrences index = occurrences.index(step_time) if index == nil 1 diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index af629117..7ecb807e 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -3,7 +3,7 @@ module IceCube module Validations::YearlyBySetPos def by_set_pos(*by_set_pos) - return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Fixnum) + return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Integer) unless by_set_pos.nil? || by_set_pos.is_a?(Array) raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 74236611..2031b334 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -86,7 +86,7 @@ module IceCube it 'should be able to parse by_set_pos start (BYSETPOS)' do rule = IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-1,1") - rule.should == IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1]) + expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) end it 'should return no occurrences after daily interval with count is over' do From 4906b838232525a44e233498b7a8e7c7fa712a04 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Mon, 12 Dec 2022 11:36:18 -0500 Subject: [PATCH 03/29] address the spec DST sensitivity in .to_yaml round trips --- spec/examples/to_yaml_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/examples/to_yaml_spec.rb b/spec/examples/to_yaml_spec.rb index e7c62c59..7166110d 100644 --- a/spec/examples/to_yaml_spec.rb +++ b/spec/examples/to_yaml_spec.rb @@ -78,7 +78,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .day_of_year" do - schedule1 = Schedule.new(Time.now) + schedule1 = Schedule.new(Time.zone.now) schedule1.add_recurrence_rule Rule.yearly.day_of_year(100, 200) yaml_string = schedule1.to_yaml @@ -112,7 +112,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .month_of_year" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.yearly.month_of_year(:april, :may) yaml_string = schedule.to_yaml From c25ace877eae1637f81af9c382e79d0973ee1a3f Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Mon, 12 Dec 2022 12:29:02 -0500 Subject: [PATCH 04/29] update PR from feedback rebased against master -- its been 4 years --- .gitignore | 3 -- lib/ice_cube/time_util.rb | 30 ------------------- .../validations/monthly_by_set_pos.rb | 22 ++++---------- lib/ice_cube/validations/yearly_by_set_pos.rb | 25 ++++------------ spec/examples/by_set_pos_spec.rb | 24 +++++++-------- spec/examples/from_ical_spec.rb | 14 +++++++++ 6 files changed, 38 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 64d30ba1..8eb3b06b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,5 @@ /spec/reports/ /tmp/ -# rubymine -.idea - # rspec failure tracking .rspec_status diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index 30e9fe7a..b1f8b907 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -195,36 +195,6 @@ def self.which_occurrence_in_month(time, wday) [nth_occurrence_of_weekday, this_weekday_in_month_count] end - # Use Activesupport CoreExt functions to manipulate time - def self.start_of_month time - time.beginning_of_month - end - - # Use Activesupport CoreExt functions to manipulate time - def self.end_of_month time - time.end_of_month - end - - # Use Activesupport CoreExt functions to manipulate time - def self.start_of_year time - time.beginning_of_year - end - - # Use Activesupport CoreExt functions to manipulate time - def self.end_of_year time - time.end_of_year - end - - # Use Activesupport CoreExt functions to manipulate time - def self.previous_month time - time - 1.month - end - - # Use Activesupport CoreExt functions to manipulate time - def self.previous_year time - time - 1.year - end - # Get the days in the month for +time def self.days_in_month(time) date = Date.new(time.year, time.month, 1) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 3bc887be..a26d5e24 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -1,23 +1,16 @@ module IceCube module Validations::MonthlyBySetPos - def by_set_pos(*by_set_pos) - return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Integer) - - unless by_set_pos.nil? || by_set_pos.is_a?(Array) - raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" - end by_set_pos.flatten! by_set_pos.each do |set_pos| - unless (set_pos >= -366 && set_pos <= -1) || - (set_pos <= 366 && set_pos >= 1) + unless (-366..366).include?(set_pos) && set_pos != 0 raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" end end @by_set_pos = by_set_pos - replace_validations_for(:by_set_pos, by_set_pos && [Validation.new(by_set_pos, self)]) + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) self end @@ -26,7 +19,6 @@ class Validation attr_reader :rule, :by_set_pos def initialize(by_set_pos, rule) - @by_set_pos = by_set_pos @rule = rule end @@ -40,12 +32,11 @@ def dst_adjust? end def validate(step_time, schedule) - start_of_month = TimeUtil.start_of_month step_time - end_of_month = TimeUtil.end_of_month step_time - + start_of_month = step_time.beginning_of_month + end_of_month = step_time.end_of_month - new_schedule = IceCube::Schedule.new(TimeUtil.previous_month(step_time)) do |s| - s.add_recurrence_rule IceCube::Rule.from_hash(rule.to_hash.reject{|k, v| [:by_set_pos, :count, :until].include? k}) + new_schedule = IceCube::Schedule.new(step_time - 1.month) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :util))) end occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) @@ -64,7 +55,6 @@ def validate(step_time, schedule) end end - def build_s(builder) builder.piece(:by_set_pos) << by_set_pos end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 7ecb807e..e1275b67 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -3,21 +3,15 @@ module IceCube module Validations::YearlyBySetPos def by_set_pos(*by_set_pos) - return by_set_pos([by_set_pos]) if by_set_pos.is_a?(Integer) - - unless by_set_pos.nil? || by_set_pos.is_a?(Array) - raise ArgumentError, "Expecting Array or nil value for count, got #{by_set_pos.inspect}" - end by_set_pos.flatten! by_set_pos.each do |set_pos| - unless (set_pos >= -366 && set_pos <= -1) || - (set_pos <= 366 && set_pos >= 1) + unless (-366..366).include?(set_pos) && set_pos != 0 raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" end end @by_set_pos = by_set_pos - replace_validations_for(:by_set_pos, by_set_pos && [Validation.new(by_set_pos, self)]) + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) self end @@ -40,12 +34,11 @@ def dst_adjust? end def validate(step_time, schedule) - start_of_year = TimeUtil.start_of_year step_time - end_of_year = TimeUtil.end_of_year step_time - + start_of_year = step_time.beginning_of_year + end_of_year = step_time.end_of_year - new_schedule = IceCube::Schedule.new(TimeUtil.previous_year(step_time)) do |s| - s.add_recurrence_rule IceCube::Rule.from_hash(rule.to_hash.reject{|k, v| [:by_set_pos, :count, :until].include? k}) + new_schedule = IceCube::Schedule.new(step_time - 1.year) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :util))) end occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) @@ -63,12 +56,8 @@ def validate(step_time, schedule) 1 end end - - - end - def build_s(builder) builder.piece(:by_set_pos) << by_set_pos end @@ -83,7 +72,5 @@ def build_ical(builder) nil end - end - end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index c9d0a124..ee0eafcf 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -1,29 +1,29 @@ require File.dirname(__FILE__) + '/../spec_helper' module IceCube - describe MonthlyRule, 'BYSETPOS' do it 'should behave correctly' do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) - expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))).to eq([ - Time.new(2015,6,24,12,0,0), - Time.new(2015,7,22,12,0,0), - Time.new(2015,8,26,12,0,0), - Time.new(2015,9,23,12,0,0) - ]) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,6,24,12,0,0), + Time.new(2015,7,22,12,0,0), + Time.new(2015,8,26,12,0,0), + Time.new(2015,9,23,12,0,0) + ]) end - end describe YearlyRule, 'BYSETPOS' do it 'should behave correctly' do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1" schedule.start_time = Time.new(1966,7,5) - expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))).to eq([ - Time.new(2015, 7, 31), - Time.new(2016, 7, 31) - ]) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015, 7, 31), + Time.new(2016, 7, 31) + ]) end end end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2031b334..9cb685bf 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -89,6 +89,20 @@ module IceCube expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) end + it 'should raise when by_set_pos is out of range (BYSETPOS)' do + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=0") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + end + it 'should return no occurrences after daily interval with count is over' do schedule = IceCube::Schedule.new(Time.now) schedule.add_recurrence_rule(IceCube::Rule.from_ical("FREQ=DAILY;COUNT=5")) From 53c328076957e193bacfdb25104b9fde533f1a52 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Wed, 21 Dec 2022 15:21:10 -0500 Subject: [PATCH 05/29] excluding until, not util --- lib/ice_cube/validations/monthly_by_set_pos.rb | 2 +- lib/ice_cube/validations/yearly_by_set_pos.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index a26d5e24..111255c0 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -36,7 +36,7 @@ def validate(step_time, schedule) end_of_month = step_time.end_of_month new_schedule = IceCube::Schedule.new(step_time - 1.month) do |s| - s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :util))) + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index e1275b67..82f90349 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -38,7 +38,7 @@ def validate(step_time, schedule) end_of_year = step_time.end_of_year new_schedule = IceCube::Schedule.new(step_time - 1.year) do |s| - s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :util))) + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) From 6173d9b1bad82adc55acf07c691f9770d7077c41 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 23 Dec 2022 15:34:37 -0500 Subject: [PATCH 06/29] remove no longer needed TimeUtil active_support require This was a holdover from the original PR back in 2016. TimeUtil has since been refactored to not need this, but the require was inadvertently left. --- lib/ice_cube/time_util.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index b1f8b907..b527b97b 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -1,7 +1,5 @@ require 'date' require 'time' -require 'active_support' -require 'active_support/core_ext' module IceCube module TimeUtil From 6edd7ce773b7edfc6a92dca4cf44033899beaa15 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 23 Dec 2022 15:35:07 -0500 Subject: [PATCH 07/29] fix interval use with bysetpos --- .../validations/monthly_by_set_pos.rb | 5 +- lib/ice_cube/validations/yearly_by_set_pos.rb | 5 +- spec/examples/by_set_pos_spec.rb | 46 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 111255c0..6208b283 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -31,11 +31,12 @@ def dst_adjust? true end - def validate(step_time, schedule) + def validate(step_time, start_time) start_of_month = step_time.beginning_of_month end_of_month = step_time.end_of_month - new_schedule = IceCube::Schedule.new(step_time - 1.month) do |s| + # Needs to start on the first day of the month + new_schedule = IceCube::Schedule.new(start_of_month.change(hour: step_time.hour, min: step_time.min, sec: step_time.sec)) do |s| s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 82f90349..94c98c54 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -33,11 +33,12 @@ def dst_adjust? true end - def validate(step_time, schedule) + def validate(step_time, start_time) start_of_year = step_time.beginning_of_year end_of_year = step_time.end_of_year - new_schedule = IceCube::Schedule.new(step_time - 1.year) do |s| + # Needs to start on the first day of the year + new_schedule = IceCube::Schedule.new(start_of_year.change(hour: step_time.hour, min: step_time.min, sec: step_time.sec)) do |s| s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index ee0eafcf..97490d66 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -13,6 +13,18 @@ module IceCube Time.new(2015,9,23,12,0,0) ]) end + + it 'should work with intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4;INTERVAL=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,7,22,12,0,0), + Time.new(2015,9,23,12,0,0), + Time.new(2015,11,25,12,0,0), + Time.new(2016,1,27,12,0,0), + ]) + end end describe YearlyRule, 'BYSETPOS' do @@ -25,5 +37,39 @@ module IceCube Time.new(2016, 7, 31) ]) end + + it 'should work with intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;INTERVAL=2" + schedule.start_time = Time.new(1966,7,5) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2023, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + Time.new(2022, 7, 31), + ]) + end + + it 'should work with counts' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3" + schedule.start_time = Time.new(2016,1,1) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2017, 7, 31), + Time.new(2018, 7, 31), + ]) + end + + it 'should work with counts and intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3;INTERVAL=2" + schedule.start_time = Time.new(2016,1,1) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + ]) + end end end From 7033771ef0db7c56425004bb7239747316f23592 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Tue, 27 Dec 2022 09:27:03 -0500 Subject: [PATCH 08/29] remove unneeded use of activesupport for date arithmetic --- lib/ice_cube/validations/monthly_by_set_pos.rb | 7 ++++--- lib/ice_cube/validations/yearly_by_set_pos.rb | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 6208b283..40d93926 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -32,11 +32,12 @@ def dst_adjust? end def validate(step_time, start_time) - start_of_month = step_time.beginning_of_month - end_of_month = step_time.end_of_month + start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) + eom_date = Date.new(step_time.year, step_time.month, -1) + end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) # Needs to start on the first day of the month - new_schedule = IceCube::Schedule.new(start_of_month.change(hour: step_time.hour, min: step_time.min, sec: step_time.sec)) do |s| + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 94c98c54..f5cc9a19 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -34,11 +34,11 @@ def dst_adjust? end def validate(step_time, start_time) - start_of_year = step_time.beginning_of_year - end_of_year = step_time.end_of_year + start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) + end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) # Needs to start on the first day of the year - new_schedule = IceCube::Schedule.new(start_of_year.change(hour: step_time.hour, min: step_time.min, sec: step_time.sec)) do |s| + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) end From 18160315af2016de0e20efe8053200bb5f828a4c Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Tue, 27 Dec 2022 11:20:32 -0500 Subject: [PATCH 09/29] support for bysetpos with freq=weekly --- lib/ice_cube.rb | 1 + lib/ice_cube/rules/weekly_rule.rb | 1 + lib/ice_cube/validations/weekly_by_set_pos.rb | 91 +++++++++++++++++++ spec/examples/by_set_pos_spec.rb | 28 ++++++ 4 files changed, 121 insertions(+) create mode 100644 lib/ice_cube/validations/weekly_by_set_pos.rb diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 29b5600d..f3af0a73 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,7 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :WeeklyBySetPos, 'ice_cube/validations/weekly_by_set_pos' autoload :MonthlyBySetPos, 'ice_cube/validations/monthly_by_set_pos' autoload :YearlyBySetPos, 'ice_cube/validations/yearly_by_set_pos' diff --git a/lib/ice_cube/rules/weekly_rule.rb b/lib/ice_cube/rules/weekly_rule.rb index fd2ffd72..9c69613d 100644 --- a/lib/ice_cube/rules/weekly_rule.rb +++ b/lib/ice_cube/rules/weekly_rule.rb @@ -10,6 +10,7 @@ class WeeklyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::WeeklyInterval + include Validations::WeeklyBySetPos attr_reader :week_start diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb new file mode 100644 index 00000000..f7c9f791 --- /dev/null +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -0,0 +1,91 @@ +module IceCube + module Validations::WeeklyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes + step_time_date = step_time.to_date + start_day_of_week = TimeUtil.sym_to_wday(rule.week_start) + step_time_day_of_week = step_time_date.wday + days_delta = step_time_day_of_week - start_day_of_week + days_to_start = days_delta >= 0 ? days_delta : 7 + days_delta + start_of_week_date = step_time_date - days_to_start + end_of_week_date = start_of_week_date + 6 + start_of_week = IceCube::TimeUtil.build_in_zone( + [start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time + ) + end_of_week = IceCube::TimeUtil.build_in_zone( + [end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time + ) + + # Needs to start on the first day of the week at the step_time's hour, min, sec + start_of_week_adjusted = IceCube::TimeUtil.build_in_zone( + [ + start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, + step_time.hour, step_time.min, step_time.sec + ], step_time + ) + new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + end + end +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index 97490d66..245790d4 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -1,6 +1,34 @@ require File.dirname(__FILE__) + '/../spec_helper' module IceCube + describe WeeklyRule, 'BYSETPOS' do + it 'should behave correctly' do + # Weekly on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 01, 01), Time.new(2024, 01, 01))). + to eq([ + Time.new(2023,1,2,12,0,0), + Time.new(2023,1,9,12,0,0), + Time.new(2023,1,16,12,0,0), + Time.new(2023,1,23,12,0,0) + ]) + end + + it 'should work with intervals' do + # Every 2 weeks on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 01, 01), Time.new(2024, 01, 01))). + to eq([ + Time.new(2023,1,9,12,0,0), + Time.new(2023,1,23,12,0,0), + Time.new(2023,2,6,12,0,0), + Time.new(2023,2,20,12,0,0) + ]) + end + end + describe MonthlyRule, 'BYSETPOS' do it 'should behave correctly' do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" From 54d4dcbf5731d9853f5b713916935f95709259fc Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Tue, 31 Jan 2023 08:49:38 -0500 Subject: [PATCH 10/29] support for parsing rrules from ical that are very long and wrap --- lib/ice_cube/parsers/ical_parser.rb | 11 +++++++++++ spec/examples/from_ical_spec.rb | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 3b00fa1a..d04beb99 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -2,7 +2,18 @@ module IceCube class IcalParser def self.schedule_from_ical(ical_string, options = {}) data = {} + + # First join lines that are wrapped + lines = [] ical_string.each_line do |line| + if lines[-1] && line =~ /\A[ \t].+\z/ + lines[-1] = lines[-1].strip + line.sub(/\A[ \t]+/, "") + else + lines << line + end + end + + lines.each do |line| (property, value) = line.split(":") (property, _tzid) = property.split(";") case property diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 9cb685bf..5de567af 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -448,5 +448,13 @@ def sorted_ical(ical) it_behaves_like "an invalid ical string" end end + + describe "ical data with wrapping" do + it "matches simple daily" do + ical_string = "DTSTART:20130314T201500Z\nDTEND:20130314T201545Z\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNT\n IL=20130531T100000Z" + schedule = IceCube::Schedule.from_ical(ical_string) + expect(schedule.to_ical.split(/\n/).select {|x| x =~ /RRULE/}.first).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") + end + end end end From 10d62869dc2e67797ccdb850dacd634b371c9d64 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Tue, 31 Jan 2023 09:01:12 -0500 Subject: [PATCH 11/29] dont require the wrapped line to be the last the ical string --- lib/ice_cube/parsers/ical_parser.rb | 2 +- spec/examples/from_ical_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index d04beb99..04ae679f 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -6,7 +6,7 @@ def self.schedule_from_ical(ical_string, options = {}) # First join lines that are wrapped lines = [] ical_string.each_line do |line| - if lines[-1] && line =~ /\A[ \t].+\z/ + if lines[-1] && line =~ /\A[ \t].+/ lines[-1] = lines[-1].strip + line.sub(/\A[ \t]+/, "") else lines << line diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 5de567af..e3d50df9 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -451,7 +451,7 @@ def sorted_ical(ical) describe "ical data with wrapping" do it "matches simple daily" do - ical_string = "DTSTART:20130314T201500Z\nDTEND:20130314T201545Z\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNT\n IL=20130531T100000Z" + ical_string = "DTSTART:20130314T201500Z\nDTEND:20130314T201545Z\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNT\n IL=20130531T100000Z\nDESCRIPTION:This is a test event\nSUMMARY:Test Event\n" schedule = IceCube::Schedule.from_ical(ical_string) expect(schedule.to_ical.split(/\n/).select {|x| x =~ /RRULE/}.first).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") end From 2ed5fb2499005d3bf90ea34bd38c7effe161d0e1 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 15:29:24 -0500 Subject: [PATCH 12/29] nitpick fixes - use map and double quotes for consistency --- lib/ice_cube.rb | 6 ++--- lib/ice_cube/parsers/ical_parser.rb | 2 +- lib/ice_cube/time_util.rb | 4 ++-- .../validations/monthly_by_set_pos.rb | 2 +- lib/ice_cube/validations/weekly_by_set_pos.rb | 2 +- lib/ice_cube/validations/yearly_by_set_pos.rb | 2 +- spec/examples/by_set_pos_spec.rb | 24 +++++++++---------- spec/examples/from_ical_spec.rb | 6 ++--- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index f3af0a73..fcd65e67 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,9 +50,9 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" - autoload :WeeklyBySetPos, 'ice_cube/validations/weekly_by_set_pos' - autoload :MonthlyBySetPos, 'ice_cube/validations/monthly_by_set_pos' - autoload :YearlyBySetPos, 'ice_cube/validations/yearly_by_set_pos' + autoload :WeeklyBySetPos, "ice_cube/validations/weekly_by_set_pos" + autoload :MonthlyBySetPos, "ice_cube/validations/monthly_by_set_pos" + autoload :YearlyBySetPos, "ice_cube/validations/yearly_by_set_pos" autoload :HourOfDay, "ice_cube/validations/hour_of_day" autoload :MonthOfYear, "ice_cube/validations/month_of_year" diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 04ae679f..41c44fc9 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -86,7 +86,7 @@ def self.rule_from_ical(ical) when "BYYEARDAY" validations[:day_of_year] = value.split(",").map(&:to_i) when "BYSETPOS" - params[:validations][:by_set_pos] = value.split(',').collect(&:to_i) + validations[:by_set_pos] = value.split(",").map(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index b527b97b..a18f7758 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -1,5 +1,5 @@ -require 'date' -require 'time' +require "date" +require "time" module IceCube module TimeUtil diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 40d93926..4435ccdb 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -66,7 +66,7 @@ def build_hash(builder) end def build_ical(builder) - builder['BYSETPOS'] << by_set_pos + builder["BYSETPOS"] << by_set_pos end nil diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index f7c9f791..acf132c0 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -82,7 +82,7 @@ def build_hash(builder) end def build_ical(builder) - builder['BYSETPOS'] << by_set_pos + builder["BYSETPOS"] << by_set_pos end nil diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index f5cc9a19..8dcf4016 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -68,7 +68,7 @@ def build_hash(builder) end def build_ical(builder) - builder['BYSETPOS'] << by_set_pos + builder["BYSETPOS"] << by_set_pos end nil diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index 245790d4..b72018ec 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -1,8 +1,8 @@ -require File.dirname(__FILE__) + '/../spec_helper' +require File.dirname(__FILE__) + "/../spec_helper" module IceCube - describe WeeklyRule, 'BYSETPOS' do - it 'should behave correctly' do + describe WeeklyRule, "BYSETPOS" do + it "should behave correctly" do # Weekly on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) @@ -15,7 +15,7 @@ module IceCube ]) end - it 'should work with intervals' do + it "should work with intervals" do # Every 2 weeks on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) @@ -29,8 +29,8 @@ module IceCube end end - describe MonthlyRule, 'BYSETPOS' do - it 'should behave correctly' do + describe MonthlyRule, "BYSETPOS" do + it "should behave correctly" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). @@ -42,7 +42,7 @@ module IceCube ]) end - it 'should work with intervals' do + it "should work with intervals" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4;INTERVAL=2" schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). @@ -55,8 +55,8 @@ module IceCube end end - describe YearlyRule, 'BYSETPOS' do - it 'should behave correctly' do + describe YearlyRule, "BYSETPOS" do + it "should behave correctly" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1" schedule.start_time = Time.new(1966,7,5) expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). @@ -66,7 +66,7 @@ module IceCube ]) end - it 'should work with intervals' do + it "should work with intervals" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;INTERVAL=2" schedule.start_time = Time.new(1966,7,5) expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2023, 01, 01))). @@ -78,7 +78,7 @@ module IceCube ]) end - it 'should work with counts' do + it "should work with counts" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3" schedule.start_time = Time.new(2016,1,1) expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). @@ -89,7 +89,7 @@ module IceCube ]) end - it 'should work with counts and intervals' do + it "should work with counts and intervals" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3;INTERVAL=2" schedule.start_time = Time.new(2016,1,1) expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index e3d50df9..ac75c637 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -84,12 +84,12 @@ module IceCube expect(rule).to eq(IceCube::Rule.weekly(2, :monday)) end - it 'should be able to parse by_set_pos start (BYSETPOS)' do + it "should be able to parse by_set_pos start (BYSETPOS)" do rule = IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-1,1") expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) end - it 'should raise when by_set_pos is out of range (BYSETPOS)' do + it "should raise when by_set_pos is out of range (BYSETPOS)" do expect { IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-367") }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) @@ -103,7 +103,7 @@ module IceCube }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) end - it 'should return no occurrences after daily interval with count is over' do + it "should return no occurrences after daily interval with count is over" do schedule = IceCube::Schedule.new(Time.now) schedule.add_recurrence_rule(IceCube::Rule.from_ical("FREQ=DAILY;COUNT=5")) expect(schedule.occurrences_between(Time.now + (IceCube::ONE_DAY * 7), Time.now + (IceCube::ONE_DAY * 14)).count).to eq(0) From c8d575557f20b56a1873d14d07f8c157b482b567 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 15:46:57 -0500 Subject: [PATCH 13/29] do not rely on ActiveSupport-only helper methods --- lib/ice_cube/validations/monthly_by_set_pos.rb | 3 ++- lib/ice_cube/validations/weekly_by_set_pos.rb | 3 ++- lib/ice_cube/validations/yearly_by_set_pos.rb | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 4435ccdb..c6bf3d60 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -38,7 +38,8 @@ def validate(step_time, start_time) # Needs to start on the first day of the month new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| - s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index acf132c0..598044d3 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -54,7 +54,8 @@ def validate(step_time, start_time) ], step_time ) new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| - s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 8dcf4016..830064cf 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -39,7 +39,8 @@ def validate(step_time, start_time) # Needs to start on the first day of the year new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| - s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) From e1cb65adb5dea544f6f13af4a83d507bdd2752bc Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 15:53:20 -0500 Subject: [PATCH 14/29] fix BYSETPOS serialization --- lib/ice_cube/validations/monthly_by_set_pos.rb | 7 ++++++- lib/ice_cube/validations/weekly_by_set_pos.rb | 7 ++++++- lib/ice_cube/validations/yearly_by_set_pos.rb | 7 ++++++- spec/examples/from_ical_spec.rb | 5 +++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index c6bf3d60..d4e3145c 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -39,6 +39,11 @@ def validate(step_time, start_time) # Needs to start on the first day of the month new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end @@ -63,7 +68,7 @@ def build_s(builder) end def build_hash(builder) - builder[:by_set_pos] = by_set_pos + builder.validations_array(:by_set_pos) << by_set_pos end def build_ical(builder) diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index 598044d3..1ccd7577 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -55,6 +55,11 @@ def validate(step_time, start_time) ) new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end @@ -79,7 +84,7 @@ def build_s(builder) end def build_hash(builder) - builder[:by_set_pos] = by_set_pos + builder.validations_array(:by_set_pos) << by_set_pos end def build_ical(builder) diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 830064cf..8c81133a 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -40,6 +40,11 @@ def validate(step_time, start_time) # Needs to start on the first day of the year new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end @@ -65,7 +70,7 @@ def build_s(builder) end def build_hash(builder) - builder[:by_set_pos] = by_set_pos + builder.validations_array(:by_set_pos) << by_set_pos end def build_ical(builder) diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index ac75c637..e328d0e5 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -89,6 +89,11 @@ module IceCube expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) end + it "should preserve by_set_pos in a to_hash/from_hash round trip" do + rule = IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1]) + expect(IceCube::Rule.from_hash(rule.to_hash)).to eq(rule) + end + it "should raise when by_set_pos is out of range (BYSETPOS)" do expect { IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-367") From a57638b907f92a7e0c434bf40d1a8762585db816 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 16:09:39 -0500 Subject: [PATCH 15/29] fix SETBYPOS with non BYDAY expansions --- .../validations/monthly_by_set_pos.rb | 8 +++++-- lib/ice_cube/validations/weekly_by_set_pos.rb | 14 +++++------- lib/ice_cube/validations/yearly_by_set_pos.rb | 8 +++++-- spec/examples/by_set_pos_spec.rb | 22 +++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index d4e3145c..4145cebd 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -24,6 +24,10 @@ def initialize(by_set_pos, rule) end def type + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] :day end @@ -36,8 +40,8 @@ def validate(step_time, start_time) eom_date = Date.new(step_time.year, step_time.month, -1) end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) - # Needs to start on the first day of the month - new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } # Avoid recursive BYSETPOS evaluation in the temporary schedule. if filtered_hash[:validations] diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index 1ccd7577..186f26f0 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -23,6 +23,10 @@ def initialize(by_set_pos, rule) end def type + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] :day end @@ -46,14 +50,8 @@ def validate(step_time, start_time) [end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time ) - # Needs to start on the first day of the week at the step_time's hour, min, sec - start_of_week_adjusted = IceCube::TimeUtil.build_in_zone( - [ - start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, - step_time.hour, step_time.min, step_time.sec - ], step_time - ) - new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } # Avoid recursive BYSETPOS evaluation in the temporary schedule. if filtered_hash[:validations] diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 8c81133a..6b920ee9 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -26,6 +26,10 @@ def initialize(by_set_pos, rule) end def type + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] :day end @@ -37,8 +41,8 @@ def validate(step_time, start_time) start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) - # Needs to start on the first day of the year - new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } # Avoid recursive BYSETPOS evaluation in the temporary schedule. if filtered_hash[:validations] diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index b72018ec..8b5c6d1d 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -53,6 +53,18 @@ module IceCube Time.new(2016,1,27,12,0,0), ]) end + + it "should work with bysecond expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYSECOND=1,2,3,4;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,5,28,12,0,2), + Time.new(2015,6,28,12,0,2), + Time.new(2015,7,28,12,0,2), + Time.new(2015,8,28,12,0,2) + ]) + end end describe YearlyRule, "BYSETPOS" do @@ -99,5 +111,15 @@ module IceCube Time.new(2020, 7, 31), ]) end + + it "should work with byhour expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYHOUR=1,2;BYSETPOS=1" + schedule.start_time = Time.new(2016, 7, 5, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2018, 01, 01))). + to eq([ + Time.new(2016, 7, 5, 1, 0, 0), + Time.new(2017, 7, 5, 1, 0, 0) + ]) + end end end From af2df291f8dbc92f66c860364b9edbea616c89ee Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 16:41:45 -0500 Subject: [PATCH 16/29] expand BYSETPOS spec coverage - weekly: positive/multi/mixed positions, BYHOUR expansion - monthly: negative positions, BYMONTHDAY, BYMINUTE - yearly: multiple positive/negative positions --- lib/ice_cube/rule.rb | 4 +- lib/ice_cube/schedule.rb | 17 +++- lib/ice_cube/single_occurrence_rule.rb | 2 +- lib/ice_cube/validated_rule.rb | 11 ++- spec/examples/by_set_pos_spec.rb | 131 +++++++++++++++++++++++++ spec/examples/schedule_spec.rb | 17 ++++ 6 files changed, 173 insertions(+), 9 deletions(-) diff --git a/lib/ice_cube/rule.rb b/lib/ice_cube/rule.rb index 30689267..d756d705 100644 --- a/lib/ice_cube/rule.rb +++ b/lib/ice_cube/rule.rb @@ -49,11 +49,11 @@ def to_hash raise MethodNotImplemented, "Expected to be overridden by subclasses" end - def next_time(time, schedule, closing_time) + def next_time(time, schedule, closing_time, increment: true) end def on?(time, schedule) - next_time(time, schedule, time).to_i == time.to_i + next_time(time, schedule, time, increment: false).to_i == time.to_i end class << self diff --git a/lib/ice_cube/schedule.rb b/lib/ice_cube/schedule.rb index 06b8e4dd..c7d9c75c 100644 --- a/lib/ice_cube/schedule.rb +++ b/lib/ice_cube/schedule.rb @@ -442,13 +442,22 @@ def enumerate_occurrences(opening_time, closing_time = nil, options = {}) # Get the next time after (or including) a specific time def next_time(time, closing_time) loop do - min_time = recurrence_rules_with_implicit_start_occurrence.reduce(nil) do |best_time, rule| - new_time = rule.next_time(time, start_time, best_time || closing_time) - [best_time, new_time].compact.min + # Probe all rules without consuming counts so we can pick the earliest + # candidate, then charge counts only to rules that emitted that time. + min_time = nil + candidates = [] + recurrence_rules_with_implicit_start_occurrence.each do |rule| + candidate = rule.next_time(time, start_time, min_time || closing_time, increment: false) + next unless candidate + candidates << [rule, candidate] + min_time = candidate if min_time.nil? || candidate < min_time rescue StopIteration - best_time + next end break unless min_time + candidates.each do |rule, candidate| + rule.increment_uses if candidate == min_time && rule.respond_to?(:increment_uses) + end next (time = min_time + 1) if exception_time?(min_time) break Occurrence.new(min_time, min_time + duration) end diff --git a/lib/ice_cube/single_occurrence_rule.rb b/lib/ice_cube/single_occurrence_rule.rb index 92961bf2..40c03298 100644 --- a/lib/ice_cube/single_occurrence_rule.rb +++ b/lib/ice_cube/single_occurrence_rule.rb @@ -11,7 +11,7 @@ def terminating? true end - def next_time(t, _, closing_time) + def next_time(t, _, closing_time, increment: true) unless closing_time && closing_time < t time if time.to_i >= t.to_i end diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index faa5ee95..fec6d9e9 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -45,17 +45,24 @@ def other_interval_validations # Compute the next time after (or including) the specified time in respect # to the given start time - def next_time(time, start_time, closing_time) + # When increment is false, callers are probing for the next candidate and + # must not consume COUNT. + def next_time(time, start_time, closing_time, increment: true) @time = time @start_time ||= realign(time, start_time) @time = @start_time if @time < @start_time return nil unless find_acceptable_time_before(closing_time) - @uses += 1 if @time + @uses += 1 if @time && increment @time end + def increment_uses + # Count is consumed only when the rule's occurrence is emitted. + @uses += 1 + end + def realign(opening_time, start_time) start_time end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index 8b5c6d1d..e2437a9d 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -27,6 +27,56 @@ module IceCube Time.new(2023,2,20,12,0,0) ]) end + + it "should support positive positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE;BYSETPOS=1") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,16,9,0,0), + Time.new(2023,1,23,9,0,0) + ]) + end + + it "should support multiple positive positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO,WE,FR;BYSETPOS=1,3") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,6,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,13,9,0,0), + Time.new(2023,1,16,9,0,0), + Time.new(2023,1,20,9,0,0) + ]) + end + + it "should support mixed positive and negative positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE,FR;BYSETPOS=1,-1") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,6,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,13,9,0,0) + ]) + end + + it "should work with hour expansions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO;BYHOUR=1,2;BYSETPOS=2") + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,2,0,0), + Time.new(2023,1,9,2,0,0), + Time.new(2023,1,16,2,0,0), + Time.new(2023,1,23,2,0,0) + ]) + end end describe MonthlyRule, "BYSETPOS" do @@ -54,6 +104,41 @@ module IceCube ]) end + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=-1" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,6,24,12,0,0), + Time.new(2015,7,29,12,0,0), + Time.new(2015,8,26,12,0,0), + Time.new(2015,9,30,12,0,0) + ]) + end + + it "should support multiple positions with monthday expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=3;BYMONTHDAY=1,15,30;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 12, 01))). + to eq([ + Time.new(2015,5,15,12,0,0), + Time.new(2015,6,15,12,0,0), + Time.new(2015,7,15,12,0,0) + ]) + end + + it "should work with byminute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMINUTE=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,5,28,12,2,0), + Time.new(2015,6,28,12,2,0), + Time.new(2015,7,28,12,2,0), + Time.new(2015,8,28,12,2,0) + ]) + end + it "should work with bysecond expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYSECOND=1,2,3,4;BYSETPOS=2" schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) @@ -65,6 +150,28 @@ module IceCube Time.new(2015,8,28,12,0,2) ]) end + + it "should not consume counts across multiple rules" do + start_time = Time.new(2019, 1, 1) + rule_a = "FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" + rule_b = "FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU,WE,TH,FR;BYMONTHDAY=13,14,15;BYSETPOS=-1" + range_end = Time.new(2021, 1, 1) + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.from_ical(rule_a)) + schedule.add_recurrence_rule(IceCube::Rule.from_ical(rule_b)) + + expected_a = IceCube::Schedule.new(start_time) + expected_a.add_recurrence_rule(IceCube::Rule.from_ical(rule_a)) + expected_b = IceCube::Schedule.new(start_time) + expected_b.add_recurrence_rule(IceCube::Rule.from_ical(rule_b)) + + occurrences = schedule.occurrences_between(start_time, range_end) + expected_occurrences = (expected_a.occurrences_between(start_time, range_end) + + expected_b.occurrences_between(start_time, range_end)).sort + expect(occurrences).to eq(expected_occurrences) + expect(occurrences.size).to eq(24) + end end describe YearlyRule, "BYSETPOS" do @@ -112,6 +219,30 @@ module IceCube ]) end + it "should support multiple positive positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=4;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=1,2" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 12, 31))). + to eq([ + Time.new(2015, 7, 1), + Time.new(2015, 7, 2), + Time.new(2016, 7, 1), + Time.new(2016, 7, 2) + ]) + end + + it "should support multiple negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=4;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1,-2" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 12, 31))). + to eq([ + Time.new(2015, 7, 30), + Time.new(2015, 7, 31), + Time.new(2016, 7, 30), + Time.new(2016, 7, 31) + ]) + end + it "should work with byhour expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYHOUR=1,2;BYSETPOS=1" schedule.start_time = Time.new(2016, 7, 5, 0, 0, 0) diff --git a/spec/examples/schedule_spec.rb b/spec/examples/schedule_spec.rb index 4696a43d..5b6052a5 100644 --- a/spec/examples/schedule_spec.rb +++ b/spec/examples/schedule_spec.rb @@ -356,6 +356,23 @@ schedule.rrule IceCube::Rule.daily.count(3) expect(schedule.all_occurrences.size).to eq(5) end + + it "should consume counts for overlapping occurrences across rules" do + start_time = Time.new(2019, 1, 1) + schedule = IceCube::Schedule.new(start_time) + schedule.rrule IceCube::Rule.daily.count(2) + schedule.rrule IceCube::Rule.daily.count(2) + expect(schedule.all_occurrences).to eq([start_time, start_time + ONE_DAY]) + end + + it "should apply count limits independently across multiple rules without overlap" do + start_time = Time.new(2019, 1, 1) + schedule = IceCube::Schedule.new(start_time) + schedule.rrule IceCube::Rule.monthly.day_of_month(1).count(12) + schedule.rrule IceCube::Rule.monthly.day_of_month(15).count(12) + occurrences = schedule.occurrences_between(start_time, Time.new(2021, 1, 1)) + expect(occurrences.size).to eq(24) + end end describe :next_occurrences do From 8b53c4da647f4c15a37bb5d231e649a62e720944 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 16:56:29 -0500 Subject: [PATCH 17/29] adding BYSETPOS validations for daily/hourly/minutely --- lib/ice_cube.rb | 3 + lib/ice_cube/rules/daily_rule.rb | 1 + lib/ice_cube/rules/hourly_rule.rb | 1 + lib/ice_cube/rules/minutely_rule.rb | 1 + lib/ice_cube/validations/daily_by_set_pos.rb | 82 +++++++++++++ lib/ice_cube/validations/hourly_by_set_pos.rb | 85 ++++++++++++++ .../validations/minutely_by_set_pos.rb | 84 +++++++++++++ spec/examples/by_set_pos_spec.rb | 111 ++++++++++++++++++ 8 files changed, 368 insertions(+) create mode 100644 lib/ice_cube/validations/daily_by_set_pos.rb create mode 100644 lib/ice_cube/validations/hourly_by_set_pos.rb create mode 100644 lib/ice_cube/validations/minutely_by_set_pos.rb diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index fcd65e67..19cdae81 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,9 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :MinutelyBySetPos, "ice_cube/validations/minutely_by_set_pos" + autoload :HourlyBySetPos, "ice_cube/validations/hourly_by_set_pos" + autoload :DailyBySetPos, "ice_cube/validations/daily_by_set_pos" autoload :WeeklyBySetPos, "ice_cube/validations/weekly_by_set_pos" autoload :MonthlyBySetPos, "ice_cube/validations/monthly_by_set_pos" autoload :YearlyBySetPos, "ice_cube/validations/yearly_by_set_pos" diff --git a/lib/ice_cube/rules/daily_rule.rb b/lib/ice_cube/rules/daily_rule.rb index ed5014f2..ab46c3d9 100644 --- a/lib/ice_cube/rules/daily_rule.rb +++ b/lib/ice_cube/rules/daily_rule.rb @@ -10,6 +10,7 @@ class DailyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::DailyInterval + include Validations::DailyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/hourly_rule.rb b/lib/ice_cube/rules/hourly_rule.rb index 1ce85d10..9d1c4757 100644 --- a/lib/ice_cube/rules/hourly_rule.rb +++ b/lib/ice_cube/rules/hourly_rule.rb @@ -10,6 +10,7 @@ class HourlyRule < ValidatedRule include Validations::DayOfYear include Validations::HourlyInterval + include Validations::HourlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/minutely_rule.rb b/lib/ice_cube/rules/minutely_rule.rb index 91fe9103..7bb2470f 100644 --- a/lib/ice_cube/rules/minutely_rule.rb +++ b/lib/ice_cube/rules/minutely_rule.rb @@ -10,6 +10,7 @@ class MinutelyRule < ValidatedRule include Validations::DayOfYear include Validations::MinutelyInterval + include Validations::MinutelyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/validations/daily_by_set_pos.rb b/lib/ice_cube/validations/daily_by_set_pos.rb new file mode 100644 index 00000000..6ccfc7c9 --- /dev/null +++ b/lib/ice_cube/validations/daily_by_set_pos.rb @@ -0,0 +1,82 @@ +module IceCube + module Validations::DailyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_day = TimeUtil.beginning_of_date(step_time, step_time) + end_of_day = TimeUtil.end_of_date(step_time, step_time) + + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) + end + + occurrences = new_schedule.occurrences_between(start_of_day, end_of_day) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder.validations_array(:by_set_pos) << by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/lib/ice_cube/validations/hourly_by_set_pos.rb b/lib/ice_cube/validations/hourly_by_set_pos.rb new file mode 100644 index 00000000..2a1c9839 --- /dev/null +++ b/lib/ice_cube/validations/hourly_by_set_pos.rb @@ -0,0 +1,85 @@ +module IceCube + module Validations::HourlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + # Use the smallest expanded unit so we don't skip intra-hour candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + :hour + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_hour = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, 0, 0], step_time + ) + end_of_hour = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, 59, 59], step_time + ) + + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) + end + + occurrences = new_schedule.occurrences_between(start_of_hour, end_of_hour) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder.validations_array(:by_set_pos) << by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/lib/ice_cube/validations/minutely_by_set_pos.rb b/lib/ice_cube/validations/minutely_by_set_pos.rb new file mode 100644 index 00000000..aec09f5b --- /dev/null +++ b/lib/ice_cube/validations/minutely_by_set_pos.rb @@ -0,0 +1,84 @@ +module IceCube + module Validations::MinutelyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + # Use the smallest expanded unit so we don't skip intra-minute candidates. + return :sec if rule.validations[:second_of_minute] + :min + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_minute = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time + ) + end_of_minute = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 59], step_time + ) + + # Use the schedule start_time to preserve implicit date/time anchors. + new_schedule = IceCube::Schedule.new(start_time) do |s| + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + # Avoid recursive BYSETPOS evaluation in the temporary schedule. + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) + end + + occurrences = new_schedule.occurrences_between(start_of_minute, end_of_minute) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder.validations_array(:by_set_pos) << by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index e2437a9d..02244919 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -253,4 +253,115 @@ module IceCube ]) end end + + describe DailyRule, "BYSETPOS" do + it "should work with hour expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=4;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,2,0,0), + Time.new(2023,1,2,2,0,0), + Time.new(2023,1,3,2,0,0), + Time.new(2023,1,4,2,0,0) + ]) + end + + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,3,0,0), + Time.new(2023,1,2,3,0,0), + Time.new(2023,1,3,3,0,0) + ]) + end + + it "should support multiple positions with minute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;BYMINUTE=10,20,30;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 03))). + to eq([ + Time.new(2023,1,1,12,10,0), + Time.new(2023,1,1,12,30,0), + Time.new(2023,1,2,12,10,0), + Time.new(2023,1,2,12,30,0) + ]) + end + end + + describe HourlyRule, "BYSETPOS" do + it "should work with minute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=4;BYMINUTE=10,20;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 5, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0), + Time.new(2023,1,1,1,20,0), + Time.new(2023,1,1,2,20,0), + Time.new(2023,1,1,3,20,0) + ]) + end + + it "should support negative positions with second expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYSECOND=5,10,15;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 34, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 4, 0, 0))). + to eq([ + Time.new(2023,1,1,0,34,15), + Time.new(2023,1,1,1,34,15), + Time.new(2023,1,1,2,34,15) + ]) + end + + it "should support multiple positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;BYMINUTE=5,10,15;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([ + Time.new(2023,1,1,0,5,0), + Time.new(2023,1,1,0,15,0), + Time.new(2023,1,1,1,5,0), + Time.new(2023,1,1,1,15,0) + ]) + end + end + + describe MinutelyRule, "BYSETPOS" do + it "should work with second expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=4;BYSECOND=5,10;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,0,10), + Time.new(2023,1,1,0,1,10), + Time.new(2023,1,1,0,2,10), + Time.new(2023,1,1,0,3,10) + ]) + end + + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 4, 0))). + to eq([ + Time.new(2023,1,1,0,0,3), + Time.new(2023,1,1,0,1,3), + Time.new(2023,1,1,0,2,3) + ]) + end + + it "should support multiple positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;BYSECOND=5,10,15;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 2, 0))). + to eq([ + Time.new(2023,1,1,0,0,5), + Time.new(2023,1,1,0,0,15), + Time.new(2023,1,1,0,1,5), + Time.new(2023,1,1,0,1,15) + ]) + end + end end From b222c4f6441af51f2efc64f721a2056eae3d479d Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 17:03:12 -0500 Subject: [PATCH 18/29] add verification that bysetpos runs after other byXXX filters --- lib/ice_cube/validated_rule.rb | 2 ++ spec/examples/by_set_pos_spec.rb | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index fec6d9e9..2167c509 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -19,6 +19,8 @@ class ValidatedRule < Rule :day_of_year, :second_of_minute, :minute_of_hour, :day_of_month, :hour_of_day, :month_of_year, :day_of_week, :interval, + # BYSETPOS selects the nth occurrence within the set after all other + # BYxxx filters/expansions are applied (RFC 5545), so it must run last. :by_set_pos ] diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index 02244919..c2438731 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -127,6 +127,18 @@ module IceCube ]) end + it "should apply after multiple BYxxx filters" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=MO,TU,WE,TH,FR;BYMONTHDAY=13,14,15;BYSETPOS=-1" + schedule.start_time = Time.new(2019, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2019, 01, 01), Time.new(2019, 05, 01))). + to eq([ + Time.new(2019,1,15,9,0,0), + Time.new(2019,2,15,9,0,0), + Time.new(2019,3,15,9,0,0), + Time.new(2019,4,15,9,0,0) + ]) + end + it "should work with byminute expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMINUTE=1,2;BYSETPOS=2" schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) @@ -267,6 +279,17 @@ module IceCube ]) end + it "should apply after multiple BYxxx expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=9,10;BYMINUTE=15,45;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,1,10,15,0), + Time.new(2023,1,2,10,15,0), + Time.new(2023,1,3,10,15,0) + ]) + end + it "should support negative positions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2,3;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -304,6 +327,17 @@ module IceCube ]) end + it "should apply after multiple BYxxx expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSECOND=5,50;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 4, 0, 0))). + to eq([ + Time.new(2023,1,1,0,10,50), + Time.new(2023,1,1,1,10,50), + Time.new(2023,1,1,2,10,50) + ]) + end + it "should support negative positions with second expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYSECOND=5,10,15;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 34, 0) @@ -341,6 +375,17 @@ module IceCube ]) end + it "should apply after BYxxx filters and expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYHOUR=1;BYSECOND=10,20,30;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 1, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 1, 5, 0))). + to eq([ + Time.new(2023,1,1,1,0,20), + Time.new(2023,1,1,1,1,20), + Time.new(2023,1,1,1,2,20) + ]) + end + it "should support negative positions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=1,2,3;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) From f72c21a7509cddfcd9d5862c82e7eb03a87ad053 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 17:13:54 -0500 Subject: [PATCH 19/29] BYSETPOS interval boundaries specs --- lib/ice_cube/validations/daily_by_set_pos.rb | 1 + lib/ice_cube/validations/hourly_by_set_pos.rb | 1 + .../validations/minutely_by_set_pos.rb | 1 + .../validations/monthly_by_set_pos.rb | 1 + lib/ice_cube/validations/weekly_by_set_pos.rb | 3 +- lib/ice_cube/validations/yearly_by_set_pos.rb | 1 + spec/examples/by_set_pos_spec.rb | 35 +++++++++++++++++++ 7 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/ice_cube/validations/daily_by_set_pos.rb b/lib/ice_cube/validations/daily_by_set_pos.rb index 6ccfc7c9..a172e434 100644 --- a/lib/ice_cube/validations/daily_by_set_pos.rb +++ b/lib/ice_cube/validations/daily_by_set_pos.rb @@ -34,6 +34,7 @@ def dst_adjust? end def validate(step_time, start_time) + # Define the current day window so BYSETPOS is applied per day. start_of_day = TimeUtil.beginning_of_date(step_time, step_time) end_of_day = TimeUtil.end_of_date(step_time, step_time) diff --git a/lib/ice_cube/validations/hourly_by_set_pos.rb b/lib/ice_cube/validations/hourly_by_set_pos.rb index 2a1c9839..b7977232 100644 --- a/lib/ice_cube/validations/hourly_by_set_pos.rb +++ b/lib/ice_cube/validations/hourly_by_set_pos.rb @@ -33,6 +33,7 @@ def dst_adjust? end def validate(step_time, start_time) + # Define the current hour window so BYSETPOS is applied per hour. start_of_hour = TimeUtil.build_in_zone( [step_time.year, step_time.month, step_time.day, step_time.hour, 0, 0], step_time ) diff --git a/lib/ice_cube/validations/minutely_by_set_pos.rb b/lib/ice_cube/validations/minutely_by_set_pos.rb index aec09f5b..edcce523 100644 --- a/lib/ice_cube/validations/minutely_by_set_pos.rb +++ b/lib/ice_cube/validations/minutely_by_set_pos.rb @@ -32,6 +32,7 @@ def dst_adjust? end def validate(step_time, start_time) + # Define the current minute window so BYSETPOS is applied per minute. start_of_minute = TimeUtil.build_in_zone( [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time ) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 4145cebd..7548987e 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -36,6 +36,7 @@ def dst_adjust? end def validate(step_time, start_time) + # Define the current month window so BYSETPOS is applied per month. start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) eom_date = Date.new(step_time.year, step_time.month, -1) end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index 186f26f0..f2d12fd6 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -35,7 +35,8 @@ def dst_adjust? end def validate(step_time, start_time) - # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes + # Define the week window using WKST so BYSETPOS is applied per week. + # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes. step_time_date = step_time.to_date start_day_of_week = TimeUtil.sym_to_wday(rule.week_start) step_time_day_of_week = step_time_date.wday diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 6b920ee9..47a243da 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -38,6 +38,7 @@ def dst_adjust? end def validate(step_time, start_time) + # Define the current year window so BYSETPOS is applied per year. start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index c2438731..e572e8a4 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -279,6 +279,17 @@ module IceCube ]) end + it "should respect day boundaries when starting late" do + # Ensures BYSETPOS grouping resets per day while preserving the start_time minute anchor. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=2;BYHOUR=1,2;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 23, 30, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,2,1,30,0), + Time.new(2023,1,3,1,30,0) + ]) + end + it "should apply after multiple BYxxx expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=9,10;BYMINUTE=15,45;BYSETPOS=3" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -327,6 +338,18 @@ module IceCube ]) end + it "should respect hour boundaries when starting late" do + # Ensures BYSETPOS grouping resets per hour, not from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 45, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 5, 0, 0))). + to eq([ + Time.new(2023,1,1,1,10,0), + Time.new(2023,1,1,2,10,0), + Time.new(2023,1,1,3,10,0) + ]) + end + it "should apply after multiple BYxxx expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSECOND=5,50;BYSETPOS=2" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -375,6 +398,18 @@ module IceCube ]) end + it "should respect minute boundaries when starting late" do + # Ensures BYSETPOS grouping resets per minute, not from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 45) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,1,10), + Time.new(2023,1,1,0,2,10), + Time.new(2023,1,1,0,3,10) + ]) + end + it "should apply after BYxxx filters and expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYHOUR=1;BYSECOND=10,20,30;BYSETPOS=2" schedule.start_time = Time.new(2023, 1, 1, 1, 0, 0) From d890737f3236fa29557b3b73a1682de2f9e48190 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Fri, 19 Dec 2025 17:20:49 -0500 Subject: [PATCH 20/29] dd BYSETPOS ordering specs --- lib/ice_cube/validations/daily_by_set_pos.rb | 2 ++ lib/ice_cube/validations/hourly_by_set_pos.rb | 2 ++ .../validations/minutely_by_set_pos.rb | 2 ++ .../validations/monthly_by_set_pos.rb | 2 ++ lib/ice_cube/validations/weekly_by_set_pos.rb | 2 ++ lib/ice_cube/validations/yearly_by_set_pos.rb | 2 ++ spec/examples/by_set_pos_spec.rb | 26 +++++++++++++++++++ 7 files changed, 38 insertions(+) diff --git a/lib/ice_cube/validations/daily_by_set_pos.rb b/lib/ice_cube/validations/daily_by_set_pos.rb index a172e434..217959a7 100644 --- a/lib/ice_cube/validations/daily_by_set_pos.rb +++ b/lib/ice_cube/validations/daily_by_set_pos.rb @@ -49,6 +49,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_day, end_of_day) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/hourly_by_set_pos.rb b/lib/ice_cube/validations/hourly_by_set_pos.rb index b7977232..9caf62c6 100644 --- a/lib/ice_cube/validations/hourly_by_set_pos.rb +++ b/lib/ice_cube/validations/hourly_by_set_pos.rb @@ -52,6 +52,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_hour, end_of_hour) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/minutely_by_set_pos.rb b/lib/ice_cube/validations/minutely_by_set_pos.rb index edcce523..371264b4 100644 --- a/lib/ice_cube/validations/minutely_by_set_pos.rb +++ b/lib/ice_cube/validations/minutely_by_set_pos.rb @@ -51,6 +51,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_minute, end_of_minute) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 7548987e..303ce6c1 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -52,6 +52,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) index = occurrences.index(step_time) if index == nil diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index f2d12fd6..f87f3293 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -62,6 +62,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index 47a243da..e87b35f8 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -53,6 +53,8 @@ def validate(step_time, start_time) s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) end + # Build the full candidate set for this interval without COUNT/UNTIL, + # then map the selected occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) index = occurrences.index(step_time) diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index e572e8a4..e5414b2f 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -301,6 +301,25 @@ module IceCube ]) end + it "should apply BYSETPOS before count" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=1;BYHOUR=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,1,3,0,0) + ]) + end + + it "should apply BYSETPOS before until" do + # If UNTIL were applied before BYSETPOS, the 02:00 occurrence would be selected. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.daily.hour_of_day(1, 2, 3).by_set_pos(-1).until(Time.new(2023, 1, 1, 2, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 02))). + to eq([]) + end + it "should support negative positions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2,3;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -361,6 +380,13 @@ module IceCube ]) end + it "should return no occurrences when BYSETPOS exceeds the set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=2;BYMINUTE=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([]) + end + it "should support negative positions with second expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYSECOND=5,10,15;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 34, 0) From 8368d70385292e79b460de3721cfffee9db0d8ed Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 07:55:19 -0500 Subject: [PATCH 21/29] add BYSETPOS anchor and interval specs --- spec/examples/by_set_pos_spec.rb | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index e5414b2f..ba62f105 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -163,6 +163,17 @@ module IceCube ]) end + it "should preserve implicit minute anchor with bysecond expansions" do + # BYSECOND should not reset the minute inherited from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=2;BYSECOND=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2015, 5, 28, 12, 30, 5) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 12, 01))). + to eq([ + Time.new(2015,5,28,12,30,10), + Time.new(2015,6,28,12,30,10) + ]) + end + it "should not consume counts across multiple rules" do start_time = Time.new(2019, 1, 1) rule_a = "FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" @@ -264,6 +275,17 @@ module IceCube Time.new(2017, 7, 5, 1, 0, 0) ]) end + + it "should preserve implicit minute/second anchor with byhour expansions" do + # BYHOUR should not reset the minute/second inherited from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2016, 7, 5, 0, 45, 30) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2018, 01, 01))). + to eq([ + Time.new(2016, 7, 5, 2, 45, 30), + Time.new(2017, 7, 5, 2, 45, 30) + ]) + end end describe DailyRule, "BYSETPOS" do @@ -279,6 +301,17 @@ module IceCube ]) end + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=3;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 8))). + to eq([ + Time.new(2023,1,1,2,0,0), + Time.new(2023,1,3,2,0,0), + Time.new(2023,1,5,2,0,0) + ]) + end + it "should respect day boundaries when starting late" do # Ensures BYSETPOS grouping resets per day while preserving the start_time minute anchor. schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=2;BYHOUR=1,2;BYSETPOS=1" @@ -357,6 +390,17 @@ module IceCube ]) end + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;INTERVAL=2;COUNT=3;BYMINUTE=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 6, 0, 0))). + to eq([ + Time.new(2023,1,1,0,10,0), + Time.new(2023,1,1,2,10,0), + Time.new(2023,1,1,4,10,0) + ]) + end + it "should respect hour boundaries when starting late" do # Ensures BYSETPOS grouping resets per hour, not from the schedule start_time. schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSETPOS=1" @@ -424,6 +468,17 @@ module IceCube ]) end + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;INTERVAL=5;COUNT=3;BYSECOND=10,20;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 20, 0))). + to eq([ + Time.new(2023,1,1,0,0,20), + Time.new(2023,1,1,0,5,20), + Time.new(2023,1,1,0,10,20) + ]) + end + it "should respect minute boundaries when starting late" do # Ensures BYSETPOS grouping resets per minute, not from the schedule start_time. schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=10,20;BYSETPOS=1" From cb377584bca0eee812618c8401a444130dbe9513 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 08:02:01 -0500 Subject: [PATCH 22/29] refactor and create BYSETPOS helper for interval bounds --- lib/ice_cube.rb | 1 + lib/ice_cube/validations/by_set_pos_helper.rb | 77 +++++++++++++++++++ lib/ice_cube/validations/daily_by_set_pos.rb | 22 ++---- lib/ice_cube/validations/hourly_by_set_pos.rb | 26 ++----- .../validations/minutely_by_set_pos.rb | 26 ++----- .../validations/monthly_by_set_pos.rb | 23 ++---- lib/ice_cube/validations/weekly_by_set_pos.rb | 34 ++------ lib/ice_cube/validations/yearly_by_set_pos.rb | 22 ++---- 8 files changed, 115 insertions(+), 116 deletions(-) create mode 100644 lib/ice_cube/validations/by_set_pos_helper.rb diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 19cdae81..1bf70cc4 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,7 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :BySetPosHelper, "ice_cube/validations/by_set_pos_helper" autoload :MinutelyBySetPos, "ice_cube/validations/minutely_by_set_pos" autoload :HourlyBySetPos, "ice_cube/validations/hourly_by_set_pos" autoload :DailyBySetPos, "ice_cube/validations/daily_by_set_pos" diff --git a/lib/ice_cube/validations/by_set_pos_helper.rb b/lib/ice_cube/validations/by_set_pos_helper.rb new file mode 100644 index 00000000..cde8763d --- /dev/null +++ b/lib/ice_cube/validations/by_set_pos_helper.rb @@ -0,0 +1,77 @@ +module IceCube + module Validations::BySetPosHelper + module_function + + def interval_bounds(interval_type, step_time, week_start: nil) + case interval_type + when :year + # Build a year window in the schedule's zone so BYSETPOS is applied + # per-year, matching RFC 5545 interval semantics. + [ + TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time), + TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) + ] + when :month + # Build a month window in the schedule's zone so BYSETPOS is applied + # per-month, preserving DST/zone handling. + start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) + eom_date = Date.new(step_time.year, step_time.month, -1) + end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) + [start_of_month, end_of_month] + when :week + raise ArgumentError, "week_start is required for weekly interval bounds" unless week_start + # Use Date arithmetic to avoid DST surprises, then rebuild in the schedule's zone. + # WKST drives the interval boundary per RFC 5545. + step_time_date = step_time.to_date + start_day_of_week = TimeUtil.sym_to_wday(week_start) + step_time_day_of_week = step_time_date.wday + days_delta = step_time_day_of_week - start_day_of_week + days_to_start = days_delta >= 0 ? days_delta : 7 + days_delta + start_of_week_date = step_time_date - days_to_start + end_of_week_date = start_of_week_date + 6 + [ + TimeUtil.build_in_zone([start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time), + TimeUtil.build_in_zone([end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time) + ] + when :day + # Build a day window in the schedule's zone so BYSETPOS is applied + # per-day (important for day-level grouping). + [ + TimeUtil.beginning_of_date(step_time, step_time), + TimeUtil.end_of_date(step_time, step_time) + ] + when :hour + # Build an hour window in the schedule's zone so BYSETPOS is applied + # per-hour (sub-day grouping stays intact). + [ + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, 0, 0], step_time), + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, 59, 59], step_time) + ] + when :min + # Build a minute window in the schedule's zone so BYSETPOS is applied + # per-minute (sub-hour grouping stays intact). + [ + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time), + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 59], step_time) + ] + else + raise ArgumentError, "Unsupported interval type: #{interval_type}" + end + end + + def build_filtered_schedule(rule, start_time) + # Strip BYSETPOS/COUNT/UNTIL so the candidate set is complete, and avoid + # recursive BYSETPOS evaluation when we rebuild the temporary rule. + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end + + # Use the schedule start_time to preserve implicit anchors like minute/second. + IceCube::Schedule.new(start_time) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) + end + end + end +end diff --git a/lib/ice_cube/validations/daily_by_set_pos.rb b/lib/ice_cube/validations/daily_by_set_pos.rb index 217959a7..170810f3 100644 --- a/lib/ice_cube/validations/daily_by_set_pos.rb +++ b/lib/ice_cube/validations/daily_by_set_pos.rb @@ -34,23 +34,13 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the current day window so BYSETPOS is applied per day. - start_of_day = TimeUtil.beginning_of_date(step_time, step_time) - end_of_day = TimeUtil.end_of_date(step_time, step_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_day, end_of_day = Validations::BySetPosHelper.interval_bounds(:day, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end - - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_day, end_of_day) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/hourly_by_set_pos.rb b/lib/ice_cube/validations/hourly_by_set_pos.rb index 9caf62c6..1cf90c37 100644 --- a/lib/ice_cube/validations/hourly_by_set_pos.rb +++ b/lib/ice_cube/validations/hourly_by_set_pos.rb @@ -33,27 +33,13 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the current hour window so BYSETPOS is applied per hour. - start_of_hour = TimeUtil.build_in_zone( - [step_time.year, step_time.month, step_time.day, step_time.hour, 0, 0], step_time - ) - end_of_hour = TimeUtil.build_in_zone( - [step_time.year, step_time.month, step_time.day, step_time.hour, 59, 59], step_time - ) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_hour, end_of_hour = Validations::BySetPosHelper.interval_bounds(:hour, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end - - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_hour, end_of_hour) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/minutely_by_set_pos.rb b/lib/ice_cube/validations/minutely_by_set_pos.rb index 371264b4..8e36fb55 100644 --- a/lib/ice_cube/validations/minutely_by_set_pos.rb +++ b/lib/ice_cube/validations/minutely_by_set_pos.rb @@ -32,27 +32,13 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the current minute window so BYSETPOS is applied per minute. - start_of_minute = TimeUtil.build_in_zone( - [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time - ) - end_of_minute = TimeUtil.build_in_zone( - [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 59], step_time - ) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_minute, end_of_minute = Validations::BySetPosHelper.interval_bounds(:min, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end - - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_minute, end_of_minute) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb index 303ce6c1..45a08b7a 100644 --- a/lib/ice_cube/validations/monthly_by_set_pos.rb +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -36,24 +36,13 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the current month window so BYSETPOS is applied per month. - start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) - eom_date = Date.new(step_time.year, step_time.month, -1) - end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) - - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_month, end_of_month = Validations::BySetPosHelper.interval_bounds(:month, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) index = occurrences.index(step_time) if index == nil diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb index f87f3293..8a553fc9 100644 --- a/lib/ice_cube/validations/weekly_by_set_pos.rb +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -35,35 +35,15 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the week window using WKST so BYSETPOS is applied per week. - # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes. - step_time_date = step_time.to_date - start_day_of_week = TimeUtil.sym_to_wday(rule.week_start) - step_time_day_of_week = step_time_date.wday - days_delta = step_time_day_of_week - start_day_of_week - days_to_start = days_delta >= 0 ? days_delta : 7 + days_delta - start_of_week_date = step_time_date - days_to_start - end_of_week_date = start_of_week_date + 6 - start_of_week = IceCube::TimeUtil.build_in_zone( - [start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_week, end_of_week = Validations::BySetPosHelper.interval_bounds( + :week, step_time, week_start: rule.week_start ) - end_of_week = IceCube::TimeUtil.build_in_zone( - [end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time - ) - - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) index = occurrences.index(step_time) if index.nil? diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb index e87b35f8..85bb4a71 100644 --- a/lib/ice_cube/validations/yearly_by_set_pos.rb +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -38,23 +38,13 @@ def dst_adjust? end def validate(step_time, start_time) - # Define the current year window so BYSETPOS is applied per year. - start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) - end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) - - # Use the schedule start_time to preserve implicit date/time anchors. - new_schedule = IceCube::Schedule.new(start_time) do |s| - filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } - # Avoid recursive BYSETPOS evaluation in the temporary schedule. - if filtered_hash[:validations] - filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } - filtered_hash.delete(:validations) if filtered_hash[:validations].empty? - end - s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) - end + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_year, end_of_year = Validations::BySetPosHelper.interval_bounds(:year, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) - # Build the full candidate set for this interval without COUNT/UNTIL, - # then map the selected occurrence to positive/negative positions. + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) index = occurrences.index(step_time) From 491c6043a530c72068ba77d35c841e67548ea2f7 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 08:27:31 -0500 Subject: [PATCH 23/29] adding more comprehensive BYSETPOS specs This covers repeated values, out-of-range, and UNTIL cases --- spec/examples/by_set_pos_spec.rb | 170 +++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index ba62f105..dd791bf2 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -77,6 +77,35 @@ module IceCube Time.new(2023,1,23,2,0,0) ]) end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE;BYSETPOS=1,1") + schedule.start_time = Time.new(2023, 1, 2, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,16,9,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=MO,WE;BYSETPOS=3") + schedule.start_time = Time.new(2023, 1, 2, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the interval. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 2, 9, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.weekly.day(:monday, :wednesday).by_set_pos(-1).until(Time.new(2023, 1, 3, 9, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([]) + end end describe MonthlyRule, "BYSETPOS" do @@ -127,6 +156,47 @@ module IceCube ]) end + it "should support multiple positions within the month" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=1,15,30;BYSETPOS=1,2" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 8, 1))). + to eq([ + Time.new(2015,5,1,12,0,0), + Time.new(2015,5,15,12,0,0), + Time.new(2015,6,1,12,0,0), + Time.new(2015,6,15,12,0,0) + ]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=3;BYMONTHDAY=10,20;BYSETPOS=1,1" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([ + Time.new(2015,5,10,12,0,0), + Time.new(2015,6,10,12,0,0), + Time.new(2015,7,10,12,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=2;BYMONTHDAY=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the month. + schedule = IceCube::Schedule.new(Time.new(2015, 5, 1, 12, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.monthly.day_of_month(10, 20).by_set_pos(2).until(Time.new(2015, 5, 15, 12, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([]) + end + it "should apply after multiple BYxxx filters" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=MO,TU,WE,TH,FR;BYMONTHDAY=13,14,15;BYSETPOS=-1" schedule.start_time = Time.new(2019, 1, 1, 9, 0, 0) @@ -286,6 +356,34 @@ module IceCube Time.new(2017, 7, 5, 2, 45, 30) ]) end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYMONTHDAY=1,2;BYSETPOS=1,1" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015, 7, 1), + Time.new(2016, 7, 1) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYMONTHDAY=1,2;BYSETPOS=3" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the year. + schedule = IceCube::Schedule.new(Time.new(2015, 1, 1)) + schedule.add_recurrence_rule( + IceCube::Rule.yearly.month_of_year(7).day_of_month(1, 2).by_set_pos(2).until(Time.new(2015, 7, 1)) + ) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2016, 01, 01))). + to eq([]) + end end describe DailyRule, "BYSETPOS" do @@ -353,6 +451,25 @@ module IceCube to eq([]) end + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2;BYSETPOS=1,1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,1,0,0), + Time.new(2023,1,2,1,0,0), + Time.new(2023,1,3,1,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=2;BYHOUR=1,2;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([]) + end + it "should support negative positions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2,3;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -431,6 +548,29 @@ module IceCube to eq([]) end + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=2;BYMINUTE=10,20;BYSETPOS=2,2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 3, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0), + Time.new(2023,1,1,1,20,0) + ]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the hour. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.hourly.minute_of_hour(10, 20).by_set_pos(2).until(Time.new(2023, 1, 1, 0, 25, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0) + ]) + end + it "should support negative positions with second expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYSECOND=5,10,15;BYSETPOS=-1" schedule.start_time = Time.new(2023, 1, 1, 0, 34, 0) @@ -479,6 +619,36 @@ module IceCube ]) end + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=2;BYSECOND=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=2;BYSECOND=10,20;BYSETPOS=1,1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,0,10), + Time.new(2023,1,1,0,1,10) + ]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the minute. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.minutely.second_of_minute(10, 20).by_set_pos(2).until(Time.new(2023, 1, 1, 0, 0, 25)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 2, 0))). + to eq([ + Time.new(2023,1,1,0,0,20) + ]) + end + it "should respect minute boundaries when starting late" do # Ensures BYSETPOS grouping resets per minute, not from the schedule start_time. schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=10,20;BYSETPOS=1" From 467ddc49db7b30ff76baf3392184a7a56525b6d3 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 08:30:53 -0500 Subject: [PATCH 24/29] updating readme with bysetpos support --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 0b4aef7f..b7a9f7bb 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,28 @@ schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(:march) schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(3) ``` +### BYSETPOS (select the nth occurrence) + +BYSETPOS selects the nth occurrence within each interval after all other BYxxx +filters/expansions are applied. Use positive values (from the start) or +negative values (from the end). Repeated values do not duplicate occurrences, +and positions beyond the set size yield no occurrence for that interval. + +```ruby +# last weekday of the month +schedule.add_recurrence_rule( + IceCube::Rule.monthly.day(:monday, :tuesday, :wednesday, :thursday, :friday).by_set_pos(-1) +) + +# second occurrence in each day's expanded set +schedule.add_recurrence_rule( + IceCube::Rule.daily.hour_of_day(9, 17).by_set_pos(2) +) +``` + +Note: If you expand with BYHOUR/BYMINUTE/BYSECOND, any unspecified smaller +time components are inherited from the schedule's start_time. + ### Hourly (by hour of day) ```ruby From 08c9b5c0e3a456ca429ba5eb2b2b1928ae0655a5 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 08:33:19 -0500 Subject: [PATCH 25/29] adding BYSETPOS to_ical spec coverage --- spec/examples/to_ical_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 39dd5209..059f134c 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -78,6 +78,14 @@ expect(rule.to_ical).to eq("FREQ=DAILY;BYSECOND=0,15,30,45") end + it "should be able to serialize a .by_set_pos rule to_ical" do + rule = IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos(-1, 1) + ical = rule.to_ical + expect(ical).to include("FREQ=MONTHLY") + expect(ical).to include("BYDAY=MO,WE") + expect(ical).to include("BYSETPOS=-1,1") + end + it "should be able to collapse a combination day_of_week and day" do rule = IceCube::Rule.daily.day(:monday, :tuesday).day_of_week(monday: [1, -1]) expect(["FREQ=DAILY;BYDAY=TU,1MO,-1MO", "FREQ=DAILY;BYDAY=1MO,-1MO,TU"].include?(rule.to_ical)).to be_truthy From 500bec1e98326cdc52bb188bc709add901540068 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 11:10:28 -0500 Subject: [PATCH 26/29] adding SECONDLY BYSETPOS support and specs --- README.md | 3 + lib/ice_cube.rb | 1 + lib/ice_cube/rules/secondly_rule.rb | 1 + lib/ice_cube/validations/by_set_pos_helper.rb | 7 ++ .../validations/secondly_by_set_pos.rb | 72 +++++++++++++++++++ spec/examples/by_set_pos_spec.rb | 48 +++++++++++++ spec/examples/to_ical_spec.rb | 7 ++ 7 files changed, 139 insertions(+) create mode 100644 lib/ice_cube/validations/secondly_by_set_pos.rb diff --git a/README.md b/README.md index b7a9f7bb..4818fe62 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,9 @@ BYSETPOS selects the nth occurrence within each interval after all other BYxxx filters/expansions are applied. Use positive values (from the start) or negative values (from the end). Repeated values do not duplicate occurrences, and positions beyond the set size yield no occurrence for that interval. +RFC 5545 requires BYSETPOS to be used with another BYxxx rule part; IceCube +allows BYSETPOS without another BYxxx and applies it to the single occurrence +in each interval. ```ruby # last weekday of the month diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 1bf70cc4..bf71d7d6 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -51,6 +51,7 @@ module Validations autoload :HourlyInterval, "ice_cube/validations/hourly_interval" autoload :BySetPosHelper, "ice_cube/validations/by_set_pos_helper" + autoload :SecondlyBySetPos, "ice_cube/validations/secondly_by_set_pos" autoload :MinutelyBySetPos, "ice_cube/validations/minutely_by_set_pos" autoload :HourlyBySetPos, "ice_cube/validations/hourly_by_set_pos" autoload :DailyBySetPos, "ice_cube/validations/daily_by_set_pos" diff --git a/lib/ice_cube/rules/secondly_rule.rb b/lib/ice_cube/rules/secondly_rule.rb index 5b32e61a..b988ebd8 100644 --- a/lib/ice_cube/rules/secondly_rule.rb +++ b/lib/ice_cube/rules/secondly_rule.rb @@ -10,6 +10,7 @@ class SecondlyRule < ValidatedRule include Validations::DayOfYear include Validations::SecondlyInterval + include Validations::SecondlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/validations/by_set_pos_helper.rb b/lib/ice_cube/validations/by_set_pos_helper.rb index cde8763d..09125fa2 100644 --- a/lib/ice_cube/validations/by_set_pos_helper.rb +++ b/lib/ice_cube/validations/by_set_pos_helper.rb @@ -54,6 +54,13 @@ def interval_bounds(interval_type, step_time, week_start: nil) TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time), TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 59], step_time) ] + when :sec + # Build a second window in the schedule's zone so BYSETPOS is applied + # per-second (the set size is typically 1). + second = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, step_time.sec], step_time + ) + [second, second] else raise ArgumentError, "Unsupported interval type: #{interval_type}" end diff --git a/lib/ice_cube/validations/secondly_by_set_pos.rb b/lib/ice_cube/validations/secondly_by_set_pos.rb new file mode 100644 index 00000000..70c84f9a --- /dev/null +++ b/lib/ice_cube/validations/secondly_by_set_pos.rb @@ -0,0 +1,72 @@ +module IceCube + module Validations::SecondlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + # There are no smaller units than seconds to expand. + :sec + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_second, end_of_second = Validations::BySetPosHelper.interval_bounds(:sec, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + occurrences = new_schedule.occurrences_between(start_of_second, end_of_second) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder.validations_array(:by_set_pos) << by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index dd791bf2..679aa850 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -421,6 +421,22 @@ module IceCube ]) end + it "should ignore nonexistent local times in the BYSETPOS set", system_time_zone: "America/New_York" do + # DST spring-forward skips 2:00 AM; the invalid time must not be counted. + start_time = Time.local(2019, 3, 10, 0, 0, 0) + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule( + IceCube::Rule.daily.count(1).hour_of_day(1, 2, 3).by_set_pos(2) + ) + occurrences = schedule.occurrences_between( + Time.local(2019, 3, 10, 0, 0, 0), + Time.local(2019, 3, 11, 0, 0, 0) + ) + expect(occurrences).to eq([ + Time.local(2019,3,10,3,0,0) + ]) + end + it "should apply after multiple BYxxx expansions" do schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=9,10;BYMINUTE=15,45;BYSETPOS=3" schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) @@ -695,4 +711,36 @@ module IceCube ]) end end + + describe SecondlyRule, "BYSETPOS" do + it "should allow BYSETPOS without other BYxxx parts" do + # RFC requires another BYxxx, but IceCube permits this for convenience. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;COUNT=3;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([ + Time.new(2023,1,1,0,0,0), + Time.new(2023,1,1,0,0,1), + Time.new(2023,1,1,0,0,2) + ]) + end + + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;INTERVAL=2;COUNT=3;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([ + Time.new(2023,1,1,0,0,0), + Time.new(2023,1,1,0,0,2), + Time.new(2023,1,1,0,0,4) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;COUNT=2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([]) + end + end end diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 059f134c..4cd7365a 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -86,6 +86,13 @@ expect(ical).to include("BYSETPOS=-1,1") end + it "should be able to serialize a secondly BYSETPOS rule to_ical" do + rule = IceCube::Rule.secondly.by_set_pos(1) + ical = rule.to_ical + expect(ical).to include("FREQ=SECONDLY") + expect(ical).to include("BYSETPOS=1") + end + it "should be able to collapse a combination day_of_week and day" do rule = IceCube::Rule.daily.day(:monday, :tuesday).day_of_week(monday: [1, -1]) expect(["FREQ=DAILY;BYDAY=TU,1MO,-1MO", "FREQ=DAILY;BYDAY=1MO,-1MO,TU"].include?(rule.to_ical)).to be_truthy From 851109dc6201633ae5d696a058a0949d4df2ad9a Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 11:24:49 -0500 Subject: [PATCH 27/29] adding BYYEARDAY BYSETPOS specs --- spec/examples/by_set_pos_spec.rb | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb index 679aa850..d735460a 100644 --- a/spec/examples/by_set_pos_spec.rb +++ b/spec/examples/by_set_pos_spec.rb @@ -384,6 +384,42 @@ module IceCube expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2016, 01, 01))). to eq([]) end + + it "should select positive positions within a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=2" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([ + Time.new(2015, 1, 10, 9, 0, 0), + Time.new(2016, 1, 10, 9, 0, 0) + ]) + end + + it "should select negative positions within a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=-1" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([ + Time.new(2015, 1, 20, 9, 0, 0), + Time.new(2016, 1, 20, 9, 0, 0) + ]) + end + + it "should return empty when BYSETPOS exceeds a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=4" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([]) + end + + it "should apply BYSETPOS before COUNT and UNTIL for BYYEARDAY" do + schedule = IceCube::Schedule.new(Time.new(2015, 1, 1, 9, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.yearly.day_of_year(1, 10, 20).by_set_pos(2).count(2).until(Time.new(2015, 1, 5)) + ) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([]) + end end describe DailyRule, "BYSETPOS" do From 6f5d6c0d7d405561325c43128fc6a677037d14c2 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 12:16:13 -0500 Subject: [PATCH 28/29] Gemfile adjustments for Ruby stdlib changes --- Gemfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Gemfile b/Gemfile index 05bfbe32..36edd3ef 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,10 @@ compatible_rails_versions = [ gem "activesupport", (ENV["RAILS_VERSION"] || compatible_rails_versions), require: false gem "i18n", require: false gem "tzinfo", require: false # only needed explicitly for RAILS_VERSION=3 + +gem "base64", require: false # remove base64 deprecation warnings for Ruby 3.3+ +gem "bigdecimal", require: false # remove bigdecimal deprecation warnings for Ruby 3.3+ +gem "mutex_m", require: false # ActiveSupport dependency on Ruby 3.4+ +gem "ostruct", require: false # remove ostruct deprecation warnings for Ruby 3.4+ +gem "logger", require: false # remove logger deprecation warnings for Ruby 3.4+ +gem "benchmark", require: false # remove benchmark deprecation warnings for Ruby 3.4+ From 86bdabd67cd1f3385fd4ca4d9d709b266cf0be95 Mon Sep 17 00:00:00 2001 From: Nathan Ehresman Date: Sat, 20 Dec 2025 12:17:42 -0500 Subject: [PATCH 29/29] adding support for more versions of ActiveSupport --- lib/ice_cube/occurrence.rb | 20 +++++++++++++++----- spec/examples/active_support_spec.rb | 1 + spec/examples/occurrence_spec.rb | 19 +++++++++++++------ spec/examples/to_ical_spec.rb | 1 + 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/ice_cube/occurrence.rb b/lib/ice_cube/occurrence.rb index 3892f6c6..76b036ab 100644 --- a/lib/ice_cube/occurrence.rb +++ b/lib/ice_cube/occurrence.rb @@ -85,11 +85,8 @@ def to_time # time formats and is only used when ActiveSupport is available. # def to_s(format = nil) - if format && to_time.public_method(:to_s).arity != 0 - t0, t1 = start_time.to_s(format), end_time.to_s(format) - else - t0, t1 = start_time.to_s, end_time.to_s - end + t0 = format_time(start_time, format) + t1 = format_time(end_time, format) (duration > 0) ? "#{t0} - #{t1}" : t0 end @@ -98,5 +95,18 @@ def overnight? midnight = Time.new(offset.year, offset.month, offset.day) midnight < end_time end + + private + + # Normalize formatted output across ActiveSupport versions: + # Rails 7.1+ prefers to_fs, older versions use to_formatted_s or to_s(:format). + def format_time(time, format) + return time.to_s unless format + return time.to_fs(format) if time.respond_to?(:to_fs) + return time.to_formatted_s(format) if time.respond_to?(:to_formatted_s) + return time.to_s(format) if time.public_method(:to_s).arity != 0 + + time.to_s + end end end diff --git a/spec/examples/active_support_spec.rb b/spec/examples/active_support_spec.rb index 51973415..2a3f2b97 100644 --- a/spec/examples/active_support_spec.rb +++ b/spec/examples/active_support_spec.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + "/../spec_helper" +require "logger" require "active_support" require "active_support/time" require "active_support/version" diff --git a/spec/examples/occurrence_spec.rb b/spec/examples/occurrence_spec.rb index 943c5ebb..634a2fa2 100644 --- a/spec/examples/occurrence_spec.rb +++ b/spec/examples/occurrence_spec.rb @@ -28,12 +28,19 @@ time_now = Time.current occurrence = Occurrence.new(time_now) - # From Rails 7.1 onwards, support for format options was removed - if time_now.public_method(:to_s).arity != 0 - expect(occurrence.to_s(:short)).to eq time_now.to_s(:short) - else - expect(occurrence.to_s(:short)).to eq time_now.to_s - end + # Match ActiveSupport formatting behavior across versions. + expected = + if time_now.respond_to?(:to_fs) + time_now.to_fs(:short) + elsif time_now.respond_to?(:to_formatted_s) + time_now.to_formatted_s(:short) + elsif time_now.public_method(:to_s).arity != 0 + time_now.to_s(:short) + else + time_now.to_s + end + + expect(occurrence.to_s(:short)).to eq expected end end diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 4cd7365a..c60d8205 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + "/../spec_helper" +require "logger" require "active_support" require "active_support/time"