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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9952ccca62cfc09c062633e0ee50828016681b77b537e736e77046a0365a47ca
4
- data.tar.gz: 577b27edc9b07c42285339995ea3a23f2082d62feaa34a1d225cf7c61300fda0
3
+ metadata.gz: 25832ca03b7dcdc188d741171ee47d3f463551d1893257e39a9b52c0fb45dd83
4
+ data.tar.gz: a4d1b9ce7039e568e8c1a614862688887bc4e188c3b24af646922983eb859b9f
5
5
  SHA512:
6
- metadata.gz: 29ec03cf3689cd29d772ca781dd750fe33c54d203307b1ce0bb378b35b211a70b33c7d81503b688fad0cd1f7204b14b3fdaebfcc7fd2375ff3dbbc42ed6615c5
7
- data.tar.gz: b73780c0e68abea384a151d2f194c17e9705f9e507fd1587809d7ddb6691b9157aa82b6ab3a3cdd86ca513acd489b7c5dac733184cd1774887f0e2d6c6e5d990
6
+ metadata.gz: cdbfed22a95da07665930c339330d1f6def0f3ce36118fa68fd249295bbb7386d1a85bd0fe4301de8c5fc35d94e1a98bdee22f81daf9fc59db8095ea02000cec
7
+ data.tar.gz: 9122e7aa6cfb881723e7acc7b20df65a12ba372ab0736e94fe2e92e6e5206f69c342b94242e5f7e67d43135a426dab7c82b01d8e57fd5f33ee2a2227b0302445
@@ -2,19 +2,27 @@
2
2
 
3
3
  require 'jirametrics/chart_base'
4
4
  require 'jirametrics/bar_chart_range'
5
+ require 'jirametrics/percentile_validation'
5
6
 
6
7
  class AgingWorkBarChart < ChartBase
8
+ include PercentileValidation
9
+
7
10
  def initialize block
8
11
  super()
9
12
 
10
13
  @age_cutoff = nil
14
+ @percentage_lines = [] # Populated by run; the description reads it, so it must never be nil.
15
+ percentiles [85]
11
16
  header_text 'Aging Work Bar Chart'
17
+ # div class="p" throughout rather than <p>: color_block emits a div and the list below is an
18
+ # ol, neither of which is legal inside a paragraph. A browser closes the p at the first one,
19
+ # which strands the rest of the text outside it. This is what the other charts use.
12
20
  description_text <<-HTML
13
- <p>
21
+ <div class="p">
14
22
  This chart shows all active (started but not completed) work, ordered from oldest at the top to
15
23
  newest at the bottom.
16
- </p>
17
- <p>
24
+ </div>
25
+ <div class="p">
18
26
  There are <%= (aggregated_project? || current_board.scrum?) ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
19
27
  <ol>
20
28
  <li>Status: The status the issue was in at any time. The colour indicates the
@@ -29,7 +37,8 @@ class AgingWorkBarChart < ChartBase
29
37
  <li>Sprints: The sprints that the issue was in.</li>
30
38
  <% end %>
31
39
  </ol>
32
- </p>
40
+ </div>
41
+ <%= percentile_description %>
33
42
  #{describe_non_working_days}
34
43
  HTML
35
44
 
@@ -53,8 +62,12 @@ class AgingWorkBarChart < ChartBase
53
62
  .flatten
54
63
  .compact
55
64
 
56
- percentage = calculate_percent_line
57
- percentage_line_x = date_range.end - calculate_percent_line if percentage
65
+ # An item sitting left of one of these lines has been aging longer than that percentage of
66
+ # everything we completed, so the line is drawn that many days back from today. Held on the
67
+ # instance because the description text reads it too, and it must not be computed twice.
68
+ @percentage_lines = percentile_lines.collect do |percentile, days|
69
+ { percentile: percentile, days: days, x: date_range.end - days, id: "percentile_#{percentile}" }
70
+ end
58
71
 
59
72
  if aging_issues.empty?
60
73
  @description_text = '<p>There is no aging work</p>'
@@ -309,17 +322,105 @@ class AgingWorkBarChart < ChartBase
309
322
  BarChartRange.new(
310
323
  start: previous_change.time,
311
324
  stop: stop_time,
312
- color: CssVariable["--priority-color-#{previous_change.value.downcase.gsub(/\s/, '')}"],
325
+ color: priority_color(previous_change.value),
313
326
  title: title,
314
327
  highlight: expedited
315
328
  )
