jirametrics 2.20.1 → 2.25

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 (79) 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 +189 -133
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  7. data/lib/jirametrics/bar_chart_range.rb +17 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  9. data/lib/jirametrics/board.rb +24 -8
  10. data/lib/jirametrics/board_config.rb +2 -1
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +103 -0
  14. data/lib/jirametrics/change_item.rb +13 -5
  15. data/lib/jirametrics/chart_base.rb +124 -1
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +15 -85
  21. data/lib/jirametrics/daily_view.rb +35 -11
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  26. data/lib/jirametrics/data_quality_report.rb +37 -11
  27. data/lib/jirametrics/dependency_chart.rb +1 -1
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +76 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  31. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  34. data/lib/jirametrics/examples/standard_project.rb +28 -18
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +7 -3
  37. data/lib/jirametrics/file_system.rb +4 -0
  38. data/lib/jirametrics/fix_version.rb +13 -0
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +106 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +9 -1
  42. data/lib/jirametrics/grouping_rules.rb +21 -3
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  44. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
  45. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  46. data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
  47. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  48. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  49. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  50. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  51. data/lib/jirametrics/html/index.css +134 -0
  52. data/lib/jirametrics/html/index.erb +6 -1
  53. data/lib/jirametrics/html/index.js +76 -2
  54. data/lib/jirametrics/html/sprint_burndown.erb +12 -12
  55. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +8 -9
  58. data/lib/jirametrics/html_generator.rb +31 -0
  59. data/lib/jirametrics/html_report_config.rb +26 -39
  60. data/lib/jirametrics/issue.rb +186 -88
  61. data/lib/jirametrics/issue_printer.rb +97 -0
  62. data/lib/jirametrics/jira_gateway.rb +6 -3
  63. data/lib/jirametrics/project_config.rb +78 -8
  64. data/lib/jirametrics/pull_request.rb +30 -0
  65. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  66. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +81 -0
  67. data/lib/jirametrics/pull_request_review.rb +13 -0
  68. data/lib/jirametrics/raw_javascript.rb +17 -0
  69. data/lib/jirametrics/settings.json +3 -1
  70. data/lib/jirametrics/sprint.rb +12 -0
  71. data/lib/jirametrics/sprint_burndown.rb +9 -3
  72. data/lib/jirametrics/status.rb +1 -1
  73. data/lib/jirametrics/stitcher.rb +76 -0
  74. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  75. data/lib/jirametrics/throughput_chart.rb +56 -22
  76. data/lib/jirametrics/time_based_histogram.rb +139 -0
  77. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  78. data/lib/jirametrics.rb +8 -1
  79. metadata +22 -5
