Sun 18 July 2010

On Date/Time/DateTime in Ruby, and why they suck

This is Old!

I wrote this when I was much younger, and arguably, a bit of a jackass (pardon my French). I keep it up for personal reasons, and there might be some technical content of note here... so just please ignore the tone.

Yeah, there, I said it - this is a stupid situation for a language to be in. The concepts of Date, Time, and DateTime are all pretty well related. Date is a point in Time, DateTime is a really nice representation of Date and Time mashed together.

In a world that actually makes sense, you'd be able to easily convert between these types (e.g: DateTime should be able to easily convert over to a Time object). Some people might suggest patching the aforementioned classes (like Rails does, for parsing relative dates), but this just feels like an incredibly hacky solution.

Now, normally, I'm not much of a Ruby guy, give me Javascript any day. However, there are a lot of awesome projects written in Ruby, and it's hard to deny that they've easily got the best packaging solution for any language. As it so happens, earlier this week I was hacking on a little Sinatra side project. DataMapper was my ORM of choice here, because it's just so beautifully plug and play.

In the example app we'll look at, it's your basic Post/Comment scenario. For each Post/Comment, we want to be able to render the relative time ago that the item in question was submitted (e.g, "16 hours ago", etc). Rails has conventions for this baked in by default (because they, y'know, enjoy polluting namespaces, go figure). When you're outside of Rails and want to do this, it's somewhat more difficult.

Originally I started by storing the creation time as DateTime; makes sense, we should be able to convert this to Time to do easy comparisons against Time.now, right?

Well, uhh, no. Definitely not that simple. After some digging around, I discovered the dm-types gem, which adds some extra types to DataMapper. One of these types is known as EpochTime, which is (you guessed it) the time since Epoch that the entry was created.

With that, we can pretty much stay within the realm of Time. Ripping apart ActionView gives us a good base to work with on the act of getting a relative string; putting it all together, we get the following Sinatra app (two files - the main Sinatra app, and our custom date_helper library).

require 'rubygems'
require 'sinatra'
require 'dm-core'
require 'dm-migrations'
require 'dm-serializer'
require 'dm-types'
require 'date_helper'

DataMapper.setup(:default, {
    :adapter => 'mysql',
    :database => 'database_name',
    :username => 'database_username',
    :password => 'database_password',
    :host => 'localhost'
})

class Post
    include DataMapper::Resource

    property :id, Serial
    property :username, String
    property :entry, Text
    property :created_at, EpochTime

    has n, :comments
end


class Comment
    include DataMapper::Resource

    property :id, Serial
    property :username, String
    property :entry, Text
    property :created_at, EpochTime

    belongs_to :post
end


DataMapper.finalize
DataMapper.auto_upgrade!

# Requests/views/etc
get '/' do
    erb :index
end

post '/post/new' do
    @post = Post.create(
        :username => params[:username],
        :entry => params[:entry],
        :created_at => Time.now.to_i # See? Epoch-goodness.
    )

    if @post
        # .create automatically saves the post; now override the created_at
        # point before we pass back our JSON object to the view.
        # "distance_of_time_in_words" is from date_helper.rb
        @post.created_at = distance_of_time_in_words(@post[:created_at].to_i)
        @post.to_json
    else
        {:error => true}.to_json
    end
end

# Get views for comments, posts, etc
# Proudly (or shamefully, depending on how you look at it) ripped right out
# of ActionView and modified to base it all on the Epoch. Give credit where credit is due.
# from_time expects another judgement from Epoch (e.g, Time.whatever.to_i)
def distance_of_time_in_words(from_time, to_time = Time.now.to_i, include_seconds = false)
    distance_in_minutes = (((to_time - from_time).abs)/60).round
    distance_in_seconds = ((to_time - from_time).abs).round

case distance_in_minutes
when 0..1
return (distance_in_minutes==0) ? 'less than a minute ago' : '1 minute ago' unless include_seconds

case distance_in_seconds
when 0..5   then 'less than 5 seconds ago'
when 6..10  then 'less than 10 seconds ago'
when 11..20 then 'less than 20 seconds ago'
when 21..40 then 'half a minute ago'
when 41..59 then 'less than a minute ago'
else             '1 minute ago'
end

when 2..45           then "#{distance_in_minutes} minutes ago"
when 46..90          then 'about 1 hour ago'
when 90..1440        then "about #{(distance_in_minutes / 60).round} hours ago"
when 1441..2880      then '1 day ago'
when 2881..43220     then "#{(distance_in_minutes / 1440).round} days ago"
when 43201..86400    then 'about 1 month ago'
when 86401..525960   then "#{(distance_in_minutes / 43200).round} months ago"
when 525961..1051920 then 'about 1 year ago'
else                      "over #{(distance_in_minutes / 525600).round} years ago"
end
end

Ryan around the Web