jirametrics 2.10 → 2.12pre9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6d6dd1e6c2811b1395ef0ce25d1458db4c3b88c2e4ab774e6c2db045549e51e
4
- data.tar.gz: 53d614e48e61a7fad285d72de82f6f59a0c9024c33f39b36478c7c01866150c2
3
+ metadata.gz: b2e99113dff452fc39af119d35fe1b480ef991dba7ef3ef8c3335bf471fed9d8
4
+ data.tar.gz: e05ad4ed94cb690856293244e23f28d1007fdb1b608820bcfee40aa082232722
5
5
  SHA512:
6
- metadata.gz: '079f3d1f72a7d4cfd3d3da08d508cacfe4f04d6926e3674d5c6654cb290b8f1a1d7bbdb585bc587b743a888b85a19714294cdd15a9f891e2a7b52f5303b70d6f'
7
- data.tar.gz: 9a6bdfbe92dc04d8264efad3d6be1569383f604968fa3c83fbda33fb20b25210b545cb86102a1e51926348f58aad97328c119be772e15f72090932fc40fff5d4
6
+ metadata.gz: 0ece3dea23ac0f9141342ae2be782dfcbf034b39db6fb6ec7cbe28d8afe85865e41906083e9fbf587ed2de4aba9f6efa586a26f6899faa00a890505f5602e125
7
+ data.tar.gz: 7fac5a0b4fdbdda9808febf6b6f3043dcdf0965a3613145458cc3fb8c88cb3c0872e370fac12c0b0c50642172caebcb9c1b932252eb78de313c1fbbe14656de8
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'jirametrics/chart_base'
4
4
  require 'jirametrics/groupable_issue_chart'
5
+ require 'jirametrics/board_movement_calculator'
5
6
 
6
7
  class AgingWorkInProgressChart < ChartBase
7
8
  include GroupableIssueChart
@@ -16,13 +17,33 @@ class AgingWorkInProgressChart < ChartBase
16
17
  This chart shows only work items that have started but not completed, grouped by the column
17
18
  they're currently in. Hovering over a dot will show you the ID of that work item.
18
19
  </p>
19
- <div>
20
- The #{color_block '--non-working-days-color'} shaded area indicates the 85%
21
- mark for work items that have passed through here; 85% of
22
- previous work items left this column while still inside the shaded area. Any work items above
23
- the shading are outliers and they are the items that you should pay special attention to.
20
+ <p>
21
+ The shaded areas indicate what percentage of the work has passed that column within that time.
22
+ Notes:
23
+ <ul>
24
+ <li>It only shows columns that are considered "in progress". If you see a column that wouldn't normally
25
+ be thought of that way, then likely issues were moving backwards or continued to progress after hitting
26
+ that column.</li>
27
+ <li>If you see a colour group that drops as it moves to the right, that generally indicates that
28
+ a different number of data points is being included in each column. Probably because tickets moved
29
+ backwards athough it could also indicate that a ticket jumped over columns as it moved to the right.
30
+ </li>
31
+ </ul>
32
+ </p>
33
+ <div style="border: 1px solid gray; padding: 0.2em">
34
+ <% @percentiles.keys.sort.reverse.each do |percent| %>
35
+ <span style="padding-left: 0.5em; padding-right: 0.5em; vertical-align: middle;"><%= color_block @percentiles[percent] %> <%= percent %>%</span>
36
+ <% end %>
24
37
  </div>
25
38
  HTML
39
+ percentiles(
40
+ 50 => '--aging-work-in-progress-chart-shading-50-color',
41
+ 85 => '--aging-work-in-progress-chart-shading-85-color',
42
+ 98 => '--aging-work-in-progress-chart-shading-98-color',
43
+ 100 => '--aging-work-in-progress-chart-shading-100-color'
44
+ )
45
+ show_all_columns false
46
+
26
47
  init_configuration_block(block) do
27
48
  grouping_rules do |issue, rule|
28
49
  rule.label = issue.type
@@ -36,13 +57,17 @@ class AgingWorkInProgressChart < ChartBase
36
57
 
37
58
  @header_text += " on board: #{@all_boards[@board_id].name}"
38
59
  data_sets = make_data_sets
39
- column_headings = @board_columns.collect(&:name)
40
60
 
41
- adjust_visibility_of_unmapped_status_column data_sets: data_sets, column_headings: column_headings
61
+ adjust_visibility_of_unmapped_status_column data_sets: data_sets
62
+ adjust_chart_height
42
63
 
43
64
  wrap_and_render(binding, __FILE__)
44
65
  end
45
66
 
67
+ def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
68
+ @show_all_columns = show
69
+ end
70
+
46
71
  def determine_board_columns
47
72
  unmapped_statuses = current_board.possible_statuses.collect(&:id)
