Skip to content

Conversation

@nehresma
Copy link

In August 2016, Nicolas Marlier added BYSETPOS support (#349). This PR updates that PR with a few small changes to run in modern Ruby (use Integer instead of Fixnum) and a more modern rspec.

@nehresma
Copy link
Author

Hat tip to @NicolasMarlier for doing this.

@davidstosik
Copy link

I can confirm this works as expected, this is fantastic! 👍

Copy link

@davidstosik davidstosik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left comments to see if we can improve the code of this PR.
I'll try implementing them myself and link a diff here a bit later.

.gitignore Outdated

# rubymine
.idea

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that necessary? It looks like an accidental addition that is not part of this PR.


# Use Activesupport CoreExt functions to manipulate time
def self.start_of_month time
time.beginning_of_month

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse the same name?


# Use Activesupport CoreExt functions to manipulate time
def self.start_of_year time
time.beginning_of_year

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse the same name?


# Use Activesupport CoreExt functions to manipulate time
def self.previous_month time
time - 1.month

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think time.last_month exists?


# Use Activesupport CoreExt functions to manipulate time
def self.previous_year time
time - 1.year

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think time.last_year exists?

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}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation seems unnecessary as well. by_set_pos is always an Array.

end
by_set_pos.flatten!
by_set_pos.each do |set_pos|
unless (set_pos >= -366 && set_pos <= -1) ||

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about (-366..366).include(set_post) && set_pos != 0?

end

@by_set_pos = by_set_pos
replace_validations_for(:by_set_pos, by_set_pos && [Validation.new(by_set_pos, self)])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, by_set_pos cannot be nil or false here (it's an Array), in which case by_set_pos && [Validation.new(by_set_pos, self)] is equivalent to its right-side operand, [Validation.new(by_set_pos, self)].

@nehresma
Copy link
Author

nehresma commented Jan 4, 2019

Hello @davidstosik, @NicolasMarlier provided this original PR. I simply updated it to work with modern versions of Ruby.



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})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With ActiveSupport, this reject construct simplifies to except(:by_set_pos, :count, :util)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but ActiveSupport isn't a runtime requirement of this gem.



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})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, use Hash#except

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),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say just breaking .to onto a newline is good enough

