jirametrics 2.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.
Files changed (68) 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 +78 -43
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +44 -15
  9. data/lib/jirametrics/board_config.rb +8 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  11. data/lib/jirametrics/change_item.rb +31 -10
  12. data/lib/jirametrics/chart_base.rb +102 -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_wip_by_age_chart.rb +44 -20
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  20. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  21. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  22. data/lib/jirametrics/data_quality_report.rb +222 -41
  23. data/lib/jirametrics/dependency_chart.rb +54 -23
  24. data/lib/jirametrics/download_config.rb +12 -0
  25. data/lib/jirametrics/downloader.rb +76 -57
  26. data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
  27. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  28. data/lib/jirametrics/examples/standard_project.rb +25 -49
  29. data/lib/jirametrics/expedited_chart.rb +28 -25
  30. data/lib/jirametrics/exporter.rb +59 -32
  31. data/lib/jirametrics/file_config.rb +34 -13
  32. data/lib/jirametrics/file_system.rb +48 -3
  33. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  35. data/lib/jirametrics/grouping_rules.rb +7 -1
  36. data/lib/jirametrics/hierarchy_table.rb +4 -4
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  39. data/lib/jirametrics/html/aging_work_table.erb +19 -25
  40. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  41. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  42. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  43. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  44. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  45. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  47. data/lib/jirametrics/html/index.css +209 -0
  48. data/lib/jirametrics/html/index.erb +16 -39
  49. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  50. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  51. data/lib/jirametrics/html_report_config.rb +108 -86
  52. data/lib/jirametrics/issue.rb +357 -96
  53. data/lib/jirametrics/jira_gateway.rb +29 -11
  54. data/lib/jirametrics/project_config.rb +256 -144
  55. data/lib/jirametrics/rules.rb +2 -2
  56. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  57. data/lib/jirametrics/settings.json +10 -0
  58. data/lib/jirametrics/sprint_burndown.rb +24 -7
  59. data/lib/jirametrics/status.rb +84 -19
  60. data/lib/jirametrics/status_collection.rb +80 -39
  61. data/lib/jirametrics/throughput_chart.rb +12 -4
  62. data/lib/jirametrics/value_equality.rb +2 -2
  63. data/lib/jirametrics.rb +25 -7
  64. metadata +16 -17
  65. data/lib/jirametrics/discard_changes_before.rb +0 -37
  66. data/lib/jirametrics/experimental/generator.rb +0 -210
  67. data/lib/jirametrics/experimental/info.rb +0 -77
  68. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -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
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,22 @@ 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
+ # @html_directory = "#{pathname.dirname}/html"
42
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
44
43
  erb.result(caller_binding)
45
44
  end
46
45
 
47
- # Render the file and then wrap it with standard headers and quality checks.
48
- def wrap_and_render caller_binding, file
46
+ def render_top_text caller_binding
49
47
  result = +''
50
48
  result << "<h1>#{@header_text}</h1>" if @header_text
51
49
  result << ERB.new(@description_text).result(caller_binding) if @description_text
50
+ result
51
+ end
52
+
53
+ # Render the file and then wrap it with standard headers and quality checks.
54
+ def wrap_and_render caller_binding, file
55
+ result = +''
56
+ result << render_top_text(caller_binding)
52
57
  result << render(caller_binding, file)
53
58
  result
54
59
  end
@@ -57,8 +62,8 @@ class ChartBase
57
62
  @@chart_counter += 1
58
63
  end
59
64
 
60
- def color_for type:, shade: :dark
61
- @chart_colors["#{shade}:#{type}"] ||= random_color
65
+ def color_for type:
66
+ @chart_colors[type] ||= random_color
62
67
  end
63
68
 
64
69
  def label_days days
@@ -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.read "#{@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
@@ -153,17 +172,6 @@ class ChartBase
153
172
  end
154
173
  end
155
174
 
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
175
  def chart_format object
168
176
  if object.is_a? Time
169
177
  # "2022-04-09T11:38:30-07:00"
@@ -173,41 +181,66 @@ class ChartBase
173
181
  end
174
182
  end
175
183
 
176
- def header_text text
177
- @header_text = text
184
+ def header_text text = :none
185
+ @header_text = text unless text == :none
186
+ @header_text
178
187
  end
179
188
 
180
- def description_text text
181
- @description_text = text
189
+ def description_text text = :none
190
+ @description_text = text unless text == :none
191
+ @description_text
182
192
  end
183
193
 