48
73
 
@@ -51,7 +76,7 @@ class AgingWorkInProgressChart < ChartBase
51
76
 
52
77
  @fake_column = BoardColumn.new({
53
78
  'name' => '[Unmapped Statuses]',
54
- 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
79
+ 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq # rubocop:disable Performance/ChainArrayAllocation
55
80
  })
56
81
  @board_columns = columns + [@fake_column]
57
82
  end
@@ -62,7 +87,7 @@ class AgingWorkInProgressChart < ChartBase
62
87
  board.id == @board_id && board.cycletime.in_progress?(issue)
63
88
  end
64
89
 
65
- percentage = 85
90
+ @max_age = 20
66
91
  rules_to_issues = group_issues aging_issues
67
92
  data_sets = rules_to_issues.keys.collect do |rules|
68
93
  {
@@ -73,7 +98,10 @@ class AgingWorkInProgressChart < ChartBase
73
98
  column = column_for issue: issue
74
99
  next if column.nil?
75
100
 
76
- { 'y' => age,
101
+ @max_age = age if age > @max_age
102
+
103
+ {
104
+ 'y' => age,
77
105
  'x' => column.name,
78
106
  'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
79
107
  }
@@ -83,45 +111,70 @@ class AgingWorkInProgressChart < ChartBase
83
111
  'backgroundColor' => rules.color
84
112
  }
85
113
  end
86
- data_sets << {
87
- 'type' => 'bar',
88
- 'label' => "#{percentage}%",
89
- 'barPercentage' => 1.0,
90
- 'categoryPercentage' => 1.0,
91
- 'backgroundColor' => CssVariable['--aging-work-in-progress-chart-shading-color'],
92
- 'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
93
- }
94
- end
95
114
 
96
- def days_at_percentage_threshold_for_all_columns percentage:, issues:
97
- accumulated_status_ids_per_column.collect do |_column, status_ids|
98
- ages = ages_of_issues_that_crossed_column_boundary issues: issues, status_ids: status_ids
99
- index = ages.size * percentage / 100
100
- ages.sort[index.to_i] || 0
115
+ calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
116
+
117
+ column_indexes_to_remove = []
118
+ unless @show_all_columns
119
+ column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
120
+
121
+ column_indexes_to_remove.reverse_each do |index|
122
+ @board_columns.delete_at index
123
+ end
124
+ end
125
+
126
+ @row_index_offset = data_sets.size
127
+
128
+ bar_data = []
129
+ calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
130
+ column_indexes_to_remove.reverse_each { |index| data.delete_at index }
131
+ color = @percentiles[percentage]
132
+
133
+ data_sets << {
134
+ 'type' => 'bar',
135
+ 'label' => "#{percentage}%",
136
+ 'barPercentage' => 1.0,
137
+ 'categoryPercentage' => 1.0,
138
+ 'backgroundColor' => color,
139
+ 'data' => data
140
+ }
141
+ bar_data << data
101
142
  end
143
+ @bar_data = adjust_bar_data bar_data
144
+
145
+ data_sets
102
146
  end
103
147
 
104
- def accumulated_status_ids_per_column
105
- accumulated_status_ids = []
106
- @board_columns.reverse.filter_map do |column|
107
- next if column == @fake_column
148
+ def adjust_bar_data input
149
+ return [] if input.empty?
150
+
151
+ row_size = input.first.size
108
152
 
109
- accumulated_status_ids += column.status_ids
110
- [column.name, accumulated_status_ids.dup]
111
- end.reverse
153
+ output = []
154
+ output << input.first
155
+ input.drop(1).each do |row|
156
+ previous_row = output.last
157
+ output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
158
+ end
159
+
160
+ output
112
161
  end
113
162
 
114
- def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
115
- issues.filter_map do |issue|
116
- stop = issue.first_time_in_status(*status_ids)&.to_time
117
- start, = issue.board.cycletime.started_stopped_times(issue)
163
+ def indexes_of_leading_and_trailing_zeros list
164
+ result = []
165
+ 0.upto(list.size - 1) do |index|
166
+ break unless list[index].zero?
118
167
 
119
- # Skip if either it hasn't crossed the boundary or we can't tell when it started.
120
- next if stop.nil? || start.nil?
121
- next if stop < start
168
+ result << index
169
+ end
122
170
 
123
- (stop.to_date - start.to_date).to_i + 1
171
+ stop_at = result.empty? ? 0 : (result.last + 1)
172
+ (list.size - 1).downto(stop_at).each do |index|
173
+ break unless list[index].zero?
174
+
175
+ result << index if list[index].zero?
124
176
  end
177
+ result
125
178
  end
126
179
 
127
180
  def column_for issue:
@@ -130,7 +183,7 @@ class AgingWorkInProgressChart < ChartBase
130
183
  end
131
184
  end
132
185
 
133
- def adjust_visibility_of_unmapped_status_column data_sets:, column_headings:
186
+ def adjust_visibility_of_unmapped_status_column data_sets:
134
187
  column_name = @fake_column.name
135
188
 
136
189
  has_unmapped = data_sets.any? do |set|
@@ -143,8 +196,19 @@ class AgingWorkInProgressChart < ChartBase
143
196
  @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
144
197
  'board but are still active. Most likely everyone has forgotten about them.</p>'
145
198
  else
146
- column_headings.pop
199
+ # @column_headings.pop
147
200
  @board_columns.pop
148
201
  end
149
202
  end
203
+
204
+ def percentiles percentile_color_hash
205
+ @percentiles = percentile_color_hash.transform_values { |value| CssVariable[value] }
206
+ end
207
+
208
+ def adjust_chart_height
209
+ min_height = @max_age * 5
210
+
211
+ @canvas_height = min_height if min_height > @canvas_height
212
+ @canvas_height = 400 if min_height > 400
213
+ end
150
214
  end
@@ -3,7 +3,7 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class AgingWorkTable < ChartBase
6
- attr_accessor :today, :board_id
6
+ attr_accessor :today
7
7
  attr_reader :any_scrum_boards
8
8
 
9
9
  def initialize block
@@ -22,18 +22,32 @@ class AgingWorkTable < ChartBase
22
22
  If there are expedited items that haven't yet started then they're at the bottom of the table.
23
23
  By the very definition of expedited, if we haven't started them already, we'd better get on that.
24
24
  </p>
25
+ <p>
26
+ Legend:
27
+ <ul>
28
+ <li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
29
+ <li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
30
+ <li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
31
+ </ul>
32
+ </p>
25
33
  TEXT
26
34
 
27
35
  instance_eval(&block)
28
36
  end
29
37
 
30
38
  def run
31
- @today = date_range.end
39
+ initialize_calculator
32
40
  aging_issues = select_aging_issues + expedited_but_not_started
33
41
 
34
42
  wrap_and_render(binding, __FILE__)
35
43
  end
36
44
 
45
+ # This is its own method simply so the tests can initialize the calculator without doing a full run.
46
+ def initialize_calculator
47
+ @today = date_range.end
48
+ @calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
49
+ end
50
+
37
51
  def expedited_but_not_started
38
52
  @issues.select do |issue|
39
53
  started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
@@ -114,6 +128,40 @@ class AgingWorkTable < ChartBase
114
128
  end.join('<br />')
115
129
  end
116
130
 
131
+ def dates_text issue
132
+ date = date_range.end
133
+ due = issue.due_date
134
+ message = nil
135
+
136
+ days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
137
+
138
+ unless error
139
+ if due
140
+ if due < date
141
+ message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
142
+ error = 'Overdue'
143
+ elsif due == date
144
+ message = 'Due: <b>today</b>'
145
+ else
146
+ error = 'Due date at risk' if date_range.end + days_remaining > due
147
+ message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
148
+ end
149
+ else
150
+ "#{label_days days_remaining} left."
151
+ end
152
+ end
153
+
154
+ text = +''
155
+ text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
156
+ if days_remaining
157
+ text << "#{label_days days_remaining} left"
158
+ else
159
+ text << 'Unable to forecast'
160
+ end
161
+ text << ' | ' << message if message
162
+ text
163
+ end
164
+
117
165
  def age_cutoff age = nil
118
166
  @age_cutoff = age.to_i if age
119
167
  @age_cutoff
@@ -11,11 +11,12 @@ class Board
11
11
  @sprints = []
12
12
 
13
13
  columns = raw['columnConfig']['columns']
14
+ ensure_uniqueness_of_column_names! columns
14
15
 
15
16
  # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
16
17
  # visible on the board. If the board is configured to have a kanban backlog then it will have
17
18
  # statuses matched to it and otherwise, there will be no statuses.
18
- columns = columns[1..] if kanban?
19
+ columns = columns.drop(1) if kanban?
19
20
 
20
21
  @backlog_statuses = []
21
22
  @visible_columns = columns.filter_map do |column|
@@ -88,4 +89,36 @@ class Board
88
89
  def name
89
90
  @raw['name']
90
91
  end
92
+
93
+ def accumulated_status_ids_per_column
94
+ accumulated_status_ids = []
95
+ visible_columns.reverse.filter_map do |column|
96
+ next if column == @fake_column
97
+
98
+ accumulated_status_ids += column.status_ids
99
+ [column.name, accumulated_status_ids.dup]
100
+ end.reverse
101
+ end
102
+
103
+ def ensure_uniqueness_of_column_names! json
104
+ all_names = []
105
+ json.each do |column_json|
106
+ name = column_json['name']
107
+ if all_names.include? name
108
+ (2..).each do |i|
109
+ new_name = "#{name}-#{i}"
110
+ next if all_names.include?(new_name)
111
+
112
+ name = new_name
113
+ column_json['name'] = new_name
114
+ break
115
+ end
116
+ end
117
+ all_names << name
118
+ end
119
+ end
120
+
121
+ def estimation_configuration
122
+ EstimationConfiguration.new raw: raw['estimation']
123
+ end
91
124
  end
@@ -13,6 +13,7 @@ class BoardConfig
13
13
  @board = @project_config.all_boards[id]
14
14
 
15
15
  instance_eval(&@block)
16
+ raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
16
17
  end
17
18
 
18
19
  def cycletime label = nil, &block
@@ -0,0 +1,155 @@
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
+ # This condition has been reported in production so we have a check for it. Having said that, we have no
128
+ # idea what conditions might make this possible and so there is no test for it.
129
+ if entry_time.nil?
130
+ message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
131
+ 'predict when it will be done'
132
+ return [nil, message]
133
+ end
134
+
135
+ age_in_column = (today - entry_time.to_date).to_i + 1
136
+
137
+ message = nil
138
+ column_index = board.visible_columns.index { |c| c.name == column_name }
139
+
140
+ last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
141
+ return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
142
+
143
+ remaining_in_current_column = likely_age_data[column_index] - age_in_column
144
+ if remaining_in_current_column.negative?
145
+ message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
146
+ "in the #{column_name.inspect} column. Most items on this board have left this column in " \
147
+ "#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
148
+ remaining_in_current_column = 0
149
+ return [nil, message]
150
+ end
151
+
152
+ forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
153
+ [forecasted_days, message]
154
+ end
155
+ end
@@ -31,8 +31,6 @@ class ChangeItem
31
31
 
32
32
  def sprint? = (field == 'Sprint')
33
33
 
34
- def story_points? = (field == 'Story Points')
35
-
36
34
  def link? = (field == 'Link')
37
35
 
38
36
  def labels? = (field == 'labels')
@@ -42,7 +40,14 @@ class ChangeItem
42
40
 
43
41
  def to_s
44
42
  message = +''
45
- message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
43
+ message << "ChangeItem(field: #{field.inspect}"
44
+ message << ", value: #{value.inspect}"
45
+ message << ':' << value_id.inspect if status?
46
+ if old_value
47
+ message << ", old_value: #{old_value.inspect}"
48
+ message << ':' << old_value_id.inspect if status?
49
+ end
50
+ message << ", time: #{time_to_s(@time).inspect}"
46
51
  message << ', artificial' if artificial?
47
52
  message << ')'
48
53
  message
@@ -260,7 +260,10 @@ class ChartBase
260
260
 
261
261
  def color_block color, title: nil
262
262
  result = +''
263
- result << "<div class='color_block' style='background: var(#{color});'"
263
+ result << "<div class='color_block' style='"
264
+ result << "background: #{CssVariable[color]};" if color
265
+ result << 'visibility: hidden;' unless color
266
+ result << "'"
264
267
  result << " title=#{title.inspect}" if title
265
268
  result << '></div>'
266
269
  result
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
22
22
  </div>
23
23
  HTML
24
24
 
25
- @y_axis_label = 'Story Point Estimates'
26
25
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
27
  @y_axis_sort_order = nil
29
28
 
30
29
  instance_eval(&configuration_block)
31
30
  end
32
31
 
33
32
  def run
33
+ if @y_axis_label.nil?
34
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
+ @y_axis_label = "Estimated #{text}"
36
+ end
34
37
  data_sets = scan_issues
35
38
 
36
39
  return '' if data_sets.empty?
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
41
44
  def scan_issues
42
45
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
46
 
47
+ estimation_units = current_board.estimation_configuration.units
44
48
  @has_aging_data = !aging_hash.empty?
45
49
 
46
50
  [
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
53
57
  # We sort so that the smaller circles are in front of the bigger circles.
54
58
  data = hash.sort(&hash_sorter).collect do |key, values|
55
59
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
60
+
61
+ title = [
62
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
63
+ "Cycletime: #{label_days(cycle_time)}, " \
64
+ "#{values.size} issues"
65
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
66
+
59
67
  {
60
68
  'x' => cycle_time,
61
69
  'y' => estimate,
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
77
85
  end
78
86
  end
79
87
 
88
+ def estimate_label estimate:, estimation_units:
89
+ if @y_axis_type == 'linear'
90
+ if estimation_units == :story_points
91
+ estimate_label = "#{estimate}pts"
92
+ elsif estimation_units == :seconds
93
+ estimate_label = label_days estimate
94
+ end
95
+ end
96
+ estimate_label = estimate.to_s if estimate_label.nil?
97
+ estimate_label
98
+ end
99
+
80
100
  def split_into_completed_and_aging issues:
81
101
  aging_hash = {}
82
102
  completed_hash = {}
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
126
146
  end
127
147
  end
128
148
 
129
- def story_points_at issue:, start_time:
130
- story_points = nil
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
131
152
  issue.changes.each do |change|
132
- return story_points if change.time >= start_time
153
+ return estimate if change.time >= start_time
133
154
 
134
- story_points = change.value if change.story_points?
155
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
156
+ estimate = change.value
157
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
158
+ end
135
159
  end
136
- story_points
160
+ estimate
137
161
  end
138
162
 
139
163
  def y_axis label:, sort_order: nil, &block
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -79,8 +79,8 @@ class Exporter
79
79
  file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
80
  else
81
81
  selected.each do |project, issue|
82
- file_system.log "\nProject #{project.name}"
83
- file_system.log issue.dump
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
84
84
  end
85
85
  end
86
86
  end
@@ -52,6 +52,8 @@ class FileSystem
52
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
53
53
  # cases where this simple compression will drop the filesize by half.
54
54
  def compress node
55
+ return node
56
+
55
57
  if node.is_a? Hash
56
58
  node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
57
59
  node.each_value { |value| compress value }
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
6
  {
7
7
  type: 'bar',
8
8
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
9
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
10
  datasets: <%= JSON.generate(data_sets) %>
11
11
  },
12
12
  options: {
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
22
  labelString: 'Date Completed'
23
23
  },
24
24
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
25
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
26
+ z: 1 // draw the grid lines on top of the bars
26
27
  },
28
+ stacked: true
27
29
  },
28
30
  y: {
29
31
  scaleLabel: {
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
37
  text: 'Age in days'
36
38
  },
37
39
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
40
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
41
+ z: 1 // draw the grid lines on top of the bars
39
42
  },
43
+ stacked: true,
44
+ max: <%= (@max_age * 1.1).to_i %>
40
45
  }
41
46
  },
