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 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
@@ -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
@@ -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
@@ -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>
@@ -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
@@ -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: []