fewald-worklog 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 +7 -0
- data/bin/wl +14 -0
- data/worklog/cli.rb +267 -0
- data/worklog/daily_log.rb +34 -0
- data/worklog/date_parser.rb +69 -0
- data/worklog/editor.rb +38 -0
- data/worklog/hash.rb +25 -0
- data/worklog/log_entry.rb +78 -0
- data/worklog/logger.rb +26 -0
- data/worklog/person.rb +19 -0
- data/worklog/printer.rb +54 -0
- data/worklog/statistics.rb +32 -0
- data/worklog/storage.rb +119 -0
- data/worklog/string_helper.rb +28 -0
- data/worklog/summary.rb +72 -0
- data/worklog/templates/favicon.svg.erb +7 -0
- data/worklog/templates/index.html.erb +150 -0
- data/worklog/version.rb +11 -0
- data/worklog/webserver.rb +102 -0
- data/worklog/worklog.rb +14 -0
- metadata +71 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 95211b380e35341b87a230655872177aa6bcb4263fcdbc0b0a590bdc357346d3
|
4
|
+
data.tar.gz: d64857404d738154ee5b5b7bd717eea2d862bad294cede2946b1debf08f6940d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 27f40f229dc637b3985793a8b2bcb5858ac3883cc739a5bc56c77919e65d3a6580b7deac2e1f5825e9165da0035754f3b15d68a22e4971bf460b0c51be4048bc
|
7
|
+
data.tar.gz: 4292212bb151fe886c8996b8538144b7a76427e5518783d62bc40616a3c969fe398edbfbaa2fcfbc042c76785f11fba1ad186670a378bcccbe2f45264e3e7e23
|
data/bin/wl
ADDED
@@ -0,0 +1,14 @@
|
|
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
|
+
require_relative File.join(ENV['WL_PATH'], 'worklog', 'cli')
|
10
|
+
else
|
11
|
+
require_relative '../worklog/cli'
|
12
|
+
end
|
13
|
+
|
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
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'hash'
|
4
|
+
|
5
|
+
class DailyLog
|
6
|
+
# Container for a day's work log.
|
7
|
+
include Hashify
|
8
|
+
|
9
|
+
# Represents a single day's work log.
|
10
|
+
attr_accessor :date, :entries
|
11
|
+
|
12
|
+
def initialize(params = {})
|
13
|
+
@date = params[:date]
|
14
|
+
@entries = params[:entries]
|
15
|
+
end
|
16
|
+
|
17
|
+
def people?
|
18
|
+
# Returns true if there are people mentioned in any entry of the current day.
|
19
|
+
people.size.positive?
|
20
|
+
end
|
21
|
+
|
22
|
+
def people
|
23
|
+
# Returns a hash of people mentioned in the log for the current day
|
24
|
+
# with the number of times they are mentioned.
|
25
|
+
# People are defined as words starting with @ or ~.
|
26
|
+
#
|
27
|
+
# @return [Hash<String, Integer>]
|
28
|
+
entries.map(&:people).flatten.tally
|
29
|
+
end
|
30
|
+
|
31
|
+
def ==(other)
|
32
|
+
date == other.date && entries == other.entries
|
33
|
+
end
|
34
|
+
end
|
@@ -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/worklog/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/worklog/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
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
require 'rainbow'
|
5
|
+
require_relative 'daily_log'
|
6
|
+
require_relative 'hash'
|
7
|
+
|
8
|
+
# A single log entry.
|
9
|
+
class LogEntry
|
10
|
+
include Hashify
|
11
|
+
|
12
|
+
# Represents a single entry in the work log.
|
13
|
+
attr_accessor :time, :tags, :ticket, :url, :epic, :message
|
14
|
+
|
15
|
+
def initialize(params = {})
|
16
|
+
@time = params[:time]
|
17
|
+
# If tags are nil, set to empty array.
|
18
|
+
# This is similar to the CLI default value.
|
19
|
+
@tags = params[:tags] || []
|
20
|
+
@ticket = params[:ticket]
|
21
|
+
@url = params[:url] || ''
|
22
|
+
@epic = params[:epic]
|
23
|
+
@message = params[:message]
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns true if the entry is an epic, false otherwise.
|
27
|
+
def epic?
|
28
|
+
@epic == true
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns the message string with formatting without the time.
|
32
|
+
def message_string
|
33
|
+
s = ''
|
34
|
+
|
35
|
+
s += if epic
|
36
|
+
Rainbow("[EPIC] #{@message}").bg(:white).fg(:black)
|
37
|
+
else
|
38
|
+
message
|
39
|
+
end
|
40
|
+
|
41
|
+
s += " [#{Rainbow(@ticket).fg(:blue)}]" if @ticket
|
42
|
+
|
43
|
+
# Add tags in brackets if defined.
|
44
|
+
s += ' [' + @tags.map { |tag| "#{tag}" }.join(', ') + ']' if @tags && @tags.size > 0
|
45
|
+
|
46
|
+
# Add URL in brackets if defined.
|
47
|
+
s += " [#{@url}]" if @url && @url != ''
|
48
|
+
|
49
|
+
s
|
50
|
+
end
|
51
|
+
|
52
|
+
def people
|
53
|
+
# Return people that are mentioned in the entry.
|
54
|
+
# People are defined as words starting with @ or ~.
|
55
|
+
# Whitespaces are used to separate people.
|
56
|
+
# Punctuation is not considered.
|
57
|
+
# Empty array if no people are mentioned.
|
58
|
+
#
|
59
|
+
# @return [Array<String>]
|
60
|
+
@message.scan(/\s[~@](\w+)/).flatten.uniq.sort
|
61
|
+
end
|
62
|
+
|
63
|
+
def people?
|
64
|
+
# Return true if there are people in the entry.
|
65
|
+
#
|
66
|
+
# @return [Boolean]
|
67
|
+
people.size.positive?
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_yaml
|
71
|
+
to_hash.to_yaml
|
72
|
+
end
|
73
|
+
|
74
|
+
def ==(other)
|
75
|
+
time == other.time && tags == other.tags && ticket == other.ticket && url == other.url &&
|
76
|
+
epic == other.epic && message == other.message
|
77
|
+
end
|
78
|
+
end
|
data/worklog/logger.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'singleton'
|
5
|
+
|
6
|
+
# Singleton class for logging work log messages
|
7
|
+
class WorkLogger
|
8
|
+
include Singleton
|
9
|
+
|
10
|
+
def self.instance
|
11
|
+
@instance ||= Logger.new($stdout)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.level=(level)
|
15
|
+
instance.level = level
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.level
|
19
|
+
instance.level
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.info(message) = instance.info(message)
|
23
|
+
def self.warn(message) = instance.warn(message)
|
24
|
+
def self.error(message) = instance.error(message)
|
25
|
+
def self.debug(message) = instance.debug(message)
|
26
|
+
end
|
data/worklog/person.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# typed: true
|
3
|
+
|
4
|
+
# Represents a person at work.
|
5
|
+
class Person
|
6
|
+
attr_reader :handle, :name, :email, :team, :notes
|
7
|
+
|
8
|
+
def initialize(handle, name, email, team, notes = [])
|
9
|
+
@handle = handle
|
10
|
+
@name = name
|
11
|
+
@email = email
|
12
|
+
@team = team
|
13
|
+
@notes = notes
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
"#{name} (~#{handle}) <#{email}>"
|
18
|
+
end
|
19
|
+
end
|
data/worklog/printer.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rainbow'
|
4
|
+
|
5
|
+
# Printer for work log entries
|
6
|
+
module Printer
|
7
|
+
# Prints a whole day of work log entries.
|
8
|
+
# If date_inline is true, the date is printed inline with the time.
|
9
|
+
# If epics_only is true, only epic entries are printed.
|
10
|
+
def self.print_day(daily_log, date_inline = false, epics_only = false)
|
11
|
+
daily_log.date = Date.strptime(daily_log.date, '%Y-%m-%d') unless daily_log.date.respond_to?(:strftime)
|
12
|
+
|
13
|
+
date_string = daily_log.date.strftime('%a, %B %-d, %Y')
|
14
|
+
puts "Work log for #{Rainbow(date_string).gold}" unless date_inline
|
15
|
+
|
16
|
+
daily_log.entries.each do |entry|
|
17
|
+
next if epics_only && !entry.epic?
|
18
|
+
|
19
|
+
print_entry(daily_log, entry, date_inline)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Print a message when no entries are found.
|
24
|
+
# @param start_date [Date]
|
25
|
+
# @param end_date [Date]
|
26
|
+
# @return [void]
|
27
|
+
def self.no_entries(start_date, end_date)
|
28
|
+
if start_date == end_date
|
29
|
+
date_string = start_date.strftime('%a, %B %-d, %Y')
|
30
|
+
puts "No entries found for #{Rainbow(date_string).gold}."
|
31
|
+
else
|
32
|
+
start_date_string = start_date.strftime('%a, %B %-d, %Y')
|
33
|
+
end_date_string = end_date.strftime('%a, %B %-d, %Y')
|
34
|
+
puts "No entries found between #{Rainbow(start_date_string).gold} and #{Rainbow(end_date_string).gold}."
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Prints a single entry, formats the date and time.
|
41
|
+
def print_entry(daily_log, entry, date_inline = false)
|
42
|
+
entry.time = DateTime.strptime(entry.time, '%H:%M:%S') unless entry.time.respond_to?(:strftime)
|
43
|
+
|
44
|
+
time_string = if date_inline
|
45
|
+
"#{daily_log.date.strftime('%a, %Y-%m-%d')} #{entry.time.strftime('%H:%M')}"
|
46
|
+
else
|
47
|
+
entry.time.strftime('%H:%M')
|
48
|
+
end
|
49
|
+
|
50
|
+
puts "#{Rainbow(time_string).gold} #{entry.message_string}"
|
51
|
+
end
|
52
|
+
|
53
|
+
module_function :print_entry
|
54
|
+
end
|
@@ -0,0 +1,32 @@
|
|
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
|
+
module Statistics
|
10
|
+
# Calculate statistics for the work log for all days.
|
11
|
+
# @return [STATS]
|
12
|
+
def self.calculate
|
13
|
+
all_entries = Storage.all_days
|
14
|
+
return STATS.new(0, 0, 0, 0, Date.today, Date.today) if all_entries.empty?
|
15
|
+
|
16
|
+
total_days = all_entries.length
|
17
|
+
total_entries = all_entries.sum { |entry| entry.entries.length }
|
18
|
+
total_epics = all_entries.sum { |entry| entry.entries.select { |item| item.epic? }.length }
|
19
|
+
avg_entries = total_entries.to_f / total_days
|
20
|
+
min_day = all_entries.min_by { |entry| entry.date }.date
|
21
|
+
max_day = all_entries.max_by { |entry| entry.date }.date
|
22
|
+
|
23
|
+
STATS.new(
|
24
|
+
total_days,
|
25
|
+
total_entries,
|
26
|
+
total_epics,
|
27
|
+
avg_entries,
|
28
|
+
min_day,
|
29
|
+
max_day
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
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
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# typed: true
|
3
|
+
|
4
|
+
# Helpers for String manipulation
|
5
|
+
module StringHelper
|
6
|
+
# Pluralize a word based on a count. If the plural form is irregular, it can be provided.
|
7
|
+
# Otherwise, it will be generated automatically.
|
8
|
+
#
|
9
|
+
# @param count [Integer] the count to base the pluralization on
|
10
|
+
# @param singular [String] the singular form of the word
|
11
|
+
# @param plural [String] the plural form of the word, if it is irregular. Otherwise it will be generated.
|
12
|
+
# @return [String] the pluralized word
|
13
|
+
def pluralize(count, singular, plural = nil)
|
14
|
+
if count == 1
|
15
|
+
singular
|
16
|
+
else
|
17
|
+
return plural if plural
|
18
|
+
|
19
|
+
return "#{singular[0..-2]}ies" if singular.end_with? 'y'
|
20
|
+
|
21
|
+
return "#{singular}es" if singular.end_with? 'ch', 's', 'sh', 'x', 'z'
|
22
|
+
|
23
|
+
return "#{singular[0..-2]}ves" if singular.end_with? 'f', 'fe'
|
24
|
+
|
25
|
+
"#{singular}s"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/worklog/summary.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
require 'httparty'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
require_relative 'logger'
|
8
|
+
|
9
|
+
# AI Summary generation.
|
10
|
+
module Summary
|
11
|
+
MODEL = 'llama3.2'
|
12
|
+
SUMMARY_INSTRUCTION = <<~INSTRUCTION
|
13
|
+
<% entries.each do |entry| -%>
|
14
|
+
<%= entry.message %>
|
15
|
+
<% end -%>
|
16
|
+
INSTRUCTION
|
17
|
+
|
18
|
+
SYSTEM_INSTRUCTION = <<~INSTRUCTION
|
19
|
+
You are a professional summarization assistant specialized in crafting performance review summaries. Your role is to take a list of achievements provided by the user and generate a concise, professional summary suitable for use in a formal performance review.
|
20
|
+
|
21
|
+
Guidelines:
|
22
|
+
|
23
|
+
Accuracy: Do not invent or infer any facts not explicitly provided by the user. Use only the information given.
|
24
|
+
Tone: Maintain a formal, professional tone throughout the summary.
|
25
|
+
Structure: Organize the summary in a coherent manner, emphasizing key accomplishments and their impact.
|
26
|
+
Clarity: Use clear and concise language, avoiding jargon unless specified by the user.
|
27
|
+
Your Task:
|
28
|
+
|
29
|
+
Analyze the list of achievements provided by the user.
|
30
|
+
Identify the key themes and accomplishments.
|
31
|
+
Draft a polished summary that highlights the individual’s contributions and results.
|
32
|
+
Constraints:
|
33
|
+
|
34
|
+
Do not fabricate details or add context that has not been explicitly stated.
|
35
|
+
Always prioritize clarity and professionalism in your writing.
|
36
|
+
Example Input:
|
37
|
+
|
38
|
+
"Exceeded sales targets by 15% in Q3."
|
39
|
+
"Implemented a new CRM system, reducing customer response time by 30%."
|
40
|
+
"Mentored two junior team members, both of whom received promotions."
|
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
|
+
INSTRUCTION
|
43
|
+
|
44
|
+
# Build the prompt from provided log entries.
|
45
|
+
def self.build_prompt(log_entries)
|
46
|
+
ERB.new(SUMMARY_INSTRUCTION, trim_mode: '-').result_with_hash(entries: log_entries)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Generate a summary from provided log entries.
|
50
|
+
def self.generate_summary(log_entries)
|
51
|
+
prompt = build_prompt(log_entries)
|
52
|
+
|
53
|
+
WorkLogger.debug("Using prompt: #{prompt}")
|
54
|
+
|
55
|
+
begin
|
56
|
+
response = HTTParty.post('http://localhost:11434/api/generate',
|
57
|
+
body: {
|
58
|
+
model: Summary::MODEL,
|
59
|
+
prompt:,
|
60
|
+
system: Summary::SYSTEM_INSTRUCTION,
|
61
|
+
stream: false
|
62
|
+
}.to_json,
|
63
|
+
headers: { 'Content-Type' => 'application/json' })
|
64
|
+
response.parsed_response['response']
|
65
|
+
rescue Errno::ECONNREFUSED
|
66
|
+
WorkLogger.error <<~MSG
|
67
|
+
Ollama doesn't seem to be running. Please start the server and try again.
|
68
|
+
puts 'You can download Ollama at https://ollama.com'
|
69
|
+
MSG
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
2
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
3
|
+
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
4
|
+
<rect fill="#fff" stroke="#000" x="0" y="0" width="128" height="128" stroke-width="4" />
|
5
|
+
<rect fill="#fff" stroke="#000" x="32" y="32" width="64" height="64" stroke-width="2" />
|
6
|
+
<text x="10" y="30" fill="green" font-size="32"><%= Date.today %></text>
|
7
|
+
</svg>
|
@@ -0,0 +1,150 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<title>Work log</title>
|
4
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
6
|
+
<link rel="icon" href="favicon.svg">
|
7
|
+
<link rel="mask-icon" href="favicon.svg" color="#000000">
|
8
|
+
<style type="text/css">
|
9
|
+
|
10
|
+
ul {
|
11
|
+
list-style-type: none;
|
12
|
+
padding: 0 1rem;
|
13
|
+
}
|
14
|
+
|
15
|
+
/* Special style for presentation mode */
|
16
|
+
.presentation {
|
17
|
+
.day {
|
18
|
+
font-size: 2rem;
|
19
|
+
line-height: 2.6rem;
|
20
|
+
|
21
|
+
ul {
|
22
|
+
border-bottom: 2px solid #AAA;
|
23
|
+
li {
|
24
|
+
padding-bottom: 1rem;
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
.entries {
|
29
|
+
display: none;
|
30
|
+
}
|
31
|
+
}
|
32
|
+
}
|
33
|
+
</style>
|
34
|
+
</head>
|
35
|
+
<body>
|
36
|
+
<div class="container <%= presentation ? 'presentation' : '' %>">
|
37
|
+
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
38
|
+
<div class="container-fluid">
|
39
|
+
<a class="navbar-brand" href="/">
|
40
|
+
<% if tags %>
|
41
|
+
<%= tags.first.capitalize %> items
|
42
|
+
<% else %>
|
43
|
+
Work log
|
44
|
+
<% end %>
|
45
|
+
</a>
|
46
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
47
|
+
<span class="navbar-toggler-icon"></span>
|
48
|
+
</button>
|
49
|
+
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
50
|
+
<div class="navbar-nav me-auto"></div>
|
51
|
+
<div class="d-flex">
|
52
|
+
<% if presentation %>
|
53
|
+
<a href="<%= update_query({'presentation' => nil}) %>" class="btn btn-primary">
|
54
|
+
Screen
|
55
|
+
</a>
|
56
|
+
<% else %>
|
57
|
+
<a href="<%= update_query({'presentation' => true}) %>" class="btn btn-primary">
|
58
|
+
Presentation
|
59
|
+
</a>
|
60
|
+
<% end %>
|
61
|
+
</div>
|
62
|
+
</div>
|
63
|
+
</div>
|
64
|
+
|
65
|
+
|
66
|
+
</nav>
|
67
|
+
|
68
|
+
<hr class="border border-primary border-2 opacity-75">
|
69
|
+
<div class="pb-4">
|
70
|
+
Show
|
71
|
+
<div class="dropdown d-inline">
|
72
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
73
|
+
<% if epics_only %>
|
74
|
+
only epics
|
75
|
+
<% else %>
|
76
|
+
all items
|
77
|
+
<% end %>
|
78
|
+
</a>
|
79
|
+
<ul class="dropdown-menu">
|
80
|
+
<li><a class="dropdown-item" href="<%= update_query({'epics_only' => false}) %>">all items</a></li>
|
81
|
+
<li><a class="dropdown-item" href="<%= update_query({'epics_only' => true}) %>">only epics</a></li>
|
82
|
+
</ul>
|
83
|
+
</div>
|
84
|
+
from the last
|
85
|
+
<div class="dropdown d-inline">
|
86
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
87
|
+
<%= days %> days
|
88
|
+
</a>
|
89
|
+
<ul class="dropdown-menu">
|
90
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 7}) %>">7 days</a></li>
|
91
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 14}) %>">2 weeks</a></li>
|
92
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 21}) %>">3 weeks</a></li>
|
93
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 28}) %>">4 weeks</a></li>
|
94
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 60}) %>">2 months</a></li>
|
95
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 90}) %>">3 months</a></li>
|
96
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 180}) %>">6 months</a></li>
|
97
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 365}) %>">1 year</a></li>
|
98
|
+
</ul>
|
99
|
+
</div>
|
100
|
+
of work with
|
101
|
+
<div class="dropdown d-inline">
|
102
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
103
|
+
<% if tags %>
|
104
|
+
<%= tags.first %> tag
|
105
|
+
<% else %>
|
106
|
+
all tags
|
107
|
+
<% end %>
|
108
|
+
</a>
|
109
|
+
<ul class="dropdown-menu">
|
110
|
+
<li><a class="dropdown-item" href="<%= update_query({'tags' => nil}) %>">all tags</a></li>
|
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
|
+
</ul>
|
114
|
+
</div>
|
115
|
+
.
|
116
|
+
</div>
|
117
|
+
|
118
|
+
|
119
|
+
|
120
|
+
<%- logs.each do |log| -%>
|
121
|
+
<section class="day">
|
122
|
+
<strong><%= log.date.strftime('%a, %B %-d, %Y') %></strong>
|
123
|
+
|
124
|
+
<ul>
|
125
|
+
<%- log.entries.each do |entry| -%>
|
126
|
+
<li>
|
127
|
+
<code><%= entry.time.strftime('%H:%M') %></code>
|
128
|
+
<% if entry.epic %>
|
129
|
+
<span class="badge text-bg-warning">EPIC</span>
|
130
|
+
<% end%>
|
131
|
+
<%= entry.message %>
|
132
|
+
<% if entry.tags and entry.tags.size > 0 %>
|
133
|
+
<% entry.tags.each do |tag| %>
|
134
|
+
<strong class="badge text-bg-secondary"><%= tag %></strong>
|
135
|
+
<% end %>
|
136
|
+
<% end %>
|
137
|
+
</li>
|
138
|
+
<%- end %>
|
139
|
+
</ul>
|
140
|
+
<p class="entries"><%= log.entries.size %> entries</p>
|
141
|
+
</section>
|
142
|
+
<%- end %>
|
143
|
+
<p><%= total_entries%> entries total</p>
|
144
|
+
</div>
|
145
|
+
<hr/>
|
146
|
+
<footer class="container pb-4">
|
147
|
+
Generated at <%= Time.now.strftime('%Y-%m-%d %H:%M %Z') %>
|
148
|
+
</footer>
|
149
|
+
</body>
|
150
|
+
</html>
|
data/worklog/version.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
# Returns the current version of the gem from `.version`.
|
6
|
+
# Versioning follows SemVer.
|
7
|
+
# @return [String] The current version of the gem.
|
8
|
+
def current_version
|
9
|
+
version_file_path = File.join(Pathname.new(__dir__).parent, '.version')
|
10
|
+
File.read(version_file_path).strip
|
11
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
require 'erb'
|
5
|
+
require 'rack'
|
6
|
+
require 'rack/constants'
|
7
|
+
require 'rackup'
|
8
|
+
require 'uri'
|
9
|
+
require_relative 'storage'
|
10
|
+
require_relative 'worklog'
|
11
|
+
|
12
|
+
class DefaultHeaderMiddleware
|
13
|
+
# Rack middleware to add default headers to the response.
|
14
|
+
|
15
|
+
def initialize(app)
|
16
|
+
@app = app
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
status, headers, body = @app.call(env)
|
21
|
+
headers[Rack::CONTENT_TYPE] ||= 'text/html'
|
22
|
+
headers[Rack::CACHE_CONTROL] ||= 'no-cache'
|
23
|
+
[status, headers, body]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class WorkLogResponse
|
28
|
+
# Class to render the main page of the WorkLog web application.
|
29
|
+
|
30
|
+
def response(request)
|
31
|
+
template = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'index.html.erb')), trim_mode: '-')
|
32
|
+
@params = request.params
|
33
|
+
days = @params['days'].nil? ? 7 : @params['days'].to_i
|
34
|
+
tags = @params['tags'].nil? ? nil : @params['tags'].split(',')
|
35
|
+
epics_only = @params['epics_only'] == 'true'
|
36
|
+
presentation = @params['presentation'] == 'true'
|
37
|
+
logs = Storage.days_between(Date.today - days, Date.today, epics_only, tags).reverse
|
38
|
+
total_entries = logs.sum { |entry| entry.entries.length }
|
39
|
+
_ = total_entries
|
40
|
+
_ = presentation
|
41
|
+
|
42
|
+
[200, {}, [template.result(binding)]]
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def update_query(new_params)
|
48
|
+
uri = URI.parse('/')
|
49
|
+
cloned = @params.clone
|
50
|
+
new_params.each do |key, value|
|
51
|
+
cloned[key] = value
|
52
|
+
end
|
53
|
+
uri.query = URI.encode_www_form(cloned)
|
54
|
+
uri
|
55
|
+
end
|
56
|
+
|
57
|
+
def build_uri(params)
|
58
|
+
uri = URI.parse('/')
|
59
|
+
uri.query = URI.encode_www_form(params)
|
60
|
+
uri.to_s
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class WorkLogApp
|
65
|
+
def self.call(env)
|
66
|
+
req = Rack::Request.new(env)
|
67
|
+
WorkLogResponse.new.response(req)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# class FaviconApp
|
72
|
+
# # Rack application that creates a favicon.
|
73
|
+
|
74
|
+
# def self.call(_env)
|
75
|
+
# content = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'favicon.svg.erb')))
|
76
|
+
# [200, { Rack::CONTENT_TYPE => 'image/svg+xml' }, [content.result]]
|
77
|
+
# end
|
78
|
+
# end
|
79
|
+
|
80
|
+
class WorkLogServer
|
81
|
+
# Main Rack server containing all endpoints.
|
82
|
+
|
83
|
+
def start
|
84
|
+
app = Rack::Builder.new do
|
85
|
+
use Rack::Deflater
|
86
|
+
use Rack::CommonLogger
|
87
|
+
use Rack::ShowExceptions
|
88
|
+
use Rack::ShowStatus
|
89
|
+
use DefaultHeaderMiddleware
|
90
|
+
|
91
|
+
map '/' do
|
92
|
+
run WorkLogApp
|
93
|
+
end
|
94
|
+
# TODO: Future development
|
95
|
+
# map '/favicon.svg' do
|
96
|
+
# run FaviconApp
|
97
|
+
# end
|
98
|
+
end
|
99
|
+
|
100
|
+
Rackup::Server.start app: app
|
101
|
+
end
|
102
|
+
end
|
data/worklog/worklog.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative 'hash'
|
5
|
+
require_relative 'daily_log'
|
6
|
+
require_relative 'log_entry'
|
7
|
+
require_relative 'storage'
|
8
|
+
|
9
|
+
require 'optparse'
|
10
|
+
require 'rainbow'
|
11
|
+
require 'yaml'
|
12
|
+
|
13
|
+
module Worklog
|
14
|
+
end
|
metadata
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: fewald-worklog
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Friedrich Ewald
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-03-12 00:00:00.000000000 Z
|
11
|
+
dependencies: []
|
12
|
+
description: |
|
13
|
+
Command line tool for tracking achievments, tasks and interactions.
|
14
|
+
|
15
|
+
You can add work items, view them and run a webserver to share them with other people,
|
16
|
+
for example via screen sharing.
|
17
|
+
|
18
|
+
This tool is designed to run in a terminal completely local without sharing any data with
|
19
|
+
any other service. No telemetry, no tracking, no data sharing of any kind.
|
20
|
+
executables:
|
21
|
+
- wl
|
22
|
+
extensions: []
|
23
|
+
extra_rdoc_files: []
|
24
|
+
files:
|
25
|
+
- bin/wl
|
26
|
+
- worklog/cli.rb
|
27
|
+
- worklog/daily_log.rb
|
28
|
+
- worklog/date_parser.rb
|
29
|
+
- worklog/editor.rb
|
30
|
+
- worklog/hash.rb
|
31
|
+
- worklog/log_entry.rb
|
32
|
+
- worklog/logger.rb
|
33
|
+
- worklog/person.rb
|
34
|
+
- worklog/printer.rb
|
35
|
+
- worklog/statistics.rb
|
36
|
+
- worklog/storage.rb
|
37
|
+
- worklog/string_helper.rb
|
38
|
+
- worklog/summary.rb
|
39
|
+
- worklog/templates/favicon.svg.erb
|
40
|
+
- worklog/templates/index.html.erb
|
41
|
+
- worklog/version.rb
|
42
|
+
- worklog/webserver.rb
|
43
|
+
- worklog/worklog.rb
|
44
|
+
homepage: https://github.com/f-ewald/worklog
|
45
|
+
licenses:
|
46
|
+
- MIT
|
47
|
+
metadata:
|
48
|
+
documentation_uri: https://f-ewald.github.io/worklog
|
49
|
+
rubygems_mfa_required: 'true'
|
50
|
+
post_install_message: 'Thanks for installing worklog! Now you can use it by running
|
51
|
+
wl from your terminal.''
|
52
|
+
|
53
|
+
'
|
54
|
+
rdoc_options: []
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.4.0
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
requirements: []
|
68
|
+
rubygems_version: 3.6.2
|
69
|
+
specification_version: 4
|
70
|
+
summary: Command line tool for tracking achievments, tasks and interactions.
|
71
|
+
test_files: []
|