jirametrics 3.2 → 3.3

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +111 -10
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +10 -6
  4. data/lib/jirametrics/aging_work_table.rb +22 -7
  5. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +15 -2
  6. data/lib/jirametrics/board_movement_calculator.rb +18 -7
  7. data/lib/jirametrics/chart_base.rb +39 -5
  8. data/lib/jirametrics/color_palette.rb +61 -0
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +9 -6
  10. data/lib/jirametrics/cycletime_scatterplot.rb +67 -8
  11. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  12. data/lib/jirametrics/dependency_chart.rb +1 -1
  13. data/lib/jirametrics/exporter.rb +77 -11
  14. data/lib/jirametrics/groupable_issue_chart.rb +27 -2
  15. data/lib/jirametrics/grouping_rules.rb +13 -1
  16. data/lib/jirametrics/html/aging_work_bar_chart.erb +16 -5
  17. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +1 -1
  18. data/lib/jirametrics/html/index.css +32 -5
  19. data/lib/jirametrics/html/index.erb +6 -2
  20. data/lib/jirametrics/html/index.js +16 -0
  21. data/lib/jirametrics/html/time_based_histogram.erb +26 -14
  22. data/lib/jirametrics/html/time_based_scatterplot.erb +29 -9
  23. data/lib/jirametrics/html_generator.rb +20 -1
  24. data/lib/jirametrics/html_report_config.rb +3 -3
  25. data/lib/jirametrics/percentile_validation.rb +26 -0
  26. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
  27. data/lib/jirametrics/settings.json +1 -0
  28. data/lib/jirametrics/time_based_histogram.rb +33 -15
  29. data/lib/jirametrics/time_based_scatterplot.rb +96 -14
  30. data/lib/jirametrics/trend_line_calculator.rb +5 -2
  31. data/lib/jirametrics/wip_by_column_chart.rb +1 -1
  32. metadata +3 -1
@@ -257,35 +257,89 @@ class Exporter
257
257
 
258
258
  def info key, name_filter:
259
259
  selected = []
260
+ searched = []
260
261
  file_system.log_only = true
261
262
  each_project_config(name_filter: name_filter) do |project|
262
263
  project.evaluate_next_level
263
264
 
264
265
  project.run load_only: true
265
- selected.concat matching_issues_in(project, key)
266
+ matches = matching_issues_in(project, key)
267
+ searched << describe_search(project: project, matches: matches.size)
268
+ selected.concat matches
266
269
  rescue => e # rubocop:disable Style/RescueStandardError
267
270
  # This happens when we're attempting to load an aggregated project because it hasn't been
268
271
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
269
272
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
273
+
274
+ searched << " #{project.name.inspect}: skipped, this is an aggregated project"
270
275
  end
271
276
  file_system.log_only = false
272
277
 
278
+ report_info_results key: key, selected: selected, where_we_looked: describe_where_we_looked(key, searched)
279
+ end
280
+
281
+ # Verbose in the log, sparse in the terminal. Where we looked is almost always what you need when
282
+ # an issue you know was downloaded cannot be found, and it is noise the rest of the time.
283
+ def describe_where_we_looked key, searched
284
+ return "Searched these projects for #{key.inspect}:\n#{searched.join "\n"}" unless searched.empty?
285
+
286
+ 'No project configurations were searched at all. Either none are defined in the config file ' \
287
+ 'or none of them matched the name filter.'
288
+ end
289
+
290
+ def report_info_results key:, selected:, where_we_looked:
273
291
  if selected.empty?
