big_bucks_no_whammies 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +88 -0
- data/Rakefile +25 -0
- data/VERSION +1 -0
- data/big_bucks_no_whammies.gemspec +80 -0
- data/lib/adapters/base/activities.rb +9 -0
- data/lib/adapters/base/activity.rb +31 -0
- data/lib/adapters/base/billable.rb +17 -0
- data/lib/adapters/base/commit.rb +33 -0
- data/lib/adapters/base/commits.rb +9 -0
- data/lib/adapters/base/meeting.rb +31 -0
- data/lib/adapters/chrometa.rb +98 -0
- data/lib/adapters/csv_file.rb +49 -0
- data/lib/adapters/git.rb +51 -0
- data/lib/adapters/time_estimators/number_of_source_commits.rb +34 -0
- data/lib/adapters/time_estimators/number_of_sources_changes.rb +38 -0
- data/lib/adapters/time_estimators/recorded_activity.rb +138 -0
- data/lib/adapters/time_sink.rb +83 -0
- data/lib/big_bucks_no_whammies.rb +95 -0
- data/lib/utils/date_time_utils.rb +42 -0
- data/lib/utils/invoice_display_utils.rb +27 -0
- data/lib/utils/line_item_utils.rb +40 -0
- data/sample_report.pdf +0 -0
- data/spec/big_bucks_no_whammies_spec.rb +7 -0
- data/spec/csv_file_spec.rb +25 -0
- data/spec/spec_helper.rb +13 -0
- metadata +141 -0
data/.document
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Jim Jones
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
= Description
|
2
|
+
Big Bucks No Whammies (BBNW) will download your time usage from {Timesink}[http://manytricks.com/timesink/], Chrometa, or any other service/app that tracks application time usage, cross reference it with your commits to a git repository, and generate an invoice. This is useful for developers that are independent contractors.
|
3
|
+
|
4
|
+
If you don't have time usage statistics, BBNW can estimate how much each commit took by looking at the number of source changes per commit.
|
5
|
+
|
6
|
+
== Contributing to big_bucks_no_whammies
|
7
|
+
I'm assuming that you're a programmer. You're working for a client making the big bucks. You spend a ton of time in your favorite editor or maybe there's a couple of terminal sessions open.
|
8
|
+
|
9
|
+
So, I assume that you're tracking all of your activity on the computer. I'm currently using {Timesink.}[http://manytricks.com/timesink/]
|
10
|
+
|
11
|
+
With all of this hard work, eventually you commit these changes to a repository. I personally use git.
|
12
|
+
|
13
|
+
BBNW will consult {Timesink}[http://manytricks.com/timesink/] / Chrometa / etc, gather all of the time that you've accumulated using your favorite development applications, and then it will cross reference that activity with your git commits.
|
14
|
+
Finally, it will generate an invoice listing for each commit you've made along with the time used for each issue.
|
15
|
+
|
16
|
+
BBNW makes the assumption that all of the work done before the commit was specifically for that commit.
|
17
|
+
|
18
|
+
E.g.
|
19
|
+
|
20
|
+
Timesink csv report :
|
21
|
+
|
22
|
+
Pool,Application,Window,Foreground Begin (Unix Time),Foreground End (Unix Time)
|
23
|
+
,Finder,,1305084898.21,1305084902.27
|
24
|
+
,Firefox,"Screen_shot_2011-05-10_at_11.42.19_AM.png (PNG Image, 405x336 pixels)",1305084938.45,1305084939.47
|
25
|
+
,Firefox,localhost,1305084943.00,1305084946.04
|
26
|
+
,Microsoft Outlook,,1305084946.55,1305085092.49
|
27
|
+
,Microsoft Outlook,Inbox • MyCompany,1305084946.55,1305085092.49
|
28
|
+
,RubyMine,,1305085092.99,1305085279.06
|
29
|
+
,RubyMine,myproject - [/Users/jimjones/dev/myproject] - .../db/seeds.rb - JetBrains RubyMine 3.1.1,1305085092.99,1305085097.04
|
30
|
+
,RubyMine,myproject - [/Users/jimjones/dev/myproject] - .../app/views/mailers/_form.html.haml - JetBrains RubyMine 3.1.1,1305085097.55,1305085100.09
|
31
|
+
,RubyMine,myproject - [/Users/jimjones/dev/myproject] - .../app/views/mailers/edit.html.haml - JetBrains RubyMine 3.1.1,1305085100.59,1305085279.06
|
32
|
+
,Microsoft Outlook,,1305085279.56,1305085289.21
|
33
|
+
|
34
|
+
And then there's a commit in your git repository with the following :
|
35
|
+
|
36
|
+
commit 1b132d58af90af8c65738f9189eb7c8d7d29173c
|
37
|
+
Author: Jim Jones <jjones@aantix.com>
|
38
|
+
Date: Sun Apr 5 09:17:51 2011 -0700
|
39
|
+
|
40
|
+
Added facial recognition. No big deal.
|
41
|
+
|
42
|
+
|
43
|
+
In your invoice, you would see the following line item :
|
44
|
+
|
45
|
+
Description Unit Price Quantity Amount
|
46
|
+
Added facial recognition. No big deal. $100 1.5 hours $150
|
47
|
+
|
48
|
+
= Meetings, Activities, Phonecalls, Anything that isn't recorded by Timesink
|
49
|
+
You can programmatically adds meetings/tasks to your invoice.
|
50
|
+
|
51
|
+
invoice.meeting("Progress Review", "#{meeting_date.strftime("%d/%m/%Y")} 8:30:00","#{meeting_date.strftime("%d/%m/%Y")} 9:30:00")
|
52
|
+
invoice.task("Answered support phone call", "04/05/2011 8:30:00","04/05/2011 8:37:00")
|
53
|
+
|
54
|
+
|
55
|
+
= Extending BBNW
|
56
|
+
For activity data, BBNW supports Chrometa, TimeSink and a generic CSV adapter. There's a Git repository adapater. But if you use another time tracker (e.g. Time Sink, Freshbooks, Freckle, etc), nothing would prevent you from writing your own activity adapter.
|
57
|
+
|
58
|
+
|
59
|
+
== Sample Usages
|
60
|
+
If you want to cross reference your recorded activities with your Git commits, do the following :
|
61
|
+
|
62
|
+
company = ['123 Easy St', 'San Francisco, CA 94132','','(505) 555-5575','jjones@aantix.com']
|
63
|
+
bill_to = ['The Software Corp','100 Microsoft Drive, Suite 150','San Francisco, CA 94105']
|
64
|
+
|
65
|
+
start_date = Date.today - 365
|
66
|
+
end_date = Date.today
|
67
|
+
|
68
|
+
activities = BBNW::TimeSink.new('~/Dropbox/time_reports/2011-07-29.csv', start_date, end_date, true)
|
69
|
+
#activities = BBNW::CsvFile.new('~/Dropbox/time_sink_report.csv', [1,3,4], true)
|
70
|
+
#activities = BBNW::Chrometa.new('jjones@example.com', '643e011b0532dfD1gjhuytyu0bc96f4058rffa', nil, nil, start_date, end_date, true)
|
71
|
+
git = BBNW::GitRepos.new('~/dev/turkee', nil, start_date, end_date, true)
|
72
|
+
|
73
|
+
invoice = BBNW::Invoice.new
|
74
|
+
invoice.line_items(activities, git, BBNW::RecordedActivity, false) # No grouping of line items
|
75
|
+
invoice.generate(invoice_num, invoice_path, company_logo, company_name, company_details, rate_per_hour)
|
76
|
+
|
77
|
+
Or if you don't have your activities recorded and would just like to generate a line itemed invoice from Git commits with the time estimated for each commit you can do the following :
|
78
|
+
|
79
|
+
git = BBNW::GitRepos.new('~/Projects/turkee', nil, start_date, end_date, true)
|
80
|
+
invoice = BBNW::Invoice.new
|
81
|
+
invoice.line_items(80, git, BBNW::NumberOfSourceCommits)
|
82
|
+
invoice.generate(invoice_num, './logo.png', 'Jim Jones', company, bill_to, 250, start_date, end_date)
|
83
|
+
|
84
|
+
== Copyright
|
85
|
+
|
86
|
+
Copyright (c) 2011 Jim Jones. See LICENSE.txt for
|
87
|
+
further details.
|
88
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'rake'
|
5
|
+
|
6
|
+
require 'jeweler'
|
7
|
+
Jeweler::Tasks.new do |gem|
|
8
|
+
# gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
|
9
|
+
gem.name = "big_bucks_no_whammies"
|
10
|
+
gem.homepage = "http://github.com/aantix/big_bucks_no_whammies"
|
11
|
+
gem.license = "MIT"
|
12
|
+
gem.summary = %Q{Big Bucks No Whammies allows software development contractors to generate a line-item invoice based on git commits. Activity}
|
13
|
+
gem.description = %Q{Auto-generate a pdf invoice utilizing your git commit activity. Time is automatically accounted for utilizing TimeSink and is proportionately applied towards each line item entry. }
|
14
|
+
gem.email = "jjones@aantix.com"
|
15
|
+
gem.authors = ["Jim Jones"]
|
16
|
+
gem.require_paths = ["lib","lib/adapters","lib/adapters/base","lib/adapters/time_estimators","lib/adapters/utils"]
|
17
|
+
gem.add_dependency(%q<grit>, [">= 2.4.1"])
|
18
|
+
gem.add_dependency(%q<payday>, [">= 1.0.2"])
|
19
|
+
gem.add_dependency(%q<jeweler>, [">= 1.6.0"])
|
20
|
+
gem.add_dependency(%<rspec>, [">= 2.3.0"])
|
21
|
+
# dependencies defined in Gemfile
|
22
|
+
end
|
23
|
+
Jeweler::GemcutterTasks.new
|
24
|
+
|
25
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{big_bucks_no_whammies}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = [%q{Jim Jones}]
|
12
|
+
s.date = %q{2011-08-22}
|
13
|
+
s.description = %q{Auto-generate a pdf invoice utilizing your git commit activity. Time is automatically accounted for utilizing TimeSink and is proportionately applied towards each line item entry. }
|
14
|
+
s.email = %q{jjones@aantix.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE.txt",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".rspec",
|
22
|
+
"Gemfile",
|
23
|
+
"LICENSE.txt",
|
24
|
+
"README.rdoc",
|
25
|
+
"Rakefile",
|
26
|
+
"VERSION",
|
27
|
+
"big_bucks_no_whammies.gemspec",
|
28
|
+
"lib/adapters/base/activities.rb",
|
29
|
+
"lib/adapters/base/activity.rb",
|
30
|
+
"lib/adapters/base/billable.rb",
|
31
|
+
"lib/adapters/base/commit.rb",
|
32
|
+
"lib/adapters/base/commits.rb",
|
33
|
+
"lib/adapters/base/meeting.rb",
|
34
|
+
"lib/adapters/chrometa.rb",
|
35
|
+
"lib/adapters/csv_file.rb",
|
36
|
+
"lib/adapters/git.rb",
|
37
|
+
"lib/adapters/time_estimators/number_of_source_commits.rb",
|
38
|
+
"lib/adapters/time_estimators/number_of_sources_changes.rb",
|
39
|
+
"lib/adapters/time_estimators/recorded_activity.rb",
|
40
|
+
"lib/adapters/time_sink.rb",
|
41
|
+
"lib/big_bucks_no_whammies.rb",
|
42
|
+
"lib/utils/date_time_utils.rb",
|
43
|
+
"lib/utils/invoice_display_utils.rb",
|
44
|
+
"lib/utils/line_item_utils.rb",
|
45
|
+
"sample_report.pdf",
|
46
|
+
"spec/big_bucks_no_whammies_spec.rb",
|
47
|
+
"spec/csv_file_spec.rb",
|
48
|
+
"spec/spec_helper.rb"
|
49
|
+
]
|
50
|
+
s.homepage = %q{http://github.com/aantix/big_bucks_no_whammies}
|
51
|
+
s.licenses = [%q{MIT}]
|
52
|
+
s.require_paths = [%q{lib}, %q{lib/adapters}, %q{lib/adapters/base}, %q{lib/adapters/time_estimators}, %q{lib/adapters/utils}]
|
53
|
+
s.rubygems_version = %q{1.8.5}
|
54
|
+
s.summary = %q{Big Bucks No Whammies allows software development contractors to generate a line-item invoice based on git commits. Activity}
|
55
|
+
|
56
|
+
if s.respond_to? :specification_version then
|
57
|
+
s.specification_version = 3
|
58
|
+
|
59
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
60
|
+
s.add_runtime_dependency(%q<big_bucks_no_whammies>, [">= 0"])
|
61
|
+
s.add_runtime_dependency(%q<grit>, [">= 2.4.1"])
|
62
|
+
s.add_runtime_dependency(%q<payday>, [">= 1.0.2"])
|
63
|
+
s.add_runtime_dependency(%q<jeweler>, [">= 1.6.0"])
|
64
|
+
s.add_runtime_dependency(%q<rspec>, [">= 2.3.0"])
|
65
|
+
else
|
66
|
+
s.add_dependency(%q<big_bucks_no_whammies>, [">= 0"])
|
67
|
+
s.add_dependency(%q<grit>, [">= 2.4.1"])
|
68
|
+
s.add_dependency(%q<payday>, [">= 1.0.2"])
|
69
|
+
s.add_dependency(%q<jeweler>, [">= 1.6.0"])
|
70
|
+
s.add_dependency(%q<rspec>, [">= 2.3.0"])
|
71
|
+
end
|
72
|
+
else
|
73
|
+
s.add_dependency(%q<big_bucks_no_whammies>, [">= 0"])
|
74
|
+
s.add_dependency(%q<grit>, [">= 2.4.1"])
|
75
|
+
s.add_dependency(%q<payday>, [">= 1.0.2"])
|
76
|
+
s.add_dependency(%q<jeweler>, [">= 1.6.0"])
|
77
|
+
s.add_dependency(%q<rspec>, [">= 2.3.0"])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'utils/invoice_display_utils'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class Activity
|
5
|
+
include BBNW::InvoiceDisplayUtils
|
6
|
+
|
7
|
+
attr_accessor :activity, :start_time, :end_time, :billable
|
8
|
+
|
9
|
+
def initialize(activity, start_time, end_time, billable = true)
|
10
|
+
@activity = activity
|
11
|
+
@start_time = start_time
|
12
|
+
@end_time = end_time
|
13
|
+
@billable = billable
|
14
|
+
end
|
15
|
+
|
16
|
+
def total_seconds
|
17
|
+
(@end_time - @start_time) rescue 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.roundup(time_in_seconds, nearest = 15) # nearest 15 minutes/900 seconds
|
21
|
+
nearest_in_seconds = nearest * 60
|
22
|
+
time_in_seconds % nearest_in_seconds == 0 ? time_in_seconds : time_in_seconds + nearest_in_seconds - (time_in_seconds % nearest_in_seconds)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.rounddown(time_in_seconds, nearest = 15) # nearest 15 minutes/900 seconds
|
26
|
+
nearest_in_seconds = nearest * 60
|
27
|
+
time_in_seconds % nearest_in_seconds == 0 ? time_in_seconds : time_in_seconds - (time_in_seconds % nearest_in_seconds)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module BBNW
|
2
|
+
class Billable
|
3
|
+
attr_accessor :descriptions, :total_time
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@descriptions = []
|
7
|
+
@total_time = 0
|
8
|
+
end
|
9
|
+
|
10
|
+
def all_descriptions(header = "")
|
11
|
+
descriptions = "#{header}\n\n"
|
12
|
+
descriptions+= @descriptions.join("\n\n")
|
13
|
+
descriptions
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class Commit
|
5
|
+
attr_reader :commit_id, :message, :committer, :timestamp, :total_changes
|
6
|
+
|
7
|
+
def initialize(commit_id, message, committer, timestamp, total_changes = 0)
|
8
|
+
@commit_id = commit_id
|
9
|
+
@message = message
|
10
|
+
@committer = committer
|
11
|
+
@timestamp = timestamp.is_a?(String) ? Time.parse(timestamp) : timestamp
|
12
|
+
@total_changes = total_changes
|
13
|
+
end
|
14
|
+
|
15
|
+
def start_time
|
16
|
+
@timestamp
|
17
|
+
end
|
18
|
+
def end_time
|
19
|
+
@timestamp
|
20
|
+
end
|
21
|
+
def activity
|
22
|
+
@message
|
23
|
+
end
|
24
|
+
# Looks for an integer > 5 chars as the task_id
|
25
|
+
def task_id
|
26
|
+
if message =~ /(\d{6,})/
|
27
|
+
return $1.to_i
|
28
|
+
end
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class Meeting
|
5
|
+
attr_reader :message, :start_time, :end_time
|
6
|
+
|
7
|
+
def initialize(message, start_time, end_time, account_for = true)
|
8
|
+
@message = message
|
9
|
+
@start_time = start_time
|
10
|
+
@end_time = end_time
|
11
|
+
@account_for = account_for
|
12
|
+
end
|
13
|
+
|
14
|
+
def elapsed
|
15
|
+
@account_for ? (@end_time - @start_time) : 0
|
16
|
+
end
|
17
|
+
|
18
|
+
def total_seconds
|
19
|
+
elapsed
|
20
|
+
end
|
21
|
+
|
22
|
+
def timestamp
|
23
|
+
start_time
|
24
|
+
end
|
25
|
+
|
26
|
+
def task_id
|
27
|
+
nil
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require "uri"
|
2
|
+
require 'cgi'
|
3
|
+
require "net/https"
|
4
|
+
require "rexml/document"
|
5
|
+
require 'base/activities'
|
6
|
+
require 'base/activity'
|
7
|
+
require 'date'
|
8
|
+
|
9
|
+
module BBNW
|
10
|
+
|
11
|
+
class Chrometa
|
12
|
+
include BBNW::Activities
|
13
|
+
|
14
|
+
def initialize(email, token, clients, projects, start_date, end_date, debug = false)
|
15
|
+
@email = email
|
16
|
+
@token = token
|
17
|
+
@start_date = start_date.is_a?(String) ? Date.parse(start_date) : start_date
|
18
|
+
@end_date = end_date.is_a?(String) ? Date.parse(end_date) : end_date
|
19
|
+
@debug = debug
|
20
|
+
|
21
|
+
@clients = clients.is_a?(String) ? [clients] : clients
|
22
|
+
@projects = projects.is_a?(String) ? [projects] : projects
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
def log(message)
|
27
|
+
puts message if @debug
|
28
|
+
end
|
29
|
+
|
30
|
+
def get_activities
|
31
|
+
activities = []
|
32
|
+
current_date = @start_date
|
33
|
+
while current_date <= @end_date
|
34
|
+
url = URI.parse("https://api.chrometa.com/api/report")
|
35
|
+
params = {"date" => current_date.strftime("%m/%d/%Y")}
|
36
|
+
req = Net::HTTP::Post.new(url.path)
|
37
|
+
req.basic_auth @email, @token
|
38
|
+
req.set_form_data(params)
|
39
|
+
http = Net::HTTP.new(url.host, url.port)
|
40
|
+
http.use_ssl = true
|
41
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
42
|
+
resp = http.start { |http| http.request(req) }
|
43
|
+
|
44
|
+
body = resp.body.clone
|
45
|
+
|
46
|
+
xml = REXML::Document.new(body)
|
47
|
+
log "==================="
|
48
|
+
log "date : #{current_date}"
|
49
|
+
log "Body : #{body}"
|
50
|
+
log "==================="
|
51
|
+
|
52
|
+
root = xml.elements["chrometa-info"]
|
53
|
+
|
54
|
+
log "Email : #{root.attributes["email"]}"
|
55
|
+
log "Date : #{root.attributes["date"]}"
|
56
|
+
|
57
|
+
root.elements.each do |client|
|
58
|
+
log " Client Name : #{client.attributes["name"]}"
|
59
|
+
|
60
|
+
next unless (@clients.blank? || @clients.include?(client.attributes["name"]))
|
61
|
+
|
62
|
+
client.elements.each do |project|
|
63
|
+
log " Project Name : #{project.attributes["name"]}"
|
64
|
+
log " Project Rate : #{project.attributes["rate"]}"
|
65
|
+
|
66
|
+
next unless (@projects.blank? || @projects.include?(project.attributes["name"]))
|
67
|
+
|
68
|
+
project.elements.each do |time_entry|
|
69
|
+
start_time = Time.parse(time_entry.attributes["timestamp"])
|
70
|
+
end_time = start_time + time_entry.attributes["seconds"].to_i
|
71
|
+
|
72
|
+
activity_entry = BBNW::Activity.new(time_entry.attributes["activity"],
|
73
|
+
start_time, end_time)
|
74
|
+
activities << activity_entry
|
75
|
+
log " Application : #{time_entry.attributes['application']}"
|
76
|
+
log " Timestamp : #{time_entry.attributes['timestamp']}"
|
77
|
+
log " Activity : #{time_entry.attributes['activity']}"
|
78
|
+
log " URL : #{time_entry.attributes['url']}"
|
79
|
+
log " Seconds : #{time_entry.attributes['seconds']}"
|
80
|
+
log " Billable : #{time_entry.attributes['billable']}"
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
end
|
85
|
+
current_date+=1
|
86
|
+
end
|
87
|
+
|
88
|
+
File.open('chrometa.csv', 'w') do |f|
|
89
|
+
activities.each do |activity|
|
90
|
+
f.puts("#{CGI.escapeHTML(activity.activity)},#{activity.start_time},#{activity.end_time}")
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
activities
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'base/activities'
|
3
|
+
require 'base/activity'
|
4
|
+
require 'utils/date_time_utils'
|
5
|
+
require 'csv'
|
6
|
+
|
7
|
+
module BBNW
|
8
|
+
|
9
|
+
class CsvFile
|
10
|
+
include BBNW::Activities
|
11
|
+
include BBNW::DateTimeUtils
|
12
|
+
|
13
|
+
def initialize(file_path, columns = [0,1,2], debug = false)
|
14
|
+
@files = Dir.glob(file_path)
|
15
|
+
@columns = columns
|
16
|
+
end
|
17
|
+
|
18
|
+
def log(message)
|
19
|
+
puts message if @debug
|
20
|
+
end
|
21
|
+
|
22
|
+
def get_activities
|
23
|
+
activities = []
|
24
|
+
blank_time = Time.at(0.0)
|
25
|
+
|
26
|
+
@files.each do |file|
|
27
|
+
csv = CSV::parse(File.open(file) { |f| f.read })
|
28
|
+
headers = csv.shift
|
29
|
+
|
30
|
+
csv.each_with_index do |row, i|
|
31
|
+
activity = row[@columns[0]]
|
32
|
+
s = row[@columns[1]]
|
33
|
+
e = row[@columns[2]]
|
34
|
+
|
35
|
+
start_time = parse_time(s)
|
36
|
+
next if start_time == blank_time
|
37
|
+
|
38
|
+
end_time = parse_time(e)
|
39
|
+
next if end_time == blank_time
|
40
|
+
|
41
|
+
time_entry = BBNW::Activity.new(activity, start_time, end_time)
|
42
|
+
activities << time_entry
|
43
|
+
end
|
44
|
+
end
|
45
|
+
activities
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
data/lib/adapters/git.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'grit'
|
2
|
+
require 'base/commit'
|
3
|
+
require 'base/commits'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
module BBNW
|
7
|
+
class GitRepos
|
8
|
+
include BBNW::Commits
|
9
|
+
|
10
|
+
attr_accessor :commits
|
11
|
+
|
12
|
+
def initialize(working_dir, committer = nil, start_date = nil, end_date = nil, debug = false)
|
13
|
+
@git = Grit::Repo.new(working_dir)
|
14
|
+
|
15
|
+
@committer = committer
|
16
|
+
@start_date = start_date
|
17
|
+
@end_date = end_date
|
18
|
+
end
|
19
|
+
|
20
|
+
def commits
|
21
|
+
@commits||=get_commits
|
22
|
+
end
|
23
|
+
|
24
|
+
def total_changes(commit)
|
25
|
+
total = 0
|
26
|
+
stats = commit.stats
|
27
|
+
stats.files.each do |f|
|
28
|
+
total+=f[3]
|
29
|
+
end
|
30
|
+
total
|
31
|
+
end
|
32
|
+
|
33
|
+
def get_commits
|
34
|
+
user_commits = []
|
35
|
+
commits = @git.commits_since('master',@start_date.strftime("%Y-%m-%d"))
|
36
|
+
start_date = Time.new(@start_date.year, @start_date.month, @start_date.day, 0, 0, 0)
|
37
|
+
end_date = Time.new(@end_date.year, @end_date.month, @end_date.day, 23, 59, 59)
|
38
|
+
|
39
|
+
commits.each do |commit|
|
40
|
+
if (commit.author.to_s =~ /#{@committer}/i || @committer.nil?) &&
|
41
|
+
(start_date.nil? || commit.committed_date >= start_date) &&
|
42
|
+
(end_date.nil? || commit.committed_date <= end_date)
|
43
|
+
user_commits << BBNW::Commit.new(commit.id, commit.message, commit.committer.to_s, commit.committed_date, total_changes(commit))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
user_commits
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'utils/invoice_display_utils'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class NumberOfSourceCommits
|
5
|
+
extend BBNW::InvoiceDisplayUtils
|
6
|
+
|
7
|
+
def self.line_items(activities_span, repository_adapter, meetings = {})
|
8
|
+
line_items = {}
|
9
|
+
task_ids_commits = {}
|
10
|
+
|
11
|
+
activities_span_in_seconds = activities_span * 3600
|
12
|
+
meetings.each {|m, time_elapsed| activities_span_in_seconds-=time_elapsed}
|
13
|
+
|
14
|
+
commits = repository_adapter.commits
|
15
|
+
return line_items if commits.size == 0
|
16
|
+
|
17
|
+
commits.each do |commit|
|
18
|
+
task_id = commit.task_id
|
19
|
+
task_id = short_date(commit.timestamp) if task_id.nil?
|
20
|
+
task_ids_commits[task_id]||=[]
|
21
|
+
task_ids_commits[task_id] << commit
|
22
|
+
end
|
23
|
+
|
24
|
+
fraction_of_work = activities_span_in_seconds / commits.size
|
25
|
+
|
26
|
+
task_ids_commits.each do |task_id, task_commits|
|
27
|
+
task_commits.each {|tc| line_items[tc] = fraction_of_work }
|
28
|
+
end
|
29
|
+
|
30
|
+
line_items
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'utils/invoice_display_utils'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class NumberOfSourceChanges
|
5
|
+
extend BBNW::InvoiceDisplayUtils
|
6
|
+
|
7
|
+
def self.line_items(activities_span, repository_adapter, meetings = {})
|
8
|
+
line_items = {}
|
9
|
+
task_ids_commits = {}
|
10
|
+
|
11
|
+
activities_span_in_seconds = activities_span * 3600
|
12
|
+
|
13
|
+
commits = repository_adapter.commits
|
14
|
+
return line_items if commits.size == 0
|
15
|
+
|
16
|
+
changes_count = 0
|
17
|
+
meetings.each {|m, time_elapsed| activities_span_in_seconds-=time_elapsed}
|
18
|
+
|
19
|
+
commits.each do |commit|
|
20
|
+
task_id = commit.task_id
|
21
|
+
task_id = short_date(commit.timestamp) if task_id.nil?
|
22
|
+
task_ids_commits[task_id]||=[]
|
23
|
+
task_ids_commits[task_id] << commit
|
24
|
+
changes_count+=commit.total_changes
|
25
|
+
end
|
26
|
+
|
27
|
+
meetings.each {|m,duration| activities_span_in_seconds-=duration }
|
28
|
+
|
29
|
+
fraction_of_work = activities_span_in_seconds / changes_count
|
30
|
+
|
31
|
+
task_ids_commits.each do |task_id, task_commits|
|
32
|
+
task_commits.each {|tc| line_items[tc] = (fraction_of_work * tc.total_changes)}
|
33
|
+
end
|
34
|
+
|
35
|
+
line_items
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'utils/invoice_display_utils'
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
class RecordedActivity
|
5
|
+
extend BBNW::InvoiceDisplayUtils
|
6
|
+
|
7
|
+
def self.line_items(activities_span, repository_adapter, meetings = {})
|
8
|
+
line_items = {}
|
9
|
+
activities = activities_span.activities
|
10
|
+
commits = repository_adapter.commits
|
11
|
+
|
12
|
+
all_activity = activities.concat(commits)
|
13
|
+
all_activity.sort! { |a, b| a.end_time <=> b.end_time }
|
14
|
+
|
15
|
+
activity_block = []
|
16
|
+
current_time = all_activity.first.end_time
|
17
|
+
|
18
|
+
all_activity.each_with_index do |activity, index|
|
19
|
+
if day_changed?(activity, current_time)
|
20
|
+
record_daily_activity(activity_block, line_items, all_activity, current_time, index)
|
21
|
+
current_time = activity.end_time
|
22
|
+
end
|
23
|
+
|
24
|
+
if activity.is_a?(BBNW::Activity)
|
25
|
+
activity_block << activity
|
26
|
+
elsif activity.is_a?(BBNW::Commit)
|
27
|
+
record_activities(activity_block, line_items, activity)
|
28
|
+
end
|
29
|
+
|
30
|
+
debug_output(activity)
|
31
|
+
end
|
32
|
+
|
33
|
+
line_items
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def self.record_daily_activity(activity_block, line_items, all_activity, current_time, index)
|
38
|
+
message = ""
|
39
|
+
commit_id = ""
|
40
|
+
committer = nil
|
41
|
+
|
42
|
+
if activity_block.size == 0
|
43
|
+
message = "No activity."
|
44
|
+
else
|
45
|
+
upcoming_commit = upcoming_commit(all_activity, index)
|
46
|
+
if upcoming_commit.nil?
|
47
|
+
message = "Work in progress.."
|
48
|
+
else
|
49
|
+
task_id = upcoming_commit.task_id
|
50
|
+
commit_id = upcoming_commit.commit_id
|
51
|
+
|
52
|
+
message = "Work towards story # #{task_id} (#{upcoming_commit.commit_id})" unless task_id.nil?
|
53
|
+
message = "Work towards commit #{upcoming_commit.commit_id}" if task_id.nil?
|
54
|
+
committer = upcoming_commit.committer
|
55
|
+
end
|
56
|
+
end
|
57
|
+
daily_incomplete_commit = BBNW::Commit.new(commit_id, message, committer, current_time)
|
58
|
+
|
59
|
+
line_items[daily_incomplete_commit]||=[]
|
60
|
+
line_items[daily_incomplete_commit].concat(activity_block)
|
61
|
+
activity_block.clear
|
62
|
+
|
63
|
+
daily_incomplete_commit
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.upcoming_commit(all_activity, index)
|
67
|
+
start_index = index + 1
|
68
|
+
while (start_index < all_activity.size) do
|
69
|
+
return all_activity[start_index] if all_activity[start_index].is_a?(BBNW::Commit)
|
70
|
+
start_index+=1
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.record_activities(activity_block, line_items, commit)
|
76
|
+
line_items[commit]||=[]
|
77
|
+
line_items[commit].concat(activity_block)
|
78
|
+
activity_block.clear
|
79
|
+
commit
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.day_changed?(activity, current_time)
|
83
|
+
end_time = activity.end_time
|
84
|
+
!(current_time.year == end_time.year && current_time.day == end_time.day && current_time.month == end_time.month)
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.debug_output(activity)
|
88
|
+
if activity.is_a?(BBNW::Activity)
|
89
|
+
puts "#{activity.start_time},#{activity.end_time},#{activity.activity}"
|
90
|
+
elsif activity.is_a?(BBNW::Commit)
|
91
|
+
puts "---------------------------------------------"
|
92
|
+
puts "Commit : #{activity.start_time},#{activity.end_time},#{activity.activity}"
|
93
|
+
puts "---------------------------------------------"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
# Example data :
|
103
|
+
#
|
104
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:38:52 -0700,1. Cypress Server (cc1)
|
105
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:38:49 -0700,https://www.pivotaltracker.com/signin
|
106
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:38:52 -0700,1. Cypress Server (git)
|
107
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:42:48 -0700,Searching "Inbox"
|
108
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:39:22 -0700,db/schema.rb at master from manilla/redwood - GitHub
|
109
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:38:52 -0700,manilla/redwood at master - GitHub
|
110
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:39:51 -0700,1. Cypress Server (bash)
|
111
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:40:22 -0700,1. Cypress Server (ruby)
|
112
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:39:01 -0700,app/models/cypress_mailer.rb at master from manilla/redwood - GitHub
|
113
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:38:57 -0700,1. Cypress Worker (ruby)
|
114
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:39:40 -0700,2. nano
|
115
|
+
#2011-05-26 04:38:47 -0700,2011-05-26 04:39:02 -0700,manilla/redwood - GitHub
|
116
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:17 -0700,2. sh
|
117
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:40:25 -0700,Git Cherry Picking: Move small code patches across branches | TechnoSophos
|
118
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:17 -0700,2. gnumake
|
119
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:14 -0700,Warning: Unresponsive script
|
120
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:17 -0700,Problem loading page
|
121
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:32 -0700,Re: Remote Redwood branch clean up
|
122
|
+
#2011-05-26 03:39:12 -0700,2011-05-26 03:39:17 -0700,2. gcc-4.2
|
123
|
+
#---------------------------------------------
|
124
|
+
#2011-05-26 01:23:03 -0700,2011-05-26 01:23:03 -0700,Merge branch 'feature-13269583-require_comments_on_robot_revisions' [13269583]
|
125
|
+
#---------------------------------------------
|
126
|
+
#---------------------------------------------
|
127
|
+
#2011-05-26 01:19:50 -0700,2011-05-26 01:19:50 -0700,User is now prompted for a comment before publishing to production. Comment is displayed in the show fields along with an icon next to the entry in the similar robots table. [13269583]
|
128
|
+
#---------------------------------------------
|
129
|
+
#---------------------------------------------
|
130
|
+
#2011-05-25 21:33:31 -0700,2011-05-25 21:33:31 -0700,Merge branch 'master' of github.com:manilla/redwood [13269479]
|
131
|
+
#---------------------------------------------
|
132
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:23:33 -0700,git_merge_fails.txt — jimjones
|
133
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:13:33 -0700,git cherry pick - Google Search
|
134
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:16:33 -0700,index.html — jquery.fancybox-1.3.4
|
135
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:21:59 -0700,
|
136
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 17:13:37 -0700,RubyMine
|
137
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:13:54 -0700,Action Controller: Exception caught
|
138
|
+
#2011-05-25 16:13:28 -0700,2011-05-25 16:36:06 -0700,Redwood
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'base/activities'
|
3
|
+
require 'base/activity'
|
4
|
+
require 'utils/date_time_utils'
|
5
|
+
require 'csv'
|
6
|
+
|
7
|
+
module BBNW
|
8
|
+
|
9
|
+
class TimeSink
|
10
|
+
include BBNW::Activities
|
11
|
+
include BBNW::DateTimeUtils
|
12
|
+
|
13
|
+
APPLICATION = 0
|
14
|
+
START_TIME = 1
|
15
|
+
END_TIME = 2
|
16
|
+
TIME_SINK_HEADERS = {'Application' => APPLICATION,
|
17
|
+
'Foreground Begin (Unix Time)' => START_TIME,
|
18
|
+
'Foreground End (Unix Time)' => END_TIME}
|
19
|
+
|
20
|
+
|
21
|
+
def initialize(file_path, start_date, end_date, debug = false)
|
22
|
+
@files = Dir.glob(file_path)
|
23
|
+
@debug = debug
|
24
|
+
@start_date = Time.new(start_date.year, start_date.month, start_date.day, 0, 0, 0)
|
25
|
+
@end_date = Time.new(end_date.year, end_date.month, end_date.day, 23, 59, 59)
|
26
|
+
end
|
27
|
+
|
28
|
+
def log(message)
|
29
|
+
puts message if @debug
|
30
|
+
end
|
31
|
+
|
32
|
+
def columns(header)
|
33
|
+
hs = {}
|
34
|
+
TIME_SINK_HEADERS.each do |k,v|
|
35
|
+
header.each_with_index do |header_item,i|
|
36
|
+
if header_item == k
|
37
|
+
hs[v] = i
|
38
|
+
break
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
hs
|
43
|
+
end
|
44
|
+
|
45
|
+
def get_activities
|
46
|
+
activities = []
|
47
|
+
prev_e = nil
|
48
|
+
blank_time = Time.at(0.0)
|
49
|
+
|
50
|
+
@files.each do |file|
|
51
|
+
csv = CSV::parse(File.open(file) { |f| f.read })
|
52
|
+
headers = csv.shift
|
53
|
+
cols = columns(headers)
|
54
|
+
|
55
|
+
csv.each_with_index do |row, i|
|
56
|
+
activity = row[cols[APPLICATION]]
|
57
|
+
s = row[cols[START_TIME]]
|
58
|
+
e = row[cols[END_TIME]]
|
59
|
+
|
60
|
+
next unless prev_e.nil? || s >= prev_e
|
61
|
+
|
62
|
+
start_time = parse_time(s)
|
63
|
+
#next if start_time == blank_time
|
64
|
+
|
65
|
+
end_time = parse_time(e)
|
66
|
+
#next if end_time == blank_time
|
67
|
+
|
68
|
+
next if end_time < @start_date
|
69
|
+
next if start_time > @end_date
|
70
|
+
#start_time = @start_date if start_time < @start_date && end_time < @end_date
|
71
|
+
#end_time = @end_date if start_time > @start_date && end_time > @end_date
|
72
|
+
|
73
|
+
time_entry = BBNW::Activity.new(activity, start_time, end_time)
|
74
|
+
activities << time_entry
|
75
|
+
|
76
|
+
prev_e = e
|
77
|
+
end
|
78
|
+
end
|
79
|
+
activities
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'adapters', '*.rb')).each {|f| require f }
|
2
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'adapters', 'base', '*.rb')).each {|f| require f }
|
3
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'adapters', 'time_estimators', '*.rb')).each {|f| require f }
|
4
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'utils', '*.rb')).each {|f| require f }
|
5
|
+
|
6
|
+
require 'payday'
|
7
|
+
|
8
|
+
module BBNW
|
9
|
+
class Invoice
|
10
|
+
include InvoiceDisplayUtils
|
11
|
+
|
12
|
+
attr_accessor :line_items
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@meetings = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# Usage :
|
19
|
+
# Compute time usage % over a 20 hour span
|
20
|
+
#
|
21
|
+
# line_items(git_commits, 20)
|
22
|
+
#
|
23
|
+
# Compute time usage using recorded time activities
|
24
|
+
# and cross reference that with git commits.
|
25
|
+
#
|
26
|
+
# line_items(git_commits, time_sink_activities)
|
27
|
+
#
|
28
|
+
def line_items(repository_adapter, activities_span, time_estimate_adapter, group_by_task_id = true)
|
29
|
+
@line_items = time_estimate_adapter.line_items(repository_adapter, activities_span, @meetings)
|
30
|
+
|
31
|
+
@line_items = BBNW::LineItemUtils.group_line_items(@line_items, {}, group_by_task_id)
|
32
|
+
@line_items = BBNW::LineItemUtils.group_line_items(@meetings, @line_items, group_by_task_id)
|
33
|
+
end
|
34
|
+
|
35
|
+
def generate(invoice_num, company_logo, company_name,
|
36
|
+
company_details, bill_to, rate_per_hour, start_date = nil, end_date = nil, hours = nil)
|
37
|
+
|
38
|
+
raise "There are not line items to generate a report with." if @line_items.empty?
|
39
|
+
|
40
|
+
rate_per_second = rate_per_hour / 3600.to_f
|
41
|
+
Payday::Config.default.invoice_logo = company_logo
|
42
|
+
Payday::Config.default.company_name = company_name
|
43
|
+
Payday::Config.default.company_details = company(company_details, start_date, end_date, hours)
|
44
|
+
|
45
|
+
due_date = Date.today + 21
|
46
|
+
invoice = Payday::Invoice.new(:invoice_number => invoice_num,
|
47
|
+
:bill_to => bill_to.join("\n"),
|
48
|
+
:due_at => due_date)
|
49
|
+
|
50
|
+
display_price = display_price(rate_per_hour)
|
51
|
+
|
52
|
+
build_line_item_report(display_price, invoice, rate_per_second, Fixnum)
|
53
|
+
build_line_item_report(display_price, invoice, rate_per_second, String)
|
54
|
+
|
55
|
+
invoice.render_pdf_to_file(invoice_path(invoice_num, bill_to[0]))
|
56
|
+
end
|
57
|
+
|
58
|
+
def meeting(label, start_time, end_time, account_for)
|
59
|
+
s = Time.parse(start_time)
|
60
|
+
e = Time.parse(end_time)
|
61
|
+
meeting_label = "Meeting (#{short_time(s)} - #{short_time(e)}): #{label}"
|
62
|
+
time_span = account_for ? (e - s) : 0
|
63
|
+
@meetings[BBNW::Meeting.new(meeting_label, s, e, account_for)] = time_span
|
64
|
+
end
|
65
|
+
|
66
|
+
def task(label, start_time, end_time)
|
67
|
+
s = Time.parse(start_time)
|
68
|
+
e = Time.parse(end_time)
|
69
|
+
label = "Task : #{label}"
|
70
|
+
@meetings[BBNW::Meeting.new(label, s, e)] = e - s
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def build_line_item_report(display_price, invoice, rate_per_second, typ = nil)
|
76
|
+
@line_items.each do |task_id, billable_item|
|
77
|
+
next unless typ.nil? || task_id.is_a?(typ)
|
78
|
+
time_elapsed, minutes, hours = time_description(billable_item.total_time)
|
79
|
+
head = header(task_id)
|
80
|
+
|
81
|
+
invoice.line_items << Payday::LineItem.new(:price => rate_per_second,
|
82
|
+
:quantity => billable_item.total_time,
|
83
|
+
:display_quantity => time_elapsed,
|
84
|
+
:display_price => display_price,
|
85
|
+
:description => billable_item.all_descriptions(head))
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def invoice_path(invoice_num, company_name)
|
90
|
+
"./#{company_name.gsub(' ','_').gsub("'",'').gsub(',','').downcase}_#{invoice_num}.pdf"
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module BBNW
|
2
|
+
module DateTimeUtils
|
3
|
+
def short_date(time)
|
4
|
+
"#{Date::DAYNAMES[time.wday]}, #{Date::ABBR_MONTHNAMES[time.month]} #{time.mday}"
|
5
|
+
end
|
6
|
+
|
7
|
+
def long_date(time)
|
8
|
+
"#{Date::DAYNAMES[time.wday]}, #{Date::ABBR_MONTHNAMES[time.month]} #{time.mday}, #{time.year}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def short_time(time)
|
12
|
+
time.strftime("%I:%M %P")
|
13
|
+
end
|
14
|
+
|
15
|
+
def long_time(time)
|
16
|
+
time.strftime("%I:%M.%S %P")
|
17
|
+
end
|
18
|
+
|
19
|
+
def long_date_time(time)
|
20
|
+
"#{long_date(time)} #{long_time(time)}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def is_number?(v)
|
24
|
+
true if Float(v) rescue false
|
25
|
+
end
|
26
|
+
|
27
|
+
def parse_time(s)
|
28
|
+
# If it's a number, it's probably a UNIX style timestamp, otherwise parse the string
|
29
|
+
is_number?(s) ? Time.at(s.to_f) : Time.parse(s)
|
30
|
+
end
|
31
|
+
|
32
|
+
def time_description(time)
|
33
|
+
hours = (time / 3600).to_i
|
34
|
+
minutes = (time / 60 - hours * 60).to_i
|
35
|
+
seconds = (time - (minutes * 60 + hours * 3600))
|
36
|
+
|
37
|
+
display = "#{minutes} minutes"
|
38
|
+
display = "#{hours} hours, #{display}" if hours > 0
|
39
|
+
return display, minutes, hours
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require File.join(File.join(File.dirname(__FILE__),'date_time_utils'))
|
2
|
+
|
3
|
+
module BBNW
|
4
|
+
module InvoiceDisplayUtils
|
5
|
+
include BBNW::DateTimeUtils
|
6
|
+
|
7
|
+
def header(task_id)
|
8
|
+
task_id.is_a?(Integer) ? "Story #: #{task_id}" : task_id
|
9
|
+
end
|
10
|
+
|
11
|
+
def display_price(price)
|
12
|
+
display_price = "$#{price}"
|
13
|
+
"#{display_price}.00" unless price =~ /./
|
14
|
+
end
|
15
|
+
|
16
|
+
def message_display(billed_commit)
|
17
|
+
"( #{billed_commit.timestamp} )\t#{billed_commit.message}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def company(details, start_date, end_date, hours)
|
21
|
+
details << "\n\n#{long_date(start_date)} - #{long_date(end_date)}" unless start_date.nil? || end_date.nil?
|
22
|
+
details << "\nBillable : #{hours} hours" unless hours.nil?
|
23
|
+
details.join("\n")
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'adapters/base/billable'
|
2
|
+
require 'utils/invoice_display_utils'
|
3
|
+
|
4
|
+
module BBNW
|
5
|
+
class LineItemUtils
|
6
|
+
MAX_IDLE = 900
|
7
|
+
extend BBNW::InvoiceDisplayUtils
|
8
|
+
|
9
|
+
def self.group_line_items(line_items, prev_line_items = {}, group_by_task_id = true)
|
10
|
+
grouped_line_items = prev_line_items.clone
|
11
|
+
|
12
|
+
line_items.each do |billed_commit, activities|
|
13
|
+
|
14
|
+
# Let's look for a long numeric ID and group by that; if it doesn't exist
|
15
|
+
# Add the commit item as it's own line item (hence the random id).
|
16
|
+
task_id = "== #{short_date(billed_commit.timestamp)} =="
|
17
|
+
task_id = billed_commit.task_id if group_by_task_id
|
18
|
+
|
19
|
+
billable_item = grouped_line_items.has_key?(task_id) ? grouped_line_items[task_id] : BBNW::Billable.new
|
20
|
+
|
21
|
+
billable_item.total_time+=(activities.is_a?(Array) ? billed_seconds(activities) : activities)
|
22
|
+
billable_item.descriptions << billed_commit.message
|
23
|
+
grouped_line_items[task_id] = billable_item
|
24
|
+
end
|
25
|
+
|
26
|
+
grouped_line_items
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.billed_seconds(activities)
|
30
|
+
seconds = 0
|
31
|
+
activities.each do |activity|
|
32
|
+
time_elapsed = activity.total_seconds
|
33
|
+
time_elapsed = MAX_IDLE if time_elapsed > MAX_IDLE
|
34
|
+
seconds += time_elapsed
|
35
|
+
end
|
36
|
+
seconds
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
data/sample_report.pdf
ADDED
Binary file
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe BBNW::CsvFile do
|
4
|
+
describe "#get_activities" do
|
5
|
+
it "should convert each line in the CSV into an Activity" do
|
6
|
+
end_date = Time.now
|
7
|
+
start_date = end_date + 10
|
8
|
+
|
9
|
+
puts start_date.to_s
|
10
|
+
|
11
|
+
fake_row = ['rubymine', start_date.to_s, end_date.to_s]
|
12
|
+
CSV.stub!(:open).with("foo", 'r').and_yield(fake_row)
|
13
|
+
|
14
|
+
csv_file = BBNW::CsvFile.new("foo", nil, nil)
|
15
|
+
# csv_file.get_activities.should == [
|
16
|
+
# BBNW::Activity.new('rubymine', start_date, end_date)
|
17
|
+
# ]
|
18
|
+
activities = csv_file.get_activities
|
19
|
+
activities.should be_an(Array)
|
20
|
+
activities.should have(1).activity
|
21
|
+
activity = activities.first
|
22
|
+
activity.start_time.to_s.should == start_date.to_s
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
3
|
+
|
4
|
+
require 'rspec'
|
5
|
+
require 'big_bucks_no_whammies'
|
6
|
+
|
7
|
+
# Requires supporting files with custom matchers and macros, etc,
|
8
|
+
# in ./support/ and its subdirectories.
|
9
|
+
Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
|
13
|
+
end
|
metadata
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: big_bucks_no_whammies
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 1.0.0
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Jim Jones
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-08-22 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: big_bucks_no_whammies
|
17
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: "0"
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: *id001
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: grit
|
28
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
29
|
+
none: false
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.4.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: *id002
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: payday
|
39
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: 1.0.2
|
45
|
+
type: :runtime
|
46
|
+
prerelease: false
|
47
|
+
version_requirements: *id003
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: jeweler
|
50
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: 1.6.0
|
56
|
+
type: :runtime
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: *id004
|
59
|
+
- !ruby/object:Gem::Dependency
|
60
|
+
name: rspec
|
61
|
+
requirement: &id005 !ruby/object:Gem::Requirement
|
62
|
+
none: false
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: 2.3.0
|
67
|
+
type: :runtime
|
68
|
+
prerelease: false
|
69
|
+
version_requirements: *id005
|
70
|
+
description: "Auto-generate a pdf invoice utilizing your git commit activity. Time is automatically accounted for utilizing TimeSink and is proportionately applied towards each line item entry. "
|
71
|
+
email: jjones@aantix.com
|
72
|
+
executables: []
|
73
|
+
|
74
|
+
extensions: []
|
75
|
+
|
76
|
+
extra_rdoc_files:
|
77
|
+
- LICENSE.txt
|
78
|
+
- README.rdoc
|
79
|
+
files:
|
80
|
+
- .document
|
81
|
+
- .rspec
|
82
|
+
- Gemfile
|
83
|
+
- LICENSE.txt
|
84
|
+
- README.rdoc
|
85
|
+
- Rakefile
|
86
|
+
- VERSION
|
87
|
+
- big_bucks_no_whammies.gemspec
|
88
|
+
- lib/adapters/base/activities.rb
|
89
|
+
- lib/adapters/base/activity.rb
|
90
|
+
- lib/adapters/base/billable.rb
|
91
|
+
- lib/adapters/base/commit.rb
|
92
|
+
- lib/adapters/base/commits.rb
|
93
|
+
- lib/adapters/base/meeting.rb
|
94
|
+
- lib/adapters/chrometa.rb
|
95
|
+
- lib/adapters/csv_file.rb
|
96
|
+
- lib/adapters/git.rb
|
97
|
+
- lib/adapters/time_estimators/number_of_source_commits.rb
|
98
|
+
- lib/adapters/time_estimators/number_of_sources_changes.rb
|
99
|
+
- lib/adapters/time_estimators/recorded_activity.rb
|
100
|
+
- lib/adapters/time_sink.rb
|
101
|
+
- lib/big_bucks_no_whammies.rb
|
102
|
+
- lib/utils/date_time_utils.rb
|
103
|
+
- lib/utils/invoice_display_utils.rb
|
104
|
+
- lib/utils/line_item_utils.rb
|
105
|
+
- sample_report.pdf
|
106
|
+
- spec/big_bucks_no_whammies_spec.rb
|
107
|
+
- spec/csv_file_spec.rb
|
108
|
+
- spec/spec_helper.rb
|
109
|
+
homepage: http://github.com/aantix/big_bucks_no_whammies
|
110
|
+
licenses:
|
111
|
+
- MIT
|
112
|
+
post_install_message:
|
113
|
+
rdoc_options: []
|
114
|
+
|
115
|
+
require_paths:
|
116
|
+
- lib
|
117
|
+
- lib/adapters
|
118
|
+
- lib/adapters/base
|
119
|
+
- lib/adapters/time_estimators
|
120
|
+
- lib/adapters/utils
|
121
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
122
|
+
none: false
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: "0"
|
127
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
128
|
+
none: false
|
129
|
+
requirements:
|
130
|
+
- - ">="
|
131
|
+
- !ruby/object:Gem::Version
|
132
|
+
version: "0"
|
133
|
+
requirements: []
|
134
|
+
|
135
|
+
rubyforge_project:
|
136
|
+
rubygems_version: 1.8.5
|
137
|
+
signing_key:
|
138
|
+
specification_version: 3
|
139
|
+
summary: Big Bucks No Whammies allows software development contractors to generate a line-item invoice based on git commits. Activity
|
140
|
+
test_files: []
|
141
|
+
|