42
47
  plugins: {
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
49
  callbacks: {
45
50
  label: function(context) {
46
51
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
52
+ let full_data = <%= @bar_data.inspect %>;
53
+ let columnIndex = context.dataIndex;
54
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
55
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
56
  }
49
57
  else {
50
- return context.dataset.data[context.dataIndex].title
58
+ return context.dataset.data[context.dataIndex].title;
51
59
  }
52
60
  }
53
61
  }
62
+ },
63
+ legend: {
64
+ labels: {
65
+ filter: function(item, chart) {
66
+ // Logic to remove a particular legend item goes here
67
+ return !item.text.includes('%');
68
+ }
69
+ }
54
70
  }
71
+
55
72
  }
56
73
  }
57
74
  });
@@ -1,11 +1,12 @@
1
1
  <table class='standard'>
2
2
  <thead>
3
3
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
4
+ <th title="Age in days">Age</th>
5
+ <th title="Expedited">E</th>
6
+ <th title="Blocked / Stalled">B/S</th>
7
7
  <th>Issue</th>
8
8
  <th>Status</th>
9
+ <th>Forecast</th>
9
10
  <th>Fix versions</th>
10
11
  <% if any_scrum_boards %>
11
12
  <th>Sprints</th>
@@ -41,6 +42,7 @@
41
42
  <% end %>
