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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 056e9e0149e1e7f1d03968b91d5a62937d82a5f529ceba4917b6b23c74f33103
|
|
4
|
+
data.tar.gz: e883680589519bb9c527873d607755dbca2d4082053a5747a9bd7de2506305f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd257eb627a31909dcb389ec7d66739d9d5d3aebf6d8301af76c41c4f989ebce899ca8fa543d2557dfd3a4ffd4af0110eb3f8bea7872bdf6ae4931fb15b6fc13
|
|
7
|
+
data.tar.gz: be1fee3d08642e25db8a998803921ef385cef5dd5cef99b074808553c7619c6af7069833df5fce644a95976ef7e7ffc4ca1cb9ccc0047b066ce8e85900a7ca85
|
|
@@ -14,6 +14,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
14
14
|
@percentage_lines = [] # Populated by run; the description reads it, so it must never be nil.
|
|
15
15
|
percentiles [85]
|
|
16
16
|
header_text 'Aging Work Bar Chart'
|
|
17
|
+
no_data_text '<%= render_header %><p>There is no aging work</p>'
|
|
17
18
|
# div class="p" throughout rather than <p>: color_block emits a div and the list below is an
|
|
18
19
|
# ol, neither of which is legal inside a paragraph. A browser closes the p at the first one,
|
|
19
20
|
# which strands the rest of the text outside it. This is what the other charts use.
|
|
@@ -69,10 +70,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
69
70
|
{ percentile: percentile, days: days, x: date_range.end - days, id: "percentile_#{percentile}" }
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
if aging_issues.empty?
|
|
73
|
-
@description_text = '<p>There is no aging work</p>'
|
|
74
|
-
return render_top_text(binding)
|
|
75
|
-
end
|
|
73
|
+
return render_no_data if aging_issues.empty?
|
|
76
74
|
|
|
77
75
|
wrap_and_render(binding, __FILE__)
|
|
78
76
|
end
|
|
@@ -12,7 +12,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
12
12
|
|
|
13
13
|
def initialize block
|
|
14
14
|
super()
|
|
15
|
-
header_text 'Aging Work in Progress'
|
|
15
|
+
header_text 'Aging Work in Progress on board: <%= current_board.name %>'
|
|
16
16
|
# div class="p" rather than <p>: the notes below are a ul, which is block level and not legal
|
|
17
17
|
# inside a paragraph, so a browser closes the p early. Harmless while the list is the last
|
|
18
18
|
# thing in the block, but it breaks the moment any text follows it.
|
|
@@ -59,7 +59,6 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
59
59
|
def run
|
|
60
60
|
determine_board_columns
|
|
61
61
|
|
|
62
|
-
@header_text += " on board: #{current_board.name}"
|
|
63
62
|
data_sets = make_data_sets
|
|
64
63
|
|
|
65
64
|
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
@@ -91,17 +90,27 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
91
90
|
|
|
92
91
|
rules_to_issues = group_issues aging_issues
|
|
93
92
|
data_sets = rules_to_issues.keys.collect { |rules| line_data_set(rules, rules_to_issues[rules]) }
|
|
94
|
-
# The y-axis is scaled to the oldest item, but never shorter than 20 days.
|
|
95
|
-
@max_age = data_sets.flat_map { |set| set['data'].collect { |point| point['y'] } }.push(20).max
|
|
96
93
|
|
|
97
94
|
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
98
95
|
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
99
|
-
@row_index_offset = data_sets.size
|
|
100
96
|
|
|
101
97
|
append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
98
|
+
@max_age = highest_plotted_age data_sets
|
|
102
99
|
data_sets
|
|
103
100
|
end
|
|
104
101
|
|
|
102
|
+
# The y-axis ceiling is set a little above this so you can see that the tallest thing on the chart
|
|
103
|
+
# really does stop where it stops, rather than being clipped by the top of the plot. Bands count as
|
|
104
|
+
# much as dots do. Never shorter than 20 days.
|
|
105
|
+
# Called after the columns have been trimmed, so a column nobody can see can't push the ceiling up
|
|
106
|
+
# and leave unexplained space at the top.
|
|
107
|
+
def highest_plotted_age data_sets
|
|
108
|
+
data_sets.flat_map do |set|
|
|
109
|
+
# A dot is {x:, y:, title:}. A band is a floating bar, [bottom, top].
|
|
110
|
+
set['data'].collect { |point| point.is_a?(Hash) ? point['y'] : point.last }
|
|
111
|
+
end.push(20).max
|
|
112
|
+
end
|
|
113
|
+
|
|
105
114
|
def aging_issue_on_board? issue
|
|
106
115
|
issue.board.id == @board_id && issue.board.cycletime.in_progress?(issue)
|
|
107
116
|
end
|
|
@@ -128,32 +137,23 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
128
137
|
end
|
|
129
138
|
end
|
|
130
139
|
|
|
140
|
+
# Each band is a floating bar running from the previous percentile's age up to its own, so it
|
|
141
|
+
# carries its own absolute position. Asking Chart.js to stack the bands instead hands it the
|
|
142
|
+
# positioning of every dataset on the chart, and it will stack the aging dots right along with them.
|
|
131
143
|
def append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
previous_ages = []
|
|
145
|
+
|
|
146
|
+
@percentiles.keys.sort.each do |percentage|
|
|
147
|
+
ages = calculator.age_data_for percentage: percentage
|
|
148
|
+
column_indexes_to_remove.reverse_each { |index| ages.delete_at index }
|
|
149
|
+
|
|
135
150
|
data_sets << {
|
|
136
151
|
'type' => 'bar', 'label' => "#{percentage}%", 'barPercentage' => 1.0, 'categoryPercentage' => 1.0,
|
|
137
|
-
'backgroundColor' => @percentiles[percentage],
|
|
152
|
+
'backgroundColor' => @percentiles[percentage],
|
|
153
|
+
'data' => ages.each_with_index.collect { |age, index| [previous_ages[index] || 0, age] }
|
|
138
154
|
}
|
|
139
|
-
|
|
155
|
+
previous_ages = ages
|
|
140
156
|
end
|
|
141
|
-
@bar_data = adjust_bar_data bar_data
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def adjust_bar_data input
|
|
145
|
-
return [] if input.empty?
|
|
146
|
-
|
|
147
|
-
row_size = input.first.size
|
|
148
|
-
|
|
149
|
-
output = []
|
|
150
|
-
output << input.first
|
|
151
|
-
input.drop(1).each do |row|
|
|
152
|
-
previous_row = output.last
|
|
153
|
-
output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
output
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def indexes_of_leading_and_trailing_zeros list
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
class AtlassianDocumentFormat
|
|
4
6
|
attr_reader :users
|
|
5
7
|
|
|
@@ -10,7 +12,7 @@ class AtlassianDocumentFormat
|
|
|
10
12
|
|
|
11
13
|
def to_html input
|
|
12
14
|
if input.is_a? String
|
|
13
|
-
input
|
|
15
|
+
escape(input)
|
|
14
16
|
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
17
|
# rubocop:disable Style/PerlBackrefs -- in a gsub block $1 reads cleaner than Regexp.last_match[:...]
|
|
16
18
|
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
@@ -52,22 +54,22 @@ class AtlassianDocumentFormat
|
|
|
52
54
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
53
55
|
when 'decisionItem', 'listItem' then ['<li>', '</li>']
|
|
54
56
|
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
55
|
-
when 'emoji', 'status' then [node_attrs['text'], nil]
|
|
56
|
-
when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
|
|
57
|
+
when 'emoji', 'status' then [escape(node_attrs['text']), nil]
|
|
58
|
+
when 'expand' then ["<div>#{escape node_attrs['title']}</div>", nil]
|
|
57
59
|
when 'hardBreak' then ['<br />', nil]
|
|
58
60
|
when 'heading'
|
|
59
61
|
level = node_attrs['level']
|
|
60
62
|
["<h#{level}>", "</h#{level}>"]
|
|
61
63
|
when 'inlineCard'
|
|
62
|
-
url = node_attrs['url']
|
|
64
|
+
url = escape node_attrs['url']
|
|
63
65
|
["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
|
|
64
66
|
when 'media'
|
|
65
|
-
text = node_attrs['alt'] || node_attrs['id']
|
|
67
|
+
text = escape(node_attrs['alt'] || node_attrs['id'])
|
|
66
68
|
["Media: #{text}", nil]
|
|
67
69
|
when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
|
|
68
|
-
when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
|
|
70
|
+
when 'mention' then ["<b>#{escape node_attrs['text']}</b>", nil]
|
|
69
71
|
when 'orderedList' then ['<ol>', '</ol>']
|
|
70
|
-
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
72
|
+
when 'panel' then ["<div>#{escape node_attrs['panelType'].upcase}</div>", nil]
|
|
71
73
|
when 'paragraph' then ['<p>', '</p>']
|
|
72
74
|
when 'rule' then ['<hr />', nil]
|
|
73
75
|
when 'table' then ['<table>', '</table>']
|
|
@@ -76,13 +78,13 @@ class AtlassianDocumentFormat
|
|
|
76
78
|
when 'tableRow' then ['<tr>', '</tr>']
|
|
77
79
|
when 'text'
|
|
78
80
|
marks = adf_marks_to_html(n['marks'])
|
|
79
|
-
[marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
|
|
81
|
+
[marks.collect(&:first).join + escape(n['text']), marks.collect(&:last).join]
|
|
80
82
|
when 'taskItem'
|
|
81
83
|
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
82
84
|
["<li>#{state} ", '</li>']
|
|
83
85
|
when 'taskList' then ["<ul class='taskList'>", '</ul>']
|
|
84
86
|
else
|
|
85
|
-
["<p>Unparseable section: #{n['type']}</p>", nil]
|
|
87
|
+
["<p>Unparseable section: #{escape n['type']}</p>", nil]
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
end
|
|
@@ -135,11 +137,11 @@ class AtlassianDocumentFormat
|
|
|
135
137
|
list.filter_map do |mark|
|
|
136
138
|
type = mark['type']
|
|
137
139
|
if type == 'textColor'
|
|
138
|
-
color = mark['attrs']['color']
|
|
140
|
+
color = escape mark['attrs']['color']
|
|
139
141
|
["<span style='color: #{color}'>", '</span>']
|
|
140
142
|
elsif type == 'link'
|
|
141
|
-
href = mark['attrs']['href']
|
|
142
|
-
title = mark['attrs']['title']
|
|
143
|
+
href = escape mark['attrs']['href']
|
|
144
|
+
title = escape mark['attrs']['title']
|
|
143
145
|
["<a href='#{href}' title='#{title}'>", '</a>']
|
|
144
146
|
else
|
|
145
147
|
line = mappings.find { |key, _open, _close| key == type }
|
|
@@ -150,13 +152,19 @@ class AtlassianDocumentFormat
|
|
|
150
152
|
|
|
151
153
|
def expand_account_id account_id
|
|
152
154
|
user = @users.find { |u| u.account_id == account_id }
|
|
153
|
-
text = account_id
|
|
154
|
-
text = "@#{user.display_name}" if user
|
|
155
|
+
text = escape account_id
|
|
156
|
+
text = "@#{escape user.display_name}" if user
|
|
155
157
|
"<span class='account_id'>#{text}</span>"
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
private
|
|
159
161
|
|
|
162
|
+
# The only markup in the output should be markup this class generated. Everything that comes out
|
|
163
|
+
# of the document is data written by whoever raised the issue, so it gets escaped on the way past.
|
|
164
|
+
def escape value
|
|
165
|
+
CGI.escapeHTML value.to_s
|
|
166
|
+
end
|
|
167
|
+
|
|
160
168
|
def adf_node_render node, &render_node
|
|
161
169
|
prefix, suffix = yield(node)
|
|
162
170
|
result = +(prefix || '')
|
|
@@ -22,29 +22,6 @@ class BoardMovementCalculator
|
|
|
22
22
|
columns.each_cons(2).any? { |previous, current| current < previous }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def stacked_age_data_for percentages:
|
|
26
|
-
data_list = percentages.sort.collect do |percentage|
|
|
27
|
-
[percentage, age_data_for(percentage: percentage)]
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
stack_data data_list
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def stack_data data_list
|
|
34
|
-
remainder = nil
|
|
35
|
-
data_list.collect do |percentage, data|
|
|
36
|
-
unless remainder.nil?
|
|
37
|
-
data = (0...data.length).collect do |i|
|
|
38
|
-
data[i] - remainder[i]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
remainder = data
|
|
43
|
-
|
|
44
|
-
[percentage, data]
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
25
|
def age_data_for percentage:
|
|
49
26
|
data = []
|
|
50
27
|
board.visible_columns.each_with_index do |_column, column_index|
|
|
@@ -37,13 +37,10 @@ class ChangeItem
|
|
|
37
37
|
raw_value.to_s.split(', ').collect(&:to_i)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
40
|
+
# It should be impossible to have no author at all, but we've seen it in production.
|
|
41
|
+
def author = User.from_raw(@author_raw)&.display_name || 'Unknown author'
|
|
43
42
|
|
|
44
|
-
def author_icon_url
|
|
45
|
-
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
|
46
|
-
end
|
|
43
|
+
def author_icon_url = User.from_raw(@author_raw)&.avatar_url
|
|
47
44
|
|
|
48
45
|
def artificial? = @artificial
|
|
49
46
|
def assignee? = (field == 'assignee')
|
|
@@ -129,8 +126,7 @@ class ChangeItem
|
|
|
129
126
|
private
|
|
130
127
|
|
|
131
128
|
def time_to_s time
|
|
132
|
-
#
|
|
133
|
-
# format so that tests work under both environments.
|
|
129
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
134
130
|
time.strftime '%Y-%m-%d %H:%M:%S %z'
|
|
135
131
|
end
|
|
136
132
|
end
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# require_all cannot resolve an include the way it resolves a superclass, and chart_format.rb
|
|
4
|
+
# sorts after this file, so name it explicitly.
|
|
5
|
+
require_relative 'chart_format'
|
|
6
|
+
|
|
3
7
|
class ChartBase
|
|
8
|
+
include ChartFormat
|
|
9
|
+
|
|
4
10
|
# Okabe-Ito palette - perceptually distinct under the most common forms of colour blindness.
|
|
5
11
|
# Ordered from most- to least-commonly useful for chart series.
|
|
6
12
|
OKABE_ITO_PALETTE = %w[
|
|
@@ -63,17 +69,44 @@ class ChartBase
|
|
|
63
69
|
erb.result(caller_binding)
|
|
64
70
|
end
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
# A fresh scope for every template expansion. self is still the chart, so instance variables and
|
|
73
|
+
# methods are reachable from a template, but the caller's local variables are not. That is the
|
|
74
|
+
# point: ERB#result assigns its output buffer into whatever binding you hand it, so two templates
|
|
75
|
+
# sharing one scope silently discard each other's output when one renders the other.
|
|
76
|
+
def render_binding = binding
|
|
77
|
+
|
|
78
|
+
def expand_template text
|
|
79
|
+
ERB.new(text).result(render_binding)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The header as the reader sees it. Stitch configs match on this through grab_by_title, so it has
|
|
83
|
+
# to be the expanded text rather than the template.
|
|
84
|
+
def expanded_header_text = @header_text.nil? ? '' : expand_template(@header_text)
|
|
85
|
+
|
|
86
|
+
def render_header
|
|
87
|
+
return '' unless @header_text
|
|
88
|
+
|
|
89
|
+
"<h1 class='foldable'>#{expanded_header_text}</h1>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_top_text
|
|
93
|
+
result = +render_header
|
|
94
|
+
result << expand_template(@description_text) if @description_text
|
|
70
95
|
result
|
|
71
96
|
end
|
|
72
97
|
|
|
98
|
+
# What a chart shows when it has nothing to show. Empty means show nothing at all, not even the
|
|
99
|
+
# header, which is what a chart that simply does not apply to this board wants.
|
|
100
|
+
def render_no_data
|
|
101
|
+
return '' if @no_data_text.nil? || @no_data_text.empty?
|
|
102
|
+
|
|
103
|
+
expand_template @no_data_text
|
|
104
|
+
end
|
|
105
|
+
|
|
73
106
|
# Render the file and then wrap it with standard headers and quality checks.
|
|
74
107
|
def wrap_and_render caller_binding, file
|
|
75
108
|
result = +''
|
|
76
|
-
result << render_top_text
|
|
109
|
+
result << render_top_text
|
|
77
110
|
result << render(caller_binding, file)
|
|
78
111
|
result
|
|
79
112
|
end
|
|
@@ -165,12 +198,24 @@ class ChartBase
|
|
|
165
198
|
|
|
166
199
|
LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
|
|
167
200
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
201
|
+
# chartjs-plugin-annotation renders each array element as its own line and does no wrapping of
|
|
202
|
+
# its own, so anything long has to arrive already split.
|
|
203
|
+
ANNOTATION_DESCRIPTION_WIDTH = 60
|
|
204
|
+
|
|
205
|
+
# The descriptions a chart needs to draw its own hover reveal, with the dates already normalized
|
|
206
|
+
# and the text already wrapped, so the JavaScript only has to decide when to show them.
|
|
207
|
+
def date_annotation_hover_data
|
|
208
|
+
annotations_in_range
|
|
209
|
+
.select { |(a, _)| a['description'] }
|
|
210
|
+
.map { |(a, normalized)| { 'date' => normalized, 'lines' => wrap_annotation_description(a['description']) } }
|
|
211
|
+
.to_json
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# hover: false leaves out the reveal callbacks and the widened hit target, for a chart that
|
|
215
|
+
# draws its own hover reveal. The CFD does, because its flow metrics triangle switches Chart.js
|
|
216
|
+
# events off and the plugin's hover cannot fire while that is on.
|
|
217
|
+
def date_annotation hover: true
|
|
218
|
+
in_range = annotations_in_range
|
|
174
219
|
|
|
175
220
|
positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
|
|
176
221
|
|
|
@@ -181,17 +226,94 @@ class ChartBase
|
|
|
181
226
|
xMin: #{normalized.to_json},
|
|
182
227
|
xMax: #{normalized.to_json},
|
|
183
228
|
borderColor: 'rgba(0,0,0,0.7)',
|
|
184
|
-
borderWidth: 1,
|
|
185
|
-
label: {
|
|
186
|
-
display: true,
|
|
187
|
-
content: #{a['label'].to_json},
|
|
188
|
-
position: #{positions[index].to_json}
|
|
189
|
-
}
|
|
229
|
+
borderWidth: 1#{annotation_hit_tolerance(a) if hover}#{annotation_label a, positions[index]}#{annotation_hover_callbacks(a, index) if hover}
|
|
190
230
|
},
|
|
191
231
|
TEXT
|
|
192
232
|
end.join
|
|
193
233
|
end
|
|
194
234
|
|
|
235
|
+
def annotations_in_range
|
|
236
|
+
(settings['date_annotations'] || [])
|
|
237
|
+
.map { |a| [a, normalize_annotation_datetime(a['date'])] }
|
|
238
|
+
.select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
|
|
239
|
+
.sort_by { |(_, dt)| dt }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The label block for one annotation, or nothing at all when there is neither a label nor a
|
|
243
|
+
# description to put in it.
|
|
244
|
+
def annotation_label annotation, position
|
|
245
|
+
# drawTime: the CFD hatches its correction windows over the chart and the scatterplot draws
|
|
246
|
+
# dots and percentile lines. Drawing the label last keeps it readable on both.
|
|
247
|
+
|
|
248
|
+
label = annotation['label']
|
|
249
|
+
description = annotation['description']
|
|
250
|
+
return '' if label.nil? && description.nil?
|
|
251
|
+
|
|
252
|
+
",\n" \
|
|
253
|
+
" label: {\n" \
|
|
254
|
+
" display: #{!label.nil?},\n" \
|
|
255
|
+
" content: #{annotation_content(annotation).to_json},\n" \
|
|
256
|
+
" position: #{position.to_json},\n" \
|
|
257
|
+
" drawTime: 'afterDraw'\n" \
|
|
258
|
+
' }'
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A label on its own stays a plain string, exactly as it always was. Paired with a description it
|
|
262
|
+
# becomes a single element array so that hovering can append the description lines below it. With
|
|
263
|
+
# no label at all the description is the whole content, hidden until the line is hovered.
|
|
264
|
+
def annotation_content annotation
|
|
265
|
+
label = annotation['label']
|
|
266
|
+
return wrap_annotation_description annotation['description'] if label.nil?
|
|
267
|
+
|
|
268
|
+
annotation['description'] ? [label] : label
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The line is a single pixel wide, so without widening the hit target there is nothing the
|
|
272
|
+
# mouse can realistically land on. Only annotations that react to hovering need it.
|
|
273
|
+
def annotation_hit_tolerance annotation
|
|
274
|
+
annotation['description'] ? ",\n hitTolerance: 6" : ''
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def wrap_annotation_description description
|
|
278
|
+
description.split.each_with_object([]) do |word, lines|
|
|
279
|
+
if lines.empty? || "#{lines.last} #{word}".length > ANNOTATION_DESCRIPTION_WIDTH
|
|
280
|
+
lines << word
|
|
281
|
+
else
|
|
282
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Two things here are easy to get wrong, and both were, so they are worth spelling out.
|
|
288
|
+
#
|
|
289
|
+
# update('none') rather than draw(): the label's box is measured during the update, so a plain
|
|
290
|
+
# redraw paints the new lines at the old box size and they spill across the chart as white text
|
|
291
|
+
# on nothing. 'none' skips the animation.
|
|
292
|
+
#
|
|
293
|
+
# Writing to the chart config rather than to ctx.element: update() re-resolves every annotation
|
|
294
|
+
# from the config, so anything written to the element is discarded before it can be drawn. The
|
|
295
|
+
# percentile lines elsewhere can write to the element because they call draw(), not update().
|
|
296
|
+
def annotation_hover_callbacks annotation, index
|
|
297
|
+
description = annotation['description']
|
|
298
|
+
return '' unless description
|
|
299
|
+
return annotation_reveal_callbacks if annotation['label'].nil?
|
|
300
|
+
|
|
301
|
+
expanded = [annotation['label'], *wrap_annotation_description(description)]
|
|
302
|
+
target = "ctx.chart.options.plugins.annotation.annotations.dateAnnotation#{index}.label.content"
|
|
303
|
+
",\n" \
|
|
304
|
+
" enter(ctx) { #{target} = #{expanded.to_json}; ctx.chart.update('none'); },\n" \
|
|
305
|
+
" leave(ctx) { #{target} = #{annotation_content(annotation).to_json}; ctx.chart.update('none'); }"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# With no label there is nothing on screen at rest, so hovering only has to switch the hidden
|
|
309
|
+
# label on. The content never changes, so its box is already measured and draw() is enough.
|
|
310
|
+
# That avoids the update() dance the case above needs, and the element can be written directly.
|
|
311
|
+
def annotation_reveal_callbacks
|
|
312
|
+
",\n" \
|
|
313
|
+
' enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },' \
|
|
314
|
+
"\n leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }"
|
|
315
|
+
end
|
|
316
|
+
|
|
195
317
|
def stagger_label_positions datetimes
|
|
196
318
|
return [] if datetimes.empty?
|
|
197
319
|
|
|
@@ -240,20 +362,16 @@ class ChartBase
|
|
|
240
362
|
end
|
|
241
363
|
end
|
|
242
364
|
|
|
243
|
-
def chart_format object
|
|
244
|
-
if object.is_a? Time
|
|
245
|
-
# "2022-04-09T11:38:30-07:00"
|
|
246
|
-
object.strftime '%Y-%m-%dT%H:%M:%S%z'
|
|
247
|
-
else
|
|
248
|
-
object.to_s
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
365
|
def header_text text = :none
|
|
253
366
|
@header_text = text unless text == :none
|
|
254
367
|
@header_text
|
|
255
368
|
end
|
|
256
369
|
|
|
370
|
+
def no_data_text text = :none
|
|
371
|
+
@no_data_text = text unless text == :none
|
|
372
|
+
@no_data_text
|
|
373
|
+
end
|
|
374
|
+
|
|
257
375
|
def description_text text = :none
|
|
258
376
|
@description_text = text unless text == :none
|
|
259
377
|
@description_text
|
|
@@ -412,7 +530,7 @@ class ChartBase
|
|
|
412
530
|
# reassemble the report. Generated because only the start/end word differs.
|
|
413
531
|
%w[start end].each do |position|
|
|
414
532
|
define_method "seam_#{position}" do |type = 'chart'|
|
|
415
|
-
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{
|
|
533
|
+
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{expanded_header_text} | #{type} -->\n"
|
|
416
534
|
end
|
|
417
535
|
end
|
|
418
536
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Formats values for a Chart.js axis. Included by ChartBase and by the specs, which build the same
|
|
4
|
+
# strings to assert against.
|
|
5
|
+
module ChartFormat
|
|
6
|
+
def chart_format object
|
|
7
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
8
|
+
|
|
9
|
+
if object.is_a? Time
|
|
10
|
+
object.strftime '%Y-%m-%dT%H:%M:%S%z' # => 2022-04-09T11:38:30-0700
|
|
11
|
+
else
|
|
12
|
+
object.to_s
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -7,9 +7,10 @@ class DailyView < ChartBase
|
|
|
7
7
|
super()
|
|
8
8
|
|
|
9
9
|
header_text 'Daily View'
|
|
10
|
+
no_data_text '<%= render_header %><div>There are no items currently in progress</div>'
|
|
10
11
|
description_text <<-HTML
|
|
11
12
|
<div class="p">
|
|
12
|
-
This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
|
|
13
|
+
This view shows all the items (<%= @aging_issues.count %>) you'll want to discuss during your daily
|
|
13
14
|
coordination meeting
|
|
14
15
|
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
15
16
|
items are at the top, and the least at the bottom.
|
|
@@ -22,15 +23,13 @@ class DailyView < ChartBase
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def run
|
|
25
|
-
aging_issues = select_aging_issues
|
|
26
|
+
@aging_issues = select_aging_issues
|
|
26
27
|
|
|
27
|
-
if aging_issues.empty?
|
|
28
|
-
return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>"
|
|
29
|
-
end
|
|
28
|
+
return render_no_data if @aging_issues.empty?
|
|
30
29
|
|
|
31
30
|
result = +''
|
|
32
|
-
result << render_top_text
|
|
33
|
-
aging_issues.each do |issue|
|
|
31
|
+
result << render_top_text
|
|
32
|
+
@aging_issues.each do |issue|
|
|
34
33
|
result << render_issue(issue, child: false)
|
|
35
34
|
end
|
|
36
35
|
result
|
|
@@ -14,7 +14,7 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
14
14
|
indication of how focused we are on higher level objectives. If there are many parent
|
|
15
15
|
tickets in progress at the same time, either this team has their focus scattered or we
|
|
16
16
|
aren't doing a good job of
|
|
17
|
-
<a href="https://
|
|
17
|
+
<a href="https://blog.mikebowler.ca/2024/02/21/slicing-epics/">splitting those parent
|
|
18
18
|
tickets</a>. Neither of those is desirable.
|
|
19
19
|
</div>
|
|
20
20
|
<div class="p">
|
|
@@ -63,11 +63,10 @@ class DataQualityReport < ChartBase
|
|
|
63
63
|
scan_for_issues_on_multiple_boards entries: @entries
|
|
64
64
|
|
|
65
65
|
entries_with_problems = entries_with_problems()
|
|
66
|
-
return
|
|
66
|
+
return render_no_data if entries_with_problems.empty?
|
|
67
67
|
|
|
68
|
-
caller_binding = binding
|
|
69
68
|
result = +''
|
|
70
|
-
result << render_top_text
|
|
69
|
+
result << render_top_text
|
|
71
70
|
|
|
72
71
|
result << '<ul class="quality_report">'
|
|
73
72
|
result << render_problem_type(:discarded_changes)
|
|
@@ -111,6 +110,7 @@ class DataQualityReport < ChartBase
|
|
|
111
110
|
|
|
112
111
|
# Return a format that's easier to assert against
|
|
113
112
|
def testable_entries
|
|
113
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
114
114
|
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
115
115
|
@entries.collect do |entry|
|
|
116
116
|
[
|
|
@@ -67,6 +67,7 @@ class DependencyChart < ChartBase
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
header_text 'Dependencies'
|
|
70
|
+
no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
|
|
70
71
|
description_text <<-HTML
|
|
71
72
|
<p>
|
|
72
73
|
These are all the "linked issues" as defined in Jira
|
|
@@ -83,13 +84,10 @@ class DependencyChart < ChartBase
|
|
|
83
84
|
instance_eval(&@rules_block) if @rules_block
|
|
84
85
|
|
|
85
86
|
dot_graph = build_dot_graph
|
|
86
|
-
if dot_graph.nil?
|
|
87
|
-
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
88
|
-
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
89
|
-
end
|
|
87
|
+
return render_no_data if dot_graph.nil?
|
|
90
88
|
|
|
91
89
|
svg = restore_css_variables execute_graphviz(dot_graph.join("\n"))
|
|
92
|
-
"
|
|
90
|
+
"#{render_top_text}<div>#{shrink_svg svg}</div>"
|
|
93
91
|
end
|
|
94
92
|
|
|
95
93
|
def link_rules &block
|