274
- file_system.log "No issues found to match #{key.inspect}"
275
- else
276
- selected.each do |project, issue|
277
- file_system.log "\nProject #{project.name}", also_write_to_stderr: true
278
- file_system.log issue.dump, also_write_to_stderr: true
279
- end
292
+ file_system.log(
293
+ "No issues found to match #{key.inspect}", more: where_we_looked, also_write_to_stderr: true
294
+ )
295
+ return
296
+ end
297
+
298
+ file_system.log where_we_looked
299
+ selected.each do |project, issue, ignored|
300
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
301
+ file_system.log(IGNORED_ISSUE_NOTE, also_write_to_stderr: true) if ignored
302
+ file_system.log issue.dump, also_write_to_stderr: true
280
303
  end
281
304
  end
282
305
 
306
+ # Only the unexpected case is announced. Not being filtered is the common one, and saying so
307
+ # every time would be noise on top of the dump that was actually asked for. Note the claim is
308
+ # about the filter, not about charts: an issue that survives filtering may still be absent from
309
+ # a given chart for its own reasons, so there is no matching note for the other case.
310
+ IGNORED_ISSUE_NOTE = 'IGNORED by a filter such as ignore_types or ignore_issues. No chart uses it.'
311
+
312
+ # One line per project saying where its issues were read from and what was there, so that a
313
+ # missing issue can be traced to the wrong directory rather than guessed at.
314
+ def describe_search project:, matches:
315
+ prefix = project.get_file_prefix raise_if_not_set: false
316
+ path = prefix.nil? ? '(no file_prefix set)' : File.join(project.target_path.to_s, "#{prefix}_issues")
317
+ counts =
318
+ begin
319
+ collection = project.issues
320
+ excluded = collection.hidden.size
321
+ detail = "#{collection.size} issues loaded"
322
+ detail << ", #{excluded} excluded by filters" if excluded.positive?
323
+ "#{detail}, #{matches} matched"
324
+ rescue StandardError
325
+ 'could not be read'
326
+ end
327
+ " #{project.name.inspect}: #{path} (#{counts})"
328
+ end
329
+
330
+ # Asking about a specific issue should always show that issue, whether or not the report used it.
331
+ # An issue removed by something like ignore_types is exactly when somebody runs info to find out
332
+ # what happened, and reporting it as missing sends them hunting for a download problem that is
333
+ # not there. The hidden list is searched first. Returns [project, issue, ignored] triples.
283
334
  def matching_issues_in project, key
284
335
  matches = []
285
- project.issues.each do |issue|
286
- matches << [project, issue] if key == issue.key
287
- issue.subtasks.each do |subtask|
288
- matches << [project, subtask] if key == subtask.key
336
+ collection = project.issues
337
+ [[collection.hidden, true], [collection, false]].each do |issues, ignored|
338
+ issues.each do |issue|
339
+ matches << [project, issue, ignored] if key == issue.key
340
+ issue.subtasks.each do |subtask|
341
+ matches << [project, subtask, ignored] if key == subtask.key
342
+ end
289
343
  end
290
344
  end
291
345
  matches
@@ -317,9 +371,21 @@ class Exporter
317
371
 
318
372
  def target_path path = nil
319
373
  unless path.nil?
374
+ previous = @target_path
320
375
  @target_path = path
321
376
  @target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
322
377
  FileUtils.mkdir_p @target_path
378
+ # A config is allowed to switch target directories partway through, and when it does, an
379
+ # issue can be downloaded into one and looked for in another. Recording every change makes
380
+ # that visible rather than something to guess at. See GitHub issue 77.
381
+ # The constructor seeds this with a bare '.', and the setter always appends a separator, so
382
+ # an unseparated '.' is the untouched default rather than something the config chose. Setting
383
+ # the same path twice is not a change and saying so would be actively misleading.
384
+ if previous == '.'
385
+ file_system.diagnostic "target_path set to #{@target_path.inspect}"
386
+ elsif previous != @target_path
387
+ file_system.diagnostic "target_path changed from #{previous.inspect} to #{@target_path.inspect}"
388
+ end
323
389
  end
324
390
  @target_path
325
391
  end
@@ -31,14 +31,39 @@ module GroupableIssueChart
31
31
 
