t_time_tracker 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/t_time_tracker +161 -0
- data/lib/t_time_tracker.rb +188 -0
- metadata +71 -0
data/bin/t_time_tracker
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Christian Genco (@cgenco)
|
3
|
+
|
4
|
+
require 'time'
|
5
|
+
require 'optparse'
|
6
|
+
require 't_time_tracker'
|
7
|
+
|
8
|
+
@tracker = TTimeTracker.new
|
9
|
+
@options = {:from => nil, :to => nil, :filter => '', :done => false}
|
10
|
+
parser = OptionParser.new do |opt|
|
11
|
+
opt.banner = "" +
|
12
|
+
"t-time-tracker: simple command line time tracking\n" +
|
13
|
+
"github.com/christiangenco/t-time-tracker\n\n" +
|
14
|
+
"Usage:\n" +
|
15
|
+
" $ t-time-tracker TASK_DESCRIPTION [OPTIONS]\n" +
|
16
|
+
" $ t-time-tracker making lunch\n" +
|
17
|
+
" $ t-time-tracker homework --at \"five minutes ago\"\n" +
|
18
|
+
" $ t-time-tracker --done\n" +
|
19
|
+
" $ t-time-tracker took a nap --from \"1 hour ago\" --to \"five minutes ago\"\n" +
|
20
|
+
" $ t-time-tracker --list --from \"one week ago\" --to \"yesterday\"\n"
|
21
|
+
opt.summary_indent = ' '
|
22
|
+
opt.separator "\nOptions:\n"
|
23
|
+
|
24
|
+
opt.on('-h', '--help', 'Shows this help message') do
|
25
|
+
puts parser
|
26
|
+
exit
|
27
|
+
end
|
28
|
+
|
29
|
+
opt.on('-v', '--version', 'Shows the current version') do
|
30
|
+
puts '0.1'
|
31
|
+
exit
|
32
|
+
end
|
33
|
+
|
34
|
+
opt.on('-u', '--update', 'Check github for updates') do
|
35
|
+
puts "Please check manually at https://github.com/christiangenco/t-time-tracker"
|
36
|
+
throw "Not yet implemented"
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
opt.on('-f', '--from [FROM]', 'The starting time in words (uses Chronic gem). Include any task that starts after it (inclusive)') do |f|
|
41
|
+
require 'chronic'
|
42
|
+
@options[:from] = Chronic.parse(f, :context => :past, :guess => false).first
|
43
|
+
end
|
44
|
+
|
45
|
+
opt.on('-a', '--at [AT]', 'A synonym for --from') do |f|
|
46
|
+
require 'chronic'
|
47
|
+
@options[:from] = Chronic.parse(f, :context => :past, :guess => false).first
|
48
|
+
end
|
49
|
+
|
50
|
+
opt.on('-t', '--to [TO]', 'The ending time in words (uses Chronic gem). Include any task that starts before it (inclusive)') do |t|
|
51
|
+
require 'chronic'
|
52
|
+
@options[:to] = (Chronic.parse(t, :context => :past, :guess => false)-1).last
|
53
|
+
end
|
54
|
+
|
55
|
+
opt.on('-d', '--done', 'End the current task without starting a new one') do
|
56
|
+
@options[:done] = true
|
57
|
+
end
|
58
|
+
|
59
|
+
opt.on('-s', '--stop', 'Synonym for --done') do
|
60
|
+
@options[:done] = true
|
61
|
+
end
|
62
|
+
|
63
|
+
opt.on('-r', '--resume', 'Resume the previous task') do
|
64
|
+
@options[:resume] = true
|
65
|
+
end
|
66
|
+
|
67
|
+
opt.on('-l', '--list', 'Print the tallied activities from --from to --to') do
|
68
|
+
@options[:list] = true
|
69
|
+
end
|
70
|
+
|
71
|
+
opt.on('-e', '--edit', 'Edit saved daily task files with your $EDITOR') do
|
72
|
+
STDERR.puts "opening #{@tracker.directory}"
|
73
|
+
|
74
|
+
if ! ENV['EDITOR']
|
75
|
+
puts "No EDITOR environment varible defined"
|
76
|
+
puts "Set your EDITOR in your .bashrc or .zshrc file by adding one of these lines:"
|
77
|
+
puts "\texport EDITOR='vim' # for vim"
|
78
|
+
puts "\texport EDITOR='subl' # for Sublime Text 2"
|
79
|
+
puts "\texport EDITOR='mate' # for Textmate"
|
80
|
+
exit
|
81
|
+
end
|
82
|
+
|
83
|
+
# batch edit the logs
|
84
|
+
`#{ENV['EDITOR']} #{@tracker.directory}`
|
85
|
+
exit
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
parser.parse!
|
90
|
+
|
91
|
+
if @options[:list]
|
92
|
+
total, linewidth = 0, 0
|
93
|
+
day = nil
|
94
|
+
# print "@options ="
|
95
|
+
# p @options
|
96
|
+
@tracker.tasks(:from => @options[:from], :to => @options[:to]).each{ |task|
|
97
|
+
if day.nil? ||
|
98
|
+
day.year != task[:start].year ||
|
99
|
+
day.month != task[:start].month ||
|
100
|
+
day.day != task[:start].day
|
101
|
+
puts task[:start].strftime("# %F #")
|
102
|
+
day = task[:start]
|
103
|
+
end
|
104
|
+
line = "#{task[:start].strftime('%l:%M')}-" +
|
105
|
+
"#{task[:finish] ? task[:finish].strftime('%l:%M%P') : ' '*7}: " +
|
106
|
+
"#{TTimeTracker.format_minutes task[:duration]}" +
|
107
|
+
" #{task[:description]}"
|
108
|
+
linewidth = [linewidth, line.length].max
|
109
|
+
puts line
|
110
|
+
total += task[:duration]
|
111
|
+
}
|
112
|
+
puts "-" * linewidth
|
113
|
+
puts "total".ljust(13) + ": " + TTimeTracker.format_minutes(total).to_s
|
114
|
+
exit
|
115
|
+
end
|
116
|
+
|
117
|
+
def start_task(task)
|
118
|
+
task = @tracker.save(task)
|
119
|
+
puts "Started: #{task[:description]} (#{@options[:from] ? 'at ' + task[:start].strftime('%-l:%M%P') : 'now'})"
|
120
|
+
end
|
121
|
+
|
122
|
+
def finish_current_task(task = @tracker.current_task)
|
123
|
+
task[:finish] = @options[:to] || @options[:from] || Time.now
|
124
|
+
@tracker.save(task)
|
125
|
+
puts "Finished: #{task[:description]} (#{TTimeTracker.format_minutes task[:duration]})"
|
126
|
+
end
|
127
|
+
|
128
|
+
if @options[:resume]
|
129
|
+
if task = @tracker.last_task
|
130
|
+
start_task(:start => @options[:from], :finish => @options[:to], :description => task[:description])
|
131
|
+
else
|
132
|
+
STDERR.puts "No task to resume"
|
133
|
+
end
|
134
|
+
exit
|
135
|
+
end
|
136
|
+
|
137
|
+
if @options[:done]
|
138
|
+
finish_current_task
|
139
|
+
exit
|
140
|
+
end
|
141
|
+
|
142
|
+
# add a new task
|
143
|
+
if !ARGV.empty?
|
144
|
+
# end the current task if it exists
|
145
|
+
if current = @tracker.current_task
|
146
|
+
finish_current_task(current)
|
147
|
+
end
|
148
|
+
|
149
|
+
description = ARGV.join(" ")
|
150
|
+
start_task(:start => @options[:from], :finish => @options[:to], :description => description)
|
151
|
+
exit
|
152
|
+
end
|
153
|
+
|
154
|
+
# default action: show current task
|
155
|
+
unless current = @tracker.current_task
|
156
|
+
STDERR.puts "You're not working on anything"
|
157
|
+
exit
|
158
|
+
end
|
159
|
+
|
160
|
+
puts "In progress: #{current[:description]} (#{TTimeTracker.format_minutes current[:duration]})"
|
161
|
+
exit
|
@@ -0,0 +1,188 @@
|
|
1
|
+
# yardoc info: http://cheat.errtheblog.com/s/yard/
|
2
|
+
|
3
|
+
class TTimeTracker
|
4
|
+
require 'time'
|
5
|
+
|
6
|
+
# @author Christian Genco (@cgenco)
|
7
|
+
|
8
|
+
# @attribute directory [String] the parent directory that the log files are stored in
|
9
|
+
# @attribute subdirectory [String] the subdirectory in which the current task will be stored
|
10
|
+
# @attribute filename [String] the full path to the log file of the current task
|
11
|
+
# @attribute task [String] the users entered task
|
12
|
+
# @attribute at [Time] the time that the task (or range) starts at
|
13
|
+
# @attribute to [Time] the time that the task (or range) ends at
|
14
|
+
attr_accessor :directory, :subdirectory, :filename, :task, :now #, :at, :to, :now
|
15
|
+
|
16
|
+
# A new instance of TTimeTracker.
|
17
|
+
# @param [Hash] params Options hash
|
18
|
+
# @option params [Symbol] :now the date to consider when deciding which log file to use
|
19
|
+
# @option params [Symbol] :directory the parent directory that the log files are stored in
|
20
|
+
# @option params [Symbol] :subdirectory the subdirectory in which the current task will be stored
|
21
|
+
# @option params [Symbol] :filename the full path to the log file of the current task
|
22
|
+
def initialize(params = {})
|
23
|
+
@now = params[:now] || Time.now
|
24
|
+
@directory = params[:directory] || File.join(Dir.home, '.ttimetracker')
|
25
|
+
@subdirectory = params[:subdirectory] || File.join(@directory, now.year.to_s, now.strftime("%m_%b"), '')
|
26
|
+
@filename = params[:filename] || File.join(@subdirectory, now.strftime('%Y-%m-%d') + '.csv')
|
27
|
+
self.class.mkdir @subdirectory
|
28
|
+
end
|
29
|
+
|
30
|
+
# Returns information about the specified task.
|
31
|
+
#
|
32
|
+
# @param task_name [Symbol] the stored task to return, `:current` or `:last`
|
33
|
+
def task(task_name)
|
34
|
+
task_filename = File.join(@directory, task_name.to_s)
|
35
|
+
return nil unless File.exists?(task_filename)
|
36
|
+
File.open(task_filename,'r') do |f|
|
37
|
+
line = f.gets
|
38
|
+
return if line.nil?
|
39
|
+
parse_task(line)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# equivalent to task(:current)
|
44
|
+
def current_task; task(:current); end
|
45
|
+
|
46
|
+
# equivalent to task(:last)
|
47
|
+
def last_task; task(:last); end
|
48
|
+
|
49
|
+
# returns an array of hashed tasks between the specified times. Defaults to today.
|
50
|
+
# @todo figure out how to make this work with an arbitrary directory structure
|
51
|
+
# @param [Hash] params Options hash
|
52
|
+
# @option params [Symbol] :from the starting time; includes any task that starts after it (inclusive)
|
53
|
+
# @option params [Symbol] :to the ending time; includes any task that starts before it (inclusive)
|
54
|
+
def tasks(params = {})
|
55
|
+
require 'active_support/core_ext/time/calculations'
|
56
|
+
require 'active_support/core_ext/date/calculations'
|
57
|
+
|
58
|
+
# Time.parse(Time.new.strftime("%F 0:00:00 %z"))
|
59
|
+
from = params[:from] || Time.new.beginning_of_day
|
60
|
+
# Time.parse(Time.new.strftime("%F 23:59:59 %z"))
|
61
|
+
to = params[:to] || Time.new.end_of_day
|
62
|
+
# ensure from < to
|
63
|
+
from, to = [from, to].sort
|
64
|
+
|
65
|
+
tasks = []
|
66
|
+
|
67
|
+
# first, get every task for the correct days
|
68
|
+
now = from
|
69
|
+
while now <= to
|
70
|
+
# TODO: make this work for arbitrary folder organisation structures
|
71
|
+
subdirectory = File.join(@directory, now.year.to_s, now.strftime("%m_%b"), '')
|
72
|
+
filename = File.join(subdirectory, now.strftime('%Y-%m-%d') + '.csv')
|
73
|
+
File.open(filename, 'r').each do |line|
|
74
|
+
tasks << parse_task(line, :day => now)
|
75
|
+
end if File.exists?(filename)
|
76
|
+
now = now.tomorrow
|
77
|
+
end
|
78
|
+
|
79
|
+
# now filter out tasks that don't fall within the requested timespan
|
80
|
+
tasks.delete_if{|t|
|
81
|
+
t[:start] < from || t[:start] > to
|
82
|
+
}
|
83
|
+
|
84
|
+
tasks
|
85
|
+
end
|
86
|
+
|
87
|
+
# warning: this will overwrite the current task. You need to save the current task before saving a new one.
|
88
|
+
def save(task = {})
|
89
|
+
# forget the last task
|
90
|
+
last = File.join(@directory, "last")
|
91
|
+
File.unlink(last) if File.exists?(last)
|
92
|
+
|
93
|
+
task[:start] ||= @now
|
94
|
+
|
95
|
+
# save this as the current task if it doesn't have an ending time
|
96
|
+
if !task[:finish]
|
97
|
+
File.open(File.join(@directory, "current"),'w') do |f|
|
98
|
+
f.puts [format_time(task[:start]), task[:description].strip].join(", ")
|
99
|
+
end
|
100
|
+
else
|
101
|
+
# task has start and finish time, so append it to today's log...
|
102
|
+
File.open(@filename,'a') do |f|
|
103
|
+
f.puts [format_time(task[:start]), format_time(task[:finish]), task[:description].strip].join(", ")
|
104
|
+
end
|
105
|
+
|
106
|
+
# ...and save it as "last" in case you want to resume it
|
107
|
+
File.rename(File.join(@directory, 'current'), File.join(@directory, 'last'))
|
108
|
+
end
|
109
|
+
|
110
|
+
task
|
111
|
+
end
|
112
|
+
|
113
|
+
# Converts an integer of minutes into a more human readable format.
|
114
|
+
#
|
115
|
+
# @example
|
116
|
+
# format_minutes(95) #=> "1:15"
|
117
|
+
# format_minutes(5) #=> "0:05"
|
118
|
+
#
|
119
|
+
# @param minutes [Integer] a number of minutes
|
120
|
+
# @return [String] the formatted minutes
|
121
|
+
def self.format_minutes(minutes)
|
122
|
+
"#{minutes.to_i / 60}:#{'%02d' % (minutes % 60)}"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Parses a comma separated stored task in csv form
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# parse_task("12:56, 13:10, did the dishes")
|
129
|
+
# #=> {:start=>2012-05-16 12:56:00, :finish=>2012-05-16 13:10:00, :description=>"did the dishes", :duration=>14}
|
130
|
+
# parse_task("14:32, homework")
|
131
|
+
# #=> {:start=>2012-05-16 14:32:00, :finish=>Time.now, :description=>"homework", :duration=>36}
|
132
|
+
#
|
133
|
+
# @param line [String] the CSV stored task
|
134
|
+
# @param [Hash] params Options hash
|
135
|
+
# @option params [Symbol] :day the default day to assign to times parsed. Defaults to @now.
|
136
|
+
# @return [{:start=>Time, :finish=>Time, :description=>String, :duration=>Integer}] the parsed data in the line
|
137
|
+
def parse_task(line, params = {})
|
138
|
+
def parse_time(time_string, day)
|
139
|
+
# if the time already has a date, parse that time
|
140
|
+
# else assign a date
|
141
|
+
if time_string =~ /\d{4}-\d{2}-\d{2}/
|
142
|
+
Time.parse(time_string)
|
143
|
+
else
|
144
|
+
Time.parse(day.strftime("%F ") + time_string)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
day = params[:day] || @now
|
149
|
+
data = line.split(",").map(&:strip)
|
150
|
+
start = parse_time(data.shift, day)
|
151
|
+
|
152
|
+
if data.length == 2
|
153
|
+
# if there are two more values, they are the finished time and the description
|
154
|
+
finish = parse_time(data.shift, day)
|
155
|
+
else
|
156
|
+
# otherwise the last value is the description; get finish elsewhere
|
157
|
+
finish = @now
|
158
|
+
end
|
159
|
+
|
160
|
+
description = data.shift
|
161
|
+
duration = ((finish - start).to_f / 60).ceil
|
162
|
+
|
163
|
+
return {:start => start, :finish => finish, :description => description, :duration => duration}
|
164
|
+
end
|
165
|
+
|
166
|
+
# Create directory if it doesn't exist, creating intermediate
|
167
|
+
# directories as required. Equivalent to `mkdir -p`.
|
168
|
+
#
|
169
|
+
# @param dir [String] a directory name
|
170
|
+
def self.mkdir(dir)
|
171
|
+
mkdir(File.dirname dir) unless File.dirname(dir) == dir
|
172
|
+
Dir.mkdir(dir) unless dir.empty? || File.directory?(dir)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Converts a Time object into a human readable condensed string.
|
176
|
+
# Options for strftime may be found here:
|
177
|
+
# http://www.ruby-doc.org/core-1.9.3/Time.html#method-i-strftime
|
178
|
+
#
|
179
|
+
# @example
|
180
|
+
# time = Time.new #=> 2012-05-16 00:32:31 +0800
|
181
|
+
# format_time(time) #=> "00:32:31"
|
182
|
+
#
|
183
|
+
# @param time [Time] a time
|
184
|
+
# @return [String] the formatted time
|
185
|
+
def format_time(time)
|
186
|
+
time.strftime("%H:%M:%S")
|
187
|
+
end
|
188
|
+
end
|
metadata
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: t_time_tracker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Christian Genco
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-05-16 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: chronic
|
16
|
+
requirement: &70149933481160 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.6.7
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70149933481160
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: activesupport
|
27
|
+
requirement: &70149933479240 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ~>
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 3.2.1
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70149933479240
|
36
|
+
description: It's like a log file for your life. Keep track of everything from freelance
|
37
|
+
project billing to how many hours per week you spend eating.
|
38
|
+
email: ! '@cgenco'
|
39
|
+
executables:
|
40
|
+
- t_time_tracker
|
41
|
+
extensions: []
|
42
|
+
extra_rdoc_files: []
|
43
|
+
files:
|
44
|
+
- lib/t_time_tracker.rb
|
45
|
+
- bin/t_time_tracker
|
46
|
+
homepage: https://github.com/christiangenco/t_time_tracker
|
47
|
+
licenses: []
|
48
|
+
post_install_message:
|
49
|
+
rdoc_options: []
|
50
|
+
require_paths:
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ! '>='
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
none: false
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
requirements: []
|
65
|
+
rubyforge_project:
|
66
|
+
rubygems_version: 1.8.10
|
67
|
+
signing_key:
|
68
|
+
specification_version: 3
|
69
|
+
summary: simple comand line time tracking
|
70
|
+
test_files: []
|
71
|
+
has_rdoc:
|