jirametrics 2.0 → 2.11
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/aggregate_config.rb +19 -26
- data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
- data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
- data/lib/jirametrics/aging_work_table.rb +78 -43
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +8 -4
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +31 -10
- data/lib/jirametrics/chart_base.rb +102 -61
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/css_variable.rb +33 -0
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +69 -4
- data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
- data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
- data/lib/jirametrics/daily_wip_chart.rb +61 -14
- data/lib/jirametrics/data_quality_report.rb +222 -41
- data/lib/jirametrics/dependency_chart.rb +54 -23
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +76 -57
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +25 -49
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +34 -13
- data/lib/jirametrics/file_system.rb +48 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -6
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
- data/lib/jirametrics/html/aging_work_table.erb +19 -25
- data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
- data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
- data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
- data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
- data/lib/jirametrics/html/expedited_chart.erb +10 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +2 -2
- data/lib/jirametrics/html/index.css +209 -0
- data/lib/jirametrics/html/index.erb +16 -39
- data/lib/jirametrics/html/sprint_burndown.erb +10 -14
- data/lib/jirametrics/html/throughput_chart.erb +10 -13
- data/lib/jirametrics/html_report_config.rb +108 -86
- data/lib/jirametrics/issue.rb +357 -96
- data/lib/jirametrics/jira_gateway.rb +29 -11
- data/lib/jirametrics/project_config.rb +256 -144
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +24 -7
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +80 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +25 -7
- metadata +16 -17
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -2,25 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
|
-
:time_range, :data_quality, :holiday_dates, :settings
|
|
5
|
+
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system
|
|
6
6
|
attr_writer :aggregated_project
|
|
7
|
-
attr_reader :
|
|
7
|
+
attr_reader :canvas_width, :canvas_height
|
|
8
8
|
|
|
9
9
|
@@chart_counter = 0
|
|
10
10
|
|
|
11
11
|
def initialize
|
|
12
12
|
@chart_colors = {
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'light:Story' => '#90EE90',
|
|
19
|
-
'light:Task' => '#87CEFA',
|
|
20
|
-
'light:Bug' => '#ffdab9',
|
|
21
|
-
'light:Defect' => 'orange',
|
|
22
|
-
'light:Epic' => '#fafad2',
|
|
23
|
-
'light:Spike' => '#DDA0DD' # light purple
|
|
13
|
+
'Story' => CssVariable['--type-story-color'],
|
|
14
|
+
'Task' => CssVariable['--type-task-color'],
|
|
15
|
+
'Bug' => CssVariable['--type-bug-color'],
|
|
16
|
+
'Defect' => CssVariable['--type-bug-color'],
|
|
17
|
+
'Spike' => CssVariable['--type-spike-color']
|
|
24
18
|
}
|
|
25
19
|
@canvas_width = 800
|
|
26
20
|
@canvas_height = 200
|
|
@@ -31,6 +25,11 @@ class ChartBase
|
|
|
31
25
|
@aggregated_project
|
|
32
26
|
end
|
|
33
27
|
|
|
28
|
+
def html_directory
|
|
29
|
+
pathname = Pathname.new(File.realpath(__FILE__))
|
|
30
|
+
"#{pathname.dirname}/html"
|
|
31
|
+
end
|
|
32
|
+
|
|
34
33
|
def render caller_binding, file
|
|
35
34
|
pathname = Pathname.new(File.realpath(file))
|
|
36
35
|
basename = pathname.basename.to_s
|
|
@@ -39,16 +38,22 @@ class ChartBase
|
|
|
39
38
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
40
39
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
41
40
|
|
|
42
|
-
@html_directory = "#{pathname.dirname}/html"
|
|
43
|
-
erb = ERB.new
|
|
41
|
+
# @html_directory = "#{pathname.dirname}/html"
|
|
42
|
+
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
44
43
|
erb.result(caller_binding)
|
|
45
44
|
end
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
def wrap_and_render caller_binding, file
|
|
46
|
+
def render_top_text caller_binding
|
|
49
47
|
result = +''
|
|
50
48
|
result << "<h1>#{@header_text}</h1>" if @header_text
|
|
51
49
|
result << ERB.new(@description_text).result(caller_binding) if @description_text
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Render the file and then wrap it with standard headers and quality checks.
|
|
54
|
+
def wrap_and_render caller_binding, file
|
|
55
|
+
result = +''
|
|
56
|
+
result << render_top_text(caller_binding)
|
|
52
57
|
result << render(caller_binding, file)
|
|
53
58
|
result
|
|
54
59
|
end
|
|
@@ -57,8 +62,8 @@ class ChartBase
|
|
|
57
62
|
@@chart_counter += 1
|
|
58
63
|
end
|
|
59
64
|
|
|
60
|
-
def color_for type
|
|
61
|
-
@chart_colors[
|
|
65
|
+
def color_for type:
|
|
66
|
+
@chart_colors[type] ||= random_color
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
def label_days days
|
|
@@ -100,7 +105,7 @@ class ChartBase
|
|
|
100
105
|
issues_id = next_id
|
|
101
106
|
|
|
102
107
|
issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
|
|
103
|
-
erb = ERB.new File.
|
|
108
|
+
erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
|
|
104
109
|
erb.result(binding)
|
|
105
110
|
end
|
|
106
111
|
|
|
@@ -125,6 +130,21 @@ class ChartBase
|
|
|
125
130
|
result
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
def working_days_annotation
|
|
134
|
+
holidays.each_with_index.collect do |range, index|
|
|
135
|
+
<<~TEXT
|
|
136
|
+
holiday#{index}: {
|
|
137
|
+
drawTime: 'beforeDraw',
|
|
138
|
+
type: 'box',
|
|
139
|
+
xMin: '#{range.begin}T00:00:00',
|
|
140
|
+
xMax: '#{range.end}T23:59:59',
|
|
141
|
+
backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
|
|
142
|
+
borderColor: #{CssVariable.new('--non-working-days-color').to_json}
|
|
143
|
+
},
|
|
144
|
+
TEXT
|
|
145
|
+
end.join
|
|
146
|
+
end
|
|
147
|
+
|
|
128
148
|
# Return only the board columns for the current board.
|
|
129
149
|
def current_board
|
|
130
150
|
if @board_id.nil?
|
|
@@ -144,8 +164,7 @@ class ChartBase
|
|
|
144
164
|
def completed_issues_in_range include_unstarted: false
|
|
145
165
|
issues.select do |issue|
|
|
146
166
|
cycletime = issue.board.cycletime
|
|
147
|
-
stopped_time = cycletime.
|
|
148
|
-
started_time = cycletime.started_time(issue)
|
|
167
|
+
started_time, stopped_time = cycletime.started_stopped_times(issue)
|
|
149
168
|
|
|
150
169
|
stopped_time &&
|
|
151
170
|
date_range.include?(stopped_time.to_date) && # Remove outside range
|
|
@@ -153,17 +172,6 @@ class ChartBase
|
|
|
153
172
|
end
|
|
154
173
|
end
|
|
155
174
|
|
|
156
|
-
def sprints_in_time_range board
|
|
157
|
-
board.sprints.select do |sprint|
|
|
158
|
-
sprint_end_time = sprint.completed_time || sprint.end_time
|
|
159
|
-
sprint_start_time = sprint.start_time
|
|
160
|
-
next false if sprint_start_time.nil?
|
|
161
|
-
|
|
162
|
-
time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
|
|
163
|
-
(sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
|
|
164
|
-
end || []
|
|
165
|
-
end
|
|
166
|
-
|
|
167
175
|
def chart_format object
|
|
168
176
|
if object.is_a? Time
|
|
169
177
|
# "2022-04-09T11:38:30-07:00"
|
|
@@ -173,41 +181,66 @@ class ChartBase
|
|
|
173
181
|
end
|
|
174
182
|
end
|
|
175
183
|
|
|
176
|
-
def header_text text
|
|
177
|
-
@header_text = text
|
|
184
|
+
def header_text text = :none
|
|
185
|
+
@header_text = text unless text == :none
|
|
186
|
+
@header_text
|
|
178
187
|
end
|
|
179
188
|
|
|
180
|
-
def description_text text
|
|
181
|
-
@description_text = text
|
|
189
|
+
def description_text text = :none
|
|
190
|
+
@description_text = text unless text == :none
|
|
191
|
+
@description_text
|
|
182
192
|
end
|
|
183
193
|
|
|
194
|
+
# Convert a number like 1234567 into the string "1,234,567"
|
|
184
195
|
def format_integer number
|
|
185
196
|
number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
|
|
186
197
|
end
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
# object will be either a Status or a ChangeItem
|
|
200
|
+
# if it's a ChangeItem then use_old_status will specify whether we're using the new or old
|
|
201
|
+
# Either way, is_category will format the category rather than the status
|
|
202
|
+
def format_status object, board:, is_category: false, use_old_status: false
|
|
203
|
+
status = nil
|
|
204
|
+
error_message = nil
|
|
205
|
+
|
|
206
|
+
case object
|
|
207
|
+
when ChangeItem
|
|
208
|
+
id = use_old_status ? object.old_value_id : object.value_id
|
|
209
|
+
status = board.possible_statuses.find_by_id(id)
|
|
210
|
+
if status.nil?
|
|
211
|
+
error_message = use_old_status ? object.old_value : object.value
|
|
212
|
+
end
|
|
213
|
+
when Status
|
|
214
|
+
status = object
|
|
215
|
+
else
|
|
216
|
+
raise "Unexpected type: #{object.class}"
|
|
195
217
|
end
|
|
196
|
-
raise "Expected exactly one match and got #{statuses.inspect} for #{name_or_id.inspect}" if statuses.size > 1
|
|
197
218
|
|
|
198
|
-
|
|
219
|
+
return "<span style='color: red'>#{error_message}</span>" if error_message
|
|
220
|
+
|
|
199
221
|
color = status_category_color status
|
|
200
222
|
|
|
201
|
-
|
|
202
|
-
|
|
223
|
+
visibility = ''
|
|
224
|
+
if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
|
|
225
|
+
visibility = icon_span(
|
|
226
|
+
title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
|
|
227
|
+
icon: ' 👀'
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
text = is_category ? status.category.name : status.name
|
|
231
|
+
"<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def icon_span title:, icon:
|
|
235
|
+
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
203
236
|
end
|
|
204
237
|
|
|
205
238
|
def status_category_color status
|
|
206
|
-
case status.
|
|
207
|
-
when
|
|
208
|
-
when '
|
|
209
|
-
when '
|
|
210
|
-
|
|
239
|
+
case status.category.key
|
|
240
|
+
when 'new' then CssVariable['--status-category-todo-color']
|
|
241
|
+
when 'indeterminate' then CssVariable['--status-category-inprogress-color']
|
|
242
|
+
when 'done' then CssVariable['--status-category-done-color']
|
|
243
|
+
else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
|
|
211
244
|
end
|
|
212
245
|
end
|
|
213
246
|
|
|
@@ -225,15 +258,23 @@ class ChartBase
|
|
|
225
258
|
@canvas_responsive
|
|
226
259
|
end
|
|
227
260
|
|
|
228
|
-
def
|
|
229
|
-
|
|
261
|
+
def color_block color, title: nil
|
|
262
|
+
result = +''
|
|
263
|
+
result << "<div class='color_block' style='"
|
|
264
|
+
result << "background: #{CssVariable[color]};" if color
|
|
265
|
+
result << 'visibility: hidden;' unless color
|
|
266
|
+
result << "'"
|
|
267
|
+
result << " title=#{title.inspect}" if title
|
|
268
|
+
result << '></div>'
|
|
269
|
+
result
|
|
230
270
|
end
|
|
231
271
|
|
|
232
|
-
def
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
272
|
+
def describe_non_working_days
|
|
273
|
+
<<-TEXT
|
|
274
|
+
<div class='p'>
|
|
275
|
+
The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
|
|
276
|
+
and any other holidays mentioned in the configuration.
|
|
277
|
+
</div>
|
|
278
|
+
TEXT
|
|
238
279
|
end
|
|
239
280
|
end
|
|
@@ -34,6 +34,10 @@ class ColumnsConfig
|
|
|
34
34
|
@columns << [:string, label, proc]
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def integer label, proc
|
|
38
|
+
@columns << [:integer, label, proc]
|
|
39
|
+
end
|
|
40
|
+
|
|
37
41
|
def column_entry_times board_id: nil
|
|
38
42
|
@file_config.project_config.find_board_by_id(board_id).visible_columns.each do |column|
|
|
39
43
|
date column.name, first_time_in_status(*column.status_ids)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CssVariable
|
|
4
|
+
attr_reader :name
|
|
5
|
+
|
|
6
|
+
def self.[](name)
|
|
7
|
+
if name.is_a?(String) && name.start_with?('--')
|
|
8
|
+
CssVariable.new name
|
|
9
|
+
else
|
|
10
|
+
name
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize name
|
|
15
|
+
@name = name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_json(*_args)
|
|
19
|
+
"getComputedStyle(document.body).getPropertyValue('#{@name}')"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s
|
|
23
|
+
"var(#{@name})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"CssVariable['#{@name}']"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def == other
|
|
31
|
+
self.class == other.class && @name == other.name
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -8,10 +8,14 @@ class CycleTimeConfig
|
|
|
8
8
|
|
|
9
9
|
attr_reader :label, :parent_config
|
|
10
10
|
|
|
11
|
-
def initialize parent_config:, label:, block:, today: Date.today
|
|
11
|
+
def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
|
|
12
12
|
@parent_config = parent_config
|
|
13
13
|
@label = label
|
|
14
14
|
@today = today
|
|
15
|
+
|
|
16
|
+
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
17
|
+
# may make it easier to find problems in the test code ;-)
|
|
18
|
+
@file_system = file_system
|
|
15
19
|
instance_eval(&block) unless block.nil?
|
|
16
20
|
end
|
|
17
21
|
|
|
@@ -26,31 +30,78 @@ class CycleTimeConfig
|
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def in_progress? issue
|
|
29
|
-
started_time
|
|
33
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
34
|
+
started_time && stopped_time.nil?
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def done? issue
|
|
33
|
-
|
|
38
|
+
started_stopped_times(issue).last
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def started_time issue
|
|
37
|
-
@
|
|
42
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
43
|
+
started_stopped_times(issue).first
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def stopped_time issue
|
|
41
|
-
@
|
|
47
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
48
|
+
started_stopped_times(issue).last
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fabricate_change_item time
|
|
52
|
+
@file_system.deprecated(
|
|
53
|
+
date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
|
|
54
|
+
)
|
|
55
|
+
raw = {
|
|
56
|
+
'field' => 'Fabricated change',
|
|
57
|
+
'to' => '0',
|
|
58
|
+
'toString' => '',
|
|
59
|
+
'from' => '0',
|
|
60
|
+
'fromString' => ''
|
|
61
|
+
}
|
|
62
|
+
ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def started_stopped_changes issue
|
|
66
|
+
started = @start_at.call(issue)
|
|
67
|
+
stopped = @stop_at.call(issue)
|
|
68
|
+
|
|
69
|
+
# Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
|
|
70
|
+
# If they are false then explicitly make them nil.
|
|
71
|
+
started ||= nil
|
|
72
|
+
stopped ||= nil
|
|
73
|
+
|
|
74
|
+
# These are only here for backwards compatibility. Hopefully nobody will ever need them.
|
|
75
|
+
started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
|
|
76
|
+
stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
|
|
77
|
+
|
|
78
|
+
# In the case where started and stopped are exactly the same time, we pretend that
|
|
79
|
+
# it just stopped and never started. This allows us to have logic like 'in or right of'
|
|
80
|
+
# for the start and not have it conflict.
|
|
81
|
+
started = nil if started&.time == stopped&.time
|
|
82
|
+
|
|
83
|
+
[started, stopped]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def started_stopped_times issue
|
|
87
|
+
started, stopped = started_stopped_changes(issue)
|
|
88
|
+
[started&.time, stopped&.time]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def started_stopped_dates issue
|
|
92
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
93
|
+
[started_time&.to_date, stopped_time&.to_date]
|
|
42
94
|
end
|
|
43
95
|
|
|
44
96
|
def cycletime issue
|
|
45
|
-
start =
|
|
46
|
-
stop = stopped_time(issue)
|
|
97
|
+
start, stop = started_stopped_times(issue)
|
|
47
98
|
return nil if start.nil? || stop.nil?
|
|
48
99
|
|
|
49
100
|
(stop.to_date - start.to_date).to_i + 1
|
|
50
101
|
end
|
|
51
102
|
|
|
52
103
|
def age issue, today: nil
|
|
53
|
-
start =
|
|
104
|
+
start = started_stopped_times(issue).first
|
|
54
105
|
stop = today || @today || Date.today
|
|
55
106
|
return nil if start.nil? || stop.nil?
|
|
56
107
|
|
|
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
|
|
|
5
5
|
class CycletimeHistogram < ChartBase
|
|
6
6
|
include GroupableIssueChart
|
|
7
7
|
attr_accessor :possible_statuses
|
|
8
|
+
attr_reader :show_stats
|
|
8
9
|
|
|
9
|
-
def initialize block
|
|
10
|
+
def initialize block
|
|
10
11
|
super()
|
|
11
12
|
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
|
|
12
16
|
header_text 'Cycletime Histogram'
|
|
13
17
|
description_text <<-HTML
|
|
14
18
|
<p>
|
|
@@ -26,21 +30,40 @@ class CycletimeHistogram < ChartBase
|
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def percentiles percs = nil
|
|
34
|
+
@percentiles = percs unless percs.nil?
|
|
35
|
+
@percentiles
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def disable_stats
|
|
39
|
+
@show_stats = false
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def run
|
|
30
43
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
31
44
|
|
|
32
45
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
33
|
-
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.
|
|
46
|
+
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
|
34
47
|
rules_to_issues = group_issues histogram_issues
|
|
35
48
|
|
|
49
|
+
the_stats = {}
|
|
50
|
+
|
|
51
|
+
overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
|
|
52
|
+
the_stats[:all] = overall_stats
|
|
36
53
|
data_sets = rules_to_issues.keys.collect do |rules|
|
|
54
|
+
the_issue_type = rules.label
|
|
55
|
+
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
|
56
|
+
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
|
57
|
+
|
|
37
58
|
data_set_for(
|
|
38
|
-
histogram_data:
|
|
39
|
-
label:
|
|
59
|
+
histogram_data: the_histogram,
|
|
60
|
+
label: the_issue_type,
|
|
40
61
|
color: rules.color
|
|
41
62
|
)
|
|
42
63
|
end
|
|
43
64
|
|
|
65
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
66
|
+
|
|
44
67
|
wrap_and_render(binding, __FILE__)
|
|
45
68
|
end
|
|
46
69
|
|
|
@@ -53,6 +76,48 @@ class CycletimeHistogram < ChartBase
|
|
|
53
76
|
count_hash
|
|
54
77
|
end
|
|
55
78
|
|
|
79
|
+
def stats_for histogram_data:, percentiles:
|
|
80
|
+
return {} if histogram_data.empty?
|
|
81
|
+
|
|
82
|
+
total_values = histogram_data.values.sum
|
|
83
|
+
|
|
84
|
+
# Calculate the average
|
|
85
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
86
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
87
|
+
|
|
88
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
89
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
90
|
+
max_freq = sorted_histogram[-1][1]
|
|
91
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
92
|
+
|
|
93
|
+
minmax = histogram_data.keys.minmax
|
|
94
|
+
|
|
95
|
+
# Calculate percentiles
|
|
96
|
+
sorted_values = histogram_data.keys.sort
|
|
97
|
+
cumulative_counts = {}
|
|
98
|
+
cumulative_sum = 0
|
|
99
|
+
|
|
100
|
+
sorted_values.each do |value|
|
|
101
|
+
cumulative_sum += histogram_data[value]
|
|
102
|
+
cumulative_counts[value] = cumulative_sum
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
percentile_results = {}
|
|
106
|
+
percentiles.each do |percentile|
|
|
107
|
+
rank = (percentile / 100.0) * total_values
|
|
108
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
109
|
+
percentile_results[percentile] = percentile_value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
average: average,
|
|
114
|
+
mode: mode.collect(&:first).sort,
|
|
115
|
+
min: minmax[0],
|
|
116
|
+
max: minmax[1],
|
|
117
|
+
percentiles: percentile_results
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
56
121
|
def data_set_for histogram_data:, label:, color:
|
|
57
122
|
keys = histogram_data.keys.sort
|
|
58
123
|
{
|
|
@@ -7,25 +7,24 @@ class CycletimeScatterplot < ChartBase
|
|
|
7
7
|
|
|
8
8
|
attr_accessor :possible_statuses
|
|
9
9
|
|
|
10
|
-
def initialize block
|
|
10
|
+
def initialize block
|
|
11
11
|
super()
|
|
12
12
|
|
|
13
13
|
header_text 'Cycletime Scatterplot'
|
|
14
14
|
description_text <<-HTML
|
|
15
|
-
<p>
|
|
15
|
+
<div class="p">
|
|
16
16
|
This chart shows only completed work and indicates both what day it completed as well as
|
|
17
17
|
how many days it took to get done. Hovering over a dot will show you the ID of the work item.
|
|
18
|
-
</
|
|
19
|
-
<p>
|
|
20
|
-
The
|
|
18
|
+
</div>
|
|
19
|
+
<div class="p">
|
|
20
|
+
The #{color_block '--cycletime-scatterplot-overall-trendline-color'} line indicates the 85th
|
|
21
|
+
percentile (<%= overall_percent_line %> days). 85% of all
|
|
21
22
|
items on this chart fall on or below the line and the remaining 15% are above the line. 85%
|
|
22
23
|
is a reasonable proxy for "most" so that we can say that based on this data set, we can
|
|
23
24
|
predict that most work of this type will complete in <%= overall_percent_line %> days or
|
|
24
25
|
less. The other lines reflect the 85% line for that respective type of work.
|
|
25
|
-
</
|
|
26
|
-
|
|
27
|
-
The gray vertical bars indicate weekends, when theoretically we aren't working.
|
|
28
|
-
</p>
|
|
26
|
+
</div>
|
|
27
|
+
#{describe_non_working_days}
|
|
29
28
|
HTML
|
|
30
29
|
|
|
31
30
|
init_configuration_block block do
|
|
@@ -44,7 +43,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
44
43
|
|
|
45
44
|
data_sets = create_datasets completed_issues
|
|
46
45
|
overall_percent_line = calculate_percent_line(completed_issues)
|
|
47
|
-
@percentage_lines << [overall_percent_line, '
|
|
46
|
+
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
48
47
|
|
|
49
48
|
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
50
49
|
|
|
@@ -54,10 +53,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
54
53
|
def create_datasets completed_issues
|
|
55
54
|
data_sets = []
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
groups.each_key do |rules|
|
|
60
|
-
completed_issues_by_type = groups[rules]
|
|
56
|
+
group_issues(completed_issues).each do |rules, completed_issues_by_type|
|
|
61
57
|
label = rules.label
|
|
62
58
|
color = rules.color
|
|
63
59
|
percent_line = calculate_percent_line completed_issues_by_type
|
|
@@ -118,7 +114,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
118
114
|
|
|
119
115
|
{
|
|
120
116
|
y: cycle_time,
|
|
121
|
-
x: chart_format(issue.board.cycletime.
|
|
117
|
+
x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
|
|
122
118
|
title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
|
|
123
119
|
}
|
|
124
120
|
end
|