Nils Sommer

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:

  1. Add an attribute to the user model that stores the time zone
  2. 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.

  1. Add an attribute to your model to store the time zone.
  2. Copy the Validator and the Concern into your rails project.
  3. 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