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 +4 -4
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +5 -2
- data/lib/jirametrics/board.rb +9 -1
- data/lib/jirametrics/examples/standard_project.rb +4 -1
- data/lib/jirametrics/html/index.css +18 -0
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_report_config.rb +20 -18
- data/lib/jirametrics/project_config.rb +1 -1
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2cf59f19d1ee1de238db86ef01bd46d25c5741a6a85789d62f64c310d97055fd
|
|
4
|
+
data.tar.gz: 31a15ee2f64eef895dbfd95bd14004b70bec1be5810224f95fee39595022b84f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: #{
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 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.
|
|
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.
|
|
212
|
+
rubygems_version: 4.0.10
|
|
211
213
|
specification_version: 4
|
|
212
214
|
summary: Extract Jira metrics
|
|
213
215
|
test_files: []
|