jirametrics 3.3.1 → 3.4

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +2 -4
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +26 -26
  4. data/lib/jirametrics/atlassian_document_format.rb +22 -14
  5. data/lib/jirametrics/board_movement_calculator.rb +0 -23
  6. data/lib/jirametrics/change_item.rb +4 -8
  7. data/lib/jirametrics/chart_base.rb +44 -15
  8. data/lib/jirametrics/chart_format.rb +15 -0
  9. data/lib/jirametrics/daily_view.rb +6 -7
  10. data/lib/jirametrics/daily_wip_by_parent_chart.rb +1 -1
  11. data/lib/jirametrics/data_quality_report.rb +3 -3
  12. data/lib/jirametrics/dependency_chart.rb +3 -5
  13. data/lib/jirametrics/estimate_accuracy_chart.rb +37 -8
  14. data/lib/jirametrics/expedited_chart.rb +5 -6
  15. data/lib/jirametrics/exporter.rb +24 -0
  16. data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -5
  17. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +10 -7
  18. data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
  19. data/lib/jirametrics/html/index.css +10 -9
  20. data/lib/jirametrics/html/index.js +13 -0
  21. data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
  22. data/lib/jirametrics/issue.rb +5 -7
  23. data/lib/jirametrics/issue_printer.rb +5 -3
  24. data/lib/jirametrics/jira_gateway.rb +5 -7
  25. data/lib/jirametrics/sprint_burndown.rb +2 -2
  26. data/lib/jirametrics/testing/mock_board.rb +34 -0
  27. data/lib/jirametrics/testing/mock_change_item.rb +112 -0
  28. data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
  29. data/lib/jirametrics/testing/mock_issue.rb +108 -0
  30. data/lib/jirametrics/testing.rb +88 -0
  31. data/lib/jirametrics/time_based_histogram.rb +2 -4
  32. data/lib/jirametrics/time_based_scatterplot.rb +2 -4
  33. data/lib/jirametrics/user.rb +16 -2
  34. data/lib/jirametrics/wip_by_column_chart.rb +1 -2
  35. data/lib/jirametrics.rb +6 -1
  36. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a2003eb5e447333b282b856e553c7f3f68792da373ce3b563c7f9e4e56dec45
4
- data.tar.gz: 50b443fbcad6fe9dff8b247a7e0662cfed8c41724bb1e8dbd5d17c33a58ff416
3
+ metadata.gz: 1f7292394330330ca10f89a28f02fab3e95cff385736c423ca3602c23551e4ca
4
+ data.tar.gz: 4690b5ce8cc47d3e980ee026e68f6f245e564ec51aa914914dd7a0db32f3c0b2
5
5
  SHA512:
6
- metadata.gz: bdaa2c39f21752d0a1a815dc39dd10235cea508f873d89a32d722ea5db4d0d59eef1738febdffde062eaa31cb9b8c24598e2e0b96ccce478a296fe255c0b7997
7
- data.tar.gz: 8a9013ed9d3bd5ebef3d2a70c1b870ec5624125d0f2fb5989d375e4d005b7259646d66b24d9babcf49d794671dbc65505770b16c5438624406e4dd21e182b98a
6
+ metadata.gz: 6f32fbe86ecfb9ecc5ce7ace7d0a2971b6777c7a61e4513d41d2444a976699487ba4da415b6e84484cad72a45ee6af4a822f14da430018e6b40b39bb48046a70
7
+ data.tar.gz: 2b4af2fe6c95834710901e3aacccc1d260ceb7c28020a853f1a643be1faf3c48a0663488839c522249e2a14a2d3c6e4665425a3c7bf5175354a0311493679ee6
@@ -14,6 +14,7 @@ class AgingWorkBarChart < ChartBase
14
14
  @percentage_lines = [] # Populated by run; the description reads it, so it must never be nil.
15
15
  percentiles [85]
16
16
  header_text 'Aging Work Bar Chart'
17
+ no_data_text '<%= render_header %><p>There is no aging work</p>'
17
18
  # div class="p" throughout rather than <p>: color_block emits a div and the list below is an
18
19
  # ol, neither of which is legal inside a paragraph. A browser closes the p at the first one,
19
20
  # which strands the rest of the text outside it. This is what the other charts use.
@@ -69,10 +70,7 @@ class AgingWorkBarChart < ChartBase
69
70
  { percentile: percentile, days: days, x: date_range.end - days, id: "percentile_#{percentile}" }
