jirametrics 3.3.1 → 3.5
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_bar_chart.rb +2 -4
- data/lib/jirametrics/aging_work_in_progress_chart.rb +26 -26
- data/lib/jirametrics/atlassian_document_format.rb +22 -14
- data/lib/jirametrics/board_movement_calculator.rb +0 -23
- data/lib/jirametrics/change_item.rb +4 -8
- data/lib/jirametrics/chart_base.rb +145 -27
- data/lib/jirametrics/chart_format.rb +15 -0
- data/lib/jirametrics/daily_view.rb +6 -7
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +3 -3
- data/lib/jirametrics/dependency_chart.rb +3 -5
- data/lib/jirametrics/estimate_accuracy_chart.rb +37 -8
- data/lib/jirametrics/expedited_chart.rb +5 -6
- data/lib/jirametrics/exporter.rb +24 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +10 -7
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +78 -10
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
- data/lib/jirametrics/html/index.css +10 -9
- data/lib/jirametrics/html/index.js +13 -0
- data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
- data/lib/jirametrics/issue.rb +5 -7
- data/lib/jirametrics/issue_printer.rb +5 -3
- data/lib/jirametrics/jira_gateway.rb +5 -7
- data/lib/jirametrics/project_config.rb +24 -0
- data/lib/jirametrics/sprint_burndown.rb +2 -2
- data/lib/jirametrics/testing/mock_board.rb +34 -0
- data/lib/jirametrics/testing/mock_change_item.rb +112 -0
- data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
- data/lib/jirametrics/testing/mock_issue.rb +108 -0
- data/lib/jirametrics/testing.rb +88 -0
- data/lib/jirametrics/time_based_histogram.rb +2 -4
- data/lib/jirametrics/time_based_scatterplot.rb +2 -4
- data/lib/jirametrics/user.rb +16 -2
- data/lib/jirametrics/wip_by_column_chart.rb +1 -2
- data/lib/jirametrics.rb +6 -1
- metadata +7 -1
|
@@ -14,17 +14,24 @@ class EstimateAccuracyChart < ChartBase
|
|
|
14
14
|
The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
|
|
15
15
|
cycletimes.
|
|
16
16
|
<% if @has_aging_data %>
|
|
17
|
-
The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging
|
|
17
|
+
The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging arrows
|
|
18
18
|
(click on the legend to turn them on) show the current
|
|
19
|
-
age of items, which will give you a hint as to where they might end up.
|
|
20
|
-
|
|
19
|
+
age of items, which will give you a hint as to where they might end up. They point right
|
|
20
|
+
because those items haven't finished yet, so every one of them will keep moving that way
|
|
21
|
+
until it does. If they're already far to the right then you know you have a problem.
|
|
21
22
|
<% end %>
|
|
22
23
|
</div>
|
|
23
24
|
<% if @correlation_coefficient %>
|
|
24
25
|
<div class="p">
|
|
25
26
|
The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
|
|
26
27
|
The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
|
|
27
|
-
the stronger the negative
|
|
28
|
+
the stronger the negative correlation. Zero would mean no correlation at all.
|
|
29
|
+
</div>
|
|
30
|
+
<% elsif @estimates_are_numeric == false %>
|
|
31
|
+
<div class="p">
|
|
32
|
+
There's no correlation coefficient here because these estimates are categories rather than
|
|
33
|
+
numbers. Working one out means measuring the gaps between values, and there's no defined
|
|
34
|
+
gap between one category and the next, so any number we showed you would be invented.
|
|
28
35
|
</div>
|
|
29
36
|
<% end %>
|
|
30
37
|
HTML
|
|
@@ -46,14 +53,15 @@ class EstimateAccuracyChart < ChartBase
|
|
|
46
53
|
end
|
|
47
54
|
data_sets = scan_issues
|
|
48
55
|
|
|
49
|
-
return
|
|
56
|
+
return render_no_data if data_sets.empty?
|
|
50
57
|
|
|
51
58
|
wrap_and_render(binding, __FILE__)
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def scan_issues
|
|
55
62
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
56
|
-
@
|
|
63
|
+
@estimates_are_numeric = numeric_estimates? completed_hash
|
|
64
|
+
@correlation_coefficient = correlation_coefficient(completed_hash) if @estimates_are_numeric
|
|
57
65
|
estimation_units = current_board.estimation_configuration.units
|
|
58
66
|
@has_aging_data = !aging_hash.empty?
|
|
59
67
|
|
|
@@ -77,7 +85,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
77
85
|
{
|
|
78
86
|
'x' => cycle_time,
|
|
79
87
|
'y' => estimate,
|
|
80
|
-
'r' => values.size
|
|
88
|
+
'r' => bubble_radius(values.size),
|
|
81
89
|
'title' => title
|
|
82
90
|
}
|
|
83
91
|
end
|
|
@@ -90,11 +98,23 @@ class EstimateAccuracyChart < ChartBase
|
|
|
90
98
|
'showLine' => false,
|
|
91
99
|
'backgroundColor' => fill_color,
|
|
92
100
|
'borderColor' => border_color,
|
|
93
|
-
'hidden' => starts_hidden
|
|
101
|
+
'hidden' => starts_hidden,
|
|
102
|
+
# The active series is drawn as right pointing arrows rather than discs, because its
|
|
103
|
+
# cycletime is a lower bound that keeps growing. The erb needs to know which one that is.
|
|
104
|
+
'still_in_progress' => completed_or_active == 'active'
|
|
94
105
|
}
|
|
95
106
|
end
|
|
96
107
|
end
|
|
97
108
|
|
|
109
|
+
# Chart.js sizes a bubble by its radius but the eye reads it by area, so the radius has
|
|
110
|
+
# to go as the square root of the count. This multiplier is the radius of a single issue
|
|
111
|
+
# and everything else scales up from there.
|
|
112
|
+
SINGLE_ISSUE_RADIUS = 4
|
|
113
|
+
|
|
114
|
+
def bubble_radius issue_count
|
|
115
|
+
(SINGLE_ISSUE_RADIUS * Math.sqrt(issue_count)).round 1
|
|
116
|
+
end
|
|
117
|
+
|
|
98
118
|
def estimate_label estimate:, estimation_units:
|
|
99
119
|
if @y_axis_type == 'linear'
|
|
100
120
|
if estimation_units == :story_points
|
|
@@ -183,6 +203,15 @@ class EstimateAccuracyChart < ChartBase
|
|
|
183
203
|
|
|
184
204
|
# Correlation coefficient is calculated using the Pearson Correlation Coefficient
|
|
185
205
|
# r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
|
|
206
|
+
# A Pearson coefficient is arithmetic on the distances between values, so it needs a scale where
|
|
207
|
+
# those distances mean something. T-shirt sizes are ordered but not spaced: nothing says the gap
|
|
208
|
+
# from M to L matches the gap from S to M. Summing them used to raise and take the export with it.
|
|
209
|
+
def numeric_estimates? completed_hash
|
|
210
|
+
return false if completed_hash.empty?
|
|
211
|
+
|
|
212
|
+
completed_hash.keys.all? { |estimate, _cycle_time| estimate.is_a? Numeric }
|
|
213
|
+
end
|
|
214
|
+
|
|
186
215
|
def correlation_coefficient completed_hash
|
|
187
216
|
list1 = []
|
|
188
217
|
list2 = []
|
|
@@ -24,12 +24,13 @@ class ExpeditedChart < ChartBase
|
|
|
24
24
|
super()
|
|
25
25
|
|
|
26
26
|
header_text 'Expedited work'
|
|
27
|
+
no_data_text '<%= render_header %><div>There is no expedited work in this time period.</div>'
|
|
27
28
|
description_text <<-HTML
|
|
28
29
|
<div class="p">
|
|
29
30
|
This chart only shows issues that have been expedited at some point. We care about these as
|
|
30
31
|
any form of expedited work will affect the entire system and will slow down non-expedited work.
|
|
31
32
|
Refer to this article on
|
|
32
|
-
<a href="https://
|
|
33
|
+
<a href="https://blog.mikebowler.ca/2021/06/16/classes-of-service/">classes of service</a>
|
|
33
34
|
for a longer explanation on why we want to avoid expedited work.
|
|
34
35
|
</div>
|
|
35
36
|
<div class="p">
|
|
@@ -49,11 +50,9 @@ class ExpeditedChart < ChartBase
|
|
|
49
50
|
make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
if data_sets.empty?
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
wrap_and_render(binding, __FILE__)
|
|
56
|
-
end
|
|
53
|
+
return render_no_data if data_sets.empty?
|
|
54
|
+
|
|
55
|
+
wrap_and_render(binding, __FILE__)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def prepare_expedite_data issue
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -6,6 +6,30 @@ class Exporter
|
|
|
6
6
|
attr_reader :project_configs
|
|
7
7
|
attr_accessor :file_system
|
|
8
8
|
|
|
9
|
+
# Nothing in examples/ is loaded with the rest of the library, because an example is something you
|
|
10
|
+
# opt into rather than something the product provides. Each file there defines the method named
|
|
11
|
+
# after it. Derived from the directory rather than listed, so a new example is covered by existing.
|
|
12
|
+
EXAMPLE_NAMES = Dir[File.join(__dir__, 'examples', '*.rb')]
|
|
13
|
+
.collect { |file| File.basename(file, '.rb').to_sym }
|
|
14
|
+
.freeze
|
|
15
|
+
|
|
16
|
+
# Deliberately method_missing rather than stub methods that the real ones overwrite. Stubs depend
|
|
17
|
+
# on this file loading before the example, which require_all does not guarantee, and when it lost
|
|
18
|
+
# that race the stub replaced the working method.
|
|
19
|
+
def method_missing name, *args, **keywords, &block
|
|
20
|
+
return super unless EXAMPLE_NAMES.include? name
|
|
21
|
+
|
|
22
|
+
raise "#{name} is an example rather than part of jirametrics, so your config has to ask for " \
|
|
23
|
+
"it. Add this line to the top of your config file:\n\n" \
|
|
24
|
+
" require 'jirametrics/examples/#{name}'\n"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# These are not things we respond to. Calling one raises, so respond_to? should say false and the
|
|
28
|
+
# message above should be the only way anyone learns they exist.
|
|
29
|
+
def respond_to_missing? name, include_private = false
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
9
33
|
def self.logfile_name
|
|
10
34
|
@logfile_name ||= 'jirametrics.log'
|
|
11
35
|
end
|
|
@@ -12,10 +12,11 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
12
12
|
super()
|
|
13
13
|
|
|
14
14
|
header_text 'Flow Efficiency'
|
|
15
|
+
no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
|
|
15
16
|
description_text <<-HTML
|
|
16
17
|
<div class="p">
|
|
17
18
|
This chart shows the active time against the the total time spent on a ticket.
|
|
18
|
-
<a href="https://
|
|
19
|
+
<a href="https://blog.mikebowler.ca/2024/07/06/flow-efficiency/">Flow efficiency</a> is the ratio
|
|
19
20
|
between these two numbers.
|
|
20
21
|
</div>
|
|
21
22
|
<div class="p">
|
|
@@ -63,10 +64,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
63
64
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
64
65
|
end
|
|
65
66
|
|
|
66
|
-
if data_sets.empty?
|
|
67
|
-
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
68
|
-
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
69
|
-
end
|
|
67
|
+
return render_no_data if data_sets.empty?
|
|
70
68
|
|
|
71
69
|
wrap_and_render(binding, __FILE__)
|
|
72
70
|
end
|
|
@@ -42,21 +42,24 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
42
42
|
z: 1 // draw the grid lines on top of the bars
|
|
43
43
|
},
|
|
44
44
|
stacked: false,
|
|
45
|
-
max: <%= (@max_age * 1.1).to_i
|
|
45
|
+
max: <%= (@max_age * 1.1).to_i %>,
|
|
46
|
+
ticks: {
|
|
47
|
+
// Uses labelExceptCrowdedCeiling() defined in index.js.
|
|
48
|
+
callback: labelExceptCrowdedCeiling
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
},
|
|
48
52
|
plugins: {
|
|
49
53
|
tooltip: {
|
|
50
54
|
callbacks: {
|
|
51
55
|
label: function(context) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
|
|
56
|
+
let point = context.dataset.data[context.dataIndex];
|
|
57
|
+
// A band is a floating bar, [bottom, top]. A dot is an object carrying its own title.
|
|
58
|
+
if( Array.isArray(point) ) {
|
|
59
|
+
return context.dataset.label + " of completed work items left this column in " + point[1] + " days or less";
|
|
57
60
|
}
|
|
58
61
|
else {
|
|
59
|
-
return
|
|
62
|
+
return point.title;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -14,6 +14,8 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
14
14
|
}
|
|
15
15
|
(function() {
|
|
16
16
|
const hatchWindows = <%= hatch_windows.to_json %>;
|
|
17
|
+
const annotationDescriptions = <%= date_annotation_hover_data %>;
|
|
18
|
+
const ANNOTATION_HOVER_TOLERANCE = 6;
|
|
17
19
|
|
|
18
20
|
// Custom plugin: draws diagonal hatching over correction windows in the affected band.
|
|
19
21
|
// Uses createDiagonalPattern() defined in index.js.
|
|
@@ -23,7 +25,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
23
25
|
// Redraw each correction-window border line so that the gaps between
|
|
24
26
|
// dashes show the band fill colour rather than being transparent.
|
|
25
27
|
// Strategy: draw a solid line in the fill colour first, then the dashed
|
|
26
|
-
// border line on top.
|
|
28
|
+
// border line on top. The hatching runs straight after this, so the
|
|
27
29
|
// hatch pattern still appears over both lines.
|
|
28
30
|
const ctx = chart.ctx;
|
|
29
31
|
const ca = chart.chartArea;
|
|
@@ -55,8 +57,15 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
55
57
|
drawSegment(win.fill_color, []); // solid fill colour fills the gaps
|
|
56
58
|
drawSegment(win.color, [6, 4]); // dashed border colour on top
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
// The hatching used to run in afterDraw, which put it above the annotation labels no
|
|
62
|
+
// matter what drawTime those asked for. Running it here, immediately after the borders,
|
|
63
|
+
// lets a label drawn at afterDraw land on top of it. The trend lines still draw over the
|
|
64
|
+
// hatching, since cfdFlowMetricsPlugin.afterDraw runs later than this either way.
|
|
65
|
+
cfdHatchPlugin.drawHatching(chart);
|
|
58
66
|
},
|
|
59
|
-
|
|
67
|
+
// Not a Chart.js hook. Called explicitly from afterDatasetsDraw above.
|
|
68
|
+
drawHatching: function(chart) {
|
|
60
69
|
const ctx = chart.ctx;
|
|
61
70
|
const ca = chart.chartArea;
|
|
62
71
|
hatchWindows.forEach(function(win) {
|
|
@@ -203,6 +212,44 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
203
212
|
ctx.restore();
|
|
204
213
|
}
|
|
205
214
|
|
|
215
|
+
// The description box for whichever annotation the cursor is near. This lives on the overlay
|
|
216
|
+
// rather than going through chartjs-plugin-annotation's own enter/leave, because those need
|
|
217
|
+
// Chart.js events and triangle mode switches those off.
|
|
218
|
+
function drawAnnotationDescription(ctx, chart, fm) {
|
|
219
|
+
const lines = fm._hoveredAnnotation.lines;
|
|
220
|
+
if (!lines.length) return;
|
|
221
|
+
|
|
222
|
+
const ca = chart.chartArea;
|
|
223
|
+
ctx.save();
|
|
224
|
+
ctx.font = '11px sans-serif';
|
|
225
|
+
const pad = 6, lineHeight = 14;
|
|
226
|
+
const w = Math.max.apply(null, lines.map(function (l) { return ctx.measureText(l).width; })) + pad * 2;
|
|
227
|
+
const h = lines.length * lineHeight + pad * 2;
|
|
228
|
+
// Sit below and right of the cursor, flipping when that would cross the edge of the plot.
|
|
229
|
+
let x = fm.mouseX + 12;
|
|
230
|
+
let y = fm.mouseY + 12;
|
|
231
|
+
if (x + w > ca.right) x = fm.mouseX - 12 - w;
|
|
232
|
+
if (y + h > ca.bottom) y = fm.mouseY - 12 - h;
|
|
233
|
+
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
|
234
|
+
ctx.fillRect(x, y, w, h);
|
|
235
|
+
ctx.fillStyle = '#ffffff';
|
|
236
|
+
ctx.textAlign = 'left';
|
|
237
|
+
ctx.textBaseline = 'top';
|
|
238
|
+
lines.forEach(function (line, i) { ctx.fillText(line, x + pad, y + pad + i * lineHeight); });
|
|
239
|
+
ctx.restore();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function annotationNear(chart, x) {
|
|
243
|
+
if (x === null) return null;
|
|
244
|
+
|
|
245
|
+
let best = null, bestDistance = ANNOTATION_HOVER_TOLERANCE;
|
|
246
|
+
annotationDescriptions.forEach(function (a) {
|
|
247
|
+
const distance = Math.abs(chart.scales.x.getPixelForValue(new Date(a.date).getTime()) - x);
|
|
248
|
+
if (distance <= bestDistance) { bestDistance = distance; best = a; }
|
|
249
|
+
});
|
|
250
|
+
return best;
|
|
251
|
+
}
|
|
252
|
+
|
|
206
253
|
function drawTriangle(ctx, chart, fm) {
|
|
207
254
|
const ca = chart.chartArea;
|
|
208
255
|
const ds = chart.data.datasets;
|
|
@@ -312,6 +359,8 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
312
359
|
const dates = chart.data.datasets[0].data.map(d => new Date(d.x).getTime());
|
|
313
360
|
chart._flowMetrics = {
|
|
314
361
|
mouseX: null,
|
|
362
|
+
mouseY: null,
|
|
363
|
+
_hoveredAnnotation: null,
|
|
315
364
|
arrivals,
|
|
316
365
|
departures,
|
|
317
366
|
dates,
|
|
@@ -323,9 +372,9 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
323
372
|
|
|
324
373
|
// The triangle is drawn on a separate overlay canvas using native DOM
|
|
325
374
|
// events, completely bypassing Chart.js's render/event cycle (which
|
|
326
|
-
// behaves unreliably in Safari).
|
|
327
|
-
//
|
|
328
|
-
//
|
|
375
|
+
// behaves unreliably in Safari). Redraws are coalesced into a single
|
|
376
|
+
// requestAnimationFrame (see scheduleOverlayRedraw) so a fast mouse move
|
|
377
|
+
// cannot queue up more redraws than the browser can paint.
|
|
329
378
|
const dpr = window.devicePixelRatio || 1;
|
|
330
379
|
const parent = canvas.parentNode;
|
|
331
380
|
if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
|
|
@@ -359,6 +408,12 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
359
408
|
// disabled entirely so its internal hover/tooltip logic cannot interfere
|
|
360
409
|
// with the overlay canvas rAF (which hangs Safari at certain positions).
|
|
361
410
|
// Our native canvas mousemove handler is unaffected by this setting.
|
|
411
|
+
//
|
|
412
|
+
// Switching events off wholesale looks heavy handed, and it is. We never found
|
|
413
|
+
// the root cause of the Safari hang. Other fixes we tried worked in some
|
|
414
|
+
// browsers but not all; this is the only one that was reliable across Safari,
|
|
415
|
+
// Chrome, Edge and Firefox. Treat it as load bearing: anything that re-enables
|
|
416
|
+
// events here has to be retested in all four.
|
|
362
417
|
const defaultEvents = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
|
|
363
418
|
|
|
364
419
|
function setTriangleMode(enabled) {
|
|
@@ -386,10 +441,9 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
386
441
|
if (fm._rafId !== null) return;
|
|
387
442
|
fm._rafId = requestAnimationFrame(function () {
|
|
388
443
|
fm._rafId = null;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
444
|
+
octx.clearRect(0, 0, cssW, cssH);
|
|
445
|
+
if (fm._triangleEnabled && fm.mouseX !== null) drawTriangle(octx, chart, fm);
|
|
446
|
+
if (fm._hoveredAnnotation) drawAnnotationDescription(octx, chart, fm);
|
|
393
447
|
});
|
|
394
448
|
}
|
|
395
449
|
|
|
@@ -399,12 +453,17 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
399
453
|
const y = e.clientY - rect.top;
|
|
400
454
|
const ca = chart.chartArea;
|
|
401
455
|
if (!ca) return;
|
|
402
|
-
|
|
456
|
+
const inside = x >= ca.left && x <= ca.right && y >= ca.top && y <= ca.bottom;
|
|
457
|
+
fm.mouseX = inside ? x : null;
|
|
458
|
+
fm.mouseY = inside ? y : null;
|
|
459
|
+
fm._hoveredAnnotation = annotationNear(chart, fm.mouseX);
|
|
403
460
|
scheduleOverlayRedraw();
|
|
404
461
|
}
|
|
405
462
|
|
|
406
463
|
function onMouseLeave() {
|
|
407
464
|
fm.mouseX = null;
|
|
465
|
+
fm.mouseY = null;
|
|
466
|
+
fm._hoveredAnnotation = null;
|
|
408
467
|
scheduleOverlayRedraw();
|
|
409
468
|
}
|
|
410
469
|
|
|
@@ -458,6 +517,15 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
458
517
|
point: { radius: 0 }
|
|
459
518
|
},
|
|
460
519
|
plugins: {
|
|
520
|
+
// The plugin draws the lines and their static labels. It does NOT get the hover callbacks,
|
|
521
|
+
// because triangle mode switches Chart.js events off and they would fire only when the
|
|
522
|
+
// triangle was hidden. The overlay canvas reveals descriptions instead, from the native
|
|
523
|
+
// mousemove handler, so it behaves the same in both modes.
|
|
524
|
+
annotation: {
|
|
525
|
+
annotations: {
|
|
526
|
+
<%= date_annotation hover: false %>
|
|
527
|
+
}
|
|
528
|
+
},
|
|
461
529
|
tooltip: {
|
|
462
530
|
enabled: false,
|
|
463
531
|
position: 'legendItem',
|
|
@@ -6,7 +6,40 @@
|
|
|
6
6
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
7
7
|
type: 'bubble',
|
|
8
8
|
data: {
|
|
9
|
-
datasets:
|
|
9
|
+
datasets: (function() {
|
|
10
|
+
const dataSets = <%= JSON.generate(data_sets) %>;
|
|
11
|
+
// The still in progress series is drawn as right pointing arrows rather than discs. Those
|
|
12
|
+
// items have not finished, so their cycletime is a lower bound that keeps growing and the
|
|
13
|
+
// arrow says "this one is still moving that way". Same idea as the capped-value arrows on
|
|
14
|
+
// the scatterplot, turned to point along the axis that is actually still moving.
|
|
15
|
+
//
|
|
16
|
+
// Sized per point rather than at a fixed size, because on this chart the radius carries the
|
|
17
|
+
// number of issues stacked at that spot and we would be throwing that away.
|
|
18
|
+
function movementArrow(color, radius) {
|
|
19
|
+
const size = Math.max(12, Math.round(radius * 3));
|
|
20
|
+
const glyph = document.createElement('canvas');
|
|
21
|
+
glyph.width = size; glyph.height = size;
|
|
22
|
+
const pen = glyph.getContext('2d');
|
|
23
|
+
const middle = size / 2, head = size * 0.3, tip = size * 0.88;
|
|
24
|
+
pen.strokeStyle = color;
|
|
25
|
+
pen.lineWidth = Math.max(2, radius * 0.42);
|
|
26
|
+
pen.lineCap = 'round'; pen.lineJoin = 'round';
|
|
27
|
+
pen.beginPath();
|
|
28
|
+
pen.moveTo(size * 0.12, middle); pen.lineTo(tip, middle);
|
|
29
|
+
pen.moveTo(tip - head, middle - head);
|
|
30
|
+
pen.lineTo(tip, middle);
|
|
31
|
+
pen.lineTo(tip - head, middle + head);
|
|
32
|
+
pen.stroke();
|
|
33
|
+
return glyph;
|
|
34
|
+
}
|
|
35
|
+
dataSets.forEach(function(dataSet) {
|
|
36
|
+
if (!dataSet.still_in_progress) return;
|
|
37
|
+
dataSet.pointStyle = dataSet.data.map(function(point) {
|
|
38
|
+
return movementArrow(dataSet.backgroundColor, point.r);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return dataSets;
|
|
42
|
+
})()
|
|
10
43
|
},
|
|
11
44
|
options: {
|
|
12
45
|
responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
|
|
@@ -106,10 +106,16 @@
|
|
|
106
106
|
--wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
|
|
107
107
|
--wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
/* Full strength, not lightened. The pastel versions of these two collapsed into the same beige
|
|
110
|
+
under protanopia (4.45 apart, where the palette's own floor is 11.08) and measured 1.6 against
|
|
111
|
+
white, half the contrast WCAG asks of a shape you need to see. At full strength they are 18.45
|
|
112
|
+
apart and clear the contrast floor. The border is the page colour rather than a second hue, so
|
|
113
|
+
it separates overlapping bubbles instead of competing with the fill, and needs no dark mode
|
|
114
|
+
override. See jirametrics-7ss. */
|
|
115
|
+
--estimate-accuracy-chart-completed-fill-color: #009E73; /* Okabe-Ito bluish green */
|
|
116
|
+
--estimate-accuracy-chart-completed-border-color: var(--body-background);
|
|
117
|
+
--estimate-accuracy-chart-active-fill-color: #D55E00; /* Okabe-Ito vermilion */
|
|
118
|
+
--estimate-accuracy-chart-active-border-color: var(--body-background);
|
|
113
119
|
|
|
114
120
|
--expedited-chart-no-longer-expedited: gray;
|
|
115
121
|
--expedited-chart-dot-issue-started-color: #E69F00; /* Okabe-Ito orange */
|
|
@@ -315,8 +321,6 @@ html[data-theme="dark"] {
|
|
|
315
321
|
band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
|
|
316
322
|
stays in the same warm family so the ramp still reads cool to warm as work ages. */
|
|
317
323
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE;
|
|
318
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
319
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
320
324
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
321
325
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
322
326
|
--expedited-chart-dot-expedite-stopped-color: #2DCB9A;
|
|
@@ -481,9 +485,6 @@ html[data-theme="light"] {
|
|
|
481
485
|
--wip-chart-duration-four-weeks-or-less-color: #D55E00;
|
|
482
486
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
|
|
483
487
|
|
|
484
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
485
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
486
|
-
|
|
487
488
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
488
489
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
489
490
|
--expedited-chart-dot-expedite-stopped-color: #2DCB9A;
|
|
@@ -157,6 +157,19 @@ function withAlpha(color, alpha) {
|
|
|
157
157
|
return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// A y-axis tick callback for charts whose ceiling is deliberately set above their tallest value, so
|
|
161
|
+
// you can see that nothing is clipped. Chart.js labels that ceiling too, which gives you an arbitrary
|
|
162
|
+
// number like 169 sitting just above 160, or 62 above 50. Drop that label, unless the ceiling happens
|
|
163
|
+
// to land exactly on the regular grid, in which case it is a perfectly good label and worth keeping.
|
|
164
|
+
// Pass it straight to ticks.callback.
|
|
165
|
+
// ticks 0 20 .. 160 169 -> 169 dropped, it breaks the spacing
|
|
166
|
+
// ticks 0 20 .. 100 120 -> 120 kept, it continues the spacing
|
|
167
|
+
function labelExceptCrowdedCeiling(value, index, ticks) {
|
|
168
|
+
if (index !== ticks.length - 1 || index < 2) return value
|
|
169
|
+
let regularGap = ticks[index - 1].value - ticks[index - 2].value
|
|
170
|
+
return value - ticks[index - 1].value === regularGap ? value : null
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
function createDiagonalPattern(color = 'black') {
|
|
161
174
|
// create a 5x5 px canvas for the pattern's base shape
|
|
162
175
|
let shape = document.createElement('canvas')
|
|
@@ -82,9 +82,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
82
82
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
83
83
|
},
|
|
84
84
|
ticks: {
|
|
85
|
+
// Uses labelExceptCrowdedCeiling() defined in index.js.
|
|
85
86
|
callback: function(value, index, ticks) {
|
|
86
87
|
<% if @cap %>if (value > <%= @cap[:cutoff] %>) return null;<% end %>
|
|
87
|
-
return index
|
|
88
|
+
return labelExceptCrowdedCeiling(value, index, ticks);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
}
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -63,7 +63,7 @@ class Issue
|
|
|
63
63
|
|
|
64
64
|
def labels = raw_fields['labels'] || []
|
|
65
65
|
|
|
66
|
-
def author = raw_fields['creator']&.
|
|
66
|
+
def author = User.from_raw(raw_fields['creator'])&.display_name || ''
|
|
67
67
|
|
|
68
68
|
def resolution = raw_fields['resolution']&.[]('name')
|
|
69
69
|
|
|
@@ -374,13 +374,11 @@ class Issue
|
|
|
374
374
|
@changes.reverse.find(&:resolution?)
|
|
375
375
|
end
|
|
376
376
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
# nil rather than '' when there's no assignee, and daily_view relies on it to decide whether to
|
|
378
|
+
# render an assignee line at all.
|
|
379
|
+
def assigned_to = User.from_raw(raw_fields['assignee'])&.display_name
|
|
380
380
|
|
|
381
|
-
def assigned_to_icon_url
|
|
382
|
-
raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
383
|
-
end
|
|
381
|
+
def assigned_to_icon_url = User.from_raw(raw_fields['assignee'])&.avatar_url
|
|
384
382
|
|
|
385
383
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
386
384
|
# on for pages. Shorten it up.
|
|
@@ -19,10 +19,11 @@ class IssuePrinter
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def assignee_line
|
|
22
|
-
|
|
23
|
-
return '' if
|
|
22
|
+
user = User.from_raw @issue.raw['fields']['assignee']
|
|
23
|
+
return '' if user.nil?
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
email = user.email_address
|
|
26
|
+
" [assignee] #{user.display_name.inspect}#{" <#{email}>" if email}\n"
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def links_section
|
|
@@ -78,6 +79,7 @@ class IssuePrinter
|
|
|
78
79
|
def render_history history
|
|
79
80
|
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
80
81
|
sort_history!(history)
|
|
82
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
81
83
|
history.map do |time, type, detail, _artificial|
|
|
82
84
|
type = type.nil? ? '-' * type_width : type.rjust(type_width)
|
|
83
85
|
" #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
@@ -109,15 +109,13 @@ class JiraGateway
|
|
|
109
109
|
|
|
110
110
|
VerifyResult = Data.define(:ok, :url, :message)
|
|
111
111
|
|
|
112
|
-
# Confirm that our credentials actually authenticate against Jira. We deliberately hit /myself,
|
|
113
|
-
# which requires authentication on every Jira edition (anonymous callers get a 401). An endpoint
|
|
114
|
-
# that also answers anonymously would happily "succeed" with bad credentials, which defeats the
|
|
115
|
-
# whole point. Cloud speaks v3, Server/Data Center speaks v2. Returns a VerifyResult rather than
|
|
116
|
-
# raising so the caller can report on every configured connection and set its own exit status.
|
|
117
112
|
def verify_connection
|
|
113
|
+
# We use /myself because the only reason it can fail is if we aren't authenticated.
|
|
118
114
|
json = call_url relative_url: "/rest/api/#{cloud? ? 3 : 2}/myself"
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
|
|
116
|
+
user = User.new raw: json
|
|
117
|
+
who = user.display_name || 'unknown user'
|
|
118
|
+
VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{who})")
|
|
121
119
|
rescue StandardError => e
|
|
122
120
|
VerifyResult.new(ok: false, url: @jira_url, message: "Could not authenticate to #{@jira_url}: #{e.message}")
|
|
123
121
|
end
|
|
@@ -44,6 +44,7 @@ class ProjectConfig
|
|
|
44
44
|
load_fix_versions
|
|
45
45
|
load_users
|
|
46
46
|
resolve_blocked_stalled_status_settings
|
|
47
|
+
report_unusable_date_annotations
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def run load_only: false
|
|
@@ -79,6 +80,29 @@ class ProjectConfig
|
|
|
79
80
|
# passed through but that no longer exists in Jira). It fires often -- any workflow rework leaves
|
|
80
81
|
# these behind -- so we say the explanation once and hand over a single ready-to-paste block of
|
|
81
82
|
# mappings rather than one repetitive paragraph per status.
|
|
83
|
+
# Print one consolidated warning covering every date annotation that has neither a label nor a
|
|
84
|
+
# description. Such an annotation draws a bare line with nothing to say what it marks, which is
|
|
85
|
+
# almost always a typo in the config rather than a choice. One warning per project, because the
|
|
86
|
+
# same settings hash is usually shared across several projects and every chart re-reads it.
|
|
87
|
+
def report_unusable_date_annotations
|
|
88
|
+
return if @unusable_date_annotations_reported
|
|
89
|
+
|
|
90
|
+
@unusable_date_annotations_reported = true
|
|
91
|
+
|
|
92
|
+
unusable = (settings['date_annotations'] || []).reject do |annotation|
|
|
93
|
+
annotation['label'] || annotation['description']
|
|
94
|
+
end
|
|
95
|
+
return if unusable.empty?
|
|
96
|
+
|
|
97
|
+
dates = unusable.map { |annotation| annotation['date'].inspect }.join(', ')
|
|
98
|
+
file_system.warning(
|
|
99
|
+
"These date annotations have neither a label nor a description: #{dates}. Each will draw " \
|
|
100
|
+
'a bare line with nothing to say what it marks, which is almost certainly a mistake. Give ' \
|
|
101
|
+
'each one a label, a description, or both. If a bare line really is what you wanted, they ' \
|
|
102
|
+
'will work as they are.'
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
82
106
|
def report_fabricated_statuses
|
|
83
107
|
statuses = possible_statuses.fabricated_statuses
|
|
84
108
|
return if statuses.empty? || @fabricated_statuses_reported
|
|
@@ -37,13 +37,13 @@ class SprintBurndown < ChartBase
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def run
|
|
40
|
-
return
|
|
40
|
+
return render_no_data unless current_board.scrum?
|
|
41
41
|
|
|
42
42
|
sprints = sprints_in_time_range current_board
|
|
43
43
|
change_data_by_sprint = gather_change_data_by_sprint sprints
|
|
44
44
|
|
|
45
45
|
result = +''
|
|
46
|
-
result << render_top_text
|
|
46
|
+
result << render_top_text
|
|
47
47
|
|
|
48
48
|
# HashEachMethods misreads this array of [method, title] pairs as a hash and thinks y_axis_title
|
|
49
49
|
# is unused; in fact the ERB template reads it (and data_method) from the binding we pass to render.
|