jirametrics 2.4 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +9 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +54 -7
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +44 -15
  8. data/lib/jirametrics/board_config.rb +7 -3
  9. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  10. data/lib/jirametrics/change_item.rb +19 -6
  11. data/lib/jirametrics/chart_base.rb +63 -27
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/cycletime_config.rb +59 -8
  14. data/lib/jirametrics/cycletime_histogram.rb +68 -3
  15. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  16. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  17. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  18. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  19. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  20. data/lib/jirametrics/data_quality_report.rb +219 -41
  21. data/lib/jirametrics/dependency_chart.rb +37 -10
  22. data/lib/jirametrics/download_config.rb +12 -0
  23. data/lib/jirametrics/downloader.rb +68 -50
  24. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  25. data/lib/jirametrics/examples/aggregated_project.rb +7 -21
  26. data/lib/jirametrics/examples/standard_project.rb +18 -34
  27. data/lib/jirametrics/expedited_chart.rb +8 -9
  28. data/lib/jirametrics/exporter.rb +28 -11
  29. data/lib/jirametrics/file_config.rb +23 -6
  30. data/lib/jirametrics/file_system.rb +39 -3
  31. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  32. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  34. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  35. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  36. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  37. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  38. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  39. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  40. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  41. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  42. data/lib/jirametrics/html/index.css +28 -5
  43. data/lib/jirametrics/html/index.erb +8 -4
  44. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  45. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  46. data/lib/jirametrics/html_report_config.rb +33 -23
  47. data/lib/jirametrics/issue.rb +232 -47
  48. data/lib/jirametrics/jira_gateway.rb +16 -3
  49. data/lib/jirametrics/project_config.rb +245 -134
  50. data/lib/jirametrics/rules.rb +2 -2
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  52. data/lib/jirametrics/settings.json +5 -2
  53. data/lib/jirametrics/sprint_burndown.rb +3 -3
  54. data/lib/jirametrics/status.rb +84 -19
  55. data/lib/jirametrics/status_collection.rb +77 -39
  56. data/lib/jirametrics/throughput_chart.rb +1 -1
  57. data/lib/jirametrics/value_equality.rb +2 -2
  58. data/lib/jirametrics.rb +22 -6
  59. metadata +10 -13
  60. data/lib/jirametrics/discard_changes_before.rb +0 -37
  61. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardMovementCalculator
