Worktime Tracker

A simple worktime tracker with record history.

Image preview of Worktime Tracker plugin.

worktime-tracker.1s.rb

Edit
Open on GitHub
#!/usr/bin/env LC_ALL=en_US.UTF-8 ruby

# <xbar.title>Worktime Tracker</xbar.title>
# <xbar.version>v0.1</xbar.version>
# <xbar.author>Ash Wu(hSATAC)</xbar.author>
# <xbar.author.github>hSATAC</xbar.author.github>
# <xbar.desc>A simple worktime tracker with record history.</xbar.desc>
# <xbar.dependencies>ruby</xbar.dependencies>

# Functionality:
# * Start / stop / pause / resume the timing session.
# * Keep history records and export to a txt file.
# * Rename session.

require 'json'

### ASSETS ###

ICON_PLAY = "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"
ICON_PLAY_COLOR = "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"
ICON_PAUSE   = "iVBORw0KGgoAAAANSUhEUgAAACoAAAAqCAYAAADFw8lbAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAABYlAAAWJQFJUiTwAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAABuElEQVRYCe1XUUrEQAzd7ioIiifw3ysIHsRbeBE/BI8h+CseSP/0RxBEdlvfW+dJpkxtp0RQNoHsZJI3SfrapeliERIMBAPBQDCwEww0c66y67oVzpXObpqm6WxOYIkjvi8tsG3f6bZPhQfz2bi1SwfG4vZMiRUbz2wmJmOQCwTOoW9QMfYB+xrx59QAsS3sE/gvoWKaLB5CHxC/R3xJHPZ+gqTbW4j1DlqSU1ZDYEVN9lkJCN9Niu9xHZNJIJNErLwm3zvWJXQfSnY3UApxultr2PTTR6V9ACV+stQ2qsT6c/A8bTZFW81phev70aBNUYwXOFmqwANZxfJAOHPXYLODHo1mCX9rE416MxuMBqPeDHjn26lnVG8abxKzfB6Mzn7bZJ2MbDwaHSnhE/ZotObW12CzK5w7PWmc4wjHoZdjHm09BlzVFO3+mMe6VcNybaMqfoxCFM6VkiMYGv+EY4w15OeeF0XhlD9ZahsVC7eo8ATtf4q8pMrEqdlH2FdQsc3Y9lMEK0U5v3Zev/iEUAPFlDZu7RJ4LG7P/FjUAq2NAprqrZv23/lc7ncW+2AgGAgGgoH/wcAnzm2uPw9Tc7IAAAAASUVORK5CYII="

### HELPERS ###

def prompt(question, default)
    result = `/usr/bin/osascript -e 'Tell application "System Events" to display dialog "#{question}" default answer "#{default}"' -e 'text returned of result' 2>/dev/null`.strip
    result.empty? ? defult : result
end

def notification(msg, title)
  `/usr/bin/osascript -e 'display notification "#{msg}" with title "#{title}"'`
end

### CLASSES ###

class WorkSession
  attr_reader :start_time, :end_time, :pause_time, :resume_time, :saved_duration, :name, :state

  def initialize(name)
    @name = name
    @state = "stopped"
    @saved_duration = 0
  end

  def duration
    t = @saved_duration

    if @state != "paused"
      if @resume_time
        t += (Time.now - @resume_time)
      else
        t += (Time.now - @start_time)
      end
    end

    Time.at(t).utc.strftime("%H:%M:%S")
  end

  def start
    return if @state != "stopped"
    @state = "recording"
    @start_time = Time.now
  end

  def stop
    return if @state != "recording"
    @state = "stopped"
    @end_time = Time.now
  end

  def last_start_time
    @resume_time || @start_time
  end

  def pause
    return if @state != "recording"
    @state = "paused"
    @pause_time = Time.now
    @saved_duration += (@pause_time - last_start_time)
  end

  def resume
    return if @state != "paused"
    @state = "recording"
    @resume_time = Time.now
  end

  def rename(new_name)
    @name = new_name
  end

  def to_s
    "#{@name}, #{@start_time} ~ #{@end_time}, #{duration}"
  end
end

class WorkTimer
  attr_reader :workdir, :session

  def initialize(workdir = nil)
    @workdir = workdir || File.dirname(__FILE__)
    load_session
  end

  def session_file
    File.join @workdir, ".worktimer.dat"
  end

  def history_file
    File.join @workdir, "worktimer", "history.txt"
  end

  def state
    @session.nil? ? "stopped" : @session.state
  end

  def start
    session_name = prompt("Enter session name:", "Unnamed Session")
    @session = WorkSession.new session_name
    @session.start
    save_session

    notification("New session [#{@session.name}] has started.", "Worktime Tracker")
  end

  def stop
    @session.stop
    save_history
    delete_session

    notification("You spent #{duration} in [#{@session.name}]", "Worktime Tracker")
  end

  def pause
    @session.pause
    save_session

    notification("Session [#{@session.name}] has been paused.", "Worktime Tracker")
  end

  def resume
    @session.resume
    save_session

    notification("Session [#{@session.name}] has been resumed.", "Worktime Tracker")
  end

  def rename
    abort "Session does not exist." if @session.nil?
    session_name = prompt("Enter new session name:", @session.name)
    @session.rename session_name
    save_session

    notification("Current session renamed to: [#{@session.name}]", "Worktime Tracker")
  end

  def duration
    @session.nil? ? '' : @session.duration
  end

  def save_session
    File.open(session_file, 'w') {|f| f.write(Marshal.dump(@session)) }
  end

  def delete_session
    File.delete(session_file)
  end

  def load_session
    if File.exist? session_file
      @session = Marshal.load(File.read(session_file))
    end
  end

  def save_history
    system 'mkdir', '-p', File.dirname(history_file)
    File.open(history_file, 'a') { |f| f.write("#{session}\n") }
  end

  def history
    if File.exist? history_file
      system '/usr/bin/open', history_file
    else
      notification("History file not found.", "Worktime Tracker")
    end
  end
end

timer = WorkTimer.new

### ACTIONS ###

if ARGV[0]
  action = ARGV[0].to_sym
  if timer.respond_to? action
    timer.send(action)
    exit
  end
end

### RENDER ###

REFRESH = "---\nReload| refresh=true"


case timer.state
when "stopped"
  TITLE = "|templateImage=#{ICON_PLAY}"
  MENU = """
Start | bash='#{__FILE__}' param1=start terminal=false
"""
when "recording"
  TITLE = " #{timer.duration} | image=#{ICON_PLAY_COLOR}"
  MENU = """
#{timer.session.name} | bash='#{__FILE__}' param1=rename terminal=false
---
Pause | bash='#{__FILE__}' param1=pause terminal=false
Stop | bash='#{__FILE__}' param1=stop terminal=false
"""
when "paused"
  TITLE = "#{timer.duration} |templateImage=#{ICON_PAUSE}"
  MENU = """
#{timer.session.name} | bash='#{__FILE__}' param1=rename terminal=false
---
Resume | bash='#{__FILE__}' param1=resume terminal=false
"""
end

HISTORY = "History | bash='#{__FILE__}' param1=history terminal=false"

puts """
#{TITLE}
---
#{MENU}
---
#{HISTORY}
---
#{REFRESH}
"""