Class: Worklog::Worklog

Inherits:
Object
  • Object
show all
Includes:
StringHelper
Defined in:
lib/worklog.rb

Overview

Main class providing all worklog functionality. This class is the main entry point for the application. It handles command line arguments, configuration, and logging.

Examples:

worklog = Worklog.new
worklog.add('Worked on feature X',
             date: '2023-10-01',
             time: '10:00:00',
             tags: ['feature', 'x'],
             ticket: 'TICKET-123')

See Also:

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from StringHelper

#format_left, #pluralize

Constructor Details

#initialize(config = nil) ⇒ Worklog

Returns a new instance of Worklog.



45
46
47
48
49
50
51
52
# File 'lib/worklog.rb', line 45

def initialize(config = nil)
  @config = config || Configuration.new
  @storage = Storage.new(@config)

  WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO

  bootstrap
end

Instance Attribute Details

#configConfiguration (readonly)

Returns The configuration object containing settings for the application.

Returns:

  • (Configuration)

    The configuration object containing settings for the application.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/worklog.rb', line 41

class Worklog
  include StringHelper
  attr_reader :config, :storage

  def initialize(config = nil)
    @config = config || Configuration.new
    @storage = Storage.new(@config)

    WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO

    bootstrap
  end

  # Bootstrap the worklog application.
  def bootstrap
    @storage.create_default_folder

    # Load all people as they're used in multiple/most of the methods.
    @people = @storage.load_people_hash
  end

  # Add new entry to the work log.
  # @param message [String] the message to add to the work log. This cannot be empty.
  # @param options [Hash] the options hash containing date, time, tags, ticket, url, epic, and project.
  # @raise [ArgumentError] if the message is empty.
  #
  # @example
  #   worklog.add('Worked on feature X', date: '2023-10-01', time: '10:00:00', tags: ['feature', 'x'], ticket:
  #   'TICKET-123', url: 'https://example.com/', epic: true, project: 'my_project')
  #
  # @return [void]
  def add(message, options = {})
    # Remove leading and trailing whitespaces
    # Raise an error if the message is empty
    message = message.strip
    raise ArgumentError, 'Message cannot be empty' if message.empty?

    date = Date.strptime(options[:date], '%Y-%m-%d')
    time = Time.strptime(options[:time], '%H:%M:%S')
    @storage.create_file_skeleton(date)

    # Validate that the project exists if provided
    validate_projects!(options[:project]) if options[:project] && !options[:project].empty?

    daily_log = @storage.load_log!(@storage.filepath(date))
    new_entry = LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
                             epic: options[:epic], message:, project: options[:project])
    daily_log.entries << new_entry

    # Sort by time in case an entry was added later out of order.
    daily_log.entries.sort_by!(&:time)

    @storage.write_log(@storage.filepath(options[:date]), daily_log)

    (new_entry.people - @people.keys).each do |handle|
      WorkLogger.warn "Person with handle #{handle} not found. Consider adding them to people.yaml"
    end

    WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
  end

  def edit(options = {})
    date = Date.strptime(options[:date], '%Y-%m-%d')

    # Load existing log
    log = @storage.load_log(@storage.filepath(date))
    unless log
      WorkLogger.error "No work log found for #{options[:date]}. Aborting."
      exit 1
    end

    txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
    return_val = Editor.open_editor(txt)

    @storage.write_log(@storage.filepath(date),
                       YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
    WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
  end

  # Show the work log for a specific date range or a single date.
  #
  # @param options [Hash] the options hash containing date range or single date.
  # @option options [Integer] :days the number of days to show from today (default: 1).
  # @option options [String] :from the start date in 'YYYY-MM-DD' format.
  # @option options [String] :to the end date in 'YYYY-MM-DD' format.
  # @option options [String] :date a specific date in 'YYYY-MM-DD' format.
  # @option options [Boolean] :epics_only whether to show only entries with epics (default: false).
  # @option options [String] :project the project key to filter entries by project.
  #
  # @example
  #   worklog.show(days: 7)
  #   worklog.show(from: '2023-10-01', to: '2023-10-31')
  #   worklog.show(date: '2023-10-01')
  def show(options = {})
    printer = Printer.new(@people)

    start_date, end_date = start_end_date(options)

    entries = @storage.days_between(start_date, end_date)
    if entries.empty?
      printer.no_entries(start_date, end_date)
    else
      entries.each do |entry|
        printer.print_day(entry, entries.size > 1, options[:epics_only], project: options[:project])
      end
    end
  end

  # Show all known people and details about a specific person.
  def people(person = nil, _options = {})
    all_logs = @storage.all_days

    if person
      unless @people.key?(person)
        WorkLogger.error Rainbow("No person found with handle #{person}.").red
        return
      end
      person_detail(all_logs, @people, @people[person.strip])
    else
      puts 'People mentioned in the work log:'

      mentions = {}

      all_logs.map(&:people).each do |people|
        mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
      end

      # Sort the mentions by handle
      mentions = mentions.to_a.sort_by { |handle, _| handle }

      mentions.each do |handle, v|
        if @people.key?(handle)
          person = @people[handle]
          print "#{Rainbow(person.name).gold} (#{handle})"
          print " (#{person.team})" if person.team
        else
          print handle
        end
        puts ": #{v} #{pluralize(v, 'occurrence')}"
      end
    end
  end

  def person_detail(all_logs, all_people, person)
    printer = Printer.new(all_people)
    puts "All interactions with #{Rainbow(person.name).gold}"

    if person.notes
      puts 'Notes:'
      person.notes.each do |note|
        puts "* #{note}"
      end
    end

    puts 'Interactions:'
    all_logs.each do |daily_log|
      daily_log.entries.each do |entry|
        printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
      end
    end
  end

  def projects(_options = {})
    project_storage = ProjectStorage.new(@config)
    projects = project_storage.load_projects

    # Load all entries to find latest activity for each project
    @storage.all_days.each do |daily_log|
      daily_log.entries.each do |entry|
        if projects.key?(entry.project)
          project = projects[entry.project]
          project.entries ||= []
          project.entries << entry
          # Update last activity date if entry time is more recent
          project.last_activity = entry.time if project.last_activity.nil? || entry.time > project.last_activity
        else
          WorkLogger.debug "Project with key '#{entry.project}' not found in projects. Skipping."
        end
      end
    end
    print_projects(projects)
  end

  def print_projects(projects)
    puts Rainbow('Active Projects:').gold
    projects.each_value do |project|
      # Sort entries by descending time
      project.entries.sort_by!(&:time).reverse!

      puts "#{Rainbow(project.name).gold} (#{project.key})"
      puts "  Description: #{project.description}" if project.description
      puts "  Start date: #{project.start_date.strftime('%b %d, %Y')}" if project.start_date
      puts "  End date: #{project.end_date.strftime('%b %d, %Y')}" if project.end_date
      puts "  Status: #{project.status}" if project.status
      puts "  Last activity: #{project.last_activity.strftime('%b %d, %Y')}" if project.last_activity

      next unless project.entries && !project.entries.empty?

      puts "  Last #{[project.entries&.size, 3].min} entries:"
      puts "    #{project.entries.last(3).map do |e|
        "#{e.time.strftime('%b %d, %Y')} #{e.message_string(@people)}"
      end.join("\n    ")}"
    end
    puts 'No projects found.' if projects.empty?
  end

  # Show all tags used in the work log or details for a specific tag
  #
  # @param tag [String, nil] the tag to show details for, or nil to show all tags
  # @param options [Hash] the options hash containing date range
  # @return [void]
  #
  # @example
  #   worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
  #   worklog.tags(nil) # Show all tags for all time
  def tags(tag = nil, options = {})
    if tag.nil? || tag.empty?
      tag_overview
    else
      tag_detail(tag, options)
    end
  end

  def tag_overview
    all_logs = @storage.all_days
    puts Rainbow('Tags used in the work log:').gold

    # Count all tags used in the work log
    tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally

    # Determine length of longest tag for formatting
    # Add one additonal space for formatting
    max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1

    tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
  end

  # Show detailed information about a specific tag
  #
  # @param tag [String] the tag to show details for
  # @param options [Hash] the options hash containing date range
  # @return [void]
  #
  # @example
  #   worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
  def tag_detail(tag, options)
    printer = Printer.new(@people)
    start_date, end_date = start_end_date(options)

    @storage.days_between(start_date, end_date).each do |daily_log|
      next unless daily_log.tags.include?(tag)

      daily_log.entries.each do |entry|
        next unless entry.tags.include?(tag)

        printer.print_entry(daily_log, entry, true)
      end
    end
  end

  def stats(_options = {})
    stats = Statistics.new(@config).calculate
    puts "#{format_left('Total days')}: #{stats.total_days}"
    puts "#{format_left('Total entries')}: #{stats.total_entries}"
    puts "#{format_left('Total epics')}: #{stats.total_epics}"
    puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
    puts "#{format_left('First entry')}: #{stats.first_entry}"
    puts "#{format_left('Last entry')}: #{stats.last_entry}"
  end

  def summary(options = {})
    start_date, end_date = start_end_date(options)
    entries = @storage.days_between(start_date, end_date).map(&:entries).flatten

    # Do nothing if no entries are found.
    if entries.empty?
      Printer.new.no_entries(start_date, end_date)
      return
    end

    # List all the epics
    epics = entries.filter(&:epic)
    puts Rainbow("Found #{epics.size} epics.").green if epics.any?
    epics.each do |entry|
      puts "#{entry.time.strftime('%b %d, %Y')} #{entry.message}"
    end

    # List all the tags and their count
    tags = entries.map(&:tags).flatten.compact.tally
    puts Rainbow("Found #{tags.size} tags.").green if tags.any?
    tags.each do |tag, count|
      print "#{tag} (#{count}x), "
    end
    puts '' if tags.any?

    # List all the people and their count
    people = entries.map(&:people).flatten.compact.tally.sort_by { |_, count| -count }.filter { |_, count| count > 1 }
    puts Rainbow("Found #{people.size} people.").green if people.any?
    people.each do |person, count|
      print "#{person} (#{count}x), "
    end
    puts '' if people.any?

    # # Print the summary
    # summary = Summary.new(entries)
    # puts summary.to_s
  end

  def remove(options = {})
    date = Date.strptime(options[:date], '%Y-%m-%d')
    unless File.exist?(@storage.filepath(date))
      WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
      exit 1
    end

    daily_log = @storage.load_log!(@storage.filepath(options[:date]))
    if daily_log.entries.empty?
      WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
      exit 1
    end

    removed_entry = daily_log.entries.pop
    @storage.write_log(@storage.filepath(date), daily_log)
    WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
  end

  # Start webserver
  def server
    app = WorkLogApp.new(@storage)
    WorkLogServer.new(app).start
  end

  # Parse the start and end date based on the options provided
  #
  # @param options [Hash] the options hash
  # @return [Array] the start and end date as an array
  def start_end_date(options)
    if options[:days]
      # Safeguard against negative days
      raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?

      start_date = Date.today - options[:days]
      end_date = Date.today
    elsif options[:from]
      start_date = DateParser.parse_date_string!(options[:from], true)
      end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
    elsif options[:date]
      start_date = Date.strptime(options[:date], '%Y-%m-%d')
      end_date = start_date
    else
      raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
    end
    [start_date, end_date]
  end

  # Validate that the project exists in the project storage if a project key is provided.
  #
  # @param project_key [String] the project key to validate
  # @raise [ProjectNotFoundError] if the project does not exist
  #
  # @return [void]
  #
  # @example
  #   validate_projects!('P001')
  def validate_projects!(project_key)
    project_storage = ProjectStorage.new(@config)
    begin
      projects = project_storage.load_projects
    rescue Errno::ENOENT
      raise ProjectNotFoundError, 'No projects found. Please create a project first.'
    end
    WorkLogger.debug "Project with key '#{project_key}' exists."
    return if projects.key?(project_key)

    raise ProjectNotFoundError, "Project with key '#{project_key}' does not exist."
  end
