Wednesday, 13 March 2013

A really productive activity logger


I was wondering if my daily time cutter idea was good or not. Should I continue it or not. It already took couple of hours and still a bit clunky. Making the UI is really hard. So if the dynamic visuals is the part that makes developing the app a hassle then probably I should change on the idea. And then it struck me.


So what I was trying to accomplish is to estimate your times on different stuff aiming by the visual time bar. So why not actually saving your activity when it happend? Well, pretty easy to answer - it's a hassle to open an application and write your time and activity and close and open again - bla bla and then count the time spent on each ticket. So what if it's much more easy and still has the preciseness of instant tracking?

Let me tell you my new idea. Imagine a micro web application. When you open the app with a certain query string it can save your current activity. Or set end of a session. Or reset - when you start a new day for example. It stores everything, so you don't have to save manually. But that is still a multistep process - which fails all the time. But! Have you heard about Alfred App? Alfred can called by a simple shortcut and able to craft custom queries easily. Imagine saving your activity is:

ALT + SPACE + "rs Meeting with a customer"

ALT + SPACE is calling Alfred - and "rs" is the shortcut to save your event. I've created an MVP, see how it's working:



Seems easy, right? So every time you start an activity it logges the time so it knows how much time you've spent on it. You can close a session - that means an end of a day - and probably the time when you put all the results to your project management system. And in the morning you can reset the log. Or you can continue, your choice.

The implementation was not too hard, I used CoffeeScript and to tell the truth it's indeed a perfect prototyping tool.

First we need a request handler - so the action router will know what to do:

class RequestUtil
  @queryParams: {}

  @init: ->
    search_items = window.location.search.replace(/\?/, '').split('&')
    save_param = (object, mixed_value) ->
      parts = mixed_value.split '='
      object[parts[0]] = parts[1]
    save_param @queryParams, item for item in search_items

  @execute: ->
    Controller[@queryParams.action]() if @queryParams.hasOwnProperty('action') && Controller.hasOwnProperty(@queryParams.action)

  @reloadBasePath: ->
    window.location.href = window.location.origin + window.location.pathname


@init gets the environmental vars and save it. @execute can really just execute if there is any action implemented for the request by the Controller. And then we need a handy helper to get rid of all action params - to prevent accidental page refresh.

Here you could see we use a Controller to actually do what the request suppose to do:

class Controller
  ###
  Saving a new entry
  Query params:
    - name string Name of the event.
  ###
  @save: ->
    Recorder.getInstance().addEntry RequestUtil.queryParams.name
    RequestUtil.reloadBasePath()

  ###
  Finishing a daily session.
  ###
  @end: ->
    Recorder.getInstance().endSession()
    RequestUtil.reloadBasePath()

  ###
  Resetting the session queue.
  ###
  @reset: ->
    Recorder.getInstance().reset()


I think it's pretty straightforward, it's simply a proxy template for the appropriate mechanism. We have the Recorder class's singleton instance to manipulate our activity. Let's see that:

class Recorder
  entries: []
  @instance

  constructor: ->
    @entries = Storage.get('entry_records', [])

  addEntry: (name) ->
    @entries.push {
      name: name,
      time: (new Date()).getTime()
      type: Entry.TYPE_NORMAL
                  }
    Storage.set 'entry_records', @entries

  endSession: () ->
    @entries.push {
      time: (new Date()).getTime()
      type: Entry.TYPE_END,
                  }
    Storage.set 'entry_records', @entries

  reset: () ->
    @entries = []
    Storage.delete('entry_records')

  entryList: () ->
    @entries

  @getInstance: () ->
    @instance || new Recorder()


It's dead simple, it's a tiny CRUD engine for the entries array, which contains all the activity-time bundles. We use 2 type of entries, a simple and a closing entry, that just ends of a session and a mark an end date:

class Entry
  @TYPE_NORMAL: 0x01
  @TYPE_END: 0x02


In order to keep our data we need a permanent storage - being agile I chose HTML5 localStorage, so I added a tiny engine around it:

class Storage
  @storage: null

  @init: () ->
    if !window.localStorage.hasOwnProperty 'actionSaver'
      @storage = {}
    else
      @storage = JSON.parse window.localStorage.actionSaver

  @sync: () ->
    json_val = JSON.stringify(@storage)
    window.localStorage.actionSaver = json_val

  @get: (key, default_value) ->
    @storage[key] || default_value

  @set: (key, value) ->
    @storage[key] = value
    @sync()

  @delete: (key) ->
    delete @storage[key]
    @sync()


So it's flexible and anything can use it with arrays and objects as well, not just strings. And that's the heart of the system. We need then a rendering engine:

class Render
  @refreshUI: ->
    entries = Recorder.getInstance().entries
    out = EntryListFormatter.format(entries)
    jQuery('#report').html out


And you can see here that to make it pluggable I added a separate formatter unit that just accomplish a simple table:

class EntryListFormatter
  @format: (entries) ->
    out = ''
    for entry, idx in entries
      entry_next = if idx >= entries.length - 1 then null else entries[idx + 1]
      if entry.type == Entry.TYPE_NORMAL
        out = out + @formatSimpleHTML(entry, entry_next)

      if entry.type == Entry.TYPE_END
        out = out + @formatEnd()
    '<table><thead><th>Name</th><th>From</th><th>To</th><th>Interval</th></thead>' + out + '</table>'

  @formatSimpleHTML: (item, item_next) ->
    time_from = (new Date(item.time)).toLocaleTimeString()
    date_to = if item_next then new Date(item_next.time) else new Date()
    time_to = date_to.toLocaleTimeString()
    interval_seconds = date_to.getTime() - item.time
    interval_hours = Math.floor(interval_seconds / 3600000)
    interval_minutes = Math.floor((interval_seconds % 3600000) / 60000)
    interval_seconds_only = Math.floor((interval_seconds % 60000) / 1000)
    interval_text = interval_hours + 'h ' + interval_minutes + 'm ' + interval_seconds_only + 's'
    '<tr><td>' + [decodeURIComponent(item.name), time_from, time_to, interval_text].join('</td><td>') + '</td></tr>'

  @formatEnd: () ->
    '<tr><td colspan="4" class="end"></td></tr>'


That's it. Now one thing left - the bootsrap:

jQuery ->
  Storage.init()
  RequestUtil.init()
  RequestUtil.execute()
  Render.refreshUI()


Again, nothing unusual. That makes sure that it prefetches all stored items and execute the request and presents the data.

And the whole shebang would worth nothing if don't have Alfred. In Alfred I've set up 4 custom queries:



So when I type "rs Bla bla bla" it adds a new activity at the exact type with the title of "Bla bla bla".

What do you think?

Oh btw, of course it's available on the GitHub repo page. If you want I can setup a site online - or you can even have it locally if you know how to compile CoffeeScript. Let me know if you need any help.

---

Peter

2 comments:

  1. Our internal project management tool (implemented in Drupal) uses comment_timer.module: when you open your ticket, the timer automagically starts (may be paused, though, and you can even enter any amount of time); when you submit your comment (eg. closing your ticket), the time you spent with it gets recorded. It even has a Views interface, so you can even have some pretty (and/or useless) summary. As it's based on JavaScript's window.setTimeout, the timer is automagically paused when you close your eye^Wlaptop's lid, er, go to sleep mode. Developing for the web needs an open browser all the time anyway - just pin the project management tool tab, and you don't even have to fiddle with some cryptic queries. Nice and productive, ha? :)

    ReplyDelete
  2. Cryptic queries? :D The one you explained is cool, however for me it would be too many steps to fail at. Opening the browser, opening the page, setting the ticket, then closing it.
    However my 3 key solution is also suffering from my attention problems. I'm 3 task away when realize I forget to blink one. I guess what we need is an intelligent electroshocking system. Whenever we miss a report it shocks us. Or burn a Jira logo on the neck. I bet we can do even the most inefficient time reports :)

    ReplyDelete