diff --git a/ice_cube.gemspec b/ice_cube.gemspec index 195a6de6..f1ca1ac4 100644 --- a/ice_cube.gemspec +++ b/ice_cube.gemspec @@ -21,5 +21,6 @@ Gem::Specification.new do |s| s.add_development_dependency('rspec', '~> 2.12.0') s.add_development_dependency('activesupport', '>= 3.0.0') s.add_development_dependency('tzinfo') + s.add_development_dependency('timecop') s.add_development_dependency('i18n') end diff --git a/lib/ice_cube/builders/ical_builder.rb b/lib/ice_cube/builders/ical_builder.rb index 2ba4e646..3067e1f3 100644 --- a/lib/ice_cube/builders/ical_builder.rb +++ b/lib/ice_cube/builders/ical_builder.rb @@ -36,11 +36,11 @@ def self.ical_utc_format(time) end def self.ical_format(time, force_utc) - time = time.dup.utc if force_utc + time = time.dup.utc if force_utc || !time.respond_to?('time_zone') if time.utc? - ":#{IceCube::I18n.l(time, format: '%Y%m%dT%H%M%SZ')}" # utc time + ":#{IceCube::I18n.l(time.utc, format: '%Y%m%dT%H%M%SZ')}" # utc time else - ";TZID=#{IceCube::I18n.l(time, format: '%Z:%Y%m%dT%H%M%S')}" # local time specified + ";TZID=#{time.time_zone.name}:#{IceCube::I18n.l(time, format: '%Y%m%dT%H%M%S')}" # local time specified end end diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 2e1e2f05..437efac9 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -7,12 +7,14 @@ def self.schedule_from_ical(ical_string, options = {}) (property, tzid) = property.split(';') case property when 'DTSTART' - data[:start_time] = Time.parse(value) + data[:start_time] = _parse_in_tzid(value, tzid) when 'DTEND' - data[:end_time] = Time.parse(value) + data[:end_time] = _parse_in_tzid(value, tzid) when 'EXDATE' data[:extimes] ||= [] - data[:extimes] += value.split(',').map{|v| Time.parse(v)} + data[:extimes] += value.split(',').map do |v| + _parse_in_tzid(v, tzid) + end when 'DURATION' data[:duration] # FIXME when 'RRULE' @@ -23,6 +25,14 @@ def self.schedule_from_ical(ical_string, options = {}) Schedule.from_hash data end + def self._parse_in_tzid(value, tzid) + time_parser = Time + if tzid + time_parser = ActiveSupport::TimeZone.new(tzid.split('=')[1]) || Time + end + time_parser.parse(value) + end + def self.rule_from_ical(ical) params = { validations: { } } diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index a46cb3f1..65e6ec6d 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -94,7 +94,7 @@ module IceCube end - describe Schedule, 'from_ical' do + describe Schedule, 'from_ical', system_time_zone: "America/Chicago" do ical_string = <<-ICAL.gsub(/^\s*/, '') DTSTART:20130314T201500Z @@ -102,7 +102,14 @@ module IceCube RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20130531T100000Z ICAL - ical_string_with_multiple_exdates = <<-ICAL.gsub(/^\s*/, '') + ical_string_with_time_zones = <<-ICAL.gsub(/^\s*/,'') + DTSTART;TZID=America/Denver:20130731T143000 + DTEND:20130731T153000 + RRULE:FREQ=WEEKLY + EXDATE;TZID=America/Chicago:20130823T143000 + ICAL + + ical_string_with_multiple_exdates = <<-ICAL.gsub(/^\s*/, '') DTSTART;TZID=America/Denver:20130731T143000 DTEND;TZID=America/Denver:20130731T153000 RRULE:FREQ=WEEKLY;UNTIL=20140730T203000Z;BYDAY=MO,WE,FR @@ -111,8 +118,8 @@ module IceCube EXDATE;TZID=America/Denver:20130807T143000 ICAL - ical_string_with_multiple_rules = <<-ICAL.gsub(/^\s*/, '' ) - DTSTART;TZID=CDT:20151005T195541 + ical_string_with_multiple_rules = <<-ICAL.gsub(/^\s*/, '' ) + DTSTART;TZID=America/Denver:20151005T195541 RRULE:FREQ=WEEKLY;BYDAY=MO,TU RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=FR ICAL @@ -130,6 +137,43 @@ def sorted_ical(ical) it "loads an ICAL string" do expect(IceCube::Schedule.from_ical(ical_string)).to be_a(IceCube::Schedule) end + + describe "parsing time zones" do + it "sets the time zone of the start time" do + schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones) + expect(schedule.start_time.time_zone).to eq ActiveSupport::TimeZone.new("America/Denver") + expect(schedule.start_time.is_a?(Time)).to be true + expect(schedule.start_time.is_a?(ActiveSupport::TimeWithZone)).to be true + end + + it "treats UTC as a Time rather than TimeWithZone" do + schedule = IceCube::Schedule.from_ical(ical_string) + expect(schedule.start_time.utc_offset).to eq 0 + expect(schedule.start_time.is_a?(Time)).to be true + expect(schedule.start_time.is_a?(ActiveSupport::TimeWithZone)).to be false + end + + it "uses the system time if a time zone is not explicity provided" do + schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones) + expect(schedule.end_time).not_to respond_to :time_zone + end + + it "sets the time zone of the exception times" do + schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones) + expect(schedule.exception_times[0].time_zone).to eq ActiveSupport::TimeZone.new("America/Chicago") + end + + it "adding the offset doesnt also change the time" do + schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones) + expect(schedule.exception_times[0].hour).to eq 14 + end + + it "loads the ical DTSTART as output by IceCube to_ical method" do + now = Time.new(2016,5,9,12).in_time_zone("America/Los_Angeles") + schedule = IceCube::Schedule.from_ical(IceCube::Schedule.new(now).to_ical) + expect(schedule.start_time).to eq(now) + end + end end describe "daily frequency" do @@ -240,7 +284,6 @@ def sorted_ical(ical) describe 'monthly frequency' do it 'matches simple monthly' do start_time = Time.now - schedule = IceCube::Schedule.new(start_time) schedule.add_recurrence_rule(IceCube::Rule.monthly) diff --git a/spec/examples/hourly_rule_spec.rb b/spec/examples/hourly_rule_spec.rb index 854cf990..199ed514 100644 --- a/spec/examples/hourly_rule_spec.rb +++ b/spec/examples/hourly_rule_spec.rb @@ -39,13 +39,14 @@ module IceCube end it 'should not skip times in DST end hour' do - schedule = Schedule.new(t0 = Time.local(2013, 11, 3, 0, 0, 0)) + tz = ActiveSupport::TimeZone["America/Vancouver"] + schedule = Schedule.new(t0 = tz.local(2013, 11, 3, 0, 0, 0)) schedule.add_recurrence_rule Rule.hourly - schedule.first(4).should == [ - Time.local(2013, 11, 3, 0, 0, 0), # -0700 - Time.local(2013, 11, 3, 1, 0, 0) - ONE_HOUR, # -0700 - Time.local(2013, 11, 3, 1, 0, 0), # -0800 - Time.local(2013, 11, 3, 2, 0, 0), # -0800 + expect(schedule.first(4)).to eq [ + tz.local(2013, 11, 3, 0, 0, 0), # -0700 + tz.local(2013, 11, 3, 1, 0, 0), # -0700 + tz.local(2013, 11, 3, 2, 0, 0) - ONE_HOUR, # -0800 + tz.local(2013, 11, 3, 2, 0, 0), # -0800 ] end diff --git a/spec/examples/recur_spec.rb b/spec/examples/recur_spec.rb index 6f31422c..ac15e5f2 100644 --- a/spec/examples/recur_spec.rb +++ b/spec/examples/recur_spec.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + '/../spec_helper' +require 'timecop' include IceCube @@ -115,6 +116,18 @@ schedule.next_occurrence(schedule.start_time).should == schedule.start_time + 30 * ONE_MINUTE end + it 'should get the next occurrence across the daylight savings time boundary' do + # 2016 daylight savings time cutoff is Sunday March 13 + # Time.zone = 'America/New_York' + start_time = Time.zone.local(2016, 3, 13, 0, 0, 0) + expected_next_time = Time.zone.local(2016, 3, 13, 5, 0, 0) + schedule = Schedule.new(start_time) + schedule.add_recurrence_rule(Rule.hourly(interval=4)) + + Timecop.freeze(start_time) do + schedule.next_occurrence(schedule.start_time).should == expected_next_time + end + end end describe :next_occurrences do diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index fdd42b45..72ec6ec0 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -94,10 +94,16 @@ ].include?(rule.to_ical).should be_true end - it 'should be able to serialize a base schedule to ical in local time' do + it 'should be able to serialize a base schedule to ical in local time, using a US timezone' do Time.zone = "Eastern Time (US & Canada)" schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) - schedule.to_ical.should == "DTSTART;TZID=EDT:20100510T090000" + schedule.to_ical.should == "DTSTART;TZID=Eastern Time (US & Canada):20100510T090000" + end + + it 'should be able to serialize a base schedule to ical in local time, using an Olson timezone' do + Time.zone = "America/New_York" + schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) + schedule.to_ical.should == "DTSTART;TZID=America/New_York:20100510T090000" end it 'should be able to serialize a base schedule to ical in UTC time' do @@ -110,7 +116,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly # test equality - expectation = "DTSTART;TZID=PDT:20100510T090000\n" + expectation = "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" expectation << 'RRULE:FREQ=WEEKLY' schedule.to_ical.should == expectation end @@ -120,7 +126,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly.day_of_week(:monday => [2, -1]) schedule.add_recurrence_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=EDT:20101020T043000\n" + expectation = "DTSTART;TZID=Eastern Time (US & Canada):20101020T043000\n" expectation << "RRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation << "RRULE:FREQ=HOURLY" schedule.to_ical.should == expectation @@ -131,17 +137,17 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_exception_rule IceCube::Rule.weekly # test equality - expectation= "DTSTART;TZID=PDT:20100510T090000\n" + expectation= "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" expectation<< 'EXRULE:FREQ=WEEKLY' schedule.to_ical.should == expectation end it 'should be able to serialize a schedule with multiple exrules' do - Time.zone ='Eastern Time (US & Canada)' + Time.zone ='America/New_York' schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_exception_rule IceCube::Rule.weekly.day_of_week(:monday => [2, -1]) schedule.add_exception_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=EDT:20101020T043000\n" + expectation = "DTSTART;TZID=America/New_York:20101020T043000\n" expectation<< "EXRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation<< "EXRULE:FREQ=HOURLY" schedule.to_ical.should == expectation @@ -192,12 +198,6 @@ schedule.duration.should == 3600 end - it 'should default to to_ical using local time' do - time = Time.now - schedule = IceCube::Schedule.new(Time.now) - schedule.to_ical.should == "DTSTART;TZID=#{time.zone}:#{time.strftime('%Y%m%dT%H%M%S')}" # default false - end - it 'should not have an rtime that duplicates start time' do start = Time.utc(2012, 12, 12, 12, 0, 0) schedule = IceCube::Schedule.new(start) @@ -205,12 +205,23 @@ schedule.to_ical.should == "DTSTART:20121212T120000Z" end - it 'should be able to receive a to_ical in utc time' do - time = Time.now - schedule = IceCube::Schedule.new(Time.now) - schedule.to_ical.should == "DTSTART;TZID=#{time.zone}:#{time.strftime('%Y%m%dT%H%M%S')}" # default false - schedule.to_ical(false).should == "DTSTART;TZID=#{time.zone}:#{time.strftime('%Y%m%dT%H%M%S')}" - schedule.to_ical(true).should == "DTSTART:#{time.utc.strftime('%Y%m%dT%H%M%S')}Z" + it 'displays an ActiveSupport::TimeWithZone at utc time as Z' do + time = Time.now.utc + schedule = IceCube::Schedule.new(time) + schedule.to_ical(false).should == "DTSTART:#{time.strftime('%Y%m%dT%H%M%S')}Z" + end + + it 'displays an ActiveSupport::TimeWithZone to utc when using force_utc' do + # this is 8am in NY, 12pm UTC (UTC -4 in summer) + time = Time.new(2016, 5, 9, 12, 0, 0, 0).in_time_zone('America/New_York') + schedule = IceCube::Schedule.new(time) + schedule.to_ical(true).should == "DTSTART:20160509T120000Z" + end + + it 'displays a Time utc time as Z' do + time = Time.now.utc + schedule = IceCube::Schedule.new(time) + schedule.to_ical(true).should == "DTSTART:#{time.strftime('%Y%m%dT%H%M%S')}Z" end it 'should be able to serialize to ical with an until date' do