end

#storageStorage (readonly)

Returns The storage object for managing file operations.

Returns:

  • (Storage)

    The storage object for managing file operations.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/worklog.rb', line 41

class Worklog
  include StringHelper
  attr_reader :config, :storage

  def initialize(config = nil)
    @config = config || Configuration.new
    @storage = Storage.new(@config)

    WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO

    bootstrap
  end

  # Bootstrap the worklog application.
  def bootstrap
    @storage.create_default_folder

    # Load all people as they're used in multiple/most of the methods.
    @people = @storage.load_people_hash
  end

  # Add new entry to the work log.
  # @param message [String] the message to add to the work log. This cannot be empty.
  # @param options [Hash] the options hash containing date, time, tags, ticket, url, epic, and project.
  # @raise [ArgumentError] if the message is empty.
  #
  # @example
  #   worklog.add('Worked on feature X', date: '2023-10-01', time: '10:00:00', tags: ['feature', 'x'], ticket:
  #   'TICKET-123', url: 'https://example.com/', epic: true, project: 'my_project')
  #
  # @return [void]
  def add(message, options = {})
    # Remove leading and trailing whitespaces
    # Raise an error if the message is empty
    message = message.strip
    raise ArgumentError, 'Message cannot be empty' if message.empty?

    date = Date.strptime(options[:date], '%Y-%m-%d')
    time = Time.strptime(options[:time], '%H:%M:%S')
    @storage.create_file_skeleton(date)

    # Validate that the project exists if provided
    validate_projects!(options[:project]) if options[:project] && !options[:project].empty?

    daily_log = @storage.load_log!(@storage.filepath(date))
    new_entry = LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
                             epic: options[:epic], message:, project: options[:project])
    daily_log.entries << new_entry

    # Sort by time in case an entry was added later out of order.
    daily_log.entries.sort_by!(&:time)

    @storage.write_log(@storage.filepath(options[:date]), daily_log)

    (new_entry.people - @people.keys).each do |handle|
      WorkLogger.warn "Person with handle #{handle} not found. Consider adding them to people.yaml"
    end

    WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
  end

  def edit(options = {})
    date = Date.strptime(options[:date], '%Y-%m-%d')

    # Load existing log
    log = @storage.load_log(@storage.filepath(date))
    unless log
      WorkLogger.error "No work log found for #{options[:date]}. Aborting."
      exit 1
    end

    txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
    return_val = Editor.open_editor(txt)

    @storage.write_log(@storage.filepath(date),
                       YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
    WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
  end

  # Show the work log for a specific date range or a single date.
  #
  # @param options [Hash] the options hash containing date range or single date.
  # @option options [Integer] :days the number of days to show from today (default: 1).
  # @option options [String] :from the start date in 'YYYY-MM-DD' format.
  # @option options [String] :to the end date in 'YYYY-MM-DD' format.
  # @option options [String] :date a specific date in 'YYYY-MM-DD' format.
  # @option options [Boolean] :epics_only whether to show only entries with epics (default: false).
  # @option options [String] :project the project key to filter entries by project.
  #
  # @example
  #   worklog.show(days: 7)
  #   worklog.show(from: '2023-10-01', to: '2023-10-31')
  #   worklog.show(date: '2023-10-01')
  def show(options = {})
    printer = Printer.new(@people)

    start_date, end_date = start_end_date(options)

    entries = @storage.days_between(start_date, end_date)
    if entries.empty?
      printer.no_entries(start_date, end_date)
    else
      entries.each do |entry|
        printer.print_day(entry, entries.size > 1, options[:epics_only], project: options[:project])
      end
    end
  end

  # Show all known people and details about a specific person.
  def people(person = nil, _options = {})
    all_logs = @storage.all_days

    if person
      unless @people.key?(person)
        WorkLogger.error Rainbow("No person found with handle #{person}.").red
        return
      end
      person_detail(all_logs, @people, @people[person.strip])
    else
      puts 'People mentioned in the work log:'

      mentions = {}

      all_logs.map(&:people).each do |people|
        mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
      end

      # Sort the mentions by handle
      mentions = mentions.to_a.sort_by { |handle, _| handle }

      mentions.each do |handle, v|
        if @people.key?(handle)
          person = @people[handle]
          print "#{Rainbow(person.name).gold} (#{handle})"
          print " (#{person.team})" if person.team
        else
          print handle
        end
        puts ": #{v} #{pluralize(v, 'occurrence')}"
      end
    end
  end

  def person_detail(all_logs, all_people, person)
    printer = Printer.new(all_people)
    puts "All interactions with #{Rainbow(person.name).gold}"

    if person.notes
      puts 'Notes:'
      person.notes.each do |note|
        puts "* #{note}"
      end
    end

    puts 'Interactions:'
    all_logs.each do |daily_log|
      daily_log.entries.each do |entry|
        printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
      end
    end
  end

  def projects(_options = {})
    project_storage = ProjectStorage.new(@config)
    projects = project_storage.load_projects

    # Load all entries to find latest activity for each project
    @storage.all_days.each do |daily_log|
      daily_log.entries.each do |entry|
        if projects.key?(entry.project)
          project = projects[entry.project]
          project.entries ||= []
          project.entries << entry
          # Update last activity date if entry time is more recent
          project.last_activity = entry.time if project.last_activity.nil? || entry.time > project.last_activity
        else
          WorkLogger.debug "Project with key '#{entry.project}' not found in projects. Skipping."
        end
      end
    end
    print_projects(projects)
  end

  def print_projects(projects)
    puts Rainbow('Active Projects:').gold
    projects.each_value do |project|
      # Sort entries by descending time
      project.entries.sort_by!(&:time).reverse!

      puts "#{Rainbow(project.name).gold} (#{project.key})"
      puts "  Description: #{project.description}" if project.description
      puts "  Start date: #{project.start_date.strftime('%b %d, %Y')}" if project.start_date
      puts "  End date: #{project.end_date.strftime('%b %d, %Y')}" if project.end_date
      puts "  Status: #{project.status}" if project.status
      puts "  Last activity: #{project.last_activity.strftime('%b %d, %Y')}" if project.last_activity

      next unless project.entries && !project.entries.empty?

      puts "  Last #{[project.entries&.size, 3].min} entries:"
      puts "    #{project.entries.last(3).map do |e|
        "#{e.time.strftime('%b %d, %Y')} #{e.message_string(@people)}"
      end.join("\n    ")}"
    end
    puts 'No projects found.' if projects.empty?
  end

  # Show all tags used in the work log or details for a specific tag
  #
  # @param tag [String, nil] the tag to show details for, or nil to show all tags
  # @param options [Hash] the options hash containing date range
  # @return [void]
  #
  # @example
  #   worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
  #   worklog.tags(nil) # Show all tags for all time
  def tags(tag = nil, options = {})
    if tag.nil? || tag.empty?
      tag_overview
    else
      tag_detail(tag, options)
    end
  end

  def tag_overview
    all_logs = @storage.all_days
    puts Rainbow('Tags used in the work log:').gold

    # Count all tags used in the work log
    tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally

    # Determine length of longest tag for formatting
    # Add one additonal space for formatting
    max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1

    tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
  end

  # Show detailed information about a specific tag
  #
  # @param tag [String] the tag to show details for
  # @param options [Hash] the options hash containing date range
  # @return [void]
  #
  # @example
  #   worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
  def tag_detail(tag, options)
    printer = Printer.new(@people)
    start_date, end_date = start_end_date(options)

    @storage.days_between(start_date, end_date).each do |daily_log|
      next unless daily_log.tags.include?(tag)

      daily_log.entries.each do |entry|
        next unless entry.tags.include?(tag)

        printer.print_entry(daily_log, entry, true)
      end
    end
  end

  def stats(_options = {})
    stats = Statistics.new(@config).calculate
    puts "#{format_left('Total days')}: #{stats.total_days}"
    puts "#{format_left('Total entries')}: #{stats.total_entries}"
    puts "#{format_left('Total epics')}: #{stats.total_epics}"
    puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
    puts "#{format_left('First entry')}: #{stats.first_entry}"
    puts "#{format_left('Last entry')}: #{stats.last_entry}"
  end

  def summary(options = {})
    start_date, end_date = start_end_date(options)
    entries = @storage.days_between(start_date, end_date).map(&:entries).flatten

    # Do nothing if no entries are found.
    if entries.empty?
      Printer.new.no_entries(start_date, end_date)
      return
    end

    # List all the epics
    epics = entries.filter(&:epic)
    puts Rainbow("Found #{epics.size} epics.").green if epics.any?
    epics.each do |entry|
      puts "#{entry.time.strftime('%b %d, %Y')} #{entry.message}"
    end

    # List all the tags and their count
    tags = entries.map(&:tags).flatten.compact.tally
    puts Rainbow("Found #{tags.size} tags.").green if tags.any?
    tags.each do |tag, count|
      print "#{tag} (#{count}x), "
    end
    puts '' if tags.any?

    # List all the people and their count
    people = entries.map(&:people).flatten.compact.tally.sort_by { |_, count| -count }.filter { |_, count| count > 1 }
    puts Rainbow("Found #{people.size} people.").green if people.any?
    people.each do |person, count|
      print "#{person} (#{count}x), "
    end
    puts '' if people.any?

    # # Print the summary
    # summary = Summary.new(entries)
    # puts summary.to_s
  end

  def remove(options = {})
    date = Date.strptime(options[:date], '%Y-%m-%d')
    unless File.exist?(@storage.filepath(date))
      WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
      exit 1
    end

    daily_log = @storage.load_log!(@storage.filepath(options[:date]))
    if daily_log.entries.empty?
      WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
      exit 1
    end

    removed_entry = daily_log.entries.pop
    @storage.write_log(@storage.filepath(date), daily_log)
    WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
  end

  # Start webserver
  def server
    app = WorkLogApp.new(@storage)
    WorkLogServer.new(app).start
  end

  # Parse the start and end date based on the options provided
  #
  # @param options [Hash] the options hash
  # @return [Array] the start and end date as an array
  def start_end_date(options)
    if options[:days]
      # Safeguard against negative days
      raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?

      start_date = Date.today - options[:days]
      end_date = Date.today
    elsif options[:from]
      start_date = DateParser.parse_date_string!(options[:from], true)
      end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
    elsif options[:date]
      start_date = Date.strptime(options[:date], '%Y-%m-%d')
      end_date = start_date
    else
      raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
    end
    [start_date, end_date]
  end

  # Validate that the project exists in the project storage if a project key is provided.
  #
  # @param project_key [String] the project key to validate
  # @raise [ProjectNotFoundError] if the project does not exist
  #
  # @return [void]
  #
  # @example
  #   validate_projects!('P001')
  def validate_projects!(project_key)
    project_storage = ProjectStorage.new(@config)
    begin
      projects = project_storage.load_projects
    rescue Errno::ENOENT
      raise ProjectNotFoundError, 'No projects found. Please create a project first.'
    end
    WorkLogger.debug "Project with key '#{project_key}' exists."
    return if projects.key?(project_key)

    raise ProjectNotFoundError, "Project with key '#{project_key}' does not exist."
  end
