jirametrics 2.22 → 2.24

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +15 -1
  4. data/lib/jirametrics/aging_work_table.rb +1 -1
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +104 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +20 -8
  9. data/lib/jirametrics/board_feature.rb +14 -0
  10. data/lib/jirametrics/change_item.rb +4 -3
  11. data/lib/jirametrics/chart_base.rb +87 -1
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  14. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  15. data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
  16. data/lib/jirametrics/daily_view.rb +32 -9
  17. data/lib/jirametrics/daily_wip_chart.rb +27 -7
  18. data/lib/jirametrics/data_quality_report.rb +31 -7
  19. data/lib/jirametrics/download_config.rb +15 -0
  20. data/lib/jirametrics/downloader.rb +76 -5
  21. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  22. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  23. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  24. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  25. data/lib/jirametrics/examples/standard_project.rb +20 -9
  26. data/lib/jirametrics/expedited_chart.rb +2 -0
  27. data/lib/jirametrics/exporter.rb +3 -1
  28. data/lib/jirametrics/file_system.rb +4 -0
  29. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
  30. data/lib/jirametrics/github_gateway.rb +106 -0
  31. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  32. data/lib/jirametrics/grouping_rules.rb +21 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  34. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  35. data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  39. data/lib/jirametrics/html/index.css +114 -0
  40. data/lib/jirametrics/html/index.erb +5 -0
  41. data/lib/jirametrics/html/index.js +52 -2
  42. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  43. data/lib/jirametrics/html/throughput_chart.erb +5 -8
  44. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
  45. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
  46. data/lib/jirametrics/html_report_config.rb +2 -0
  47. data/lib/jirametrics/issue.rb +84 -95
  48. data/lib/jirametrics/issue_printer.rb +97 -0
  49. data/lib/jirametrics/jira_gateway.rb +6 -3
  50. data/lib/jirametrics/project_config.rb +66 -6
  51. data/lib/jirametrics/pull_request.rb +30 -0
  52. data/lib/jirametrics/pull_request_review.rb +13 -0
  53. data/lib/jirametrics/raw_javascript.rb +4 -0
  54. data/lib/jirametrics/settings.json +3 -1
  55. data/lib/jirametrics/sprint_burndown.rb +2 -0
  56. data/lib/jirametrics/stitcher.rb +2 -1
  57. data/lib/jirametrics/throughput_chart.rb +7 -1
  58. data/lib/jirametrics/time_based_histogram.rb +139 -0
  59. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  60. metadata +12 -5
@@ -3,7 +3,7 @@
3
3
  class ChartBase
4
4
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
5
  :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
6
- :atlassian_document_format
6
+ :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
7
7
  attr_writer :aggregated_project
8
8
  attr_reader :canvas_width, :canvas_height
9
9
 
@@ -80,10 +80,20 @@ class ChartBase
80
80
  "#{days} day#{'s' unless days == 1}"
81
81
  end
82
82
 
83
+ def label_hours hours
84
+ return 'unknown' if hours.nil?
85
+
86
+ "#{hours} hour#{'s' unless hours == 1}"
87
+ end
88
+
83
89
  def label_issues count
84
90
  "#{count} issue#{'s' unless count == 1}"
85
91
  end
86
92
 
