fewald-worklog 0.2.9 → 0.2.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236505ddc94f7f28294723a0c6e0afc7c6b9c2ef49baf6b32f555eafddbff9e7
4
- data.tar.gz: b7ed9f40ecf876b199b2c3526b2c42775302c980fb4e862f852a947631c2bc76
3
+ metadata.gz: d1bb6e2ee81700ee384981acfa68bfde33ba9d2e5e7d0af30b0c08c392bde042
4
+ data.tar.gz: ac0fa2e1c558f97bc11cdcb145e5a1b8eb12fd0ca661dd932f545108b0386732
5
5
  SHA512:
6
- metadata.gz: 0fedfe9d106478c247dfd175e076b15c3da4d293dd2ae7385319d51b52c33fed9c99add51dcbd64228edd70569fcba6eb25eb7ae7368181948bf1993d94c8c3c
7
- data.tar.gz: ab040f7009457b38e20453f4af7251deea62baad23c2557f0e0455399a48df6288c77433de1c6cd04a26f4ba13ef3d374dc8903908ee60c524f1d37b57d06e85
6
+ metadata.gz: 5dcae260eadbe46143a35b2dab7a6c3e780104d5b95dbeef0b439f52613e2dcc4ffa014d2963947715700feca90a88ec9c814fa5a7b90bea56068262b8bd8891
7
+ data.tar.gz: a4eedd1a9d6e7395fdfd7a758c90548e1eccc62195c80fb6475433500c4a376b67b3ba1e6ab857f9048234b643670f9b814908941093f9881be3d8d4aedb31b3
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.9
1
+ 0.2.11
data/lib/cli.rb CHANGED
@@ -53,22 +53,23 @@ class WorklogCLI < Thor
53
53
  option :ticket, type: :string, desc: 'Ticket number associated with the entry. Can be any alphanumeric string.'
54
54
  option :url, type: :string, desc: 'URL to associate with the entry'
55
55
  option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
56
+ option :project, type: :string, desc: 'Key of the project. The project needs to be defined first.'
56
57
  def add(message)
57
- worklog = Worklog.new
58
+ worklog = Worklog::Worklog.new
58
59
  worklog.add(message, options)
59
60
  end
60
61
 
61
62
  desc 'edit', 'Edit a day in the work log. By default, the current date is used.'
62
63
  option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
63
64
  def edit
64
- worklog = Worklog.new
65
+ worklog = Worklog::Worklog.new
65
66
  worklog.edit(options)
66
67
  end
67
68
 
68
69
  desc 'remove', 'Remove last entry from the log'
69
70
  option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
70
71
  def remove
71
- worklog = Worklog.new
72
+ worklog = Worklog::Worklog.new
72
73
  worklog.remove(options)
73
74
  end
74
75
 
@@ -92,16 +93,22 @@ class WorklogCLI < Thor
92
93
  option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
93
94
  option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
94
95
  def show
95
- worklog = Worklog.new
96
+ worklog = Worklog::Worklog.new
96
97
  worklog.show(options)
97
98
  end
98
99
 
99
100
  desc 'people', 'Show all people mentioned in the work log'
100
101
  def people(person = nil)
101
- worklog = Worklog.new
102
+ worklog = Worklog::Worklog.new
102
103
  worklog.people(person, options)
103
104
  end
104
105
 
106
+ desc 'projects', 'Show all projects defined in the work log'
107
+ def projects(options = {})
108
+ worklog = Worklog::Worklog.new
109
+ worklog.projects(options)
110
+ end
111
+
105
112
  desc 'tags', 'Show all tags used in the work log'
106
113
  option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'),
107
114
  desc: <<~DESC
@@ -117,19 +124,19 @@ class WorklogCLI < Thor
117
124
  Number of days to show starting from --date. Takes precedence over --from and --to if defined.
118
125
  EOF
119
126
  def tags(tag = nil)
120
- worklog = Worklog.new
127
+ worklog = Worklog::Worklog.new
121
128
  worklog.tags(tag, options)
122
129
  end
123
130
 
124
131
  desc 'server', 'Start the work log server'
125
132
  def server
126
- worklog = Worklog.new
133
+ worklog = Worklog::Worklog.new
127
134
  worklog.server