end

Instance Method Details

#add(message, options = {}) ⇒ void

This method returns an undefined value.

Add new entry to the work log.

Examples:

worklog.add('Worked on feature X', date: '2023-10-01', time: '10:00:00', tags: ['feature', 'x'], ticket:
'TICKET-123', url: 'https://example.com/', epic: true, project: 'my_project')

Parameters:

  • message (String)

    the message to add to the work log. This cannot be empty.

  • options (Hash) (defaults to: {})

    the options hash containing date, time, tags, ticket, url, epic, and project.

Raises:

  • (ArgumentError)

    if the message is empty.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/worklog.rb', line 72

def add(message, options = {})
  # Remove leading and trailing whitespaces
  # Raise an error if the message is empty
  message = message.strip
  raise ArgumentError, 'Message cannot be empty' if message.empty?

  date = Date.strptime(options[:date], '%Y-%m-%d')
  time = Time.strptime(options[:time], '%H:%M:%S')
  @storage.create_file_skeleton(date)

  # Validate that the project exists if provided
  validate_projects!(options[:project]) if options[:project] && !options[:project].empty?

  daily_log = @storage.load_log!(@storage.filepath(date))
  new_entry = LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
                           epic: options[:epic], message:, project: options[:project])
  daily_log.entries << new_entry

  # Sort by time in case an entry was added later out of order.
  daily_log.entries.sort_by!(&:time)

  @storage.write_log(@storage.filepath(options[:date]), daily_log)

  (new_entry.people - @people.keys).each do |handle|
    WorkLogger.warn "Person with handle #{handle} not found. Consider adding them to people.yaml"
  end

  WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
