jirametrics 2.4 → 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 +9 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +54 -7
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +7 -3
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +63 -27
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +68 -3
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +219 -41
- data/lib/jirametrics/dependency_chart.rb +37 -10
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +68 -50
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +7 -21
- data/lib/jirametrics/examples/standard_project.rb +18 -34
- data/lib/jirametrics/expedited_chart.rb +8 -9
- data/lib/jirametrics/exporter.rb +28 -11
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +1 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +6 -4
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +28 -5
- data/lib/jirametrics/html/index.erb +8 -4
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +33 -23
- data/lib/jirametrics/issue.rb +232 -47
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +245 -134
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +5 -2
- data/lib/jirametrics/sprint_burndown.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +77 -39
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +22 -6
- metadata +10 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class BoardMovementCalculator
|
|
4
|
+
attr_reader :board, :issues, :today
|
|
5
|
+
|
|
6
|
+
def initialize board:, issues:, today:
|
|
7
|
+
@board = board
|
|
8
|
+
@issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
|
|
9
|
+
@today = today
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def moves_backwards? issue
|
|
13
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
14
|
+
return false unless started
|
|
15
|
+
|
|
16
|
+
previous_column = nil
|
|
17
|
+
issue.status_changes.each do |change|
|
|
18
|
+
column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
|
|
19
|
+
next if change.time < started
|
|
20
|
+
next if column.nil? # It disappeared from the board for a bit
|
|
21
|
+
return true if previous_column && column && column < previous_column
|
|
22
|
+
|
|
23
|
+
previous_column = column
|
|
24
|
+
end
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stacked_age_data_for percentages:
|
|
29
|
+
data_list = percentages.sort.collect do |percentage|
|
|
30
|
+
[percentage, age_data_for(percentage: percentage)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stack_data data_list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stack_data data_list
|
|
37
|
+
remainder = nil
|
|
38
|
+
data_list.collect do |percentage, data|
|
|
39
|
+
unless remainder.nil?
|
|
40
|
+
data = (0...data.length).collect do |i|
|
|
41
|
+
data[i] - remainder[i]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
end
|
|
45
|
+
remainder = data
|
|
46
|
+
|
|
47
|
+
[percentage, data]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def age_data_for percentage:
|
|
52
|
+
data = []
|
|
53
|
+
board.visible_columns.each_with_index do |_column, column_index|
|
|
54
|
+
ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
|
|
55
|
+
|
|
56
|
+
if ages.empty?
|
|
57
|
+
data << 0
|
|
58
|
+
else
|
|
59
|
+
index = ((ages.size - 1) * percentage / 100).to_i
|
|
60
|
+
data << ages[index]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
data
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ages_of_issues_when_leaving_column column_index:, today:
|
|
67
|
+
this_column = board.visible_columns[column_index]
|
|
68
|
+
next_column = board.visible_columns[column_index + 1]
|
|
69
|
+
|
|
70
|
+
@issues.filter_map do |issue|
|
|
71
|
+
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
|
+
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
+
issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
|
|
74
|
+
|
|
75
|
+
# Skip if we can't tell when it started.
|
|
76
|
+
next if issue_start.nil?
|
|
77
|
+
|
|
78
|
+
# Skip if it never entered this column
|
|
79
|
+
next if this_column_start.nil?
|
|
80
|
+
|
|
81
|
+
# Skip if it left this column before the item is considered started.
|
|
82
|
+
next 0 if next_column_start && next_column_start <= issue_start
|
|
83
|
+
|
|
84
|
+
# Skip if it was already done by the time it got to this column or it became done when it got to this column
|
|
85
|
+
next if issue_done && issue_done <= this_column_start
|
|
86
|
+
|
|
87
|
+
end_date = case # rubocop:disable Style/EmptyCaseCondition
|
|
88
|
+
when next_column_start.nil?
|
|
89
|
+
# If this is the last column then base age against today
|
|
90
|
+
today
|
|
91
|
+
when issue_done && issue_done < next_column_start
|
|
92
|
+
# it completed while in this column
|
|
93
|
+
issue_done.to_date
|
|
94
|
+
else
|
|
95
|
+
# It passed through this whole column
|
|
96
|
+
next_column_start.to_date
|
|
97
|
+
end
|
|
98
|
+
(end_date - issue_start.to_date).to_i + 1
|
|
99
|
+
end.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
|
103
|
+
# aging and forecasting purposes
|
|
104
|
+
def find_current_column_and_entry_time_in_column issue
|
|
105
|
+
column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
106
|
+
return [] if column.nil? # This issue isn't visible on the board
|
|
107
|
+
|
|
108
|
+
status_ids = column.status_ids
|
|
109
|
+
|
|
110
|
+
entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
|
|
111
|
+
|
|
112
|
+
[column.name, entry_at]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def label_days days
|
|
116
|
+
"#{days} day#{'s' unless days == 1}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def forecasted_days_remaining_and_message issue:, today:
|
|
120
|
+
return [nil, 'Already done'] if issue.done?
|
|
121
|
+
|
|
122
|
+
likely_age_data = age_data_for percentage: 85
|
|
123
|
+
|
|
124
|
+
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
|
125
|
+
return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
|
|
126
|
+
|
|
127
|
+
age_in_column = (today - entry_time.to_date).to_i + 1
|
|
128
|
+
|
|
129
|
+
message = nil
|
|
130
|
+
column_index = board.visible_columns.index { |c| c.name == column_name }
|
|
131
|
+
|
|
132
|
+
last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
|
|
133
|
+
return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
|
|
134
|
+
|
|
135
|
+
remaining_in_current_column = likely_age_data[column_index] - age_in_column
|
|
136
|
+
if remaining_in_current_column.negative?
|
|
137
|
+
message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
|
|
138
|
+
"in the #{column_name.inspect} column. Most items on this board have left this column in " \
|
|
139
|
+
"#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
|
|
140
|
+
remaining_in_current_column = 0
|
|
141
|
+
return [nil, message]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
|
|
145
|
+
[forecasted_days, message]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :author
|
|
5
|
-
attr_accessor :value, :old_value
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
|
|
5
|
+
attr_accessor :value, :old_value
|
|
6
6
|
|
|
7
7
|
def initialize raw:, time:, author:, artificial: false
|
|
8
8
|
@raw = raw
|
|
9
9
|
@time = time
|
|
10
|
-
raise
|
|
10
|
+
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
|
11
|
+
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
|
11
12
|
|
|
12
|
-
@field =
|
|
13
|
-
@value =
|
|
13
|
+
@field = @raw['field']
|
|
14
|
+
@value = @raw['toString']
|
|
14
15
|
@value_id = @raw['to'].to_i
|
|
15
16
|
@old_value = @raw['fromString']
|
|
16
17
|
@old_value_id = @raw['from']&.to_i
|
|
@@ -34,9 +35,21 @@ class ChangeItem
|
|
|
34
35
|
|
|
35
36
|
def link? = (field == 'Link')
|
|
36
37
|
|
|
38
|
+
def labels? = (field == 'labels')
|
|
39
|
+
|
|
40
|
+
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
|
+
def to_time = @time
|
|
42
|
+
|
|
37
43
|
def to_s
|
|
38
44
|
message = +''
|
|
39
|
-
message << "ChangeItem(field: #{field.inspect}
|
|
45
|
+
message << "ChangeItem(field: #{field.inspect}"
|
|
46
|
+
message << ", value: #{value.inspect}"
|
|
47
|
+
message << ':' << value_id.inspect if status?
|
|
48
|
+
if old_value
|
|
49
|
+
message << ", old_value: #{old_value.inspect}"
|
|
50
|
+
message << ':' << old_value_id.inspect if status?
|
|
51
|
+
end
|
|
52
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
|
40
53
|
message << ', artificial' if artificial?
|
|
41
54
|
message << ')'
|
|
42
55
|
message
|
|
@@ -25,6 +25,11 @@ class ChartBase
|
|
|
25
25
|
@aggregated_project
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def html_directory
|
|
29
|
+
pathname = Pathname.new(File.realpath(__FILE__))
|
|
30
|
+
"#{pathname.dirname}/html"
|
|
31
|
+
end
|
|
32
|
+
|
|
28
33
|
def render caller_binding, file
|
|
29
34
|
pathname = Pathname.new(File.realpath(file))
|
|
30
35
|
basename = pathname.basename.to_s
|
|
@@ -33,8 +38,8 @@ class ChartBase
|
|
|
33
38
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
34
39
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
35
40
|
|
|
36
|
-
@html_directory = "#{pathname.dirname}/html"
|
|
37
|
-
erb = ERB.new file_system.load "#{
|
|
41
|
+
# @html_directory = "#{pathname.dirname}/html"
|
|
42
|
+
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
38
43
|
erb.result(caller_binding)
|
|
39
44
|
end
|
|
40
45
|
|
|
@@ -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_system.load
|
|
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
|
|
@@ -162,40 +181,54 @@ class ChartBase
|
|
|
162
181
|
end
|
|
163
182
|
end
|
|
164
183
|
|
|
165
|
-
def header_text text =
|
|
166
|
-
@header_text = text
|
|
184
|
+
def header_text text = :none
|
|
185
|
+
@header_text = text unless text == :none
|
|
167
186
|
@header_text
|
|
168
187
|
end
|
|
169
188
|
|
|
170
|
-
def description_text text =
|
|
171
|
-
@description_text = text
|
|
189
|
+
def description_text text = :none
|
|
190
|
+
@description_text = text unless text == :none
|
|
172
191
|
@description_text
|
|
173
192
|
end
|
|
174
193
|
|
|
194
|
+
# Convert a number like 1234567 into the string "1,234,567"
|
|
175
195
|
def format_integer number
|
|
176
196
|
number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
|
|
177
197
|
end
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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}"
|
|
184
217
|
end
|
|
185
218
|
|
|
186
|
-
|
|
219
|
+
return "<span style='color: red'>#{error_message}</span>" if error_message
|
|
220
|
+
|
|
187
221
|
color = status_category_color status
|
|
188
222
|
|
|
189
223
|
visibility = ''
|
|
190
224
|
if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
|
|
191
225
|
visibility = icon_span(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
226
|
+
title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
|
|
227
|
+
icon: ' 👀'
|
|
228
|
+
)
|
|
196
229
|
end
|
|
197
|
-
text = is_category ? status.
|
|
198
|
-
"<span title='Category: #{status.
|
|
230
|
+
text = is_category ? status.category.name : status.name
|
|
231
|
+
"<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
199
232
|
end
|
|
200
233
|
|
|
201
234
|
def icon_span title:, icon:
|
|
@@ -203,11 +236,11 @@ class ChartBase
|
|
|
203
236
|
end
|
|
204
237
|
|
|
205
238
|
def status_category_color status
|
|
206
|
-
case status.
|
|
207
|
-
when '
|
|
208
|
-
when '
|
|
209
|
-
when '
|
|
210
|
-
else '
|
|
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
|
|
|
@@ -227,7 +260,10 @@ class ChartBase
|
|
|
227
260
|
|
|
228
261
|
def color_block color, title: nil
|
|
229
262
|
result = +''
|
|
230
|
-
result << "<div class='color_block' style='
|
|
263
|
+
result << "<div class='color_block' style='"
|
|
264
|
+
result << "background: #{CssVariable[color]};" if color
|
|
265
|
+
result << 'visibility: hidden;' unless color
|
|
266
|
+
result << "'"
|
|
231
267
|
result << " title=#{title.inspect}" if title
|
|
232
268
|
result << '></div>'
|
|
233
269
|
result
|
|
@@ -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
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
|
{
|
|
@@ -24,7 +24,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
24
24
|
predict that most work of this type will complete in <%= overall_percent_line %> days or
|
|
25
25
|
less. The other lines reflect the 85% line for that respective type of work.
|
|
26
26
|
</div>
|
|
27
|
-
#{
|
|
27
|
+
#{describe_non_working_days}
|
|
28
28
|
HTML
|
|
29
29
|
|
|
30
30
|
init_configuration_block block do
|
|
@@ -53,10 +53,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
53
53
|
def create_datasets completed_issues
|
|
54
54
|
data_sets = []
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
groups.each_key do |rules|
|
|
59
|
-
completed_issues_by_type = groups[rules]
|
|
56
|
+
group_issues(completed_issues).each do |rules, completed_issues_by_type|
|
|
60
57
|
label = rules.label
|
|
61
58
|
color = rules.color
|
|
62
59
|
percent_line = calculate_percent_line completed_issues_by_type
|
|
@@ -117,7 +114,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
117
114
|
|
|
118
115
|
{
|
|
119
116
|
y: cycle_time,
|
|
120
|
-
x: chart_format(issue.board.cycletime.
|
|
117
|
+
x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
|
|
121
118
|
title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
|
|
122
119
|
}
|
|
123
120
|
end
|
|
@@ -4,7 +4,7 @@ require 'jirametrics/daily_wip_chart'
|
|
|
4
4
|
|
|
5
5
|
class DailyWipByAgeChart < DailyWipChart
|
|
6
6
|
def initialize block
|
|
7
|
-
super
|
|
7
|
+
super
|
|
8
8
|
|
|
9
9
|
add_trend_line line_color: '--aging-work-in-progress-by-age-trend-line-color', group_labels: [
|
|
10
10
|
'Less than a day',
|
|
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
|
|
53
|
-
started = cycletime.started_time(issue)&.to_date
|
|
54
|
-
stopped = cycletime.stopped_time(issue)&.to_date
|
|
52
|
+
started, stopped = issue.board.cycletime.started_stopped_dates(issue)
|
|
55
53
|
|
|
56
54
|
rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
|
|
57
55
|
|
|
@@ -39,8 +39,8 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
|
-
started = issue.board.cycletime.
|
|
43
|
-
stopped_date =
|
|
42
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
43
|
+
stopped_date = stopped&.to_date
|
|
44
44
|
|
|
45
45
|
date = rules.current_date
|
|
46
46
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|