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