timet 1.6.1.1 → 1.6.3

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.
@@ -1,189 +1,210 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Helper module for formatting tag distribution displays
3
4
  require_relative 'time_statistics'
4
- require_relative 'color_codes' # Ensure color methods are available
5
+ require_relative 'color_codes'
6
+
5
7
  module Timet
6
- # The TagDistribution module provides functionality to format and display the distribution of tags based on their
7
- # durations. This is particularly useful for visualizing how time is distributed across different tags in a project
8
- # or task management system.
9
- module TagDistribution
10
- MAX_BAR_LENGTH = 70
11
- BLOCK_CHAR = '▅'
12
- TAG_SIZE = 12
8
+ # Helper module for formatting tag distribution displays
9
+ module TagDistributionFormatting
10
+ module_function
13
11
 
14
- # Formats and displays the tag distribution.
15
- #
16
- # @param duration_by_tag [Hash<String, Integer>] A hash where keys are tags and values are durations in seconds.
17
- # @param colors [Hash<String, String>] A hash where keys are tags and values are color codes for display.
18
- # @return [void] This method outputs the formatted tag distribution to the console.
19
- #
20
- # @example
21
- # duration_by_tag = { "timet" => 3600, "nextjs" => 1800 }
22
- # colors = { "timet" => "\e[31m", "nextjs" => "\e[32m" }
23
- # Formatter.format_tag_distribution(duration_by_tag, colors)
24
- # # Output:
25
- # # \e[31m timet: 66.67% ==================== \e[0m
26
- # # \e[32m nextjs: 33.33% ========== \e[0m
27
- def tag_distribution(colors)
28
- time_stats = TimeStatistics.new(@items)
29
- total = time_stats.total_duration
12
+ def calculate_value_and_bar_length(duration, total)
13
+ value = duration.to_f / total
14
+ percentage_value = (value * 100).round(1)
15
+ bar_length = (value * TagDistribution::MAX_BAR_LENGTH).round
16
+ [percentage_value, bar_length]
17
+ end
30
18
 
31
- return unless total.positive?
19
+ def generate_horizontal_bar(bar_length, color_index)
20
+ (TagDistribution::BLOCK_CHAR * bar_length).to_s.color(color_index + 1)
21
+ end
32
22
 
33
- process_and_print_tags(time_stats, total, colors)
23
+ def generate_stats(tag, time_stats)
24
+ total_hours = (time_stats.total_duration_by_tag[tag] / 3600.0).round(1)
25
+ avg_minutes = (time_stats.average_by_tag[tag] / 60.0).round(1)
26
+ sd_minutes = (time_stats.standard_deviation_by_tag[tag] / 60).round(1)
27
+ "T: #{total_hours}h, AVG: #{avg_minutes}min SD: #{sd_minutes}min".gray
34
28
  end
35
29
 
36
- # Processes and prints the tag distribution information.
37
- #
38
- # @param time_stats [Object] An object containing the time statistics, including totals and sorted durations by tag.
39
- # @param total [Numeric] The total duration of all tags combined.
40
- # @param colors [Object] An object containing color formatting methods.
41
- # @return [void] This method outputs the tag distribution information to the standard output.
42
- def process_and_print_tags(time_stats, total, colors)
43
- print_summary(time_stats, total)
44
- print_tags_info(time_stats, total, colors)
45
- print_footer
30
+ def introduction(total)
31
+ total_hours = (total / 3600.0).round(1)
32
+ [
33
+ "\n---",
34
+ 'Time Report Summary'.bold,
35
+ 'This report provides a detailed breakdown of time spent ' \
36
+ "across various categories, totaling #{"#{total_hours}h".bold} of tracked work.".white,
37
+ "\n"
38
+ ]
46
39
  end
47
40
 
48
- # Prints the footer information.
49
- #
50
- # @return [void] This method outputs the footer information to the standard output.
51
- def print_footer
52
- puts '-' * 45
53
- puts 'T:'.rjust(4).red + 'The total duration'.gray
54
- puts 'AVG:'.rjust(4).red + 'The average duration'.gray
55
- puts 'SD:'.rjust(4).red + 'The standard deviation of the durations'.gray
41
+ def build_duration_part(duration)
42
+ total_hours = (duration / 3600.0).round(1)
43
+ " The cumulative time spent was #{"#{total_hours}h".bold}, " \
44
+ 'indicating the overall effort dedicated to this area.'
56
45
  end
57
46
 
