jirametrics 3.2 → 3.3.1
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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +111 -10
- data/lib/jirametrics/aging_work_in_progress_chart.rb +10 -6
- data/lib/jirametrics/aging_work_table.rb +22 -7
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +15 -2
- data/lib/jirametrics/board_movement_calculator.rb +18 -7
- data/lib/jirametrics/chart_base.rb +39 -5
- data/lib/jirametrics/color_palette.rb +61 -0
- data/lib/jirametrics/cumulative_flow_diagram.rb +9 -6
- data/lib/jirametrics/cycletime_scatterplot.rb +67 -8
- data/lib/jirametrics/daily_wip_chart.rb +1 -1
- data/lib/jirametrics/dependency_chart.rb +106 -15
- data/lib/jirametrics/exporter.rb +77 -11
- data/lib/jirametrics/groupable_issue_chart.rb +27 -2
- data/lib/jirametrics/grouping_rules.rb +13 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +16 -5
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +1 -1
- data/lib/jirametrics/html/index.css +77 -5
- data/lib/jirametrics/html/index.erb +6 -2
- data/lib/jirametrics/html/index.js +16 -0
- data/lib/jirametrics/html/legacy_colors.css +18 -0
- data/lib/jirametrics/html/time_based_histogram.erb +34 -18
- data/lib/jirametrics/html/time_based_scatterplot.erb +29 -9
- data/lib/jirametrics/html_generator.rb +20 -1
- data/lib/jirametrics/html_report_config.rb +3 -3
- data/lib/jirametrics/percentile_validation.rb +26 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
- data/lib/jirametrics/settings.json +1 -0
- data/lib/jirametrics/time_based_histogram.rb +47 -15
- data/lib/jirametrics/time_based_scatterplot.rb +96 -14
- data/lib/jirametrics/trend_line_calculator.rb +5 -2
- data/lib/jirametrics/wip_by_column_chart.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a2003eb5e447333b282b856e553c7f3f68792da373ce3b563c7f9e4e56dec45
|
|
4
|
+
data.tar.gz: 50b443fbcad6fe9dff8b247a7e0662cfed8c41724bb1e8dbd5d17c33a58ff416
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdaa2c39f21752d0a1a815dc39dd10235cea508f873d89a32d722ea5db4d0d59eef1738febdffde062eaa31cb9b8c24598e2e0b96ccce478a296fe255c0b7997
|
|
7
|
+
data.tar.gz: 8a9013ed9d3bd5ebef3d2a70c1b870ec5624125d0f2fb5989d375e4d005b7259646d66b24d9babcf49d794671dbc65505770b16c5438624406e4dd21e182b98a
|
|
@@ -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
|
-
</
|
|
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
|
-
</
|
|
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
|
|
57
|
-
|
|
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:
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
|
234
|
-
'board but are still active. Most likely everyone has forgotten about
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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] ||=
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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 ||
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 ||
|
|
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})")
|