end

#bootstrapObject

Bootstrap the worklog application.



55
56
57
58
59
60
# File 'lib/worklog.rb', line 55

def bootstrap
  @storage.create_default_folder

  # Load all people as they're used in multiple/most of the methods.
  @people = @storage.load_people_hash
end

#edit(options = {}) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/worklog.rb', line 102

def edit(options = {})
  date = Date.strptime(options[:date], '%Y-%m-%d')

  # Load existing log
  log = @storage.load_log(@storage.filepath(date))
  unless log
    WorkLogger.error "No work log found for #{options[:date]}. Aborting."
    exit 1
  end

  txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
  return_val = Editor.open_editor(txt)

  @storage.write_log(@storage.filepath(date),
                     YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
  WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
end

#people(person = nil, _options = {}) ⇒ Object

Show all known people and details about a specific person.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/worklog.rb', line 150

def people(person = nil, _options = {})
  all_logs = @storage.all_days

  if person
    unless @people.key?(person)
      WorkLogger.error Rainbow("No person found with handle #{person}.").red
      return
    end
    person_detail(all_logs, @people, @people[person.strip])
  else
    puts 'People mentioned in the work log:'

    mentions = {}

    all_logs.map(&:people).each do |people|
      mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
    end

    # Sort the mentions by handle
    mentions = mentions.to_a.sort_by { |handle, _| handle }

    mentions.each do |handle, v|
      if @people.key?(handle)
        person = @people[handle]
        print "#{Rainbow(person.name).gold} (#{handle})"
        print " (#{person.team})" if person.team
      else
        print handle
      end
      puts ": #{v} #{pluralize(v, 'occurrence')}"
    end
  end
end

#person_detail(all_logs, all_people, person) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/worklog.rb', line 184

def person_detail(all_logs, all_people, person)
  printer = Printer.new(all_people)
  puts "All interactions with #{Rainbow(person.name).gold}"

  if person.notes
    puts 'Notes:'
    person.notes.each do |note|
      puts "* #{note}"
    end
  end

  puts 'Interactions:'
  all_logs.each do |daily_log|
    daily_log.entries.each do |entry|
      printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
    end
  end
end


224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/worklog.rb', line 224

def print_projects(projects)
  puts Rainbow('Active Projects:').gold
  projects.each_value do |project|
    # Sort entries by descending time
    project.entries.sort_by!(&:time).reverse!

    puts "#{Rainbow(project.name).gold} (#{project.key})"
    puts "  Description: #{project.description}" if project.description
    puts "  Start date: #{project.start_date.strftime('%b %d, %Y')}" if project.start_date
    puts "  End date: #{project.end_date.strftime('%b %d, %Y')}" if project.end_date
    puts "  Status: #{project.status}" if project.status
    puts "  Last activity: #{project.last_activity.strftime('%b %d, %Y')}" if project.last_activity

    next unless project.entries && !project.entries.empty?

    puts "  Last #{[project.entries&.size, 3].min} entries:"
    puts "    #{project.entries.last(3).map do |e|
      "#{e.time.strftime('%b %d, %Y')} #{e.message_string(@people)}"
    end.join("\n    ")}"
  end
  puts 'No projects found.' if projects.empty?
