time_tap 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|