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.
- 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 +1 -1
- 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 +32 -5
- data/lib/jirametrics/html/index.erb +6 -2
- data/lib/jirametrics/html/index.js +16 -0
- data/lib/jirametrics/html/time_based_histogram.erb +26 -14
- 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 +33 -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
|
@@ -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
|
|
@@ -98,19 +122,13 @@ class TimeBasedHistogram < TimeBasedChart
|
|
|
98
122
|
sorted_by_frequency.select { |_value, frequency| frequency == max_frequency }.collect(&:first).sort
|
|
99
123
|
end
|
|
100
124
|
|
|
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
|
|
125
|
+
# The data arrives as value => count. Expanded back to a flat list so that the one shared
|
|
126
|
+
# percentile implementation is used here too: this chart and the scatterplot must not report
|
|
127
|
+
# different answers for the same percentile of the same data. Chart sized data makes the
|
|
128
|
+
# expansion cheap.
|
|
129
|
+
def percentiles_for histogram_data, percentiles, _total_values
|
|
130
|
+
values = histogram_data.flat_map { |value, count| Array.new(count, value) }
|
|
131
|
+
percentiles.to_h { |percentile| [percentile, percentile_of(values, percentile)] }
|
|
114
132
|
end
|
|
115
133
|
|
|
116
134
|
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: '3.
|
|
4
|
+
version: '3.3'
|
|
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
|