jirametrics 3.3 → 3.4
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 +44 -15
- 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 +109 -20
- 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/estimate_accuracy_chart.erb +34 -1
- data/lib/jirametrics/html/index.css +55 -9
- data/lib/jirametrics/html/index.js +13 -0
- data/lib/jirametrics/html/legacy_colors.css +18 -0
- data/lib/jirametrics/html/time_based_histogram.erb +9 -5
- 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/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 +16 -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: 1f7292394330330ca10f89a28f02fab3e95cff385736c423ca3602c23551e4ca
|
|
4
|
+
data.tar.gz: 4690b5ce8cc47d3e980ee026e68f6f245e564ec51aa914914dd7a0db32f3c0b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f32fbe86ecfb9ecc5ce7ace7d0a2971b6777c7a61e4513d41d2444a976699487ba4da415b6e84484cad72a45ee6af4a822f14da430018e6b40b39bb48046a70
|
|
7
|
+
data.tar.gz: 2b4af2fe6c95834710901e3aacccc1d260ceb7c28020a853f1a643be1faf3c48a0663488839c522249e2a14a2d3c6e4665425a3c7bf5175354a0311493679ee6
|
|
@@ -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
|
|
@@ -240,20 +273,16 @@ class ChartBase
|
|
|
240
273
|
end
|
|
241
274
|
end
|
|
242
275
|
|
|
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
276
|
def header_text text = :none
|
|
253
277
|
@header_text = text unless text == :none
|
|
254
278
|
@header_text
|
|
255
279
|
end
|
|
256
280
|
|
|
281
|
+
def no_data_text text = :none
|
|
282
|
+
@no_data_text = text unless text == :none
|
|
283
|
+
@no_data_text
|
|
284
|
+
end
|
|
285
|
+
|
|
257
286
|
def description_text text = :none
|
|
258
287
|
@description_text = text unless text == :none
|
|
259
288
|
@description_text
|
|
@@ -412,7 +441,7 @@ class ChartBase
|
|
|
412
441
|
# reassemble the report. Generated because only the start/end word differs.
|
|
413
442
|
%w[start end].each do |position|
|
|
414
443
|
define_method "seam_#{position}" do |type = 'chart'|
|
|
415
|
-
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{
|
|
444
|
+
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{expanded_header_text} | #{type} -->\n"
|
|
416
445
|
end
|
|
417
446
|
end
|
|
418
447
|
|
|
@@ -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
|
[
|
|
@@ -32,10 +32,42 @@ class DependencyChart < ChartBase
|
|
|
32
32
|
attr_accessor :color, :label
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# A fill and the label colour that goes on top of it, kept together because they are not
|
|
36
|
+
# independent choices: some of these fills are light and take black text, others are dark and
|
|
37
|
+
# take white, and a node that took its fill from one and its label from another would be
|
|
38
|
+
# unreadable. Always hand them out as a pair.
|
|
39
|
+
Palette = Struct.new :fill, :label
|
|
40
|
+
|
|
41
|
+
def self.palette_entry name
|
|
42
|
+
Palette.new CssVariable["--dependency-chart-#{name}-color"],
|
|
43
|
+
CssVariable["--dependency-chart-#{name}-label-color"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
PALETTE = {
|
|
47
|
+
story: palette_entry('story'),
|
|
48
|
+
task: palette_entry('task'),
|
|
49
|
+
bug: palette_entry('bug'),
|
|
50
|
+
epic: palette_entry('epic'),
|
|
51
|
+
spike: palette_entry('spike')
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
35
54
|
def initialize rules_block
|
|
36
55
|
super()
|
|
37
56
|
|
|
57
|
+
# Not the inherited type colours, because these are fills with label text sitting on top of
|
|
58
|
+
# them and those are line colours, judged against the page rather than against the text. See
|
|
59
|
+
# index.css for which colours these are and why.
|
|
60
|
+
@palette_by_type = {
|
|
61
|
+
'Story' => PALETTE[:story],
|
|
62
|
+
'Task' => PALETTE[:task],
|
|
63
|
+
'Bug' => PALETTE[:bug],
|
|
64
|
+
'Defect' => PALETTE[:bug],
|
|
65
|
+
'Epic' => PALETTE[:epic],
|
|
66
|
+
'Spike' => PALETTE[:spike]
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
header_text 'Dependencies'
|
|
70
|
+
no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
|
|
39
71
|
description_text <<-HTML
|
|
40
72
|
<p>
|
|
41
73
|
These are all the "linked issues" as defined in Jira
|
|
@@ -52,13 +84,10 @@ class DependencyChart < ChartBase
|
|
|
52
84
|
instance_eval(&@rules_block) if @rules_block
|
|
53
85
|
|
|
54
86
|
dot_graph = build_dot_graph
|
|
55
|
-
if dot_graph.nil?
|
|
56
|
-
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
57
|
-
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
58
|
-
end
|
|
87
|
+
return render_no_data if dot_graph.nil?
|
|
59
88
|
|
|
60
|
-
svg = execute_graphviz(dot_graph.join("\n"))
|
|
61
|
-
"
|
|
89
|
+
svg = restore_css_variables execute_graphviz(dot_graph.join("\n"))
|
|
90
|
+
"#{render_top_text}<div>#{shrink_svg svg}</div>"
|
|
62
91
|
end
|
|
63
92
|
|
|
64
93
|
def link_rules &block
|
|
@@ -77,6 +106,42 @@ class DependencyChart < ChartBase
|
|
|
77
106
|
result
|
|
78
107
|
end
|
|
79
108
|
|
|
109
|
+
# Graphviz has never heard of CSS variables. Handed one it emits a warning nobody sees and
|
|
110
|
+
# falls back to black, which is how a node ends up as black text on a black background. So a
|
|
111
|
+
# variable is swapped for a placeholder colour here and mapped back to the variable in the
|
|
112
|
+
# generated SVG, by #restore_css_variables. Anything that isn't a variable is left alone.
|
|
113
|
+
#
|
|
114
|
+
# The placeholders only need to be colours that nobody would ever choose deliberately, so that
|
|
115
|
+
# the CSS selectors matching them in the SVG cannot hit anything else.
|
|
116
|
+
def graphviz_color color
|
|
117
|
+
return color unless color.is_a? CssVariable
|
|
118
|
+
|
|
119
|
+
css_variable_placeholders[color.name] ||= format '#fe00%02x', css_variable_placeholders.size + 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def css_variable_placeholders
|
|
123
|
+
@css_variable_placeholders ||= {}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Turns the placeholders from #graphviz_color back into the variables they stood for, by way of
|
|
127
|
+
# a stylesheet that selects on the placeholder value. Rewriting the attributes in place would be
|
|
128
|
+
# the obvious move, but var() is only legal in a CSS value and not in an SVG presentation
|
|
129
|
+
# attribute, so the colour has to arrive as a real CSS rule. Author rules beat presentation
|
|
130
|
+
# attributes, so the placeholder never wins.
|
|
131
|
+
#
|
|
132
|
+
# fillcolor and fontcolor come out of graphviz as fill and color comes out as stroke, and an
|
|
133
|
+
# arrowhead uses both, so every placeholder gets a rule for each.
|
|
134
|
+
def restore_css_variables svg
|
|
135
|
+
return svg if css_variable_placeholders.empty?
|
|
136
|
+
|
|
137
|
+
rules = css_variable_placeholders.map do |name, placeholder|
|
|
138
|
+
%([fill="#{placeholder}"]{fill:var(#{name})}[stroke="#{placeholder}"]{stroke:var(#{name})})
|
|
139
|
+
end
|
|
140
|
+
svg.sub(/(?<opening_tag><svg\b[^>]*>)/) do
|
|
141
|
+
"#{Regexp.last_match[:opening_tag]}<style>#{rules.join}</style>"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
80
145
|
def make_dot_link issue_link:, link_rules:
|
|
81
146
|
result = +''
|
|
82
147
|
result << issue_link.origin.key.inspect
|
|
@@ -84,8 +149,9 @@ class DependencyChart < ChartBase
|
|
|
84
149
|
result << issue_link.other_issue.key.inspect
|
|
85
150
|
result << '['
|
|
86
151
|
result << 'label=' << (link_rules.label || issue_link.label).inspect
|
|
87
|
-
|
|
88
|
-
result << ',
|
|
152
|
+
line_color = graphviz_color(link_rules.line_color || default_link_color)
|
|
153
|
+
result << ',color=' << line_color.inspect
|
|
154
|
+
result << ',fontcolor=' << line_color.inspect
|
|
89
155
|
result << ',dir=both' if link_rules.bidirectional_arrows?
|
|
90
156
|
result << '];'
|
|
91
157
|
result
|
|
@@ -102,24 +168,47 @@ class DependencyChart < ChartBase
|
|
|
102
168
|
tooltip = "#{issue.key}: #{issue.summary}"
|
|
103
169
|
result << ",tooltip=#{tooltip[0..80].inspect}"
|
|
104
170
|
unless issue_rules.color == :none
|
|
105
|
-
|
|
171
|
+
fill_color = graphviz_color(issue_rules.color || color_for(type: issue.type))
|
|
172
|
+
result << %(,style=filled,fillcolor="#{fill_color}")
|
|
106
173
|
end
|
|
174
|
+
result << %(,fontcolor="#{graphviz_color label_color(issue: issue, issue_rules: issue_rules)}")
|
|
107
175
|
result << ']'
|
|
108
176
|
result
|
|
109
177
|
end
|
|
110
178
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
179
|
+
# A filled node is its own background, so its label is measured against the fill rather than
|
|
180
|
+
# against the page.
|
|
181
|
+
def label_color issue:, issue_rules:
|
|
182
|
+
return CssVariable['--default-text-color'] if issue_rules.color == :none
|
|
183
|
+
|
|
184
|
+
# A colour somebody configured is one we know nothing about, so guessing that a particular
|
|
185
|
+
# type's label would suit it is worse than falling back to the general one.
|
|
186
|
+
return CssVariable['--dependency-chart-label-color'] if issue_rules.color
|
|
187
|
+
|
|
188
|
+
palette_for(issue.type).label
|
|
189
|
+
end
|
|
190
|
+
|
|
114
191
|
def color_for type:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
192
|
+
palette_for(type).fill
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def palette_for type
|
|
196
|
+
@palette_by_type[type] ||= next_palette_entry
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def default_link_color
|
|
200
|
+
CssVariable['--dependency-chart-link-color']
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# An issue type we have no colour for cannot come from the shared palette, the way it does on
|
|
204
|
+
# every other chart. That palette holds line colours, judged against the page, and its first slot
|
|
205
|
+
# is Okabe-Ito blue, which leaves black label text at 4.05:1 once it becomes the fill behind it.
|
|
206
|
+
# These rotate this chart's own entries, which are all known to be comfortable. Two unknown types
|
|
207
|
+
# can therefore land on the same colour as each other or as a known type, which costs little:
|
|
208
|
+
# every node already names its type in the label.
|
|
209
|
+
def next_palette_entry
|
|
210
|
+
@fallback_color_index = (@fallback_color_index || -1) + 1
|
|
211
|
+
PALETTE.values[@fallback_color_index % PALETTE.size]
|
|
123
212
|
end
|
|
124
213
|
|
|
125
214
|
def build_dot_graph
|