jirametrics 3.3.1 → 3.5

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 (38) 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 +145 -27
  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/cumulative_flow_diagram.erb +78 -10
  19. data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
  20. data/lib/jirametrics/html/index.css +10 -9
  21. data/lib/jirametrics/html/index.js +13 -0
  22. data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
  23. data/lib/jirametrics/issue.rb +5 -7
  24. data/lib/jirametrics/issue_printer.rb +5 -3
  25. data/lib/jirametrics/jira_gateway.rb +5 -7
  26. data/lib/jirametrics/project_config.rb +24 -0
  27. data/lib/jirametrics/sprint_burndown.rb +2 -2
  28. data/lib/jirametrics/testing/mock_board.rb +34 -0
  29. data/lib/jirametrics/testing/mock_change_item.rb +112 -0
  30. data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
  31. data/lib/jirametrics/testing/mock_issue.rb +108 -0
  32. data/lib/jirametrics/testing.rb +88 -0
  33. data/lib/jirametrics/time_based_histogram.rb +2 -4
  34. data/lib/jirametrics/time_based_scatterplot.rb +2 -4
  35. data/lib/jirametrics/user.rb +16 -2
  36. data/lib/jirametrics/wip_by_column_chart.rb +1 -2
  37. data/lib/jirametrics.rb +6 -1
  38. 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: 056e9e0149e1e7f1d03968b91d5a62937d82a5f529ceba4917b6b23c74f33103
4
+ data.tar.gz: e883680589519bb9c527873d607755dbca2d4082053a5747a9bd7de2506305f4
5
5
  SHA512:
6
- metadata.gz: bdaa2c39f21752d0a1a815dc39dd10235cea508f873d89a32d722ea5db4d0d59eef1738febdffde062eaa31cb9b8c24598e2e0b96ccce478a296fe255c0b7997
7
- data.tar.gz: 8a9013ed9d3bd5ebef3d2a70c1b870ec5624125d0f2fb5989d375e4d005b7259646d66b24d9babcf49d794671dbc65505770b16c5438624406e4dd21e182b98a
6
+ metadata.gz: bd257eb627a31909dcb389ec7d66739d9d5d3aebf6d8301af76c41c4f989ebce899ca8fa543d2557dfd3a4ffd4af0110eb3f8bea7872bdf6ae4931fb15b6fc13
7
+ data.tar.gz: be1fee3d08642e25db8a998803921ef385cef5dd5cef99b074808553c7619c6af7069833df5fce644a95976ef7e7ffc4ca1cb9ccc0047b066ce8e85900a7ca85
@@ -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
@@ -165,12 +198,24 @@ class ChartBase
165
198
 
166
199
  LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
167
200
 
168
- def date_annotation
169
- annotations = settings['date_annotations'] || []
170
- in_range = annotations
171
- .map { |a| [a, normalize_annotation_datetime(a['date'])] }
172
- .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
173
- .sort_by { |(_, dt)| dt }
201
+ # chartjs-plugin-annotation renders each array element as its own line and does no wrapping of
202
+ # its own, so anything long has to arrive already split.
203
+ ANNOTATION_DESCRIPTION_WIDTH = 60
204
+
205
+ # The descriptions a chart needs to draw its own hover reveal, with the dates already normalized
206
+ # and the text already wrapped, so the JavaScript only has to decide when to show them.
207
+ def date_annotation_hover_data
208
+ annotations_in_range
209
+ .select { |(a, _)| a['description'] }
210
+ .map { |(a, normalized)| { 'date' => normalized, 'lines' => wrap_annotation_description(a['description']) } }
211
+ .to_json
212
+ end
213
+
214
+ # hover: false leaves out the reveal callbacks and the widened hit target, for a chart that
215
+ # draws its own hover reveal. The CFD does, because its flow metrics triangle switches Chart.js
216
+ # events off and the plugin's hover cannot fire while that is on.
217
+ def date_annotation hover: true
218
+ in_range = annotations_in_range
174
219
 
175
220
  positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
176
221
 
@@ -181,17 +226,94 @@ class ChartBase
181
226
  xMin: #{normalized.to_json},
182
227
  xMax: #{normalized.to_json},
183
228
  borderColor: 'rgba(0,0,0,0.7)',
184
- borderWidth: 1,
185
- label: {
186
- display: true,
187
- content: #{a['label'].to_json},
188
- position: #{positions[index].to_json}
189
- }
229
+ borderWidth: 1#{annotation_hit_tolerance(a) if hover}#{annotation_label a, positions[index]}#{annotation_hover_callbacks(a, index) if hover}
190
230
  },
191
231
  TEXT
192
232
  end.join
193
233
  end
194
234
 
