Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2edcd21
Support BYSETPOS for MONTHLY AND YEARLY freq
Aug 23, 2016
13f14c9
Modernize BYSETPOS commit
nehresma Jul 30, 2018
4906b83
address the spec DST sensitivity in .to_yaml round trips
nehresma Dec 12, 2022
c25ace8
update PR from feedback
nehresma Dec 12, 2022
53c3280
excluding until, not util
nehresma Dec 21, 2022
6173d9b
remove no longer needed TimeUtil active_support require
nehresma Dec 23, 2022
6edd7ce
fix interval use with bysetpos
nehresma Dec 23, 2022
7033771
remove unneeded use of activesupport for date arithmetic
nehresma Dec 27, 2022
1816031
support for bysetpos with freq=weekly
nehresma Dec 27, 2022
54d4dcb
support for parsing rrules from ical that are very long and wrap
nehresma Jan 31, 2023
10d6286
dont require the wrapped line to be the last the ical string
nehresma Jan 31, 2023
2ed5fb2
nitpick fixes - use map and double quotes for consistency
nehresma Dec 19, 2025
c8d5755
do not rely on ActiveSupport-only helper methods
nehresma Dec 19, 2025
e1cb65a
fix BYSETPOS serialization
nehresma Dec 19, 2025
a57638b
fix SETBYPOS with non BYDAY expansions
nehresma Dec 19, 2025
af2df29
expand BYSETPOS spec coverage
nehresma Dec 19, 2025
8b53c4d
adding BYSETPOS validations for daily/hourly/minutely
nehresma Dec 19, 2025
b222c4f
add verification that bysetpos runs after other byXXX filters
nehresma Dec 19, 2025
f72c21a
BYSETPOS interval boundaries specs
nehresma Dec 19, 2025
d890737
dd BYSETPOS ordering specs
nehresma Dec 19, 2025
8368d70
add BYSETPOS anchor and interval specs
nehresma Dec 20, 2025
cb37758
refactor and create BYSETPOS helper for interval bounds
nehresma Dec 20, 2025
491c604
adding more comprehensive BYSETPOS specs
nehresma Dec 20, 2025
467ddc4
updating readme with bysetpos support
nehresma Dec 20, 2025
08c9b5c
adding BYSETPOS to_ical spec coverage
nehresma Dec 20, 2025
500bec1
adding SECONDLY BYSETPOS support and specs
nehresma Dec 20, 2025
851109d
adding BYYEARDAY BYSETPOS specs
nehresma Dec 20, 2025
6f5d6c0
Gemfile adjustments for Ruby stdlib changes
nehresma Dec 20, 2025
86bdabd
adding support for more versions of ActiveSupport
nehresma Dec 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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+
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,31 @@ 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.
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
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
Expand Down
9 changes: 9 additions & 0 deletions lib/ice_cube.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ 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 :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"
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"
autoload :MinuteOfHour, "ice_cube/validations/minute_of_hour"
Expand Down
20 changes: 15 additions & 5 deletions lib/ice_cube/occurrence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
13 changes: 12 additions & 1 deletion lib/ice_cube/parsers/ical_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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].+/
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
Expand Down Expand Up @@ -75,7 +86,7 @@ def self.rule_from_ical(ical)
when "BYYEARDAY"
validations[:day_of_year] = value.split(",").map(&:to_i)
when "BYSETPOS"
# noop
validations[:by_set_pos] = value.split(",").map(&:to_i)
else
validations[name] = nil # invalid type
end
Expand Down
4 changes: 2 additions & 2 deletions lib/ice_cube/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/daily_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class DailyRule < ValidatedRule
# include Validations::DayOfYear # n/a

include Validations::DailyInterval
include Validations::DailyBySetPos

def initialize(interval = 1)
super
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/hourly_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class HourlyRule < ValidatedRule
include Validations::DayOfYear

include Validations::HourlyInterval
include Validations::HourlyBySetPos

def initialize(interval = 1)
super
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/minutely_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class MinutelyRule < ValidatedRule
include Validations::DayOfYear

include Validations::MinutelyInterval
include Validations::MinutelyBySetPos

def initialize(interval = 1)
super
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/monthly_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class MonthlyRule < ValidatedRule
# include Validations::DayOfYear # n/a

include Validations::MonthlyInterval
include Validations::MonthlyBySetPos

def initialize(interval = 1)
super
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/secondly_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class SecondlyRule < ValidatedRule
include Validations::DayOfYear

include Validations::SecondlyInterval
include Validations::SecondlyBySetPos

def initialize(interval = 1)
super
Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/weekly_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class WeeklyRule < ValidatedRule
# include Validations::DayOfYear # n/a

include Validations::WeeklyInterval
include Validations::WeeklyBySetPos

attr_reader :week_start

Expand Down
1 change: 1 addition & 0 deletions lib/ice_cube/rules/yearly_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class YearlyRule < ValidatedRule
include Validations::DayOfYear

include Validations::YearlyInterval
include Validations::YearlyBySetPos

def initialize(interval = 1)
super
Expand Down
17 changes: 13 additions & 4 deletions lib/ice_cube/schedule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/ice_cube/single_occurrence_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions lib/ice_cube/validated_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ 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,
# 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
]

attr_reader :validations
Expand All @@ -44,17 +47,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
Expand Down
84 changes: 84 additions & 0 deletions lib/ice_cube/validations/by_set_pos_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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)
]
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
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
Loading