32
32
  @issue_hints[issue] = rules.issue_hint
33
33
  @issue_periods[issue] = rules.last_day_of_period
34
- (result[rules] ||= []) << issue
34
+ accumulate_issue_for_group result, rules, issue
35
35
  end
36
36
 
37
37
  completed_issues.reject! { |issue| ignored_issues.include? issue }
38
38
 
39
39
  result.each_key do |rules|
40
- rules.color = random_color if rules.color.nil?
40
+ rules.color = next_palette_color if rules.color.nil?
41
41
  end
42
42
  result
43
43
  end
44
+
45
+ # Ruby's Hash keeps whichever key object it saw first, so a later issue whose rules object
46
+ # has different percentiles needs to be reconciled against that retained key rather than
47
+ # just appended under its own (discarded) key.
48
+ def accumulate_issue_for_group result, rules, issue
49
+ existing_key = result.keys.find { |key| key.eql? rules }
50
+ reconcile_percentiles existing_key, rules if existing_key
51
+ (result[existing_key || rules] ||= []) << issue
52
+ end
53
+
54
+ # The retained hash key is whichever rules object arrived first, so a later issue setting a
55
+ # different list would silently lose. That can only be a config error, so say so.
56
+ def reconcile_percentiles existing_key, rules
57
+ incoming = rules.percentiles
58
+ return if incoming.nil?
59
+
60
+ existing = existing_key.percentiles
61
+ if existing.nil?
62
+ existing_key.percentiles = incoming
63
+ elsif existing != incoming
64
+ raise ArgumentError,
65
+ "group #{existing_key.label.inspect} was given conflicting percentiles: " \
66
+ "#{existing.inspect} and #{incoming.inspect}"
67
+ end
68
+ end
44
69
  end
@@ -1,8 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'jirametrics/percentile_validation'
4
+
3
5
  class GroupingRules < Rules
6
+ include PercentileValidation
7
+
4
8
  attr_accessor :label, :issue_hint, :label_hint
5
- attr_reader :color, :last_day_of_period
9
+ attr_reader :color, :last_day_of_period, :percentiles
10
+
11
+ # nil means "inherit whatever the chart is configured with" and an empty list means "draw no
12
+ # lines for this group", so neither is validated. Everything else gets the same treatment as
13
+ # the chart level setter, because these numbers become JavaScript identifiers downstream and a
14
+ # bad one takes the whole chart out with a syntax error.
15
+ def percentiles= list
16
+ @percentiles = list.nil? ? nil : validate_percentiles(list)
17
+ end
6
18
 
7
19
  def last_day_of_period= value
8
20
  @last_day_of_period = value.is_a?(String) ? Date.parse(value) : value
@@ -40,15 +40,26 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
40
40
  annotations: {
41
41
  <%= working_days_annotation %>
42
42
 
43
- <% if percentage_line_x %>
44
- line: {
43
+ <% @percentage_lines.each do |percentage_line| %>
44
+ <%= percentage_line[:id].to_json %>: {
45
45
  type: 'line',
46
46
  scaleID: 'x',
47
- value: '<%= percentage_line_x %>',
47
+ value: '<%= percentage_line[:x] %>',
48
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
49
49
  borderWidth: 1,
50
- drawTime: 'afterDraw'
51
- }
50
+ hitTolerance: 6,
51
+ drawTime: 'afterDraw',
52
+ label: {
53
+ display: false,
54
+ content: <%= "#{ordinal percentage_line[:percentile]} percentile of completed work".to_json %>,
55
+ position: 'start',
56
+ backgroundColor: 'rgba(0,0,0,0.85)',
57
+ color: '#fff',
58
+ font: { size: 11 }
59
+ },
60
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
61
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
62
+ },
52
63
  <% end %>
53
64
  }
54
65
  },
@@ -57,7 +57,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
57
57
  }
58
58
  nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
59
59
 
