jirametrics 3.1.1 → 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 +40 -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 +102 -11
  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 +153 -16
  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
@@ -106,9 +106,8 @@ class HtmlReportConfig < HtmlGenerator
106
106
  @file_config.project_config.exporter.timezone_offset
107
107
  end
108
108
 
109
- def random_color
110
- @palette_index = (@palette_index || -1) + 1
111
- ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
109
+ def next_palette_color
110
+ color_palette.next_color
112
111
  end
113
112
 
114
113
  def html string, type: :body
@@ -137,6 +136,7 @@ class HtmlReportConfig < HtmlGenerator
137
136
  project_config = @file_config.project_config
138
137
 
139
138
  chart.file_system = file_system
139
+ chart.color_palette = color_palette
140
140
  chart.issues = issues
141
141
  chart.time_range = project_config.time_range
142
142
  chart.timezone_offset = timezone_offset
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Percentile lists arrive from two different places in the config DSL: the chart level
4
+ # "percentiles [50, 85]" setter and "rule.percentiles = [...]" inside a grouping_rules block.
5
+ # Both are user supplied and both end up as JavaScript annotation ids in the rendered chart, so
6
+ # both need the same guard rails. Including this module gives you a private validate_percentiles.
7
+ module PercentileValidation
8
+ module_function
9
+
10
+ # Returns the cleaned up list. Raises ArgumentError, naming the offending value, for anything
11
+ # that isn't an Integer between 0 and 100.
12
+ def validate_percentiles list
13
+ list.each do |percentile|
14
+ raise ArgumentError, "percentile #{percentile} must be an integer" unless percentile.is_a? Integer
15
+
16
+ raise ArgumentError, "percentile #{percentile} must be between 0 and 100" unless percentile.between?(0, 100)
17
+ end
18
+ list.uniq.sort
19
+ end
20
+
21
+ # For the places where exactly one value is meaningful, such as a forecast that has to produce a
22
+ # single number of days. Returns the value; raises the same errors as the list form.
23
+ def validate_percentile value
24
+ validate_percentiles([value]).first
25
+ end
26
+ end
@@ -13,6 +13,7 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
13
13
  <div class="p">
14
14
  This graph shows the cycle time for all closed pull requests (time from opened to closed).
15
15
  </div>
16
+ <%= trend_line_description %>
16
17
  #{describe_non_working_days}
17
18
  HTML
18
19
 
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "stalled_threshold_days": 5,
3
3
  "stalled_statuses": [],
4
+ "stalled_ignored_fields": ["RemoteIssueLink"],
4
5
 
5
6
  "blocked_link_text": ["is blocked by"],
6
7
  "blocked_statuses": [],
@@ -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
- def percentiles percs = nil
24
- @percentiles = percs unless percs.nil?
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
- def percentiles_for histogram_data, percentiles, total_values
102
- sorted_values = histogram_data.keys.sort
103
- cumulative_counts = {}
104
- cumulative_sum = 0
105
- sorted_values.each do |value|
106
- cumulative_sum += histogram_data[value]
107
- cumulative_counts[value] = cumulative_sum
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,16 +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
 
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
21
+
9
22
  def initialize
10
23
  super
11
24
 
12
25
  @percentage_lines = []
13
26
  @highest_y_value = 0
27
+ @percentiles = [85]
14
28
  end
15
29
 
16
30
  # On a scatterplot the cycle time is plotted up the y-axis.
@@ -18,11 +32,29 @@ class TimeBasedScatterplot < TimeBasedChart
18
32
  @y_axis_title = title
19
33
  end
20
34
 
35
+ def cap_y_axis percentile: 98
36
+ @y_axis_cap_percentile = percentile
37
+ end
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
+
21
47
  def run
22
48
  items = all_items
23
49
  data_sets = create_datasets items
24
- overall_percent_line = calculate_percent_line(items)
25
- @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
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
26
58
 
27
59
  if data_sets.empty?
28
60
  return "<h1 class='foldable'>#{@header_text}</h1>" \
@@ -33,15 +65,20 @@ class TimeBasedScatterplot < TimeBasedChart
33
65
  end
34
66
 
35
67
  def create_datasets items
68
+ @cap = compute_cap items
36
69
  data_sets = []
37
70
 
38
- group_issues(items).each do |rules, items_by_type|
71
+ group_issues(items).each_with_index do |(rules, items_by_type), group_index|
39
72
  label = rules.label
40
73
  color = rules.color
