jirametrics 2.9 → 2.12pre9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  4. data/lib/jirametrics/aging_work_table.rb +50 -2
  5. data/lib/jirametrics/board.rb +34 -1
  6. data/lib/jirametrics/board_config.rb +1 -0
  7. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  8. data/lib/jirametrics/change_item.rb +8 -3
  9. data/lib/jirametrics/chart_base.rb +19 -1
  10. data/lib/jirametrics/css_variable.rb +1 -1
  11. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  12. data/lib/jirametrics/data_quality_report.rb +1 -1
  13. data/lib/jirametrics/downloader.rb +0 -14
  14. data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
  15. data/lib/jirametrics/estimation_configuration.rb +25 -0
  16. data/lib/jirametrics/exporter.rb +10 -8
  17. data/lib/jirametrics/file_config.rb +9 -4
  18. data/lib/jirametrics/file_system.rb +6 -0
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  21. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  22. data/lib/jirametrics/html/aging_work_table.erb +5 -3
  23. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  24. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  25. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  26. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  27. data/lib/jirametrics/html/index.css +11 -2
  28. data/lib/jirametrics/html/index.erb +8 -1
  29. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  30. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  31. data/lib/jirametrics/issue.rb +36 -11
  32. data/lib/jirametrics/jira_gateway.rb +16 -3
  33. data/lib/jirametrics/project_config.rb +7 -2
  34. data/lib/jirametrics/sprint_burndown.rb +35 -33
  35. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  36. data/lib/jirametrics/status.rb +3 -6
  37. data/lib/jirametrics.rb +1 -0
  38. metadata +4 -2
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
5
5
  class CycletimeHistogram < ChartBase
6
6
  include GroupableIssueChart
7
7
  attr_accessor :possible_statuses
8
+ attr_reader :show_stats
8
9
 
9
10
  def initialize block
10
11
  super()
11
12
 
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+
12
16
  header_text 'Cycletime Histogram'
13
17
  description_text <<-HTML
14
18
  <p>
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
26
30
  end
27
31
  end
28
32
 
33
+ def percentiles percs = nil
34
+ @percentiles = percs unless percs.nil?
35
+ @percentiles
36
+ end
37
+
38
+ def disable_stats
39
+ @show_stats = false
40
+ end
41
+
29
42
  def run
30
43
  stopped_issues = completed_issues_in_range include_unstarted: true
31
44
 
@@ -33,10 +46,18 @@ class CycletimeHistogram < ChartBase
33
46
  histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
47
  rules_to_issues = group_issues histogram_issues
35
48
 
49
+ the_stats = {}
50
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
55
76
  count_hash
56
77
  end
57
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
58
121
  def data_set_for histogram_data:, label:, color:
59
122
  keys = histogram_data.keys.sort
