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.
Files changed (33) 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 +106 -15
  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 +77 -5
  19. data/lib/jirametrics/html/index.erb +6 -2
  20. data/lib/jirametrics/html/index.js +16 -0
  21. data/lib/jirametrics/html/legacy_colors.css +18 -0
  22. data/lib/jirametrics/html/time_based_histogram.erb +34 -18
  23. data/lib/jirametrics/html/time_based_scatterplot.erb +29 -9
  24. data/lib/jirametrics/html_generator.rb +20 -1
  25. data/lib/jirametrics/html_report_config.rb +3 -3
  26. data/lib/jirametrics/percentile_validation.rb +26 -0
  27. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
  28. data/lib/jirametrics/settings.json +1 -0
  29. data/lib/jirametrics/time_based_histogram.rb +47 -15
  30. data/lib/jirametrics/time_based_scatterplot.rb +96 -14
  31. data/lib/jirametrics/trend_line_calculator.rb +5 -2
  32. data/lib/jirametrics/wip_by_column_chart.rb +1 -1
  33. metadata +3 -1
@@ -36,20 +36,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
36
36
  annotation: {
37
37
  annotations: {
38
38
  <%
39
- results = the_stats[:all][:percentiles]
40
- results.each do |percentile, value|
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
- percentile<%= percentile.to_s %>: {
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
- enabled: true,
50
- content: '<%= "#{percentile}%" %>',
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 |p| %>
87
- <th><%= p %>th</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;"><%= sprintf('%.2f', v[:average]) %></td>
96
- <td><%= v[:mode].join(', ') %></td>
97
- <% percentiles.each do |p| %>
98
- <td style="text-align: right;"><%= v[:percentiles][p] %></td>
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>&ndash;</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
- <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. Typical percentiles of interest are:</li>
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
- <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
113
- <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
114
- <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
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.each_with_index do |args, index| %>
133
- <% percent, color = args %>
134
- line<%= index %>: {
132
+ <% @percentage_lines.each do |line| %>
133
+ <%= line[:id].to_json %>: {
135
134
  type: 'line',
136
- yMin: <%= percent %>,
137
- yMax: <%= percent %>,
138
- borderColor: <%= color.to_json %>,
135
+ yMin: <%= line[:value] %>,
136
+ yMax: <%= line[:value] %>,
137
+ borderColor: <%= line[:color].to_json %>,
139
138
  borderWidth: 1,
140
- drawTime: 'beforeDraw'
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 the 85% line for that dataset
155
- legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
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 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
@@ -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 '&ndash;' 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
- 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
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
- attr_reader :y_axis_cap_percentile
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
- overall_percent_line = calculate_percent_line(items)
31
- @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
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).each do |rules, items_by_type|
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
- percent_line = calculate_percent_line items_by_type
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: "#{label} (85% at #{label_days(percent_line)})",
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
- @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
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
- def calculate_percent_line items
124
- percentile_value items, 85
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
- values = filtered_values(items)
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
- # 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.2'
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