fewald-worklog 0 → 0.1.1
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 +4 -4
- data/bin/wl +2 -4
- data/worklog/cli.rb +267 -0
- data/{lib → worklog}/daily_log.rb +1 -2
- data/{lib → worklog}/log_entry.rb +6 -22
- data/{lib → worklog}/person.rb +1 -8
- data/{lib → worklog}/printer.rb +8 -15
- data/{lib → worklog}/statistics.rb +4 -10
- data/worklog/storage.rb +119 -0
- data/{lib → worklog}/string_helper.rb +1 -8
- data/{lib → worklog}/summary.rb +4 -10
- data/{lib → worklog}/templates/index.html.erb +8 -6
- data/worklog/version.rb +11 -0
- data/{lib → worklog}/webserver.rb +7 -20
- data/worklog/worklog.rb +14 -0
- metadata +22 -122
- data/.version +0 -1
- data/lib/cli.rb +0 -149
- data/lib/configuration.rb +0 -33
- data/lib/storage.rb +0 -172
- data/lib/version.rb +0 -31
- data/lib/worklog.rb +0 -216
- /data/{lib → worklog}/date_parser.rb +0 -0
- /data/{lib → worklog}/editor.rb +0 -0
- /data/{lib → worklog}/hash.rb +0 -0
- /data/{lib/worklogger.rb → worklog/logger.rb} +0 -0
- /data/{lib → worklog}/templates/favicon.svg.erb +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 95211b380e35341b87a230655872177aa6bcb4263fcdbc0b0a590bdc357346d3
|
4
|
+
data.tar.gz: d64857404d738154ee5b5b7bd717eea2d862bad294cede2946b1debf08f6940d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 27f40f229dc637b3985793a8b2bcb5858ac3883cc739a5bc56c77919e65d3a6580b7deac2e1f5825e9165da0035754f3b15d68a22e4971bf460b0c51be4048bc
|
7
|
+
data.tar.gz: 4292212bb151fe886c8996b8538144b7a76427e5518783d62bc40616a3c969fe398edbfbaa2fcfbc042c76785f11fba1ad186670a378bcccbe2f45264e3e7e23
|
data/bin/wl
CHANGED
@@ -6,11 +6,9 @@
|
|
6
6
|
if ENV['WL_PATH']
|
7
7
|
# Import the worklog CLI from the path specified in the WL_PATH environment variable
|
8
8
|
# This is used during development to avoid having to rely on the order of the $PATH.
|
9
|
-
|
10
|
-
puts 'To use the installed worklog, unset the WL_PATH environment variable.'
|
11
|
-
require_relative File.join(ENV['WL_PATH'], 'lib', 'cli')
|
9
|
+
require_relative File.join(ENV['WL_PATH'], 'worklog', 'cli')
|
12
10
|
else
|
13
|
-
require_relative '../
|
11
|
+
require_relative '../worklog/cli'
|
14
12
|
end
|
15
13
|
|
16
14
|
WorklogCLI.start
|
data/worklog/cli.rb
ADDED
@@ -0,0 +1,267 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Add the current directory to the load path
|
5
|
+
curr_dir = File.expand_path(__dir__)
|
6
|
+
$LOAD_PATH << curr_dir unless $LOAD_PATH.include?(curr_dir)
|
7
|
+
|
8
|
+
require 'thor'
|
9
|
+
require 'date'
|
10
|
+
require 'logger'
|
11
|
+
|
12
|
+
require 'date_parser'
|
13
|
+
require 'printer'
|
14
|
+
require 'statistics'
|
15
|
+
require 'storage'
|
16
|
+
require 'webserver'
|
17
|
+
require 'worklog'
|
18
|
+
require_relative 'summary'
|
19
|
+
require_relative 'editor'
|
20
|
+
require_relative 'string_helper'
|
21
|
+
|
22
|
+
# CLI for the work log application
|
23
|
+
class WorklogCLI < Thor
|
24
|
+
include StringHelper
|
25
|
+
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Enable verbose output'
|
26
|
+
|
27
|
+
package_name 'Worklog'
|
28
|
+
|
29
|
+
def self.exit_on_failure?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
desc 'add MESSAGE', 'Add a new entry to the work log, defaults to the current date.'
|
34
|
+
long_desc <<~LONGDESC
|
35
|
+
Add a new entry with the current date and time to the work log.
|
36
|
+
The message is required and must be enclosed in quotes if it contains more than one word.
|
37
|
+
|
38
|
+
People can be referenced either by using the tilde "~" or the at symbol "@", followed by
|
39
|
+
an alphanumeric string.
|
40
|
+
LONGDESC
|
41
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'), desc: 'Set the date of the entry'
|
42
|
+
option :time, type: :string, default: DateTime.now.strftime('%H:%M:%S'), desc: 'Set the time of the entry'
|
43
|
+
option :tags, type: :array, default: [], desc: 'Add tags to the entry'
|
44
|
+
option :ticket, type: :string, desc: 'Ticket number associated with the entry. Can be any alphanumeric string.'
|
45
|
+
option :url, type: :string, desc: 'URL to associate with the entry'
|
46
|
+
option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
|
47
|
+
def add(message)
|
48
|
+
set_log_level
|
49
|
+
|
50
|
+
# Remove leading and trailing whitespaces
|
51
|
+
# Raise an error if the message is empty
|
52
|
+
message = message.strip
|
53
|
+
raise ArgumentError, 'Message cannot be empty' if message.empty?
|
54
|
+
|
55
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
56
|
+
time = Time.strptime(options[:time], '%H:%M:%S')
|
57
|
+
Storage.create_file_skeleton(date)
|
58
|
+
|
59
|
+
daily_log = Storage.load_log(Storage.filepath(date))
|
60
|
+
daily_log.entries << LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
|
61
|
+
epic: options[:epic], message:)
|
62
|
+
|
63
|
+
# Sort by time in case an entry was added later out of order.
|
64
|
+
daily_log.entries.sort_by!(&:time)
|
65
|
+
|
66
|
+
Storage.write_log(Storage.filepath(options[:date]), daily_log)
|
67
|
+
WorkLogger.info Rainbow("Added to the work log for #{options[:date]}").green
|
68
|
+
end
|
69
|
+
|
70
|
+
desc 'edit', 'Edit a specified day in the work log'
|
71
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
72
|
+
def edit
|
73
|
+
set_log_level
|
74
|
+
|
75
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
76
|
+
|
77
|
+
# Load existing log
|
78
|
+
log = Storage.load_log(Storage.filepath(date))
|
79
|
+
unless log
|
80
|
+
WorkLogger.error "No work log found for #{options[:date]}. Aborting."
|
81
|
+
exit 1
|
82
|
+
end
|
83
|
+
|
84
|
+
txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
|
85
|
+
return_val = Editor.open_editor(txt)
|
86
|
+
|
87
|
+
Storage.write_log(Storage.filepath(date),
|
88
|
+
YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
|
89
|
+
WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
|
90
|
+
end
|
91
|
+
|
92
|
+
desc 'remove', 'Remove last entry from the log'
|
93
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
94
|
+
def remove
|
95
|
+
set_log_level
|
96
|
+
|
97
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
98
|
+
unless File.exist?(Storage.filepath(date))
|
99
|
+
WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
|
100
|
+
exit 1
|
101
|
+
end
|
102
|
+
|
103
|
+
daily_log = Storage.load_log(Storage.filepath(options[:date]))
|
104
|
+
if daily_log.entries.empty?
|
105
|
+
WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
|
106
|
+
exit 1
|
107
|
+
end
|
108
|
+
|
109
|
+
removed_entry = daily_log.entries.pop
|
110
|
+
Storage.write_log(Storage.filepath(date), daily_log)
|
111
|
+
WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
|
112
|
+
end
|
113
|
+
|
114
|
+
desc 'show', 'Show the work log for a specific date or a range of dates. Defaults to todays date.'
|
115
|
+
long_desc <<~LONGDESC
|
116
|
+
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.
|
117
|
+
LONGDESC
|
118
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'),
|
119
|
+
desc: <<~DESC
|
120
|
+
Show the work log for a specific date. If this option is provided, --from and --to and --days should not be used.
|
121
|
+
DESC
|
122
|
+
option :from, type: :string, desc: <<~EOF
|
123
|
+
Inclusive start date of the range. Takes precedence over --date, if defined.
|
124
|
+
EOF
|
125
|
+
option :to, type: :string, desc: <<~EOF
|
126
|
+
Inclusive end date of the range. Takes precedence over --date, if defined.
|
127
|
+
EOF
|
128
|
+
option :days, type: :numeric, desc: <<~EOF
|
129
|
+
Number of days to show starting from --date. Takes precedence over --from and --to if defined.
|
130
|
+
EOF
|
131
|
+
option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
|
132
|
+
option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
|
133
|
+
def show
|
134
|
+
set_log_level
|
135
|
+
|
136
|
+
start_date, end_date = start_end_date(options)
|
137
|
+
|
138
|
+
entries = Storage.days_between(start_date, end_date)
|
139
|
+
if entries.empty?
|
140
|
+
Printer.no_entries(start_date, end_date)
|
141
|
+
else
|
142
|
+
entries.each do |entry|
|
143
|
+
Printer.print_day(entry, entries.size > 1, options[:epics_only])
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
desc 'people', 'Show all people mentioned in the work log'
|
149
|
+
def people
|
150
|
+
set_log_level
|
151
|
+
|
152
|
+
puts 'People mentioned in the work log:'
|
153
|
+
|
154
|
+
mentions = {}
|
155
|
+
all_logs = Storage.all_days
|
156
|
+
all_logs.map(&:people).each do |people|
|
157
|
+
mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
|
158
|
+
end
|
159
|
+
mentions.each { |k, v| puts "#{Rainbow(k).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
160
|
+
end
|
161
|
+
|
162
|
+
desc 'tags', 'Show all tags used in the work log'
|
163
|
+
def tags
|
164
|
+
set_log_level
|
165
|
+
|
166
|
+
all_logs = Storage.all_days
|
167
|
+
|
168
|
+
puts Rainbow('Tags used in the work log:').gold
|
169
|
+
|
170
|
+
# Count all tags used in the work log
|
171
|
+
tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
|
172
|
+
|
173
|
+
# Determine length of longest tag for formatting
|
174
|
+
max_len = tags.keys.map(&:length).max
|
175
|
+
|
176
|
+
tags.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
177
|
+
end
|
178
|
+
|
179
|
+
desc 'server', 'Start the work log server'
|
180
|
+
def server
|
181
|
+
set_log_level
|
182
|
+
|
183
|
+
WorkLogServer.new.start
|
184
|
+
end
|
185
|
+
|
186
|
+
desc 'stats', 'Show statistics for the work log'
|
187
|
+
def stats
|
188
|
+
stats = Statistics.calculate
|
189
|
+
puts "#{format_left('Total days')}: #{stats.total_days}"
|
190
|
+
puts "#{format_left('Total entries')}: #{stats.total_entries}"
|
191
|
+
puts "#{format_left('Total epics')}: #{stats.total_epics}"
|
192
|
+
puts "#{format_left('Entries per day')}: #{'%.2f' % stats.avg_entries}"
|
193
|
+
puts "#{format_left('First entry')}: #{stats.first_entry}"
|
194
|
+
puts "#{format_left('Last entry')}: #{stats.last_entry}"
|
195
|
+
end
|
196
|
+
|
197
|
+
desc 'summary', 'Generate a summary of the work log entries'
|
198
|
+
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
199
|
+
option :from, type: :string, desc: <<-EOF
|
200
|
+
'Inclusive start date of the range. Takes precedence over --date if defined.'
|
201
|
+
EOF
|
202
|
+
option :to, type: :string, desc: <<-EOF
|
203
|
+
'Inclusive end date of the range. Takes precedence over --date if defined.'
|
204
|
+
EOF
|
205
|
+
option :days, type: :numeric, desc: <<-EOF
|
206
|
+
'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
|
207
|
+
EOF
|
208
|
+
def summary
|
209
|
+
set_log_level
|
210
|
+
|
211
|
+
start_date, end_date = start_end_date(options)
|
212
|
+
entries = Storage.days_between(start_date, end_date).map(&:entries).flatten
|
213
|
+
|
214
|
+
# Do nothing if no entries are found.
|
215
|
+
if entries.empty?
|
216
|
+
Printer.no_entries(start_date, end_date)
|
217
|
+
return
|
218
|
+
end
|
219
|
+
puts Summary.generate_summary(entries)
|
220
|
+
end
|
221
|
+
|
222
|
+
# Define shortcuts and aliases
|
223
|
+
map 'a' => :add
|
224
|
+
map 'statistics' => :stats
|
225
|
+
map 'serve' => :server
|
226
|
+
|
227
|
+
no_commands do
|
228
|
+
def set_log_level
|
229
|
+
# Set the log level based on the verbose option
|
230
|
+
WorkLogger.level = options[:verbose] ? Logger::Severity::DEBUG : Logger::Severity::INFO
|
231
|
+
end
|
232
|
+
|
233
|
+
def format_left(string)
|
234
|
+
# Format a string to be left-aligned in a fixed-width field
|
235
|
+
#
|
236
|
+
# @param string [String] the string to format
|
237
|
+
# @return [String] the formatted string
|
238
|
+
format('%18s', string)
|
239
|
+
end
|
240
|
+
|
241
|
+
# Parse the start and end date based on the options provided
|
242
|
+
#
|
243
|
+
# @param options [Hash] the options hash
|
244
|
+
# @return [Array] the start and end date as an array
|
245
|
+
def start_end_date(options)
|
246
|
+
if options[:days]
|
247
|
+
# Safeguard against negative days
|
248
|
+
raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
|
249
|
+
|
250
|
+
start_date = Date.today - options[:days]
|
251
|
+
end_date = Date.today
|
252
|
+
elsif options[:from]
|
253
|
+
start_date = DateParser.parse_date_string!(options[:from], true)
|
254
|
+
end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
|
255
|
+
else
|
256
|
+
start_date = Date.strptime(options[:date], '%Y-%m-%d')
|
257
|
+
end_date = start_date
|
258
|
+
end
|
259
|
+
[start_date, end_date]
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# Start the CLI if the file is executed
|
265
|
+
# This prevents the CLI from starting when the file is required in another file,
|
266
|
+
# which is useful for testing.
|
267
|
+
WorklogCLI.start if __FILE__ == $PROGRAM_NAME
|
@@ -2,13 +2,11 @@
|
|
2
2
|
|
3
3
|
require 'yaml'
|
4
4
|
require 'rainbow'
|
5
|
-
|
6
|
-
|
5
|
+
require_relative 'daily_log'
|
6
|
+
require_relative 'hash'
|
7
7
|
|
8
8
|
# A single log entry.
|
9
9
|
class LogEntry
|
10
|
-
PERSON_REGEX = /\s[~@](\w+)/
|
11
|
-
|
12
10
|
include Hashify
|
13
11
|
|
14
12
|
# Represents a single entry in the work log.
|
@@ -31,27 +29,13 @@ class LogEntry
|
|
31
29
|
end
|
32
30
|
|
33
31
|
# Returns the message string with formatting without the time.
|
34
|
-
|
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
|
-
|
32
|
+
def message_string
|
49
33
|
s = ''
|
50
34
|
|
51
35
|
s += if epic
|
52
|
-
Rainbow("[EPIC] #{
|
36
|
+
Rainbow("[EPIC] #{@message}").bg(:white).fg(:black)
|
53
37
|
else
|
54
|
-
|
38
|
+
message
|
55
39
|
end
|
56
40
|
|
57
41
|
s += " [#{Rainbow(@ticket).fg(:blue)}]" if @ticket
|
@@ -73,7 +57,7 @@ class LogEntry
|
|
73
57
|
# Empty array if no people are mentioned.
|
74
58
|
#
|
75
59
|
# @return [Array<String>]
|
76
|
-
@message.scan(
|
60
|
+
@message.scan(/\s[~@](\w+)/).flatten.uniq.sort
|
77
61
|
end
|
78
62
|
|
79
63
|
def people?
|
data/{lib → worklog}/person.rb
RENAMED
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
# typed: true
|
2
3
|
|
3
4
|
# Represents a person at work.
|
4
5
|
class Person
|
@@ -13,14 +14,6 @@ class Person
|
|
13
14
|
end
|
14
15
|
|
15
16
|
def to_s
|
16
|
-
return "#{name} (~#{handle})" if @email.nil?
|
17
|
-
|
18
17
|
"#{name} (~#{handle}) <#{email}>"
|
19
18
|
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
19
|
end
|
data/{lib → worklog}/printer.rb
RENAMED
@@ -3,19 +3,11 @@
|
|
3
3
|
require 'rainbow'
|
4
4
|
|
5
5
|
# Printer for work log entries
|
6
|
-
|
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
|
-
|
6
|
+
module Printer
|
15
7
|
# Prints a whole day of work log entries.
|
16
8
|
# If date_inline is true, the date is printed inline with the time.
|
17
9
|
# If epics_only is true, only epic entries are printed.
|
18
|
-
def print_day(daily_log, date_inline = false, epics_only = false)
|
10
|
+
def self.print_day(daily_log, date_inline = false, epics_only = false)
|
19
11
|
daily_log.date = Date.strptime(daily_log.date, '%Y-%m-%d') unless daily_log.date.respond_to?(:strftime)
|
20
12
|
|
21
13
|
date_string = daily_log.date.strftime('%a, %B %-d, %Y')
|
@@ -32,7 +24,7 @@ class Printer
|
|
32
24
|
# @param start_date [Date]
|
33
25
|
# @param end_date [Date]
|
34
26
|
# @return [void]
|
35
|
-
def no_entries(start_date, end_date)
|
27
|
+
def self.no_entries(start_date, end_date)
|
36
28
|
if start_date == end_date
|
37
29
|
date_string = start_date.strftime('%a, %B %-d, %Y')
|
38
30
|
puts "No entries found for #{Rainbow(date_string).gold}."
|
@@ -43,10 +35,9 @@ class Printer
|
|
43
35
|
end
|
44
36
|
end
|
45
37
|
|
38
|
+
private
|
39
|
+
|
46
40
|
# 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
41
|
def print_entry(daily_log, entry, date_inline = false)
|
51
42
|
entry.time = DateTime.strptime(entry.time, '%H:%M:%S') unless entry.time.respond_to?(:strftime)
|
52
43
|
|
@@ -56,6 +47,8 @@ class Printer
|
|
56
47
|
entry.time.strftime('%H:%M')
|
57
48
|
end
|
58
49
|
|
59
|
-
puts "#{Rainbow(time_string).gold} #{entry.message_string
|
50
|
+
puts "#{Rainbow(time_string).gold} #{entry.message_string}"
|
60
51
|
end
|
52
|
+
|
53
|
+
module_function :print_entry
|
61
54
|
end
|
@@ -6,17 +6,11 @@ require 'storage'
|
|
6
6
|
STATS = Data.define(:total_days, :total_entries, :total_epics, :avg_entries, :first_entry, :last_entry)
|
7
7
|
|
8
8
|
# Module for calculating statistics for the work log.
|
9
|
-
|
10
|
-
# Initialize the Statistics class.
|
11
|
-
def initialize(config)
|
12
|
-
@config = config
|
13
|
-
@storage = Storage.new(config)
|
14
|
-
end
|
15
|
-
|
9
|
+
module Statistics
|
16
10
|
# Calculate statistics for the work log for all days.
|
17
|
-
# @return [STATS]
|
18
|
-
def calculate
|
19
|
-
all_entries =
|
11
|
+
# @return [STATS]
|
12
|
+
def self.calculate
|
13
|
+
all_entries = Storage.all_days
|
20
14
|
return STATS.new(0, 0, 0, 0, Date.today, Date.today) if all_entries.empty?
|
21
15
|
|
22
16
|
total_days = all_entries.length
|
data/worklog/storage.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rainbow'
|
4
|
+
require_relative 'daily_log'
|
5
|
+
require_relative 'logger'
|
6
|
+
|
7
|
+
module Storage
|
8
|
+
# LogNotFoundError is raised when a log file is not found
|
9
|
+
class LogNotFoundError < StandardError; end
|
10
|
+
|
11
|
+
FILE_SUFFIX = '.yaml'
|
12
|
+
DATA_DIR = File.join(Dir.home, '.worklog')
|
13
|
+
|
14
|
+
def self.folder_exists?
|
15
|
+
Dir.exist?(DATA_DIR)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Return all days with logs
|
19
|
+
def self.all_days
|
20
|
+
return [] unless folder_exists?
|
21
|
+
|
22
|
+
logs = []
|
23
|
+
Dir.glob(File.join(DATA_DIR, "*#{FILE_SUFFIX}")).map do |file|
|
24
|
+
logs << load_log(file)
|
25
|
+
end
|
26
|
+
|
27
|
+
logs
|
28
|
+
end
|
29
|
+
|
30
|
+
# Return days between start_date and end_date
|
31
|
+
# If end_date is nil, return logs from start_date to today
|
32
|
+
#
|
33
|
+
# @param [Date] start_date The start date, inclusive
|
34
|
+
# @param [Date] end_date The end date, inclusive
|
35
|
+
# @param [Boolean] epics_only If true, only return logs with epic entries
|
36
|
+
# @param [Array] tags_filter If provided, only return logs with entries that have at least one of the tags
|
37
|
+
def self.days_between(start_date, end_date = nil, epics_only = nil, tags_filter = nil)
|
38
|
+
return [] unless folder_exists?
|
39
|
+
|
40
|
+
logs = []
|
41
|
+
end_date = Date.today if end_date.nil?
|
42
|
+
|
43
|
+
return [] if start_date > end_date
|
44
|
+
|
45
|
+
while start_date <= end_date
|
46
|
+
if File.exist?(filepath(start_date))
|
47
|
+
tmp_logs = load_log(filepath(start_date))
|
48
|
+
tmp_logs.entries.keep_if { |entry| entry.epic? } if epics_only
|
49
|
+
|
50
|
+
if tags_filter
|
51
|
+
# Safeguard against entries without any tags, not just empty array
|
52
|
+
tmp_logs.entries.keep_if { |entry| entry.tags && (entry.tags & tags_filter).size > 0 }
|
53
|
+
end
|
54
|
+
|
55
|
+
logs << tmp_logs if tmp_logs.entries.length > 0
|
56
|
+
end
|
57
|
+
|
58
|
+
start_date += 1
|
59
|
+
end
|
60
|
+
logs
|
61
|
+
end
|
62
|
+
|
63
|
+
# Create file for a new day if it does not exist
|
64
|
+
def self.create_file_skeleton(date)
|
65
|
+
create_folder
|
66
|
+
|
67
|
+
File.write(filepath(date), YAML.dump(DailyLog.new(date:, entries: []))) unless File.exist?(filepath(date))
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.load_log(file)
|
71
|
+
load_log!(file)
|
72
|
+
rescue LogNotFoundError
|
73
|
+
WorkLogger.error "No work log found for #{file}. Aborting."
|
74
|
+
nil
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.load_log!(file)
|
78
|
+
WorkLogger.debug "Loading file #{file}"
|
79
|
+
begin
|
80
|
+
log = YAML.load_file(file, permitted_classes: [Date, Time, DailyLog, LogEntry])
|
81
|
+
log.entries.each do |entry|
|
82
|
+
entry.time = Time.parse(entry.time) unless entry.time.respond_to?(:strftime)
|
83
|
+
end
|
84
|
+
log
|
85
|
+
rescue Errno::ENOENT
|
86
|
+
raise LogNotFoundError
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.write_log(file, daily_log)
|
91
|
+
create_folder
|
92
|
+
|
93
|
+
WorkLogger.debug "Writing to file #{file}"
|
94
|
+
|
95
|
+
File.open(file, 'w') do |f|
|
96
|
+
f.puts daily_log.to_yaml
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.load_single_log_file(file, headline = true)
|
101
|
+
daily_log = load_log(file)
|
102
|
+
puts "Work log for #{Rainbow(daily_log.date).gold}:" if headline
|
103
|
+
daily_log.entries
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
# Create folder if not exists already.
|
109
|
+
def create_folder
|
110
|
+
Dir.mkdir(DATA_DIR) unless Dir.exist?(DATA_DIR)
|
111
|
+
end
|
112
|
+
|
113
|
+
def filepath(date)
|
114
|
+
# Construct filepath for a given date.
|
115
|
+
File.join(DATA_DIR, "#{date}#{FILE_SUFFIX}")
|
116
|
+
end
|
117
|
+
|
118
|
+
module_function :create_folder, :filepath
|
119
|
+
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
# typed: true
|
2
3
|
|
3
4
|
# Helpers for String manipulation
|
4
5
|
module StringHelper
|
@@ -24,12 +25,4 @@ module StringHelper
|
|
24
25
|
"#{singular}s"
|
25
26
|
end
|
26
27
|
end
|
27
|
-
|
28
|
-
# Format a string to be left-aligned in a fixed-width field
|
29
|
-
#
|
30
|
-
# @param string [String] the string to format
|
31
|
-
# @return [String] the formatted string
|
32
|
-
def format_left(string)
|
33
|
-
format('%18s', string)
|
34
|
-
end
|
35
28
|
end
|
data/{lib → worklog}/summary.rb
RENAMED
@@ -4,10 +4,10 @@ require 'erb'
|
|
4
4
|
require 'httparty'
|
5
5
|
require 'json'
|
6
6
|
|
7
|
-
|
7
|
+
require_relative 'logger'
|
8
8
|
|
9
9
|
# AI Summary generation.
|
10
|
-
|
10
|
+
module Summary
|
11
11
|
MODEL = 'llama3.2'
|
12
12
|
SUMMARY_INSTRUCTION = <<~INSTRUCTION
|
13
13
|
<% entries.each do |entry| -%>
|
@@ -41,19 +41,13 @@ class Summary
|
|
41
41
|
Example Output: "[Name] demonstrated outstanding performance during the review period. Key accomplishments include exceeding sales targets by 15% in Q3, implementing a new CRM system that improved customer response times by 30%, and mentoring two junior team members who achieved career advancements. These achievements highlight [Name]'s exceptional contributions to team success and organizational growth."
|
42
42
|
INSTRUCTION
|
43
43
|
|
44
|
-
def initialize(config)
|
45
|
-
@config = config
|
46
|
-
end
|
47
|
-
|
48
44
|
# Build the prompt from provided log entries.
|
49
|
-
def build_prompt(log_entries)
|
45
|
+
def self.build_prompt(log_entries)
|
50
46
|
ERB.new(SUMMARY_INSTRUCTION, trim_mode: '-').result_with_hash(entries: log_entries)
|
51
47
|
end
|
52
48
|
|
53
49
|
# Generate a summary from provided log entries.
|
54
|
-
|
55
|
-
# @return [String] The generated summary.
|
56
|
-
def generate_summary(log_entries)
|
50
|
+
def self.generate_summary(log_entries)
|
57
51
|
prompt = build_prompt(log_entries)
|
58
52
|
|
59
53
|
WorkLogger.debug("Using prompt: #{prompt}")
|
@@ -64,6 +64,7 @@
|
|
64
64
|
|
65
65
|
|
66
66
|
</nav>
|
67
|
+
|
67
68
|
<hr class="border border-primary border-2 opacity-75">
|
68
69
|
<div class="pb-4">
|
69
70
|
Show
|
@@ -100,21 +101,22 @@
|
|
100
101
|
<div class="dropdown d-inline">
|
101
102
|
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
102
103
|
<% if tags %>
|
103
|
-
<%= tags.
|
104
|
+
<%= tags.first %> tag
|
104
105
|
<% else %>
|
105
106
|
all tags
|
106
107
|
<% end %>
|
107
108
|
</a>
|
108
109
|
<ul class="dropdown-menu">
|
109
110
|
<li><a class="dropdown-item" href="<%= update_query({'tags' => nil}) %>">all tags</a></li>
|
110
|
-
|
111
|
-
|
112
|
-
<% end %>
|
111
|
+
<li><a class="dropdown-item" href="<%= update_query({'tags' => ['oncall']}) %>">oncall tags</a></li>
|
112
|
+
<li><a class="dropdown-item" href="<%= update_query({'tags' => ['1:1']}) %>">1:1 tags</a></li>
|
113
113
|
</ul>
|
114
114
|
</div>
|
115
115
|
.
|
116
116
|
</div>
|
117
117
|
|
118
|
+
|
119
|
+
|
118
120
|
<%- logs.each do |log| -%>
|
119
121
|
<section class="day">
|
120
122
|
<strong><%= log.date.strftime('%a, %B %-d, %Y') %></strong>
|
@@ -138,10 +140,10 @@
|
|
138
140
|
<p class="entries"><%= log.entries.size %> entries</p>
|
139
141
|
</section>
|
140
142
|
<%- end %>
|
141
|
-
<p><%= total_entries
|
143
|
+
<p><%= total_entries%> entries total</p>
|
142
144
|
</div>
|
143
145
|
<hr/>
|
144
|
-
<footer class="container pb-4
|
146
|
+
<footer class="container pb-4">
|
145
147
|
Generated at <%= Time.now.strftime('%Y-%m-%d %H:%M %Z') %>
|
146
148
|
</footer>
|
147
149
|
</body>
|