@@ -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
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/cfd_data_builder'
4
+
5
+ class CumulativeFlowDiagram < ChartBase
6
+ # Used to embed a Chart.js segment callback (which contains JS functions) into
7
+ # a JSON-like dataset object. The custom to_json emits raw JS rather than a
8
+ # quoted string, following the same pattern as ExpeditedChart::EXPEDITED_SEGMENT.
9
+ class Segment
10
+ def initialize windows
11
+ # Build a JS array literal of [start_date, end_date] string pairs
12
+ @windows_js = windows
13
+ .map { |w| "[#{w[:start_date].to_json}, #{w[:end_date].to_json}]" }
14
+ .join(', ')
15
+ end
16
+
17
+ def to_json *_args
18
+ <<~JS
19
+ {
20
+ borderDash: function(ctx) {
21
+ const x = ctx.p1.parsed.x;
22
+ const windows = [#{@windows_js}];
23
+ return windows.some(function(w) {
24
+ return x >= new Date(w[0]).getTime() && x <= new Date(w[1]).getTime();
25
+ }) ? [6, 4] : undefined;
26
+ }
27
+ }
28
+ JS
29
+ end
30
+ end
31
+ private_constant :Segment
32
+
33
+ class CfdColumnRules < Rules
34
+ attr_accessor :color, :label, :label_hint
35
+ end
36
+ private_constant :CfdColumnRules
37
+
38
+ def initialize block
39
+ super()
40
+ header_text 'Cumulative Flow Diagram'
41
+ description_text <<~HTML
42
+ <div class="p">
43
+ A Cumulative Flow Diagram (CFD) shows how work accumulates across board columns over time.
44
+ Each coloured band represents a workflow stage. The top edge of the leftmost band shows
45
+ total work entered; the top edge of the rightmost band shows total work completed.
46
+ </div>
47
+ <div class="p">
48
+ A widening band means work is piling up in that stage — a bottleneck. Parallel top edges
49
+ (bands staying the same width) indicate smooth flow. Steep rises in the leftmost band
50
+ without corresponding rises on the right mean new work is arriving faster than it is
51
+ being finished.
52
+ </div>
53
+ <div class="p">
54
+ Dashed lines and hatched regions indicate periods where an item moved backwards through
55
+ the workflow (a correction). These highlight rework or process irregularities worth
56
+ investigating.
57
+ </div>
58
+ <div class="p">
59
+ The chart also overlays two trend lines and an interactive triangle. The <b>arrival rate</b>
60
+ trend line shows how fast work is entering the system; the <b>departure rate</b> trend line
61
+ shows how fast it is leaving. Move the mouse over the chart to see a Little's Law triangle
62
+ at that point in time, labelled with three derived metrics: <b>Work In Progress (WIP)</b> (items started
63
+ but not finished), <b>approximate average cycle time (CT)</b> (roughly how long an average item takes to complete), and
64
+ <b>average throughput (TP)</b> (items completed per day). Use the checkbox above the chart to toggle
65
+ between the triangle and the normal data tooltips.
66
+ </div>
67
+ HTML
68
+ instance_eval(&block)
69
+ end
70
+
71
+ def column_rules &block
72
+ @column_rules_block = block
73
+ end
74
+
75
+ def triangle_color color
76
+ @triangle_color = parse_theme_color(color)
77
+ end
78
+
79
+ def arrival_rate_line_color color
80
+ @arrival_rate_line_color = parse_theme_color(color)
81
+ end
82
+
83
+ def departure_rate_line_color color
84
+ @departure_rate_line_color = parse_theme_color(color)
85
+ end
86
+
87
+ def run
88
+ all_columns = current_board.visible_columns
89
+
90
+ column_rules_list = all_columns.map do |column|
91
+ rules = CfdColumnRules.new
92
+ @column_rules_block&.call(column, rules)
93
+ rules
94
+ end
95
+
96
+ active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
97
+ active_columns = active_pairs.map(&:first)
98
+ active_rules = active_pairs.map(&:last)
99
+
100
+ cfd = CfdDataBuilder.new(
101
+ board: current_board,
102
+ issues: issues,
103
+ date_range: date_range,
104
+ columns: active_columns
105
+ ).run
106
+
107
+ columns = cfd[:columns]
108
+ daily_counts = cfd[:daily_counts]
109
+ correction_windows = cfd[:correction_windows]
110
+ column_count = columns.size
111
+
112
+ # Convert cumulative totals to marginal band heights for Chart.js stacking.
113
+ # cumulative[i] = issues that reached column i or further.
114
+ # marginal[i] = cumulative[i] - cumulative[i+1] (last column: marginal = cumulative)
115
+ daily_marginals = daily_counts.transform_values do |cumulative|
116
+ cumulative.each_with_index.map do |count, i|
117
+ i < column_count - 1 ? count - cumulative[i + 1] : count
118
+ end
119
+ end
120
+
121
+ border_colors = active_rules.map { |rules| rules.color || random_color }
122
+
123
+ fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
124
+
125
+ # Datasets in reversed order: rightmost column first (bottom of stack), leftmost last (top).
126
+ data_sets = columns.each_with_index.map do |name, col_index|
127
+ col_windows = correction_windows
128
+ .select { |w| w[:column_index] == col_index }
129
+ .map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
130
+
131
+ {
132
+ label: active_rules[col_index].label || name,
133
+ label_hint: active_rules[col_index].label_hint,
134
+ data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
135
+ backgroundColor: fill_colors[col_index],
136
+ borderColor: border_colors[col_index],
137
+ fill: true,
138
+ tension: 0,
139
+ segment: Segment.new(col_windows)
140
+ }
141
+ end.reverse
142
+
143
+ # Correction windows for the afterDraw hatch plugin, with dataset index in
144
+ # Chart.js dataset array (reversed: done column = index 0).
145
+ hatch_windows = correction_windows.map do |w|
146
+ {
147
+ dataset_index: column_count - 1 - w[:column_index],
148
+ start_date: w[:start_date].to_s,
149
+ end_date: w[:end_date].to_s,
150
+ color: border_colors[w[:column_index]],
151
+ fill_color: fill_colors[w[:column_index]]
152
+ }
153
+ end
154
+
155
+ @triangle_color = parse_theme_color(['#333333', '#ffffff']) unless instance_variable_defined?(:@triangle_color)
156
+ unless instance_variable_defined?(:@arrival_rate_line_color)
157
+ @arrival_rate_line_color = 'rgba(255,138,101,0.85)'
158
+ end
159
+ unless instance_variable_defined?(:@departure_rate_line_color)
160
+ @departure_rate_line_color = 'rgba(128,203,196,0.85)'
161
+ end
162
+
163
+ wrap_and_render(binding, __FILE__)
164
+ end
165
+
166
+ private
167
+
168
+ def parse_theme_color color
169
+ return color unless color.is_a?(Array)
170
+
171
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
172
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
173
+
174
+ if color.any? { |c| c.start_with?('--') }
175
+ raise ArgumentError,
176
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
177
+ end
178
+
179
+ light, dark = color
180
+ RawJavascript.new(
181
+ "(document.documentElement.dataset.theme === 'dark' || " \
182
+ '(!document.documentElement.dataset.theme && ' \
183
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
184
+ "? #{dark.to_json} : #{light.to_json}"
185
+ )
186
+ end
187
+
188
+ def hex_to_rgba hex, alpha
189
+ r, g, b = hex.delete_prefix('#').scan(/../).map { |c| c.to_i(16) }
190
+ "rgba(#{r}, #{g}, #{b}, #{alpha})"
191
+ end
192
+
193
+ def fill_color_for rules, border
194
+ if rules.color.nil? || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
195
+ hex_to_rgba(border, 0.35)
196
+ else
197
+ rules.color
198
+ end
199
+ end
200
+ end
@@ -6,15 +6,13 @@ require 'date'
6
6
  class CycleTimeConfig
7
7
  include SelfOrIssueDispatcher
8
8
 
9
- attr_reader :label, :parent_config, :settings, :file_system
9
+ attr_reader :label, :settings, :file_system
10
10
 
11
- def initialize parent_config:, label:, block:, settings:, file_system: nil, today: Date.today
12
-
13
- @parent_config = parent_config
11
+ def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
12
+ @possible_statuses = possible_statuses
14
13
  @label = label
15
14
  @today = today
16
15
  @settings = settings
17
- @cache_cycletime_calculations = settings['cache_cycletime_calculations']
18
16
 
19
17
  # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
20
18
  # may make it easier to find problems in the test code ;-)
@@ -68,7 +66,7 @@ class CycleTimeConfig
68
66
  def started_stopped_changes issue
69
67
  cache_key = "#{issue.key}:#{issue.board.id}"
70
68
  last_result = (@cache ||= {})[cache_key]
71
- return *last_result if last_result && @cache_cycletime_calculations
69
+ return *last_result if last_result && settings['cache_cycletime_calculations']
72
70
 
73
71
  started = @start_at.call(issue)
74
72
  stopped = @stop_at.call(issue)
@@ -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.started_stopped_times.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,95 +33,25 @@ class CycletimeScatterplot < ChartBase
33
33
  rule.color = color_for type: issue.type
34
34
  end
35
35
  end
36
-
37
- @percentage_lines = []
38
- @highest_cycletime = 0
39
36
  end
40
37
 
41
- def run
42
- completed_issues = completed_issues_in_range include_unstarted: false
43
-
44
- data_sets = create_datasets completed_issues
45
- overall_percent_line = calculate_percent_line(completed_issues)
46
- @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
47
-
48
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
49
-
50
- wrap_and_render(binding, __FILE__)
51
- end
52
-
53
- def create_datasets completed_issues
54
- data_sets = []
55
-
56
- group_issues(completed_issues).each do |rules, completed_issues_by_type|
57
- label = rules.label
58
- color = rules.color
59
- percent_line = calculate_percent_line completed_issues_by_type
60
- data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
61
- data_sets << {
62
- label: "#{label} (85% at #{label_days(percent_line)})",
63
- data: data,
64
- fill: false,
65
- showLine: false,
66
- backgroundColor: color
67
- }
68
-
69
- data_sets << trend_line_data_set(label: label, data: data, color: color)
70
-
71
- @percentage_lines << [percent_line, color]
72
- end
73
- data_sets
38
+ def all_items
39
+ completed_issues_in_range include_unstarted: false
74
40
  end
75
41
 
76
- def show_trend_lines
77
- @show_trend_lines = true
42
+ def x_value item
43
+ item.started_stopped_times.last
78
44
  end
79
45
 
80
- def trend_line_data_set label:, data:, color:
81
- points = data.collect do |hash|
82
- [Time.parse(hash[:x]).to_i, hash[:y]]
83
- end
84
-
85
- # The trend calculation works with numbers only so convert Time to an int and back
86
- calculator = TrendLineCalculator.new(points)
87
- data_points = calculator.chart_datapoints(
88
- range: time_range.begin.to_i..time_range.end.to_i,
89
- max_y: @highest_cycletime
90
- )
91
- data_points.each do |point_hash|
92
- point_hash[:x] = chart_format Time.at(point_hash[:x])
93
- end
94
-
95
- {
96
- type: 'line',
97
- label: "#{label} Trendline",
98
- data: data_points,
99
- fill: false,
100
- borderWidth: 1,
101
- markerType: 'none',
102
- borderColor: color,
103
- borderDash: [6, 3],
104
- pointStyle: 'dash',
105
- hidden: !@show_trend_lines
106
- }
46
+ def y_value item
47
+ item.board.cycletime.cycletime(item)
107
48
  end
108
49
 
109
- def data_for_issue issue
110
- cycle_time = issue.board.cycletime.cycletime(issue)
111
- return nil if cycle_time < 1 # These will get called out on the quality report
112
-
113
- @highest_cycletime = cycle_time if @highest_cycletime < cycle_time
114
-
115
- {
116
- y: cycle_time,
117
- x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
118
- title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
119
- }
50
+ def title_value item, rules: nil
51
+ hint = @issue_hints&.fetch(item, nil)
52
+ "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
120
53
  end
121
54
 
122
- def calculate_percent_line completed_issues
123
- times = completed_issues.collect { |issue| issue.board.cycletime.cycletime(issue) }
124
- index = times.size * 85 / 100
125
- times.sort[index]
126
- end
55
+ # Kept for backwards compatibility with existing callers and specs
56
+ alias data_for_issue data_for_item
127
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>
@@ -35,7 +36,7 @@ class DailyView < ChartBase
35
36
 
36
37
  def select_aging_issues
37
38
  aging_issues = issues.select do |issue|
38
- started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
39
+ started_at, stopped_at = issue.started_stopped_times
39
40
  started_at && !stopped_at
40
41
  end
41
42
 
@@ -72,7 +73,7 @@ class DailyView < ChartBase
72
73
 
73
74
  def make_blocked_stalled_lines issue
74
75
  today = date_range.end
75
- started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
76
+ started_date = issue.started_stopped_times.first&.to_date
76
77
  return [] unless started_date
77
78
 
78
79
  blocked_stalled = issue.blocked_stalled_by_date(
@@ -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,11 @@ 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>" \
260
+ "<div>#{atlassian_document_format.to_html(description)}</div>"
261
+ [[text]]
243
262
  end
244
263
 
245
264
  def assemble_issue_lines issue, child:
@@ -247,6 +266,7 @@ class DailyView < ChartBase
247
266
 
248
267
  lines = []
249
268
  lines << [make_title_line(issue: issue, done: done)]
269
+ lines << make_not_visible_line(issue)
250
270
  lines += make_parent_lines(issue) unless child
251
271
  lines += make_stats_lines(issue: issue, done: done)
252
272
  unless done
@@ -256,7 +276,7 @@ class DailyView < ChartBase
256
276
  lines += make_child_lines(issue)
257
277
  lines += make_history_lines(issue)
258
278
  end
259
- lines
279
+ lines.compact
260
280
  end
261
281
 
262
282
  def render_issue issue, child:
@@ -278,4 +298,8 @@ class DailyView < ChartBase
278
298
  end
279
299
  result << '</div>'
280
300
  end
301
+
302
+ def make_not_visible_line issue
303
+ not_visible_text issue
304
+ end
281
305
  end
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- started, stopped = issue.board.cycletime.started_stopped_dates(issue)
53
-
54
- rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
52
+ started, stopped = issue.started_stopped_dates
55
53
 
56
54
  if stopped && started.nil? # We can't tell when it started
57
55
  @has_completed_but_not_started = true
@@ -72,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
72
70
  rules.label = 'Start date unknown'
73
71
  rules.color = '--body-background'
74
72
  rules.group_priority = 11
75
- created_days = rules.current_date - created + 1
73
+ created_days = rules.current_date - created
76
74
  rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
77
75
  end
78
76
  end
@@ -84,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
84
82
  end
85
83
 
86
84
  def group_by_age started:, rules:
87
- age = rules.current_date - started + 1
85
+ age = (rules.current_date - started).to_i + 1
86
+ rules.issue_hint = "(age: #{label_days age})"
88
87
 
89
88
  case age
90
89
  when 1