harvest_overtime 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5922899a634a981bdc3748ddbdda615b61c7c31f
4
+ data.tar.gz: 43548262290ac6a3cc860fe464f5236a46cfa7df
5
+ SHA512:
6
+ metadata.gz: 151245df2b95315eb3434e7bc899acfc8cb233ad37722c838df8e9db76378bb9f55dc6ac5cce083f89cf1841e039fd6c286c07d213edd04801aa5084c2b51ee1
7
+ data.tar.gz: 1ae47d19b4da295d94d50946716a768e1c9d4081af3523c7723712eb49369b360428a6bf9f286f90f8d4cb7c19c10ef92a1b74b2b189de33b4a37fad3400b9fc
data/bin/overtime ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'harvest_overtime'
5
+ require 'active_support/time'
6
+
7
+ ACCOUNT_ID_ENV_VAR = 'HARVEST_ACCOUNT_ID'
8
+ TOKEN_ENV_VAR = 'HARVEST_TOKEN'
9
+ COLUMN_SIZE = 20
10
+
11
+ def print_in_columns(array, column_size)
12
+ puts array.map { |e| e.to_s.ljust(column_size, ' ') }.join
13
+ end
14
+
15
+ harvest_account_id = ENV[ACCOUNT_ID_ENV_VAR]
16
+ harvest_personal_access_token = ENV[TOKEN_ENV_VAR]
17
+ number_of_months = ARGV[0]&.to_i || 3
18
+
19
+ unless harvest_account_id && harvest_personal_access_token
20
+ puts "#{ACCOUNT_ID_ENV_VAR} and #{TOKEN_ENV_VAR} environment variables must be set to use #{__FILE__} script!\n\n" \
21
+ "usage: #{__FILE__} [number_of_months]"
22
+ exit(1)
23
+ end
24
+
25
+ end_date = Date.yesterday
26
+ start_date = (end_date - (number_of_months - 1).months).beginning_of_month
27
+
28
+ overtime = HarvestOvertime.new(account_id: harvest_account_id, personal_access_token: harvest_personal_access_token)
29
+ per_month_stats = overtime.monthly_stats(start_date, end_date)
30
+
31
+ print_in_columns(['Month', 'Business hours', 'Billed hours', 'Overtime'], COLUMN_SIZE)
32
+ per_month_stats.each do |month, stats|
33
+ print_in_columns([month, stats.business_hours, stats.billed_hours.round(1), stats.overtime.round(1)], COLUMN_SIZE)
34
+ end
35
+
36
+ unless per_month_stats.empty?
37
+ total_overtime = per_month_stats.values.map(&:overtime).reduce(&:+)
38
+
39
+ puts "\nTotal overtime: #{total_overtime.round(1)} hour(s) -> #{(total_overtime / 8).floor} day(s)"
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BusinessDaysComputer
4
+ def business_days(start_date, end_date)
5
+ (start_date..end_date).reject(&:saturday?).reject(&:sunday?)
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ class HarvestClient
7
+ def initialize(account_id:, personal_access_token:)
8
+ @faraday = Faraday.new(
9
+ url: 'https://api.harvestapp.com/api/v2',
10
+ headers: { 'Harvest-Account-ID' => account_id, 'Authorization' => "Bearer #{personal_access_token}" }
11
+ )
12
+ end
13
+
14
+ def time_entries(start_date, end_date)
15
+ user_id = retrieve_user_id
16
+
17
+ time_entry_hashes = retrieve_time_entries(user_id, start_date, end_date)
18
+
19
+ build_time_entry_objects(time_entry_hashes)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :faraday
25
+
26
+ def retrieve_user_id
27
+ response = faraday.get('users/me')
28
+
29
+ response_body_hash = parse_response(response)
30
+
31
+ response_body_hash['id']
32
+ end
33
+
34
+ def retrieve_time_entries(user_id, start_date, end_date)
35
+ time_entry_hashes = []
36
+
37
+ response = faraday.get('time_entries', user_id: user_id, from: start_date.iso8601, to: end_date.iso8601)
38
+ body_hash = parse_response(response)
39
+ time_entry_hashes.concat(body_hash['time_entries'])
40
+
41
+ while (next_page_url = body_hash.dig('links', 'next'))
42
+ response = faraday.get(next_page_url)
43
+ body_hash = parse_response(response)
44
+ time_entry_hashes.concat(body_hash['time_entries'])
45
+ end
46
+
47
+ time_entry_hashes
48
+ end
49
+
50
+ def parse_response(response)
51
+ raise "Harvest API request error (status #{response.code}): #{response.body}" unless response.success?
52
+
53
+ JSON.parse(response.body)
54
+ end
55
+
56
+ def build_time_entry_objects(time_entry_hashes)
57
+ time_entry_hashes.map do |time_entry_hash|
58
+ date = Date.parse(time_entry_hash['spent_date'])
59
+ hours = time_entry_hash['hours']
60
+
61
+ TimeEntry.new(date, hours)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Month = Struct.new(:year, :month) do
4
+ def self.from_date(date)
5
+ new(date.year, date.month)
6
+ end
7
+
8
+ def to_s
9
+ "#{year}-#{month.to_s.rjust(2, '0')}"
10
+ end
11
+ end
12
+
13
+ TimeEntry = Struct.new(:date, :hours) do
14
+ def month
15
+ Month.new(date.year, date.month)
16
+ end
17
+ end
18
+
19
+ TimeStats = Struct.new(:business_hours, :billed_hours) do
20
+ def overtime
21
+ billed_hours - business_hours
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ require_relative 'harvest_overtime/structs'
6
+ require_relative 'harvest_overtime/business_days_computer'
7
+ require_relative 'harvest_overtime/harvest_client'
8
+
9
+ class HarvestOvertime
10
+ HOURS_PER_DAY = 8
11
+
12
+ def initialize(account_id: nil, personal_access_token: nil, harvest_client: nil, business_days_computer: nil)
13
+ @business_days_computer = business_days_computer || BusinessDaysComputer.new
14
+ @harvest_client = harvest_client || build_default_harvest_client(account_id, personal_access_token)
15
+ end
16
+
17
+ def monthly_stats(start_date, end_date)
18
+ business_days = business_days_computer.business_days(start_date, end_date)
19
+ business_hours_number_by_month = compute_per_month_business_hours_number(business_days)
20
+
21
+ time_entries = harvest_client.time_entries(start_date, end_date)
22
+ billed_hours_by_month = sum_billed_hours_by_month(time_entries)
23
+
24
+ business_hours_number_by_month.each_with_object({}) do |(month, business_hours), hash|
25
+ billed_hours = billed_hours_by_month[month] || 0
26
+
27
+ hash[month] = TimeStats.new(business_hours, billed_hours)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :business_days_computer, :harvest_client
34
+
35
+ def build_default_harvest_client(account_id, personal_access_token)
36
+ raise ArgumentError, 'account_id must be provided' unless account_id
37
+ raise ArgumentError, 'personal_access_token must be provided' unless personal_access_token
38
+
39
+ HarvestClient.new(account_id: account_id, personal_access_token: personal_access_token)
40
+ end
41
+
42
+ def compute_per_month_business_hours_number(business_days)
43
+ business_days.each_with_object({}) do |date, hash|
44
+ month = Month.from_date(date)
45
+
46
+ hash[month] ||= 0
47
+ hash[month] += HOURS_PER_DAY
48
+ end
49
+ end
50
+
51
+ def sum_billed_hours_by_month(time_entries)
52
+ time_entries.each_with_object({}) do |entry, hash|
53
+ month = Month.from_date(entry.date)
54
+
55
+ hash[month] ||= 0
56
+ hash[month] += entry.hours
57
+ end
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: harvest_overtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Marek Mateja
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: vcr
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Simple command-line tool for tracking overtime in Harvest
98
+ email: matejowy@gmail.com
99
+ executables:
100
+ - overtime
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - bin/overtime
105
+ - lib/harvest_overtime.rb
106
+ - lib/harvest_overtime/business_days_computer.rb
107
+ - lib/harvest_overtime/harvest_client.rb
108
+ - lib/harvest_overtime/structs.rb
109
+ homepage: https://github.com/mmateja/harvest_overtime
110
+ licenses:
111
+ - ISC
112
+ metadata: {}
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '2.3'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubyforge_project:
129
+ rubygems_version: 2.6.11
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Keep track of your billed hours in Harvest!
133
+ test_files: []