235
+ def annotations_in_range
236
+ (settings['date_annotations'] || [])
237
+ .map { |a| [a, normalize_annotation_datetime(a['date'])] }
238
+ .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
239
+ .sort_by { |(_, dt)| dt }
240
+ end
241
+
242
+ # The label block for one annotation, or nothing at all when there is neither a label nor a
243
+ # description to put in it.
244
+ def annotation_label annotation, position
245
+ # drawTime: the CFD hatches its correction windows over the chart and the scatterplot draws
246
+ # dots and percentile lines. Drawing the label last keeps it readable on both.
247
+
248
+ label = annotation['label']
249
+ description = annotation['description']
250
+ return '' if label.nil? && description.nil?
251
+
252
+ ",\n" \
253
+ " label: {\n" \
254
+ " display: #{!label.nil?},\n" \
255
+ " content: #{annotation_content(annotation).to_json},\n" \
256
+ " position: #{position.to_json},\n" \
257
+ " drawTime: 'afterDraw'\n" \
258
+ ' }'
259
+ end
260
+
261
+ # A label on its own stays a plain string, exactly as it always was. Paired with a description it
262
+ # becomes a single element array so that hovering can append the description lines below it. With
263
+ # no label at all the description is the whole content, hidden until the line is hovered.
264
+ def annotation_content annotation
265
+ label = annotation['label']
266
+ return wrap_annotation_description annotation['description'] if label.nil?
267
+
268
+ annotation['description'] ? [label] : label
269
+ end
270
+
271
+ # The line is a single pixel wide, so without widening the hit target there is nothing the
272
+ # mouse can realistically land on. Only annotations that react to hovering need it.
273
+ def annotation_hit_tolerance annotation
274
+ annotation['description'] ? ",\n hitTolerance: 6" : ''
275
+ end
276
+
277
+ def wrap_annotation_description description
278
+ description.split.each_with_object([]) do |word, lines|
279
+ if lines.empty? || "#{lines.last} #{word}".length > ANNOTATION_DESCRIPTION_WIDTH
280
+ lines << word
281
+ else
282
+ lines[-1] = "#{lines.last} #{word}"
283
+ end
284
+ end
285
+ end
286
+
287
+ # Two things here are easy to get wrong, and both were, so they are worth spelling out.
288
+ #
289
+ # update('none') rather than draw(): the label's box is measured during the update, so a plain
290
+ # redraw paints the new lines at the old box size and they spill across the chart as white text
291
+ # on nothing. 'none' skips the animation.
292
+ #
293
+ # Writing to the chart config rather than to ctx.element: update() re-resolves every annotation
294
+ # from the config, so anything written to the element is discarded before it can be drawn. The
295
+ # percentile lines elsewhere can write to the element because they call draw(), not update().
296
+ def annotation_hover_callbacks annotation, index
297
+ description = annotation['description']
298
+ return '' unless description
299
+ return annotation_reveal_callbacks if annotation['label'].nil?
300
+
301
+ expanded = [annotation['label'], *wrap_annotation_description(description)]
302
+ target = "ctx.chart.options.plugins.annotation.annotations.dateAnnotation#{index}.label.content"
303
+ ",\n" \
304
+ " enter(ctx) { #{target} = #{expanded.to_json}; ctx.chart.update('none'); },\n" \
305
+ " leave(ctx) { #{target} = #{annotation_content(annotation).to_json}; ctx.chart.update('none'); }"
306
+ end
307
+
308
+ # With no label there is nothing on screen at rest, so hovering only has to switch the hidden
309
+ # label on. The content never changes, so its box is already measured and draw() is enough.
310
+ # That avoids the update() dance the case above needs, and the element can be written directly.
311
+ def annotation_reveal_callbacks
312
+ ",\n" \
313
+ ' enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },' \
314
+ "\n leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }"
315
+ end
316
+
195
317
  def stagger_label_positions datetimes
196
318
  return [] if datetimes.empty?
197
319
 
@@ -240,20 +362,16 @@ class ChartBase
240
362
  end
241
363
  end
242
364
 
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
365
  def header_text text = :none
253
366
  @header_text = text unless text == :none
254
367
  @header_text
255
368
  end
256
369
 
370
+ def no_data_text text = :none
371
+ @no_data_text = text unless text == :none
372
+ @no_data_text
373
+ end
374
+
257
375
  def description_text text = :none
258
376
  @description_text = text unless text == :none
259
377
  @description_text
@@ -412,7 +530,7 @@ class ChartBase
412
530
  # reassemble the report. Generated because only the start/end word differs.
413
531
  %w[start end].each do |position|
414
532
  define_method "seam_#{position}" do |type = 'chart'|
415
- "\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
533
+ "\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{expanded_header_text} | #{type} -->\n"
416
534
  end
417
535
  end
418
536
 
@@ -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