316
329
  end
317
330
 
331
+ # Which percentiles of completed cycle time to mark with a vertical line. An empty list draws
332
+ # none. The lines all share one colour because, unlike the scatterplot, they do not stand for
333
+ # groups; their position is what tells them apart.
334
+ def percentiles list = nil
335
+ @percentiles = validate_percentiles(list) unless list.nil?
336
+ @percentiles
337
+ end
338
+
339
+ # Explains the vertical line or lines, following whatever was configured. Note the caller must
340
+ # be the ERB tag <%= percentile_description %> and NOT string interpolation: description_text is
341
+ # built during initialize, before the config block has run, so interpolation would freeze the
342
+ # default into every report while ignoring what the user asked for.
343
+ def percentile_description
344
+ lines = @percentage_lines
345
+ return '' if lines.empty?
346
+
347
+ swatch = color_block '--aging-work-bar-chart-percentage-line-color'
348
+ if lines.size == 1
349
+ percentile = lines.first[:percentile]
350
+ days = lines.first[:days]
351
+ # <div class="p"> rather than <p>: color_block emits a div, and a div inside a p is invalid
352
+ # HTML, so the browser closes the paragraph early and the rest of the sentence escapes it.
353
+ <<-HTML
354
+ <div class="p">
355
+ The vertical #{swatch} line marks the #{ordinal percentile} percentile of how long
356
+ completed work actually took (#{label_days days}). Anything still in progress that
357
+ extends past it has now been aging longer than #{percentile}% of everything we
358
+ finished, which makes it worth a conversation.
359
+ </div>
360
+ HTML
361
+ else
362
+ described = lines.collect { |line| ordinal line[:percentile] }
363
+ <<-HTML
364
+ <div class="p">
365
+ The vertical #{swatch} lines mark the #{comma_and described} percentiles of how long
366
+ completed work actually took. Anything still in progress that extends past one of them
367
+ has been aging longer than that percentage of everything we finished. Hover a line to
368
+ see which one it is.
369
+ </div>
370
+ HTML
371
+ end
372
+ end
373
+
374
+ # Returns [[percentile, days], ...] for the configured percentiles, dropping any that have no
375
+ # value because nothing completed in range.
376
+ def percentile_lines
377
+ percentiles.filter_map do |percentile|
378
+ days = calculate_percent_line percentage: percentile
379
+ [percentile, days] unless days.nil?
380
+ end
381
+ end
382
+
318
383
  def calculate_percent_line percentage: 85
319
- days = completed_issues_in_range.filter_map { |issue| issue.board.cycletime.cycletime(issue) }.sort
320
- return nil if days.empty?
384
+ percentile_of(
385
+ completed_issues_in_range.filter_map { |issue| issue.board.cycletime.cycletime(issue) },
386
+ percentage
387
+ )
388
+ end
389
+
390
+ # Priority names come from Jira and an admin can define whatever they like, so the variable we
391
+ # build from one may simply not exist, in which case the bar draws black. That is acceptable, but
392
+ # silently is not, so say it once per unknown priority and hand over the line to paste.
393
+ def priority_color priority_name
394
+ key = priority_name.downcase.gsub(/\s/, '')
395
+ warn_about_unknown_priority priority_name, key unless defined_priority_colors.include? key
396
+ CssVariable["--priority-color-#{key}"]
397
+ end
321
398
 
322
- days[days.length * percentage / 100]
399
+ def warn_about_unknown_priority priority_name, key
400
+ @warned_priorities ||= []
401
+ return if @warned_priorities.include? key
402
+
403
+ @warned_priorities << key
404
+ file_system.log(
405
+ "Warning: the priority #{priority_name.inspect} has no colour defined, so it will be drawn " \
406
+ 'in black on the aging work bar chart. That is fine if you do not mind how it looks. To ' \
407
+ 'give it a colour, add this to the CSS file named by your include_css setting: ' \
408
+ ":root { --priority-color-#{key}: #0072B2; }",
409
+ also_write_to_stderr: true
410
+ )
411
+ end
412
+
413
+ # Read from the CSS rather than kept as a list here, so that defining a new priority colour is a
414
+ # CSS edit and the two cannot drift apart. Includes the user's own stylesheet, so defining the
415
+ # colour there silences the warning.
416
+ def defined_priority_colors
417
+ @defined_priority_colors ||=
418
+ begin
419
+ css = File.read File.join(html_directory, 'index.css')
420
+ extra = settings && settings['include_css']
421
+ css += File.read(extra) if extra && File.exist?(extra)
422
+ css.scan(/--priority-color-([a-z0-9]+)\s*:/).flatten.uniq
423
+ end
323
424
  end
324
425
 
325
426
  def age_cutoff days
@@ -13,12 +13,15 @@ class AgingWorkInProgressChart < ChartBase
13
13
  def initialize block
14
14
  super()
15
15
  header_text 'Aging Work in Progress'
16
+ # div class="p" rather than <p>: the notes below are a ul, which is block level and not legal
17
+ # inside a paragraph, so a browser closes the p early. Harmless while the list is the last
18
+ # thing in the block, but it breaks the moment any text follows it.
16
19
  description_text <<-HTML
17
- <p>
20
+ <div class="p">
18
21
  This chart shows only work items that have started but not completed, grouped by the column
19
22
  they're currently in. Hovering over a dot will show you the ID of that work item.
20
- </p>
21
- <p>
23
+ </div>
24
+ <div class="p">
22
25
  The shaded areas indicate what percentage of the work has passed that column within that time.
23
26
  Notes:
24
27
  <ul>
@@ -30,7 +33,7 @@ class AgingWorkInProgressChart < ChartBase
30
33
  backwards athough it could also indicate that a ticket jumped over columns as it moved to the right.
31
34
  </li>
32
35
  </ul>
33
- </p>
36
+ </div>
34
37
  <div style="border: 1px solid gray; padding: 0.2em">
35
38
  <% @percentiles.keys.sort.reverse.each do |percent| %>
36
39
  <span style="padding-left: 0.5em; padding-right: 0.5em; vertical-align: middle;"><%= color_block @percentiles[percent] %> <%= percent %>%</span>
@@ -230,8 +233,9 @@ class AgingWorkInProgressChart < ChartBase
230
233
  end
231
234
 
232
235
  if has_unmapped && @description_text
233
- @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
234
- 'board but are still active. Most likely everyone has forgotten about them.</p>'
236
+ @description_text += "<div class=\"p\">The items shown in #{column_name.inspect} are not " \
237
+ 'visible on the board but are still active. Most likely everyone has forgotten about ' \
238
+ 'them.</div>'
235
239
  else
236
240
  # @column_headings.pop
237
241
  @board_columns.pop
@@ -2,7 +2,11 @@
2
2
 
3
3
  require 'jirametrics/chart_base'
4
4
 
5
+ require 'jirametrics/percentile_validation'
6
+
5
7
  class AgingWorkTable < ChartBase
8
+ include PercentileValidation
9
+
6
10
  attr_accessor :today
7
11
  attr_reader :any_scrum_boards
8
12
 
@@ -11,25 +15,28 @@ class AgingWorkTable < ChartBase
11
15
  @stalled_threshold = 5
12
16
  @dead_threshold = 45
13
17
  @age_cutoff = 0
18
+ @percentile = 85
14
19
 
15
20
  header_text 'Aging Work Table'
21
+ # div class="p" rather than <p>: the legend below is a ul, which is block level and not legal
22
+ # inside a paragraph, so a browser closes the p early.
16
23
  description_text <<-TEXT
17
- <p>
24
+ <div class="p">
18
25
  This chart shows all active (started but not completed) work, ordered from oldest at the top to
19
26
  newest at the bottom.
20
- </p>
21
- <p>
27
+ </div>
28
+ <div class="p">
22
29
  If there are expedited items that haven't yet started then they're at the bottom of the table.
23
30
  By the very definition of expedited, if we haven't started them already, we'd better get on that.
24
- </p>
25
- <p>
31
+ </div>
32
+ <div class="p">
26
33
  Legend:
27
34
  <ul>
28
35
  <li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
29
36
  <li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
30
37
  <li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
31
38
  </ul>
32
- </p>
39
+ </div>
33
40
  TEXT
34
41
 
35
42
  instance_eval(&block)
@@ -125,7 +132,7 @@ class AgingWorkTable < ChartBase
125
132
 
126
133
  def dates_text issue
127
134
  days_remaining, error = @calculators[issue.board.id].forecasted_days_remaining_and_message(
128
- issue: issue, today: @today
135
+ issue: issue, today: @today, percentile: percentile
129
136
  )
130
137
  message = nil
131
138
  message, error = due_date_status(issue, days_remaining, error) unless error
@@ -158,6 +165,14 @@ class AgingWorkTable < ChartBase
158
165
  text
159
166
  end
160
167
 
168
+ # Which percentile of historical column movement the Forecast column is based on. Singular,
169
+ # unlike the charts' "percentiles", because a forecast has to resolve to one number of days: the
170
+ # due date risk calculation compares that single figure against the due date.
171
+ def percentile value = nil
172
+ @percentile = validate_percentile(value) unless value.nil?
173
+ @percentile
174
+ end
175
+
161
176
  def age_cutoff age = nil
162
177
  @age_cutoff = age.to_i if age
163
178
  @age_cutoff
@@ -31,12 +31,16 @@ class BlockedStalledChangeStreamBuilder
31
31
  mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
32
32
 
33
33
  (@changes + [mock_change]).each do |change|
34
- previous_was_active = false if check_for_stalled(
34
+ counts_as_activity = !ignored_for_stalled?(change)
35
+
36
+ if counts_as_activity && check_for_stalled(
35
37
  change_time: change.time,
36
38
  previous_change_time: previous_change_time,
37
39
  stalled_threshold: @settings['stalled_threshold_days'],
38
40
  blocking_stalled_changes: result
39
41
  )
42
+ previous_was_active = false
43
+ end
40
44
 
41
45
  update_blocking_state state, change
42
46
 
@@ -47,13 +51,22 @@ class BlockedStalledChangeStreamBuilder
47
51
  result << new_change if record_change? new_change, previous_was_active, change, mock_change
48
52
 
49
53
  previous_was_active = new_change.active?
50
- previous_change_time = change.time
54
+ # An ignored change must not advance the clock, or it would split one long gap into two
55
+ # short ones and hide a stall.
56
+ previous_change_time = change.time if counts_as_activity
51
57
  end
52
58
 
53
59
  finalize_stalled_tail result
54
60
  result
55
61
  end
56
62
 
63
+ # Some changelog entries are not somebody working on the item. Adding a Jira issue macro to a
64
+ # Confluence page writes a RemoteIssueLink, which should not reset the inactivity clock. They are
65
+ # still processed for blocking, so putting a field here only affects the stalled calculation.
66
+ def ignored_for_stalled? change
67
+ (@settings['stalled_ignored_fields'] || []).include? change.field
68
+ end
69
+
57
70
  def update_blocking_state state, change
58
71
  if change.flagged? && flagged_means_blocked?
59
72
  state.flag, state.flag_reason = flag_logic change
@@ -119,10 +119,13 @@ class BoardMovementCalculator
119
119
  "#{days} day#{'s' unless days == 1}"
120
120
  end
121
121
 
122
- def forecasted_days_remaining_and_message issue:, today:
122
+ # percentile is passed in rather than held on the calculator because this class is shared: the
123
+ # aging work in progress chart builds one too and never forecasts, so it has no business
124
+ # carrying a forecast setting.
125
+ def forecasted_days_remaining_and_message issue:, today:, percentile: 85
123
126
  return [nil, 'Already done'] if issue.done?
124
127
 
125
- likely_age_data = age_data_for percentage: 85
128
+ likely_age_data = age_data_for percentage: percentile
126
129
 
127
130
  column_name, entry_time = find_current_column_and_entry_time_in_column issue
128
131
  return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
@@ -145,14 +148,22 @@ class BoardMovementCalculator
145
148
 
146
149
  remaining_in_current_column = likely_age_data[column_index] - age_in_column
147
150
  if remaining_in_current_column.negative?
148
- message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
149
- "in the #{column_name.inspect} column. Most items on this board have left this column in " \
150
- "#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
151
- remaining_in_current_column = 0
152
- return [nil, message]
151
+ return [nil, outlier_message(
152
+ issue: issue, today: today, column_name: column_name, percentile: percentile,
153
+ column_age: likely_age_data[column_index]
154
+ )]
153
155
  end
154
156
 
155
157
  forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
156
158
  [forecasted_days, message]
157
159
  end
160
+
161
+ # Why we cannot forecast: this item has already been in the column longer than the historical
162
+ # figure we would forecast from. The message names the percentile rather than characterising it
163
+ # as "most", which was only ever a fair description at the default and plainly wrong at the median.
164
+ def outlier_message issue:, today:, column_name:, percentile:, column_age:
165
+ "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
166
+ "in the #{column_name.inspect} column. #{percentile}% of items on this board have left this " \
167
+ "column in #{label_days column_age} or less, so we cannot forecast when it will be done."
168
+ end
158
169
  end
@@ -14,7 +14,7 @@ class ChartBase
14
14
  ].freeze
15
15
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
16
16
  :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
17
- :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
17
+ :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions, :color_palette
18
18
  attr_writer :aggregated_project
19
19
  attr_reader :canvas_width, :canvas_height
20
20
 
@@ -83,7 +83,7 @@ class ChartBase
83
83
  end
84
84
 
85
85
  def color_for type:
86
- @chart_colors[type] ||= random_color
86
+ @chart_colors[type] ||= next_palette_color
87
87
  end
88
88
 
89
89
  # Defines label_days, label_hours, label_minutes and label_issues - each renders a pluralised count
@@ -259,6 +259,38 @@ class ChartBase
259
259
  @description_text
260
260
  end
261
261
 
262
+ # The nearest rank percentile: the smallest value with at least this percentage of the data at
263
+ # or below it. One implementation for the whole product, because three call sites used to
264
+ # compute this separately and two of them overshot by one whenever the rank landed exactly on a
265
+ # boundary, so the same percentile of the same data could read differently on two charts.
266
+ # Returns nil when there is nothing to measure.
267
+ def percentile_of values, percentile
268
+ return nil if values.empty?
269
+
270
+ sorted = values.sort
271
+ rank = (percentile / 100.0 * sorted.size).ceil
272
+ sorted[rank.clamp(1, sorted.size) - 1]
273
+ end
274
+
275
+ # "a and b" for two, "a, b and c" for more. Used when listing configured percentiles in prose.
276
+ def comma_and phrases
277
+ return phrases.join ' and ' if phrases.size <= 2
278
+
279
+ "#{phrases[0..-2].join ', '} and #{phrases.last}"
280
+ end
281
+
282
+ # 1st, 2nd, 3rd, 4th ... 11th, 12th, 13th ... 21st. Percentiles are the main caller and they
283
+ # range over 0..100, so blindly appending "th" would render "1th" and "22th".
284
+ def ordinal number
285
+ suffix =
286
+ if [11, 12, 13].include?(number % 100)
287
+ 'th'
288
+ else
289
+ { 1 => 'st', 2 => 'nd', 3 => 'rd' }.fetch(number % 10, 'th')
290
+ end
291
+ "#{number}#{suffix}"
292
+ end
293
+
262
294
  # Convert a number like 1234567 into the string "1,234,567"
263
295
  def format_integer number
264
296
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
@@ -324,9 +356,11 @@ class ChartBase
324
356
  end
325
357
  end
326
358
 
327
- def random_color
328
- @palette_index = (@palette_index || -1) + 1
329
- OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
359
+ # A colour for something that needs telling apart, where no specific colour is wanted. If a
360
+ # specific colour matters, configure it instead. See ColorPalette for why this is not random and
361
+ # why it must not become random again.
362
+ def next_palette_color
363
+ (@color_palette ||= ColorPalette.default).next_color
330
364
  end
331
365
 
332
366
  def canvas width:, height:, responsive: true
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hands out colours for things that need to be told apart but where no specific colour is wanted.
4
+ # If a specific colour matters it should be configured, not obtained from here.
5
+ #
6
+ # Two properties matter and neither is obvious from the call site:
7
+ #
8
+ # 1. The colours have to stay distinguishable to people with colour vision deficiency. This started
9
+ # life as a genuinely random picker, which regularly produced pairs nobody could tell apart, so
10
+ # it is a curated set and must stay one. Do not "improve" it back into a generator.
11
+ # 2. It returns CssVariable rather than a literal, so the colours follow the light and dark themes
12
+ # and can be overridden by a user's own stylesheet. Baked in hex can do neither.
13
+ #
14
+ # The number of slots is read from the CSS rather than declared here, so adding a colour is a CSS
15
+ # edit and the count never becomes something the Ruby and the CSS have to agree on separately.
16
+ class ColorPalette
17
+ PALETTE_VARIABLE = /--palette-color-(\d+)\s*:/
18
+
19
+ attr_reader :size
20
+
21
+ # For anything holding a chart on its own, outside a report run: tests, and any future standalone
22
+ # use. A report injects a shared instance so the whole page draws from one rotation, but nothing
23
+ # depends on that, because a caller who needs a SPECIFIC colour is supposed to configure it.
24
+ # A FRESH palette each call, not a shared singleton. The rotation is mutable state, so a memoised
25
+ # instance would leak position between charts and, worse, between test examples. Only the parsed
26
+ # stylesheet is cached, since that is the expensive part and it does not change.
27
+ def self.default
28
+ new css: shipped_css
29
+ end
30
+
31
+ def self.shipped_css
32
+ @shipped_css ||= File.read File.join(__dir__, 'html', 'index.css')
33
+ end
34
+
35
+ def initialize css:
36
+ @size = count_slots css
37
+ @index = -1
38
+ end
39
+
40
+ # The next slot, cycling back to the first once they have all been used.
41
+ def next_color
42
+ @index += 1
43
+ CssVariable["--palette-color-#{(@index % @size) + 1}"]
44
+ end
45
+
46
+ private
47
+
48
+ # Only the contiguous run from 1 counts. A theme block that overrides slot 2 is not a third slot,
49
+ # it is the cascade doing its job, so counting distinct numbers is right and counting occurrences
50
+ # would not be. A gap ends the run because the cycle would otherwise land on a slot that no rule
51
+ # defines, and an undefined variable resolves to nothing rather than to a colour.
52
+ def count_slots css
53
+ defined_slots = css.scan(PALETTE_VARIABLE).flatten.map(&:to_i).uniq
54
+ if defined_slots.empty?
55
+ raise 'The CSS defines no --palette-color-N variables, so there are no colours to hand out. ' \
56
+ 'They are normally defined in the :root block of jirametrics/html/index.css.'
57
+ end
58
+
59
+ (1..).take_while { |slot| defined_slots.include? slot }.size
60
+ end
61
+ end
@@ -116,7 +116,7 @@ class CumulativeFlowDiagram < ChartBase
116
116
 
117
117
  daily_marginals = marginal_band_heights(daily_counts, column_count)
118
118
 
119
- border_colors = active_rules.map { |rules| rules.color || random_color }
119
+ border_colors = active_rules.map { |rules| rules.color || next_palette_color }
120
120
 
121
121
  fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
122
122
 
@@ -206,14 +206,17 @@ class CumulativeFlowDiagram < ChartBase
206
206
  )
