jirametrics 2.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +19 -26
  3. data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
  5. data/lib/jirametrics/aging_work_table.rb +84 -54
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +51 -23
  9. data/lib/jirametrics/board_config.rb +9 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  11. data/lib/jirametrics/change_item.rb +56 -21
  12. data/lib/jirametrics/chart_base.rb +101 -61
  13. data/lib/jirametrics/columns_config.rb +4 -0
  14. data/lib/jirametrics/css_variable.rb +33 -0
  15. data/lib/jirametrics/cycletime_config.rb +59 -8
  16. data/lib/jirametrics/cycletime_histogram.rb +69 -4
  17. data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
  18. data/lib/jirametrics/daily_view.rb +277 -0
  19. data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
  20. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  21. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  22. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  23. data/lib/jirametrics/data_quality_report.rb +222 -41
  24. data/lib/jirametrics/dependency_chart.rb +54 -23
  25. data/lib/jirametrics/download_config.rb +12 -0
  26. data/lib/jirametrics/downloader.rb +86 -56
  27. data/lib/jirametrics/estimate_accuracy_chart.rb +173 -0
  28. data/lib/jirametrics/estimation_configuration.rb +25 -0
  29. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  30. data/lib/jirametrics/examples/standard_project.rb +26 -48
  31. data/lib/jirametrics/expedited_chart.rb +28 -25
  32. data/lib/jirametrics/exporter.rb +59 -32
  33. data/lib/jirametrics/file_config.rb +35 -14
  34. data/lib/jirametrics/file_system.rb +48 -3
  35. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  36. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  37. data/lib/jirametrics/grouping_rules.rb +7 -1
  38. data/lib/jirametrics/hierarchy_table.rb +4 -4
  39. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  40. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  41. data/lib/jirametrics/html/aging_work_table.erb +21 -25
  42. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  43. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  44. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  45. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  46. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  47. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  48. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  49. data/lib/jirametrics/html/index.css +280 -0
  50. data/lib/jirametrics/html/index.erb +33 -39
  51. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  52. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  53. data/lib/jirametrics/html_report_config.rb +110 -86
  54. data/lib/jirametrics/issue.rb +390 -109
  55. data/lib/jirametrics/issue_collection.rb +33 -0
  56. data/lib/jirametrics/jira_gateway.rb +33 -12
  57. data/lib/jirametrics/project_config.rb +276 -147
  58. data/lib/jirametrics/rules.rb +2 -2
  59. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  60. data/lib/jirametrics/settings.json +11 -0
  61. data/lib/jirametrics/sprint.rb +1 -0
  62. data/lib/jirametrics/sprint_burndown.rb +59 -40
  63. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  64. data/lib/jirametrics/status.rb +84 -19
  65. data/lib/jirametrics/status_collection.rb +86 -39
  66. data/lib/jirametrics/throughput_chart.rb +12 -4
  67. data/lib/jirametrics/user.rb +12 -0
  68. data/lib/jirametrics/value_equality.rb +2 -2
  69. data/lib/jirametrics.rb +29 -7
  70. metadata +20 -17
  71. data/lib/jirametrics/discard_changes_before.rb +0 -37
  72. data/lib/jirametrics/experimental/generator.rb +0 -210
  73. data/lib/jirametrics/experimental/info.rb +0 -77
  74. data/lib/jirametrics/html/data_quality_report.erb +0 -126
  75. data/lib/jirametrics/story_point_accuracy_chart.rb +0 -134
@@ -1,51 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author
5
- attr_accessor :value, :old_value, :time
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
5
+ attr_accessor :value, :old_value
6
6
 
7
- def initialize raw:, time:, author:, artificial: false
8
- # raw will only ever be nil in a test and in that case field and value should be passed in
7
+ def initialize raw:, author_raw:, time:, artificial: false
9
8
  @raw = raw
9
+ @author_raw = author_raw
10
10
  @time = time
11
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
11
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
12
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
12
13
 
13
- @field = field || @raw['field']
14
- @value = value || @raw['toString']
14
+ @field = @raw['field']
15
+ @value = @raw['toString']
15
16
  @value_id = @raw['to'].to_i
16
17
  @old_value = @raw['fromString']
17
18
  @old_value_id = @raw['from']&.to_i
18
19
  @artificial = artificial
19
- @author = author
20
20
  end
21
21
 
22
- def status? = (field == 'status')
22
+ def author
23
+ @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
24
+ end
23
25
 
