If your project uses time zones, it’s likely that you will sometimes want to perform a task at a certain hour of the day in the timezone. It turns out that, with the help of ActiveSupport::Timezone it’s actually quite simple.
The problem
Let’s take a simple example. You have a User
model where the timezone is stored. The user has tasks to perform and you want to send him a daily email at 7am with the task to do today.
class User < ActiveRecord::Base
#first_name
#last_name
validates :timezone, presence: true
has_many :tasks
end
Has we see, our User has a first name, last name, a timezone and many tasks.
In order to send a daily email at 7am, we will need to create a rake task
and an associated cron job
.
If you are hosting your application on Heroku, your options are the basic scheduler or the more advanced clock process.
For the purpose of this exercice say you have a basic scheduler task, bundle exec rake user_tasks:send_daily_due
that will run hourly at xx:10.
How do we know the timezones where it’s 7am right now in order for our daily email to be sent?
ActiveSupport::Timezone to the rescue
ActiveSupport::Timezone lets you loop thought all the time zones and query their current time.
So if your task is being called every hour at 10, and you are looking for every time zone where it’s 7am you can achieve this with
zones = ActiveSupport::TimeZone.all.select{ |time| time.now.hour == 7 }.map(&:name)
# => ["Dublin", "Edinburgh", "Lisbon", "London", "West Central Africa"]
Now zones
contains an array of zone names where it’s 7am right now, so you can select the users by doing
User.where(timezone: zones)
And we now have our users where time is 7am right now.
Create a lib for it
This system works fine where you only have to do it once or twice within the app, but if you find yourself having to duplicate this call multiple times, we’ve created a lib for it.
class TimeZoneSelector
def initialize(options = [])
reset_zones_caches
# See here why we need to reset zone caches https://github.com/rails/rails/issues/7245
all_zones
end
def where(options = [])
options.each do |method|
send(method)
end
self
end
def reset_zones_caches
ActiveSupport::TimeZone.instance_variable_set("@zones", nil)
ActiveSupport::TimeZone.instance_variable_set("@zones_map", nil)
end
def all_zones
@zones ||= ActiveSupport::TimeZone.all
end
def where_hour_is(hour)
# Hours range from 0,1,2,3 to 23
all_zones.select!{ |time| time.now.hour == hour }
self
end
def where_day_is(day)
all_zones.select!{ |time| time.now.try(day) }
self
end
def hour_7
where_hour_is(7)
end
def hour_14
where_hour_is(14)
end
def monday
where_day_is(:monday?)
end
def wednesday
where_day_is(:monday?)
end
def zones_names
all_zones.map(&:name)
end
end
Stick this in a lib/time_zone_selector.rb
or app/services/time_zone_selector.rb
depending on how trendy you are, and you can now query the lib at your own will:
# Assumes it's now Wednesday 14pm in the UK
TimeZoneSelector.new.where_hour_is(14).where_day_is(:wednesday?).zones_names
# => ["Dublin", "Edinburgh", "Lisbon", "London", "West Central Africa"]
TimeZoneSelector.new.where_hour_is(14).where_day_is(:monday?).zones_names
# => []
TimeZoneSelector.new.where([:hour_14, :wednesday]).zones_names
# => ["Dublin", "Edinburgh", "Lisbon", "London", "West Central Africa"]
TimeZoneSelector.new.where_hour_is(14).where_day_is(:wednesday?)
# => #<TimeZoneSelector:0x00000006ecec98 @zones=[#<ActiveSupport::TimeZone:0x00000005174760 @name="Dublin", @utc_offset=nil, @tzinfo=#<TZInfo::TimezoneProxy: Europe/Dublin>, @current_period=#<TZInfo::TimezonePeriod: #<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1427590800>,#<TZInfo::TimezoneOffset: 0,3600,IST>>,#<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1445734800>,#<TZInfo::TimezoneOffset: 0,0,GMT>>>>, #<ActiveSupport::TimeZone:0x000000050cc330 @name="Edinburgh", @utc_offset=nil, @tzinfo=#<TZInfo::TimezoneProxy: Europe/London>, @current_period=#<TZInfo::TimezonePeriod: #<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1427590800>,#<TZInfo::TimezoneOffset: 0,3600,BST>>,#<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1445734800>,#<TZInfo::TimezoneOffset: 0,0,GMT>>>>, #<ActiveSupport::TimeZone:0x00000004f2a4f0 @name="Lisbon", @utc_offset=nil, @tzinfo=#<TZInfo::TimezoneProxy: Europe/Lisbon>, @current_period=#<TZInfo::TimezonePeriod: #<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1427590800>,#<TZInfo::TimezoneOffset: 0,3600,WEST>>,#<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1445734800>,#<TZInfo::TimezoneOffset: 0,0,WET>>>>, #<ActiveSupport::TimeZone:0x00000004e76860 @name="London", @utc_offset=nil, @tzinfo=#<TZInfo::TimezoneProxy: Europe/London>, @current_period=#<TZInfo::TimezonePeriod: #<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1427590800>,#<TZInfo::TimezoneOffset: 0,3600,BST>>,#<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 1445734800>,#<TZInfo::TimezoneOffset: 0,0,GMT>>>>, #<ActiveSupport::TimeZone:0x00000002f1ee18 @name="West Central Africa", @utc_offset=nil, @tzinfo=#<TZInfo::TimezoneProxy: Africa/Algiers>, @current_period=#<TZInfo::TimezonePeriod: #<TZInfo::TimezoneTransitionDefinition: #<TZInfo::TimeOrDateTime: 357523200>,#<TZInfo::TimezoneOffset: 3600,0,CET>>,nil>>]>
I hope you find it useful, and if you do, don’t hesitate to share it!