jirametrics 2.28 → 2.29

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d93d43ce61e0fcec89c5ee737044cff4007b1f0079be2c6be774c56cb15130e8
4
- data.tar.gz: ffbe1b15fc8aa08f928e7c322cf53dc5a1646b9da01b7b9e81e8b5bd2c44650d
3
+ metadata.gz: 2cf59f19d1ee1de238db86ef01bd46d25c5741a6a85789d62f64c310d97055fd
4
+ data.tar.gz: 31a15ee2f64eef895dbfd95bd14004b70bec1be5810224f95fee39595022b84f
5
5
  SHA512:
6
- metadata.gz: 66d0c9c0db9b11c4278935a8342cae5f9b6b4e34fffc46d1bad6b719b6ca779374191f05f4139caf28995e8014ab77cfea79945df9abe546405abf9e381e4311
7
- data.tar.gz: 3f10707f9b44c14dd8dae67ce73cd208cb7554cee147d502f3b99315b49cb524aa6f12662c1624720f204e814a5686e37393f907da8ec43f1befc88398a79cc7
6
+ metadata.gz: 812c19230db1c44dc41e3b99f32d6d65235de7e620d4faedb4b51ac41c4da220795013b1b80c058f0f85ef6ab6fab6146ad5ed7cb2c48abe6ccddaed92002968
7
+ data.tar.gz: 1895fc706f93d0ad4cc60a83fb1bc82fa416f389b3b620ca01f9fefb774e11a5f9c1f96d8f9b00a0b2cdd82ca5443a7dd21c4f069fdb45988a5de4669cebf7ae
@@ -6,6 +6,7 @@ require 'jirametrics/board_movement_calculator'
6
6
 
7
7
  class AgingWorkInProgressChart < ChartBase
8
8
  include GroupableIssueChart
9
+
9
10
  attr_accessor :possible_statuses, :board_id
10
11
  attr_reader :board_columns
11
12
 
@@ -55,7 +56,7 @@ class AgingWorkInProgressChart < ChartBase
55
56
  def run
56
57
  determine_board_columns
57
58
 
58
- @header_text += " on board: #{@all_boards[@board_id].name}"
59
+ @header_text += " on board: #{current_board.name}"
59
60
  data_sets = make_data_sets
60
61
 
61
62
  adjust_visibility_of_unmapped_status_column data_sets: data_sets
@@ -76,7 +77,7 @@ class AgingWorkInProgressChart < ChartBase
76
77
 
77
78
  @fake_column = BoardColumn.new({
78
79
  'name' => '[Unmapped Statuses]',
79
- 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq # rubocop:disable Performance/ChainArrayAllocation
80
+ 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
80
81
  })
81
82
  @board_columns = columns + [@fake_column]
82
83
  end
@@ -114,14 +115,7 @@ class AgingWorkInProgressChart < ChartBase
114
115
 
115
116
  calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
116
117
 
117
- column_indexes_to_remove = []
118
- unless @show_all_columns
119
- column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
120
-
121
- column_indexes_to_remove.reverse_each do |index|
122
- @board_columns.delete_at index
123
- end
124
- end
118
+ column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
125
119
 
126
120
  @row_index_offset = data_sets.size
127
121
 
@@ -177,6 +171,44 @@ class AgingWorkInProgressChart < ChartBase
177
171
  result
178
172
  end
179
173
 