42
43
  </td>
43
44
  <td><%= format_status issue.status, board: issue.board %></td>
45
+ <td><%= dates_text(issue) %></td>
44
46
  <td><%= fix_versions_text(issue) %></td>
45
47
  <% if any_scrum_boards %>
46
48
  <td><%= sprints_text(issue) %></td>
@@ -2,6 +2,7 @@
2
2
  --body-background: white;
3
3
  --default-text-color: black;
4
4
  --grid-line-color: lightgray;
5
+ --warning-banner: yellow;
5
6
 
6
7
  --cycletime-scatterplot-overall-trendline-color: gray;
7
8
 
@@ -27,8 +28,16 @@
27
28
  --throughput_chart_total_line_color: gray;
28
29
 
29
30
  --aging-work-in-progress-chart-shading-color: lightgray;
31
+ --aging-work-in-progress-chart-shading-50-color: #2E8BC0; // green;
32
+ --aging-work-in-progress-chart-shading-85-color: #ADD8E6; // yellow;
33
+ --aging-work-in-progress-chart-shading-98-color: #FF8A8A; // orange;
34
+ --aging-work-in-progress-chart-shading-100-color: #FF2E2E; // red;
35
+
30
36
  --aging-work-in-progress-by-age-trend-line-color: gray;
31
37
 