24
- def flagged? = (field == 'Flagged')
26
+ def author_icon_url
27
+ @author_raw&.[]('avatarUrls')&.[]('16x16')
28
+ end
25
29
 
30
+ def artificial? = @artificial
31
+ def assignee? = (field == 'assignee')
32
+ def comment? = (field == 'comment')
33
+ def due_date? = (field == 'duedate')
34
+ def flagged? = (field == 'Flagged')
35
+ def issue_type? = field == 'issuetype'
36
+ def labels? = (field == 'labels')
37
+ def link? = (field == 'Link')
26
38
  def priority? = (field == 'priority')
27
-
28
39
  def resolution? = (field == 'resolution')
29
-
30
- def artificial? = @artificial
31
-
32
40
  def sprint? = (field == 'Sprint')
41
+ def status? = (field == 'status')
33
42
 
34
- def story_points? = (field == 'Story Points')
35
-
36
- def link? = (field == 'Link')
43
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
44
+ def to_time = @time
37
45
 
38
46
  def to_s
39
- message = "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: \"#{@time}\""
40
- message += ', artificial' if artificial?
41
- message += ')'
47
+ message = +''
48
+ message << "ChangeItem(field: #{field.inspect}"
49
+ message << ", value: #{value.inspect}"
50
+ message << ':' << value_id.inspect if status?
51
+ if old_value
52
+ message << ", old_value: #{old_value.inspect}"
53
+ message << ':' << old_value_id.inspect if status?
54
+ end
55
+ message << ", time: #{time_to_s(@time).inspect}"
56
+ message << ', artificial' if artificial?
57
+ message << ')'
42
58
  message
43
59
  end
44
60
 
45
61
  def inspect = to_s
46
62
 
47
63
  def == other
48
- field.eql?(other.field) && value.eql?(other.value) && time.to_s.eql?(other.time.to_s)
64
+ field.eql?(other.field) && value.eql?(other.value) && time_to_s(time).eql?(time_to_s(other.time))
49
65
  end
50
66
 
51
67
  def current_status_matches *status_names_or_ids
@@ -77,4 +93,23 @@ class ChangeItem
77
93
  end
78
94
  end
79
95
  end
96
+
97
+ def field_as_human_readable
98
+ case @field
99
+ when 'duedate' then 'Due date'
100
+ when 'timeestimate' then 'Time estimate'
101
+ when 'timeoriginalestimate' then 'Time original estimate'
102
+ when 'issuetype' then 'Issue type'
103
+ when 'IssueParentAssociation' then 'Issue parent association'
104
+ else @field.capitalize
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def time_to_s time
111
+ # MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
112
+ # format so that tests work under both environments.
113
+ time.strftime '%Y-%m-%d %H:%M:%S %z'
114
+ end
80
115
  end
@@ -2,25 +2,19 @@
2
2
 
3
3
  class ChartBase
4
4
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
- :time_range, :data_quality, :holiday_dates, :settings
5
+ :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
6
6
  attr_writer :aggregated_project
7
- attr_reader :issues, :canvas_width, :canvas_height
7
+ attr_reader :canvas_width, :canvas_height
8
8
 
9
9
  @@chart_counter = 0
10
10
 
11
11
  def initialize
12
12
  @chart_colors = {
13
- 'dark:Story' => 'green',
14
- 'dark:Task' => 'blue',
15
- 'dark:Bug' => 'orange',
16
- 'dark:Defect' => 'orange',
17
- 'dark:Spike' => '#9400D3', # dark purple
18
- 'light:Story' => '#90EE90',
19
- 'light:Task' => '#87CEFA',
20
- 'light:Bug' => '#ffdab9',
21
- 'light:Defect' => 'orange',
22
- 'light:Epic' => '#fafad2',
23
- 'light:Spike' => '#DDA0DD' # light purple
13
+ 'Story' => CssVariable['--type-story-color'],
14
+ 'Task' => CssVariable['--type-task-color'],
15
+ 'Bug' => CssVariable['--type-bug-color'],
16
+ 'Defect' => CssVariable['--type-bug-color'],
17
+ 'Spike' => CssVariable['--type-spike-color']
24
18
  }
25
19
  @canvas_width = 800
26
20
  @canvas_height = 200
@@ -31,6 +25,11 @@ class ChartBase
31
25
  @aggregated_project
32
26
  end
33
27
 
28
+ def html_directory
29
+ pathname = Pathname.new(File.realpath(__FILE__))
30
+ "#{pathname.dirname}/html"
31
+ end
32
+
34
33
  def render caller_binding, file
