├── .gitignore ├── .travis.yml ├── .yardopts ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── time_zone_scheduler.rb └── time_zone_scheduler │ ├── version.rb │ └── view.rb ├── test ├── test_helper.rb ├── time_zone_scheduler_test.rb └── view_test.rb └── time_zone_scheduler.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0 4 | - 2.1 5 | - 2.2 6 | - 2.3.0 7 | before_install: gem install bundler 8 | install: bundle install --without doc 9 | 10 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | --markup markdown 3 | --main README.md 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :doc do 6 | gem 'yard' 7 | end 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Artsy, Eloy Durán 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeZoneScheduler 2 | 3 | [![Build Status](https://travis-ci.org/alloy/time_zone_scheduler.svg?branch=master)](https://travis-ci.org/alloy/time_zone_scheduler) 4 | 5 | A Ruby library that assists in scheduling events whilst taking time zones into account. E.g. when to best deliver 6 | notifications such as push notifications or emails. 7 | 8 | It includes a ORM ‘view’ that is able to partition a collection by time zone and extends the partitions with the 9 | TimeZoneScheduler API. 10 | 11 | NOTE: _It is not yet battle-tested. This will all follow over the next few weeks._ 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'time_zone_scheduler' 19 | ``` 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as: 26 | 27 | $ gem install time_zone_scheduler 28 | 29 | ## Usage 30 | 31 | For full details, see [the documentation](http://www.rubydoc.info/gems/time_zone_scheduler). 32 | 33 | Here’s an example that uses the ‘view’ ORM helper. This example uses Mongoid, but its usage is pretty much the same with 34 | ActiveRecord. 35 | 36 | ```ruby 37 | require 'time_zone_scheduler/view' 38 | 39 | class User 40 | include Mongoid::Document 41 | 42 | field :time_zone, type: String 43 | 44 | extend TimeZoneScheduler::View 45 | view_field_as_time_zone :time_zone 46 | end 47 | 48 | User.create(time_zone: 'Europe/Amsterdam') # => # 49 | User.create(time_zone: 'Europe/Paris') # => # 50 | User.create(time_zone: 'America/New_York') # => # 51 | 52 | time_zone_views = User.in_time_zones 53 | p time_zone_views.map { |view| view.map(&:id) } # => [[1], [2], [3]] 54 | p time_zone_views.map { |view| view.map(&:class) } # => [Mongoid::Criteria, Mongoid::Criteria, Mongoid::Criteria] 55 | p time_zone_views.map { |view| view.time_zone } # => ['Europe/Amsterdam', 'Europe/Paris', 'America/New_York'] 56 | ``` 57 | 58 | Now consider, for instance, that you want to schedule the delivery of Apple Push Notifications, but do want to respect 59 | the user and so, regardless of the user’s time zone, always deliver on Friday, January the 15th of 2016, at 10AM: 60 | 61 | ```ruby 62 | date_and_time = Time.parse('2016-01-15 10:00') 63 | 64 | User.in_time_zones.each do |users| 65 | # Calculate the system time that corresponds to the date and time in the user’s time zone. 66 | run_at = users.schedule_on_date(date_and_time) 67 | # Perform the delivery of the push notifications on the calculated time. 68 | # (This example uses the DelayedJob API to delay delivery.) 69 | PushNotification.delay(run_at: run_at).deliver(users) 70 | end 71 | ``` 72 | 73 | ## Contributing 74 | 75 | Bug reports and pull requests are welcome on GitHub at https://github.com/alloy/time_zone_scheduler. 76 | 77 | ## License 78 | 79 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 80 | 81 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | desc "Install all dependencies" 2 | task :bootstrap do 3 | if system('which bundle') 4 | sh "bundle install" 5 | #sh "git submodule update --init" 6 | else 7 | $stderr.puts "\033[0;31m[!] Please install the bundler gem manually: $ [sudo] gem install bundler\e[0m" 8 | exit 1 9 | end 10 | end 11 | 12 | begin 13 | require 'bundler/gem_tasks' 14 | 15 | desc "Generate documentation" 16 | task :doc do 17 | sh "yard doc" 18 | end 19 | 20 | require "rake/testtask" 21 | Rake::TestTask.new(:test) do |t| 22 | t.libs << "test" 23 | t.libs << "lib" 24 | t.test_files = FileList['test/**/*_test.rb'] 25 | end 26 | 27 | task :default => :test 28 | 29 | rescue LoadError 30 | $stderr.puts "\033[0;33m[!] Disabling rake tasks because the environment couldn’t be loaded. Be sure to run `rake bootstrap` first.\e[0m" 31 | end 32 | -------------------------------------------------------------------------------- /lib/time_zone_scheduler.rb: -------------------------------------------------------------------------------- 1 | require "time_zone_scheduler/version" 2 | 3 | require "active_support/core_ext/time/zones" 4 | require 'active_support/duration' 5 | 6 | # A Ruby library that assists in scheduling events whilst taking time zones into account. E.g. when to best deliver 7 | # notifications such as push notifications or emails. 8 | # 9 | # It relies on ActiveSupport’s time and time zone functionality and expects a current system time zone to be specified 10 | # through `Time.zone`. 11 | # 12 | # ### Terminology 13 | # 14 | # Consider a server sending notifications to a user: 15 | # 16 | # - **system time**: The local time of the server in the current time zone, as specified with `Time.zone`. 17 | # - **reference time**: The time that needs to be e.g. converted into the user’s destination time zone. 18 | # - **destination time zone**: The time zone that the user resides in. 19 | # - **destination time**: The local time of the time zone that the user resides in. 20 | # 21 | class TimeZoneScheduler 22 | # @return [ActiveSupport::TimeZone] 23 | # the destination time zone for the various calculations this class performs. 24 | # 25 | attr_reader :destination_time_zone 26 | 27 | # @param [String, ActiveSupport::TimeZone] destination_time_zone 28 | # the destination time zone that calculations will be performed in. 29 | # 30 | def initialize(destination_time_zone) 31 | @destination_time_zone = Time.find_zone!(destination_time_zone) 32 | end 33 | 34 | # This calculation takes the local date and time of day of the reference time and converts that to the exact same date 35 | # and time of day in the destination time zone and returns it in the system time. In other words, you’d use this to 36 | # calculate the system time at which a specific date and time of day occurs in the destination time zone. 37 | # 38 | # For instance, you could use this to schedule notifications that should be sent to users on specific days of the week 39 | # at times of the day that they are most likely to be good for the user. E.g. every Thursday at 10AM. 40 | # 41 | # @example Calculate the system time that corresponds to Sunday 2015-10-25 at 10AM in the Pacific/Niue time zone. 42 | # 43 | # Time.zone = "Pacific/Kiritimati" # Set the system time zone 44 | # scheduler = TimeZoneScheduler.new("Pacific/Niue") 45 | # reference_time = Time.parse("2015-10-25 10:00 UTC") 46 | # system_time = scheduler.schedule_on_date(reference_time, false) 47 | # 48 | # p reference_time # => Sun, 25 Oct 2015 10:00:00 UTC +00:00 49 | # p system_time # => Mon, 26 Oct 2015 11:00:00 LINT +14:00 50 | # 51 | # p system_time.sunday? # => false 52 | # p system_time.hour # => 11 53 | # 54 | # p local_time = system_time.in_time_zone("Pacific/Niue") 55 | # p local_time.sunday? # => true 56 | # p local_time.hour # => 10 57 | # 58 | # @param [Time] reference_time 59 | # the reference date and time of day that’s to be scheduled in the destination time zone. 60 | # 61 | # @param [Boolean] raise_if_time_has_passed 62 | # whether or not to check if the time in the destination time zone has already passed. 63 | # 64 | # @raise [ArgumentError] 65 | # in case the check is enabled, this is raised if the time in the destination time zone has already passed. 66 | # 67 | # @return [Time] 68 | # the system time that corresponds to the time scheduled in the destination time zone. 69 | # 70 | def schedule_on_date(reference_time, raise_if_time_has_passed = true) 71 | destination_time = @destination_time_zone.parse(reference_time.strftime('%F %T')) 72 | system_time = destination_time.in_time_zone(Time.zone) 73 | if raise_if_time_has_passed && system_time < Time.zone.now 74 | raise ArgumentError, "The specified time has already passed in the #{@destination_time_zone.name} timezone." 75 | end 76 | system_time 77 | end 78 | 79 | # This calculation schedules the time to be at the same time as the reference time (real time), except when that time, 80 | # in the destination time zone, falls _outside_ of the specified timeframe. In that case it delays the time until the 81 | # next minimum time of the timeframe is reached. 82 | # 83 | # For instance, you could use this to schedule notifications about an event starting in either real-time, if that’s a 84 | # good time for the user in their time zone, or otherwise delay it to the next good time. 85 | # 86 | # @example Return the real time, as the reference time falls in the specified timeframe in the Europe/Amsterdam time zone. 87 | # 88 | # Time.zone = "UTC" # Set the system time zone 89 | # scheduler = TimeZoneScheduler.new("Europe/Amsterdam") 90 | # reference_time = Time.parse("2015-10-25 12:00 UTC") 91 | # system_time = scheduler.schedule_in_timeframe(reference_time, "10:00".."14:00") 92 | # local_time = system_time.in_time_zone("Europe/Amsterdam") 93 | # 94 | # p reference_time # => Sun, 25 Oct 2015 12:00:00 UTC +00:00 95 | # p system_time # => Sun, 25 Oct 2015 12:00:00 UTC +00:00 96 | # p local_time # => Sun, 25 Oct 2015 13:00:00 CET +01:00 97 | # 98 | # @example Delay the reference time so that it’s not scheduled before 10AM in the Pacific/Kiritimati time zone. 99 | # 100 | # Time.zone = "UTC" # Set the system time zone 101 | # scheduler = TimeZoneScheduler.new("Pacific/Kiritimati") 102 | # reference_time = Time.parse("2015-10-25 12:00 UTC") 103 | # system_time = scheduler.schedule_in_timeframe(reference_time, "10:00".."14:00") 104 | # local_time = system_time.in_time_zone("Pacific/Kiritimati") 105 | # 106 | # p reference_time # => Sun, 25 Oct 2015 12:00:00 UTC +00:00 107 | # p system_time # => Sun, 25 Oct 2015 20:00:00 UTC +00:00 108 | # p local_time # => Mon, 26 Oct 2015 10:00:00 LINT +14:00 109 | # 110 | # @example Delay the reference time so that it’s not scheduled after 2PM in the Europe/Moscow time zone. 111 | # 112 | # Time.zone = "UTC" # Set the system time zone 113 | # scheduler = TimeZoneScheduler.new("Europe/Moscow") 114 | # reference_time = Time.parse("2015-10-25 12:00 UTC") 115 | # system_time = scheduler.schedule_in_timeframe(reference_time, "10:00".."14:00") 116 | # local_time = system_time.in_time_zone("Europe/Moscow") 117 | # 118 | # p reference_time # => Sun, 25 Oct 2015 12:00:00 UTC +00:00 119 | # p system_time # => Mon, 26 Oct 2015 07:00:00 UTC +00:00 120 | # p local_time # => Mon, 26 Oct 2015 10:00:00 MSK +03:00 121 | # 122 | # @param [Time] reference_time 123 | # the reference time that’s to be re-scheduled in the destination time zone if it falls outside the timeframe. 124 | # 125 | # @param [Range] timeframe 126 | # a range of times (of the day) in which the scheduled time should fall. 127 | # 128 | # @return [Time] 129 | # either the original reference time, if it falls in the timeframe, or the delayed time. 130 | # 131 | def schedule_in_timeframe(reference_time, timeframe) 132 | timeframe = TimeFrame.new(@destination_time_zone, reference_time, timeframe) 133 | if timeframe.reference_before_timeframe? 134 | timeframe.min 135 | elsif timeframe.reference_after_timeframe? 136 | timeframe.min.tomorrow 137 | else 138 | reference_time 139 | end.in_time_zone(Time.zone) 140 | end 141 | 142 | # This checks if the reference time falls in the given timeframe in the destination time zone. 143 | # 144 | # For instance, you could use this to disable playing a sound for notifications that **have** to be scheduled in real 145 | # time, but you don’t necessarily want to e.g. wake the user. 146 | # 147 | # @example Return that 1PM in the Europe/Amsterdam time zone falls in the timeframe. 148 | # 149 | # Time.zone = "UTC" # Set the system time zone 150 | # scheduler = TimeZoneScheduler.new("Europe/Amsterdam") 151 | # reference_time = Time.parse("2015-10-25 12:00 UTC") 152 | # 153 | # p scheduler.in_timeframe?(reference_time, "08:00".."14:00") # => true 154 | # 155 | # @example Return that 3PM in the Europe/Moscow time zone falls outside the timeframe. 156 | # 157 | # Time.zone = "UTC" # Set the system time zone 158 | # scheduler = TimeZoneScheduler.new("Europe/Moscow") 159 | # reference_time = Time.parse("2015-10-25 12:00 UTC") 160 | # 161 | # p scheduler.in_timeframe?(reference_time, "08:00".."14:00") # => true 162 | # 163 | # @param [Time] reference_time 164 | # the reference time that’s to be checked if it falls in the timeframe in the destination time zone. 165 | # 166 | # @param [Range] timeframe 167 | # a range of times (of the day) in which the reference time should fall. 168 | # 169 | # @return [Boolean] 170 | # whether or not the reference time falls in the specified timeframe in the destination time zone. 171 | # 172 | def in_timeframe?(reference_time, timeframe) 173 | TimeFrame.new(@destination_time_zone, reference_time, timeframe).reference_in_timeframe? 174 | end 175 | 176 | # @!visibility private 177 | # 178 | # Assists in calculations regarding timeframes. It caches the results so the caller doesn’t need to worry about cost. 179 | # 180 | class TimeFrame 181 | # @param [ActiveSupport::TimeZone] destination_time_zone 182 | # @param [Time] reference_time 183 | # @param [Range] timeframe 184 | # 185 | def initialize(destination_time_zone, reference_time, timeframe) 186 | @destination_time_zone, @reference_time, @timeframe = destination_time_zone, reference_time, timeframe 187 | end 188 | 189 | # @return [Time] 190 | # the minimum time of the timeframe range in the destination time zone. 191 | # 192 | def min 193 | @min ||= @destination_time_zone.parse("#{local_date} #{@timeframe.min}") 194 | end 195 | 196 | # @return [Time] 197 | # the maximum time of the timeframe range in the destination time zone. 198 | # 199 | def max 200 | @max ||= @destination_time_zone.parse("#{local_date} #{@timeframe.max}") 201 | end 202 | 203 | # @return [Boolean] 204 | # whether the reference time falls before the timeframe. 205 | # 206 | def reference_before_timeframe? 207 | local_time < min 208 | end 209 | 210 | # @return [Boolean] 211 | # whether the reference time falls after the timeframe. 212 | # 213 | def reference_after_timeframe? 214 | local_time > max 215 | end 216 | 217 | # @note First checks if the reference time falls before the timeframe, because if that fails {#max} never needs to 218 | # be performed for {TimeZoneScheduler#schedule_in_timeframe} to be able to perform its work. 219 | # 220 | # @return [Boolean] 221 | # whether the reference time falls in the timeframe. 222 | # 223 | def reference_in_timeframe? 224 | !reference_before_timeframe? && !reference_after_timeframe? 225 | end 226 | 227 | private 228 | 229 | # @return [Time] 230 | # the reference time in the destination timezone. 231 | # 232 | def local_time 233 | @local_time ||= @reference_time.in_time_zone(@destination_time_zone) 234 | end 235 | 236 | # @return [String] 237 | # the date of the reference time in the destination timezone. 238 | # 239 | def local_date 240 | @date ||= local_time.strftime('%F') 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/time_zone_scheduler/version.rb: -------------------------------------------------------------------------------- 1 | class TimeZoneScheduler 2 | VERSION = "0.2.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/time_zone_scheduler/view.rb: -------------------------------------------------------------------------------- 1 | require 'time_zone_scheduler' 2 | require 'forwardable' 3 | 4 | class TimeZoneScheduler 5 | module View 6 | # Defines a singleton method called `in_time_zones` which returns a list of 7 | # views on the model partitioned by the time zones available in the specified 8 | # time zone model field. 9 | # 10 | # It is able to extend ActiveRecord and Mongoid models. 11 | # 12 | # @see View#in_time_zones 13 | # 14 | # @param [Symbol] time_zone_field 15 | # the model field that holds the time zone names. 16 | # 17 | # @param [String] default_time_zone 18 | # the time zone to use if the value of the field can be `nil`. 19 | # 20 | # @return [void] 21 | # 22 | def view_field_as_time_zone(time_zone_field, default_time_zone = nil) 23 | time_zone_scopes = lambda do |scope, time_zones| 24 | time_zones.map do |time_zone| 25 | scope.where(time_zone_field => time_zone).tap do |scope| 26 | scope.extend Mixin 27 | scope.time_zone_scheduler = TimeZoneScheduler.new(time_zone || default_time_zone) 28 | end 29 | end 30 | end 31 | 32 | if respond_to?(:scoped) 33 | # Mongoid 34 | define_singleton_method :in_time_zones do 35 | time_zone_scopes.call(scoped, scoped.distinct(time_zone_field)) 36 | end 37 | elsif respond_to?(:scope) 38 | # ActiveRecord (has dropped `scoped` since v4.0.2) 39 | define_singleton_method :in_time_zones do 40 | time_zone_scopes.call(scope, scope.select(time_zone_field).distinct) 41 | end 42 | else 43 | raise 'Unknown ORM.' 44 | end 45 | end 46 | 47 | # Defines a singleton method called `in_time_zones` which returns a list of 48 | # views on the model partitioned by the time zones available in the specified 49 | # time zone model field. 50 | # 51 | # Returns views that are regular `Mongoid::Criteria` or `ActiveRecord::Relation` instances created from the current 52 | # scope (and narrowed down by time zone), but are extended with the `TimeZoneScheduler` API. 53 | # 54 | # @see https://github.com/alloy/time_zone_scheduler 55 | # @see http://www.rubydoc.info/gems/time_zone_scheduler/TimeZoneScheduler 56 | # 57 | # @example 58 | # 59 | # class User 60 | # field :time_zone, type: String 61 | # 62 | # extend TimeZoneScheduler::View 63 | # view_field_as_time_zone :time_zone 64 | # end 65 | # 66 | # User.create(time_zone: 'Europe/Amsterdam') 67 | # time_zone_view = User.in_time_zones.first 68 | # 69 | # p time_zone_view # => # 70 | # p time_zone_view.time_zone # => 'Europe/Amsterdam' 71 | # p time_zone_view.time_zone_scheduler # => # 72 | # 73 | # # See TimeZoneScheduler for documentation on these available methods. 74 | # time_zone_view.schedule_on_date(time) 75 | # time_zone_view.schedule_in_timeframe(time, timeframe) 76 | # time_zone_view.in_timeframe?(time, timeframe) 77 | # 78 | # @return [Array] 79 | # a list of criteria partitioned by time zone and extended with the {Mixin} module. 80 | # 81 | def in_time_zones 82 | raise 'Need to call view_field_as_time_zone first.' 83 | end 84 | 85 | module Mixin 86 | extend Forwardable 87 | 88 | attr_accessor :time_zone_scheduler 89 | def_delegator :time_zone_scheduler, :destination_time_zone, :time_zone 90 | def_delegator :time_zone_scheduler, :schedule_on_date 91 | def_delegator :time_zone_scheduler, :schedule_in_timeframe 92 | def_delegator :time_zone_scheduler, :in_timeframe? 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require "time_zone_scheduler" 3 | 4 | require "minitest/spec" 5 | require "minitest/autorun" 6 | 7 | require "timecop" 8 | -------------------------------------------------------------------------------- /test/time_zone_scheduler_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | require "active_support/core_ext/numeric/time" 4 | 5 | describe TimeZoneScheduler do 6 | before do 7 | # On the 25th of October 2015, at 02:00 UTC, DST ended in CET and clocks 8 | # were turned back 1 hour, which makes this an ideal scenario to test against. 9 | # 10 | # Current time in Alofi is: 2015-10-24 01:00 (Pacific/Niue) 11 | # Current time in New York is: 2015-10-24 08:00 (America/New_York) 12 | # Current time in Rio de Janeiro is: 2015-10-24 10:00 (Brazil/East) 13 | # Current time in Amsterdam is: 2015-10-24 14:00 (Europe/Amsterdam) 14 | # Current time in Moscow is: 2015-10-24 15:00 (Europe/Moscow) 15 | # Current time in South Tarawa is: 2015-10-25 02:00 (Pacific/Kiritimati) 16 | # 17 | Time.zone = 'UTC' 18 | Timecop.freeze(ActiveSupport::TimeZone['UTC'].parse('2015-10-24 12:00')) 19 | end 20 | 21 | it "initializes with a destination time zone name" do 22 | scheduler = TimeZoneScheduler.new("Europe/Amsterdam") 23 | scheduler.destination_time_zone.must_equal ActiveSupport::TimeZone["Europe/Amsterdam"] 24 | end 25 | 26 | describe "#schedule_on_date" do 27 | { 28 | 'Pacific/Kiritimati' => 8, 29 | 'Europe/Amsterdam' => 21, # +1 for DST change 30 | 'Europe/Moscow' => 19, 31 | 'Brazil/East' => 24, 32 | 'America/New_York' => 26, 33 | 'Pacific/Niue' => 33, 34 | }.each do |time_zone, hours_from_now| 35 | it "schedules the date+time in `#{time_zone}' to be #{hours_from_now} hours from now" do 36 | reference_time = ActiveSupport::TimeZone['UTC'].parse('2015-10-25 10:00') 37 | 38 | scheduler = TimeZoneScheduler.new(time_zone) 39 | system_time = scheduler.schedule_on_date(reference_time) 40 | 41 | system_time.must_equal hours_from_now.hours.from_now 42 | 43 | destination_time = system_time.in_time_zone(time_zone) 44 | destination_time.sunday?.must_equal true 45 | destination_time.hour.must_equal 10 46 | end 47 | end 48 | 49 | it 'raises an error if the time has already passed in any of the timezones' do 50 | # This has been 2 hours ago in South Tarawa 51 | reference_time = ActiveSupport::TimeZone['UTC'].parse('2015-10-25 00:00') 52 | lambda do 53 | TimeZoneScheduler.new('Pacific/Kiritimati').schedule_on_date(reference_time) 54 | end.must_raise(ArgumentError) 55 | end 56 | end 57 | 58 | describe "#schedule_in_timeframe" do 59 | before do 60 | # Reference time in UTC: 2015-10-25 12:00 61 | @reference_time = ActiveSupport::TimeZone['Brazil/East'].parse('2015-10-25 10:00') 62 | @timeframe = '08:00'..'14:00' 63 | @real_time = 24.hours.from_now 64 | end 65 | 66 | it 'delivers in real-time where local time falls within the allowed timeframe' do 67 | # This is in the reference timezone, so no changes required. 68 | TimeZoneScheduler.new('Brazil/East').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time) 69 | # This is 2015-10-25 08:00 (America/New_York). 70 | TimeZoneScheduler.new('America/New_York').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time) 71 | # This is 2015-10-25 15:00 (Europe/Amsterdam) with DST, but as during 72 | # this night DST ended, it is actually 2015-10-25 14:00. 73 | TimeZoneScheduler.new('Europe/Amsterdam').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time) 74 | end 75 | 76 | it 'delays those that fall before the allowed timeframe till start same day' do 77 | # This is 2015-10-26 02:00 Pacific/Kiritimati. 78 | TimeZoneScheduler.new('Pacific/Kiritimati').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time + 6.hours) 79 | # This is 2015-10-25 01:00 Pacific/Niue. 80 | TimeZoneScheduler.new('Pacific/Niue').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time + 7.hours) 81 | end 82 | 83 | it 'delays those that fall after the allowed timeframe till start next day' do 84 | # This is 2015-10-25 15:00 Europe/Moscow. 85 | TimeZoneScheduler.new('Europe/Moscow').schedule_in_timeframe(@reference_time, @timeframe).must_equal(@real_time + 17.hours) 86 | end 87 | end 88 | 89 | describe "#in_timeframe?" do 90 | it "returns whether or not the local time falls within the allowed timeframe" do 91 | # Reference time in UTC: 2015-10-25 12:00 92 | reference_time = ActiveSupport::TimeZone["Brazil/East"].parse('2015-10-25 10:00') 93 | 94 | %w{ America/New_York Brazil/East Europe/Amsterdam }.each do |time_zone| 95 | TimeZoneScheduler.new(time_zone).in_timeframe?(reference_time, '08:00'..'14:00').must_equal true 96 | end 97 | 98 | %w{ Europe/Moscow Pacific/Kiritimati Pacific/Niue }.each do |time_zone| 99 | TimeZoneScheduler.new(time_zone).in_timeframe?(reference_time, '08:00'..'14:00').must_equal false 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/view_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'time_zone_scheduler/view' 3 | 4 | # Represents e.g. ActiveRecord::Relation or Mongoid::Criteria 5 | Query = Struct.new(:value) 6 | 7 | describe TimeZoneScheduler::View do 8 | it 'defines a Mongoid view getter' do 9 | model = Object.new 10 | time_zones = %w( Europe/Amsterdam America/New_York ) 11 | 12 | scoped = Object.new 13 | model.define_singleton_method :scoped do 14 | scoped 15 | end 16 | scoped.define_singleton_method :distinct do |field| 17 | time_zones if field == :timezone 18 | end 19 | time_zones.each do |time_zone| 20 | scoped.define_singleton_method :where do |params| 21 | Query.new(params.values.first) if params.keys == [:timezone] 22 | end 23 | end 24 | 25 | model.extend TimeZoneScheduler::View 26 | model.view_field_as_time_zone :timezone 27 | 28 | model.in_time_zones.zip(time_zones).each do |view, expected_time_zone| 29 | view.class.must_equal Query 30 | view.value.must_equal expected_time_zone 31 | view.time_zone_scheduler.destination_time_zone.name.must_equal expected_time_zone 32 | end 33 | end 34 | 35 | it 'defines a ActiveRecord view getter' do 36 | model = Object.new 37 | time_zones = %w( Europe/Amsterdam America/New_York ) 38 | 39 | scoped = Object.new 40 | model.define_singleton_method :scope do 41 | scoped 42 | end 43 | selected = Object.new 44 | scoped.define_singleton_method :select do |field| 45 | selected if field == :timezone 46 | end 47 | selected.define_singleton_method :distinct do 48 | time_zones 49 | end 50 | time_zones.each do |time_zone| 51 | scoped.define_singleton_method :where do |params| 52 | Query.new(params.values.first) if params.keys == [:timezone] 53 | end 54 | end 55 | 56 | model.extend TimeZoneScheduler::View 57 | model.view_field_as_time_zone :timezone 58 | 59 | model.in_time_zones.zip(time_zones).each do |view, expected_time_zone| 60 | view.class.must_equal Query 61 | view.value.must_equal expected_time_zone 62 | view.time_zone_scheduler.destination_time_zone.name.must_equal expected_time_zone 63 | end 64 | end 65 | end 66 | 67 | describe TimeZoneScheduler::View::Mixin do 68 | it 'forwards the various scheduling methods to the scheduler' do 69 | scheduler = Minitest::Mock.new 70 | 71 | view = Object.new 72 | view.extend TimeZoneScheduler::View::Mixin 73 | view.time_zone_scheduler = scheduler 74 | 75 | scheduler.expect(:schedule_on_date, nil, [:time]) 76 | view.schedule_on_date(:time) 77 | 78 | scheduler.expect(:schedule_in_timeframe, nil, [:time, :timeframe]) 79 | view.schedule_in_timeframe(:time, :timeframe) 80 | 81 | scheduler.expect(:in_timeframe?, nil, [:time, :timeframe]) 82 | view.in_timeframe?(:time, :timeframe) 83 | 84 | scheduler.verify 85 | end 86 | end 87 | 88 | -------------------------------------------------------------------------------- /time_zone_scheduler.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'time_zone_scheduler/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "time_zone_scheduler" 8 | spec.version = TimeZoneScheduler::VERSION 9 | spec.authors = ["Eloy Durán"] 10 | spec.email = ["eloy.de.enige@gmail.com"] 11 | 12 | spec.summary = "A library that assists in scheduling events whilst taking time zones into account." 13 | spec.homepage = "https://github.com/alloy/time_zone_scheduler" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.require_paths = ["lib"] 18 | 19 | spec.add_runtime_dependency "activesupport" 20 | 21 | spec.add_development_dependency "bundler", "~> 1.11" 22 | spec.add_development_dependency "rake", "~> 10.0" 23 | spec.add_development_dependency "minitest", "~> 5.0" 24 | spec.add_development_dependency "timecop", "~> 0.8.0" 25 | end 26 | --------------------------------------------------------------------------------