60
123
  {
@@ -272,7 +272,7 @@ class DataQualityReport < ChartBase
272
272
 
273
273
  entry.report(
274
274
  problem_key: :items_blocked_on_closed_tickets,
275
- detail: "#{entry.issue.key} thinks it's blocked on #{link.other_issue.key}, " \
275
+ detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
276
276
  "except #{link.other_issue.key} is closed."
277
277
  )
278
278
  end
@@ -103,8 +103,6 @@ class Downloader
103
103
  json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
104
  "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
105
 
106
- exit_if_call_failed json
107
-
108
106
  json['issues'].each do |issue_json|
109
107
  issue_json['exporter'] = {
110
108
  'in_initial_query' => initial_query
@@ -139,15 +137,6 @@ class Downloader
139
137
  end
140
138
  end
141
139
 
142
- def exit_if_call_failed json
143
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
144
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
145
-
146
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
147
- log " #{JSON.pretty_generate(json)}"
148
- exit 1
149
- end
150
-
151
140
  def download_statuses
152
141
  log ' Downloading all statuses', both: true
153
142
  json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
@@ -188,8 +177,6 @@ class Downloader
188
177
  log " Downloading board configuration for board #{board_id}", both: true
189
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
190
179
 
191
- exit_if_call_failed json
192
-
193
180
  @file_system.save_json(
194
181
  json: json,
195
182
  filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
@@ -213,7 +200,6 @@ class Downloader
213
200
  while is_last == false
214
201
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
215
202
  "maxResults=#{max_results}&startAt=#{start_at}"
216
- exit_if_call_failed json
217
203
 
218
204
  @file_system.save_json(
219
205
  json: json,
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
22
22
  </div>
23
23
  HTML
24
24
 
25
- @y_axis_label = 'Story Point Estimates'
26
25
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
27
  @y_axis_sort_order = nil
29
28
 
30
29
  instance_eval(&configuration_block)
31
30
  end
32
31
 
33
32
  def run
33
+ if @y_axis_label.nil?
34
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
+ @y_axis_label = "Estimated #{text}"
36
+ end
34
37
  data_sets = scan_issues
35
38
 
36
39
  return '' if data_sets.empty?
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
41
44
  def scan_issues
42
45
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
46
 
47
+ estimation_units = current_board.estimation_configuration.units
44
48
  @has_aging_data = !aging_hash.empty?
45
49
 
46
50
  [
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
53
57
  # We sort so that the smaller circles are in front of the bigger circles.
54
58
  data = hash.sort(&hash_sorter).collect do |key, values|
55
59
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
60
+
61
+ title = [
62
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
63
+ "Cycletime: #{label_days(cycle_time)}, " \
64
+ "#{values.size} issues"
65
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
66
+
59
67
  {
60
68
  'x' => cycle_time,
61
69
  'y' => estimate,
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
77
85
  end
78
86
  end
79
87
 
88
+ def estimate_label estimate:, estimation_units:
89
+ if @y_axis_type == 'linear'
90
+ if estimation_units == :story_points
91
+ estimate_label = "#{estimate}pts"
92
+ elsif estimation_units == :seconds
93
+ estimate_label = label_days estimate
94
+ end
95
+ end
96
+ estimate_label = estimate.to_s if estimate_label.nil?
97
+ estimate_label
98
+ end
99
+
80
100
  def split_into_completed_and_aging issues:
81
101
  aging_hash = {}
82
102
  completed_hash = {}
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
126
146
  end
127
147
  end
128
148
 
129
- def story_points_at issue:, start_time:
130
- story_points = nil
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
131
152
  issue.changes.each do |change|
132
- return story_points if change.time >= start_time
153
+ return estimate if change.time >= start_time
133
154
 
134
- story_points = change.value if change.story_points?
155
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
156
+ estimate = change.value
157
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
158
+ end
135
159
  end
136
- story_points
160
+ estimate
137
161
  end
138
162
 
139
163
  def y_axis label:, sort_order: nil, &block
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -64,12 +64,8 @@ class Exporter
64
64
  selected = []
65
65
  each_project_config(name_filter: name_filter) do |project|
66
66
  project.evaluate_next_level
67
- # next if project.aggregated_project?
68
67
 
69
68
  project.run load_only: true
70
- project.board_configs.each do |board_config|
71
- board_config.run
72
- end
73
69
  project.issues.each do |issue|
74
70
  selected << [project, issue] if keys.include? issue.key
75
71
  end
@@ -79,9 +75,13 @@ class Exporter
79
75
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
80
76
  end
81
77
 
82
- selected.each do |project, issue|
83
- puts "\nProject #{project.name}"
84
- puts issue.dump
78
+ if selected.empty?
79
+ file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
+ else
81
+ selected.each do |project, issue|
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
84
+ end
85
85
  end
86
86
  end
87
87
 
@@ -116,7 +116,9 @@ class Exporter
116
116
 
117
117
  def jira_config filename = nil
118
118
  if filename
119
- @jira_config = file_system.load_json(filename)
119
+ @jira_config = file_system.load_json(filename, fail_on_error: false)
120
+ raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
121
+
120
122
  @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
121
123
  end
122
124
  @jira_config
@@ -66,15 +66,20 @@ class FileConfig
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
68
  all_lines.sort do |a, b|
69
+ result = nil
69
70
  if a[0] == b[0]
70
- a[1..] <=> b[1..]
71
+ result = a[1..] <=> b[1..]
71
72
  elsif a[0].nil?
72
- 1
73
+ result = 1
73
74
  elsif b[0].nil?
74
- -1
75
+ result = -1
75
76
  else
76
- a[0] <=> b[0]
77
+ result = a[0] <=> b[0]
77
78
  end
79
+
80
+ # This will only happen if one of the objects isn't comparable. Seen in production.
81
+ result = -1 if result.nil?
82
+ result
78
83
  end
79
84
  end
80
85
 
@@ -35,6 +35,10 @@ class FileSystem
35
35
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
36
  end
37
37
 
38
+ def error message, more: nil
39
+ log "Error: #{message}", more: more, also_write_to_stderr: true
40
+ end
41
+
38
42
  def log message, more: nil, also_write_to_stderr: false
39
43
  message += " See #{logfile_name} for more details about this message." if more
40
44
 
@@ -48,6 +52,8 @@ class FileSystem
48
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
49
53
  # cases where this simple compression will drop the filesize by half.
50
54
  def compress node
55
+ return node
56
+
51
57
  if node.is_a? Hash
52
58
  node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
53
59
  node.each_value { |value| compress value }
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
@@ -38,22 +38,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
38
38
  plugins: {
39
39
  annotation: {
40
40
  annotations: {
41
- <% holidays.each_with_index do |range, index| %>
42
- holiday<%= index %>: {
43
- drawTime: 'beforeDraw',
44
- type: 'box',
45
- xMin: '<%= range.begin %>T00:00:00',
46
- xMax: '<%= range.end %>T23:59:59',
47
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
48
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
49
- },
50
- <% end %>
41
+ <%= working_days_annotation %>
51
42
 
52
43
  <% if percentage_line_x %>
53
44
  line: {
54
45
  type: 'line',
55
- xMin: '<%= percentage_line_x %>',
56
- xMax: '<%= percentage_line_x %>',
46
+ scaleID: 'x',
47
+ value: '<%= percentage_line_x %>',
57
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
58
49
  borderWidth: 1,
59
50
  drawTime: 'afterDraw'
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
6
  {
7
7
  type: 'bar',
8
8
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
9
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
10
  datasets: <%= JSON.generate(data_sets) %>
11
11
  },
12
12
  options: {
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
22
  labelString: 'Date Completed'
23
23
  },
24
24
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
25
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
26
+ z: 1 // draw the grid lines on top of the bars
26
27
  },
28
+ stacked: true
27
29
  },
28
30
  y: {
29
31
  scaleLabel: {
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
37
  text: 'Age in days'
36
38
  },
37
39
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
40
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
41
+ z: 1 // draw the grid lines on top of the bars
39
42
  },
43
+ stacked: true,
44
+ max: <%= (@max_age * 1.1).to_i %>
40
45
  }
41
46
  },
42
47
  plugins: {
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
49
  callbacks: {
45
50
  label: function(context) {
46
51
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
52
+ let full_data = <%= @bar_data.inspect %>;
53
+ let columnIndex = context.dataIndex;
54
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
55
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
56
  }
49
57
  else {
50
- return context.dataset.data[context.dataIndex].title
58
+ return context.dataset.data[context.dataIndex].title;
51
59
  }
52
60
  }
53
61
  }
62
+ },
63
+ legend: {
64
+ labels: {
65
+ filter: function(item, chart) {
66
+ // Logic to remove a particular legend item goes here
67
+ return !item.text.includes('%');
68
+ }
69
+ }
54
70
  }
71
+
55
72
  }
56
73
  }
57
74
  });
@@ -1,11 +1,12 @@
1
1
  <table class='standard'>
2
2
  <thead>
3
3
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
4
+ <th title="Age in days">Age</th>
5
+ <th title="Expedited">E</th>
6
+ <th title="Blocked / Stalled">B/S</th>
7
7
  <th>Issue</th>
8
8
  <th>Status</th>
9
+ <th>Forecast</th>
9
10
  <th>Fix versions</th>
10
11
  <% if any_scrum_boards %>
11
12
  <th>Sprints</th>
@@ -41,6 +42,7 @@
41
42
  <% end %>
42
43
  </td>
43
44
  <td><%= format_status issue.status, board: issue.board %></td>
45
+ <td><%= dates_text(issue) %></td>
44
46
  <td><%= fix_versions_text(issue) %></td>
45
47
  <% if any_scrum_boards %>
46
48
  <td><%= sprints_text(issue) %></td>
@@ -1,6 +1,57 @@
1
1
  <div class="chart">
2
2
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
3
  </div>
4
+ <%
5
+ if show_stats
6
+ link_id = next_id
7
+ issues_id = next_id
8
+ %>
9
+ [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
+ <div id="<%= issues_id %>" style="display: none;">
11
+ <div>
12
+ <table class="standard">
13
+ <tr>
14
+ <th>Issue Type</th>
15
+ <th>Min</th>
16
+ <th>Max</th>
17
+ <th>Avg</th>
18
+ <th>Mode</th>
19
+ <% percentiles.each do |p| %>
20
+ <th><%= p %>th</th>
21
+ <% end %>
22
+ </tr>
23
+ <% the_stats.each do |k, v| %>
24
+ <tr>
25
+ <td><%= k %></td>
26
+ <td style="text-align: right;"><%= v[:min] %></td>
27
+ <td style="text-align: right;"><%= v[:max] %></td>
28
+ <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
29
+ <td><%= v[:mode].join(', ') %></td>
30
+ <% percentiles.each do |p| %>
31
+ <td style="text-align: right;"><%= v[:percentiles][p] %></td>
32
+ <% end %>
33
+ </tr>
34
+ <% end %>
35
+ </table>
36
+ </div>
37
+ <div>
38
+ <p>These statistics help understand the <i>"shape"</i> of the cycletime histogram distribution, to help us with predictions.</p>
39
+ <ul>
40
+ <li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
41
+ <li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
42
+ <li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
43
+ <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of cycletime values are X or less. Typical percentiles of interest are:</li>
44
+ <ul>
45
+ <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
46
+ <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
47
+ <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
48
+ </ul>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ <%
53
+ end
54
+ %>
4
55
  <script>
5
56
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
6
57
  {
@@ -21,6 +72,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
21
72
  grid: {
22
73
  color: <%= CssVariable['--grid-line-color'].to_json %>
23
74
  },
75
+ min: 0,
76
+ offset: false, // Gets rid of the ugly padding on left.
24
77
  },
25
78
  y: {
26
79
  stacked: true,
@@ -34,6 +87,27 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
34
87
  }
35
88
  },
36
89
  plugins: {
90
+ annotation: {
91
+ annotations: {
92
+ <%
93
+ results = the_stats[:all][:percentiles]
94
+ results.each do |percentile, value|
95
+ %>
96
+ percentile<%= percentile.to_s %>: {
97
+ type: 'line',
98
+ scaleID: 'x',
99
+ value: <%= value %>,
100
+ borderWidth: 1,
101
+ drawTime: 'beforeDatasetsDraw',
102
+ label: {
103
+ enabled: true,
104
+ content: '<%= "#{percentile}%" %>',
105
+ position: 'start',
106
+ }
107
+ },
108
+ <% end %>
109
+ },
110
+ },
37
111
  tooltip: {
38
112
  callbacks: {
39
113
  label: function(context) {
@@ -53,16 +53,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
53
53
  autocolors: false,
54
54
  annotation: {
55
55
  annotations: {
56
- <% holidays.each_with_index do |range, index| %>
57
- holiday<%= index %>: {
58
- drawTime: 'beforeDraw',
59
- type: 'box',
60
- xMin: '<%= range.begin %>T00:00:00',
61
- xMax: '<%= range.end %>T23:59:59',
62
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
63
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
64
- },
65
- <% end %>
56
+ <%= working_days_annotation %>
66
57
 
67
58
  <% @percentage_lines.each_with_index do |args, index| %>
68
59
  <% percent, color = args %>
@@ -50,16 +50,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
50
50
  },
51
51
  annotation: {
52
52
  annotations: {
53
- <% holidays.each_with_index do |range, index| %>
54
- holiday<%= index %>: {
55
- drawTime: 'beforeDraw',
56
- type: 'box',
57
- xMin: '<%= range.begin %>T00:00:00',
58
- xMax: '<%= range.end %>T23:59:59',
59
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
60
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
61
- },
62
- <% end %>
53
+ <%= working_days_annotation %>
63
54
  }
64
55
  },
65
56
  legend: {
@@ -55,16 +55,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
55
55
  autocolors: false,
56
56
  annotation: {
57
57
  annotations: {
58
- <% holidays.each_with_index do |range, index| %>
59
- holiday<%= index %>: {
60
- drawTime: 'beforeDraw',
61
- type: 'box',
62
- xMin: '<%= range.begin %>T00:00:00',
63
- xMax: '<%= range.end %>T23:59:59',
64
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
65
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
66
- },
67
- <% end %>
58
+ <%= working_days_annotation %>
68
59
  }
69
60
  }
70
61
  }
@@ -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;