Class: BoardMovementCalculator
- Inherits:
-
Object
- Object
- BoardMovementCalculator
- Defined in:
- lib/jirametrics/board_movement_calculator.rb
Instance Attribute Summary collapse
-
#board ⇒ Object
readonly
Returns the value of attribute board.
-
#issues ⇒ Object
readonly
Returns the value of attribute issues.
-
#today ⇒ Object
readonly
Returns the value of attribute today.
Instance Method Summary collapse
- #age_data_for(percentage:) ⇒ Object
-
#ages_of_issues_when_leaving_column(column_index:, today:) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity.
-
#find_current_column_and_entry_time_in_column(issue) ⇒ Object
Figure out what column this is issue is currently in and what time it entered that column.
-
#forecasted_days_remaining_and_message(issue:, today:, percentile: 85) ⇒ Object
percentile is passed in rather than held on the calculator because this class is shared: the aging work in progress chart builds one too and never forecasts, so it has no business carrying a forecast setting.
-
#initialize(board:, issues:, today:) ⇒ BoardMovementCalculator
constructor
A new instance of BoardMovementCalculator.
- #label_days(days) ⇒ Object
- #moves_backwards?(issue) ⇒ Boolean
-
#outlier_message(issue:, today:, column_name:, percentile:, column_age:) ⇒ Object
Why we cannot forecast: this item has already been in the column longer than the historical figure we would forecast from.
Constructor Details
#initialize(board:, issues:, today:) ⇒ BoardMovementCalculator
Returns a new instance of BoardMovementCalculator.
6 7 8 9 10 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 6 def initialize board:, issues:, today: @board = board @issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) } @today = today end |
Instance Attribute Details
#board ⇒ Object (readonly)
Returns the value of attribute board.
4 5 6 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 4 def board @board end |
#issues ⇒ Object (readonly)
Returns the value of attribute issues.
4 5 6 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 4 def issues @issues end |
#today ⇒ Object (readonly)
Returns the value of attribute today.
4 5 6 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 4 def today @today end |
Instance Method Details
#age_data_for(percentage:) ⇒ Object
25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 25 def age_data_for percentage: data = [] board.visible_columns.each_with_index do |_column, column_index| ages = ages_of_issues_when_leaving_column column_index: column_index, today: today if ages.empty? data << 0 else index = ((ages.size - 1) * percentage / 100).to_i data << ages[index] end end data end |
#ages_of_issues_when_leaving_column(column_index:, today:) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 41 def ages_of_issues_when_leaving_column column_index:, today: # Why are complexity warnings disabled? This is an inherently sequential run of guard clauses # feeding a single age calculation. Pulling pieces out (the skip rules, the end-date case) would # scatter the linear "compute the column times -> apply the skip rules -> pick the end date -> age" # story across single-use methods and read worse, so we keep it whole. this_column = board.visible_columns[column_index] next_column = board.visible_columns[column_index + 1] @issues.filter_map do |issue| this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time issue_start, issue_done = issue.started_stopped_times # Skip if we can't tell when it started. next if issue_start.nil? # Skip if it never entered this column next if this_column_start.nil? # Skip if it left this column before the item is considered started. next 0 if next_column_start && next_column_start <= issue_start # Skip if it was already done by the time it got to this column or it became done when it got to this column next if issue_done && issue_done <= this_column_start end_date = if next_column_start.nil? # If this is the last column then base age against today today elsif issue_done && issue_done < next_column_start # it completed while in this column issue_done.to_date else # It passed through this whole column next_column_start.to_date end (end_date - issue_start.to_date).to_i + 1 end.sort end |
#find_current_column_and_entry_time_in_column(issue) ⇒ Object
Figure out what column this is issue is currently in and what time it entered that column. We need this for aging and forecasting purposes
84 85 86 87 88 89 90 91 92 93 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 84 def find_current_column_and_entry_time_in_column issue column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) } return [] if column.nil? # This issue isn't visible on the board status_ids = column.status_ids entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time [column.name, entry_at] end |
#forecasted_days_remaining_and_message(issue:, today:, percentile: 85) ⇒ Object
percentile is passed in rather than held on the calculator because this class is shared: the aging work in progress chart builds one too and never forecasts, so it has no business carrying a forecast setting.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 102 def issue:, today:, percentile: 85 return [nil, 'Already done'] if issue.done? likely_age_data = age_data_for percentage: percentile column_name, entry_time = find_current_column_and_entry_time_in_column issue return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil? # This condition has been reported in production so we have a check for it. Having said that, we have no # idea what conditions might make this possible and so there is no test for it. if entry_time.nil? = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \ 'predict when it will be done' return [nil, ] end age_in_column = (today - entry_time.to_date).to_i + 1 = nil column_index = board.visible_columns.index { |c| c.name == column_name } last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? } return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil? remaining_in_current_column = likely_age_data[column_index] - age_in_column if remaining_in_current_column.negative? return [nil, ( issue: issue, today: today, column_name: column_name, percentile: percentile, column_age: likely_age_data[column_index] )] end forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column [forecasted_days, ] end |
#label_days(days) ⇒ Object
95 96 97 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 95 def label_days days "#{days} day#{'s' unless days == 1}" end |
#moves_backwards?(issue) ⇒ Boolean
12 13 14 15 16 17 18 19 20 21 22 23 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 12 def moves_backwards? issue started, = issue.started_stopped_times return false unless started # filter_map drops any status that isn't on a visible column (it disappeared from the board for a bit), # so a dip either side of that gap is still seen as consecutive columns. columns = issue.status_changes .select { |change| change.time >= started } .filter_map { |change| board.visible_columns.index { |c| c.status_ids.include?(change.value_id) } } columns.each_cons(2).any? { |previous, current| current < previous } end |
#outlier_message(issue:, today:, column_name:, percentile:, column_age:) ⇒ Object
Why we cannot forecast: this item has already been in the column longer than the historical figure we would forecast from. The message names the percentile rather than characterising it as "most", which was only ever a fair description at the default and plainly wrong at the median.
141 142 143 144 145 |
# File 'lib/jirametrics/board_movement_calculator.rb', line 141 def issue:, today:, column_name:, percentile:, column_age: "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \ "in the #{column_name.inspect} column. #{percentile}% of items on this board have left this " \ "column in #{label_days column_age} or less, so we cannot forecast when it will be done." end |