end

#projects(_options = {}) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/worklog.rb', line 203

def projects(_options = {})
  project_storage = ProjectStorage.new(@config)
  projects = project_storage.load_projects

  # Load all entries to find latest activity for each project
  @storage.all_days.each do |daily_log|
    daily_log.entries.each do |entry|
      if projects.key?(entry.project)
        project = projects[entry.project]
        project.entries ||= []
        project.entries << entry
        # Update last activity date if entry time is more recent
        project.last_activity = entry.time if project.last_activity.nil? || entry.time > project.last_activity
      else
        WorkLogger.debug "Project with key '#{entry.project}' not found in projects. Skipping."
      end
    end
  end
  print_projects(projects)
end

#remove(options = {}) ⇒ Object



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/worklog.rb', line 349

def remove(options = {})
  date = Date.strptime(options[:date], '%Y-%m-%d')
  unless File.exist?(@storage.filepath(date))
    WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
    exit 1
  end

  daily_log = @storage.load_log!(@storage.filepath(options[:date]))
  if daily_log.entries.empty?
    WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
    exit 1
  end

  removed_entry = daily_log.entries.pop
  @storage.write_log(@storage.filepath(date), daily_log)
  WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
end

#serverObject

Start webserver



368
369
370
371
# File 'lib/worklog.rb', line 368