128
135
  end
129
136
 
130
137
  desc 'stats', 'Show statistics for the work log'
131
138
  def stats
132
- worklog = Worklog.new
139
+ worklog = Worklog::Worklog.new
133
140
  worklog.stats(options)
134
141
  end
135
142
 
@@ -145,7 +152,7 @@ class WorklogCLI < Thor
145
152
  'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
146
153
  EOF
147
154
  def summary
148
- worklog = Worklog.new
155
+ worklog = Worklog::Worklog.new
149
156
  worklog.summary(options)
150
157
  end
151
158
 
@@ -158,4 +165,5 @@ class WorklogCLI < Thor
158
165
  map 'a' => :add
159
166
  map 'statistics' => :stats
160
167
  map 'serve' => :server
168
+ map 'project' => :projects
161
169
  end
data/lib/daily_log.rb CHANGED
@@ -28,7 +28,7 @@ class DailyLog
28
28
  #
29
29
  # @return [Hash<String, Integer>]
30
30
  def people
31
- entries.map(&:people).flatten.tally
31
+ entries.map { |entry| entry.people.to_a }.flatten.tally
32
32
  end
33
33
 
34
34
  # Returns a sorted list of tags used in the entries for the current day.
data/lib/date_parser.rb CHANGED
@@ -4,6 +4,10 @@ require 'date'
4
4
 
5
5
  module DateParser
6
6
  # Best effort date parsing from multiple formats.
7
+ #
8
+ # @param date_str [String] The date string to parse.
9
+ # @param from_beginning [Boolean] If true, returns the beginning of the date (e.g., first day of month or year).
10
+ # @return [Date, nil] Returns a Date object if parsing is successful, or nil if parsing fails.
7
11
  def self.parse_date_string(date_str, from_beginning = true)
8
12
  return nil if date_str.nil?
9
13
  return nil if date_str.empty?
@@ -60,6 +64,7 @@ module DateParser
60
64
  Date.new(d.year, d.month + 2, -1)
61
65
  end
62
66
 
67
+ # Similar to parse_date_string, but raises an error if parsing fails.
63
68
  def self.parse_date_string!(date_str, from_beginning = true)
64
69
  date = parse_date_string(date_str, from_beginning)
65
70
  raise ArgumentError, "Could not parse date string: \"#{date_str}\"" if date.nil?
data/lib/log_entry.rb CHANGED
@@ -12,7 +12,9 @@ class LogEntry
12
12
  include Hashify
13
13
 
14
14
  # Represents a single entry in the work log.
15
- attr_accessor :time, :tags, :ticket, :url, :epic, :message
15
+ attr_accessor :time, :tags, :ticket, :url, :epic, :message, :project
16
+
17
+ attr_reader :day
16
18
 
17
19
  def initialize(params = {})
18
20
  @time = params[:time]
@@ -23,6 +25,10 @@ class LogEntry
23
25
  @url = params[:url] || ''
24
26
  @epic = params[:epic]
25
27
  @message = params[:message]
28
+ @project = params[:project]
29
+
30
+ # Back reference to the day
31
+ @day = params[:day] || nil
26
32
  end
27
33
 
28
34
  # Returns true if the entry is an epic, false otherwise.
@@ -62,18 +68,17 @@ class LogEntry
62
68
  # Add URL in brackets if defined.
63
69
  s += " [#{@url}]" if @url && @url != ''
64
70
 
71
+ s += " [#{@project}]" if @project && @project != ''
72
+
65
73
  s
66
74
  end
67
75
 
68
76
  def people
69
- # Return people that are mentioned in the entry.
70
- # People are defined as words starting with @ or ~.
71
- # Whitespaces are used to separate people.
72
- # Punctuation is not considered.
73
- # Empty array if no people are mentioned.
74
- #
75
- # @return [Array<String>]
76
- @message.scan(PERSON_REGEX).flatten.uniq.sort
77
+ # Return people that are mentioned in the entry. People are defined as character sequences
78
+ # starting with @ or ~. Whitespaces are used to separate people. Punctuation is ignored.
79
+ # Empty set if no people are mentioned.
80
+ # @return [Set<String>]
81
+ @message.scan(PERSON_REGEX).flatten.uniq.sort.to_set
77
82
  end