60
- // Hide/show the 85% line for that dataset
60
+ // Hide/show the percentile lines for that dataset
61
61
  legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
62
62
 
63
63
  // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
@@ -8,6 +8,20 @@
8
8
  --cycletime-scatterplot-cap-rule-color: #555555;
9
9
  --cycletime-scatterplot-cap-gutter-color: rgba(0, 0, 0, 0.05);
10
10
 
11
+ /* Palette for things that need telling apart where no specific colour is wanted. Okabe-Ito,
12
+ chosen because it stays distinguishable under the common forms of colour vision deficiency.
13
+ If you need a SPECIFIC colour, configure it rather than relying on whichever slot comes up.
14
+ Add a slot by defining the next number; the count is read from this file, not hardcoded.
15
+ These deliberately have no dark mode overrides yet: lightening them for dark backgrounds
16
+ collapses blue into sky blue, and getting it right needs measuring. See jirametrics-60z. */
17
+ --palette-color-1: #0072B2; /* Okabe-Ito blue */
18
+ --palette-color-2: #E69F00; /* Okabe-Ito orange */
19
+ --palette-color-3: #009E73; /* Okabe-Ito bluish green */
20
+ --palette-color-4: #56B4E9; /* Okabe-Ito sky blue */
21
+ --palette-color-5: #D55E00; /* Okabe-Ito vermilion */
22
+ --palette-color-6: #CC79A7; /* Okabe-Ito reddish purple */
23
+ --palette-color-7: #F0E442; /* Okabe-Ito yellow */
24
+
11
25
  --non-working-days-color: #F0F0F0;
12
26
  --expedited-color: #D55E00; /* Okabe-Ito vermilion */
13
27
  --blocked-color: #D55E00; /* Okabe-Ito vermilion */