def server
  app = WorkLogApp.new(@storage)
  WorkLogServer.new(app).start
end

#show(options = {}) ⇒ Object

Show the work log for a specific date range or a single date.

Examples:

worklog.show(days: 7)
worklog.show(from: '2023-10-01', to: '2023-10-31')
worklog.show(date: '2023-10-01')

Parameters:

  • options (Hash) (defaults to: {})

    the options hash containing date range or single date.

Options Hash (options):

  • :days (Integer)

    the number of days to show from today (default: 1).

  • :from (String)

    the start date in ‘YYYY-MM-DD’ format.

  • :to (String)

    the end date in ‘YYYY-MM-DD’ format.

  • :date (String)

    a specific date in ‘YYYY-MM-DD’ format.

  • :epics_only (Boolean)

    whether to show only entries with epics (default: false).

  • :project (String)

    the project key to filter entries by project.



134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/worklog.rb', line 134

def show(options = {})
  printer = Printer.new(@people)

  start_date, end_date = start_end_date(options)

  entries = @storage.days_between(start_date, end_date)
  if entries.empty?
    printer.no_entries(start_date, end_date)
  else
    entries.each do |entry|
      printer.print_day(entry, entries.size > 1, options[:epics_only], project: options[:project])
    end
  end
end

#start_end_date(options) ⇒ Array