4
+ attr_reader :board, :issues, :today
5
+
6
+ def initialize board:, issues:, today:
7
+ @board = board
8
+ @issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
9
+ @today = today
10
+ end
11
+
12
+ def moves_backwards? issue
13
+ started, stopped = issue.board.cycletime.started_stopped_times(issue)
14
+ return false unless started
15
+
16
+ previous_column = nil
17
+ issue.status_changes.each do |change|
18
+ column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
19
+ next if change.time < started
20
+ next if column.nil? # It disappeared from the board for a bit
21
+ return true if previous_column && column && column < previous_column
22
+
23
+ previous_column = column
24
+ end
25
+ false
26
+ end
27
+
28
+ def stacked_age_data_for percentages:
29
+ data_list = percentages.sort.collect do |percentage|
30
+ [percentage, age_data_for(percentage: percentage)]
31
+ end
32
+
33
+ stack_data data_list
34
+ end
35
+
36
+ def stack_data data_list
37
+ remainder = nil
38
+ data_list.collect do |percentage, data|
39
+ unless remainder.nil?
40
+ data = (0...data.length).collect do |i|
41
+ data[i] - remainder[i]
42
+ end
43
+
44
+ end
45
+ remainder = data
46
+
47
+ [percentage, data]
48
+ end
49
+ end
50
+
51
+ def age_data_for percentage:
52
+ data = []
53
+ board.visible_columns.each_with_index do |_column, column_index|
54
+ ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
55
+
56
+ if ages.empty?
57
+ data << 0
58
+ else
59
+ index = ((ages.size - 1) * percentage / 100).to_i
60
+ data << ages[index]
61
+ end
62
+ end
63
+ data
64
+ end
65
+
66
+ def ages_of_issues_when_leaving_column column_index:, today:
67
+ this_column = board.visible_columns[column_index]
68
+ next_column = board.visible_columns[column_index + 1]
69
+
70
+ @issues.filter_map do |issue|
71
+ this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
+ next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
73
+ issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
74
+
75
+ # Skip if we can't tell when it started.
76
+ next if issue_start.nil?
77
+
78
+ # Skip if it never entered this column
79
+ next if this_column_start.nil?
80
+
81
+ # Skip if it left this column before the item is considered started.
82
+ next 0 if next_column_start && next_column_start <= issue_start
83
+
84
+ # Skip if it was already done by the time it got to this column or it became done when it got to this column
85
+ next if issue_done && issue_done <= this_column_start
86
+
87
+ end_date = case # rubocop:disable Style/EmptyCaseCondition
88
+ when next_column_start.nil?
89
+ # If this is the last column then base age against today
90
+ today
91
+ when issue_done && issue_done < next_column_start
92
+ # it completed while in this column
93
+ issue_done.to_date
94
+ else
95
+ # It passed through this whole column
96
+ next_column_start.to_date
97
+ end
98
+ (end_date - issue_start.to_date).to_i + 1
99
+ end.sort
100
+ end
101
+
102
+ # Figure out what column this is issue is currently in and what time it entered that column. We need this for
103
+ # aging and forecasting purposes
104
+ def find_current_column_and_entry_time_in_column issue
105
+ column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
106
+ return [] if column.nil? # This issue isn't visible on the board
107
+
108
+ status_ids = column.status_ids
109
+
110
+ entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
111
+
112
+ [column.name, entry_at]
113
+ end
114
+
115
+ def label_days days
116
+ "#{days} day#{'s' unless days == 1}"
117
+ end
118
+
119
+ def forecasted_days_remaining_and_message issue:, today:
120
+ return [nil, 'Already done'] if issue.done?
121
+
122
+ likely_age_data = age_data_for percentage: 85
123
+
124
+ column_name, entry_time = find_current_column_and_entry_time_in_column issue
125
+ return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
126
+
127
+ age_in_column = (today - entry_time.to_date).to_i + 1
128
+
129
+ message = nil
130
+ column_index = board.visible_columns.index { |c| c.name == column_name }
131
+
132
+ last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
133
+ return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
134
+
135
+ remaining_in_current_column = likely_age_data[column_index] - age_in_column
136
+ if remaining_in_current_column.negative?
137
+ message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
138
+ "in the #{column_name.inspect} column. Most items on this board have left this column in " \
139
+ "#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
140
+ remaining_in_current_column = 0
141
+ return [nil, message]
142
+ end
143
+
144
+ forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
145
+ [forecasted_days, message]
146
+ end
147
+ end
@@ -1,16 +1,17 @@
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, :author, :time
5
+ attr_accessor :value, :old_value
6
6
 
7
7
  def initialize raw:, time:, author:, artificial: false
8
8
  @raw = raw
9
9
  @time = time
10
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
10
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
11
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
11
12
 
12
- @field = field || @raw['field']
13
- @value = value || @raw['toString']
13
+ @field = @raw['field']
14
+ @value = @raw['toString']
14
15
  @value_id = @raw['to'].to_i
15
16
  @old_value = @raw['fromString']
16
17
  @old_value_id = @raw['from']&.to_i
@@ -34,9 +35,21 @@ class ChangeItem
34
35
 
35
36
  def link? = (field == 'Link')
36
37
 
38
+ def labels? = (field == 'labels')
39
+
40
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
41
+ def to_time = @time
42
+
37
43
  def to_s
38
44
  message = +''
39
- message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
45
+ message << "ChangeItem(field: #{field.inspect}"
46
+ message << ", value: #{value.inspect}"
47
+ message << ':' << value_id.inspect if status?
48
+ if old_value
49
+ message << ", old_value: #{old_value.inspect}"
50
+ message << ':' << old_value_id.inspect if status?
51
+ end
52
+ message << ", time: #{time_to_s(@time).inspect}"
40
53
  message << ', artificial' if artificial?
41
54
  message << ')'
42
55
  message
@@ -25,6 +25,11 @@ class ChartBase
25
25
  @aggregated_project
26
26
  end
27
27
 