41
- percent_line = calculate_percent_line items_by_type
74
+ lines = percentile_lines_for items_by_type, (rules.percentiles || @percentiles)
42
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
43
80
  data_sets << {
44
- label: "#{label} (85% at #{label_days(percent_line)})",
81
+ label: percentile_label(label, lines),
45
82
  data: data,
46
83
  fill: false,
47
84
  showLine: false,
@@ -50,25 +87,74 @@ class TimeBasedScatterplot < TimeBasedChart
50
87
 
51
88
  data_sets << trend_line_data_set(label: label, data: data, color: color)
52
89
 
53
- @percentage_lines << [percent_line, color]
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
54
96
  end
55
97
  data_sets
56
98
  end
57
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
+
58
108
  def show_trend_lines
59
109
  @show_trend_lines = true
60
110
  end
61
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
+
62
148
  def trend_line_data_set label:, data:, color:
63
149
  points = data.collect do |hash|
64
- [Time.parse(hash[:x]).to_i, hash[:y]]
150
+ [Time.parse(hash[:x]).to_i, hash[:true_y] || hash[:y]]
65
151
  end
66
152
 
67
153
  # The trend calculation works with numbers only so convert Time to an int and back
68
154
  calculator = TrendLineCalculator.new(points)
69
155
  data_points = calculator.chart_datapoints(
70
156
  range: time_range.begin.to_i..time_range.end.to_i,
71
- max_y: @highest_y_value
157
+ max_y: (@cap ? @cap[:cutoff] : @highest_y_value)
72
158
  )
73
159
  data_points.each do |point_hash|
74
160
  point_hash[:x] = chart_format Time.at(point_hash[:x])
@@ -97,20 +183,71 @@ class TimeBasedScatterplot < TimeBasedChart
97
183
  min = minimum_y_value
98
184
  return nil if min && y < min
99
185
 
100
- @highest_y_value = y if @highest_y_value < y
186
+ over = @cap && y > @cap[:cutoff]
187
+ plotted_y = over ? @cap[:pin_row] : y
188
+ @highest_y_value = plotted_y if @highest_y_value < plotted_y
101
189
 
102
- {
103
- y: y,
190
+ point = {
191
+ y: plotted_y,
104
192
  x: chart_format(x_value(item)),
105
193
  title: [title_value(item, rules: rules)]
106
194
  }
195
+ if over
196
+ point[:over] = true
197
+ point[:true_y] = y
198
+ end
199
+ point
200
+ end
201
+
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
211
+ end
212
+
213
+ def percentile_value items, percentile
214
+ percentile_of filtered_values(items), percentile
215
+ end
216
+
217
+ def compute_cap items
218
+ return nil unless @y_axis_cap_percentile
219
+
220
+ cutoff = percentile_value items, @y_axis_cap_percentile
221
+ return nil unless cutoff
222
+
223
+ values = filtered_values(items)
224
+ outlier_count = values.count { |value| value > cutoff }
225
+ return nil if outlier_count.zero?
226
+
227
+ pad = cutoff * 0.06 # breathing room so the top real dot does not touch the break
228
+ gutter_height = cutoff * 0.15
229
+ sep = cutoff + pad
230
+ {
231
+ cutoff: cutoff,
232
+ sep: sep,
233
+ pin_row: sep + (gutter_height * 0.55),
234
+ axis_max: (sep + gutter_height).ceil,
235
+ outlier_count: outlier_count,
236
+ label: cap_label(outlier_count: outlier_count, cutoff: cutoff)
237
+ }
238
+ end
239
+
240
+ private
241
+
242
+ def cap_label outlier_count:, cutoff:
243
+ item_word = outlier_count == 1 ? 'item' : 'items'
244
+ "#{outlier_count} #{item_word} above #{cutoff.round} days"
107
245
  end
108
246
 
109
- def calculate_percent_line items
247
+ def filtered_values items
110
248
  min = minimum_y_value
111
- times = items.collect { |item| y_value(item) }
112
- times.reject! { |y| min && y < min }
113
- index = times.size * 85 / 100
114
- times.sort[index]
249
+ values = items.collect { |item| y_value(item) }
250
+ values.reject! { |value| min && value < min }
251
+ values
115
252
  end
116
253
  end
@@ -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
- # We can't do trend calculations with less than two data points
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 }
@@ -122,7 +122,7 @@ class WipByColumnChart < ChartBase
122
122
  cumulative = 0
123
123
  stat.wip_history.sort.find do |_wip, seconds|
124
124
  cumulative += seconds
125
- cumulative / total >= 0.85
125
+ cumulative / total >= (85 / 100.0)
126
126
  end&.first
127
127
  end
128
128
  end
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.1.1
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