78
83
 
79
84
  def people?
data/lib/project.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worklog
4
+ # Represents a project. A project is a longer running task or initiative.
5
+ # Single log entries can be associated with a project.
6
+ class Project
7
+ attr_accessor :key, :name, :description, :start_date, :end_date, :status
8
+
9
+ # Creates a new Project instance from a hash of attributes.
10
+ # @param hash [Hash] A hash containing project attributes
11
+ # @option hash [String] :key The project key
12
+ # @option hash [String] :name The project name
13
+ # @option hash [String] :description The project description
14
+ # @option hash [Date] :start_date The project start date
15
+ # @option hash [Date] :end_date The project end date
16
+ # @option hash [String] :status The project status
17
+ # @return [Project] A new Project instance
18
+ def self.from_hash(hash)
19
+ project = new
20
+ # Ensure that at least the key is present
21
+ raise ArgumentError, 'Project key is required' unless hash[:key] || hash['key']
22
+
23
+ project.key = hash[:key] || hash['key']
24
+ project.name = hash[:name] || hash['name']
25
+ project.description = hash[:description] || hash['description']
26
+ project.start_date = hash[:start_date] || hash['start_date']
27
+ project.end_date = hash[:end_date] || hash['end_date']
28
+ project.status = hash[:status] || hash['status']
29
+ project
30
+ end
31
+
32
+ # Returns true if the project has started, false otherwise.
33
+ # A project is considered started if either
34
+ # - its start date is nil or
35
+ # - its start date is less than or equal to today's date.
36
+ # @return [Boolean] true if the project has started, false otherwise
37
+ def started?
38
+ start_date.nil? || (!start_date.nil? && start_date <= Date.today)
39
+ end
40
+
41
+ # Returns true if the project has ended, false otherwise.
42
+ # @return [Boolean] true if the project has ended, false otherwise
43
+ def ended?
44
+ !end_date.nil? && end_date < Date.today
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'project'
4
+
5
+ module Worklog
6
+ # Custom error for when a project is not found.
7
+ # This error is raised when a project with a given key does not exist in the project storage.
8
+ # It is used to signal that an operation requiring a project cannot proceed.
9
+ class ProjectNotFoundError < StandardError; end
10
+
11
+ # ProjectStorage is responsible for loading and managing project data.
12
+ # It provides methods to load projects from a YAML file and check if a project exists.
13
+ #
14
+ # @see Project
15
+ # @see Configuration
16
+ # Handles storage operations for projects.
17
+ class ProjectStorage
18
+ attr_writer :projects
19
+
20
+ PROJECT_TEMPLATE = <<~YAML
21
+ # Each project is defined by the following attributes:
22
+ # - key: <project_key>
23
+ # name: <project_name>
24
+ # description: <project_description>
25
+ # start_date: <start_date>
26
+ # end_date: <end_date>
27
+ # status: <status>
28
+ # --- Define your projects below this line ---
29
+ YAML
30
+
31
+ # Constructs a new ProjectStorage instance.
32
+ # @param configuration [Configuration] The configuration object.
33
+ def initialize(configuration)
34
+ @configuration = configuration
35
+ end
36
+
37
+ def projects
38
+ @projects ||= load_projects
39
+ end
40
+
41
+ # Loads all projects from disk.
42
+ # If the file does not exist, it creates a template.
43
+ # @return [Hash<String, Project>] A hash of project objects keyed by their project keys.
44
+ def load_projects
45
+ create_template unless file_exist?
46
+
47
+ file_path = File.join(@configuration.storage_path, 'projects.yml')
48
+ projects = {}
49
+ YAML.load_file(file_path, permitted_classes: [Date])&.each do |project_hash|
50
+ project = Project.from_hash(project_hash)
51
+ projects[project.key] = project if project
52
+ end
53
+
54
+ projects
55
+ end
56
+
57
+ # Check if a project with a given handle exists.
58
+ # @param handle [String] The handle of the project to check.
59
+ # @return [Boolean] Returns true if the project exists, false otherwise.
60
+ def exist?(handle)
61
+ projects = load_projects
62
+ projects.key?(handle)
63
+ end
64
+
65
+ private
66
+
67
+ # Check whether projects.yaml exists in the project_dir
68
+ # @return [Boolean] Returns true if the project YAML file exists, false otherwise
69
+ def file_exist?
70
+ file_path = File.join(@configuration.storage_path, 'projects.yml')
71
+ File.exist?(file_path)
72
+ end
73
+
74
+ def create_template
75
+ File.write(File.join(@configuration.storage_path, 'projects.yml'), PROJECT_TEMPLATE)
76
+ end
77
+ end
78
+ end
data/lib/storage.rb CHANGED
@@ -131,6 +131,9 @@ class Storage
131
131
  daily_log.entries
