fewald-worklog 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.
- checksums.yaml +7 -0
- data/.version +1 -0
- data/bin/wl +16 -0
- data/lib/cli.rb +149 -0
- data/lib/configuration.rb +33 -0
- data/lib/daily_log.rb +35 -0
- data/lib/date_parser.rb +69 -0
- data/lib/editor.rb +38 -0
- data/lib/hash.rb +25 -0
- data/lib/log_entry.rb +94 -0
- data/lib/person.rb +26 -0
- data/lib/printer.rb +61 -0
- data/lib/statistics.rb +38 -0
- data/lib/storage.rb +172 -0
- data/lib/string_helper.rb +35 -0
- data/lib/summary.rb +78 -0
- data/lib/templates/favicon.svg.erb +7 -0
- data/lib/templates/index.html.erb +148 -0
- data/lib/version.rb +31 -0
- data/lib/webserver.rb +115 -0
- data/lib/worklog.rb +216 -0
- data/lib/worklogger.rb +26 -0
- metadata +171 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: dc96d7e3c9fa2cb64c09f04b13683f64dbce8b993d16e0529df92fddff947901
|
4
|
+
data.tar.gz: 0d4de596547f66de795f7de9ec979924b18d19bac9e5b4722f755306db85f8fd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a48499cd05d20d472b4126e18b588114740c26a18cb7b9fe6eb10aaabff231654406a70cf467c3a5d175165b8b6a7c81749bed376a8f0ffebbf97d43e50a1e94
|
7
|
+
data.tar.gz: ef081d883a7f2ed48e7513690a2844235851cf90bd03792d49e74f0b574b42161e1f674a37fadded9a37c66b6f938719dfdda4666a94a7066b11de17da5b18ce
|
data/.version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
|
data/bin/wl
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# This is the main entry point for the worklog CLI.
|
5
|
+
|
6
|
+
if ENV['WL_PATH']
|
7
|
+
# Import the worklog CLI from the path specified in the WL_PATH environment variable
|
8
|
+
# This is used during development to avoid having to rely on the order of the $PATH.
|
9
|
+
puts "Loading worklog from #{ENV['WL_PATH']}. This should only be used during development."
|
10
|
+
puts 'To use the installed worklog, unset the WL_PATH environment variable.'
|
11
|
+
require_relative File.join(ENV['WL_PATH'], 'lib', 'cli')
|
12
|
+
else
|
13
|
+
require_relative '../lib/cli'
|
14
|
+
end
|
15
|
+
|
16
|
+
WorklogCLI.start
|
data/lib/cli.rb
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add the current directory to the load path
|
4
|
+
# curr_dir = File.expand_path(__dir__)
|
5
|
+
# $LOAD_PATH << curr_dir unless $LOAD_PATH.include?(curr_dir)
|
6
|
+
|
7
|
+
require 'thor'
|
8
|
+
require 'date'
|
9
|
+
require 'worklogger'
|
10
|
+
|
11
|
+
require 'worklog'
|
12
|
+
require 'date_parser'
|
13
|
+
require 'configuration'
|
14
|
+
require 'editor'
|
15
|
+
require 'printer'
|
16
|
+
require 'statistics'
|
17
|
+
require 'storage'
|
18
|
+
require 'string_helper'
|
19
|
+
require 'summary'
|
20
|
+
require 'version'
|
21
|
+
require 'webserver'
|
22
|
+
|
23
|
+
# CLI for the work log application
|
24
|
+
class WorklogCLI < Thor
|
25
|
+
attr_accessor :config, :storage
|
26
|
+
|
27
|
+
include StringHelper
|
28
|
+
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Enable verbose output'
|
29
|
+
|
30
|
+
package_name 'Worklog'
|
31
|
+
|
32
|
+
# Initialize the CLI with the given arguments, options, and configuration
|
33
|
+
def initialize(args = [], options = {}, config = {})
|
34
|
+
@config = load_configuration
|
35
|
+
@storage = Storage.new(@config)
|
36
|
+
super
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.exit_on_failure?
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
desc 'add MESSAGE', 'Add a new entry to the work log, defaults to the current date.'
|
44
|
+
long_desc <<~LONGDESC
|
45
|
+
Add a new entry with the current date and time to the work log.
|
46
|
+
The message is required and must be enclosed in quotes if it contains more than one word.
|
47
|
+
|
48
|
+
People can be referenced either by using the tilde "~" or the at symbol "@", followed by
|
49
|
+
an alphanumeric string.
|
50
|
+
LONGDESC
|
51
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'), desc: 'Set the date of the entry'
|
52
|
+
option :time, type: :string, default: DateTime.now.strftime('%H:%M:%S'), desc: 'Set the time of the entry'
|
53
|
+
option :tags, type: :array, default: [], desc: 'Add tags to the entry'
|
54
|
+
option :ticket, type: :string, desc: 'Ticket number associated with the entry. Can be any alphanumeric string.'
|
55
|
+
option :url, type: :string, desc: 'URL to associate with the entry'
|
56
|
+
option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
|
57
|
+
def add(message)
|
58
|
+
worklog = Worklog.new
|
59
|
+
worklog.add(message, options)
|
60
|
+
end
|
61
|
+
|
62
|
+
desc 'edit', 'Edit a day in the work log. By default, the current date is used.'
|
63
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
64
|
+
def edit
|
65
|
+
worklog = Worklog.new
|
66
|
+
worklog.edit(options)
|
67
|
+
end
|
68
|
+
|
69
|
+
desc 'remove', 'Remove last entry from the log'
|
70
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
71
|
+
def remove
|
72
|
+
worklog = Worklog.new
|
73
|
+
worklog.remove(options)
|
74
|
+
end
|
75
|
+
|
76
|
+
desc 'show', 'Show the work log for a specific date or a range of dates. Defaults to todays date.'
|
77
|
+
long_desc <<~LONGDESC
|
78
|
+
Show the work log for a specific date or a range of dates. As a default, all items from the current day will be shown.
|
79
|
+
LONGDESC
|
80
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'),
|
81
|
+
desc: <<~DESC
|
82
|
+
Show the work log for a specific date. If this option is provided, --from and --to and --days should not be used.
|
83
|
+
DESC
|
84
|
+
option :from, type: :string, desc: <<~EOF
|
85
|
+
Inclusive start date of the range. Takes precedence over --date, if defined.
|
86
|
+
EOF
|
87
|
+
option :to, type: :string, desc: <<~EOF
|
88
|
+
Inclusive end date of the range. Takes precedence over --date, if defined.
|
89
|
+
EOF
|
90
|
+
option :days, type: :numeric, desc: <<~EOF
|
91
|
+
Number of days to show starting from --date. Takes precedence over --from and --to if defined.
|
92
|
+
EOF
|
93
|
+
option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
|
94
|
+
option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
|
95
|
+
def show
|
96
|
+
worklog = Worklog.new
|
97
|
+
worklog.show(options)
|
98
|
+
end
|
99
|
+
|
100
|
+
desc 'people', 'Show all people mentioned in the work log'
|
101
|
+
def people(person = nil)
|
102
|
+
worklog = Worklog.new
|
103
|
+
worklog.people(person, options)
|
104
|
+
end
|
105
|
+
|
106
|
+
desc 'tags', 'Show all tags used in the work log'
|
107
|
+
def tags
|
108
|
+
worklog = Worklog.new
|
109
|
+
worklog.tags(options)
|
110
|
+
end
|
111
|
+
|
112
|
+
desc 'server', 'Start the work log server'
|
113
|
+
def server
|
114
|
+
app = WorkLogApp.new(@storage)
|
115
|
+
WorkLogServer.new(app).start
|
116
|
+
end
|
117
|
+
|
118
|
+
desc 'stats', 'Show statistics for the work log'
|
119
|
+
def stats
|
120
|
+
worklog = Worklog.new
|
121
|
+
worklog.stats(options)
|
122
|
+
end
|
123
|
+
|
124
|
+
desc 'summary', 'Generate a summary of the work log entries'
|
125
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
126
|
+
option :from, type: :string, desc: <<-EOF
|
127
|
+
'Inclusive start date of the range. Takes precedence over --date if defined.'
|
128
|
+
EOF
|
129
|
+
option :to, type: :string, desc: <<-EOF
|
130
|
+
'Inclusive end date of the range. Takes precedence over --date if defined.'
|
131
|
+
EOF
|
132
|
+
option :days, type: :numeric, desc: <<-EOF
|
133
|
+
'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
|
134
|
+
EOF
|
135
|
+
def summary
|
136
|
+
worklog = Worklog.new
|
137
|
+
worklog.summary(options)
|
138
|
+
end
|
139
|
+
|
140
|
+
desc 'version', 'Show the version of the Worklog'
|
141
|
+
def version
|
142
|
+
puts "Worklog #{current_version} running on Ruby #{RUBY_VERSION}"
|
143
|
+
end
|
144
|
+
|
145
|
+
# Define shortcuts and aliases
|
146
|
+
map 'a' => :add
|
147
|
+
map 'statistics' => :stats
|
148
|
+
map 'serve' => :server
|
149
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
|
5
|
+
# Configuration class for the application.
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :storage_path, :log_level, :webserver_port
|
8
|
+
|
9
|
+
def initialize(&block)
|
10
|
+
block.call(self) if block_given?
|
11
|
+
|
12
|
+
# Set default values if not set
|
13
|
+
@storage_path ||= File.join(Dir.home, '.worklog')
|
14
|
+
@log_level ||= :info
|
15
|
+
@webserver_port ||= 3000
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Load configuration from a YAML file.
|
20
|
+
# The file should be located at ~/.worklog.yaml.
|
21
|
+
def load_configuration
|
22
|
+
file_path = File.join(Dir.home, '.worklog.yaml')
|
23
|
+
if File.exist?(file_path)
|
24
|
+
file_cfg = YAML.load_file(file_path)
|
25
|
+
Configuration.new do |cfg|
|
26
|
+
cfg.storage_path = file_cfg['storage_path'] if file_cfg['storage_path']
|
27
|
+
cfg.log_level = file_cfg['log_level'].to_sym if file_cfg['log_level']
|
28
|
+
cfg.webserver_port = file_cfg['webserver_port'] if file_cfg['webserver_port']
|
29
|
+
end
|
30
|
+
else
|
31
|
+
puts "Configuration file does not exist in #{file_path}"
|
32
|
+
end
|
33
|
+
end
|
data/lib/daily_log.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'hash'
|
4
|
+
|
5
|
+
# DailyLog is a container for a day's work log.
|
6
|
+
class DailyLog
|
7
|
+
# Container for a day's work log.
|
8
|
+
include Hashify
|
9
|
+
|
10
|
+
# Represents a single day's work log.
|
11
|
+
attr_accessor :date, :entries
|
12
|
+
|
13
|
+
def initialize(params = {})
|
14
|
+
@date = params[:date]
|
15
|
+
@entries = params[:entries]
|
16
|
+
end
|
17
|
+
|
18
|
+
def people?
|
19
|
+
# Returns true if there are people mentioned in any entry of the current day.
|
20
|
+
people.size.positive?
|
21
|
+
end
|
22
|
+
|
23
|
+
def people
|
24
|
+
# Returns a hash of people mentioned in the log for the current day
|
25
|
+
# with the number of times they are mentioned.
|
26
|
+
# People are defined as words starting with @ or ~.
|
27
|
+
#
|
28
|
+
# @return [Hash<String, Integer>]
|
29
|
+
entries.map(&:people).flatten.tally
|
30
|
+
end
|
31
|
+
|
32
|
+
def ==(other)
|
33
|
+
date == other.date && entries == other.entries
|
34
|
+
end
|
35
|
+
end
|
data/lib/date_parser.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
module DateParser
|
6
|
+
# Best effort date parsing from multiple formats.
|
7
|
+
def self.parse_date_string(date_str, from_beginning = true)
|
8
|
+
return nil if date_str.nil?
|
9
|
+
return nil if date_str.empty?
|
10
|
+
return nil if date_str.length > 10
|
11
|
+
|
12
|
+
# Try to parse basic format YYYY-MM-DD
|
13
|
+
begin
|
14
|
+
return Date.strptime(date_str, '%Y-%m-%d') if date_str.match(/^\d{4}-\d{1,2}-\d{1,2}$/)
|
15
|
+
rescue Date::Error
|
16
|
+
# puts "Date not in format YYYY-MM-DD."
|
17
|
+
end
|
18
|
+
|
19
|
+
# Try to parse format YYYY-MM
|
20
|
+
begin
|
21
|
+
if date_str.match(/^\d{4}-\d{1,2}$/)
|
22
|
+
d = Date.strptime(date_str, '%Y-%m')
|
23
|
+
return d if from_beginning
|
24
|
+
|
25
|
+
return Date.new(d.year, d.month, -1)
|
26
|
+
|
27
|
+
end
|
28
|
+
rescue Date::Error
|
29
|
+
# puts "Date not in format YYYY-MM."
|
30
|
+
end
|
31
|
+
|
32
|
+
# Try to parse format YYYY (without Q1234)
|
33
|
+
if date_str.match(/^\d{4}$/)
|
34
|
+
d = Date.strptime(date_str, '%Y')
|
35
|
+
return d if from_beginning
|
36
|
+
|
37
|
+
return Date.new(d.year, -1, -1)
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
# Long form quarter (2024-Q1, etc.)
|
42
|
+
match = date_str.match(/(\d{4})-[qQ]([1234])/)
|
43
|
+
if match
|
44
|
+
year, quarter = match.captures.map(&:to_i)
|
45
|
+
d = Date.new(year, ((quarter - 1) * 3) + 1, 1)
|
46
|
+
return d if from_beginning
|
47
|
+
|
48
|
+
return Date.new(d.year, d.month + 2, -1)
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
# Short form quarter
|
53
|
+
match = date_str.match(/[qQ]([1234])/)
|
54
|
+
return unless match
|
55
|
+
|
56
|
+
quarter = match.captures.first.to_i
|
57
|
+
d = Date.new(Date.today.year, ((quarter - 1) * 3) + 1, 1)
|
58
|
+
return d if from_beginning
|
59
|
+
|
60
|
+
Date.new(d.year, d.month + 2, -1)
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.parse_date_string!(date_str, from_beginning = true)
|
64
|
+
date = parse_date_string(date_str, from_beginning)
|
65
|
+
raise ArgumentError, "Could not parse date string: \"#{date_str}\"" if date.nil?
|
66
|
+
|
67
|
+
date
|
68
|
+
end
|
69
|
+
end
|
data/lib/editor.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
require 'tempfile'
|
5
|
+
|
6
|
+
# Editor to handle editing of log entries.
|
7
|
+
module Editor
|
8
|
+
EDITOR_PREAMBLE = ERB.new <<~README
|
9
|
+
# Edit the content below, then save the file and quit the editor.
|
10
|
+
# The update content will be saved. The content MUST be valid YAML
|
11
|
+
# in order for the application to be able to update the records.
|
12
|
+
|
13
|
+
<%= content %>
|
14
|
+
README
|
15
|
+
|
16
|
+
# Open text editor (currently ViM) with the initial text.
|
17
|
+
# Upon saving and exiting the editor, the updated text is returned.
|
18
|
+
# @param initial_text [String] The initial text to display in the editor.
|
19
|
+
# @return [String] The updated text.
|
20
|
+
def self.open_editor(initial_text)
|
21
|
+
file_handle = Tempfile.create
|
22
|
+
file_handle.write(initial_text)
|
23
|
+
file_handle.close
|
24
|
+
|
25
|
+
# Open the editor with the temporary file.
|
26
|
+
system('vim', file_handle.path)
|
27
|
+
|
28
|
+
updated_text = nil
|
29
|
+
|
30
|
+
# Read the updated text from the file.
|
31
|
+
File.open(file_handle.path, 'r') do |f|
|
32
|
+
updated_text = f.read
|
33
|
+
WorkLogger.debug("Updated text: #{updated_text}")
|
34
|
+
end
|
35
|
+
File.unlink(file_handle.path)
|
36
|
+
updated_text
|
37
|
+
end
|
38
|
+
end
|
data/lib/hash.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hashify
|
4
|
+
def to_hash
|
5
|
+
hash = {}
|
6
|
+
instance_variables.each do |var|
|
7
|
+
value = instance_variable_get(var)
|
8
|
+
hash[var.to_s.delete('@')] = value
|
9
|
+
end
|
10
|
+
hash
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class Hash
|
15
|
+
# Convert all keys to strings so that the YAML file can be read from different languages.
|
16
|
+
# This is a monkey patch to the Hash class.
|
17
|
+
|
18
|
+
def stringify_keys
|
19
|
+
# Convert all keys to strings.
|
20
|
+
# This is useful when the hash is serialized to YAML.
|
21
|
+
#
|
22
|
+
# @return [Hash] the hash with all keys converted to strings
|
23
|
+
map { |k, v| [k.to_s, v] }.to_h
|
24
|
+
end
|
25
|
+
end
|
data/lib/log_entry.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
require 'rainbow'
|
5
|
+
require 'daily_log'
|
6
|
+
require 'hash'
|
7
|
+
|
8
|
+
# A single log entry.
|
9
|
+
class LogEntry
|
10
|
+
PERSON_REGEX = /\s[~@](\w+)/
|
11
|
+
|
12
|
+
include Hashify
|
13
|
+
|
14
|
+
# Represents a single entry in the work log.
|
15
|
+
attr_accessor :time, :tags, :ticket, :url, :epic, :message
|
16
|
+
|
17
|
+
def initialize(params = {})
|
18
|
+
@time = params[:time]
|
19
|
+
# If tags are nil, set to empty array.
|
20
|
+
# This is similar to the CLI default value.
|
21
|
+
@tags = params[:tags] || []
|
22
|
+
@ticket = params[:ticket]
|
23
|
+
@url = params[:url] || ''
|
24
|
+
@epic = params[:epic]
|
25
|
+
@message = params[:message]
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns true if the entry is an epic, false otherwise.
|
29
|
+
def epic?
|
30
|
+
@epic == true
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the message string with formatting without the time.
|
34
|
+
# @param people Hash[String, Person] A hash of people with their handles as keys.
|
35
|
+
def message_string(known_people = nil)
|
36
|
+
# replace all mentions of people with their names.
|
37
|
+
msg = @message.dup
|
38
|
+
people.each do |person|
|
39
|
+
next unless known_people && known_people[person]
|
40
|
+
|
41
|
+
msg.gsub!(/[~@]#{person}/) do |match|
|
42
|
+
s = ''
|
43
|
+
s += ' ' if match[0] == ' '
|
44
|
+
s += "#{Rainbow(known_people[person].name).underline} (~#{person})" if known_people && known_people[person]
|
45
|
+
s
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
s = ''
|
50
|
+
|
51
|
+
s += if epic
|
52
|
+
Rainbow("[EPIC] #{msg}").bg(:white).fg(:black)
|
53
|
+
else
|
54
|
+
msg
|
55
|
+
end
|
56
|
+
|
57
|
+
s += " [#{Rainbow(@ticket).fg(:blue)}]" if @ticket
|
58
|
+
|
59
|
+
# Add tags in brackets if defined.
|
60
|
+
s += ' [' + @tags.map { |tag| "#{tag}" }.join(', ') + ']' if @tags && @tags.size > 0
|
61
|
+
|
62
|
+
# Add URL in brackets if defined.
|
63
|
+
s += " [#{@url}]" if @url && @url != ''
|
64
|
+
|
65
|
+
s
|
66
|
+
end
|
67
|
+
|
68
|
+
def people
|
69
|
+
# Return people that are mentioned in the entry.
|
70
|
+
# People are defined as words starting with @ or ~.
|
71
|
+
# Whitespaces are used to separate people.
|
72
|
+
# Punctuation is not considered.
|
73
|
+
# Empty array if no people are mentioned.
|
74
|
+
#
|
75
|
+
# @return [Array<String>]
|
76
|
+
@message.scan(PERSON_REGEX).flatten.uniq.sort
|
77
|
+
end
|
78
|
+
|
79
|
+
def people?
|
80
|
+
# Return true if there are people in the entry.
|
81
|
+
#
|
82
|
+
# @return [Boolean]
|
83
|
+
people.size.positive?
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_yaml
|
87
|
+
to_hash.to_yaml
|
88
|
+
end
|
89
|
+
|
90
|
+
def ==(other)
|
91
|
+
time == other.time && tags == other.tags && ticket == other.ticket && url == other.url &&
|
92
|
+
epic == other.epic && message == other.message
|
93
|
+
end
|
94
|
+
end
|
data/lib/person.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Represents a person at work.
|
4
|
+
class Person
|
5
|
+
attr_reader :handle, :name, :email, :team, :notes
|
6
|
+
|
7
|
+
def initialize(handle, name, email, team, notes = [])
|
8
|
+
@handle = handle
|
9
|
+
@name = name
|
10
|
+
@email = email
|
11
|
+
@team = team
|
12
|
+
@notes = notes
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
return "#{name} (~#{handle})" if @email.nil?
|
17
|
+
|
18
|
+
"#{name} (~#{handle}) <#{email}>"
|
19
|
+
end
|
20
|
+
|
21
|
+
def ==(other)
|
22
|
+
return false unless other.is_a?(Person)
|
23
|
+
|
24
|
+
handle == other.handle && name == other.name && email == other.email && team == other.team && notes == other.notes
|
25
|
+
end
|
26
|
+
end
|
data/lib/printer.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rainbow'
|
4
|
+
|
5
|
+
# Printer for work log entries
|
6
|
+
class Printer
|
7
|
+
attr_reader :people
|
8
|
+
|
9
|
+
# Initializes the printer with a list of people.
|
10
|
+
# @param people [Array<Person>] An array of Person objects.
|
11
|
+
def initialize(people = nil)
|
12
|
+
@people = (people || []).to_h { |person| [person.handle, person] }
|
13
|
+
end
|
14
|
+
|
15
|
+
# Prints a whole day of work log entries.
|
16
|
+
# If date_inline is true, the date is printed inline with the time.
|
17
|
+
# If epics_only is true, only epic entries are printed.
|
18
|
+
def print_day(daily_log, date_inline = false, epics_only = false)
|
19
|
+
daily_log.date = Date.strptime(daily_log.date, '%Y-%m-%d') unless daily_log.date.respond_to?(:strftime)
|
20
|
+
|
21
|
+
date_string = daily_log.date.strftime('%a, %B %-d, %Y')
|
22
|
+
puts "Work log for #{Rainbow(date_string).gold}" unless date_inline
|
23
|
+
|
24
|
+
daily_log.entries.each do |entry|
|
25
|
+
next if epics_only && !entry.epic?
|
26
|
+
|
27
|
+
print_entry(daily_log, entry, date_inline)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Print a message when no entries are found.
|
32
|
+
# @param start_date [Date]
|
33
|
+
# @param end_date [Date]
|
34
|
+
# @return [void]
|
35
|
+
def no_entries(start_date, end_date)
|
36
|
+
if start_date == end_date
|
37
|
+
date_string = start_date.strftime('%a, %B %-d, %Y')
|
38
|
+
puts "No entries found for #{Rainbow(date_string).gold}."
|
39
|
+
else
|
40
|
+
start_date_string = start_date.strftime('%a, %B %-d, %Y')
|
41
|
+
end_date_string = end_date.strftime('%a, %B %-d, %Y')
|
42
|
+
puts "No entries found between #{Rainbow(start_date_string).gold} and #{Rainbow(end_date_string).gold}."
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Prints a single entry, formats the date and time.
|
47
|
+
# @param daily_log [DailyLog]
|
48
|
+
# @param entry [LogEntry]
|
49
|
+
# @param date_inline [Boolean] If true, the date is printed inline with the time.
|
50
|
+
def print_entry(daily_log, entry, date_inline = false)
|
51
|
+
entry.time = DateTime.strptime(entry.time, '%H:%M:%S') unless entry.time.respond_to?(:strftime)
|
52
|
+
|
53
|
+
time_string = if date_inline
|
54
|
+
"#{daily_log.date.strftime('%a, %Y-%m-%d')} #{entry.time.strftime('%H:%M')}"
|
55
|
+
else
|
56
|
+
entry.time.strftime('%H:%M')
|
57
|
+
end
|
58
|
+
|
59
|
+
puts "#{Rainbow(time_string).gold} #{entry.message_string(@people)}"
|
60
|
+
end
|
61
|
+
end
|
data/lib/statistics.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
require 'storage'
|
5
|
+
|
6
|
+
STATS = Data.define(:total_days, :total_entries, :total_epics, :avg_entries, :first_entry, :last_entry)
|
7
|
+
|
8
|
+
# Module for calculating statistics for the work log.
|
9
|
+
class Statistics
|
10
|
+
# Initialize the Statistics class.
|
11
|
+
def initialize(config)
|
12
|
+
@config = config
|
13
|
+
@storage = Storage.new(config)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Calculate statistics for the work log for all days.
|
17
|
+
# @return [STATS] The statistics for the work log
|
18
|
+
def calculate
|
19
|
+
all_entries = @storage.all_days
|
20
|
+
return STATS.new(0, 0, 0, 0, Date.today, Date.today) if all_entries.empty?
|
21
|
+
|
22
|
+
total_days = all_entries.length
|
23
|
+
total_entries = all_entries.sum { |entry| entry.entries.length }
|
24
|
+
total_epics = all_entries.sum { |entry| entry.entries.select { |item| item.epic? }.length }
|
25
|
+
avg_entries = total_entries.to_f / total_days
|
26
|
+
min_day = all_entries.min_by { |entry| entry.date }.date
|
27
|
+
max_day = all_entries.max_by { |entry| entry.date }.date
|
28
|
+
|
29
|
+
STATS.new(
|
30
|
+
total_days,
|
31
|
+
total_entries,
|
32
|
+
total_epics,
|
33
|
+
avg_entries,
|
34
|
+
min_day,
|
35
|
+
max_day
|
36
|
+
)
|
37
|
+
end
|
38
|
+
end
|