70
71
  end
71
72
 
72
- if aging_issues.empty?
73
- @description_text = '<p>There is no aging work</p>'
74
- return render_top_text(binding)
75
- end
73
+ return render_no_data if aging_issues.empty?
76
74
 
77
75
  wrap_and_render(binding, __FILE__)
78
76
  end
@@ -12,7 +12,7 @@ class AgingWorkInProgressChart < ChartBase
12
12
 
13
13
  def initialize block
14
14
  super()
15
- header_text 'Aging Work in Progress'
15
+ header_text 'Aging Work in Progress on board: <%= current_board.name %>'
16
16
  # div class="p" rather than <p>: the notes below are a ul, which is block level and not legal
17
17
  # inside a paragraph, so a browser closes the p early. Harmless while the list is the last
18
18
  # thing in the block, but it breaks the moment any text follows it.
@@ -59,7 +59,6 @@ class AgingWorkInProgressChart < ChartBase
59
59
  def run
60
60
  determine_board_columns
61
61
 
62
- @header_text += " on board: #{current_board.name}"
63
62
  data_sets = make_data_sets
64
63
 
65
64
  adjust_visibility_of_unmapped_status_column data_sets: data_sets
@@ -91,17 +90,27 @@ class AgingWorkInProgressChart < ChartBase
91
90
 
92
91
  rules_to_issues = group_issues aging_issues
93
92
  data_sets = rules_to_issues.keys.collect { |rules| line_data_set(rules, rules_to_issues[rules]) }
94
- # The y-axis is scaled to the oldest item, but never shorter than 20 days.
95
- @max_age = data_sets.flat_map { |set| set['data'].collect { |point| point['y'] } }.push(20).max
96
93
 
97
94
  calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
98
95
  column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
99
- @row_index_offset = data_sets.size
100
96
 
101
97
  append_bar_data_sets data_sets, calculator, column_indexes_to_remove
98
+ @max_age = highest_plotted_age data_sets
102
99
  data_sets
103
100
  end
104
101
 
102
+ # The y-axis ceiling is set a little above this so you can see that the tallest thing on the chart
103
+ # really does stop where it stops, rather than being clipped by the top of the plot. Bands count as
104
+ # much as dots do. Never shorter than 20 days.
105
+ # Called after the columns have been trimmed, so a column nobody can see can't push the ceiling up
106
+ # and leave unexplained space at the top.
107
+ def highest_plotted_age data_sets
108
+ data_sets.flat_map do |set|
109
+ # A dot is {x:, y:, title:}. A band is a floating bar, [bottom, top].
110
+ set['data'].collect { |point| point.is_a?(Hash) ? point['y'] : point.last }
111
+ end.push(20).max
112
+ end
113
+
105
114
  def aging_issue_on_board? issue
106
115
  issue.board.id == @board_id && issue.board.cycletime.in_progress?(issue)
107
116
  end
@@ -128,32 +137,23 @@ class AgingWorkInProgressChart < ChartBase
128
137
  end
129
138
  end
130
139
 
140
+ # Each band is a floating bar running from the previous percentile's age up to its own, so it
141
+ # carries its own absolute position. Asking Chart.js to stack the bands instead hands it the
142
+ # positioning of every dataset on the chart, and it will stack the aging dots right along with them.
131
143
  def append_bar_data_sets data_sets, calculator, column_indexes_to_remove
132
- bar_data = []
133
- calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
134
- column_indexes_to_remove.reverse_each { |index| data.delete_at index }
144
+ previous_ages = []
145
+
146
+ @percentiles.keys.sort.each do |percentage|
147
+ ages = calculator.age_data_for percentage: percentage
148
+ column_indexes_to_remove.reverse_each { |index| ages.delete_at index }
149
+
135
150
  data_sets << {
136
151
  'type' => 'bar', 'label' => "#{percentage}%", 'barPercentage' => 1.0, 'categoryPercentage' => 1.0,
137
- 'backgroundColor' => @percentiles[percentage], 'data' => data
152
+ 'backgroundColor' => @percentiles[percentage],
153
+ 'data' => ages.each_with_index.collect { |age, index| [previous_ages[index] || 0, age] }
138
154
  }
139
- bar_data << data
155
+ previous_ages = ages
140
156
  end