Parse the start and end date based on the options provided

Parameters:

  • options (Hash)

    the options hash

Returns:

  • (Array)

    the start and end date as an array



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# File 'lib/worklog.rb', line 377

def start_end_date(options)
  if options[:days]
    # Safeguard against negative days
    raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?

    start_date = Date.today - options[:days]
    end_date = Date.today
  elsif options[:from]
    start_date = DateParser.parse_date_string!(options[:from], true)
    end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
  elsif options[:date]
    start_date = Date.strptime(options[:date], '%Y-%m-%d')
    end_date = start_date
  else
    raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
  end
  [start_date, end_date]
end

#stats(_options = {}) ⇒ Object



301
302
303
304
305
306
307
308
309
# File 'lib/worklog.rb', line 301

def stats(_options = {})
  stats = Statistics.new(@config).calculate
  puts "#{format_left('Total days')}: #{stats.total_days}"
  puts "#{format_left('Total entries')}: #{stats.total_entries}"
  puts "#{format_left('Total epics')}: #{stats.total_epics}"
  puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
  puts "#{format_left('First entry')}: #{stats.first_entry}"
  puts "#{format_left('Last entry')}: #{stats.last_entry}"
end

#summary(options = {}) ⇒ Object



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/worklog.rb', line 311