93
+ def to_human_readable number
94
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
95
+ end
96
+
87
97
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
88
98
  {
89
99
  type: 'bar',
@@ -155,6 +165,56 @@ class ChartBase
155
165
  end.join
156
166
  end
157
167
 
168
+ LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
169
+
170
+ def date_annotation
171
+ annotations = settings['date_annotations'] || []
172
+ in_range = annotations
173
+ .map { |a| [a, normalize_annotation_datetime(a['date'])] }
174
+ .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
175
+ .sort_by { |(_, dt)| dt }
176
+
177
+ positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
178
+
179
+ in_range.each_with_index.collect do |(a, normalized), index|
180
+ <<~TEXT
181
+ dateAnnotation#{index}: {
182
+ type: 'line',
183
+ xMin: #{normalized.to_json},
184
+ xMax: #{normalized.to_json},
185
+ borderColor: 'rgba(0,0,0,0.7)',
186
+ borderWidth: 1,
187
+ label: {
188
+ display: true,
189
+ content: #{a['label'].to_json},
190
+ position: #{positions[index].to_json}
191
+ }
192
+ },
193
+ TEXT
194
+ end.join
195
+ end
196
+
197
+ def stagger_label_positions datetimes
198
+ return [] if datetimes.empty?
199
+
200
+ threshold_days = (date_range.end - date_range.begin).to_f / 5.0
201
+ slot = 0
202
+ [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
203
+ days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
204
+ slot = days_apart < threshold_days ? slot + 1 : 0
205
+ LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
206
+ end
207
+ end
208
+
209
+ def normalize_annotation_datetime value
210
+ offset = timezone_offset || '+00:00'
211
+ if value.include?('T')
212
+ value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
213
+ else
214
+ "#{value}T00:00:00#{offset}"
215
+ end
216
+ end
217
+
158
218
  # Return only the board columns for the current board.
159
219
  def current_board
160
220
  if @board_id.nil?
@@ -245,6 +305,13 @@ class ChartBase
245
305
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
246
306
  end
247
307
 
308
+ def not_visible_text issue
309
+ reasons = issue.reasons_not_visible_on_board
310
+ return nil if reasons.empty?
311
+
312
+ "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
313
+ end
314
+
248
315
  def status_category_color status
249
316
  case status.category.key
250
317
  when 'new' then CssVariable['--status-category-todo-color']
@@ -310,4 +377,23 @@ class ChartBase
310
377
  def seam_end type = 'chart'
311
378
  "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
312
379
  end
380
+
381
+ def render_axis_title axis_direction
382
+ text = case axis_direction
383
+ when :x
384
+ x_axis_title
385
+ when :y
386
+ y_axis_title
387
+ else
388
+ raise "Unexpected axis_direction: #{axis_direction}"
389
+ end
390
+ return '' unless text
391
+
392
+ <<~CONTENT
393
+ title: {
394
+ display: true,
395
+ text: "#{text}"
396
+ },
397
+ CONTENT
398
+ end
313
399
  end
@@ -16,7 +16,7 @@ class CssVariable
16
16
  end
17
17
 
18
18
  def to_json(*_args)
19
- "getComputedStyle(document.body).getPropertyValue('#{@name}')"
19
+ "getComputedStyle(document.documentElement).getPropertyValue('#{@name}').trim()"
20
20
  end
21
21
 
22
22
  def to_s
@@ -6,10 +6,9 @@ require 'date'
6
6
  class CycleTimeConfig
7
7
  include SelfOrIssueDispatcher
8
8
 
9
- attr_reader :label, :possible_statuses, :settings, :file_system
9
+ attr_reader :label, :settings, :file_system
10
10
 
11
11
  def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
12
-
13
12
  @possible_statuses = possible_statuses
14
13
  @label = label
15
14
  @today = today
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
3
+ require 'jirametrics/time_based_histogram'
4
4
 
5
- class CycletimeHistogram < ChartBase
6
- include GroupableIssueChart
5
+ class CycletimeHistogram < TimeBasedHistogram
7
6
  attr_accessor :possible_statuses
8
- attr_reader :show_stats
9
7
 
10
8
  def initialize block
11
9
  super()
12
10
 
13
- percentiles [50, 85, 98]
14
- @show_stats = true
11
+ @x_axis_title = 'Cycletime in days'
12
+ @y_axis_title = 'Count'
15
13
 
16
14
  header_text 'Cycletime Histogram'
17
15
  description_text <<-HTML
@@ -30,112 +28,26 @@ class CycletimeHistogram < ChartBase
30
28
  end
31
29
  end
32
30
 
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
-
42
- def run
31
+ def all_items
43
32
  stopped_issues = completed_issues_in_range include_unstarted: true
44
33
 
45
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
46
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
47
- rules_to_issues = group_issues histogram_issues
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
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
-
58
- data_set_for(
59
- histogram_data: the_histogram,
60
- label: the_issue_type,
61
- color: rules.color
62
- )
63
- end
64
-
65
- if data_sets.empty?
66
- return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
67
- end
68
-
69
- wrap_and_render(binding, __FILE__)
35
+ stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
70
36
  end
71
37
 
72
- def histogram_data_for issues:
73
- count_hash = {}
74
- issues.each do |issue|
75
- days = issue.board.cycletime.cycletime(issue)
76
- count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
77
- end
78
- count_hash
38
+ def value_for_item issue
39
+ issue.board.cycletime.cycletime(issue)
79
40
  end
80
41
 
81
- def stats_for histogram_data:, percentiles:
82
- return {} if histogram_data.empty?
83
-
84
- total_values = histogram_data.values.sum
85
-
86
- # Calculate the average
87
- weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
88
- average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
89
-
90
- # Find the mode (or modes!) and the spread of the distribution
91
- sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
92
- max_freq = sorted_histogram[-1][1]
93
- mode = sorted_histogram.select { |_v, f| f == max_freq }
94
-
95
- minmax = histogram_data.keys.minmax
96
-
97
- # Calculate percentiles
98
- sorted_values = histogram_data.keys.sort
99
- cumulative_counts = {}
100
- cumulative_sum = 0
101
-
102
- sorted_values.each do |value|
103
- cumulative_sum += histogram_data[value]
104
- cumulative_counts[value] = cumulative_sum
105
- end
106
-
107
- percentile_results = {}
108
- percentiles.each do |percentile|
109
- rank = (percentile / 100.0) * total_values
110
- percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
111
- percentile_results[percentile] = percentile_value
112
- end
113
-
114
- {
115
- average: average,
116
- mode: mode.collect(&:first).sort,
117
- min: minmax[0],
118
- max: minmax[1],
119
- percentiles: percentile_results
120
- }
42
+ def title_for_item count:, value:
43
+ "#{count} items completed in #{label_days value}"
121
44
  end
122
45
 
123
- def data_set_for histogram_data:, label:, color:
124
- keys = histogram_data.keys.sort
125
- {
126
- type: 'bar',
127
- label: label,
128
- data: keys.sort.filter_map do |key|
129
- next if histogram_data[key].zero?
46
+ def sort_items items
47
+ items.sort_by(&:key_as_i)
48
+ end
130
49
 
131
- {
132
- x: key,
133
- y: histogram_data[key],
134
- title: "#{histogram_data[key]} items completed in #{label_days key}"
135
- }
136
- end,
137
- backgroundColor: color,
138
- borderRadius: 0
139
- }
50
+ def label_for_item issue, hint:
51
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
140
52
  end
141
53
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
4
-
5
- class CycletimeScatterplot < ChartBase
6
- include GroupableIssueChart
3
+ require 'jirametrics/time_based_scatterplot'
7
4
 
5
+ class CycletimeScatterplot < TimeBasedScatterplot
8
6
  attr_accessor :possible_statuses
9
7
 
10
8
  def initialize block
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
26
24
  </div>
27
25
  #{describe_non_working_days}
28
26
  HTML
27
+ @x_axis_title = 'Date completed'
28
+ @y_axis_title = 'Cycletime in days'
29
29
 
30
30
  init_configuration_block block do
31
31
  grouping_rules do |issue, rule|
@@ -33,9 +33,6 @@ class CycletimeScatterplot < ChartBase
33
33
  rule.color = color_for type: issue.type
34
34
  end
35
35
  end
36
-
37
- @percentage_lines = []
38
- @highest_y_value = 0
39
36
  end
40
37
 
41
38
  def all_items
@@ -51,96 +48,10 @@ class CycletimeScatterplot < ChartBase
51
48
  end
52
49
 
53
50
  def title_value item
54
- "#{item.key} : #{item.summary} (#{label_days(y_value(item))})"
55
- end
56
-
57
- def y_axis_heading
58
- 'Cycle time in days'
59
- end
60
-
61
- def run
62
- items = all_items
63
- data_sets = create_datasets items
64
- overall_percent_line = calculate_percent_line(items)
65
- @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
66
-
67
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
68
-
69
- wrap_and_render(binding, __FILE__)
70
- end
71
-
72
- def create_datasets items
73
- data_sets = []
74
-
75
- group_issues(items).each do |rules, completed_items_by_type|
76
- label = rules.label
77
- color = rules.color
78
- percent_line = calculate_percent_line completed_items_by_type
79
- data = completed_items_by_type.filter_map { |issue| data_for_issue(issue) }
80
- data_sets << {
81
- label: "#{label} (85% at #{label_days(percent_line)})",
82
- data: data,
83
- fill: false,
84
- showLine: false,
85
- backgroundColor: color
86
- }
87
-
88
- data_sets << trend_line_data_set(label: label, data: data, color: color)
89
-
90
- @percentage_lines << [percent_line, color]
91
- end
92
- data_sets
51
+ hint = @issue_hints&.fetch(item, nil)
52
+ "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
93
53
  end
94
54
 
95
- def show_trend_lines
96
- @show_trend_lines = true
97
- end
98
-
99
- def trend_line_data_set label:, data:, color:
100
- points = data.collect do |hash|
101
- [Time.parse(hash[:x]).to_i, hash[:y]]
102
- end
103
-
104
- # The trend calculation works with numbers only so convert Time to an int and back
105
- calculator = TrendLineCalculator.new(points)
106
- data_points = calculator.chart_datapoints(
107
- range: time_range.begin.to_i..time_range.end.to_i,
108
- max_y: @highest_y_value
109
- )
110
- data_points.each do |point_hash|
111
- point_hash[:x] = chart_format Time.at(point_hash[:x])
112
- end
113
-
114
- {
115
- type: 'line',
116
- label: "#{label} Trendline",
117
- data: data_points,
118
- fill: false,
119
- borderWidth: 1,
120
- markerType: 'none',
121
- borderColor: color,
122
- borderDash: [6, 3],
123
- pointStyle: 'dash',
124
- hidden: !@show_trend_lines
125
- }
126
- end
127
-
128
- def data_for_issue item
129
- cycle_time = y_value(item)
130
- return nil if cycle_time < 1 # These will get called out on the quality report
131
-
132
- @highest_y_value = cycle_time if @highest_y_value < cycle_time
133
-
134
- {
135
- y: cycle_time,
136
- x: chart_format(x_value(item)),
137
- title: [title_value(item)]
138
- }
139
- end
140
-
141
- def calculate_percent_line items
142
- times = items.collect { |item| y_value(item) }
143
- index = times.size * 85 / 100
144
- times.sort[index]
145
- end
55
+ # Kept for backwards compatibility with existing callers and specs
56
+ alias data_for_issue data_for_item
146
57
  end
@@ -9,7 +9,8 @@ class DailyView < ChartBase
9
9
  header_text 'Daily View'
10
10
  description_text <<-HTML
11
11
  <div class="p">
12
- This view shows all the items you'll want to discuss during your daily coordination meeting
12
+ This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
13
+ coordination meeting
13
14
  (aka daily scrum, standup), in the order that you should be discussing them. The most important
14
15
  items are at the top, and the least at the bottom.
15
16
  </div>
@@ -86,9 +87,14 @@ class DailyView < ChartBase
86
87
  lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
87
88
  lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
88
89
  blocked_stalled.blocking_issue_keys&.each do |key|
89
- lines << ["#{marker} Blocked by issue: #{key}"]
90
90
  blocking_issue = issues.find { |i| i.key == key }
91
- lines << blocking_issue if blocking_issue
91
+ if blocking_issue
92
+ lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: #{key}</div>"
93
+ lines << blocking_issue
94
+ lines << '</section>'
95
+ else
96
+ lines << ["#{marker} Blocked by issue: #{key}"]
97
+ end
92
98
  end
93
99
  elsif blocked_stalled.stalled_by_status?
94
100
  lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
@@ -146,7 +152,18 @@ class DailyView < ChartBase
146
152
  line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
147
153
  end
148
154
 
149
- line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
155
+ if issue.due_date
156
+ today = date_range.end
157
+ days = (issue.due_date - today).to_i
158
+ relative =
159
+ if days.zero? then 'today'
160
+ elsif days.positive? then "in #{label_days days}"
161
+ else "#{label_days(-days)} ago"
162
+ end
163
+ content = "#{issue.due_date} (#{relative})"
164
+ content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
165
+ line << "Due: <b>#{content}</b>"
166
+ end
150
167
 
151
168
  block = lambda do |collection, label|
152
169
  unless collection.empty?
@@ -166,7 +183,7 @@ class DailyView < ChartBase
166
183
 
167
184
  return lines if subtasks.empty?
168
185
 
169
- lines << '<section><div class="foldable">Child issues</div>'
186
+ lines << "<section><div class=\"foldable startFolded\">Child issues (#{subtasks.count})</div>"
170
187
  lines += subtasks
171
188
  lines << '</section>'
172
189
 
@@ -237,9 +254,10 @@ class DailyView < ChartBase
237
254
 
238
255
  def make_description_lines issue
239
256
  description = issue.raw['fields']['description']
240
- result = []
241
- result << [atlassian_document_format.to_html(description)] if description
242
- result
257
+ return [] unless description
258
+
259
+ text = "<div class='foldable startFolded'>Description</div><div>#{atlassian_document_format.to_html(description)}</div>"
260
+ [[text]]
243
261
  end
244
262
 
245
263
  def assemble_issue_lines issue, child:
@@ -247,6 +265,7 @@ class DailyView < ChartBase
247
265
 
248
266
  lines = []
249
267
  lines << [make_title_line(issue: issue, done: done)]
268
+ lines << make_not_visible_line(issue)
250
269
  lines += make_parent_lines(issue) unless child
251
270
  lines += make_stats_lines(issue: issue, done: done)
252
271
  unless done
@@ -256,7 +275,7 @@ class DailyView < ChartBase
256
275
  lines += make_child_lines(issue)
257
276
  lines += make_history_lines(issue)
258
277
  end
259
- lines
278
+ lines.compact
260
279
  end
261
280
 
262
281
  def render_issue issue, child:
@@ -278,4 +297,8 @@ class DailyView < ChartBase
278
297
  end
279
298
  result << '</div>'
280
299
  end
300
+
301
+ def make_not_visible_line issue
302
+ not_visible_text issue
303
+ end
281
304
  end
@@ -3,12 +3,16 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class DailyGroupingRules < GroupingRules
6
- attr_accessor :current_date, :group_priority, :issue_hint
6
+ attr_accessor :current_date, :group_priority, :issue_hint, :highlight
7
7
 
8
8
  def initialize
9
9
  super
10
10
  @group_priority = 0
11
11
  end
12
+
13
+ def group
14
+ [@label, @color, @highlight ? true : false]
15
+ end
12
16
  end
13
17
 
14
18
  class DailyWipChart < ChartBase
@@ -19,6 +23,8 @@ class DailyWipChart < ChartBase
19
23
 
20
24
  header_text default_header_text
21
25
  description_text default_description_text
26
+ @x_axis_title = nil
27
+ @y_axis_title = 'Count of items'
22
28
 
23
29
  instance_eval(&block) if block
24
30
 
@@ -33,8 +39,15 @@ class DailyWipChart < ChartBase
33
39
  issue_rules_by_active_date = group_issues_by_active_dates
34
40
  possible_rules = select_possible_rules issue_rules_by_active_date
35
41
 
42
+ conflicting_labels = possible_rules
43
+ .group_by(&:label)
44
+ .select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
45
+ .keys
46
+
36
47
  data_sets = possible_rules.collect do |grouping_rule|
37
- make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date
48
+ suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
49
+ make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
50
+ label_suffix: suffix
38
51
  end
39
52
  if @trend_lines
40
53
  data_sets = @trend_lines.filter_map do |group_labels, line_color|
@@ -82,16 +95,16 @@ class DailyWipChart < ChartBase
82
95
  hash
83
96
  end
84
97
 
85
- def make_data_set grouping_rule:, issue_rules_by_active_date:
98
+ def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
86
99
  positive = grouping_rule.group_priority >= 0
100
+ display_label = "#{grouping_rule.label}#{label_suffix}"
87
101
 
88
102
  data = issue_rules_by_active_date.collect do |date, issue_rules|
89
- # issues = []
90
103
  issue_strings = issue_rules
91
104
  .select { |_issue, rules| rules.group == grouping_rule.group }
92
105
  .sort_by { |issue, _rules| issue.key_as_i }
93
106
  .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
94
- title = ["#{grouping_rule.label} (#{label_issues issue_strings.size})"] + issue_strings
107
+ title = ["#{display_label} (#{label_issues issue_strings.size})"] + issue_strings
95
108
 
96
109
  {
97
110
  x: date,
@@ -100,11 +113,18 @@ class DailyWipChart < ChartBase
100
113
  }
101
114
  end
102
115
 
116
+ color = grouping_rule.color || random_color
117
+ background_color = if grouping_rule.highlight
118
+ RawJavascript.new("createDiagonalPattern(#{color.to_json})")
119
+ else
120
+ color
121
+ end
122
+
103
123
  {
104
124
  type: 'bar',
105
- label: grouping_rule.label,
125
+ label: display_label,
106
126
  data: data,
107
- backgroundColor: grouping_rule.color || random_color,
127
+ backgroundColor: background_color,
108
128
  borderColor: CssVariable['--wip-chart-border-color'],
109
129
  borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
110
130
  borderRadius: positive ? 0 : 5
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
45
45
  scan_for_completed_issues_without_a_start_time entry: entry
46
46
  scan_for_status_change_after_done entry: entry
47
47
  scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
48
+ scan_for_issue_not_in_active_sprint entry: entry
48
49
  scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
49
50
  scan_for_stopped_before_started entry: entry
50
51
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
68
69
  result << render_problem_type(:status_changes_after_done)
69
70
  result << render_problem_type(:backwards_through_status_categories)
70
71
  result << render_problem_type(:backwords_through_statuses)
71
- result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:issue_not_visible_on_board)
72
73
  result << render_problem_type(:created_in_wrong_status)
73
74
  result << render_problem_type(:stopped_before_started)
74
75
  result << render_problem_type(:issue_not_started_but_subtasks_have)
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
194
195
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
195
196
  detail = nil if backlog_statuses.any? { |s| s.name == change.value }
196
197
 
197
- entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
198
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
198
199
  elsif change.old_value.nil?
199
200
  # Do nothing
200
201
  elsif index < last_index
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
223
224
  end
224
225
  end
225
226
 
227
+ def scan_for_issue_not_in_active_sprint entry:
228
+ issue = entry.issue
229
+ return unless issue.board.scrum?
230
+ return if issue.sprints.any?(&:active?)
231
+
232
+ entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
233
+ end
234
+
235
+ def scan_for_issue_never_visible_on_board entry:
236
+ issue = entry.issue
237
+ ever_visible = issue.changes.any? do |change|
238
+ next unless change.status?
239
+
240
+ issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
241
+ end
242
+ return if ever_visible
243
+
244
+ entry.report(
245
+ problem_key: :issue_not_visible_on_board,
246
+ detail: 'Issue has never been in a status mapped to a visible column on the board'
247
+ )
248
+ end
249
+
226
250
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
251
  creation_change = entry.issue.changes.find { |issue| issue.status? }
228
252
 
@@ -295,7 +319,7 @@ class DataQualityReport < ChartBase
295
319
  return "#{delta} hours" if delta < 24
296
320
 
297
321
  delta /= 24
298
- "#{delta} days"
322
+ label_days delta
299
323
  end
300
324
 
301
325
  def scan_for_incomplete_subtasks_when_issue_done entry:
@@ -409,12 +433,12 @@ class DataQualityReport < ChartBase
409
433
  HTML
410
434
  end
411
435
 
412
- def render_status_not_on_board problems
436
+ def render_issue_not_visible_on_board problems
413
437
  <<-HTML
414
438
  #{label_issues problems.size} were not visible on the board for some period of time. This may impact
415
- timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
416
- mean in this context? The issue was in a status that is not mapped to any visible column on the board.
417
- Look in "unmapped statuses" on your board.
439
+ timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
440
+ for two reasons: the issue was in a status that is not mapped to any visible column on the board
441
+ (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
418
442
  HTML
419
443
  end
420
444