141
- @bar_data = adjust_bar_data bar_data
142
- end
143
-
144
- def adjust_bar_data input
145
- return [] if input.empty?
146
-
147
- row_size = input.first.size
148
-
149
- output = []
150
- output << input.first
151
- input.drop(1).each do |row|
152
- previous_row = output.last
153
- output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
154
- end
155
-
156
- output
157
157
  end
158
158
 
159
159
  def indexes_of_leading_and_trailing_zeros list
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+
3
5
  class AtlassianDocumentFormat
4
6
  attr_reader :users
5
7
 
@@ -10,7 +12,7 @@ class AtlassianDocumentFormat
10
12
 
11
13
  def to_html input
12
14
  if input.is_a? String
13
- input
15
+ escape(input)
14
16
  .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
15
17
  # rubocop:disable Style/PerlBackrefs -- in a gsub block $1 reads cleaner than Regexp.last_match[:...]
16
18
  .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
@@ -52,22 +54,22 @@ class AtlassianDocumentFormat
52
54
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
53
55
  when 'decisionItem', 'listItem' then ['<li>', '</li>']
54
56
  when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
55
- when 'emoji', 'status' then [node_attrs['text'], nil]
56
- when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
57
+ when 'emoji', 'status' then [escape(node_attrs['text']), nil]
58
+ when 'expand' then ["<div>#{escape node_attrs['title']}</div>", nil]
57
59
  when 'hardBreak' then ['<br />', nil]
58
60
  when 'heading'
59
61
  level = node_attrs['level']
60
62
  ["<h#{level}>", "</h#{level}>"]
61
63
  when 'inlineCard'