174
+ def trim_board_columns data_sets:, calculator:
175
+ return [] if @show_all_columns
176
+
177
+ columns_with_aging_items = data_sets.flat_map do |set|
178
+ set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
179
+ end.uniq
180
+
181
+ # @fake_column is always the last element and is handled separately.
182
+ real_column_count = @board_columns.size - 1
183
+
184
+ # The last visible column always has artificially inflated age_data because
185
+ # ages_of_issues_when_leaving_column uses `today` as end_date when there is no
186
+ # next column. Exclude it from the right-boundary search so it is only kept when
187
+ # it has current aging items (handled by the last_aging fallback below).
188
+ age_data = calculator.age_data_for(percentage: 100)
189
+ last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
190
+
191
+ in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
192
+ first_aging = (0...real_column_count).find(&in_current)
193
+ last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
194
+
195
+ # Combine: include any column with age_data (up to but not including the last visible
196
+ # column) and any column with current aging items.
197
+ first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
198
+ left_bound = [first_data, first_aging].compact.min
199
+ right_bound = [last_data, last_aging].compact.max
200
+
201
+ indexes_to_remove =
202
+ if left_bound && right_bound
203
+ (0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
204
+ else
205
+ (0...real_column_count).to_a
206
+ end
207
+
208
+ indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
209
+ indexes_to_remove
210
+ end
211
+
180
212
  def column_for issue:
181
213
  @board_columns.find do |board_column|
182
214
  board_column.status_ids.include? issue.status.id
@@ -192,7 +224,7 @@ class AgingWorkInProgressChart < ChartBase
192
224
  end
193
225
  end
194
226
 
195
- if has_unmapped
227
+ if has_unmapped && @description_text
196
228
  @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
197
229
  'board but are still active. Most likely everyone has forgotten about them.</p>'
198
230
  else
@@ -45,7 +45,9 @@ class AgingWorkTable < ChartBase
45
45
  # This is its own method simply so the tests can initialize the calculator without doing a full run.
46
46
  def initialize_calculator
47
47
  @today = date_range.end
48
- @calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
48
+ @calculators = @all_boards.transform_values do |board|
49
+ BoardMovementCalculator.new board: board, issues: issues, today: @today
50
+ end
49
51
  end
50
52
 
51
53
  def expedited_but_not_started
@@ -123,7 +125,8 @@ class AgingWorkTable < ChartBase
123
125
  due = issue.due_date
124
126
  message = nil
125
127
 
126
- days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
128
+ calculator = @calculators[issue.board.id]
129
+ days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
127
130
 
128
131
  unless error
129
132
  if due
@@ -72,7 +72,7 @@ class Board
72
72
  return true if board_type == 'scrum'
73
73
  return false unless board_type == 'simple'
74
74
 
75
- @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
75
+ has_sprints_feature?
76
76
  end
77
77
 
78
78
  def kanban?
@@ -82,6 +82,14 @@ class Board
82
82
  !scrum?
83
83
  end
84
84
 
85
+ def team_managed_kanban?
86
+ board_type == 'simple' && !has_sprints_feature?
87
+ end
88
+
89
+ def has_sprints_feature?
90
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
91
+ end
92
+
85
93
  def id
86
94
  @raw['id'].to_i
87
95
  end
@@ -9,7 +9,7 @@ class Exporter
9
9
  show_experimental_charts: false, github_repos: nil
10
10
  exporter = self
11
11
  project name: name do
12
- file_system.log name
12
+ file_system.log name, also_write_to_stderr: true
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
@@ -82,6 +82,9 @@ class Exporter
82
82
  end
83
83
 
84
84
  aging_work_in_progress_chart
85
+ wip_by_column_chart do
86
+ show_recommendations
87
+ end
85
88
  aging_work_bar_chart
86
89
  aging_work_table
87
90
  daily_wip_by_age_chart
@@ -50,6 +50,11 @@
50
50
  --wip-chart-active-color: #326cff;
51
51
  --wip-chart-border-color: gray;
52
52
 
53
+ --wip-by-column-chart-bar-fill-color: #0072B2; /* Okabe-Ito blue */
54
+ --wip-by-column-chart-bar-text-color: #ffffff;
55
+ --wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
56
+ --wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
57
+
53
58
  --estimate-accuracy-chart-completed-fill-color: #00ff00;
54
59
  --estimate-accuracy-chart-completed-border-color: green;
55
60
  --estimate-accuracy-chart-active-fill-color: #FFCCCB;
@@ -233,6 +238,10 @@ html[data-theme="dark"] {
233
238
  --wip-chart-active-color: #2551c1;
234
239
  --status-category-inprogress-color: #1c49bb;
235
240
  --hierarchy-table-inactive-item-text-color: #939393;
241
+ --wip-by-column-chart-bar-fill-color: #56B4E9; /* Okabe-Ito sky blue */
242
+ --wip-by-column-chart-bar-text-color: #000000;
243
+ --wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
244
+ --wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
236
245
  --wip-chart-completed-color: #03cb03;
237
246
  --wip-chart-duration-less-than-day-color: #d2d988;
238
247
  --wip-chart-duration-week-or-less-color: #dfcd00;
@@ -274,6 +283,10 @@ html[data-theme="light"] {
274
283
  --wip-chart-active-color: #326cff;
275
284
  --status-category-inprogress-color: #2663ff;
276
285
  --hierarchy-table-inactive-item-text-color: gray;
286
+ --wip-by-column-chart-bar-fill-color: #0072B2;
287
+ --wip-by-column-chart-bar-text-color: #ffffff;
288
+ --wip-by-column-chart-limit-line-color: #D55E00;
289
+ --wip-by-column-chart-recommendation-color: #009E73;
277
290
  --wip-chart-completed-color: #00ff00;
278
291
  --wip-chart-duration-less-than-day-color: #ffef41;
279
292
  --wip-chart-duration-week-or-less-color: #dcc900;
@@ -352,6 +365,11 @@ html[data-theme="light"] {
352
365
 
353
366
  --status-category-inprogress-color: #1c49bb;
354
367
 
368
+ --wip-by-column-chart-bar-fill-color: #56B4E9;
369
+ --wip-by-column-chart-bar-text-color: #000000;
370
+ --wip-by-column-chart-limit-line-color: #E69F00;
371
+ --wip-by-column-chart-recommendation-color: #2DCB9A;
372
+
355
373
  --cycletime-scatterplot-overall-trendline-color: gray;
356
374
 
357
375
  --hierarchy-table-inactive-item-text-color: #939393;
@@ -0,0 +1,250 @@
1
+ <%= seam_start %>
2
+ <div class="chart" style="position:relative;">
3
+ <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
4
+ <div id="<%= chart_id %>-tooltip" style="
5
+ display:none; position:absolute; pointer-events:none;
6
+ background:rgba(0,0,0,0.75); color:#fff; border-radius:4px;
7
+ padding:4px 8px; font:12px sans-serif; white-space:nowrap;
8
+ "></div>
9
+ </div>
10
+ <script>
11
+ (function() {
12
+ var wipData = <%= @wip_data.to_json %>;
13
+ var wipLimits = <%= @wip_limits.to_json %>;
14
+ var recommendations = <%= @recommendations.to_json %>;
15
+ var maxWip = <%= @max_wip %>;
16
+ var gridColor = <%= CssVariable['--grid-line-color'].to_json %>;
17
+ var barFillColor = <%= CssVariable['--wip-by-column-chart-bar-fill-color'].to_json %>;
18
+ var barTextColor = <%= CssVariable['--wip-by-column-chart-bar-text-color'].to_json %>;
19
+ var limitColor = <%= CssVariable['--wip-by-column-chart-limit-line-color'].to_json %>;
20
+ var recColor = <%= CssVariable['--wip-by-column-chart-recommendation-color'].to_json %>;
21
+ var tooltipEl = document.getElementById(<%= "#{chart_id}-tooltip".inspect %>);
22
+
23
+ var hitAreas = [];
24
+
25
+ var rectPlugin = {
26
+ id: 'wipRects',
27
+ afterDraw: function(chart) {
28
+ var ctx = chart.ctx;
29
+ var xScale = chart.scales['x'];
30
+ var yScale = chart.scales['y'];
31
+ var slotWidth = xScale.width / Math.max(xScale.ticks.length, 1);
32
+
33
+ hitAreas = [];
34
+
35
+ // 1. Draw y-axis gridlines at integer band boundaries
36
+ ctx.save();
37
+ ctx.beginPath();
38
+ ctx.rect(xScale.left, yScale.top, xScale.right - xScale.left, yScale.bottom - yScale.top);
39
+ ctx.clip();
40
+ ctx.strokeStyle = gridColor;
41
+ ctx.lineWidth = 1;
42
+ ctx.setLineDash([]);
43
+ for (var gi = 0; gi <= maxWip + 1; gi++) {
44
+ var gy = yScale.getPixelForValue(gi);
45
+ ctx.beginPath();
46
+ ctx.moveTo(xScale.left, gy);
47
+ ctx.lineTo(xScale.right, gy);
48
+ ctx.stroke();
49
+ }
50
+ ctx.restore();
51
+
52
+ // 2. Draw WIP limit lines (behind rectangles)
53
+ wipLimits.forEach(function(limits, colIndex) {
54
+ var xCenter = xScale.getPixelForValue(colIndex);
55
+ var halfSlot = slotWidth * 0.45;
56
+
57
+ [['min', 'bottom'], ['max', 'top']].forEach(function(pair) {
58
+ var type = pair[0];
59
+ var baseline = pair[1];
60
+ var val = limits[type];
61
+ if (val === null || val === undefined) return;
62
+
63
+ var y = yScale.getPixelForValue(val + 0.5);
64
+
65
+ ctx.save();
66
+ ctx.strokeStyle = limitColor;
67
+ ctx.lineWidth = 2;
68
+ ctx.setLineDash([5, 3]);
69
+ ctx.beginPath();
70
+ ctx.moveTo(xCenter - halfSlot, y);
71
+ ctx.lineTo(xCenter + halfSlot, y);
72
+ ctx.stroke();
73
+ ctx.restore();
74
+
75
+ ctx.save();
76
+ ctx.fillStyle = limitColor;
77
+ ctx.font = 'bold 10px sans-serif';
78
+ ctx.textAlign = 'right';
79
+ ctx.textBaseline = baseline;
80
+ ctx.fillText(type + ': ' + val, xCenter + halfSlot, baseline === 'bottom' ? y - 2 : y + 2);
81
+ ctx.restore();
82
+ });
83
+ });
84
+
85
+ <% if @show_recommendations %>
86
+ // 3. Draw recommendation lines (behind rectangles, label on left)
87
+ recommendations.forEach(function(rec, colIndex) {
88
+ if (rec === null || rec === undefined || rec === 0) return;
89
+ var xCenter = xScale.getPixelForValue(colIndex);
90
+ var halfSlot = slotWidth * 0.45;
91
+ var y = yScale.getPixelForValue(rec + 0.5);
92
+
93
+ ctx.save();
94
+ ctx.strokeStyle = recColor;
95
+ ctx.lineWidth = 2;
96
+ ctx.setLineDash([5, 3]);
97
+ ctx.beginPath();
98
+ ctx.moveTo(xCenter - halfSlot, y);
99
+ ctx.lineTo(xCenter + halfSlot, y);
100
+ ctx.stroke();
101
+ ctx.restore();
102
+
103
+ ctx.save();
104
+ ctx.fillStyle = recColor;
105
+ ctx.font = 'bold 10px sans-serif';
106
+ ctx.textAlign = 'left';
107
+ ctx.textBaseline = 'top';
108
+ ctx.fillText('rec: ' + rec, xCenter - halfSlot, y + 2);
109
+ ctx.restore();
110
+ });
111
+ <% end %>
112
+
113
+ // 4. Draw WIP rectangles centered in their bands (wip + 0.5)
114
+ var yStep = Math.abs(yScale.getPixelForValue(0.5) - yScale.getPixelForValue(1.5));
115
+
116
+ wipData.forEach(function(colData, colIndex) {
117
+ var xCenter = xScale.getPixelForValue(colIndex);
118
+
119
+ colData.forEach(function(entry) {
120
+ var wip = entry['wip'];
121
+ var pct = entry['pct'];
122
+ var rectWidth = slotWidth * pct / 100;
123
+ var rectHeight = yStep * 0.8;
124
+ var yCenter = yScale.getPixelForValue(wip + 0.5);
125
+ var x1 = xCenter - rectWidth / 2;
126
+ var y1 = yCenter - rectHeight / 2;
127
+
128
+ ctx.save();
129
+ ctx.fillStyle = barFillColor;
130
+ ctx.strokeStyle = barFillColor;
131
+ ctx.lineWidth = 1;
132
+ ctx.fillRect(x1, y1, rectWidth, rectHeight);
133
+ ctx.strokeRect(x1, y1, rectWidth, rectHeight);
134
+
135
+ ctx.fillStyle = barTextColor;
136
+ ctx.font = '11px sans-serif';
137
+ ctx.textAlign = 'center';
138
+ ctx.textBaseline = 'middle';
139
+ if (rectWidth > 25) {
140
+ ctx.fillText(pct + '%', xCenter, yCenter);
141
+ }
142
+ ctx.restore();
143
+
144
+ var hitWidth = Math.max(rectWidth, slotWidth);
145
+ hitAreas.push({
146
+ x1: xCenter - hitWidth / 2, y1: y1,
147
+ x2: xCenter + hitWidth / 2, y2: y1 + rectHeight,
148
+ label: 'WIP ' + wip + ': ' + pct + '%'
149
+ });
150
+ });
151
+ });
152
+ }
153
+ };
154
+
155
+ var canvas = document.getElementById(<%= chart_id.inspect %>);
156
+
157
+ canvas.addEventListener('mousemove', function(e) {
158
+ var rect = canvas.getBoundingClientRect();
159
+ var mx = e.clientX - rect.left;
160
+ var my = e.clientY - rect.top;
161
+
162
+ var hit = null;
163
+ for (var i = 0; i < hitAreas.length; i++) {
164
+ var a = hitAreas[i];
165
+ if (mx >= a.x1 && mx <= a.x2 && my >= a.y1 && my <= a.y2) {
166
+ hit = a;
167
+ break;
168
+ }
169
+ }
170
+
171
+ if (hit) {
172
+ tooltipEl.textContent = hit.label;
173
+ tooltipEl.style.display = 'block';
174
+ tooltipEl.style.left = (mx + 10) + 'px';
175
+ tooltipEl.style.top = (my - 20) + 'px';
176
+ } else {
177
+ tooltipEl.style.display = 'none';
178
+ }
179
+ });
180
+
181
+ canvas.addEventListener('mouseleave', function() {
182
+ tooltipEl.style.display = 'none';
183
+ });
184
+
185
+ new Chart(canvas.getContext('2d'),
186
+ {
187
+ type: 'bar',
188
+ plugins: [rectPlugin],
189
+ data: {
190
+ labels: <%= @column_names.to_json %>,
191
+ datasets: [{
192
+ data: [],
193
+ backgroundColor: 'transparent'
194
+ }]
195
+ },
196
+ options: {
197
+ responsive: <%= canvas_responsive? %>,
198
+ scales: {
199
+ x: {
200
+ grid: {
201
+ color: gridColor,
202
+ z: 1
203
+ }
204
+ },
205
+ y: {
206
+ title: {
207
+ display: true,
208
+ text: 'WIP'
209
+ },
210
+ grid: {
211
+ display: false
212
+ },
213
+ min: 0,
214
+ max: <%= @max_wip + 1 %>,
215
+ afterBuildTicks: function(scale) {
216
+ scale.ticks = [];
217
+ for (var i = 0; i <= maxWip; i++) {
218
+ scale.ticks.push({ value: i + 0.5 });
219
+ }
220
+ },
221
+ ticks: {
222
+ callback: function(value) {
223
+ return Math.round(value - 0.5);
224
+ }
225
+ }
226
+ }
227
+ },
228
+ plugins: {
229
+ legend: {
230
+ display: false
231
+ },
232
+ tooltip: {
233
+ enabled: false
234
+ }
235
+ }
236
+ }
237
+ });
238
+ })();
239
+ </script>
240
+ <%= seam_end %>
241
+ <% unless @recommendation_texts.empty? %>
242
+ <div style="margin-top: 0.5em;">
243
+ <strong>WIP limit recommendations</strong>
244
+ <ul style="margin: 0.3em 0 0 0; padding-left: 1.5em;">
245
+ <% @recommendation_texts.each do |text| %>
246
+ <li><%= text %></li>
247
+ <% end %>
248
+ </ul>
249
+ </div>
250
+ <% end %>
@@ -33,17 +33,35 @@ class HtmlReportConfig < HtmlGenerator
33
33
  @charts = [] # Where we store all the charts we executed so we can assert against them.
34
34
  end
35
35
 
36
- def method_missing name, &block
36
+ def method_missing name, *_args, board_id: nil, **_kwargs, &block
37
37
  class_name = name.to_s.split('_').map(&:capitalize).join
38
38
  klass = Object.const_get(class_name)
39
39
  raise NameError unless klass < ChartBase
40
40
 
41
41
  block ||= ->(_) {}
42
- execute_chart klass.new(block)
42
+
43
+ if klass.instance_method(:board_id=).owner == klass
44
+ execute_chart_per_board klass: klass, block: block, board_id: board_id
45
+ else
46
+ execute_chart klass.new(block)
47
+ end
43
48
  rescue NameError
44
49
  super
45
50
  end
46
51
 
52
+ def execute_chart_per_board klass:, block:, board_id:
53
+ all_boards = @file_config.project_config.all_boards
54
+ ids = board_id ? [board_id] : issues.collect { |i| i.board.id }.uniq
55
+ ids = ids.sort_by { |id| all_boards[id]&.name || '' }
56
+ ids.each_with_index do |id, index|
57
+ execute_chart(klass.new(block)) do |chart|
58
+ chart.board_id = id
59
+ # We're showing the description only on the first one in order to reduce noise on the report
60
+ chart.description_text nil unless index.zero?
61
+ end
62
+ end
63
+ end
64
+
47
65
  def respond_to_missing? name, include_private = false
48
66
  class_name = name.to_s.split('_').map(&:capitalize).join
49
67
  klass = Object.const_get(class_name)
@@ -98,22 +116,6 @@ class HtmlReportConfig < HtmlGenerator
98
116
  @file_config.project_config.exporter.timezone_offset
99
117
  end
100
118
 
101
- def aging_work_in_progress_chart board_id: nil, &block
102
- block ||= ->(_) {}
103
-
104
- if board_id.nil?
105
- ids = issues.collect { |i| i.board.id }.uniq.sort
106
- else
107
- ids = [board_id]
108
- end
109
-
110
- ids.each do |id|
111
- execute_chart(AgingWorkInProgressChart.new(block)) do |chart|
112
- chart.board_id = id
113
- end
114
- end
115
- end
116
-
117
119
  def random_color
118
120
  "##{Random.bytes(3).unpack1('H*')}"
119
121
  end
@@ -453,7 +453,7 @@ class ProjectConfig
453
453
  # To be used by the aggregate_config only. Not intended to be part of the public API
454
454
  def add_issues issues_list
455
455
  @issues = IssueCollection.new if @issues.nil?
456
- @all_boards = {}
456
+ @all_boards ||= {}
457
457
 
458
458
  issues_list.each do |issue|
459
459
  @issues << issue
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class WipByColumnChart < ChartBase
6
+ attr_accessor :possible_statuses, :board_id
7
+
8
+ ColumnStats = Struct.new(:name, :min_wip_limit, :max_wip_limit, :wip_history, keyword_init: true)
9
+
10
+ def initialize block
11
+ super()
12
+ header_text 'WIP by column'
13
+ description_text <<-HTML
14
+ <p>
15
+ This chart shows how much time each board column has spent at different WIP (Work in Progress) levels.
16
+ </p>
17
+ <p>
18
+ Each row on the Y axis is a WIP level (the number of items in that column at the same time).
19
+ Each column on the X axis is a board column.
20
+ The horizontal bars show what percentage of the total time that column spent at that WIP level —
21
+ a wider bar means more time was spent there.
22
+ </p>
23
+ <p>
24
+ A column whose widest bar is at WIP&nbsp;1 was almost always working on one item at a time, often called
25
+ single-piece-flow. This team is likely collaborating very well and might have been
26
+ <a href="https://blog.mikebowler.ca/2021/06/19/pair-programming/">pairing</a> or
27
+ <a href="https://blog.mikebowler.ca/2023/04/22/ensemble-programming/">mobbing/ensembling</a>
28
+ and these teams tend to be very effective.
29
+ </p>
30
+ <p>
31
+ A column with wide bars at high WIP levels usually indicates a team that is highly siloed. Where each person
32
+ is working by themselves.
33
+ </p>
34
+ <p>
35
+ The dashed lines show the minimum and maximum WIP limits configured on the board.
36
+ If the widest bar sits well above the maximum limit, the limit may be set too low or not being respected.
37
+ If the widest bar sits below the minimum limit, consider whether that limit is still meaningful.
38
+ </p>
39
+ <p>
40
+ Hover over any bar to see the exact percentage.
41
+ </p>
42
+ <% if @all_boards[@board_id].team_managed_kanban? %>
43
+ <p>
44
+ If the data looks a bit off then that's probably because you're using a Team Managed project in "kanban mode".
45
+ For this specific case, we are unable to tell if an item is actually visible on the board and so we may
46
+ be reporting more items started than you actually see on the board. See
47
+ <a href="https://jirametrics.org/faq/#team-managed-kanban-backlog">the FAQ</a>.
48
+ </p>
49
+ <% end %>
50
+ HTML
51
+
52
+ instance_eval(&block)
53
+ end
54
+
55
+ def show_recommendations
56
+ @show_recommendations = true
57
+ end
58
+
59
+ def run
60
+ @header_text += " on board: #{current_board.name}"
61
+ stats = column_stats
62
+ @column_names = stats.collect(&:name)
63
+ @wip_data = stats.collect do |stat|
64
+ total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
65
+ next [] if total.zero?
66
+
67
+ stat.wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
68
+ end
69
+ @max_wip = stats.flat_map { |s| s.wip_history.collect { |wip, _| wip } }.max || 0
70
+ @wip_limits = stats.collect { |s| { 'min' => s.min_wip_limit, 'max' => s.max_wip_limit } }
71
+ @recommendations = @show_recommendations ? compute_recommendations(stats) : Array.new(stats.size)
72
+
73
+ trim_zero_end_columns
74
+ @recommendation_texts = @show_recommendations ? build_recommendation_texts : []
75
+
76
+ wrap_and_render(binding, __FILE__)
77
+ end
78
+
79
+ def column_stats
80
+ board = current_board
81
+ columns = board.visible_columns
82
+ status_to_column = build_status_to_column_map(columns)
83
+ relevant_issues = @issues.select { |issue| issue.board.id == @board_id }
84
+
85
+ current_column = initial_column_state(relevant_issues, status_to_column)
86
+ events = events_within_range(relevant_issues, status_to_column)
87
+ column_wip_seconds = compute_wip_seconds(columns, current_column, events)
88
+
89
+ columns.collect.with_index do |column, index|
90
+ ColumnStats.new(
91
+ name: column.name,
92
+ min_wip_limit: column.min,
93
+ max_wip_limit: column.max,
94
+ wip_history: column_wip_seconds[index].sort.to_a
95
+ )
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def trim_zero_end_columns
102
+ all_zero = @wip_data.map { |col| col.none? { |e| e['wip'].positive? } }
103
+ first = all_zero.index(false)
104
+ return unless first
105
+
106
+ last = all_zero.rindex(false)
107
+ @column_names = @column_names[first..last]
108
+ @wip_data = @wip_data[first..last]
109
+ @wip_limits = @wip_limits[first..last]
110
+ @recommendations = @recommendations[first..last]
111
+ @max_wip = @wip_data.flat_map { |col| col.map { |e| e['wip'] } }.max || 0
112
+ end
113
+
114
+ def compute_recommendations stats
115
+ stats.collect do |stat|
116
+ next nil if stat.wip_history.empty?
117
+
118
+ total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
119
+ next nil if total.zero?
120
+
121
+ cumulative = 0
122
+ stat.wip_history.sort.find do |_wip, seconds|
123
+ cumulative += seconds
124
+ cumulative / total >= 0.85
125
+ end&.first
126
+ end
127
+ end
128
+
129
+ def build_recommendation_texts
130
+ @column_names.each_with_index.filter_map do |name, i|
131
+ rec = @recommendations[i]
132
+ next if rec.nil?
133
+
134
+ next "Almost nothing passes through column '#{name}'. Do we still need it?" if rec.zero?
135
+
136
+ max = @wip_limits[i]['max']
137
+ if max.nil?
138
+ "Add a WIP limit to column '#{name}' — suggested maximum: #{rec}"
139
+ elsif rec < max
140
+ "Lower the WIP limit for '#{name}' from #{max} to #{rec}"
141
+ elsif rec > max
142
+ "Raise the WIP limit for '#{name}' from #{max} to #{rec}"
143
+ end
144
+ end
145
+ end
146
+
147
+ def format_pct seconds, total
148
+ raw = seconds / total * 100.0
149
+ (1..10).each do |decimals|
150
+ rounded = raw.round(decimals)
151
+ next if rounded.zero? && raw.positive?
152
+ next if rounded >= 100.0 && raw < 100.0
153
+
154
+ return rounded
155
+ end
156
+ raw
157
+ end
158
+
159
+ def build_status_to_column_map columns
160
+ columns.each_with_object({}).with_index do |(column, map), index|
161
+ column.status_ids.each { |id| map[id] = index }
162
+ end
163
+ end
164
+
165
+ def initial_column_state relevant_issues, status_to_column
166
+ relevant_issues.each_with_object({}) do |issue, hash|
167
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
168
+ in_wip = started_time &&
169
+ started_time <= time_range.begin &&
170
+ (stopped_time.nil? || stopped_time > time_range.begin)
171
+ unless in_wip
172
+ hash[issue] = nil
173
+ next
174
+ end
175
+
176
+ last_change = issue.status_changes.reverse.find { |c| c.time <= time_range.begin }
177
+ hash[issue] = last_change ? status_to_column[last_change.value_id] : nil
178
+ end
179
+ end
180
+
181
+ def events_within_range relevant_issues, status_to_column
182
+ events = []
183
+ relevant_issues.each do |issue|
184
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
185
+ next unless started_time
186
+
187
+ # Issue starts within the window: add an explicit event to enter WIP in its current column
188
+ if started_time > time_range.begin && started_time <= time_range.end
189
+ last_change = issue.status_changes.reverse.find { |c| c.time <= started_time }
190
+ events << [started_time, issue, last_change ? status_to_column[last_change.value_id] : nil]
191
+ end
192
+
193
+ # Status changes while the issue is actively in WIP and within the window
194
+ issue.status_changes.each do |change|
195
+ next unless change.time > time_range.begin
196
+ next if change.time > time_range.end
197
+ next unless change.time >= started_time
198
+ next if stopped_time && change.time >= stopped_time
199
+
200
+ events << [change.time, issue, status_to_column[change.value_id]]
201
+ end
202
+
203
+ # Issue stops within the window: add an explicit event to exit WIP
204
+ if stopped_time && stopped_time > time_range.begin && stopped_time <= time_range.end
205
+ events << [stopped_time, issue, nil]
206
+ end
207
+ end
208
+ events.sort_by!(&:first)
209
+ end
210
+
211
+ def compute_wip_seconds columns, current_column, events
212
+ wip_counts = Array.new(columns.size, 0)
213
+ current_column.each_value { |col| wip_counts[col] += 1 unless col.nil? }
214
+
215
+ column_wip_seconds = Array.new(columns.size) { Hash.new(0) }
216
+ prev_time = time_range.begin
217
+
218
+ events.each do |time, issue, new_col|
219
+ elapsed = (time - prev_time).to_i
220
+ if elapsed.positive?
221
+ wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
222
+ prev_time = time
223
+ end
224
+
225
+ old_col = current_column[issue]
226
+ wip_counts[old_col] -= 1 unless old_col.nil?
227
+ wip_counts[new_col] += 1 unless new_col.nil?
228
+ current_column[issue] = new_col
229
+ end
230
+
231
+ elapsed = (time_range.end - prev_time).to_i
232
+ wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed } if elapsed.positive?
233
+
234
+ column_wip_seconds
235
+ end
236
+ 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: '2.28'
4
+ version: '2.29'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -154,6 +154,7 @@ files:
154
154
  - lib/jirametrics/html/throughput_chart.erb
155
155
  - lib/jirametrics/html/time_based_histogram.erb
156
156
  - lib/jirametrics/html/time_based_scatterplot.erb
157
+ - lib/jirametrics/html/wip_by_column_chart.erb
157
158
  - lib/jirametrics/html_generator.rb
158
159
  - lib/jirametrics/html_report_config.rb
159
160
  - lib/jirametrics/issue.rb
@@ -185,6 +186,7 @@ files:
185
186
  - lib/jirametrics/trend_line_calculator.rb
186
187
  - lib/jirametrics/user.rb
187
188
  - lib/jirametrics/value_equality.rb
189
+ - lib/jirametrics/wip_by_column_chart.rb
188
190
  homepage: https://jirametrics.org
189
191
  licenses:
190
192
  - Apache-2.0
@@ -207,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
207
209
  - !ruby/object:Gem::Version
208
210
  version: '0'
209
211
  requirements: []
210
- rubygems_version: 4.0.8
212
+ rubygems_version: 4.0.10
211
213
  specification_version: 4
212
214
  summary: Extract Jira metrics
213
215
  test_files: []