Smart dates in CL

Sometimes it’s convenient to present dates in a way that depends on their offset from the current time.

For example, in different resolutions: 23 seconds ago, one minute ago, two days ago.

Another example, making use of human naming conventions: yesterday, Monday (implicitly assuming the closest Monday before the current date).

In Common Lisp, without further babbling:

(load "time.lisp")
;; http://cybertiggyr.com/gene/pdl/time.lisp
;; you could also use, for example, CL-L10N.
 
(defmacro base-bind (unit-var amount (&rest var-and-radix) &body code)
  "Thanks to Alan Crowe for this wonderful macro."
  (if (endp var-and-radix)
    `(let ((,unit-var ,amount)) ,@code)
    (let ((transfer (gensym)))
      `(multiple-value-bind (,transfer ,unit-var)
         (floor ,amount ,(cadar var-and-radix))
         (base-bind ,(caar var-and-radix) ,transfer ,(cdr var-and-radix)
                    ,@code)))))
 
(defun smart-date (then)
  (let ((now (get-universal-time)))
    (base-bind now-sec now ((now-min 60) (now-hour 60) (now-day 24))
      (base-bind then-sec then ((then-min 60) (then-hour 60) (then-day 24))
        (base-bind diff-sec (- now then) ((diff-min 60) (diff-hour 60) (diff-day 24))
          (cond
            ;; add more stuff here (e.g. negative offsets) and modify to suit your needs
            ((> diff-day 6) (CYBERTIGGYR-TIME:format-time nil CYBERTIGGYR-TIME:*FORMAT-TIME-FULL* then))
            ((> diff-day 1) (CYBERTIGGYR-TIME:format-time nil "%A" then))
            ((= diff-day 1) "Yesterday")
            ((> diff-hour 0) (format nil "~Dh~Dm ago" diff-hour diff-min))
            ((> diff-min 0) (format nil "~Dm~Ds ago" diff-min diff-sec))
            (t (format nil "~D seconds ago" diff-sec))))))))
 
; demonstration/test
(loop for offset in (list 36 90 120 130 3599 3600 3601 86400 86500 173000 14290010)
      do (format t "~D: ~A~%" offset (smart-date (- (get-universal-time) offset))))
 
; output:
36: 36 seconds ago
90: 1m30s ago
120: 2m0s ago
130: 2m10s ago
3599: 59m59s ago
3600: 1h0m ago
3601: 1h0m ago
86400: Yesterday
86500: Yesterday
173000: Tuesday
14290010: Sunday, 2008 January 27, 03:06 +1

Again, I’d like to see solutions from other languages.