35
34
  pathname = Pathname.new(File.realpath(file))
36
35
  basename = pathname.basename.to_s
@@ -39,16 +38,21 @@ class ChartBase
39
38
  # Insert a incrementing chart_id so that all the chart names on the page are unique
40
39
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
41
40
 
42
- @html_directory = "#{pathname.dirname}/html"
43
- erb = ERB.new File.read "#{@html_directory}/#{$1}.erb"
41
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
44
42
  erb.result(caller_binding)
45
43
  end
46
44
 
47
- # Render the file and then wrap it with standard headers and quality checks.
48
- def wrap_and_render caller_binding, file
45
+ def render_top_text caller_binding
49
46
  result = +''
50
47
  result << "<h1>#{@header_text}</h1>" if @header_text
51
48
  result << ERB.new(@description_text).result(caller_binding) if @description_text
49
+ result
50
+ end
51
+
52
+ # Render the file and then wrap it with standard headers and quality checks.
53
+ def wrap_and_render caller_binding, file
54
+ result = +''
55
+ result << render_top_text(caller_binding)
52
56
  result << render(caller_binding, file)
53
57
  result
54
58
  end
@@ -57,8 +61,8 @@ class ChartBase
57
61
  @@chart_counter += 1
58
62
  end
59
63
 
60
- def color_for type:, shade: :dark
61
- @chart_colors["#{shade}:#{type}"] ||= random_color
64
+ def color_for type:
65
+ @chart_colors[type] ||= random_color
62
66
  end
63
67
 
64
68
  def label_days days
@@ -100,7 +104,7 @@ class ChartBase
100
104
  issues_id = next_id
101
105
 
102
106
  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
- erb = ERB.new File.read "#{@html_directory}/collapsible_issues_panel.erb"
107
+ erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
104
108
  erb.result(binding)
105
109
  end
106
110
 
@@ -125,6 +129,21 @@ class ChartBase
125
129
  result
126
130
  end
127
131
 
132
+ def working_days_annotation
133
+ holidays.each_with_index.collect do |range, index|
134
+ <<~TEXT
135
+ holiday#{index}: {
136
+ drawTime: 'beforeDraw',
137
+ type: 'box',
138
+ xMin: '#{range.begin}T00:00:00',
139
+ xMax: '#{range.end}T23:59:59',
140
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
141
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
142
+ },
143
+ TEXT
144
+ end.join
145
+ end
146
+
128
147
  # Return only the board columns for the current board.
129
148
  def current_board
130
149
  if @board_id.nil?
@@ -144,8 +163,7 @@ class ChartBase
144
163
  def completed_issues_in_range include_unstarted: false
145
164
  issues.select do |issue|
146
165
  cycletime = issue.board.cycletime
147
- stopped_time = cycletime.stopped_time(issue)
148
- started_time = cycletime.started_time(issue)
166
+ started_time, stopped_time = cycletime.started_stopped_times(issue)
149
167
 
150
168
  stopped_time &&
151
169
  date_range.include?(stopped_time.to_date) && # Remove outside range
@@ -153,17 +171,6 @@ class ChartBase
153
171
  end
154
172
  end
155
173
 
