time_tap 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +42 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +48 -0
- data/LICENSE +20 -0
- data/README.md +96 -0
- data/Rakefile +49 -0
- data/VERSION +1 -0
- data/bin/timetap +55 -0
- data/config.yaml +8 -0
- data/lib/time_tap.rb +128 -0
- data/lib/time_tap/daemon.rb +24 -0
- data/lib/time_tap/editors.rb +23 -0
- data/lib/time_tap/project.rb +149 -0
- data/lib/time_tap/server.rb +81 -0
- data/lib/time_tap/tasks.rb +68 -0
- data/lib/time_tap/views/index.haml +38 -0
- data/lib/time_tap/views/layout.haml +14 -0
- data/lib/time_tap/views/project.haml +24 -0
- data/lib/time_tap/views/project_day.haml +24 -0
- data/lib/time_tap/views/stylesheet.sass +151 -0
- data/lib/time_tap/watcher.rb +49 -0
- data/spec/.rspec +1 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/time_tap_spec.rb +7 -0
- data/time_tap.gemspec +115 -0
- metadata +344 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
# Define Process::daemon without waiting for active support to be loaded
|
2
|
+
# so that the "timetap" command exits immediatly.
|
3
|
+
# from active_support-3
|
4
|
+
def Process.daemon(nochdir = nil, noclose = nil)
|
5
|
+
exit if fork # Parent exits, child continues.
|
6
|
+
Process.setsid # Become session leader.
|
7
|
+
exit if fork # Zap session leader. See [1].
|
8
|
+
|
9
|
+
unless nochdir
|
10
|
+
Dir.chdir "/" # Release old working directory.
|
11
|
+
end
|
12
|
+
|
13
|
+
File.umask 0000 # Ensure sensible umask. Adjust as needed.
|
14
|
+
|
15
|
+
unless noclose
|
16
|
+
STDIN.reopen '/dev/null' # Free file descriptors and
|
17
|
+
STDOUT.reopen '/dev/null', "a" # point them somewhere sensible.
|
18
|
+
STDERR.reopen '/dev/null', 'a'
|
19
|
+
end
|
20
|
+
|
21
|
+
trap("TERM") { exit }
|
22
|
+
|
23
|
+
return 0
|
24
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module TimeTap
|
2
|
+
module Editors
|
3
|
+
class EditorError < StandardError
|
4
|
+
end
|
5
|
+
|
6
|
+
class TextMate
|
7
|
+
require 'appscript'
|
8
|
+
include Appscript
|
9
|
+
|
10
|
+
def is_running?
|
11
|
+
not(`ps -ax -o comm|grep TextMate`.chomp.strip.empty?)
|
12
|
+
end
|
13
|
+
|
14
|
+
def current_path
|
15
|
+
mate = app('TextMate')
|
16
|
+
document = mate.document.get
|
17
|
+
raise(EditorError) if document.blank?
|
18
|
+
path = document.first.path.get rescue nil
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
module TimeTap
|
2
|
+
class Project
|
3
|
+
attr_reader :pinches
|
4
|
+
class << self
|
5
|
+
attr_accessor :pause_limit
|
6
|
+
def pause_limit
|
7
|
+
@pause_limit ||= 30.minutes
|
8
|
+
end
|
9
|
+
|
10
|
+
def history_file
|
11
|
+
"#{TimeTap.config[:root]}/.tap_history"
|
12
|
+
end
|
13
|
+
|
14
|
+
def load_file path
|
15
|
+
|
16
|
+
File.open(File.expand_path(path), 'r', RUBY19 ? {:external_encoding => 'utf-8'} : nil) do |file|
|
17
|
+
file.each_line do |line|
|
18
|
+
time, path = line.split(": ")
|
19
|
+
|
20
|
+
project = self[path]
|
21
|
+
project << time.to_i if project
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def projects
|
27
|
+
@projects ||= HashWithIndifferentAccess.new
|
28
|
+
end
|
29
|
+
|
30
|
+
def all
|
31
|
+
load_file(history_file) if projects.empty?
|
32
|
+
projects.values
|
33
|
+
end
|
34
|
+
|
35
|
+
def find name
|
36
|
+
load_file(history_file) if projects.empty?
|
37
|
+
projects[name.underscore.downcase]
|
38
|
+
end
|
39
|
+
|
40
|
+
def [] path
|
41
|
+
path = File.expand_path(path)
|
42
|
+
how_nested = 1
|
43
|
+
|
44
|
+
regex_suffix = "([^/]+)"
|
45
|
+
if TimeTap.config["nested_project_layers"] && TimeTap.config["nested_project_layers"].to_i > 0
|
46
|
+
how_nested = TimeTap.config["nested_project_layers"].to_i
|
47
|
+
|
48
|
+
# nested project layers works "how many folders inside your code folder
|
49
|
+
# do you keep projects.
|
50
|
+
#
|
51
|
+
# For example, if your directory structure looks like:
|
52
|
+
# ~/Code/
|
53
|
+
# Clients/
|
54
|
+
# AcmeCorp/
|
55
|
+
# website/
|
56
|
+
# intranet
|
57
|
+
# BetaCorp/
|
58
|
+
# skunkworks/
|
59
|
+
# OpenSource/
|
60
|
+
# project_one/
|
61
|
+
# timetap/
|
62
|
+
#
|
63
|
+
# A nested_project_layers setting of 2 would mean we track "AcmeCorp", "BetaCorp", and everything
|
64
|
+
# under OpenSource, as their own projects
|
65
|
+
regex_suffix = [regex_suffix] * how_nested
|
66
|
+
regex_suffix = regex_suffix.join("/")
|
67
|
+
end
|
68
|
+
|
69
|
+
res = path.scan(%r{(#{TimeTap.config[:code] || "Code"})/#{regex_suffix}}).flatten
|
70
|
+
mid_path = res[0] # not in a MatchObj group any more, so it's 0 based
|
71
|
+
name = res[how_nested]
|
72
|
+
mid_path, name = path.scan(%r{#{File.expand_path("~")}/([^/]+)/([^/]+)}).flatten if name.nil?
|
73
|
+
if name
|
74
|
+
name.chomp!
|
75
|
+
key = name.underscore.downcase
|
76
|
+
if projects[key].nil?
|
77
|
+
project = Project.new mid_path, name
|
78
|
+
projects[key] = project
|
79
|
+
end
|
80
|
+
projects[key]
|
81
|
+
else
|
82
|
+
nil
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
attr_reader :name, :path
|
88
|
+
def initialize mid_path, name
|
89
|
+
@name = name
|
90
|
+
@path = File.expand_path("~/#{mid_path}/#{name}/")
|
91
|
+
@pinches = []
|
92
|
+
end
|
93
|
+
|
94
|
+
class Pinch
|
95
|
+
attr_accessor :start_time, :end_time
|
96
|
+
def initialize start_time
|
97
|
+
@end_time = @start_time = start_time
|
98
|
+
end
|
99
|
+
|
100
|
+
def duration
|
101
|
+
end_time ? end_time - start_time : 30.seconds
|
102
|
+
end
|
103
|
+
|
104
|
+
def humanized_duration
|
105
|
+
seconds = duration
|
106
|
+
|
107
|
+
hours = mins = 0
|
108
|
+
if seconds >= 60 then
|
109
|
+
mins = (seconds / 60).to_i
|
110
|
+
seconds = (seconds % 60 ).to_i
|
111
|
+
|
112
|
+
if mins >= 60 then
|
113
|
+
hours = (mins / 60).to_i
|
114
|
+
mins = (mins % 60).to_i
|
115
|
+
end
|
116
|
+
end
|
117
|
+
"#{hours}h #{mins}m #{seconds}s"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def << time
|
122
|
+
time = Time.at time
|
123
|
+
last_pinch = pinches.last
|
124
|
+
if pinches.empty?
|
125
|
+
pinches << Pinch.new(time)
|
126
|
+
else
|
127
|
+
last_time = last_pinch.end_time
|
128
|
+
return unless time > last_time
|
129
|
+
|
130
|
+
if (time - last_time) < self.class.pause_limit
|
131
|
+
last_pinch.end_time = time
|
132
|
+
else
|
133
|
+
pinches << Pinch.new(time)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def work_time
|
139
|
+
pinches.map(&:duration).inject(0.seconds, &:+)
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
def days
|
144
|
+
pinches.group_by do |pinch|
|
145
|
+
pinch.start_time.to_date
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
require 'haml'
|
3
|
+
require 'sass'
|
4
|
+
require 'action_view'
|
5
|
+
|
6
|
+
module TimeTap
|
7
|
+
class Server < Sinatra::Application
|
8
|
+
|
9
|
+
include ActionView::Helpers::DateHelper
|
10
|
+
set :haml, { :format => :html5,
|
11
|
+
:attr_wrapper => '"' ,
|
12
|
+
:encoding => RUBY19 ? 'UTF-8' : nil}
|
13
|
+
set :root, File.dirname(__FILE__)
|
14
|
+
set :views, Proc.new { File.expand_path("../views", __FILE__) }
|
15
|
+
|
16
|
+
|
17
|
+
before do
|
18
|
+
content_type "text/html", :charset => "utf-8"
|
19
|
+
Project.load_file('~/.tap_history')
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
|
26
|
+
get '/' do
|
27
|
+
sort = (params[:sort] || :last).to_sym
|
28
|
+
|
29
|
+
@projects = Project.all.sort_by do |project|
|
30
|
+
case sort
|
31
|
+
when :name; project.name
|
32
|
+
when :last; -project.pinches.last.end_time.to_i
|
33
|
+
when :elapsed; -project.work_time
|
34
|
+
end
|
35
|
+
end.select{|p| p.work_time > 30.minutes}
|
36
|
+
|
37
|
+
haml :index
|
38
|
+
end
|
39
|
+
|
40
|
+
get '/project/:name' do
|
41
|
+
@project = Project.find(params[:name])
|
42
|
+
redirect '/' if @project.nil?
|
43
|
+
|
44
|
+
haml :project
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
get "/project/:name/:day" do
|
49
|
+
@project = Project.find(params[:name])
|
50
|
+
redirect '/' if @project.nil?
|
51
|
+
|
52
|
+
days = @project.days.to_a.sort_by(&:first).reverse
|
53
|
+
@current_day = days[ params[:day].to_i ][0] #pick an arbitrary date in the pinches
|
54
|
+
|
55
|
+
@pinches = @project.days[@current_day]
|
56
|
+
haml :project_day
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
get "/stylesheet.css" do
|
61
|
+
content_type "text/css", :charset => "utf-8"
|
62
|
+
sass :stylesheet
|
63
|
+
end
|
64
|
+
|
65
|
+
get '/mate' do
|
66
|
+
tm_project = File.expand_path("#{TimeTap.config[:textmate][:projects] || 'Development/Current Projects'}/#{File.basename(params[:path])}.tmproj")
|
67
|
+
if File.exist?(tm_project)
|
68
|
+
`open "#{tm_project}"`
|
69
|
+
else
|
70
|
+
`mate "#{params[:path]}"`
|
71
|
+
end
|
72
|
+
redirect '/'
|
73
|
+
end
|
74
|
+
|
75
|
+
get '/stop' do
|
76
|
+
$stop = true
|
77
|
+
Process.exit
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
namespace :timetap do
|
2
|
+
|
3
|
+
plist_name = "com.eliaesocietas.TimeTap.plist"
|
4
|
+
home = File.expand_path("~")
|
5
|
+
ruby = ENV['TAP_RUBY'] || "#{home}/.rvm/bin/ruby-1.9.2-p0@global"
|
6
|
+
plist_path = File.expand_path("#{home}/Library/LaunchAgents/#{plist_name}")
|
7
|
+
launcher = File.expand_path('../../../bin/timetap', __FILE__)
|
8
|
+
include_dir = '-I'+File.expand_path('../../../lib', __FILE__)
|
9
|
+
|
10
|
+
desc "Add a plist for OSX's launchd and have *TimeTap* launched automatically at login."
|
11
|
+
task :launcher do
|
12
|
+
puts "\nCreating launchd plist in\n #{plist_path}"
|
13
|
+
|
14
|
+
File.open(plist_path, 'w') do |file|
|
15
|
+
file << <<-PLIST
|
16
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
17
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
18
|
+
<plist version="1.0">
|
19
|
+
<dict>
|
20
|
+
<key>Label</key>
|
21
|
+
<string>com.eliaesocietas.TimeTap</string>
|
22
|
+
|
23
|
+
<key>Program</key>
|
24
|
+
<string>#{ruby}</string>
|
25
|
+
|
26
|
+
<key>ProgramArguments</key>
|
27
|
+
<array>
|
28
|
+
<string>#{ruby}</string>
|
29
|
+
<string>#{include_dir}</string>
|
30
|
+
<string>#{launcher}</string>
|
31
|
+
<string>-f</string>
|
32
|
+
</array>
|
33
|
+
|
34
|
+
<key>OnDemand</key>
|
35
|
+
<false/>
|
36
|
+
|
37
|
+
<key>RunAtLoad</key>
|
38
|
+
<true/>
|
39
|
+
</dict>
|
40
|
+
</plist>
|
41
|
+
PLIST
|
42
|
+
|
43
|
+
puts "\nCreated. Now run:\n launchctl load ~/Library/LaunchAgents\n\n"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
desc "Restarts the daemon."
|
48
|
+
task :restart do
|
49
|
+
command = "launchctl unload #{plist_path}; launchctl load #{plist_path}"
|
50
|
+
puts command
|
51
|
+
exec command
|
52
|
+
end
|
53
|
+
|
54
|
+
desc "Stops the daemon."
|
55
|
+
task :stop do
|
56
|
+
command = "launchctl unload #{plist_path}"
|
57
|
+
puts command
|
58
|
+
exec command
|
59
|
+
end
|
60
|
+
|
61
|
+
desc "Starts the daemon."
|
62
|
+
task :start do
|
63
|
+
command = "launchctl load #{plist_path}"
|
64
|
+
puts command
|
65
|
+
exec command
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
%table
|
2
|
+
%tr.header
|
3
|
+
%th
|
4
|
+
%a{:href => '/?'+params.merge('sort' => :name).map{|pair| pair.join('=')}.join(';')} Project
|
5
|
+
%th
|
6
|
+
%a{:href => '/?'+params.merge('sort' => :elapsed).map{|pair| pair.join('=')}.join(';')} Work Time
|
7
|
+
%th
|
8
|
+
%a{:href => '/?'+params.merge('sort' => :last).map{|pair| pair.join('=')}.join(';')} Last Access
|
9
|
+
|
10
|
+
- @projects.each do |project|
|
11
|
+
- elapsed_time = project.work_time.to_i
|
12
|
+
- hours = elapsed_time/1.hour
|
13
|
+
%tr
|
14
|
+
%th{:title => project.name}
|
15
|
+
%a.project{:href => "/project/#{project.name}", :title => "#{hours.to_i}h \n eur#{hours*20}@eur20/h \n eur#{hours*25}@eur25/h \n eur#{hours*30}@eur30/h"}= project.name ? project.name.underscore.humanize : '<i>other</i>'
|
16
|
+
%td
|
17
|
+
- if elapsed_time < 1.hours
|
18
|
+
= elapsed_minutes = elapsed_time / 1.minute
|
19
|
+
= elapsed_minutes == 1 ? 'minute' : 'minutes'
|
20
|
+
- elsif elapsed_time < 8.hours
|
21
|
+
= elapsed_hours = elapsed_time / 1.hour
|
22
|
+
= elapsed_hours == 1 ? 'hour' : 'hours'
|
23
|
+
- else
|
24
|
+
= elapsed_days = elapsed_time / 8.hours
|
25
|
+
man
|
26
|
+
= elapsed_days == 1 ? 'day' : 'days'
|
27
|
+
- if (elapsed_hours = (elapsed_time % 8.hours) / 1.hour) >= 1
|
28
|
+
and
|
29
|
+
= elapsed_hours
|
30
|
+
= elapsed_hours == 1 ? 'hour' : 'hours'
|
31
|
+
|
32
|
+
|
33
|
+
%td
|
34
|
+
%i #{time_ago_in_words Time.at(project.pinches.last.end_time)} ago
|
35
|
+
%td
|
36
|
+
%a.tip{:href => "/mate?path=#{project.path}"} mate
|
37
|
+
|
38
|
+
.clear
|
@@ -0,0 +1,14 @@
|
|
1
|
+
!!! Strict
|
2
|
+
%html(html_attrs)
|
3
|
+
%head
|
4
|
+
%title TimeTap Log
|
5
|
+
%link{ :rel => 'stylesheet', :media => 'screen', :type => "text/css", :href => "/stylesheet.css?#{File.stat(__FILE__).mtime.to_i}" }
|
6
|
+
%body
|
7
|
+
%h1
|
8
|
+
TimeTap Log
|
9
|
+
%span.tip= Time.now.to_s
|
10
|
+
#content
|
11
|
+
#page_contents= yield
|
12
|
+
|
13
|
+
%a.tip{:href => "/stop"} stop
|
14
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
- this_year = Time.now.year
|
2
|
+
|
3
|
+
.back
|
4
|
+
%a{:href => '/'} « Home
|
5
|
+
.clear
|
6
|
+
%h2
|
7
|
+
Project:
|
8
|
+
%b= @project.name.humanize
|
9
|
+
%span.tip (#{@project.work_time.to_i / 1.hours} hours)
|
10
|
+
|
11
|
+
%p.tip #{@project.pinches.size} pinches, #{time_ago_in_words Time.now - (@project.work_time / @project.days.size / 1.hour).hours} per day in #{@project.days.size} days of work
|
12
|
+
|
13
|
+
%table.days
|
14
|
+
- current = 0
|
15
|
+
- @project.days.to_a.sort_by(&:first).reverse.each do |day, pinches|
|
16
|
+
- time = pinches.map(&:duration).inject(0.seconds, &:+)
|
17
|
+
- if time >= 1.minute
|
18
|
+
%tr.day
|
19
|
+
%td
|
20
|
+
%a.day{ :href=> "/project/#{@project.name}/#{current}" } #{( this_year != day.year ? day.strftime("%Y %b %d, %a") : day.strftime("%b %d, %a") )}
|
21
|
+
%td
|
22
|
+
%span.work{:style => "background-color:green;color:#ccc;overflow:visible;height:1em"}= ' ' * (time / 5.minutes.to_f)
|
23
|
+
%span.tip= time_ago_in_words Time.now - time
|
24
|
+
- current = current + 1
|
@@ -0,0 +1,24 @@
|
|
1
|
+
- this_year = Time.now.year
|
2
|
+
|
3
|
+
.back
|
4
|
+
%a{:href => '/'} « Home
|
5
|
+
.clear
|
6
|
+
%h2
|
7
|
+
Project:
|
8
|
+
%b= @project.name.humanize
|
9
|
+
%span.tip (#{"%.2f" % (@project.work_time.to_i / 1.hours.to_f).to_s} hours)
|
10
|
+
%h2
|
11
|
+
Detailed View For:
|
12
|
+
%b=( this_year != @current_day.year ? @current_day.strftime("%Y %b %d, %a") : @current_day.strftime("%b %d, %a") )
|
13
|
+
|
14
|
+
%table.pinches
|
15
|
+
%tr
|
16
|
+
%th Start Time
|
17
|
+
%th End Time
|
18
|
+
%th Duration
|
19
|
+
- @pinches.each do |current_pinch|
|
20
|
+
%tr
|
21
|
+
%td= current_pinch.start_time.strftime("%H:%M:%S")
|
22
|
+
%td= current_pinch.end_time.strftime("%H:%M:%S")
|
23
|
+
%td= current_pinch.humanized_duration
|
24
|
+
|