joshbeckman added a commit to officeluv/ice_cube that referenced this pull request Feb 5, 2019
In August 2016, @NicolasMarlier added BYSETPOS support (ice-cube-ruby#349).

Then, in July 2018, @nehresma added a few small changes to run in modern
Ruby and a more modern rspec
(ice-cube-ruby#449).

Then, in January 2019, @davidstosik and @k3rni suggested changes to
reduce complexity.

This incorporates all the above into a single diff.
@dimerman
Copy link
Contributor

@nehresma what's the status of this PR? I'd very much like to use the setpos functionality.

@nehresma
Copy link
Author

@dimerman It has been merged but I do not believe a new version of the gem has been released. I've pointed my Gemfile to the latest git ref on master for the time being. Hopefully someone will have a few minutes to cut a new gem release at some point so we wont have to point to specific refs.

@nehresma
Copy link
Author

@dimerman Err, no -- I'm an idiot. I just realized it has NOT been merged. I checked my Gemfile and I'm pointing to the ref with BYSETPOS support out on my fork -- not master on seejohnrun/ice_cube.

Sorry for the confusion. I should have double checked before trusting my memory.

@dimerman
Copy link
Contributor

dimerman commented Mar 23, 2019

@nehresma no worries - I've been pointing my gemfile to your branch/repo. thanks for that!

However, I found the following case is not working right:

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"

when I create a IceCube::Schedule and add each rule individually, the occurrences are accurate. But if I create a schedule with both these rules, I get 18 occurrences instead of the expected 24.
I thought you might want to know. 🙌

@mattbooks
Copy link

What's still holding this back — the PR feedback seems pretty minor? If that gets cleaned up can we get this merged?

@mattbooks
Copy link

@seejohnrun It seems like several people need this and are using forks to get around it being missing. I'd be more than happy to help clean this up to avoid using a fork myself if it is otherwise merge-able.



def build_s(builder)
builder.piece(:by_set_pos) << by_set_pos
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @nehresma

Looks like this should be improved:

> IceCube::Rule.monthly(2).day(1,2,3,4,5).by_set_pos(2).to_s
"Every 2 months on Weekdays [2]"

@pacso
Copy link
Collaborator

pacso commented Oct 21, 2021

@nehresma / @mattbooks / @ovinix - If you can get this PR cleaned up and resolve the feedback and conflicts with master, I'll give it a proper review.

@Naren1997
Copy link

Naren1997 commented Mar 6, 2022

Hello, looks like the gem IS already supporting what BYTSETPOS does but in different ical format. Example: If we want recurring for every 2 months 3rd Wednesday, ical format w.r.t the BYSETPOS is FREQ=MONTHLY;INTERVAL=2;BYDAY=WE;BYSETPOS=3 which is same as FREQ=MONTHLY;INTERVAL=2;BYDAY=3WE, the latter one is already supported by the gem, so not sure why we still wants this PR.

@davidstosik
Copy link

not sure why we still wants this PR

I don’t use the gem anymore, but you might want to consider that some people use the gem to parse strings produced by another piece of software they don’t have control on, and this would require the whole specification to be supported. 😉

@jhubert
Copy link

jhubert commented May 13, 2022

@avit Anything we can do to help get this merged?

@dimerman
Copy link
Contributor

@davidstosik what gem do you use instead?

@davidstosik
Copy link

davidstosik commented May 19, 2022

Nothing. 😬
My need to handle repeating schedules in Ruby has vanished. 🙇🏻‍♂️

@nehresma
Copy link
Author

It's been over 4 years since I created this PR and over 7 years since Nicolas Marlier's initial commit with this support. I have rebased it against current master and I've applied changes per the feedback received above.

If someone has time to review and merge, that would be most appreciated. BYSETPOS is part of RFC 5545 and it would be great if this gem could finally get support merged in rather than just the "noop" that it currently is (https://github.com/ice-cube-ruby/ice_cube/blob/master/lib/ice_cube/parsers/ical_parser.rb#L77-L78).

@pacso
Copy link
Collaborator

pacso commented Dec 22, 2022

I'll take a look. Am going to try to dedicate sometime this holiday season to get things tidied up and in better shape.

@jkehres
Copy link

jkehres commented Dec 22, 2022

Trying this out for monthly frequencies with an interval > 1 seems to get stuck in an infinite loop. I used the monthly test case from this PR and ran the following code, which defaults to an interval of 1, and everything works fine:

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)
schedule.next_occurrence(Time.new(2015, 01, 01))
=> 2015-06-24 12:00:00 -0400

However, if I add an interval value > 1 then next_occurrence never returns:

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)
schedule.next_occurrence(Time.new(2015, 01, 01))
# method hangs

If I add a puts statement in MonthlyBySetPos#Validation#validate to log step_time and run the code above again I can see it just going infinitely into the future.

The same thing happens for yearly frequencies with an interval > 1.

@jkehres
Copy link

jkehres commented Dec 22, 2022

I don't think this PR is sufficient to fully support BYSETPOS. It only deals with monthly and yearly frequencies. However, playing around in the rrule.js demo site, BYSETPOS can have an effect on shorter frequencies too. For example these rule pairs each return different results with and without BYSETPOS:

RRULE:FREQ=WEEKLY;COUNT=15;INTERVAL=2;WKST=MO;BYDAY=MO,TU
RRULE:FREQ=WEEKLY;COUNT=15;INTERVAL=2;WKST=MO;BYDAY=MO,TU;BYSETPOS=1
RRULE:FREQ=DAILY;COUNT=15;INTERVAL=2;WKST=MO;BYHOUR=1,2
RRULE:FREQ=DAILY;COUNT=15;INTERVAL=2;WKST=MO;BYHOUR=1,2;BYSETPOS=1
RRULE:FREQ=HOURLY;COUNT=15;INTERVAL=2;WKST=MO;BYMINUTE=1,2
RRULE:FREQ=HOURLY;COUNT=15;INTERVAL=2;WKST=MO;BYMINUTE=1,2;BYSETPOS=1
RRULE:FREQ=MINUTELY;COUNT=15;INTERVAL=2;WKST=MO;BYSECOND=1,2
RRULE:FREQ=MINUTELY;COUNT=15;INTERVAL=2;WKST=MO;BYSECOND=1,2;BYSETPOS=1

A few small updates to Nicolas Marlier's BYSETPOS support added in
PR ice-cube-ruby#349
rebased against master -- its been 4 years
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.
- weekly: positive/multi/mixed positions, BYHOUR expansion
- monthly: negative positions, BYMONTHDAY, BYMINUTE
- yearly: multiple positive/negative positions
This covers repeated values, out-of-range, and UNTIL cases
@nehresma
Copy link
Author

nehresma commented Dec 20, 2025

Thanks everyone for the patience on this long‑running BYSETPOS PR. It has been 9 years since @NicolasMarlier first began work on this, and over 7 since I added to it.

I’ve rebased on current master, worked through every prior comment, and expanded the implementation/tests to full RFC 5545 behavior for all BYxxx rule parts ice_cube supports.

In short: BYSETPOS now works across all frequencies that ice_cube implements (SECONDLY -> YEARLY), applies per‑interval after all BYxxx parts (per RFC ordering), preserves DTSTART anchors, and is fully covered by tests. I also fixed several long‑standing edge cases identified in review comments (interval > 1 hangs, BYSECOND+MONTHLY empty sets, multi‑rule COUNT undercounting, etc.).

What’s addressed, mapped to prior feedback:

  • @dimerman’s multi‑rule COUNT issue (18 vs 24 occurrences) is fixed; COUNT is consumed only when a rule’s occurrence is actually emitted. Regression specs added for multi‑rule COUNT with/without BYSETPOS.
  • @jkehres’s interval > 1 hang is fixed for monthly/yearly, and BYSETPOS support now covers weekly/daily/hourly/minutely/secondly per RFC (with extensive tests). The monthly BYSECOND empty‑set case is fixed and covered by specs.
  • @Naren1997’s note about BYDAY=3WE: true for some cases, but BYSETPOS is required for negative positions and for parsing RRULEs produced elsewhere. BYSETPOS is now fully supported and serialized in iCal.
  • @pacso’s request to get tests passing: full rspec spec/ passes for me locally on a matrix of ruby 3.1 through 3.4 and ActiveSupport 6.1 through 8.1.
  • @jorgemanrubia’s Apple Calendar use case: BYSETPOS is now supported end‑to‑end and tested.
  • @rgalanakis’ comment on partial compliance: we now provide full RFC behavior (for supported BYxxx), not just monthly/yearly.

RFC 5545 alignment (for supported BYxxx parts)

  • BYSETPOS runs after all BYxxx filters/expansions and before COUNT/UNTIL (RFC section 3.3.10).
  • Valid range enforced (+/- 1..366, no zero).
  • Per‑interval application for all frequencies (including SECONDLY).
  • Invalid dates / nonexistent local times are ignored (DST spring‑forward test added).
  • Implicit DTSTART anchors preserved when BYHOUR/BYMINUTE/BYSECOND expand the set.

Intentional divergence

  • RFC requires BYSETPOS to be used with another BYxxx. ice_cube allows standalone BYSETPOS for convenience; this is documented in README.

Summary of changes from master

  • Full BYSETPOS support for SECONDLY/MINUTELY/HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY.
  • Shared helper for interval bounds + temporary schedule construction to avoid recursion and preserve anchors.
  • Fixed COUNT consumption across multiple rules; added regression specs.
  • Fixed BYSETPOS with sub‑day expansions (BYSECOND/BYMINUTE/BYHOUR).
  • Serialization: BYSETPOS now survives to_hash/from_hash and appears in to_ical (including SECONDLY).
  • README now includes a BYSETPOS section with examples and caveats.

Test coverage highlights (new/expanded)

  • Positive/negative/mixed BYSETPOS, duplicates, out‑of‑range.
  • INTERVAL > 1 for all applicable frequencies.
  • COUNT/UNTIL ordering checks.
  • DST nonexistent‑time regression.
  • BYYEARDAY + BYSETPOS explicit specs.
  • BYSETPOS serialization specs (including SECONDLY).

I believe this brings BYSETPOS to full RFC‑correct behavior for all BYxxx parts ice_cube supports, with comprehensive coverage. Happy to answer questions or tweak any remaining concerns. I would love to get this merged into master. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.