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
|
@@ -36,20 +36,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
36
36
|
annotation: {
|
|
37
37
|
annotations: {
|
|
38
38
|
<%
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
content:
|
|
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,23 +93,27 @@ if show_stats
|
|
|
83
93
|
<th>Max</th>
|
|
84
94
|
<th>Avg</th>
|
|
85
95
|
<th>Mode</th>
|
|
86
|
-
<% percentiles.each do |
|
|
87
|
-
<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| %>
|
|
91
101
|
<tr>
|
|
92
102
|
<td><%= k %></td>
|
|
93
|
-
<td style="text-align: right;"><%= v[:min] %></td>
|
|
94
|
-
<td style="text-align: right;"><%= v[:max] %></td>
|
|
95
|
-
<td style="text-align: right;"><%=
|
|
96
|
-
<td><%= v[:mode].join
|
|
97
|
-
<% percentiles.each do |
|
|
98
|
-
<td style="text-align: right;"><%= v
|
|
103
|
+
<td style="text-align: right;"><%= stats_cell v[:min] %></td>
|
|
104
|
+
<td style="text-align: right;"><%= stats_cell v[:max] %></td>
|
|
105
|
+
<td style="text-align: right;"><%= stats_cell(v[:average]) { |average| format '%.2f', average } %></td>
|
|
106
|
+
<td><%= stats_cell(v[:mode]) { |modes| modes.join ', ' } %></td>
|
|
107
|
+
<% percentiles.each do |percentile| %>
|
|
108
|
+
<td style="text-align: right;"><%= stats_cell v.dig(:percentiles, percentile) %></td>
|
|
99
109
|
<% end %>
|
|
100
110
|
</tr>
|
|
101
111
|
<% end %>
|
|
102
112
|
</table>
|
|
113
|
+
<% if any_empty_stats? the_stats %>
|
|
114
|
+
<p><b>–</b> means no usable data for that group. Every item in it was excluded from the histogram
|
|
115
|
+
because it had no measurable cycle time. The Data Quality report has the details.</p>
|
|
116
|
+
<% end %>
|
|
103
117
|
</div>
|
|
104
118
|
<div>
|
|
105
119
|
<p>These statistics help understand the <i>"shape"</i> of the histogram distribution, to help us with predictions.</p>
|
|
@@ -107,12 +121,14 @@ if show_stats
|
|
|
107
121
|
<li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
|
|
108
122
|
<li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
|
|
109
123
|
<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
|
-
|
|
124
|
+
<% unless percentiles.empty? %>
|
|
125
|
+
<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
126
|
<ul>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
<% percentiles.each do |percentile| %>
|
|
128
|
+
<li><b><%= percentile %>%</b>: <%= percentile_explanation percentile %></li>
|
|
129
|
+
<% end %>
|
|
115
130
|
</ul>
|
|
131
|
+
<% end %>
|
|
116
132
|
</ul>
|
|
117
133
|
</div>
|
|
118
134
|
</div>
|
|
@@ -129,15 +129,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
129
129
|
},
|
|
130
130
|
<% end %>
|
|
131
131
|
|
|
132
|
-
<% @percentage_lines.
|
|
133
|
-
|
|
134
|
-
line<%= index %>: {
|
|
132
|
+
<% @percentage_lines.each do |line| %>
|
|
133
|
+
<%= line[:id].to_json %>: {
|
|
135
134
|
type: 'line',
|
|
136
|
-
yMin: <%=
|
|
137
|
-
yMax: <%=
|
|
138
|
-
borderColor: <%= color.to_json %>,
|
|
135
|
+
yMin: <%= line[:value] %>,
|
|
136
|
+
yMax: <%= line[:value] %>,
|
|
137
|
+
borderColor: <%= line[:color].to_json %>,
|
|
139
138
|
borderWidth: 1,
|
|
140
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
require 'jirametrics/percentile_validation'
|
|
4
5
|
require 'jirametrics/time_based_chart'
|
|
5
6
|
|
|
6
7
|
class TimeBasedHistogram < TimeBasedChart
|
|
7
8
|
include GroupableIssueChart
|
|
9
|
+
include PercentileValidation
|
|
8
10
|
|
|
9
11
|
attr_reader :show_stats
|
|
10
12
|
|
|
@@ -20,8 +22,11 @@ class TimeBasedHistogram < TimeBasedChart
|
|
|
20
22
|
@x_axis_title = title
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
# Which percentiles to show as columns in the statistics table. An empty list drops the columns
|
|
26
|
+
# entirely. Values are validated here rather than at use, because they feed the percentile
|
|
27
|
+
# arithmetic and the table headers, where a bad one produces a wrong chart instead of an error.
|
|
28
|
+
def percentiles list = nil
|
|
29
|
+
@percentiles = validate_percentiles(list) unless list.nil?
|
|
25
30
|
@percentiles
|
|
26
31
|
end
|
|
27
32
|
|
|
@@ -29,6 +34,25 @@ class TimeBasedHistogram < TimeBasedChart
|
|
|
29
34
|
@show_stats = false
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# What a given percentile is actually good for. These used to be a hardcoded list describing
|
|
38
|
+
# the 50th, 85th and 98th, sitting underneath a table whose columns follow the configuration,
|
|
39
|
+
# so the two drifted apart the moment anyone changed the setting. Bands rather than exact
|
|
40
|
+
# values, because 90 deserves an answer just as much as 85 does.
|
|
41
|
+
def percentile_explanation percentile
|
|
42
|
+
case percentile
|
|
43
|
+
when 0..49
|
|
44
|
+
'below the median, so half or more of your work takes longer than this. Useful for ' \
|
|
45
|
+
'understanding your faster cases, but not a number to plan around.'
|
|
46
|
+
when 50
|
|
47
|
+
'also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor ' \
|
|
48
|
+
"that it's not drifting to the right."
|
|
49
|
+
when 51..94
|
|
50
|
+
'useful to establish service level expectations, accounting for rare events.'
|
|
51
|
+
else
|
|
52
|
+
'useful to gauge worst case expectations.'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
def run
|
|
33
57
|
histogram_items = all_items
|
|
34
58
|
rules_to_items = group_issues histogram_items
|
|
@@ -70,6 +94,20 @@ class TimeBasedHistogram < TimeBasedChart
|
|
|
70
94
|
items_hash
|
|
71
95
|
end
|
|
72
96
|
|
|
97
|
+
# One cell of the statistics table. A group can survive grouping and still have nothing to plot,
|
|
98
|
+
# when every item in it was excluded for having no measurable cycle time, and then there are no
|
|
99
|
+
# statistics to show for it at all. Dashing the cells keeps the group visible: dropping the row
|
|
100
|
+
# would make it look like the group had never existed.
|
|
101
|
+
def stats_cell value
|
|
102
|
+
return '–' if value.nil?
|
|
103
|
+
|
|
104
|
+
block_given? ? yield(value) : value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def any_empty_stats? the_stats
|
|
108
|
+
the_stats.any? { |_label, stats| stats.empty? }
|
|
109
|
+
end
|
|
110
|
+
|
|
73
111
|
def stats_for histogram_data:, percentiles:
|
|
74
112
|
return {} if histogram_data.empty?
|
|
75
113
|
|
|
@@ -98,19 +136,13 @@ class TimeBasedHistogram < TimeBasedChart
|
|
|
98
136
|
sorted_by_frequency.select { |_value, frequency| frequency == max_frequency }.collect(&:first).sort
|
|
99
137
|
end
|
|
100
138
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
percentiles.to_h do |percentile|
|
|
111
|
-
rank = (percentile / 100.0) * total_values
|
|
112
|
-
[percentile, sorted_values.find { |value| cumulative_counts[value] >= rank }]
|
|
113
|
-
end
|
|
139
|
+
# The data arrives as value => count. Expanded back to a flat list so that the one shared
|
|
140
|
+
# percentile implementation is used here too: this chart and the scatterplot must not report
|
|
141
|
+
# different answers for the same percentile of the same data. Chart sized data makes the
|
|
142
|
+
# expansion cheap.
|
|
143
|
+
def percentiles_for histogram_data, percentiles, _total_values
|
|
144
|
+
values = histogram_data.flat_map { |value, count| Array.new(count, value) }
|
|
145
|
+
percentiles.to_h { |percentile| [percentile, percentile_of(values, percentile)] }
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
def sort_items items
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
require 'jirametrics/percentile_validation'
|
|
4
5
|
require 'jirametrics/time_based_chart'
|
|
5
6
|
|
|
6
7
|
class TimeBasedScatterplot < TimeBasedChart
|
|
7
8
|
include GroupableIssueChart
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# What the whole-data-set percentile lines call themselves when you hover them. They have no
|
|
11
|
+
# legend entry, so without this there is nothing identifying them at all. "items" rather than
|
|
12
|
+
# "data" or "everything" because it matches the description prose and because anything a
|
|
13
|
+
# grouping rule ignored has already been dropped by the time these lines are calculated.
|
|
14
|
+
OVERALL_LABEL = 'All items'
|
|
15
|
+
include PercentileValidation
|
|
16
|
+
|
|
17
|
+
# percentage_lines is internal, not part of the documented config DSL. It exists so that specs
|
|
18
|
+
# and the ERB can see the computed lines without reaching into instance variables. Its shape,
|
|
19
|
+
# including the :id strings that encode positional group indices, is free to change.
|
|
20
|
+
attr_reader :y_axis_cap_percentile, :percentage_lines
|
|
10
21
|
|
|
11
22
|
def initialize
|
|
12
23
|
super
|
|
13
24
|
|
|
14
25
|
@percentage_lines = []
|
|
15
26
|
@highest_y_value = 0
|
|
27
|
+
@percentiles = [85]
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
# On a scatterplot the cycle time is plotted up the y-axis.
|
|
@@ -24,11 +36,25 @@ class TimeBasedScatterplot < TimeBasedChart
|
|
|
24
36
|
@y_axis_cap_percentile = percentile
|
|
25
37
|
end
|
|
26
38
|
|
|
39
|
+
# Percentile reference lines. The chart level value defines the lines drawn across the whole
|
|
40
|
+
# data set AND the default for each group; a group can override with rule.percentiles.
|
|
41
|
+
# An empty list switches the lines off.
|
|
42
|
+
def percentiles list = nil
|
|
43
|
+
@percentiles = validate_percentiles(list) unless list.nil?
|
|
44
|
+
@percentiles
|
|
45
|
+
end
|
|
46
|
+
|
|
27
47
|
def run
|
|
28
48
|
items = all_items
|
|
29
49
|
data_sets = create_datasets items
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
overall_color = CssVariable['--cycletime-scatterplot-overall-trendline-color']
|
|
51
|
+
|
|
52
|
+
percentile_lines_for(items, @percentiles).each do |percentile, value|
|
|
53
|
+
@percentage_lines << {
|
|
54
|
+
percentile: percentile, value: value, color: overall_color,
|
|
55
|
+
id: "overall_#{percentile}", dataset_index: nil, label: OVERALL_LABEL
|
|
56
|
+
}
|
|
57
|
+
end
|
|
32
58
|
|
|
33
59
|
if data_sets.empty?
|
|
34
60
|
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
@@ -42,13 +68,17 @@ class TimeBasedScatterplot < TimeBasedChart
|
|
|
42
68
|
@cap = compute_cap items
|
|
43
69
|
data_sets = []
|
|
44
70
|
|
|
45
|
-
group_issues(items).
|
|
71
|
+
group_issues(items).each_with_index do |(rules, items_by_type), group_index|
|
|
46
72
|
label = rules.label
|
|
47
73
|
color = rules.color
|
|
48
|
-
|
|
74
|
+
lines = percentile_lines_for items_by_type, (rules.percentiles || @percentiles)
|
|
49
75
|
data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
|
|
76
|
+
|
|
77
|
+
# Where this group's scatter set is about to land. The legend handler knows the clicked
|
|
78
|
+
# dataset by index, so that's what the annotation map is keyed by.
|
|
79
|
+
dataset_index = data_sets.size
|
|
50
80
|
data_sets << {
|
|
51
|
-
label:
|
|
81
|
+
label: percentile_label(label, lines),
|
|
52
82
|
data: data,
|
|
53
83
|
fill: false,
|
|
54
84
|
showLine: false,
|
|
@@ -57,15 +87,64 @@ class TimeBasedScatterplot < TimeBasedChart
|
|
|
57
87
|
|
|
58
88
|
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
59
89
|
|
|
60
|
-
|
|
90
|
+
lines.each do |percentile, value|
|
|
91
|
+
@percentage_lines << {
|
|
92
|
+
percentile: percentile, value: value, color: color,
|
|
93
|
+
id: "group#{group_index}_#{percentile}", dataset_index: dataset_index, label: label
|
|
94
|
+
}
|
|
95
|
+
end
|
|
61
96
|
end
|
|
62
97
|
data_sets
|
|
63
98
|
end
|
|
64
99
|
|
|
100
|
+
# "Story (85% at 81 days)" for one, comma separated for several, bare label for none.
|
|
101
|
+
def percentile_label label, lines
|
|
102
|
+
return label if lines.empty?
|
|
103
|
+
|
|
104
|
+
parts = lines.collect { |percentile, value| "#{percentile}% at #{label_days value}" }
|
|
105
|
+
"#{label} (#{parts.join ', '})"
|
|
106
|
+
end
|
|
107
|
+
|
|
65
108
|
def show_trend_lines
|
|
66
109
|
@show_trend_lines = true
|
|
67
110
|
end
|
|
68
111
|
|
|
112
|
+
# The lines are always built but drawn hidden unless asked for, so this stays empty until they
|
|
113
|
+
# are actually switched on. Note the caller must be the ERB tag <%= trend_line_description %>:
|
|
114
|
+
# description_text is built during initialize, before the config block has called
|
|
115
|
+
# show_trend_lines, so interpolation would freeze "off" in permanently.
|
|
116
|
+
def trend_line_description
|
|
117
|
+
return '' unless @show_trend_lines
|
|
118
|
+
|
|
119
|
+
<<-HTML
|
|
120
|
+
<div class="p">
|
|
121
|
+
The dashed lines are trend lines, one per group in that group's colour. Each is a straight
|
|
122
|
+
line fitted through that group's dots, so the slope tells you whether cycle times have been
|
|
123
|
+
getting longer or shorter across the period shown. A line sloping up means work of that
|
|
124
|
+
kind has been taking progressively longer to finish.
|
|
125
|
+
</div>
|
|
126
|
+
<div class="p">
|
|
127
|
+
Read the slope as a description of this window rather than a prediction. A line is drawn
|
|
128
|
+
whenever a group has at least three dots and nothing checks how well it actually fits
|
|
129
|
+
them, so a scattered cloud with no real trend in it still gets a confident looking line.
|
|
130
|
+
It is a straight line, so it cannot show a trend that changed direction partway through,
|
|
131
|
+
and a handful of unusually long items will tilt it noticeably. If the slope surprises you,
|
|
132
|
+
look at the dots before you believe it.
|
|
133
|
+
</div>
|
|
134
|
+
HTML
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Dataset index to the annotation ids belonging to that dataset's group, so the legend handler
|
|
138
|
+
# can toggle all of a group's lines. Keyed by index rather than by label because two groups may
|
|
139
|
+
# legitimately share a label while differing in colour, and keying by label would then toggle
|
|
140
|
+
# both of them at once. Overall lines are deliberately absent; they are not owned by any group
|
|
141
|
+
# and stay visible when a group is switched off.
|
|
142
|
+
def legend_annotation_map
|
|
143
|
+
@percentage_lines.reject { |line| line[:dataset_index].nil? }
|
|
144
|
+
.group_by { |line| line[:dataset_index] }
|
|
145
|
+
.transform_values { |lines| lines.collect { |line| line[:id] } }
|
|
146
|
+
end
|
|
147
|
+
|
|
69
148
|
def trend_line_data_set label:, data:, color:
|
|
70
149
|
points = data.collect do |hash|
|
|
71
150
|
[Time.parse(hash[:x]).to_i, hash[:true_y] || hash[:y]]
|
|
@@ -120,16 +199,19 @@ class TimeBasedScatterplot < TimeBasedChart
|
|
|
120
199
|
point
|
|
121
200
|
end
|
|
122
201
|
|
|
123
|
-
|
|
124
|
-
|
|
202
|
+
# Returns [[percentile, value], ...] for the requested percentiles, sorted ascending by
|
|
203
|
+
# percentile and dropping any that have no value because the item list is empty after
|
|
204
|
+
# filtering. Sorting happens here, not in the caller, because GroupingRules#percentiles is
|
|
205
|
+
# user-assigned with no ordering guarantee.
|
|
206
|
+
def percentile_lines_for items, percentiles
|
|
207
|
+
percentiles.sort.filter_map do |percentile|
|
|
208
|
+
value = percentile_value items, percentile
|
|
209
|
+
[percentile, value] unless value.nil?
|
|
210
|
+
end
|
|
125
211
|
end
|
|
126
212
|
|
|
127
213
|
def percentile_value items, percentile
|
|
128
|
-
|
|
129
|
-
return nil if values.empty?
|
|
130
|
-
|
|
131
|
-
index = [values.size * percentile / 100, values.size - 1].min
|
|
132
|
-
values.sort[index]
|
|
214
|
+
percentile_of filtered_values(items), percentile
|
|
133
215
|
end
|
|
134
216
|
|
|
135
217
|
def compute_cap items
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
class TrendLineCalculator
|
|
4
4
|
# Using math from https://math.stackexchange.com/questions/204020/what-is-the-equation-used-to-calculate-a-linear-trendline
|
|
5
5
|
|
|
6
|
+
# Three points minimum. Two will always fit a straight line perfectly, so the result looks
|
|
7
|
+
# authoritative while carrying no evidence of a trend at all; drawing it was misleading.
|
|
8
|
+
MINIMUM_POINTS = 3
|
|
9
|
+
|
|
6
10
|
def initialize points
|
|
7
|
-
|
|
8
|
-
@valid = points.size >= 2
|
|
11
|
+
@valid = points.size >= MINIMUM_POINTS
|
|
9
12
|
return unless valid?
|
|
10
13
|
|
|
11
14
|
sum_of_x = points.sum { |x, _y| x }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jirametrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
@@ -122,6 +122,7 @@ files:
|
|
|
122
122
|
- lib/jirametrics/cfd_data_builder.rb
|
|
123
123
|
- lib/jirametrics/change_item.rb
|
|
124
124
|
- lib/jirametrics/chart_base.rb
|
|
125
|
+
- lib/jirametrics/color_palette.rb
|
|
125
126
|
- lib/jirametrics/columns_config.rb
|
|
126
127
|
- lib/jirametrics/css_variable.rb
|
|
127
128
|
- lib/jirametrics/cumulative_flow_diagram.rb
|
|
@@ -181,6 +182,7 @@ files:
|
|
|
181
182
|
- lib/jirametrics/issue_printer.rb
|
|
182
183
|
- lib/jirametrics/jira_gateway.rb
|
|
183
184
|
- lib/jirametrics/mcp_server.rb
|
|
185
|
+
- lib/jirametrics/percentile_validation.rb
|
|
184
186
|
- lib/jirametrics/project_config.rb
|
|
185
187
|
- lib/jirametrics/pull_request.rb
|
|
186
188
|
- lib/jirametrics/pull_request_cycle_time_histogram.rb
|