207
207
  end
208
208
 
209
- def hex_to_rgba hex, alpha
210
- r, g, b = hex.delete_prefix('#').scan(/../).map { |c| c.to_i(16) }
211
- "rgba(#{r}, #{g}, #{b}, #{alpha})"
209
+ # The fill is a translucent version of the border colour. Border colours may now be CssVariable
210
+ # rather than a literal, and Ruby cannot resolve one, so the alpha is applied in the browser.
211
+ def translucent border, alpha
212
+ RawJavascript.new "withAlpha(#{border.to_json}, #{alpha})"
212
213
  end
213
214
 
215
+ # A colour the user set explicitly is used as given. Anything else, including a palette colour,
216
+ # gets the translucent treatment.
214
217
  def fill_color_for rules, border
215
- if rules.color.nil? || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
216
- hex_to_rgba(border, 0.35)
218
+ if rules.color.nil? || rules.color.is_a?(CssVariable) || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
219
+ translucent border, 0.35
217
220
  else
218
221
  rules.color
219
222
  end
@@ -14,14 +14,8 @@ class CycletimeScatterplot < TimeBasedScatterplot
14
14
  This chart shows only completed work and indicates both what day it completed as well as
15
15
  how many days it took to get done. Hovering over a dot will show you the ID of the work item.
