jirametrics 2.10 → 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/aging_work_in_progress_chart.rb +105 -41
 - data/lib/jirametrics/aging_work_table.rb +50 -2
 - data/lib/jirametrics/board.rb +30 -1
 - data/lib/jirametrics/board_movement_calculator.rb +147 -0
 - data/lib/jirametrics/change_item.rb +8 -1
 - data/lib/jirametrics/chart_base.rb +4 -1
 - data/lib/jirametrics/css_variable.rb +1 -1
 - data/lib/jirametrics/exporter.rb +2 -2
 - data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
 - data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
 - data/lib/jirametrics/html/aging_work_table.erb +5 -3
 - data/lib/jirametrics/html/index.css +11 -2
 - data/lib/jirametrics/html/index.erb +8 -1
 - data/lib/jirametrics/issue.rb +29 -8
 - data/lib/jirametrics/status.rb +3 -0
 - metadata +3 -2
 
    
        checksums.yaml
    CHANGED
    
    | 
         @@ -1,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            ---
         
     | 
| 
       2 
2 
     | 
    
         
             
            SHA256:
         
     | 
| 
       3 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       4 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 3 
     | 
    
         
            +
              metadata.gz: 670df42835c7d41e30dcde01ead744a132f81313165297d434334e272de795c7
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: 12822842210b4aeddb7f59e6681a9f1bd25060bae0ff98e8490d14e895dea657
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: a4f36b8921c94a457710dc68251a9a2ddb38be2f0f7027991cf381fdf1cff28d2e549e508314733a3c753f583fd3c695b7e4979e1bb7be43c45de90ec001cbab
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: 28a2553b31cd4de5fce0c943da38643ad13dbfad3a8bddc77f4764e179394968966bb676e70daa75a4faeb42a67027fb7efb63895b56c01a3db563a029df66aa
         
     | 
| 
         @@ -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 
     | 
    
         
            -
                  < 
     | 
| 
       20 
     | 
    
         
            -
                    The  
     | 
| 
       21 
     | 
    
         
            -
                     
     | 
| 
       22 
     | 
    
         
            -
                     
     | 
| 
       23 
     | 
    
         
            -
             
     | 
| 
      
 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 
     | 
| 
      
 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 
     | 
    
         
            -
                 
     | 
| 
      
 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 
     | 
    
         
            -
                         
     | 
| 
      
 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 
     | 
    
         
            -
             
     | 
| 
       97 
     | 
    
         
            -
             
     | 
| 
       98 
     | 
    
         
            -
             
     | 
| 
       99 
     | 
    
         
            -
             
     | 
| 
       100 
     | 
    
         
            -
                   
     | 
| 
      
 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  
     | 
| 
       105 
     | 
    
         
            -
                 
     | 
| 
       106 
     | 
    
         
            -
             
     | 
| 
       107 
     | 
    
         
            -
             
     | 
| 
      
 148 
     | 
    
         
            +
              def adjust_bar_data input
         
     | 
| 
      
 149 
     | 
    
         
            +
                return [] if input.empty?
         
     | 
| 
      
 150 
     | 
    
         
            +
             
     | 
| 
      
 151 
     | 
    
         
            +
                row_size = input.first.size
         
     | 
| 
       108 
152 
     | 
    
         | 
| 
       109 
     | 
    
         
            -
             
     | 
| 
       110 
     | 
    
         
            -
             
     | 
| 
       111 
     | 
    
         
            -
                 
     | 
| 
      
 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  
     | 
| 
       115 
     | 
    
         
            -
                 
     | 
| 
       116 
     | 
    
         
            -
             
     | 
| 
       117 
     | 
    
         
            -
                   
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       120 
     | 
    
         
            -
             
     | 
| 
       121 
     | 
    
         
            -
                  next if stop < start
         
     | 
| 
      
 168 
     | 
    
         
            +
                  result << index
         
     | 
| 
      
 169 
     | 
    
         
            +
                end
         
     | 
| 
       122 
170 
     | 
    
         | 
| 
       123 
     | 
    
         
            -
             
     | 
| 
      
 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 
     | 
| 
      
 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 
     | 
| 
      
 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 
     | 
    
         
            -
                 
     | 
| 
      
 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
         
     | 
    
        data/lib/jirametrics/board.rb
    CHANGED
    
    | 
         @@ -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 
     | 
| 
      
 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,32 @@ 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
         
     | 
| 
       91 