28
+ def html_directory
29
+ pathname = Pathname.new(File.realpath(__FILE__))
30
+ "#{pathname.dirname}/html"
31
+ end
32
+
28
33
  def render caller_binding, file
29
34
  pathname = Pathname.new(File.realpath(file))
30
35
  basename = pathname.basename.to_s
@@ -33,8 +38,8 @@ class ChartBase
33
38
  # Insert a incrementing chart_id so that all the chart names on the page are unique
34
39
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
35
40
 
36
- @html_directory = "#{pathname.dirname}/html"
37
- erb = ERB.new file_system.load "#{@html_directory}/#{$1}.erb"
41
+ # @html_directory = "#{pathname.dirname}/html"
42
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
38
43
  erb.result(caller_binding)
39
44
  end
40
45
 
@@ -100,7 +105,7 @@ class ChartBase
100
105
  issues_id = next_id
101
106
 
102
107
  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
- erb = ERB.new file_system.load "#{@html_directory}/collapsible_issues_panel.erb"
108
+ erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
104
109
  erb.result(binding)
105
110
  end
106
111
 
@@ -125,6 +130,21 @@ class ChartBase
125
130
  result
126
131
  end
127
132
 
133
+ def working_days_annotation
134
+ holidays.each_with_index.collect do |range, index|
135
+ <<~TEXT
136
+ holiday#{index}: {
137
+ drawTime: 'beforeDraw',
138
+ type: 'box',
139
+ xMin: '#{range.begin}T00:00:00',
140
+ xMax: '#{range.end}T23:59:59',
141
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
142
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
143
+ },
144
+ TEXT
145
+ end.join
146
+ end
147
+
128
148
  # Return only the board columns for the current board.
129
149
  def current_board
130
150
  if @board_id.nil?
@@ -144,8 +164,7 @@ class ChartBase
144
164
  def completed_issues_in_range include_unstarted: false
145
165
  issues.select do |issue|
146
166
  cycletime = issue.board.cycletime
147
- stopped_time = cycletime.stopped_time(issue)
148
- started_time = cycletime.started_time(issue)
167
+ started_time, stopped_time = cycletime.started_stopped_times(issue)
149
168
 
150
169
  stopped_time &&
151
170
  date_range.include?(stopped_time.to_date) && # Remove outside range
@@ -162,40 +181,54 @@ class ChartBase
162
181
  end
163
182
  end
164
183
 
165
- def header_text text = nil
166
- @header_text = text if text
184
+ def header_text text = :none
185
+ @header_text = text unless text == :none
167
186
  @header_text
168
187
  end
169
188
 
170
- def description_text text = nil
171
- @description_text = text if text
189
+ def description_text text = :none
190
+ @description_text = text unless text == :none
172
191
  @description_text
173
192
  end
174
193
 
194
+ # Convert a number like 1234567 into the string "1,234,567"
175
195
  def format_integer number
176
196
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
177
197
  end
178
198
 
179
- def format_status name_or_id, board:, is_category: false
180
- begin
181
- statuses = board.possible_statuses.expand_statuses([name_or_id])
182
- rescue StatusNotFoundError => e
183
- return "<span style='color: red'>#{name_or_id}</span>"
199
+ # object will be either a Status or a ChangeItem
200
+ # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
201
+ # Either way, is_category will format the category rather than the status
202
+ def format_status object, board:, is_category: false, use_old_status: false
203
+ status = nil
204
+ error_message = nil
205
+
206
+ case object
207
+ when ChangeItem
208
+ id = use_old_status ? object.old_value_id : object.value_id
209
+ status = board.possible_statuses.find_by_id(id)
210
+ if status.nil?
211
+ error_message = use_old_status ? object.old_value : object.value
212
+ end
213
+ when Status
214
+ status = object
215
+ else
216
+ raise "Unexpected type: #{object.class}"
184
217
  end
185
218
 
186
- status = statuses.first
219
+ return "<span style='color: red'>#{error_message}</span>" if error_message
220
+
187
221
  color = status_category_color status
188
222
 
189
223
  visibility = ''
190
224
  if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
191
225
  visibility = icon_span(
192
- title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
193
- icon: ' 👀'
194
- )
195
-
226
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
227
+ icon: ' 👀'
228
+ )
196
229
  end