16
16
  </div>
17
- <div class="p">
18
- The #{color_block '--cycletime-scatterplot-overall-trendline-color'} line indicates the 85th
19
- percentile (<%= overall_percent_line %> days). 85% of all
20
- items on this chart fall on or below the line and the remaining 15% are above the line. 85%
21
- is a reasonable proxy for "most" so that we can say that based on this data set, we can
22
- predict that most work of this type will complete in <%= overall_percent_line %> days or
23
- less. The other lines reflect the 85% line for that respective type of work.
24
- </div>
17
+ <%= percentile_description %>
18
+ <%= trend_line_description %>
25
19
  #{describe_non_working_days}
26
20
  HTML
27
21
  @x_axis_title = 'Date completed'
@@ -65,4 +59,69 @@ class CycletimeScatterplot < TimeBasedScatterplot
65
59
 
66
60
  # Kept for backwards compatibility with existing callers and specs
67
61
  alias data_for_issue data_for_item
62
+
63
+ # The number that the "reasonable proxy for most" claim is actually about. That claim is only
64
+ # defensible near this value: at the median half the work runs longer, and at the 98th you are
65
+ # describing the worst case, not the typical one. So the sentence appears when this percentile
66
+ # is on the chart and is silently dropped when it is not, rather than being reworded into
67
+ # something that sounds authoritative and is wrong.
68
+ PROXY_FOR_MOST_PERCENTILE = 85
69
+
70
+ # The prose follows the configuration, so it has to read well for one percentile or several,
71
+ # and say nothing at all for none. Values come from percentage_lines because run has already
72
+ # computed them, and because this string is not run through ERB a second time.
73
+ def percentile_description
74
+ overall_lines = percentage_lines.select { |line| line[:dataset_index].nil? }
75
+ return '' if overall_lines.empty?
76
+
77
+ sentences = [
78
+ percentile_summary_sentence(overall_lines),
79
+ proxy_for_most_sentence(overall_lines),
80
+ per_type_sentence(overall_lines)
81
+ ].compact
82
+ <<-HTML
83
+ <div class="p">
84
+ #{sentences.join ' '}
85
+ </div>
86
+ HTML
87
+ end
88
+
89
+ # Mechanical and true whatever the configured percentiles are. Singular phrasing is preserved
90
+ # word for word from the original so the default chart reads exactly as it always has.
91
+ def percentile_summary_sentence lines
92
+ swatch = color_block '--cycletime-scatterplot-overall-trendline-color'
93
+ if lines.size == 1
94
+ percentile = lines.first[:percentile]
95
+ "The #{swatch} line indicates the #{ordinal percentile} percentile " \
96
+ "(#{lines.first[:value]} days). #{percentile}% of all items on this chart fall on or " \
97
+ "below the line and the remaining #{100 - percentile}% are above the line."
98
+ else
99
+ # No values inline here. With several lines the sentence turns into a wall of parentheses,
100
+ # and each value is already on the line's hover label and in that group's legend entry.
101
+ described = lines.collect { |line| ordinal line[:percentile] }
102
+ "The #{swatch} lines indicate the #{comma_and described} percentiles of all items on " \
103
+ 'this chart. For each line, that percentage of items fall on or below it and the rest ' \
104
+ 'are above.'
105
+ end
106
+ end
107
+
108
+ # Only emitted when 85 is actually among the configured percentiles. See PROXY_FOR_MOST_PERCENTILE.
109
+ def proxy_for_most_sentence lines
110
+ line = lines.find { |candidate| candidate[:percentile] == PROXY_FOR_MOST_PERCENTILE }
111
+ return nil unless line
112
+
113
+ "#{PROXY_FOR_MOST_PERCENTILE}% is a " \
114
+ '<a href="https://jirametrics.org/faq/#why-85">reasonable proxy</a> for "most" so that we ' \
115
+ 'can say that based on this data set, we can predict that most work of this type will ' \
116
+ "complete in #{line[:value]} days or less."
117
+ end
118
+
119
+ def per_type_sentence lines
120
+ if lines.size == 1
121
+ "The other lines reflect the #{lines.first[:percentile]}% line for that respective type of work."
122
+ else
123
+ 'Each type of work also gets its own lines in its own colour, at whichever percentiles ' \
124
+ 'were configured for that type. Hover any line to see which one it is and its value.'
125
+ end
126
+ end
68
127
  end
@@ -150,7 +150,7 @@ class DailyWipChart < ChartBase
150
150
  end
151
151
 
152
152
  def background_color_for grouping_rule
153
- color = grouping_rule.color || random_color
153
+ color = grouping_rule.color || next_palette_color
154
154
  return color unless grouping_rule.highlight
155
155
 
156
156
  RawJavascript.new("createDiagonalPattern(#{color.to_json})")
@@ -119,7 +119,7 @@ class DependencyChart < ChartBase
119
119
  'Defect' => '#ffdab9',
120
120
  'Epic' => '#fafad2',
121
121
  'Spike' => '#DDA0DD' # light purple
122
- }[type] ||= random_color
122
+ }[type] ||= next_palette_color
123
123
  end
124
124
 
125
125
  def build_dot_graph