120 
     | 
    
         
             
            end
         
     | 
| 
         @@ -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
         
     | 
| 
         @@ -42,7 +42,14 @@ class ChangeItem 
     | 
|
| 
       42 
42 
     | 
    
         | 
| 
       43 
43 
     | 
    
         
             
              def to_s
         
     | 
| 
       44 
44 
     | 
    
         
             
                message = +''
         
     | 
| 
       45 
     | 
    
         
            -
                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}"
         
     | 
| 
       46 
53 
     | 
    
         
             
                message << ', artificial' if artificial?
         
     | 
| 
       47 
54 
     | 
    
         
             
                message << ')'
         
     | 
| 
       48 
55 
     | 
    
         
             
                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=' 
     | 
| 
      
 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
         
     | 
    
        data/lib/jirametrics/exporter.rb
    CHANGED
    
    | 
         @@ -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
         
     | 
| 
         @@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase 
     | 
|
| 
       27 
27 
     | 
    
         
             
                      </mfrac>
         
     | 
| 
       28 
28 
     | 
    
         
             
                    </math>
         
     | 
| 
       29 
29 
     | 
    
         
             
                  </div>
         
     | 
| 
       30 
     | 
    
         
            -
                  <div style="background:  
     | 
| 
      
 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: [<%=  
     | 
| 
      
 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 
     | 
    
         
            -
                           
     | 
| 
      
 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 
     | 
| 
       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>
         
     | 
    
        data/lib/jirametrics/issue.rb
    CHANGED
    
    | 
         @@ -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 
     | 
    
         
            -
                #  
     | 
| 
       133 
     | 
    
         
            -
                 
     | 
| 
      
 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)
         
     | 
| 
         @@ -604,12 +620,17 @@ class Issue 
     | 
|
| 
       604 
620 
     | 
    
         
             
                end
         
     | 
| 
       605 
621 
     | 
    
         | 
| 
       606 
622 
     | 
    
         
             
                (changes + (@discarded_changes || [])).each do |change|
         
     | 
| 
       607 
     | 
    
         
            -
                   
     | 
| 
       608 
     | 
    
         
            -
             
     | 
| 
      
 623 
     | 
    
         
            +
                  if change.status?
         
     | 
| 
      
 624 
     | 
    
         
            +
                    value = "#{change.value.inspect}:#{change.value_id.inspect}"
         
     | 
| 
      
 625 
     | 
    
         
            +
                    old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
         
     | 
| 
      
 626 
     | 
    
         
            +
                  else
         
     | 
| 
      
 627 
     | 
    
         
            +
                    value = compact_text(change.value).inspect
         
     | 
| 
      
 628 
     | 
    
         
            +
                    old_value = change.old_value ? compact_text(change.old_value).inspect : nil
         
     | 
| 
      
 629 
     | 
    
         
            +
                  end
         
     | 
| 
       609 
630 
     | 
    
         | 
| 
       610 
631 
     | 
    
         
             
                  message = +''
         
     | 
| 
       611 
     | 
    
         
            -
                  message << "#{ 
     | 
| 
       612 
     | 
    
         
            -
                  message <<  
     | 
| 
      
 632 
     | 
    
         
            +
                  message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
         
     | 
| 
      
 633 
     | 
    
         
            +
                  message << value
         
     | 
| 
       613 
634 
     | 
    
         
             
                  if change.artificial?
         
     | 
| 
       614 
635 
     | 
    
         
             
                    message << ' (Artificial entry)' if change.artificial?
         
     | 
| 
       615 
636 
     | 
    
         
             
                  else
         
     | 
    
        data/lib/jirametrics/status.rb
    CHANGED
    
    | 
         @@ -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'],
         
     | 
    
        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. 
     | 
| 
      
 4 
     | 
    
         
            +
              version: '2.11'
         
     | 
| 
       5 
5 
     | 
    
         
             
            platform: ruby
         
     | 
| 
       6 
6 
     | 
    
         
             
            authors:
         
     | 
| 
       7 
7 
     | 
    
         
             
            - Mike Bowler
         
     | 
| 
       8 
8 
     | 
    
         
             
            bindir: bin
         
     | 
| 
       9 
9 
     | 
    
         
             
            cert_chain: []
         
     | 
| 
       10 
     | 
    
         
            -
            date: 2025- 
     | 
| 
      
 10 
     | 
    
         
            +
            date: 2025-03-11 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
         
     |