icalendar-recurrence 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. data/.gitignore +17 -0
  2. data/.rspec +3 -0
  3. data/.travis.yml +6 -0
  4. data/Gemfile +3 -0
  5. data/Guardfile +5 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +86 -0
  8. data/Rakefile +6 -0
  9. data/icalendar-recurrence.gemspec +31 -0
  10. data/lib/icalendar-recurrence.rb +1 -0
  11. data/lib/icalendar/recurrence.rb +12 -0
  12. data/lib/icalendar/recurrence/event_extensions.rb +36 -0
  13. data/lib/icalendar/recurrence/schedule.rb +148 -0
  14. data/lib/icalendar/recurrence/time_util.rb +94 -0
  15. data/lib/icalendar/recurrence/version.rb +5 -0
  16. data/lib/icalendar/recurrence/weekday_extensions.rb +13 -0
  17. data/spec/lib/recurrence_spec.rb +154 -0
  18. data/spec/lib/schedule_spec.rb +52 -0
  19. data/spec/lib/time_util_spec.rb +137 -0
  20. data/spec/spec_helper.rb +14 -0
  21. data/spec/support/fixtures/daily_event.ics +30 -0
  22. data/spec/support/fixtures/embedded_timezone_event.ics +36 -0
  23. data/spec/support/fixtures/every_monday_event.ics +28 -0
  24. data/spec/support/fixtures/every_other_day_event.ics +29 -0
  25. data/spec/support/fixtures/every_weekday_daily_event.ics +26 -0
  26. data/spec/support/fixtures/everyday_for_four_days_event.ics +23 -0
  27. data/spec/support/fixtures/first_of_every_year_event.ics +31 -0
  28. data/spec/support/fixtures/first_saturday_of_month_event.ics +49 -0
  29. data/spec/support/fixtures/first_sunday_of_january_yearly_event.ics +26 -0
  30. data/spec/support/fixtures/monday_until_friday_event.ics +23 -0
  31. data/spec/support/fixtures/multi_day_weekly_event.ics +45 -0
  32. data/spec/support/fixtures/on_third_every_two_months_event.ics +28 -0
  33. data/spec/support/fixtures/one_day_a_month_for_three_months_event.ics +29 -0
  34. data/spec/support/fixtures/utc_event.ics +28 -0
  35. data/spec/support/helpers.rb +14 -0
  36. metadata +266 -0