132
132
  end
133
133
 
134
+ # Load all people from the people file, or return an empty array if the file does not exist
135
+ #
136
+ # @return [Array<Person>] List of people
134
137
  def load_people
135
138
  load_people!
136
139
  rescue Errno::ENOENT
@@ -138,6 +141,12 @@ class Storage
138
141
  []
139
142
  end
140
143
 
144
+ # Load all people from the people file and return them as a hash with handle as key
145
+ # @return [Hash<String, Person>] Hash of people with handle as key
146
+ def load_people_hash
147
+ load_people.to_h { |person| [person.handle, person] }
148
+ end
149
+
141
150
  # Load all people from the people file
142
151
  # @return [Array<Person>] List of people
143
152
  def load_people!
data/lib/worklog.rb CHANGED
@@ -15,250 +15,326 @@ require 'string_helper'
15
15
  require 'printer'
16
16
  require 'statistics'
17
17
  require 'summary'
18
+ require 'project_storage'
18
19
 
19
- # Main class providing all worklog functionality.
20
- # This class is the main entry point for the application.
21
- # It handles command line arguments, configuration, and logging.
22
- class Worklog
23
- include StringHelper
24
- attr_reader :config
20
+ module Worklog
21
+ # Main class providing all worklog functionality.
22
+ # This class is the main entry point for the application.
23
+ # It handles command line arguments, configuration, and logging.
24
+ class Worklog
25
+ include StringHelper
26
+ attr_reader :config, :storage
25
27
 
26
- def initialize(config = nil)
27
- @config = config || Configuration.new
28
- @storage = Storage.new(@config)
28
+ def initialize(config = nil)
29
+ @config = config || Configuration.new
30
+ @storage = Storage.new(@config)
29
31
 
30
- WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO
31
- end
32
+ WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO
33
+ end
32
34
 
33
- def add(message, options = {})
34
- # Remove leading and trailing whitespaces
35
- # Raise an error if the message is empty
36
- message = message.strip
37
- raise ArgumentError, 'Message cannot be empty' if message.empty?
35
+ # Add new entry to the work log.
36
+ # @param message [String] the message to add to the work log. This cannot be empty.
37
+ # @param options [Hash] the options hash containing date, time, tags, ticket, url, epic, and project.
38
+ # @raise [ArgumentError] if the message is empty.
39
+ #
40
+ # @example
41
+ # worklog.add('Worked on feature X', date: '2023-10-01', time: '10:00:00', tags: ['feature', 'x'], ticket:
42
+ # 'TICKET-123', url: 'https://example.com/', epic: true, project: 'my_project')
43
+ #
44
+ # @return [void]
45
+ def add(message, options = {})
46
+ # Remove leading and trailing whitespaces
47
+ # Raise an error if the message is empty
48
+ message = message.strip
49
+ raise ArgumentError, 'Message cannot be empty' if message.empty?
50
+
51
+ date = Date.strptime(options[:date], '%Y-%m-%d')
52
+ time = Time.strptime(options[:time], '%H:%M:%S')
53
+ @storage.create_file_skeleton(date)
54
+
55
+ # Validate that the project exists if provided
56
+ validate_projects!(options[:project]) if options[:project] && !options[:project].empty?
57
+
58
+ daily_log = @storage.load_log!(@storage.filepath(date))
59
+ new_entry = LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
60
+ epic: options[:epic], message:, project: options[:project])
61
+ daily_log.entries << new_entry
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
+
68
+ people_hash = @storage.load_people_hash
69
+ (new_entry.people - people_hash.keys).each do |handle|
70
+ WorkLogger.warn "Person with handle #{handle} not found. Consider adding them to people.yaml"
71
+ end
38
72
 