156
- def sprints_in_time_range board
157
- board.sprints.select do |sprint|
158
- sprint_end_time = sprint.completed_time || sprint.end_time
159
- sprint_start_time = sprint.start_time
160
- next false if sprint_start_time.nil?
161
-
162
- time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
163
- (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
164
- end || []
165
- end
166
-
167
174
  def chart_format object
168
175
  if object.is_a? Time
169
176
  # "2022-04-09T11:38:30-07:00"
@@ -173,41 +180,66 @@ class ChartBase
173
180
  end
174
181
  end
175
182
 
176
- def header_text text
177
- @header_text = text
183
+ def header_text text = :none
184
+ @header_text = text unless text == :none
185
+ @header_text
178
186
  end
179
187
 
180
- def description_text text
181
- @description_text = text
188
+ def description_text text = :none
189
+ @description_text = text unless text == :none
190
+ @description_text
182
191
  end
183
192
 
193
+ # Convert a number like 1234567 into the string "1,234,567"
184
194
  def format_integer number
185
195
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
186
196
  end
187
197
 
188
- def format_status name_or_id, board:, is_category: false
189
- begin
190
- statuses = board.possible_statuses.expand_statuses([name_or_id])
191
- rescue RuntimeError => e
192
- return "<span style='color: red'>#{name_or_id}</span>" if e.message.match?(/^Status not found:/)
193
-
194
- throw e
198
+ # object will be either a Status or a ChangeItem
199
+ # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
200
+ # Either way, is_category will format the category rather than the status
201
+ def format_status object, board:, is_category: false, use_old_status: false
202
+ status = nil
203
+ error_message = nil
204
+
205
+ case object
206
+ when ChangeItem
207
+ id = use_old_status ? object.old_value_id : object.value_id
208
+ status = board.possible_statuses.find_by_id(id)
209
+ if status.nil?
210
+ error_message = use_old_status ? object.old_value : object.value
211
+ end
212
+ when Status
213
+ status = object
214
+ else
215
+ raise "Unexpected type: #{object.class}"
195
216
  end
196
- raise "Expected exactly one match and got #{statuses.inspect} for #{name_or_id.inspect}" if statuses.size > 1
197
217
 
198
- status = statuses.first
218
+ return "<span style='color: red'>#{error_message}</span>" if error_message
219
+
199
220
  color = status_category_color status
200
221
 
201
- text = is_category ? status.category_name : status.name
202
- "<span style='color: #{color}'>#{text}</span>"
222
+ visibility = ''
223
+ if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
224
+ visibility = icon_span(
225
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
226
+ icon: ' 👀'
227
+ )
228
+ end
229
+ text = is_category ? status.category : status
230
+ "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
231
+ end
232
+
233
+ def icon_span title:, icon:
234
+ "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
203
235
  end
204
236
 
205
237
  def status_category_color status
206
- case status.category_name
207
- when nil then 'black'
208
- when 'To Do' then 'gray'
209
- when 'In Progress' then 'blue'
210
- when 'Done' then 'green'
238
+ case status.category.key
239
+ when 'new' then CssVariable['--status-category-todo-color']
240
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
241
+ when 'done' then CssVariable['--status-category-done-color']
242
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
211
243
  end
212
244
  end
213
245
 
@@ -225,15 +257,23 @@ class ChartBase
225
257
  @canvas_responsive
226
258
  end
227
259
 
228
- def filter_issues &block
229
- @filter_issues_block = block
260
+ def color_block color, title: nil
261
+ result = +''
262
+ result << "<div class='color_block' style='"
263
+ result << "background: #{CssVariable[color]};" if color
264
+ result << 'visibility: hidden;' unless color
265
+ result << "'"
266
+ result << " title=#{title.inspect}" if title
267
+ result << '></div>'
268
+ result
230
269
  end
231
270
 
232
- def issues= issues
233
- @issues = issues
234
- return unless @filter_issues_block
235
-
236
- @issues = issues.filter_map { |i| @filter_issues_block.call(i) }.uniq
237
- puts @issues.collect(&:key).join(', ')
271
+ def describe_non_working_days
272
+ <<-TEXT
273
+ <div class='p'>
274
+ The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
275
+ and any other holidays mentioned in the configuration.
276
+ </div>
277
+ TEXT
238
278
  end
239
279
  end
@@ -34,6 +34,10 @@ class ColumnsConfig
34
34
  @columns << [:string, label, proc]
35
35
  end
36
36
 
37
+ def integer label, proc
38
+ @columns << [:integer, label, proc]
39
+ end
40
+
37
41
  def column_entry_times board_id: nil
38
42
  @file_config.project_config.find_board_by_id(board_id).visible_columns.each do |column|
39
43
  date column.name, first_time_in_status(*column.status_ids)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CssVariable
4
+ attr_reader :name
5
+
6
+ def self.[](name)
7
+ if name.is_a?(String) && name.start_with?('--')
8
+ CssVariable.new name
9
+ else
10
+ name
11
+ end
12
+ end
13
+
14
+ def initialize name
15
+ @name = name
16
+ end
17
+
18
+ def to_json(*_args)
19
+ "getComputedStyle(document.body).getPropertyValue('#{@name}')"
20
+ end
21
+
22
+ def to_s
23
+ "var(#{@name})"
24
+ end
25
+
26
+ def inspect
27
+ "CssVariable['#{@name}']"
28
+ end
29
+
30
+ def == other
31
+ self.class == other.class && @name == other.name
32
+ end
33
+ end
@@ -8,10 +8,14 @@ class CycleTimeConfig
8
8
 
9
9
  attr_reader :label, :parent_config
10
10
 
11
- def initialize parent_config:, label:, block:, today: Date.today
11
+ def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
12
12
  @parent_config = parent_config
13
13
  @label = label
14
14
  @today = today
15
+
16
+ # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
17
+ # may make it easier to find problems in the test code ;-)
18
+ @file_system = file_system
15
19
  instance_eval(&block) unless block.nil?
16
20
  end
17
21
 
@@ -26,31 +30,78 @@ class CycleTimeConfig
26
30
  end
27
31
 
28
32
  def in_progress? issue
29
- started_time(issue) && stopped_time(issue).nil?
33
+ started_time, stopped_time = started_stopped_times(issue)
34
+ started_time && stopped_time.nil?
30
35
  end
31
36
 
32
37
  def done? issue
33
- stopped_time(issue)
38
+ started_stopped_times(issue).last
34
39
  end
35
40
 
36
41
  def started_time issue
37
- @start_at.call(issue)
42
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
43
+ started_stopped_times(issue).first
38
44
  end
39
45
 
40
46
  def stopped_time issue
41
- @stop_at.call(issue)
47
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
48
+ started_stopped_times(issue).last
49
+ end
50
+
51
+ def fabricate_change_item time
52
+ @file_system.deprecated(
53
+ date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
54
+ )
55
+ raw = {
56
+ 'field' => 'Fabricated change',
57
+ 'to' => '0',
58
+ 'toString' => '',
59
+ 'from' => '0',
60
+ 'fromString' => ''
61
+ }
62
+ ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
63
+ end
64
+
65
+ def started_stopped_changes issue
66
+ started = @start_at.call(issue)
67
+ stopped = @stop_at.call(issue)
68
+
69
+ # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
70
+ # If they are false then explicitly make them nil.
71
+ started ||= nil
72
+ stopped ||= nil
73
+
74
+ # These are only here for backwards compatibility. Hopefully nobody will ever need them.
75
+ started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
76
+ stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
77
+
78
+ # In the case where started and stopped are exactly the same time, we pretend that
79
+ # it just stopped and never started. This allows us to have logic like 'in or right of'
80
+ # for the start and not have it conflict.
81
+ started = nil if started&.time == stopped&.time
82
+
83
+ [started, stopped]
84
+ end
85
+
86
+ def started_stopped_times issue
87
+ started, stopped = started_stopped_changes(issue)
88
+ [started&.time, stopped&.time]
89
+ end
90
+
91
+ def started_stopped_dates issue
92
+ started_time, stopped_time = started_stopped_times(issue)
93
+ [started_time&.to_date, stopped_time&.to_date]
42
94
  end
43
95
 
44
96
  def cycletime issue
45
- start = started_time(issue)
46
- stop = stopped_time(issue)
97
+ start, stop = started_stopped_times(issue)
47
98
  return nil if start.nil? || stop.nil?
48
99
 
49
100
  (stop.to_date - start.to_date).to_i + 1
50
101
  end
51
102
 
52
103
  def age issue, today: nil
53
- start = started_time(issue)
104
+ start = started_stopped_times(issue).first
54
105
  stop = today || @today || Date.today
55
106
  return nil if start.nil? || stop.nil?
56
107
 
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
5
5
  class CycletimeHistogram < ChartBase
6
6
  include GroupableIssueChart
7
7
  attr_accessor :possible_statuses
8
+ attr_reader :show_stats
8
9
 
9
- def initialize block = nil
10
+ def initialize block
10
11
  super()
11
12
 
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+
12
16
  header_text 'Cycletime Histogram'
13
17
  description_text <<-HTML
14
18
  <p>
@@ -26,21 +30,40 @@ class CycletimeHistogram < ChartBase
26
30
  end
27
31
  end
28
32
 
33
+ def percentiles percs = nil
34
+ @percentiles = percs unless percs.nil?
35
+ @percentiles
36
+ end
37
+
38
+ def disable_stats
39
+ @show_stats = false
40
+ end
41
+
29
42
  def run
30
43
  stopped_issues = completed_issues_in_range include_unstarted: true
31
44
 
32
45
  # For the histogram, we only want to consider items that have both a start and a stop time.
33
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_time(issue) }
46
+ histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
47
  rules_to_issues = group_issues histogram_issues
35
48
 
49
+ the_stats = {}
50
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
43
64
 
65
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
66
+
44
67
  wrap_and_render(binding, __FILE__)
45
68
  end
46
69
 
@@ -53,6 +76,48 @@ class CycletimeHistogram < ChartBase
53
76
  count_hash
54
77
  end
55
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
56
121
  def data_set_for histogram_data:, label:, color:
57
122
  keys = histogram_data.keys.sort
58
123
  {