def summary(options = {})
  start_date, end_date = start_end_date(options)
  entries = @storage.days_between(start_date, end_date).map(&:entries).flatten

  # Do nothing if no entries are found.
  if entries.empty?
    Printer.new.no_entries(start_date, end_date)
    return
  end

  # List all the epics
  epics = entries.filter(&:epic)
  puts Rainbow("Found #{epics.size} epics.").green if epics.any?
  epics.each do |entry|
    puts "#{entry.time.strftime('%b %d, %Y')} #{entry.message}"
  end

  # List all the tags and their count
  tags = entries.map(&:tags).flatten.compact.tally
  puts Rainbow("Found #{tags.size} tags.").green if tags.any?
  tags.each do |tag, count|
    print "#{tag} (#{count}x), "
  end
  puts '' if tags.any?

  # List all the people and their count
  people = entries.map(&:people).flatten.compact.tally.sort_by { |_, count| -count }.filter { |_, count| count > 1 }
  puts Rainbow("Found #{people.size} people.").green if people.any?
  people.each do |person, count|
    print "#{person} (#{count}x), "
  end
  puts '' if people.any?

  # # Print the summary
  # summary = Summary.new(entries)
  # puts summary.to_s
end

#tag_detail(tag, options) ⇒ void

This method returns an undefined value.

Show detailed information about a specific tag

Examples:

worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')

Parameters:

  • tag (String)

    the tag to show details for

  • options (Hash)

    the options hash containing date range



286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/worklog.rb', line 286

def tag_detail(tag, options)
  printer = Printer.new(@people)
  start_date, end_date = start_end_date(options)

  @storage.days_between(start_date, end_date).each do |daily_log|
    next unless daily_log.tags.include?(tag)

    daily_log.entries.each do |entry|
      next unless entry.tags.include?(tag)

      printer.print_entry(daily_log, entry, true)
    end
  end
end

#tag_overviewObject



264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/worklog.rb', line 264

def tag_overview
  all_logs = @storage.all_days
  puts Rainbow('Tags used in the work log:').gold

  # Count all tags used in the work log
  tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally

  # Determine length of longest tag for formatting
  # Add one additonal space for formatting
  max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1

  tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
end

#tags(tag = nil, options = {}) ⇒ void

This method returns an undefined value.

Show all tags used in the work log or details for a specific tag

Examples:

worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
worklog.tags(nil) # Show all tags for all time

Parameters:

  • tag (String, nil) (defaults to: nil)

    the tag to show details for, or nil to show all tags

  • options (Hash) (defaults to: {})

    the options hash containing date range



256
257
258
259
260
261
262
# File 'lib/worklog.rb', line 256

def tags(tag = nil, options = {})
  if tag.nil? || tag.empty?
    tag_overview
  else
    tag_detail(tag, options)
  end
end

#validate_projects!(project_key) ⇒ void

This method returns an undefined value.

Validate that the project exists in the project storage if a project key is provided.

Examples:

validate_projects!('P001')

Parameters:

  • project_key (String)

    the project key to validate

Raises:



405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/worklog.rb', line 405

def validate_projects!(project_key)
  project_storage = ProjectStorage.new(@config)
  begin
    projects = project_storage.load_projects
  rescue Errno::ENOENT
    raise ProjectNotFoundError, 'No projects found. Please create a project first.'
  end
  WorkLogger.debug "Project with key '#{project_key}' exists."
  return if projects.key?(project_key)

  raise ProjectNotFoundError, "Project with key '#{project_key}' does not exist."
end