@@ -0,0 +1,5 @@
1
+ module Icalendar
2
+ module Recurrence
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ module Icalendar
2
+ module Recurrence
3
+ module WeekdayExtensions
4
+ attr_accessor :day, :position
5
+ end
6
+ end
7
+
8
+ class RRule
9
+ class Weekday
10
+ include Icalendar::Recurrence::WeekdayExtensions
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,154 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Event#occurrences_between" do
4
+ let(:start_time) { event.start_time }
5
+
6
+ context "event repeating daily" do
7
+ let(:event) { example_event :daily } # has exclusion on Jan 28th
8
+ it "properly calculates recurrence, including exclusion date" do
9
+ occurrences = event.occurrences_between(start_time, start_time + 2.days)
10
+
11
+ expect(occurrences.length).to eq(2)
12
+ expect(occurrences.first.start_time).to eq(Time.parse("2014-01-27"))
13
+ expect(occurrences.last.start_time).to eq(Time.parse("2014-01-29"))
14
+ end
15
+ end
16
+
17
+ context "event repeating every other day" do
18
+ let(:event) { example_event :every_other_day }
19
+
20
+ it "occurs 3 times over 6 days" do
21
+ occurrences = event.occurrences_between(start_time, start_time + 5.days)
22
+
23
+ expect(occurrences.length).to eq(3)
24
+ expect(occurrences[0].start_time).to eq(Time.parse("2014-01-27"))
25
+ expect(occurrences[1].start_time).to eq(Time.parse("2014-01-29"))
26
+ expect(occurrences[2].start_time).to eq(Time.parse("2014-01-31"))
27
+ end
28
+ end
29
+
30
+ context "event repeating every monday" do
31
+ let(:event) { example_event :every_monday }
32
+
33
+ it "occurs twice over 8 days" do
34
+ occurrences = event.occurrences_between(start_time, start_time + 8.days)
35
+
36
+ expect(occurrences.length).to eq(2)
37
+ expect(occurrences[0].start_time).to eq(Time.parse("2014-02-03 16:00:00 -0800"))
38
+ expect(occurrences[1].start_time).to eq(Time.parse("2014-02-10 16:00:00 -0800"))
39
+ end
40
+ end
41
+
42
+ context "event repeating on Mon, Wed, Fri" do
43
+ let(:event) { example_event :multi_day_weekly }
44
+
45
+ it "occurs 3 times over 7 days" do
46
+ occurrences = event.occurrences_between(start_time, start_time + 7.days)
47
+
48
+ expect(occurrences.length).to eq(3)
49
+ expect(occurrences[0].start_time).to eq(Time.parse("2014-02-03 16:00:00 -0800"))
50
+ expect(occurrences[1].start_time).to eq(Time.parse("2014-02-05 16:00:00 -0800"))
51
+ expect(occurrences[2].start_time).to eq(Time.parse("2014-02-07 16:00:00 -0800"))
52
+ end
53
+ end
54
+
55
+ context "event repeating bimonthly (DST example)" do
56
+ let(:event) { example_event :on_third_every_two_months }
57
+
58
+ it "occurs twice over 60 days" do
59
+ occurrences = event.occurrences_between(start_time, start_time + 60.days)
60
+
61
+ expect(occurrences.length).to eq(2)
62
+ expect(occurrences[0].start_time).to eq(Time.parse("2014-02-03 16:00:00 -0800"))
63
+ expect(occurrences[1].start_time).to eq(Time.parse("2014-04-03 16:00:00 -0700"))
64
+ end
65
+ end
66
+
67
+ context "event repeating yearly" do
68
+ let(:event) { example_event :first_of_every_year }
69
+
70
+ it "occurs twice over 366 days" do
71
+ occurrences = event.occurrences_between(start_time, start_time + 365.days)
72
+
73
+ expect(occurrences.length).to eq(2)
74
+ expect(occurrences[0].start_time).to eq(Time.parse("2014-01-01"))
75
+ expect(occurrences[1].start_time).to eq(Time.parse("2015-01-01"))
76
+ end
77
+ end
78
+
79
+ context "event repeating Mon-Fri" do
80
+ let(:event) { example_event :every_weekday_daily }
81
+
82
+ it "occurrs 10 times over two weeks" do
83
+ occurrences = event.occurrences_between(start_time, start_time + 13.days)
84
+
85
+ expect(occurrences.length).to eq(10)
86
+ expect(occurrences.map(&:start_time)).to include(Time.parse("2014-01-10"))
87
+ expect(occurrences.map(&:start_time)).to_not include(Time.parse("2014-01-11"))
88
+ end
89
+ end
90
+
91
+ context "event repeating daily until January 18th" do
92
+ let(:event) { example_event :monday_until_friday }
93
+
94
+ it "occurs from start date until specified 'until' date" do
95
+ occurrences = event.occurrences_between(start_time, start_time + 30.days)
96
+
97
+ expected_start_times = [
98
+ Time.parse("2014-01-15 at 12pm").force_zone("America/Los_Angeles").utc,
99
+ Time.parse("2014-01-18 at 12pm").force_zone("America/Los_Angeles").utc
100
+ ]
101
+
102
+ expect(occurrences.length).to eq(5)
103
+ expect(occurrences.map(&:start_time)).to include(expected_start_times[0])
104
+ expect(occurrences.map(&:start_time)).to_not include(expected_start_times[1])
105
+ end
106
+ end
107
+
108
+ context "event repeating daily with occurrence count of 4" do
109
+ let(:event) { example_event :everyday_for_four_days }
110
+
111
+ it "occurs 4 times then stops" do
112
+ occurrences = event.occurrences_between(start_time, start_time + 365.days)
113
+ expect(occurrences.length).to eq(4)
114
+ expect(occurrences.map(&:start_time)).to include(Time.parse("2014-01-15 at 12pm").force_zone("America/Los_Angeles").utc)
115
+ expect(occurrences.map(&:start_time)).to_not include(Time.parse("2014-01-17 at 12pm").force_zone("America/Los_Angeles").utc)
116
+ end
117
+ end
118
+
119
+ context "event repeating on first saturday of month event" do
120
+ let(:event) { example_event :first_saturday_of_month }
121
+
122
+ it "occurs twice over two months" do
123
+ occurrences = event.occurrences_between(start_time, start_time + 55.days)
124
+
125
+ expected_start_times = [
126
+ Time.parse("2014-01-04 at 12am").force_zone("America/Los_Angeles"),
127
+ Time.parse("2014-02-01 at 12am").force_zone("America/Los_Angeles"),
128
+ ]
129
+
130
+ expect(occurrences.length).to eq(2)
131
+ expect(occurrences[0].start_time).to eq(expected_start_times[0])
132
+ expect(occurrences[1].start_time).to eq(expected_start_times[1])
133
+ end
134
+ end
135
+
136
+ context "event repeating once a month for three months" do
137
+ let(:event) { example_event :one_day_a_month_for_three_months }
138
+
139
+ it "only occurs twice when we look for occurrences after the first one" do
140
+ occurrences = event.occurrences_between(start_time + 30.days, start_time + 90.days)
141
+
142
+ expect(occurrences.length).to eq(2)
143
+ end
144
+ end
145
+
146
+ context "event in UTC time" do
147
+ let(:event) { example_event :utc }
148
+
149
+ it "occurs at the correct time" do
150
+ occurrences = event.occurrences_between(Time.parse("2014-01-01"), Time.parse("2014-02-01"))
151
+ expect(occurrences.first.start_time).to eq(Time.parse("20140114T180000Z"))
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,52 @@
1
+ require 'spec_helper'
2
+
3
+ describe Icalendar::Recurrence::Schedule do
4
+ describe "#transform_byday_to_hash" do
5
+ it "returns an array of days when no monthly interval is set" do
6
+ byday = ["MO", "WE", "FR"]
7
+ schedule = Schedule.new(nil)
8
+ expect(schedule.transform_byday_to_hash(byday)).to eq([:monday, :wednesday, :friday])
9
+ end
10
+
11
+ it "returns hash with day of week and interval" do
12
+ byday = ["1SA"]
13
+ schedule = Schedule.new(nil);
14
+ expect(schedule.transform_byday_to_hash(byday)).to eq({saturday: [1]})
15
+ end
16
+ end
17
+
18
+ describe "#occurrences_between" do
19
+ let(:example_occurrence) do
20
+ daily_event = example_event :daily
21
+ schedule = Schedule.new(daily_event)
22
+ schedule.occurrences_between(Date.parse("2014-02-01"), Date.parse("2014-03-01")).first
23
+ end
24
+
25
+ it "returns object that responds to start_time and end_time" do
26
+ expect(example_occurrence).to respond_to :start_time
27
+ expect(example_occurrence).to respond_to :end_time
28
+ end
29
+
30
+ context "timezoned event" do
31
+ let(:example_occurrence) do
32
+ timezoned_event = example_event :first_saturday_of_month
33
+ schedule = Schedule.new(timezoned_event)
34
+ example_occurrence = schedule.occurrences_between(Date.parse("2014-02-01"), Date.parse("2014-03-01")).first
35
+ end
36
+
37
+ it "#occurrences_between return object that responds to #start_time and #end_time (timezoned example)" do
38
+ expect(example_occurrence).to respond_to :start_time
39
+ expect(example_occurrence).to respond_to :end_time
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "#parse_ical_byday" do
45
+ let(:schedule) { Schedule.new(nil) }
46
+
47
+ it "returns a hash of data" do
48
+ expect(schedule.parse_ical_byday("1SA")).to eq({day_code: "SA", position: 1})
49
+ expect(schedule.parse_ical_byday("MO")).to eq({day_code: "MO", position: 0})
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+
3
+ describe TimeUtil do
4
+ describe ".datetime_to_time" do
5
+ it "converts DateTime to Time correctly" do
6
+ datetime = Icalendar::Values::DateTime.new(DateTime.parse("2014-01-27T12:55:21-08:00"))
7
+ correct_time = Time.parse("2014-01-27T12:55:21-08:00")
8
+ expect(TimeUtil.datetime_to_time(datetime)).to eq(correct_time)
9
+ end
10
+
11
+ it "converts UTC datetime to time with no offset" do
12
+ utc_datetime = Icalendar::Values::DateTime.new(DateTime.parse("20140114T180000Z"))
13
+ expect(TimeUtil.datetime_to_time(utc_datetime).utc_offset).to eq(0)
14
+ end
15
+
16
+ it "converts PST datetime to time with 8 hour offset" do
17
+ pst_datetime = Icalendar::Values::DateTime.new(DateTime.parse("2014-01-27T12:55:21-08:00"))
18
+ expect(TimeUtil.datetime_to_time(pst_datetime).utc_offset).to eq(-8*60*60)
19
+ end
20
+ end
21
+
22
+ describe ".to_time" do
23
+ it "uses specified timezone ID offset while converting to a Time object" do
24
+ utc_midnight = DateTime.parse("2014-01-27T12:00:00+00:00")
25
+ pst_midnight = Time.parse("2014-01-27T12:00:00-08:00")
26
+
27
+ zoned_datetime = Icalendar::Values::DateTime.new(utc_midnight, "tzid" => "America/Los_Angeles")
28
+
29
+ expect(TimeUtil.to_time(zoned_datetime)).to eq(pst_midnight)
30
+ end
31
+
32
+ it "parses a string" do
33
+ expect(TimeUtil.to_time("20140118T075959Z")).to eq(Time.parse("20140118T075959Z"))
34
+ end
35
+ end
36
+
37
+ describe ".date_to_time" do
38
+ it "converts date to time object in local time" do
39
+ local_time = Time.parse("2014-01-01")
40
+ expect(TimeUtil.date_to_time(Date.parse("2014-01-01"))).to eq(local_time)
41
+ end
42
+ end
43
+
44
+ describe "timezone_offset" do
45
+ # Avoid DST changes by freezing time
46
+ before { Timecop.freeze("2014-01-01") }
47
+ after { Timecop.return }
48
+
49
+ it "calculates negative offset" do
50
+ expect(TimeUtil.timezone_offset("America/Los_Angeles")).to eq("-08:00")
51
+ end
52
+
53
+ it "calculates positive offset" do
54
+ expect(TimeUtil.timezone_offset("Europe/Amsterdam")).to eq("+01:00")
55
+ end
56
+
57
+ it "handles UTC zone" do
58
+ expect(TimeUtil.timezone_offset("GMT")).to eq("+00:00")
59
+ end
60
+
61
+ it "returns nil when given an unknown timezone" do
62
+ expect(TimeUtil.timezone_offset("Foo/Bar")).to eq(nil)
63
+ end
64
+
65
+ it "removes quotes from given TZID" do
66
+ expect(TimeUtil.timezone_offset("\"America/Los_Angeles\"")).to eq("-08:00")
67
+ end
68
+
69
+ it "uses first element from array when given" do
70
+ expect(TimeUtil.timezone_offset(["America/Los_Angeles"])).to eq("-08:00")
71
+ end
72
+
73
+ it "returns nil when given nil" do
74
+ expect(TimeUtil.timezone_offset(nil)).to eq(nil)
75
+ end
76
+
77
+ it "calculates offset at a given moment" do
78
+ after_daylight_savings = Date.parse("2014-05-01")
79
+ expect(TimeUtil.timezone_offset("America/Los_Angeles", moment: after_daylight_savings)).to eq("-07:00")
80
+ end
81
+
82
+ it "handles daylight savings" do
83
+ # FYI, clocks turn forward an hour on Nov 2 at 9:00:00 UTC
84
+ minute_before_clocks_change = Time.parse("Nov 2 at 08:59:00 UTC") # on west coast
85
+ minute_after_clocks_change = Time.parse("Nov 2 at 09:01:00 UTC") # on west coast
86
+
87
+ expect(TimeUtil.timezone_offset("America/Los_Angeles", moment: minute_before_clocks_change)).to eq("-07:00")
88
+ expect(TimeUtil.timezone_offset("America/Los_Angeles", moment: minute_after_clocks_change)).to eq("-08:00")
89
+ end
90
+ end
91
+
92
+ describe ".force_zone" do
93
+ it "replaces the exist offset with the offset from a named zone" do
94
+ eight_am_utc = Time.parse("20140101T0800Z")
95
+ forced_time = TimeUtil.force_zone(eight_am_utc, "America/Los_Angeles")
96
+ expect(forced_time.utc.to_s).to eq("2014-01-01 16:00:00 UTC")
97
+ end
98
+
99
+ context "when forced timezone is different than original" do
100
+ it "changes the moment in time the object refers to" do
101
+ eight_am_utc = Time.parse("20140101T0800Z")
102
+ forced_time = TimeUtil.force_zone(eight_am_utc, "America/Los_Angeles")
103
+ expect(forced_time.to_i).to_not eq(eight_am_utc.to_i)
104
+ end
105
+ end
106
+
107
+ it "works for non-UTC time" do
108
+ eight_am_local = Time.parse("2014-01-01 08:00")
109
+ forced_time = TimeUtil.force_zone(eight_am_local, "Asia/Hong_Kong")
110
+ expect(forced_time.utc.to_s).to eq("2014-01-01 00:00:00 UTC")
111
+ end
112
+
113
+ context "when given an unknown TZID" do
114
+ it "raises an error" do
115
+ expect {
116
+ TimeUtil.force_zone(Time.now, "Foo/Bar")
117
+ }.to raise_error(ArgumentError)
118
+ end
119
+ end
120
+
121
+ it "works with this example" do
122
+ forced_time = Time.parse("2014-01-04 at 4pm").force_zone("America/Los_Angeles")
123
+ expect(forced_time.hour).to eq(16)
124
+ end
125
+
126
+ it "extends Time" do
127
+ forced_time = Time.parse("2014-01-01 at 8am").force_zone("Asia/Hong_Kong")
128
+ expect(forced_time.utc.to_s).to eq("2014-01-01 00:00:00 UTC")
129
+ end
130
+
131
+ it "doesn't change passed in time objects to UTC" do
132
+ eight_am_local = Time.parse("2014-01-01 08:00")
133
+ TimeUtil.force_zone(eight_am_local, "Asia/Hong_Kong")
134
+ expect(eight_am_local.utc?).to eq(false)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,14 @@
1
+ require 'support/helpers'
2
+ require 'timecop'
3
+
4
+ require 'icalendar/recurrence'
5
+
6
+ include Icalendar::Recurrence
7
+ include Helpers
8
+
9
+ RSpec.configure do |config|
10
+ config.treat_symbols_as_metadata_keys_with_true_values = true
11
+ config.run_all_when_everything_filtered = true
12
+ config.filter_run :focus
13
+ config.order = 'random'
14
+ end
@@ -0,0 +1,30 @@
1
+ BEGIN:VCALENDAR
2
+ X-WR-CALNAME:Test Public
3
+ X-WR-CALID:f512e378-050c-4366-809a-ef471ce45b09:101165
4
+ PRODID:Zimbra-Calendar-Provider
5
+ VERSION:2.0
6
+ METHOD:PUBLISH
7
+ BEGIN:VEVENT
8
+ UID:efcb99ae-d540-419c-91fa-42cc2bd9d302
9
+ RRULE:FREQ=DAILY;INTERVAL=1
10
+ SUMMARY:Every day, except the 28th
11
+ X-ALT-DESC;FMTTYPE=text/html:<html><body></body></html>
12
+ ORGANIZER;CN=Jordan Raine:mailto:foo@sfu.ca
13
+ DTSTART;VALUE=DATE:20140127
14
+ DTEND;VALUE=DATE:20140128
15
+ STATUS:CONFIRMED
16
+ CLASS:PUBLIC
17
+ X-MICROSOFT-CDO-ALLDAYEVENT:TRUE
18
+ X-MICROSOFT-CDO-INTENDEDSTATUS:FREE
19
+ TRANSP:TRANSPARENT
20
+ LAST-MODIFIED:20140113T200625Z
21
+ DTSTAMP:20140113T200625Z
22
+ SEQUENCE:0
23
+ EXDATE;VALUE=DATE:20140128
24
+ BEGIN:VALARM
25
+ ACTION:DISPLAY
26
+ TRIGGER;RELATED=START:-PT5M
27
+ DESCRIPTION:Reminder
28
+ END:VALARM
29
+ END:VEVENT
30
+ END:VCALENDAR
@@ -0,0 +1,36 @@
1
+ BEGIN:VCALENDAR
2
+ X-WR-CALNAME:Calendar
3
+ X-WR-CALID:19234061-9654-4990-a740-64ad6ed79058:10
4
+ PRODID:Zimbra-Calendar-Provider
5
+ VERSION:2.0
6
+ METHOD:PUBLISH
7
+ BEGIN:VTIMEZONE
8
+ TZID:GMT-08.00/-07.00
9
+ BEGIN:STANDARD
10
+ DTSTART:19710101T010000
11
+ TZOFFSETTO:-0800
12
+ TZOFFSETFROM:-0700
13
+ RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=11;BYDAY=1SU;WKST=MO
14
+ END:STANDARD
15
+ BEGIN:DAYLIGHT
16
+ DTSTART:19710101T030000
17
+ TZOFFSETTO:-0700
18
+ TZOFFSETFROM:-0800
19
+ RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3;BYDAY=2SU;WKST=MO
20
+ END:DAYLIGHT
21
+ END:VTIMEZONE
22
+ BEGIN:VEVENT
23
+ UID:7690678C7CBC43EB82AC1E63580D39A30
24
+ SUMMARY:Event using embedded timezone
25
+ PRIORITY:0
26
+ DTSTART;TZID="GMT-08.00/-07.00":20131222T120000
27
+ DTEND;TZID="GMT-08.00/-07.00":20131222T200000
28
+ STATUS:CONFIRMED
29
+ CLASS:PUBLIC
30
+ X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
31
+ TRANSP:OPAQUE
32
+ LAST-MODIFIED:20131211T204108Z
33
+ DTSTAMP:20131211T204108Z
34
+ SEQUENCE:0
35
+ END:VEVENT
36
+ END:VCALENDAR