194
+ # Convert a number like 1234567 into the string "1,234,567"
184
195
  def format_integer number
185
196
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
186
197
  end
187
198
 
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
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}"
195
217
  end
196
- raise "Expected exactly one match and got #{statuses.inspect} for #{name_or_id.inspect}" if statuses.size > 1
197
218
 
198
- status = statuses.first
219
+ return "<span style='color: red'>#{error_message}</span>" if error_message
220
+
199
221
  color = status_category_color status
200
222
 
201
- text = is_category ? status.category_name : status.name
202
- "<span style='color: #{color}'>#{text}</span>"
223
+ visibility = ''
224
+ if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
225
+ visibility = icon_span(
226
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
227
+ icon: ' 👀'
228
+ )
229
+ end
230
+ text = is_category ? status.category.name : status.name
231
+ "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
232
+ end
233
+
234
+ def icon_span title:, icon:
235
+ "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
203
236
  end
204
237
 
205
238
  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'
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
 
@@ -225,15 +258,23 @@ class ChartBase
225
258
  @canvas_responsive
226
259
  end
227
260
 
228
- def filter_issues &block
229
- @filter_issues_block = block
261
+ def color_block color, title: nil
262
+ result = +''
263
+ result << "<div class='color_block' style='"
264
+ result << "background: #{CssVariable[color]};" if color
265
+ result << 'visibility: hidden;' unless color
266
+ result << "'"
267
+ result << " title=#{title.inspect}" if title
268
+ result << '></div>'
269
+ result
230
270
  end
231
271
 
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(', ')
272
+ def describe_non_working_days
273
+ <<-TEXT
274
+ <div class='p'>
275
+ The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
276
+ and any other holidays mentioned in the configuration.
277
+ </div>
278
+ TEXT
238
279
  end
239
280
  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, 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
- 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
  {
@@ -7,25 +7,24 @@ class CycletimeScatterplot < ChartBase
7
7
 
8
8
  attr_accessor :possible_statuses
9
9
 
10
- def initialize block = nil
10
+ def initialize block
11
11
  super()
12
12
 
13
13
  header_text 'Cycletime Scatterplot'
14
14
  description_text <<-HTML
15
- <p>
15
+ <div class="p">
16
16
  This chart shows only completed work and indicates both what day it completed as well as
17
17
  how many days it took to get done. Hovering over a dot will show you the ID of the work item.
18
- </p>
19
- <p>
20
- The gray line indicates the 85th percentile (<%= overall_percent_line %> days). 85% of all
18
+ </div>
19
+ <div class="p">
20
+ The #{color_block '--cycletime-scatterplot-overall-trendline-color'} line indicates the 85th
21
+ percentile (<%= overall_percent_line %> days). 85% of all
21
22
  items on this chart fall on or below the line and the remaining 15% are above the line. 85%
22
23
  is a reasonable proxy for "most" so that we can say that based on this data set, we can
23
24
  predict that most work of this type will complete in <%= overall_percent_line %> days or
24
25
  less. The other lines reflect the 85% line for that respective type of work.
25
- </p>
26
- <p>
27
- The gray vertical bars indicate weekends, when theoretically we aren't working.
28
- </p>
26
+ </div>
27
+ #{describe_non_working_days}
29
28
  HTML
30
29
 
31
30
  init_configuration_block block do
@@ -44,7 +43,7 @@ class CycletimeScatterplot < ChartBase
44
43
 
45
44
  data_sets = create_datasets completed_issues
46
45
  overall_percent_line = calculate_percent_line(completed_issues)
47
- @percentage_lines << [overall_percent_line, 'gray']
46
+ @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
48
47
 
49
48
  return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
50
49
 
@@ -54,10 +53,7 @@ class CycletimeScatterplot < ChartBase
54
53
  def create_datasets completed_issues
55
54
  data_sets = []
56
55
 
57
- groups = group_issues completed_issues
58
-
59
- groups.each_key do |rules|
60
- completed_issues_by_type = groups[rules]
56
+ group_issues(completed_issues).each do |rules, completed_issues_by_type|
61
57
  label = rules.label
62
58
  color = rules.color
63
59
  percent_line = calculate_percent_line completed_issues_by_type
@@ -118,7 +114,7 @@ class CycletimeScatterplot < ChartBase
118
114
 
119
115
  {
120
116
  y: cycle_time,
121
- x: chart_format(issue.board.cycletime.stopped_time(issue)),
117
+ x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
122
118
  title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
123
119
  }
124
120
  end