39
- date = Date.strptime(options[:date], '%Y-%m-%d')
40
- time = Time.strptime(options[:time], '%H:%M:%S')
41
- @storage.create_file_skeleton(date)
73
+ WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
74
+ end
42
75
 
43
- daily_log = @storage.load_log!(@storage.filepath(date))
44
- daily_log.entries << LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
45
- epic: options[:epic], message:)
76
+ def edit(options = {})
77
+ date = Date.strptime(options[:date], '%Y-%m-%d')
46
78
 
47
- # Sort by time in case an entry was added later out of order.
48
- daily_log.entries.sort_by!(&:time)
79
+ # Load existing log
80
+ log = @storage.load_log(@storage.filepath(date))
81
+ unless log
82
+ WorkLogger.error "No work log found for #{options[:date]}. Aborting."
83
+ exit 1
84
+ end
49
85
 
50
- @storage.write_log(@storage.filepath(options[:date]), daily_log)
86
+ txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
87
+ return_val = Editor.open_editor(txt)
51
88
 
52
- WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
53
- end
89
+ @storage.write_log(@storage.filepath(date),
90
+ YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
91
+ WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
92
+ end
93
+
94
+ def show(options = {})
95
+ people = @storage.load_people!
96
+ printer = Printer.new(people)
54
97
 
55
- def edit(options = {})
56
- date = Date.strptime(options[:date], '%Y-%m-%d')
98
+ start_date, end_date = start_end_date(options)
57
99
 
58
- # Load existing log
59
- log = @storage.load_log(@storage.filepath(date))
60
- unless log
61
- WorkLogger.error "No work log found for #{options[:date]}. Aborting."
62
- exit 1
100
+ entries = @storage.days_between(start_date, end_date)
101
+ if entries.empty?
102
+ printer.no_entries(start_date, end_date)
103
+ else
104
+ entries.each do |entry|
105
+ printer.print_day(entry, entries.size > 1, options[:epics_only])
106
+ end
107
+ end
63
108
  end
64
109
 
65
- txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
66
- return_val = Editor.open_editor(txt)
110
+ def people(person = nil, _options = {})
111
+ all_people = @storage.load_people!
112
+ people_map = all_people.to_h { |p| [p.handle, p] }
113
+ all_logs = @storage.all_days
67
114
 
68
- @storage.write_log(@storage.filepath(date),
69
- YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
70
- WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
71
- end
115
+ if person
116
+ unless people_map.key?(person)
117
+ WorkLogger.error Rainbow("No person found with handle #{person}.").red
118
+ return
119
+ end
120
+ person_detail(all_logs, all_people, people_map[person.strip])
121
+ else
122
+ puts 'People mentioned in the work log:'
72
123
 
73
- def show(options = {})
74
- people = @storage.load_people!
75
- printer = Printer.new(people)
124
+ mentions = {}
76
125
 
77
- start_date, end_date = start_end_date(options)
126
+ all_logs.map(&:people).each do |people|
127
+ mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
128
+ end
78
129
 
79
- entries = @storage.days_between(start_date, end_date)
80
- if entries.empty?
81
- printer.no_entries(start_date, end_date)
82
- else
83
- entries.each do |entry|
84
- printer.print_day(entry, entries.size > 1, options[:epics_only])
130
+ # Sort the mentions by handle
131
+ mentions = mentions.to_a.sort_by { |handle, _| handle }
132
+
133
+ mentions.each do |handle, v|
134
+ if people_map.key?(handle)
135
+ person = people_map[handle]
136
+ print "#{Rainbow(person.name).gold} (#{handle})"
137
+ print " (#{person.team})" if person.team
138
+ else
139
+ print handle
140
+ end
141
+ puts ": #{v} #{pluralize(v, 'occurrence')}"
142
+ end
85
143
  end
86
144
  end
87
- end
88
145
 
89
- def people(person = nil, _options = {})
90
- all_people = @storage.load_people!
91
- people_map = all_people.to_h { |p| [p.handle, p] }
92
- all_logs = @storage.all_days
146
+ def person_detail(all_logs, all_people, person)
147
+ printer = Printer.new(all_people)
148
+ puts "All interactions with #{Rainbow(person.name).gold}"
93
149
 
94
- if person
95
- unless people_map.key?(person)
96
- WorkLogger.error Rainbow("No person found with handle #{person}.").red
97
- return
150
+ if person.notes
151
+ puts 'Notes:'
152
+ person.notes.each do |note|
153
+ puts "* #{note}"
154
+ end
98
155
  end
99
- person_detail(all_logs, all_people, people_map[person.strip])
100
- else
101
- puts 'People mentioned in the work log:'
102
-
103
- mentions = {}
104
156
 
105
- all_logs.map(&:people).each do |people|
106
- mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
157
+ puts 'Interactions:'
158
+ all_logs.each do |daily_log|
159
+ daily_log.entries.each do |entry|
160
+ printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
161
+ end
107
162
  end
163
+ end
108
164
 
109
- # Sort the mentions by handle
110
- mentions = mentions.to_a.sort_by { |handle, _| handle }
165
+ def projects(_options = {})
166
+ project_storage = ProjectStorage.new(@config)
167
+ projects = project_storage.load_projects
168
+ puts Rainbow('Projects:').gold
169
+ projects.each_value do |project|
170
+ puts "#{Rainbow(project.name).gold} (#{project.key})"
171
+ puts " Description: #{project.description}" if project.description
172
+ puts " Start date: #{project.start_date}" if project.start_date
173
+ puts " End date: #{project.end_date}" if project.end_date
174
+ puts " Status: #{project.status}" if project.status
175
+ end
176
+ puts 'No projects found.' if projects.empty?
177
+ end
111
178
 
112
- mentions.each do |handle, v|
113
- if people_map.key?(handle)
114
- print "#{Rainbow(people_map[handle].name).gold} (#{handle})"
115
- else
116
- print handle
117
- end
118
- puts ": #{v} #{pluralize(v, 'occurrence')}"
179
+ # Show all tags used in the work log or details for a specific tag
180
+ #
181
+ # @param tag [String, nil] the tag to show details for, or nil to show all tags
182
+ # @param options [Hash] the options hash containing date range
183
+ # @return [void]
184
+ #
185
+ # @example
186
+ # worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
187
+ # worklog.tags(nil) # Show all tags for all time
188
+ def tags(tag = nil, options = {})
189
+ if tag.nil? || tag.empty?
190
+ tag_overview
191
+ else
192
+ tag_detail(tag, options)
119
193
  end
120
194
  end
121
- end
122
195
 
123
- def person_detail(all_logs, all_people, person)
124
- printer = Printer.new(all_people)
125
- puts "All interactions with #{Rainbow(person.name).gold}"
196
+ def tag_overview
197
+ all_logs = @storage.all_days
198
+ puts Rainbow('Tags used in the work log:').gold
126
199
 
127
- if person.notes
128
- puts 'Notes:'
129
- person.notes.each do |note|
130
- puts "* #{note}"
131
- end
200
+ # Count all tags used in the work log
201
+ tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
202
+
203
+ # Determine length of longest tag for formatting
204
+ # Add one additonal space for formatting
205
+ max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1
206
+
207
+ tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
132
208
  end
133
209
 
134
- puts 'Interactions:'
135
- all_logs.each do |daily_log|
136
- daily_log.entries.each do |entry|
137
- printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
210
+ # Show detailed information about a specific tag
211
+ #
212
+ # @param tag [String] the tag to show details for
213
+ # @param options [Hash] the options hash containing date range
214
+ # @return [void]
215
+ #
216
+ # @example
217
+ # worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
218
+ def tag_detail(tag, options)
219
+ printer = Printer.new(@storage.load_people!)
220
+ start_date, end_date = start_end_date(options)
221
+
222
+ @storage.days_between(start_date, end_date).each do |daily_log|
223
+ next unless daily_log.tags.include?(tag)
224
+
225
+ daily_log.entries.each do |entry|
226
+ next unless entry.tags.include?(tag)
227
+
228
+ printer.print_entry(daily_log, entry, true)
229
+ end
138
230
  end
139
231
  end
140
- end
141
232
 
142
- # Show all tags used in the work log or details for a specific tag
143
- #
144
- # @param tag [String, nil] the tag to show details for, or nil to show all tags
145
- # @param options [Hash] the options hash containing date range
146
- # @return [void]
147
- #
148
- # @example
149
- # worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
150
- # worklog.tags(nil) # Show all tags for all time
151
- def tags(tag = nil, options = {})
152
- if tag.nil? || tag.empty?
153
- tag_overview
154
- else
155
- tag_detail(tag, options)
233
+ def stats(_options = {})
234
+ stats = Statistics.new(@config).calculate
235
+ puts "#{format_left('Total days')}: #{stats.total_days}"
236
+ puts "#{format_left('Total entries')}: #{stats.total_entries}"
237
+ puts "#{format_left('Total epics')}: #{stats.total_epics}"
238
+ puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
239
+ puts "#{format_left('First entry')}: #{stats.first_entry}"
240
+ puts "#{format_left('Last entry')}: #{stats.last_entry}"
156
241
  end
157
- end
158
242
 
159
- def tag_overview
160
- all_logs = @storage.all_days
161
- puts Rainbow('Tags used in the work log:').gold
243
+ def summary(options = {})
244
+ start_date, end_date = start_end_date(options)
245
+ entries = @storage.days_between(start_date, end_date).map(&:entries).flatten
162
246
 
163
- # Count all tags used in the work log
164
- tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
247
+ # Do nothing if no entries are found.
248
+ if entries.empty?
249
+ Printer.new.no_entries(start_date, end_date)
250
+ return
251
+ end
165
252
 
166
- # Determine length of longest tag for formatting
167
- # Add one additonal space for formatting
168
- max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1
253
+ # List all the epics
254
+ epics = entries.filter(&:epic)
255
+ puts Rainbow("Found #{epics.size} epics.").green if epics.any?
256
+ epics.each do |entry|
257
+ puts "#{entry.time.strftime('%b %d, %Y')} #{entry.message}"
258
+ end
169
259
 
170
- tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
171
- end
260
+ # List all the tags and their count
261
+ tags = entries.map(&:tags).flatten.compact.tally
262
+ puts Rainbow("Found #{tags.size} tags.").green if tags.any?
263
+ tags.each do |tag, count|
264
+ print "#{tag} (#{count}x), "
265
+ end
266
+ puts '' if tags.any?
172
267
 
173
- # Show detailed information about a specific tag
174
- #
175
- # @param tag [String] the tag to show details for
176
- # @param options [Hash] the options hash containing date range
177
- # @return [void]
178
- #
179
- # @example
180
- # worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
181
- def tag_detail(tag, options)
182
- printer = Printer.new(@storage.load_people!)
183
- start_date, end_date = start_end_date(options)
184
-
185
- @storage.days_between(start_date, end_date).each do |daily_log|
186
- next unless daily_log.tags.include?(tag)
187
-
188
- daily_log.entries.each do |entry|
189
- next unless entry.tags.include?(tag)
190
-
191
- printer.print_entry(daily_log, entry, true)
268
+ # List all the people and their count
269
+ people = entries.map(&:people).flatten.compact.tally.sort_by { |_, count| -count }.filter { |_, count| count > 1 }
270
+ puts Rainbow("Found #{people.size} people.").green if people.any?
271
+ people.each do |person, count|
272
+ print "#{person} (#{count}x), "
192
273
  end
274
+ puts '' if people.any?
275
+
276
+ # # Print the summary
277
+ # summary = Summary.new(entries)
278
+ # puts summary.to_s
193
279
  end
194
- end
195
280
 
196
- def stats(_options = {})
197
- stats = Statistics.new(@config).calculate
198
- puts "#{format_left('Total days')}: #{stats.total_days}"
199
- puts "#{format_left('Total entries')}: #{stats.total_entries}"
200
- puts "#{format_left('Total epics')}: #{stats.total_epics}"
201
- puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
202
- puts "#{format_left('First entry')}: #{stats.first_entry}"
203
- puts "#{format_left('Last entry')}: #{stats.last_entry}"
204
- end
281
+ def remove(options = {})
282
+ date = Date.strptime(options[:date], '%Y-%m-%d')
283
+ unless File.exist?(@storage.filepath(date))
284
+ WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
285
+ exit 1
286
+ end
205
287
 
206
- def summary(options = {})
207
- start_date, end_date = start_end_date(options)
208
- entries = @storage.days_between(start_date, end_date).map(&:entries).flatten
288
+ daily_log = @storage.load_log!(@storage.filepath(options[:date]))
289
+ if daily_log.entries.empty?
290
+ WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
291
+ exit 1
292
+ end
209
293
 
210
- # Do nothing if no entries are found.
211
- if entries.empty?
212
- Printer.new.no_entries(start_date, end_date)
213
- return
294
+ removed_entry = daily_log.entries.pop
295
+ @storage.write_log(@storage.filepath(date), daily_log)
296
+ WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
214
297
  end
215
- puts Summary.new(@config).generate_summary(entries)
216
- end
217
298
 
218
- def remove(options = {})
219
- date = Date.strptime(options[:date], '%Y-%m-%d')
220
- unless File.exist?(@storage.filepath(date))
221
- WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
222
- exit 1
299
+ # Start webserver
300
+ def server
301
+ app = WorkLogApp.new(@storage)
302
+ WorkLogServer.new(app).start
223
303
  end
224
304
 
225
- daily_log = @storage.load_log!(@storage.filepath(options[:date]))
226
- if daily_log.entries.empty?
227
- WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
228
- exit 1
305
+ # Parse the start and end date based on the options provided
306
+ #
307
+ # @param options [Hash] the options hash
308
+ # @return [Array] the start and end date as an array
309
+ def start_end_date(options)
310
+ if options[:days]
311
+ # Safeguard against negative days
312
+ raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
313
+
314
+ start_date = Date.today - options[:days]
315
+ end_date = Date.today
316
+ elsif options[:from]
317
+ start_date = DateParser.parse_date_string!(options[:from], true)
318
+ end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
319
+ elsif options[:date]
320
+ start_date = Date.strptime(options[:date], '%Y-%m-%d')
321
+ end_date = start_date
322
+ else
323
+ raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
324
+ end
325
+ [start_date, end_date]
229
326
  end
230
327
 
231
- removed_entry = daily_log.entries.pop
232
- @storage.write_log(@storage.filepath(date), daily_log)
233
- WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
234
- end
235
-
236
- # Start webserver
237
- def server
238
- app = WorkLogApp.new(@storage)
239
- WorkLogServer.new(app).start
240
- end
328
+ def validate_projects!(project_key)
329
+ project_storage = ProjectStorage.new(@config)
330
+ begin
331
+ projects = project_storage.load_projects
332
+ rescue Errno::ENOENT
333
+ raise ProjectNotFoundError, 'No projects found. Please create a project first.'
334
+ end
335
+ return if projects.key?(project_key)
241
336
 
242
- # Parse the start and end date based on the options provided
243
- #
244
- # @param options [Hash] the options hash
245
- # @return [Array] the start and end date as an array
246
- def start_end_date(options)
247
- if options[:days]
248
- # Safeguard against negative days
249
- raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
250
-
251
- start_date = Date.today - options[:days]
252
- end_date = Date.today
253
- elsif options[:from]
254
- start_date = DateParser.parse_date_string!(options[:from], true)
255
- end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
256
- elsif options[:date]
257
- start_date = Date.strptime(options[:date], '%Y-%m-%d')
258
- end_date = start_date
259
- else
260
- raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
337
+ raise ProjectNotFoundError, "Project with key '#{project_key}' does not exist."
261
338
  end
262
- [start_date, end_date]
263
339
  end
264
340
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fewald-worklog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.2.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Friedrich Ewald
@@ -131,6 +131,8 @@ files:
131
131
  - lib/log_entry.rb
132
132
  - lib/person.rb
133
133
  - lib/printer.rb
134
+ - lib/project.rb
135
+ - lib/project_storage.rb
134
136
  - lib/statistics.rb
135
137
  - lib/storage.rb
136
138
  - lib/string_helper.rb