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.
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 +32 -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 +29 -9
  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 +96 -14
  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
@@ -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,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'
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