62
- url = node_attrs['url']
64
+ url = escape node_attrs['url']
63
65
  ["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
64
66
  when 'media'
65
- text = node_attrs['alt'] || node_attrs['id']
67
+ text = escape(node_attrs['alt'] || node_attrs['id'])
66
68
  ["Media: #{text}", nil]
67
69
  when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
68
- when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
70
+ when 'mention' then ["<b>#{escape node_attrs['text']}</b>", nil]
69
71
  when 'orderedList' then ['<ol>', '</ol>']
70
- when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
72
+ when 'panel' then ["<div>#{escape node_attrs['panelType'].upcase}</div>", nil]
71
73
  when 'paragraph' then ['<p>', '</p>']
72
74
  when 'rule' then ['<hr />', nil]
73
75
  when 'table' then ['<table>', '</table>']
@@ -76,13 +78,13 @@ class AtlassianDocumentFormat
76
78
  when 'tableRow' then ['<tr>', '</tr>']
77
79
  when 'text'
78
80
  marks = adf_marks_to_html(n['marks'])
79
- [marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
81
+ [marks.collect(&:first).join + escape(n['text']), marks.collect(&:last).join]
80
82
  when 'taskItem'
81
83
  state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
82
84
  ["<li>#{state} ", '</li>']
83
85
  when 'taskList' then ["<ul class='taskList'>", '</ul>']
84
86
  else
85
- ["<p>Unparseable section: #{n['type']}</p>", nil]
87
+ ["<p>Unparseable section: #{escape n['type']}</p>", nil]
86
88
  end
87
89
  end
88
90
  end
@@ -135,11 +137,11 @@ class AtlassianDocumentFormat
135
137
  list.filter_map do |mark|
136
138
  type = mark['type']
137
139
  if type == 'textColor'
138
- color = mark['attrs']['color']
140
+ color = escape mark['attrs']['color']
139
141
  ["<span style='color: #{color}'>", '</span>']
140
142
  elsif type == 'link'
141
- href = mark['attrs']['href']
142
- title = mark['attrs']['title']
143
+ href = escape mark['attrs']['href']
144
+ title = escape mark['attrs']['title']
143
145
  ["<a href='#{href}' title='#{title}'>", '</a>']
144
146
  else
145
147
  line = mappings.find { |key, _open, _close| key == type }
@@ -150,13 +152,19 @@ class AtlassianDocumentFormat
150
152
 
151
153
  def expand_account_id account_id
152
154
  user = @users.find { |u| u.account_id == account_id }
153
- text = account_id
154
- text = "@#{user.display_name}" if user
155
+ text = escape account_id
156
+ text = "@#{escape user.display_name}" if user
155
157
  "<span class='account_id'>#{text}</span>"
156
158
  end
157
159
 
158
160
  private
159
161
 
162
+ # The only markup in the output should be markup this class generated. Everything that comes out
163
+ # of the document is data written by whoever raised the issue, so it gets escaped on the way past.
164
+ def escape value
165
+ CGI.escapeHTML value.to_s
166
+ end
167
+
160
168
  def adf_node_render node, &render_node
161
169
  prefix, suffix = yield(node)
162
170
  result = +(prefix || '')
@@ -22,29 +22,6 @@ class BoardMovementCalculator
22
22
  columns.each_cons(2).any? { |previous, current| current < previous }
23
23
  end
24
24
 
25
- def stacked_age_data_for percentages:
26
- data_list = percentages.sort.collect do |percentage|
27
- [percentage, age_data_for(percentage: percentage)]
28
- end
29
-
30
- stack_data data_list
31
- end
32
-
33
- def stack_data data_list
34
- remainder = nil
35
- data_list.collect do |percentage, data|
36
- unless remainder.nil?
37
- data = (0...data.length).collect do |i|
38
- data[i] - remainder[i]
39
- end
40
-
41
- end
42
- remainder = data
43
-
44
- [percentage, data]
45
- end
46
- end
47
-
48
25
  def age_data_for percentage:
49
26
  data = []
50
27
  board.visible_columns.each_with_index do |_column, column_index|
@@ -37,13 +37,10 @@ class ChangeItem
37
37
  raw_value.to_s.split(', ').collect(&:to_i)
38
38
  end
39
39
 
40
- def author
41
- @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
42
- end
40
+ # It should be impossible to have no author at all, but we've seen it in production.
41
+ def author = User.from_raw(@author_raw)&.display_name || 'Unknown author'
43
42
 
44
- def author_icon_url
45
- @author_raw&.[]('avatarUrls')&.[]('16x16')
46
- end
43
+ def author_icon_url = User.from_raw(@author_raw)&.avatar_url
47
44
 
48
45
  def artificial? = @artificial
49
46
  def assignee? = (field == 'assignee')
@@ -129,8 +126,7 @@ class ChangeItem
129
126
  private
130
127
 
131
128
  def time_to_s time
132
- # MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
133
- # format so that tests work under both environments.
129
+ # Not all ruby versions return the same string for to_s so we force to a known format.
134
130
  time.strftime '%Y-%m-%d %H:%M:%S %z'
135
131
  end
136
132
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # require_all cannot resolve an include the way it resolves a superclass, and chart_format.rb
4
+ # sorts after this file, so name it explicitly.
5
+ require_relative 'chart_format'
6
+
3
7
  class ChartBase
8
+ include ChartFormat
9
+
4
10
  # Okabe-Ito palette - perceptually distinct under the most common forms of colour blindness.
5
11
  # Ordered from most- to least-commonly useful for chart series.
6
12
  OKABE_ITO_PALETTE = %w[
@@ -63,17 +69,44 @@ class ChartBase
63
69
  erb.result(caller_binding)
64
70
  end
65
71
 
66
- def render_top_text caller_binding
67
- result = +''
68
- result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
69
- result << ERB.new(@description_text).result(caller_binding) if @description_text
72
+ # A fresh scope for every template expansion. self is still the chart, so instance variables and
73
+ # methods are reachable from a template, but the caller's local variables are not. That is the
74
+ # point: ERB#result assigns its output buffer into whatever binding you hand it, so two templates
75
+ # sharing one scope silently discard each other's output when one renders the other.
76
+ def render_binding = binding
77
+
78
+ def expand_template text
79
+ ERB.new(text).result(render_binding)
80
+ end
81
+
82
+ # The header as the reader sees it. Stitch configs match on this through grab_by_title, so it has
83
+ # to be the expanded text rather than the template.
84
+ def expanded_header_text = @header_text.nil? ? '' : expand_template(@header_text)
85
+
86
+ def render_header
87
+ return '' unless @header_text
88
+
89
+ "<h1 class='foldable'>#{expanded_header_text}</h1>"
90
+ end
91
+
92
+ def render_top_text
93
+ result = +render_header
94
+ result << expand_template(@description_text) if @description_text
70
95
  result
71
96
  end
72
97
 
98
+ # What a chart shows when it has nothing to show. Empty means show nothing at all, not even the
99
+ # header, which is what a chart that simply does not apply to this board wants.
100
+ def render_no_data
101
+ return '' if @no_data_text.nil? || @no_data_text.empty?
102
+
103
+ expand_template @no_data_text
104
+ end
105
+
73
106
  # Render the file and then wrap it with standard headers and quality checks.
74
107
  def wrap_and_render caller_binding, file
75
108
  result = +''
76
- result << render_top_text(caller_binding)
109
+ result << render_top_text
77
110
  result << render(caller_binding, file)
78
111
  result
79
112
  end
@@ -240,20 +273,16 @@ class ChartBase
240
273
  end
241
274
  end
242
275
 
243
- def chart_format object
244
- if object.is_a? Time
245
- # "2022-04-09T11:38:30-07:00"
246
- object.strftime '%Y-%m-%dT%H:%M:%S%z'
247
- else
248
- object.to_s
249
- end
250
- end
251
-
252
276
  def header_text text = :none
253
277
  @header_text = text unless text == :none
254
278
  @header_text
255
279
  end
256
280
 
281
+ def no_data_text text = :none
282
+ @no_data_text = text unless text == :none
283
+ @no_data_text
284
+ end
285
+
257
286
  def description_text text = :none
258
287
  @description_text = text unless text == :none
259
288
  @description_text
@@ -412,7 +441,7 @@ class ChartBase
412
441
  # reassemble the report. Generated because only the start/end word differs.
413
442
  %w[start end].each do |position|
414
443
  define_method "seam_#{position}" do |type = 'chart'|
415
- "\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
444
+ "\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{expanded_header_text} | #{type} -->\n"
416
445
  end
417
446
  end
418
447
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Formats values for a Chart.js axis. Included by ChartBase and by the specs, which build the same
4
+ # strings to assert against.
5
+ module ChartFormat
6
+ def chart_format object
7
+ # Not all ruby versions return the same string for to_s so we force to a known format.
8
+
9
+ if object.is_a? Time
10
+ object.strftime '%Y-%m-%dT%H:%M:%S%z' # => 2022-04-09T11:38:30-0700
11
+ else
12
+ object.to_s
13
+ end
14
+ end
15
+ end
@@ -7,9 +7,10 @@ class DailyView < ChartBase
7
7
  super()
8
8
 
9
9
  header_text 'Daily View'
10
+ no_data_text '<%= render_header %><div>There are no items currently in progress</div>'
10
11
  description_text <<-HTML
11
12
  <div class="p">
12
- This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
13
+ This view shows all the items (<%= @aging_issues.count %>) you'll want to discuss during your daily
13
14
  coordination meeting
14
15
  (aka daily scrum, standup), in the order that you should be discussing them. The most important
15
16
  items are at the top, and the least at the bottom.
@@ -22,15 +23,13 @@ class DailyView < ChartBase
22
23
  end
23
24
 
24
25
  def run
25
- aging_issues = select_aging_issues
26
+ @aging_issues = select_aging_issues
26
27
 
27
- if aging_issues.empty?
28
- return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>"
29
- end
28
+ return render_no_data if @aging_issues.empty?
30
29
 
31
30
  result = +''
32
- result << render_top_text(binding)
33
- aging_issues.each do |issue|
31
+ result << render_top_text
32
+ @aging_issues.each do |issue|
34
33
  result << render_issue(issue, child: false)
35
34
  end
36
35
  result
@@ -14,7 +14,7 @@ class DailyWipByParentChart < DailyWipChart
14
14
  indication of how focused we are on higher level objectives. If there are many parent
15
15
  tickets in progress at the same time, either this team has their focus scattered or we
16
16
  aren't doing a good job of
17
- <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
17
+ <a href="https://blog.mikebowler.ca/2024/02/21/slicing-epics/">splitting those parent
18
18
  tickets</a>. Neither of those is desirable.
19
19
  </div>
20
20
  <div class="p">
@@ -63,11 +63,10 @@ class DataQualityReport < ChartBase
63
63
  scan_for_issues_on_multiple_boards entries: @entries
64
64
 
65
65
  entries_with_problems = entries_with_problems()
66
- return '' if entries_with_problems.empty?
66
+ return render_no_data if entries_with_problems.empty?
67
67
 
68
- caller_binding = binding
69
68
  result = +''
70
- result << render_top_text(caller_binding)
69
+ result << render_top_text
71
70
 
72
71
  result << '<ul class="quality_report">'
73
72
  result << render_problem_type(:discarded_changes)
@@ -111,6 +110,7 @@ class DataQualityReport < ChartBase
111
110
 
112
111
  # Return a format that's easier to assert against
113
112
  def testable_entries
113
+ # Not all ruby versions return the same string for to_s so we force to a known format.
114
114
  formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
115
115
  @entries.collect do |entry|
116
116
  [
@@ -67,6 +67,7 @@ class DependencyChart < ChartBase
67
67
  }
68
68
 
69
69
  header_text 'Dependencies'
70
+ no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
70
71
  description_text <<-HTML
71
72
  <p>
72
73
  These are all the "linked issues" as defined in Jira
@@ -83,13 +84,10 @@ class DependencyChart < ChartBase
83
84
  instance_eval(&@rules_block) if @rules_block
84
85
 
85
86
  dot_graph = build_dot_graph
86
- if dot_graph.nil?
87
- return "<h1 class='foldable'>#{@header_text}</h1>" \
88
- '<div>No data matched the selected criteria. Nothing to show.</div>'
89
- end
87
+ return render_no_data if dot_graph.nil?
90
88
 
91
89
  svg = restore_css_variables execute_graphviz(dot_graph.join("\n"))
92
- "<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
90
+ "#{render_top_text}<div>#{shrink_svg svg}</div>"
93
91
  end
94
92
 
95
93
  def link_rules &block
@@ -14,17 +14,24 @@ class EstimateAccuracyChart < ChartBase
14
14
  The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
15
15
  cycletimes.
16
16
  <% if @has_aging_data %>
17
- The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging dots
17
+ The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging arrows
18
18
  (click on the legend to turn them on) show the current
19
- age of items, which will give you a hint as to where they might end up. If they're already
20
- far to the right then you know you have a problem.
19
+ age of items, which will give you a hint as to where they might end up. They point right
20
+ because those items haven't finished yet, so every one of them will keep moving that way
21
+ until it does. If they're already far to the right then you know you have a problem.
21
22
  <% end %>
22
23
  </div>
23
24
  <% if @correlation_coefficient %>
24
25
  <div class="p">
25
26
  The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
26
27
  The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
27
- the stronger the negative collalation. Zero would mean no correlation at all.
28
+ the stronger the negative correlation. Zero would mean no correlation at all.
29
+ </div>
30
+ <% elsif @estimates_are_numeric == false %>
31
+ <div class="p">
32
+ There's no correlation coefficient here because these estimates are categories rather than
33
+ numbers. Working one out means measuring the gaps between values, and there's no defined
34
+ gap between one category and the next, so any number we showed you would be invented.
28
35
  </div>
29
36
  <% end %>
30
37
  HTML
@@ -46,14 +53,15 @@ class EstimateAccuracyChart < ChartBase
46
53
  end
47
54
  data_sets = scan_issues
48
55
 
49
- return '' if data_sets.empty?
56
+ return render_no_data if data_sets.empty?
50
57
 
51
58
  wrap_and_render(binding, __FILE__)
52
59
  end
53
60
 
54
61
  def scan_issues
55
62
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
56
- @correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
63
+ @estimates_are_numeric = numeric_estimates? completed_hash
64
+ @correlation_coefficient = correlation_coefficient(completed_hash) if @estimates_are_numeric
57
65
  estimation_units = current_board.estimation_configuration.units
58
66
  @has_aging_data = !aging_hash.empty?
59
67
 
@@ -77,7 +85,7 @@ class EstimateAccuracyChart < ChartBase
77
85
  {
78
86
  'x' => cycle_time,
79
87
  'y' => estimate,
80
- 'r' => values.size * 2,
88
+ 'r' => bubble_radius(values.size),
81
89
  'title' => title
82
90
  }
83
91
  end
@@ -90,11 +98,23 @@ class EstimateAccuracyChart < ChartBase
90
98
  'showLine' => false,
91
99
  'backgroundColor' => fill_color,
92
100
  'borderColor' => border_color,
93
- 'hidden' => starts_hidden
101
+ 'hidden' => starts_hidden,
102
+ # The active series is drawn as right pointing arrows rather than discs, because its
103
+ # cycletime is a lower bound that keeps growing. The erb needs to know which one that is.
104
+ 'still_in_progress' => completed_or_active == 'active'
94
105
  }
95
106
  end
96
107
  end
97
108
 
109
+ # Chart.js sizes a bubble by its radius but the eye reads it by area, so the radius has
110
+ # to go as the square root of the count. This multiplier is the radius of a single issue
111
+ # and everything else scales up from there.
112
+ SINGLE_ISSUE_RADIUS = 4
113
+
114
+ def bubble_radius issue_count
115
+ (SINGLE_ISSUE_RADIUS * Math.sqrt(issue_count)).round 1
116
+ end
117
+
98
118
  def estimate_label estimate:, estimation_units:
99
119
  if @y_axis_type == 'linear'
100
120
  if estimation_units == :story_points
@@ -183,6 +203,15 @@ class EstimateAccuracyChart < ChartBase
183
203
 
184
204
  # Correlation coefficient is calculated using the Pearson Correlation Coefficient
185
205
  # r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
206
+ # A Pearson coefficient is arithmetic on the distances between values, so it needs a scale where
207
+ # those distances mean something. T-shirt sizes are ordered but not spaced: nothing says the gap
208
+ # from M to L matches the gap from S to M. Summing them used to raise and take the export with it.
209
+ def numeric_estimates? completed_hash
210
+ return false if completed_hash.empty?
211
+
212
+ completed_hash.keys.all? { |estimate, _cycle_time| estimate.is_a? Numeric }
213
+ end
214
+
186
215
  def correlation_coefficient completed_hash
187
216
  list1 = []
188
217
  list2 = []
@@ -24,12 +24,13 @@ class ExpeditedChart < ChartBase
24
24
  super()
25
25
 
26
26
  header_text 'Expedited work'
27
+ no_data_text '<%= render_header %><div>There is no expedited work in this time period.</div>'
27
28
  description_text <<-HTML
28
29
  <div class="p">
29
30
  This chart only shows issues that have been expedited at some point. We care about these as
30
31
  any form of expedited work will affect the entire system and will slow down non-expedited work.
31
32
  Refer to this article on
32
- <a href="https://improvingflow.com/2021/06/16/classes-of-service.html">classes of service</a>
33
+ <a href="https://blog.mikebowler.ca/2021/06/16/classes-of-service/">classes of service</a>
33
34
  for a longer explanation on why we want to avoid expedited work.
34
35
  </div>
35
36
  <div class="p">
@@ -49,11 +50,9 @@ class ExpeditedChart < ChartBase
49
50
  make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
50
51
  end
51
52
 
52
- if data_sets.empty?
53
- '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
54
- else
55
- wrap_and_render(binding, __FILE__)
56
- end
53
+ return render_no_data if data_sets.empty?
54
+
55
+ wrap_and_render(binding, __FILE__)
57
56
  end
58
57
 
59
58
  def prepare_expedite_data issue
@@ -6,6 +6,30 @@ class Exporter
6
6
  attr_reader :project_configs
7
7
  attr_accessor :file_system
8
8
 
9
+ # Nothing in examples/ is loaded with the rest of the library, because an example is something you
10
+ # opt into rather than something the product provides. Each file there defines the method named
11
+ # after it. Derived from the directory rather than listed, so a new example is covered by existing.
12
+ EXAMPLE_NAMES = Dir[File.join(__dir__, 'examples', '*.rb')]
13
+ .collect { |file| File.basename(file, '.rb').to_sym }
14
+ .freeze
15
+
16
+ # Deliberately method_missing rather than stub methods that the real ones overwrite. Stubs depend
17
+ # on this file loading before the example, which require_all does not guarantee, and when it lost
18
+ # that race the stub replaced the working method.
19
+ def method_missing name, *args, **keywords, &block
20
+ return super unless EXAMPLE_NAMES.include? name
21
+
22
+ raise "#{name} is an example rather than part of jirametrics, so your config has to ask for " \
23
+ "it. Add this line to the top of your config file:\n\n" \
24
+ " require 'jirametrics/examples/#{name}'\n"
25
+ end
26
+
27
+ # These are not things we respond to. Calling one raises, so respond_to? should say false and the
28
+ # message above should be the only way anyone learns they exist.
29
+ def respond_to_missing? name, include_private = false
30
+ super
31
+ end
32
+
9
33
  def self.logfile_name
10
34
  @logfile_name ||= 'jirametrics.log'
11
35
  end