197
- text = is_category ? status.category_name : status.name
198
- "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
230
+ text = is_category ? status.category.name : status.name
231
+ "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
199
232
  end
200
233
 
201
234
  def icon_span title:, icon:
@@ -203,11 +236,11 @@ class ChartBase
203
236
  end
204
237
 
205
238
  def status_category_color status
206
- case status.category_name
207
- when 'To Do' then CssVariable['--status-category-todo-color']
208
- when 'In Progress' then CssVariable['--status-category-inprogress-color']
209
- when 'Done' then CssVariable['--status-category-done-color']
210
- else 'black' # Theoretically impossible but seen in prod.
239
+ case status.category.key
240
+ when 'new' then CssVariable['--status-category-todo-color']
241
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
242
+ when 'done' then CssVariable['--status-category-done-color']
243
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
211
244
  end
212
245
  end
213
246
 
@@ -227,7 +260,10 @@ class ChartBase
227
260
 
228
261
  def color_block color, title: nil
229
262
  result = +''
230
- result << "<div class='color_block' style='background: var(#{color});'"
263
+ result << "<div class='color_block' style='"
264
+ result << "background: #{CssVariable[color]};" if color
265
+ result << 'visibility: hidden;' unless color
266
+ result << "'"
231
267
  result << " title=#{title.inspect}" if title
232
268
  result << '></div>'
233
269
  result
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -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, author: 'unknown', artificial: true
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
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
  {
@@ -24,7 +24,7 @@ class CycletimeScatterplot < ChartBase
24
24
  predict that most work of this type will complete in <%= overall_percent_line %> days or
25
25
  less. The other lines reflect the 85% line for that respective type of work.
26
26
  </div>
27
- #{ describe_non_working_days }
27
+ #{describe_non_working_days}
28
28
  HTML
29
29
 
30
30
  init_configuration_block block do
@@ -53,10 +53,7 @@ class CycletimeScatterplot < ChartBase
53
53
  def create_datasets completed_issues
54
54
  data_sets = []
55
55
 
56
- groups = group_issues completed_issues
57
-
58
- groups.each_key do |rules|
59
- completed_issues_by_type = groups[rules]
56
+ group_issues(completed_issues).each do |rules, completed_issues_by_type|
60
57
  label = rules.label
61
58
  color = rules.color
62
59
  percent_line = calculate_percent_line completed_issues_by_type
@@ -117,7 +114,7 @@ class CycletimeScatterplot < ChartBase
117
114
 
118
115
  {
119
116
  y: cycle_time,
120
- x: chart_format(issue.board.cycletime.stopped_time(issue)),
117
+ x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
121
118
  title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
122
119
  }
123
120
  end
@@ -4,7 +4,7 @@ require 'jirametrics/daily_wip_chart'
4
4
 
5
5
  class DailyWipByAgeChart < DailyWipChart
6
6
  def initialize block
7
- super(block)
7
+ super
8
8
 
9
9
  add_trend_line line_color: '--aging-work-in-progress-by-age-trend-line-color', group_labels: [
10
10
  'Less than a day',
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- cycletime = issue.board.cycletime
53
- started = cycletime.started_time(issue)&.to_date
54
- stopped = cycletime.stopped_time(issue)&.to_date
52
+ started, stopped = issue.board.cycletime.started_stopped_dates(issue)
55
53
 
56
54
  rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
57
55
 
@@ -39,8 +39,8 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started = issue.board.cycletime.started_time(issue)
43
- stopped_date = issue.board.cycletime.stopped_time(issue)&.to_date
42
+ started, stopped = issue.board.cycletime.started_stopped_times(issue)
43
+ stopped_date = stopped&.to_date
44
44
 
45
45
  date = rules.current_date
46
46
  change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
@@ -3,10 +3,6 @@
3
3
  require 'jirametrics/daily_wip_chart'
4
4
 
5
5
  class DailyWipByParentChart < DailyWipChart
6
- def initialize block
7
- super(block)
8
- end
9
-
10
6
  def default_header_text
11
7
  'Daily WIP, grouped by the parent ticket (Epic, Feature, etc)'
12
8
  end