Nov 6

Rails, Time Zones, and Scheduling

Category: Development,Rails

The time zone support in rails has vastly improved since the integrated TZInfo into Rails 2.1. However if you have any scheduled tasks stored in your DB, you’ll notice it quickly breaks down. Here is a short and sweet explanation:
I have an app that delivers system notification to users at a specified time they set. I have a field in my “reminders” table called “deliver_at” which is a datetime field. The data is stored as a UTC timestamp. Everything works as expected until daylight savings time, or the user changes their time zone. This is because the “deliver_at” timestamp has no context of what offset was used when the record was created. So when daylight saving rolls around your messages are now delivered an hour off. Or if the user moves to a new timezone, it will be off by x hours. Here is the solution I came up with.
I added a field to the “reminders” table called “offset”.

add_column :reminders, :offset, :decimal, :precision =>; 4, :scale => 2, :null =>; false, :default => 0.0

This field is recorded when the record is first created. You can override the attribute using write_attribute and read_attribute, but I was already doing a seporate update call, so I just stuck it there. *Important, make sure you are setting the Time Zone on login.

self.update_attributes!(:delivered_at => Time.zone.now, :next_reminder_at => date, :offset => user_utc_offset)

Here is my user_utc_offset method:

def user_utc_offset
    TimeZone[self.user.timezone].utc_offset.to_f / 1.hour.to_f
end

I’m using floats because not all the offsets are whole numbers. Same reason I used a decimal field in the DB. Then when I calculate my next reminder delivery, I basically do this:

offset_adjustment = adjust_for_difference_in_offsets
		  case self.reminder.repeat
		      when "daily"
			  next_reminder = reminder.deliver_at + 1.day - offset_adjustment.hours
		      when "weekly"
			  next_reminder = reminder.deliver_at + 7.days - offset_adjustment.hours
		      when "monthly"
			  next_reminder = reminder.deliver_at + 1.month - offset_adjustment.hours
		   end

I have a case statement to calculate daily, weekly, and monthly reminders. Here is the method to calculate the offset difference.

def adjust_for_difference_in_offsets
    user_utc_offset - self.offset.to_f
  end

I use the above for the mailer, but we need to do the same for displaying the datetime stamp to the user. So I created a helper method that does like so:

def reminder_repeat_datetime(reminder)
    offset_adjustment = reminder.adjust_for_difference_in_offsets
    case reminder.repeat
      when "monthly"
        repeat = "#{reminder.deliver_at.strftime('%d')}th"
      when "weekly"
        repeat = "#{reminder.deliver_at.strftime('%A')}s"
      when "daily"
        repeat = "Daily"
      when "once"
        repeat = "#{reminder.deliver_at.strftime('%B %d, %Y')}"
    end
    "#{repeat} at #{(reminder.deliver_at - offset_adjustment.hours).strftime('%I:%M %p')}"
  end

A lot of the above is just pretty formatting of the datetime for the user. You could most likely just get away with something like the last line. That is called from my index view in my reminders controller like so:

<%= reminder_repeat_datetime(reminder) %>

So now no matter if the offset changes because of DST or the user changes their timezone, the offset will be calculated and applied to the datetime field.

No comments

No Comments

Leave a comment