Comments

  1. July 10th, 2008 | 4:43 pm

    Ok, to save you from having to indent the above comment again I’m trying bbcode (a preview function would really help ;-)).

    Here’s my attempt on it in python (keep in mind that I haven’t done much with python yet, so this could probably be done in a more pythonic way):

    #!/usr/bin/env python
     
    from datetime import datetime, timedelta
     
    def smartdate(now, offset):
        delta = {}
        base = offset
     
        delta['d'] = offset / (60*60*24)
        if delta['d'] > 0:
            offset = (offset - ((60*60*24) * delta['d']))
     
        delta['h'] = offset / (60*60)
        if delta['h'] > 0:
            offset = (offset - ((60*60) * delta['h']))
     
        delta['m'] = offset / 60
        if delta['m'] > 0:
            offset = (offset - (60 * delta['m']))
     
        delta['s'] = offset
     
        if delta['d'] > 1:
            if delta['d'] > 6:
                date = now + timedelta(days=-delta['d'], hours=-delta['h'], minutes=-delta['m'])
                return date.strftime('%A, %Y %B %m, %H:%I')
            else:
                wday = now + timedelta(days=-delta['d'])
                return wday.strftime('%A')
        if delta['d'] == 1:
            return "Yesterday"
        if delta['h'] > 0:
            return "%dh%dm ago" % (delta['h'], delta['m'])
        if delta['m'] > 0:
            return "%dm%ds ago" % (delta['m'], delta['s'])
        else:
            return "%ds ago" % delta['s']
     
    if __name__ == '__main__':
        now = datetime.utcnow()
        offsets = [ 36, 90, 120, 130, 3599, 3600, 3601, 86400, 86500, 173000, 14290010 ]
        for offset in offsets:
            print "%d: %s" % (offset, smartdate(now, offset))
  2. July 10th, 2008 | 5:04 pm

    Thanks for the Python version! :)

    Use <pre lang="LANG"> to mark code blocks, where LANG is a valid GeSHi language identifier.

    I’ll probably install a comment preview plugin.

    Don’t worry, editing comments isn’t such a huge chore. I don’t have to reindent stuff.

  3. July 10th, 2008 | 5:37 pm

    Aha thanks for the info :-).

    I really like posts like this one, solving these little problems is always fun, so keep ‘em coming!

    P.S.: Btw, it seems all “>” in my comment were converted to “>” (and I just noticed that the variable “base” in the function is obsolete, a leftover from previous attempts).

  4. Ben Zimmerman
    July 12th, 2008 | 7:41 am

    Here is my python version. A few pieces such as the date format strings were borrowed from Michael Klier’s version.

    from datetime import datetime, timedelta
     
    MINUTE = 60
    HOUR = 60 * 60
    DAY = 60 * 60 * 24
     
    def smart_date(now, offset):
        if offset &lt; MINUTE:
            return '%s seconds ago' % offset
        if offset &lt; HOUR:
            return '%sm%ss ago' % (offset // MINUTE, offset % MINUTE)
        if offset &lt; DAY:
            return '%sh%sm ago' % (offset // HOUR, (offset % HOUR) // MINUTE)
        if offset &lt; (DAY * 2):
            return 'Yesterday'
        old_date = (now - timedelta(seconds=offset))
        if offset &lt; (DAY * 7):
            return old_date.strftime('%A')
        return old_date.strftime('%A, %Y %B %m, %H:%I')
     
    if __name__ == '__main__':
        now = datetime.utcnow()
        for offset in [36, 90, 120, 130, 3599, 3600, 3601, 86400, 86500, 173000, 14290010]:
            print "%d: %s" % (offset, smart_date(now, offset))
    # ouput
    36: 36 seconds ago
    90: 1m30s ago
    120: 2m0s ago
    130: 2m10s ago
    3599: 59m59s ago
    3600: 1h0m ago
    3601: 1h0m ago
    86400: Yesterday
    86500: Yesterday
    173000: Thursday
    14290010: Monday, 2008 January 01, 20:08
  5. July 13th, 2008 | 1:33 am

    I tried to duplicate your CL algorithm because I thought it was clever.

    #!/usr/bin/ruby
     
    class Time
      def smart_date
        base_bind(Time.now - self, 60, 60, 24) do |sec, min, hour, day|
          if day &gt; 6
            to_s
          elsif day &gt; 1
            strftime '%A'
          elsif day == 1
            'Yesterday'
          elsif hour &gt; 0
            "%dh%dm ago" % [hour, min]
          elsif min &gt; 0
            "%dm%ds ago" % [min, sec]
          else
            "%d seconds ago" % sec
          end
        end
      end
     
      private
      def base_bind(amount, *radices)
        if radices.empty?
          yield amount
        else
          radix = radices.shift
          quotient, remainder = amount.divmod(radix)
          base_bind(quotient, *radices) do |*args|
            yield *args.unshift(remainder)
          end
        end
      end
    end
     
     
    [36, 90, 120, 130, 3599, 3600, 3601, 86400, 86500, 173000, 14290010].each do |offset|
      puts "%d: %s" % [offset, (Time.now - offset).smart_date]
    end
  6. July 13th, 2008 | 11:10 am

    Yeah, you know I prefer CL, but… lovely! :)

  7. pgd
    August 3rd, 2008 | 10:07 pm

    Well, a Java solution must be presented, also; Joda-time: http://joda-time.sourceforge.net/

  8. August 4th, 2008 | 9:14 am

    Looks good! Too bad it’s in Java…

  9. September 23rd, 2008 | 12:48 am

    floor’ing thrice? um, what about subtracting first then flooring?

    btw, greetz in your blog is not something i would expect to see. i would like to see the documentation of that macro. it’s hard to figure out what your syntactic sugars do.

  10. September 23rd, 2008 | 10:20 am

    http://www.google.com/search?q=alan+crowe+base-bind

    Unfortunately Alan hasn’t provided documentation for BASE-BIND, either.

    But it’s not very hard to figure out from the name and the usage in the code.

    You’re right of course that it doesn’t end up numerically optimal. How about posting a version with that flaw fixed? :)

  11. mango
    December 12th, 2008 | 5:44 pm

    Why do you nest the base-bind 3 times? Seems like you only use the diff-* variables in the body.

    PS: wordpress uses horizontal space really well (NOT!)

Leave a reply