58
- # Generates and prints an explanation of the time report based on tag distribution.
59
- #
60
- # @param time_stats [Object] An object containing the time statistics.
61
- # @param total [Numeric] The total duration of all tags combined in seconds.
62
- # @return [void] This method outputs the explanation to the standard output.
63
- def print_explanation(time_stats, total)
64
- explanations = []
65
- high_sd_threshold = 0.5
66
- moderate_sd_threshold = 0.2
67
-
68
- # --- Introduction ---
69
- total_duration_hours = (total / 3600.0).round(1)
70
- explanations << "\n---"
71
- explanations << 'Time Report Summary'.bold
72
- explanations << "This report provides a detailed breakdown of time spent across various categories, totaling #{"#{total_duration_hours}h".bold} of tracked work.".white
73
- explanations << "\n"
74
-
75
- # --- Individual Category Explanations ---
76
- explanations << 'Category Breakdown'.bold
77
- time_stats.sorted_duration_by_tag.each do |tag, duration|
78
- explanation = "#{"#{tag.capitalize}".bold}:"
79
-
80
- # Percentage
81
- percentage = (duration.to_f / total * 100).round(1)
82
- explanation += " This category consumed #{"#{percentage}%".bold} of the total tracked time."
83
-
84
- # Total Duration
85
- total_hours = (duration / 3600.0).round(1)
86
- explanation += " The cumulative time spent was #{"#{total_hours}h".bold}, indicating the overall effort dedicated to this area."
87
-
88
- # Average Duration
89
- avg_minutes = (time_stats.average_by_tag[tag] / 60.0).round(1)
90
- explanation += " On average, each task took #{"#{avg_minutes}min".bold}, which helps in understanding the typical time commitment per task."
91
-
92
- # Standard Deviation
93
- sd_minutes = (time_stats.standard_deviation_by_tag[tag] / 60.0).round(1)
94
- avg_duration_seconds = time_stats.average_by_tag[tag]
95
-
96
- if sd_minutes > avg_duration_seconds / 60.0 * high_sd_threshold
97
- explanation += " A high standard deviation of #{"#{sd_minutes}min".bold} relative to the average suggests significant variability in task durations. This could imply inconsistent task definitions, varying complexity, or frequent interruptions.".red
98
- elsif sd_minutes > avg_duration_seconds / 60.0 * moderate_sd_threshold
99
- explanation += " A moderate standard deviation of #{"#{sd_minutes}min".bold} indicates some variation in task durations.".blue
100
- else
101
- explanation += " A low standard deviation of #{"#{sd_minutes}min".bold} suggests that task durations were quite consistent and predictable.".green
102
- end
47
+ def build_average_part(metrics)
48
+ " On average, each task took #{"#{metrics[:avg_minutes]}min".bold}, " \
49
+ 'which helps in understanding the typical time commitment per task.'
50
+ end
51
+
52
+ def high_sd_message(sd_min)
53
+ " A high standard deviation of #{"#{sd_min}min".bold} " \
54
+ 'relative to the average suggests significant variability in task durations. ' \
55
+ 'This could imply inconsistent task definitions, varying complexity, ' \
56
+ 'or frequent interruptions.'.red
57
+ end
58
+
59
+ def moderate_sd_message(sd_min)
60
+ " A moderate standard deviation of #{"#{sd_min}min".bold} " \
61
+ 'indicates some variation in task durations.'.blue
62
+ end
63
+
64
+ def low_sd_message(sd_min)
65
+ " A low standard deviation of #{"#{sd_min}min".bold} " \
66
+ 'suggests that task durations were quite consistent and predictable.'.green
67
+ end
68
+
69
+ def build_major_summary(major_categories)
70
+ total_percentage = major_categories.sum(&:last)
71
+ category_names = major_categories.map { |c, _| "'#{c.capitalize}'" }.join(' and ')
72
+ ["\nTogether, #{category_names} dominate the time spent, " \
73
+ "accounting for nearly #{"#{total_percentage.round}%".bold} of the total.".white]
74
+ end
75
+ end
76
+
77
+ # The TagDistribution module provides functionality to format and display
78
+ # the distribution of tags based on their durations.
79
+ module TagDistribution
80
+ MAX_BAR_LENGTH = 70
81
+ BLOCK_CHAR = '▅'
82
+ TAG_SIZE = 12
83
+
84
+ # Context object for tag distribution formatting
85
+ Context = Struct.new(:time_stats, :total, :colors) do
86
+ include TagDistributionFormatting
87
+
88
+ # Formats the overall average and standard deviation from the time statistics.
89
+ # @return [Hash] A hash with :avg and :sd keys, values in minutes rounded to 1 decimal place.
90
+ def format_avg
91
+ time_stats.totals.slice(:avg, :sd).transform_values { |val| (val / 60.0).round(1) }
92
+ end
103
93
 
104
- explanations << explanation.white
94
+ # Formats the total duration in hours.
95
+ # @return [Float] Total duration in hours rounded to 1 decimal place.
96
+ def format_total_hours
97
+ (total / 3600.0).round(1)
105
98
  end
106
99
 
107
- # --- Overall Summary ---
108
- if time_stats.sorted_duration_by_tag.any?
109
- sorted_categories = time_stats.sorted_duration_by_tag.map do |tag, duration|
110
- [tag, (duration.to_f / total * 100).round(1)]
111
- end.sort_by { |_, percentage| -percentage }
112
-
113
- major_categories = sorted_categories.select { |_, percentage| percentage > 10 }
114
- if major_categories.size > 1
115
- total_percentage = major_categories.sum { |_, percentage| percentage }
116
- category_names = major_categories.map { |c, _| "'#{c.capitalize}'" }.join(' and ')
117
- explanations << "\nTogether, #{category_names} dominate the time spent, accounting for nearly #{"#{total_percentage.round}%".bold} of the total.".white
100
+ # Calculates metrics for a specific tag.
101
+ # @param tag [String] The tag name.
102
+ # @param duration [Numeric] The duration for the tag.
103
+ # @return [Hash] A hash of metrics for the tag.
104
+ def tag_metrics(tag, duration)
105
+ stats = time_stats
106
+ value = duration.to_f / total
107
+ avg_sec = stats.average_by_tag[tag]
108
+ sd_sec = stats.standard_deviation_by_tag[tag]
109
+
110
+ {
111
+ tag: tag, duration: duration, percentage: (value * 100).round(1),
112
+ avg_minutes: (avg_sec / 60.0).round(1), sd_minutes: (sd_sec / 60.0).round(1),
113
+ avg_seconds: avg_sec, sd_seconds: sd_sec, value: value
114
+ }
115
+ end
116
+
117
+ def category_breakdown
118
+ time_stats.sorted_duration_by_tag.each_with_object(['Category Breakdown'.bold]) do |(tag, duration), parts|
119
+ parts << build_category_explanation(tag, duration).white
118
120
  end
119
121
  end
120
- puts explanations.join("\n")
121
- end
122
122
 
123
- # Prints the summary information including total duration, average duration, and standard deviation.
124
- #
125
- # @param time_stats [Object] An object containing the time statistics, including totals.
126
- # @param total [Numeric] The total duration of all tags combined.
127
- # @return [void] This method outputs the summary information to the standard output.
128
- def print_summary(time_stats, total)
129
- avg = (time_stats.totals[:avg] / 60.0).round(1)
130
- sd = (time_stats.totals[:sd] / 60.0).round(1)
131
- summary = "#{' ' * TAG_SIZE} #{'Summary'.underline}: "
132
- summary += "[T: #{(total / 3600.0).round(1)}h, AVG: #{avg}min SD: #{sd}min]".white
133
- puts summary
134
- end
123
+ def build_category_explanation(tag, duration)
124
+ metrics = tag_metrics(tag, duration)
125
+ [
126
+ "#{tag.capitalize.to_s.bold}:",
127
+ " This category consumed #{"#{metrics[:percentage]}%".bold} of the total tracked time.",
128
+ build_duration_part(duration), build_average_part(metrics),
129
+ sd_variation_message(metrics)
130
+ ].join
131
+ end
132
+
133
+ def sd_variation_message(metrics)
134
+ sd_min = metrics[:sd_minutes]
135
+ avg_hour = metrics[:avg_minutes] / 60.0
136
+ return high_sd_message(sd_min) if sd_min > avg_hour * 0.5
137
+ return moderate_sd_message(sd_min) if sd_min > avg_hour * 0.2
135
138
 
136
- # Prints the detailed information for each tag.
137
- #
138
- # @param time_stats [Object] An object containing the time statistics, including sorted durations by tag.
139
- # @param total [Numeric] The total duration of all tags combined.
140
- # @param colors [Object] An object containing color formatting methods.
141
- # @return [void] This method outputs the detailed tag information to the standard output.
142
- def print_tags_info(time_stats, total, colors)
143
- time_stats.sorted_duration_by_tag.each do |tag, duration|
144
- value, bar_length = calculate_value_and_bar_length(duration, total)
145
- horizontal_bar = generate_horizontal_bar(bar_length, colors[tag])
146
- formatted_tag = tag[0...TAG_SIZE].rjust(TAG_SIZE)
147
- stats = generate_stats(tag, time_stats)
148
-
149
- puts "#{formatted_tag}: #{value.to_s.rjust(5)}% #{horizontal_bar} [#{stats}]"
139
+ low_sd_message(sd_min)
150
140
  end
151
- end
152
141
 
153
- # Generates a horizontal bar for display based on the bar length and color index.
154
- #
155
- # @param bar_length [Numeric] The length of the bar to generate.
156
- # @param color_index [Numeric] The color index to use for the bar.
157
- # @return [String] The generated horizontal bar string.
158
- def generate_horizontal_bar(bar_length, color_index)
159
- (BLOCK_CHAR * bar_length).to_s.color(color_index + 1)
142
+ def build_sorted_categories
143
+ time_stats.sorted_duration_by_tag.map do |tag, duration|
144
+ [tag, tag_metrics(tag, duration)[:percentage]]
145
+ end.sort_by(&:last).reverse
146
+ end
147
+
148
+ def overall_summary
149
+ return [] unless time_stats.sorted_duration_by_tag.any?
150
+
151
+ major_categories = build_sorted_categories.select { |_, pct| pct > 10 }
152
+ return [] if major_categories.size <= 1
153
+
154
+ build_major_summary(major_categories)
155
+ end
156
+
157
+ def print_summary
158
+ f_avg = format_avg
159
+ summary = "#{' ' * TAG_SIZE} #{'Summary'.underline}: "
160
+ summary += "[T: #{format_total_hours}, AVG: #{f_avg[:avg]}min SD: #{f_avg[:sd]}min]".white
161
+ puts summary
162
+ end
163
+
164
+ def print_explanation
165
+ parts = []
166
+ parts << introduction(total)
167
+ parts << category_breakdown
168
+ parts << overall_summary
169
+ puts parts.flatten.join("\n")
170
+ end
171
+
172
+ def print_tags_info
173
+ stats = time_stats
174
+ stats.sorted_duration_by_tag.each do |tag, duration|
175
+ value, bar_length = calculate_value_and_bar_length(duration, total)
176
+ horizontal_bar = generate_horizontal_bar(bar_length, colors[tag])
177
+ formatted_tag = tag[0...TAG_SIZE].rjust(TAG_SIZE)
178
+ tag_stats = generate_stats(tag, stats)
179
+
180
+ puts "#{formatted_tag}: #{value.to_s.rjust(5)}% #{horizontal_bar} [#{tag_stats}]"
181
+ end
182
+ end
183
+
184
+ def render
185
+ print_summary
186
+ print_tags_info
187
+ end
160
188
  end
161
189
 
162
- # Generates the statistics string for a given tag.
163
- #
164
- # @param tag [String] The tag for which to generate the statistics.
165
- # @param time_stats [Object] An object containing time statistics for the tags.
166
- # @return [String] The generated statistics string.
167
- def generate_stats(tag, time_stats)
168
- total_hours = (time_stats.total_duration_by_tag[tag] / 3600.0).round(1)
169
- avg_minutes = (time_stats.average_by_tag[tag] / 60.0).round(1)
170
- sd_minutes = (time_stats.standard_deviation_by_tag[tag] / 60).round(1)
171
- "T: #{total_hours}h, AVG: #{avg_minutes}min SD: #{sd_minutes}min".gray
190
+ include TagDistributionFormatting
191
+
192
+ def tag_distribution(colors)
193
+ time_stats = TimeStatistics.new(@items)
194
+ total = time_stats.total_duration
195
+
196
+ return unless total.positive?
197
+
198
+ ctx = Context.new(time_stats, total, colors)
199
+ ctx.render
200
+ print_footer
172
201
  end
173
202
 
174
- # Calculates the percentage value and bar length for a given duration and total duration.
175
- #
176
- # @param duration [Numeric] The duration for the current tag.
177
- # @param total [Numeric] The total duration.
178
- # @return [Array<(Float, Integer)>] An array containing the calculated value and bar length.
179
- #
180
- # @example
181
- # calculate_value_and_bar_length(50, 100, 2) #=> [50.0, 25]
182
- def calculate_value_and_bar_length(duration, total)
183
- value = duration.to_f / total
184
- percentage_value = (value * 100).round(1)
185
- bar_length = (value * MAX_BAR_LENGTH).round
186
- [percentage_value, bar_length]
203
+ def print_footer
204
+ puts '-' * 45
205
+ puts 'T:'.rjust(4).red + 'The total duration'.gray
206
+ puts 'AVG:'.rjust(4).red + 'The average duration'.gray
207
+ puts 'SD:'.rjust(4).red + 'The standard deviation of the durations'.gray
187
208
  end
188
209
  end
189
210
  end
@@ -278,9 +278,24 @@ module Timet
278
278
  def self.update_time_field(item, field, new_time)
279
279
  field_index = Timet::Application::FIELD_INDEX[field]
280
280
  timestamp = item[field_index]
281
- edit_time = Time.at(timestamp || item[1]).to_s.split
282
- edit_time[1] = new_time
283
- DateTime.strptime(edit_time.join(' '), '%Y-%m-%d %H:%M:%S %z').to_time
281
+ base_time = Time.at(timestamp || item[1])
282
+
283
+ # Extract hours, minutes, and seconds from the new_time string
284
+ # Supports format "HH:MM:SS" or "HH:MM"
285
+ parts = new_time.split(':').map(&:to_i)
286
+ hours = parts[0]
287
+ minutes = parts[1]
288
+ seconds = parts[2] || 0
289
+
290
+ Time.new(
291
+ base_time.year,
292
+ base_time.month,
293
+ base_time.day,
294
+ hours,
295
+ minutes,
296
+ seconds,
297
+ base_time.utc_offset
298
+ )
284
299
  end
285
300
  end
286
301
  end
@@ -3,7 +3,6 @@
3
3
  require 'date'
4
4
  require 'csv'
5
5
  require 'icalendar'
6
- require_relative 'time_report_helper'
7
6
  require_relative 'table'
8
7
  require_relative 'time_block_chart'
9
8
  require_relative 'tag_distribution'
@@ -13,7 +12,6 @@ module Timet
13
12
  # entries. It allows filtering the report by time periods and displays
14
13
  # a formatted table with the relevant information.
15
14
  class TimeReport
16
- include TimeReportHelper
17
15
  include TagDistribution
18
16
 
19
17
  # Provides access to the database instance.
@@ -103,7 +101,10 @@ module Timet
103
101
  def print_tag_explanation_report
104
102
  time_stats = TimeStatistics.new(@items)
105
103
  total = time_stats.total_duration
106
- print_explanation(time_stats, total) if total.positive?
104
+ return unless total.positive?
105
+
106
+ ctx = TagDistribution::Context.new(time_stats, total, nil)
107
+ ctx.print_explanation
107
108
  end
108
109
 
109
110
  # Displays a single row of the report.
@@ -133,6 +134,19 @@ module Timet
133
134
  @table.total
134
135
  end
135
136
 
137
+ def export_csv
138
+ file_name = "#{csv_filename}.csv"
139
+ write_csv(file_name)
140
+ puts "The #{file_name} has been exported."
141
+ end
142
+
143
+ def export_icalendar
144
+ file_name = "#{ics_filename}.ics"
145
+ cal = Timet::Utils.add_events(items)
146
+ File.write(file_name, cal.to_ical)
147
+ puts "The #{file_name} has been generated."
148
+ end
149
+
136
150
  private
137
151
 
138
152
  # Writes the items to a CSV file.
data/lib/timet/utils.rb CHANGED
@@ -23,12 +23,12 @@ module Timet
23
23
  end
24
24
  end
25
25
 
26
- # Converts a timestamp to a DateTime object.
26
+ # Converts a timestamp to a Time object.
27
27
  #
28
28
  # @param timestamp [Integer] the timestamp to convert
29
- # @return [DateTime] the converted DateTime object
30
- def self.convert_to_datetime(timestamp)
31
- Time.at(timestamp).to_datetime
29
+ # @return [Time] the converted Time object
30
+ def self.convert_to_time(timestamp)
31
+ Time.at(timestamp)
32
32
  end
33
33
 
34
34
  # Provides predefined date ranges for filtering.
@@ -82,8 +82,8 @@ module Timet
82
82
  #
83
83
  # @note The method validates the date format for single dates and date ranges.
84
84
  def self.valid_date_format?(date_string)
85
- date_format_single = /^\d{4}-\d{2}-\d{2}$/
86
- date_format_range = /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/
85
+ date_format_single = /\A\d{4}-\d{2}-\d{2}\z/
86
+ date_format_range = /\A\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}\z/
87
87
 
88
88
  date_string.match?(date_format_single) || date_string.match?(date_format_range)
89
89
  end
@@ -93,8 +93,8 @@ module Timet
93
93
  # @param event [Icalendar::Event] the event object
94
94
  # @param item [Array] the item containing event details
95
95
  def self.assign_event_attributes(event, item)
96
- dtstart = convert_to_datetime(item[1])
97
- dtend = convert_to_datetime(item[2] || TimeHelper.current_timestamp)
96
+ dtstart = convert_to_time(item[1])
97
+ dtend = convert_to_time(item[2] || TimeHelper.current_timestamp)
98
98
 
99
99
  event.dtstart = dtstart
100
100
  event.dtend = dtend