@@ -254,7 +268,14 @@ html[data-theme="dark"] {
254
268
  --wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
255
269
  --wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
256
270
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
257
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
271
+ /* Was inheriting the light #92D9C0, which sat too close to the completed colour above for
272
+ anyone with protanopia or deuteranopia. Those two mean "completed" and "completed but not
273
+ started", so they are exactly the pair a reader must separate. See jirametrics-3fg. */
274
+ --wip-chart-completed-but-not-started-color: #00A062;
275
+ /* Was #DE9AC4, a lightened reddish purple that collided with the sky blue used for the fastest
276
+ band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
277
+ stays in the same warm family so the ramp still reads cool to warm as work ages. */
278
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE;
258
279
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
259
280
  --estimate-accuracy-chart-active-border-color: #E69F00;
260
281
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
@@ -266,7 +287,10 @@ html[data-theme="dark"] {
266
287
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
267
288
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
268
289
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
269
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
290
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
291
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
292
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
293
+ background. See jirametrics-60z for the measurements. */
270
294
  --daily-view-selected-issue-background: #474747;
271
295
  --daily-view-issue-border: #2DCB9A;
272
296
  --daily-view-selected-issue-border: #E69F00;
@@ -405,12 +429,12 @@ html[data-theme="light"] {
405
429
  --hierarchy-table-inactive-item-text-color: #939393;
406
430
 
407
431
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
408
- --wip-chart-completed-but-not-started-color: #92D9C0;
432
+ --wip-chart-completed-but-not-started-color: #00A062; /* see jirametrics-3fg */
409
433
  --wip-chart-duration-less-than-day-color: #56B4E9;
410
434
  --wip-chart-duration-week-or-less-color: #F0E442;
411
435
  --wip-chart-duration-two-weeks-or-less-color: #E69F00;
412
436
  --wip-chart-duration-four-weeks-or-less-color: #D55E00;
413
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
437
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
414
438
 
415
439
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
416
440
  --estimate-accuracy-chart-active-border-color: #E69F00;
@@ -425,7 +449,10 @@ html[data-theme="light"] {
425
449
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
426
450
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
427
451
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
428
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
452
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
453
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
454
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
455
+ background. See jirametrics-60z for the measurements. */
429
456
 
430
457
  --daily-view-selected-issue-background: #474747;
431
458
  --daily-view-issue-border: #2DCB9A;
@@ -4,9 +4,13 @@
4
4
  <title><%= project_name.empty? ? 'JiraMetrics' : "JiraMetrics - #{project_name}" %></title>
5
5
  <link rel="icon" type="image/png" href="https://github.com/mikebowler/jirametrics/blob/main/favicon.png?raw=true" />
6
6
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
7
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
+ <!-- Pinned to a major version, not to an exact one: patch and minor releases still arrive
8
+ automatically, but a new major cannot land in someone's report unannounced. The charts
9
+ depend on specific annotation plugin behaviour (hitTolerance, enter/leave, the label's own
10
+ drawTime), and nothing in the test suite reaches browser behaviour to catch a break. -->
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
8
12
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
9
- <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
13
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
10
14
  <script type="text/javascript">
11
15
  <%= javascript %>
12
16
  </script>
@@ -141,6 +141,22 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', eve
141
141
 
142
142
  // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
143
143
  // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
144
+ // Apply an alpha to a colour that may only be resolvable in the browser, such as one that came
145
+ // from a CSS variable. Ruby cannot do this arithmetic any more now that palette colours are
146
+ // variables rather than literals.
147
+ function withAlpha(color, alpha) {
148
+ const probe = document.createElement('canvas').getContext('2d')
149
+ probe.fillStyle = color
150
+ const resolved = probe.fillStyle // normalised by the browser to #rrggbb or rgba(...)
151
+ if (resolved.startsWith('#')) {
152
+ const r = parseInt(resolved.substr(1, 2), 16)
153
+ const g = parseInt(resolved.substr(3, 2), 16)
154
+ const b = parseInt(resolved.substr(5, 2), 16)
155
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
156
+ }
157
+ return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
158
+ }
159
+
144
160
  function createDiagonalPattern(color = 'black') {
145
161
  // create a 5x5 px canvas for the pattern's base shape
146
162
  let shape = document.createElement('canvas')
@@ -36,20 +36,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
36
36
  annotation: {
37
37
  annotations: {
38
38
  <%
39
- results = the_stats[:all][:percentiles]
40
- results.each do |percentile, value|
39
+ # Guard the nil value: an unlabelled annotation would emit "value:" with nothing after
40
+ # it, which is a syntax error that takes the whole chart script out.
41
+ (the_stats.dig(:all, :percentiles) || {}).each do |percentile, value|
42
+ next if value.nil?
41
43
  %>
42
- percentile<%= percentile.to_s %>: {
44
+ <%= "percentile#{percentile}".to_json %>: {
43
45
  type: 'line',
44
46
  scaleID: 'x',
45
47
  value: <%= value %>,
46
48
  borderWidth: 1,
49
+ hitTolerance: 6,
47
50
  drawTime: 'beforeDatasetsDraw',
48
51
  label: {
49
- enabled: true,
50
- content: '<%= "#{percentile}%" %>',
52
+ display: false,
53
+ content: <%= "#{ordinal percentile} percentile at #{label_cycletime value}".to_json %>,
51
54
  position: 'start',
52
- }
55
+ // Drawn late so the label sits on top of the bars rather than behind them.
56
+ drawTime: 'afterDraw',
57
+ backgroundColor: 'rgba(0,0,0,0.85)',
58
+ color: '#fff',
59
+ font: { size: 11 }
60
+ },
61
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
62
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
53
63
  },
54
64
  <% end %>
55
65
  },
@@ -83,8 +93,8 @@ if show_stats
83
93
  <th>Max</th>
84
94
  <th>Avg</th>
85
95
  <th>Mode</th>
86
- <% percentiles.each do |p| %>
87
- <th><%= p %>th</th>
96
+ <% percentiles.each do |percentile| %>
97
+ <th><%= ordinal percentile %></th>
88
98
  <% end %>
89
99
  </tr>
90
100
  <% the_stats.each do |k, v| %>
@@ -94,8 +104,8 @@ if show_stats
94
104
  <td style="text-align: right;"><%= v[:max] %></td>
95
105
  <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
96
106
  <td><%= v[:mode].join(', ') %></td>
97
- <% percentiles.each do |p| %>
98
- <td style="text-align: right;"><%= v[:percentiles][p] %></td>
107
+ <% percentiles.each do |percentile| %>
108
+ <td style="text-align: right;"><%= v[:percentiles][percentile] %></td>
99
109
  <% end %>
100
110
  </tr>
101
111
  <% end %>
@@ -107,12 +117,14 @@ if show_stats
107
117
  <li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
108
118
  <li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
109
119
  <li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
110
- <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of values are X or less. Typical percentiles of interest are:</li>
120
+ <% unless percentiles.empty? %>
121
+ <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of values are X or less. The ones in the table above:</li>
111
122
  <ul>
112
- <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
113
- <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
114
- <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
123
+ <% percentiles.each do |percentile| %>
124
+ <li><b><%= percentile %>%</b>: <%= percentile_explanation percentile %></li>
125
+ <% end %>
115
126
  </ul>
127
+ <% end %>
116
128
  </ul>
117
129
  </div>
118
130
  </div>
@@ -129,15 +129,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
129
129
  },
130
130
  <% end %>
131
131
 
132
- <% @percentage_lines.each_with_index do |args, index| %>
133
- <% percent, color = args %>
134
- line<%= index %>: {
132
+ <% @percentage_lines.each do |line| %>
133
+ <%= line[:id].to_json %>: {
135
134
  type: 'line',
136
- yMin: <%= percent %>,
137
- yMax: <%= percent %>,
138
- borderColor: <%= color.to_json %>,
135
+ yMin: <%= line[:value] %>,
136
+ yMax: <%= line[:value] %>,
137
+ borderColor: <%= line[:color].to_json %>,
139
138
  borderWidth: 1,
140
- drawTime: 'beforeDraw'
139
+ hitTolerance: 6,
140
+ drawTime: 'beforeDraw',
141
+ label: {
142
+ display: false,
143
+ <%# to_json rather than quoting by hand: the group label comes from the user's %>
144
+ <%# grouping_rules block and an apostrophe in it would otherwise end the string. %>
145
+ content: <%= "#{line[:label]} #{line[:percentile]}% at #{label_days line[:value]}".to_json %>,
146
+ position: 'end',
147
+ // The line itself draws early so it sits under the dots, but the hover label has to
148
+ // draw last or the non-working-day hatching (also beforeDraw) covers it.
149
+ drawTime: 'afterDraw',
150
+ backgroundColor: 'rgba(0,0,0,0.85)',
151
+ color: '#fff',
152
+ font: { size: 11 }
153
+ },
154
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
155
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
141
156
  },
142
157
  <% end %>
143
158
  }
@@ -151,8 +166,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
151
166
  }
152
167
  nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
153
168
 
154
- // Hide/show the 85% line for that dataset
155
- legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
169
+ // Hide/show every percentile line belonging to this group. The map is keyed by the
170
+ // dataset index we just worked out, which is unambiguous even when two groups share a
171
+ // label. A group with no lines of its own simply isn't in the map.
172
+ const annotationMap = <%= legend_annotation_map.to_json %>;
173
+ (annotationMap[i] || []).forEach((id) => {
174
+ legend.chart.options.plugins.annotation.annotations[id].display = nextVisibility;
175
+ });
156
176
 
157
177
  // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
158
178
  // there but not always visible.
@@ -6,13 +6,32 @@ class HtmlGenerator
6
6
  def create_html output_filename:, settings:, project_name: ''
7
7
  @settings = settings
8
8
  project_name = project_name.to_s
9
- html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
10
9
  css = load_css html_directory: html_directory
11
10
  javascript = file_system.load(File.join(html_directory, 'index.js'))
12
11
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
13
12
  file_system.save_file content: erb.result(binding), filename: output_filename
14
13
  end
15
14
 
15
+ # Charts allocate colours while they run, which happens before create_html is reached, so the
16
+ # palette cannot wait for the CSS that create_html loads. It reads the files directly instead.
17
+ # Deliberately not going through file_system: the shipped stylesheet is part of the gem rather
18
+ # than user data, and the palette only needs to count slots in it.
19
+ def color_palette
20
+ @color_palette ||= ColorPalette.new css: palette_css
21
+ end
22
+
23
+ def palette_css
24
+ base = File.read File.join(html_directory, 'index.css')
25
+ extra = settings && settings['include_css']
26
+ return base unless extra && File.exist?(extra)
27
+
28
+ "#{base}\n\n#{File.read extra}"
29
+ end
30
+
31
+ def html_directory
32
+ "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
33
+ end
34
+
16
35
  def load_css html_directory:
17
36
  base_css_filename = File.join(html_directory, 'index.css')
18
37
  base_css = file_system.load(base_css_filename)
@@ -106,9 +106,8 @@ class HtmlReportConfig < HtmlGenerator
106
106
  @file_config.project_config.exporter.timezone_offset
107
107
  end
108
108
 
109
- def random_color
110
- @palette_index = (@palette_index || -1) + 1
111
- ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
109
+ def next_palette_color
110
+ color_palette.next_color
112
111
  end
113
112
 
114
113
  def html string, type: :body
@@ -137,6 +136,7 @@ class HtmlReportConfig < HtmlGenerator
137
136
  project_config = @file_config.project_config
138
137
 
139
138
  chart.file_system = file_system
139
+ chart.color_palette = color_palette
140
140
  chart.issues = issues
141
141
  chart.time_range = project_config.time_range
142
142
  chart.timezone_offset = timezone_offset
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Percentile lists arrive from two different places in the config DSL: the chart level
4
+ # "percentiles [50, 85]" setter and "rule.percentiles = [...]" inside a grouping_rules block.
5
+ # Both are user supplied and both end up as JavaScript annotation ids in the rendered chart, so
6
+ # both need the same guard rails. Including this module gives you a private validate_percentiles.
7
+ module PercentileValidation
8
+ module_function
9
+
10
+ # Returns the cleaned up list. Raises ArgumentError, naming the offending value, for anything
11
+ # that isn't an Integer between 0 and 100.
12
+ def validate_percentiles list
13
+ list.each do |percentile|
14
+ raise ArgumentError, "percentile #{percentile} must be an integer" unless percentile.is_a? Integer
15
+
16
+ raise ArgumentError, "percentile #{percentile} must be between 0 and 100" unless percentile.between?(0, 100)
17
+ end
18
+ list.uniq.sort
19
+ end
20
+
21
+ # For the places where exactly one value is meaningful, such as a forecast that has to produce a
22
+ # single number of days. Returns the value; raises the same errors as the list form.
23
+ def validate_percentile value
24
+ validate_percentiles([value]).first
25
+ end
26
+ end
@@ -13,6 +13,7 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
13
13
  <div class="p">
14
14
  This graph shows the cycle time for all closed pull requests (time from opened to closed).
15
15
  </div>
16
+ <%= trend_line_description %>
16
17
  #{describe_non_working_days}
17
18
  HTML
18
19
 
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "stalled_threshold_days": 5,
3
3
  "stalled_statuses": [],
4
+ "stalled_ignored_fields": ["RemoteIssueLink"],
4
5
 
5
6
  "blocked_link_text": ["is blocked by"],
6
7
  "blocked_statuses": [],