icalendar-recurrence 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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