38
+ --aging-work-table-date-in-jeopardy: yellow;
39
+ --aging-work-table-date-overdue: red;
40
+
32
41
  --hierarchy-table-inactive-item-text-color: gray;
33
42
 
34
43
  --wip-chart-completed-color: #00ff00;
@@ -135,6 +144,8 @@ ul.quality_report {
135
144
 
136
145
  @media screen and (prefers-color-scheme: dark) {
137
146
  :root {
147
+ --warning-banner: #9F2B00;
148
+
138
149
  --non-working-days-color: #2f2f2f;
139
150
  --type-story-color: #6fb86f;
140
151
  --type-task-color: #0021b3;
@@ -150,8 +161,6 @@ ul.quality_report {
150
161
  --dead-color: black;
151
162
  --wip-chart-active-color: #2551c1;
152
163
 
153
- --aging-work-in-progress-chart-shading-color: #b4b4b4;
154
-
155
164
  --status-category-inprogress-color: #1c49bb;
156
165
 
157
166
  --cycletime-scatterplot-overall-trendline-color: gray;
@@ -23,13 +23,20 @@
23
23
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
24
24
  location.reload()
25
25
  })
26
-
27
26
  </script>
28
27
  <style>
29
28
  <%= css %>
30
29
  </style>
30
+ <script type="text/javascript">
31
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--default-text-color');
32
+ </script>
31
33
  </head>
32
34
  <body>
35
+ <noscript>
36
+ <div style="padding: 1em; background: gray; color: white; font-size: 2em;">
37
+ Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'd loaded this from a folder on SharePoint then save it locally and load it again.
38
+ </div>
39
+ </noscript>
33
40
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
34
41
  <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
35
42
  </body>
@@ -49,14 +49,26 @@ class Issue
49
49
 
50
50
  def summary = @raw['fields']['summary']
51
51
 
52
- def status = Status.from_raw(@raw['fields']['status'])
53
-
54
52
  def labels = @raw['fields']['labels'] || []
55
53
 
56
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
57
55
 
58
56
  def resolution = @raw['fields']['resolution']&.[]('name')
59
57
 
58
+ def status
59
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
60
+ @status
61
+ end
62
+
63
+ def status= status
64
+ @status = status
65
+ end
66
+
67
+ def due_date
68
+ text = @raw['fields']['duedate']
69
+ text.nil? ? nil : Date.parse(text)
70
+ end
71
+
60
72
  def url
61
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
62
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -129,13 +141,16 @@ class Issue
129
141
  end
130
142
 
131
143
  def most_recent_status_change
132
- # We artificially insert a status change to represent creation so by definition there will always be at least one.
133
- changes.reverse.find { |change| change.status? }
144
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
145
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
146
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
147
+ status_changes.last
134
148
  end
135
149
 
136
150
  # Are we currently in this status? If yes, then return the most recent status change.
137
151
  def currently_in_status *status_names
138
152
  change = most_recent_status_change
153
+ return false if change.nil?
139
154
 
140
155
  change if change.current_status_matches(*status_names)
141
156
  end
@@ -145,6 +160,7 @@ class Issue
145
160
  category_ids = find_status_category_ids_by_names category_names
146
161
 
147
162
  change = most_recent_status_change
163
+ return false if change.nil?
148
164
 
149
165
  status = find_or_create_status id: change.value_id, name: change.value
150
166
  change if status && category_ids.include?(status.category.id)
@@ -595,21 +611,30 @@ class Issue
595
611
  end
596
612
  history = [] # time, type, detail
597
613
 
598
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
599
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
600
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
614
+ if board.cycletime
615
+ started_at, stopped_at = board.cycletime.started_stopped_times(self)
616
+ history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
617
+ history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
618
+ else
619
+ result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
620
+ end
601
621
 
602
622
  @discarded_change_times&.each do |time|
603
623
  history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
604
624
  end
605
625
 
606
626
  (changes + (@discarded_changes || [])).each do |change|
607
- value = change.value
608
- old_value = change.old_value
627
+ if change.status?
628
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
629
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
630
+ else
631
+ value = compact_text(change.value).inspect
632
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
633
+ end
609
634
 
610
635
  message = +''
611
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
612
- message << compact_text(value).inspect
636
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
637
+ message << value
613
638
  if change.artificial?
614
639
  message << ' (Artificial entry)' if change.artificial?
615
640
  else
@@ -151,6 +151,8 @@ class ProjectConfig
151
151
  end
152
152
 
153
153
  def status_category_mapping status:, category:
154
+ return if @exporter.downloading?
155
+
154
156
  status, status_id = possible_statuses.parse_name_id status
155
157
  category, category_id = possible_statuses.parse_name_id category
156
158
 
@@ -450,6 +452,9 @@ class ProjectConfig
450
452
  end
451
453
 
452
454
  boards.each do |board|
455
+ if board.cycletime.nil?
456
+ raise "The board declaration for board #{board.id} must come before the first usage of 'issues' in the configuration"
457
+ end
453
458
  issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
454
459
  end
455
460
  end
@@ -462,7 +467,7 @@ class ProjectConfig
462
467
  # board ids appropriately.
463
468
  def group_filenames_and_board_ids path:
464
469
  hash = {}
465
- Dir.foreach(path) do |filename|
470
+ file_system.foreach(path) do |filename|
466
471
  # Matches either FAKE-123.json or FAKE-123-456.json
467
472
  if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
468
473
  (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
@@ -121,11 +121,13 @@ class SprintBurndown < ChartBase
121
121
 
122
122
  # select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
123
123
  def changes_for_one_issue issue:, sprint:
124
- story_points = 0.0
124
+ estimate = 0.0
125
125
  ever_in_sprint = false
126
126
  currently_in_sprint = false
127
127
  change_data = []
128
128
 
129
+ estimate_display_name = current_board.estimation_configuration.display_name
130
+
129
131
  issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
130
132
  completed_has_been_tracked = false
131
133
 
@@ -140,26 +142,26 @@ class SprintBurndown < ChartBase
140
142
  if currently_in_sprint == false && in_change_item
141
143
  action = :enter_sprint
142
144
  ever_in_sprint = true
143
- value = story_points
145
+ value = estimate
144
146
  elsif currently_in_sprint && in_change_item == false
145
147
  action = :leave_sprint
146
- value = -story_points
148
+ value = -estimate
147
149
  end
148
150
  currently_in_sprint = in_change_item
149
- elsif change.story_points? && (issue_completed_time.nil? || change.time < issue_completed_time)
151
+ elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
150
152
  action = :story_points
151
- story_points = change.value.to_f
152
- value = story_points - change.old_value.to_f
153
+ estimate = change.value.to_f
154
+ value = estimate - change.old_value.to_f
153
155
  elsif completed_has_been_tracked == false && change.time == issue_completed_time
154
156
  completed_has_been_tracked = true
155
157
  action = :issue_stopped
156
- value = -story_points
158
+ value = -estimate
157
159
  end
158
160
 
159
161
  next unless action
160
162
 
161
163
  change_data << SprintIssueChangeData.new(
162
- time: change.time, issue: issue, action: action, value: value, story_points: story_points
164
+ time: change.time, issue: issue, action: action, value: value, estimate: estimate
163
165
  )
164
166
  end
165
167
 
@@ -176,7 +178,7 @@ class SprintBurndown < ChartBase
176
178
  summary_stats = SprintSummaryStats.new
177
179
  summary_stats.completed = 0.0
178
180
 
179
- story_points = 0.0
181
+ estimate = 0.0
180
182
  start_data_written = false
181
183
  data_set = []
182
184
 
@@ -185,11 +187,11 @@ class SprintBurndown < ChartBase
185
187
  change_data_for_sprint.each do |change_data|
186
188
  if start_data_written == false && change_data.time >= sprint.start_time
187
189
  data_set << {
188
- y: story_points,
190
+ y: estimate,
189
191
  x: chart_format(sprint.start_time),
190
- title: "Sprint started with #{story_points} points"
192
+ title: "Sprint started with #{estimate} points"
191
193
  }
192
- summary_stats.started = story_points
194
+ summary_stats.started = estimate
193
195
  start_data_written = true
194
196
  end
195
197
 
@@ -198,12 +200,12 @@ class SprintBurndown < ChartBase
198
200
  case change_data.action
199
201
  when :enter_sprint
200
202
  issues_currently_in_sprint << change_data.issue.key
201
- story_points += change_data.story_points
203
+ estimate += change_data.estimate
202
204
  when :leave_sprint
203
205
  issues_currently_in_sprint.delete change_data.issue.key
204
- story_points -= change_data.story_points
206
+ estimate -= change_data.estimate
205
207
  when :story_points
206
- story_points += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
208
+ estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
207
209
  end
208
210
 
209
211
  next unless change_data.time >= sprint.start_time
@@ -213,26 +215,26 @@ class SprintBurndown < ChartBase
213
215
  when :story_points
214
216
  next unless issues_currently_in_sprint.include? change_data.issue.key
215
217
 
216
- old_story_points = change_data.story_points - change_data.value
217
- message = "Story points changed from #{old_story_points} points to #{change_data.story_points} points"
218
+ old_estimate = change_data.estimate - change_data.value
219
+ message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
218
220
  summary_stats.points_values_changed = true
219
221
  when :enter_sprint
220
- message = "Added to sprint with #{change_data.story_points || 'no'} points"
221
- summary_stats.added += change_data.story_points
222
+ message = "Added to sprint with #{change_data.estimate || 'no'} points"
223
+ summary_stats.added += change_data.estimate
222
224
  when :issue_stopped
223
- story_points -= change_data.story_points
224
- message = "Completed with #{change_data.story_points || 'no'} points"
225
+ estimate -= change_data.estimate
226
+ message = "Completed with #{change_data.estimate || 'no'} points"
225
227
  issues_currently_in_sprint.delete change_data.issue.key
226
- summary_stats.completed += change_data.story_points
228
+ summary_stats.completed += change_data.estimate
227
229
  when :leave_sprint
228
- message = "Removed from sprint with #{change_data.story_points || 'no'} points"
229
- summary_stats.removed += change_data.story_points
230
+ message = "Removed from sprint with #{change_data.estimate || 'no'} points"
231
+ summary_stats.removed += change_data.estimate
230
232
  else
231
233
  raise "Unexpected action: #{change_data.action}"
232
234
  end
233
235
 
234
236
  data_set << {
235
- y: story_points,
237
+ y: estimate,
236
238
  x: chart_format(change_data.time),
237
239
  title: "#{change_data.issue.key} #{message}"
238
240
  }
@@ -241,27 +243,27 @@ class SprintBurndown < ChartBase
241
243
  unless start_data_written
242
244
  # There was nothing that triggered us to write the sprint started block so do it now.
243
245
  data_set << {
244
- y: story_points,
246
+ y: estimate,
245
247
  x: chart_format(sprint.start_time),
246
- title: "Sprint started with #{story_points} points"
248
+ title: "Sprint started with #{estimate} points"
247
249
  }
248
- summary_stats.started = story_points
250
+ summary_stats.started = estimate
249
251
  end
250
252
 
251
253
  if sprint.completed_time
252
254
  data_set << {
253
- y: story_points,
255
+ y: estimate,
254
256
  x: chart_format(sprint.completed_time),
255
- title: "Sprint ended with #{story_points} points unfinished"
257
+ title: "Sprint ended with #{estimate} points unfinished"
256
258
  }
257
- summary_stats.remaining = story_points
259
+ summary_stats.remaining = estimate
258
260
  end
259
261
 
260
262
  unless sprint.completed_at?(time_range.end)
261
263
  data_set << {
262
- y: story_points,
264
+ y: estimate,
263
265
  x: chart_format(time_range.end),
264
- title: "Sprint still active. #{story_points} points still in progress."
266
+ title: "Sprint still active. #{estimate} points still in progress."
265
267
  }
266
268
  end
267
269
 
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class SprintIssueChangeData
6
6
  include ValueEquality
7
- attr_reader :time, :action, :value, :issue, :story_points
7
+ attr_reader :time, :action, :value, :issue, :estimate
8
8
 
9
- def initialize time:, action:, value:, issue:, story_points:
9
+ def initialize time:, action:, value:, issue:, estimate:
10
10
  @time = time
11
11
  @action = action
12
12
  @value = value
13
13
  @issue = issue
14
- @story_points = story_points
14
+ @estimate = estimate
15
15
  end
16
16
 
17
17
  def inspect
@@ -36,7 +36,10 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
+ raise "raw cannot be nil" if raw.nil?
40
+
39
41
  category_config = raw['statusCategory']
42
+ raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
40
43
 
41
44
  Status.new(
42
45
  name: raw['name'],
data/lib/jirametrics.rb CHANGED
@@ -112,6 +112,7 @@ class JiraMetrics < Thor
112
112
  require 'jirametrics/download_config'
113
113
  require 'jirametrics/columns_config'
114
114
  require 'jirametrics/hierarchy_table'
115
+ require 'jirametrics/estimation_configuration'
115
116
  require 'jirametrics/board'
116
117
  load config_file
117
118
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.10'
4
+ version: 2.12pre9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-02-06 00:00:00.000000000 Z
10
+ date: 2025-04-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: random-word
@@ -69,6 +69,7 @@ files:
69
69
  - lib/jirametrics/board.rb
70
70
  - lib/jirametrics/board_column.rb
71
71
  - lib/jirametrics/board_config.rb
72
+ - lib/jirametrics/board_movement_calculator.rb
72
73
  - lib/jirametrics/change_item.rb
73
74
  - lib/jirametrics/chart_base.rb
74
75
  - lib/jirametrics/columns_config.rb
@@ -85,6 +86,7 @@ files:
85
86
  - lib/jirametrics/download_config.rb
86
87
  - lib/jirametrics/downloader.rb
87
88
  - lib/jirametrics/estimate_accuracy_chart.rb
89
+ - lib/jirametrics/estimation_configuration.rb
88
90
  - lib/jirametrics/examples/aggregated_project.rb
89
91
  - lib/jirametrics/examples/standard_project.rb
90
92
  - lib/jirametrics/expedited_chart.rb