If all users of a Rails application are in the same time zone, there's nothing to worry about. Configure the time zone globally and everything works fine. However, a little bit of work is needed if users are in different time zones.
Every Rails app can be configured to use a specific time zone globally through the time_zone
attribute in config/application.rb
. It looks like this.
class Application < Rails::Application
config.time_zone = 'Eastern Time (US & Canada)'
end
If you have model attributes that represent a date or a time, ActiveRecord will store a timestamp in UTC time in the database. Any time you access such an attribute, ActiveRecord uses the application's time zone and calculates the correct timestamp for this time zone.
Per-user time zones
To support per-user time zones rather than one globally configured one, you will need to:
- Add an attribute to the user model that stores the time zone
- Do time conversions when accessing timestamp attributes
Connecting each user to a time zone
Let's say we have an ActiveRecord model called User
. To connect every instance of a user to a specific time zone, we can add an attribute to the model. The following command generates a migration that adds a time_zone
attribute to the model and applies it to the database schema.
./bin/rails g migration AddTimeZoneToUser time_zone:string
./bin/rails db:migrate
Because we can't store ActiveSupport::TimeZone
instances directly into the database, we use an attribute of type string. To validate that a given string is a valid time zone identifier, a custom Validator class can be used.
class TimeZoneValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless ActiveSupport::TimeZone[value]
record.errors[attribute] << (options[:message] || 'is not a valid time zone!')
end
end
end
If we put this class into app/validators/time_zone_validator.rb
, it will be automatically included into the LOAD_PATH
by Rails. It can be used to validate the time zone attribute we added before like this.
class User < ApplicationRecord
validates :time_zone, presence: true, time_zone: true
end
A test case might look like this.
# test/models/user_test.rb
class UserTest < ActiveSupport::TestCase
setup do
@user = User.new(time_zone: "Europe/Berlin")
end
test "should have a valid time zone" do
@user.time_zone = "invalid time zone"
assert_not @user.valid?
end
end
Time conversions on attribute readers
At this point, something like User#created_at
will still return a timestamp instance using the application wide time zone setting. Rather than overwriting attribute reader methods, I decided to introduce additional attribute readers with a _local
suffix. So while User#created_at
returns a UTC timestamp if this is the application setting, User#created_at_local
will return a Europe/Berlin timestamp if this is my time zone. This way standard rails behavior remains as it is.
To add such methods to a model in a reusable way, I wrote an ActiveSupport::Concern
module that can be included into models and offers a DSL to configure it. See the file annotated with comments on GitHub.
# app/models/concerns/local_date_time_attr_readers.rb
module LocalDateTimeAttrReaders
extend ActiveSupport::Concern
included do
include ActiveModel::AttributeMethods
end
class_methods do
def time_zone_attr_reader(attr)
define_method :time_zone_reader do
self.send(attr)
end
end
def local_attr_reader(*attrs)
# We use ActiveModel::AttributeMethods instead of manually registering methods
attribute_method_suffix '_local'
attrs.each { |attr| define_attribute_method attr }
end
alias_method :local_date_attr_reader, :local_attr_reader
alias_method :local_time_attr_reader, :local_attr_reader
alias_method :local_datetime_attr_reader, :local_attr_reader
end
private
def attribute_local(attr)
send(attr).in_time_zone(time_zone_reader)
end
end
Now, we can include this concern into our User model like this.
class User < ApplicationRecord
validates :time_zone, presence: true, time_zone: true
include LocalDateTimeAttrReaders
time_zone_attr_reader :time_zone
local_date_attr_reader :created_at, :updated_at
end
The time_zone_attr_reader
class method tells the concern that it can read the time zone by calling a method called time_zone
(the attribute reader of the db column). The local_date_attr_reader
class method generates _local
suffix methods that return the timestamp attributes converted to the user's time zone.
Fire up an irb session (./bin/rails c
) and see it in action.
Rails.application.config.time_zone
=> "UTC"
user = User.create(time_zone: "Europe/Berlin")
user.created_at
=> Thu, 08 Feb 2018 10:52:38 UTC +00:00
user.created_at_local
=> Thu, 08 Feb 2018 11:52:38 CET +01:00
Existing Rails goodies
We already used ActiveSupport::TimeZone
, but Rails has more to help with time zones.
You most probably need some kind of HTML form with a select to let the user set the time zone. Rails has great form helpers for this.
<%= time_zone_select(:user, :time_zone) %>
Summary
Per-user time zone management needs to be built on top of Rails. However, this is relatively easy with the concern module introduced here. To apply this technique, follow these steps.
- Add an attribute to your model to store the time zone.
- Copy the Validator and the Concern into your rails project.
- Include the concern into your model and use the DSL to setup _local accessor methods.
GitHub: Time Zone Validator | LocalDateTimeAttrReaders Concern
Write a comment
via