jirametrics 2.24 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +3 -3
  3. data/lib/jirametrics/aging_work_table.rb +3 -4
  4. data/lib/jirametrics/atlassian_document_format.rb +8 -19
  5. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  6. data/lib/jirametrics/cfd_data_builder.rb +103 -0
  7. data/lib/jirametrics/chart_base.rb +6 -0
  8. data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
  9. data/lib/jirametrics/cycletime_histogram.rb +1 -1
  10. data/lib/jirametrics/cycletime_scatterplot.rb +2 -2
  11. data/lib/jirametrics/daily_view.rb +4 -3
  12. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  13. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  14. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  15. data/lib/jirametrics/daily_wip_chart.rb +3 -1
  16. data/lib/jirametrics/data_quality_report.rb +4 -4
  17. data/lib/jirametrics/dependency_chart.rb +1 -1
  18. data/lib/jirametrics/examples/standard_project.rb +12 -13
  19. data/lib/jirametrics/expedited_chart.rb +1 -1
  20. data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -1
  21. data/lib/jirametrics/github_gateway.rb +1 -1
  22. data/lib/jirametrics/grouping_rules.rb +1 -1
  23. data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
  24. data/lib/jirametrics/html/daily_wip_chart.erb +33 -1
  25. data/lib/jirametrics/html/index.css +3 -0
  26. data/lib/jirametrics/html/throughput_chart.erb +35 -1
  27. data/lib/jirametrics/html/time_based_histogram.erb +2 -0
  28. data/lib/jirametrics/html_report_config.rb +19 -15
  29. data/lib/jirametrics/issue.rb +18 -2
  30. data/lib/jirametrics/issue_printer.rb +1 -1
  31. data/lib/jirametrics/project_config.rb +1 -1
  32. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  33. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +81 -0
  34. data/lib/jirametrics/sprint_burndown.rb +1 -1
  35. data/lib/jirametrics/status.rb +1 -1
  36. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  37. data/lib/jirametrics/throughput_chart.rb +50 -22
  38. data/lib/jirametrics/time_based_scatterplot.rb +3 -3
  39. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab33e299bd374f28754ecfbd750f6afd89861ecf64a323f28d4678d7a22959d
4
- data.tar.gz: a0bf98e4d51b86eaa36aef8c6bfb69e8cdba958d3afeaf67da71a37ff6eb3ae5
3
+ metadata.gz: 615528aa77577881d7658b0ce059e9e3972ce4285aeb3c1081c4347dd1a20ca4
4
+ data.tar.gz: 483cc9b7535da95ca2249813e5ad8ff924334f65c8b7fa84c5705367a9d1b147
5
5
  SHA512:
6
- metadata.gz: 7c017b109ce143403190db03124148def9be7a92fe20961f6f7c42f459482a4056018601f131d6ca6275889e7231e54df54befbccb4279de0073838d50280ff4
7
- data.tar.gz: 07b783818d028d3d95fd1cd861f272fe624c46fe3d4b671f3e2e6fede8fcdc694e5118da888c7c12113838e37711ccd6935a54261d4edf5ceaac4e29c6a08a73
6
+ metadata.gz: 36acebeef4d036c6ed043f0073177944c390d5c80ba6ef162319f6a9bbb67bb2762ca4a345f4160d0f1d3fdc7047e257474920c09da9c5770c8a0e45a4748e59
7
+ data.tar.gz: 2c6f92cdb1d61b49ed7b318bc5c2108649ad387e9af51c312ade43f1522f3cc842dc949dde7d0373d41dc25630bbf9e5a3f883fc9255423ecd6e19c9008b3a4f
@@ -66,7 +66,7 @@ class AgingWorkBarChart < ChartBase
66
66
 
67
67
  def adjust_time_date_ranges_to_start_from_earliest_issue_start aging_issues
68
68
  earliest_start_time = aging_issues.collect do |issue|
69
- issue.board.cycletime.started_stopped_times(issue).first
69
+ issue.started_stopped_times.first
70
70
  end.min
71
71
  return if earliest_start_time.nil? || earliest_start_time >= @time_range.begin
72
72
 
@@ -102,7 +102,7 @@ class AgingWorkBarChart < ChartBase
102
102
 
103
103
  def select_aging_issues issues:
104
104
  issues.select do |issue|
105
- started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
105
+ started_time, stopped_time = issue.started_stopped_times
106
106
  next false unless started_time && stopped_time.nil?
107
107
 
108
108
  age = (date_range.end - started_time.to_date).to_i + 1
@@ -128,7 +128,7 @@ class AgingWorkBarChart < ChartBase
128
128
 
129
129
  def collect_status_ranges issue:, now:
130
130
  ranges = []
131
- issue_started_time = issue.board.cycletime.started_stopped_times(issue).first
131
+ issue_started_time = issue.started_stopped_times.first
132
132
  previous_start = nil
133
133
  previous_status = nil
134
134
  issue.status_changes.each do |change|
@@ -50,15 +50,14 @@ class AgingWorkTable < ChartBase
50
50
 
51
51
  def expedited_but_not_started
52
52
  @issues.select do |issue|
53
- started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
53
+ started_time, stopped_time = issue.started_stopped_times
54
54
  started_time.nil? && stopped_time.nil? && issue.expedited?
55
55
  end.sort_by(&:created)
56
56
  end
57
57
 
58
58
  def select_aging_issues
59
59
  aging_issues = @issues.select do |issue|
60
- cycletime = issue.board.cycletime
61
- started, stopped = cycletime.started_stopped_times(issue)
60
+ started, stopped = issue.started_stopped_times
62
61
  next false if started.nil? || stopped
63
62
  next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
64
63
 
@@ -77,7 +76,7 @@ class AgingWorkTable < ChartBase
77
76
  end
78
77
 
79
78
  def blocked_text issue
80
- started_time, _stopped_time = issue.board.cycletime.started_stopped_times(issue)
79
+ started_time, _stopped_time = issue.started_stopped_times
81
80
  return nil if started_time.nil?
82
81
 
83
82
  current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
@@ -44,9 +44,9 @@ class AtlassianDocumentFormat
44
44
  when 'codeBlock' then ['<code>', '</code>']
45
45
  when 'date'
46
46
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
47
- when 'decisionItem' then ['<li>', '</li>']
47
+ when 'decisionItem', 'listItem' then ['<li>', '</li>']
48
48
  when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
49
- when 'emoji' then [node_attrs['text'], nil]
49
+ when 'emoji', 'status' then [node_attrs['text'], nil]
50
50
  when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
51
51
  when 'hardBreak' then ['<br />', nil]
52
52
  when 'heading'
@@ -55,7 +55,6 @@ class AtlassianDocumentFormat
55
55
  when 'inlineCard'
56
56
  url = node_attrs['url']
57
57
  ["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
58
- when 'listItem' then ['<li>', '</li>']
59
58
  when 'media'
60
59
  text = node_attrs['alt'] || node_attrs['id']
61
60
  ["Media: #{text}", nil]
@@ -65,7 +64,6 @@ class AtlassianDocumentFormat
65
64
  when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
66
65
  when 'paragraph' then ['<p>', '</p>']
67
66
  when 'rule' then ['<hr />', nil]
68
- when 'status' then [node_attrs['text'], nil]
69
67
  when 'table' then ['<table>', '</table>']
70
68
  when 'tableCell' then ['<td>', '</td>']
71
69
  when 'tableHeader' then ['<th>', '</th>']
@@ -87,38 +85,29 @@ class AtlassianDocumentFormat
87
85
  adf_node_render(node) do |n|
88
86
  node_attrs = n['attrs']
89
87
  case n['type']
90
- when 'blockquote' then ['', nil]
91
- when 'bulletList' then ['', nil]
92
- when 'codeBlock' then ['', nil]
88
+ when 'blockquote', 'bulletList', 'codeBlock',
89
+ 'mediaSingle', 'mediaGroup',
90
+ 'orderedList', 'table', 'taskList' then ['', nil]
93
91
  when 'date'
94
92
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
95
93
  when 'decisionItem' then ['- ', "\n"]
96
94
  when 'decisionList' then ["Decisions:\n", nil]
97
- when 'emoji' then [node_attrs['text'], nil]
95
+ when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
98
96
  when 'expand' then ["#{node_attrs['title']}\n", nil]
99
97
  when 'hardBreak' then ["\n", nil]
100
- when 'heading' then ['', "\n"]
98
+ when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
101
99
  when 'inlineCard' then [node_attrs['url'], nil]
102
100
  when 'listItem' then ['- ', nil]
103
101
  when 'media'
104
102
  text = node_attrs['alt'] || node_attrs['id']
105
103
  ["Media: #{text}", nil]
106
- when 'mediaSingle', 'mediaGroup' then ['', nil]
107
- when 'mention' then [node_attrs['text'], nil]
108
- when 'orderedList' then ['', nil]
109
104
  when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
110
- when 'paragraph' then ['', "\n"]
111
105
  when 'rule' then ["---\n", nil]
112
- when 'status' then [node_attrs['text'], nil]
113
- when 'table' then ['', nil]
114
- when 'tableCell' then ['', "\t"]
115
- when 'tableHeader' then ['', "\t"]
116
- when 'tableRow' then ['', "\n"]
106
+ when 'tableCell', 'tableHeader' then ['', "\t"]
117
107
  when 'text' then [n['text'], nil]
118
108
  when 'taskItem'
119
109
  state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
120
110
  ["#{state} ", "\n"]
121
- when 'taskList' then ['', nil]
122
111
  else
123
112
  ["[Unparseable: #{n['type']}]\n", nil]
124
113
  end
@@ -10,7 +10,7 @@ class BoardMovementCalculator
10
10
  end
11
11
 
12
12
  def moves_backwards? issue
13
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
13
+ started, stopped = issue.started_stopped_times
14
14
  return false unless started
15
15
 
16
16
  previous_column = nil
@@ -70,7 +70,7 @@ class BoardMovementCalculator
70
70
  @issues.filter_map do |issue|
71
71
  this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
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)
73
+ issue_start, issue_done = issue.started_stopped_times
74
74
 
75
75
  # Skip if we can't tell when it started.
76
76
  next if issue_start.nil?
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CfdDataBuilder
4
+ def initialize board:, issues:, date_range:, columns: nil
5
+ @board = board
6
+ @issues = issues
7
+ @date_range = date_range
8
+ @columns = columns || board.visible_columns
9
+ end
10
+
11
+ def run
12
+ column_map = build_column_map
13
+ issue_states = @issues.map { |issue| process_issue(issue, column_map) }
14
+
15
+ {
16
+ columns: @columns.map(&:name),
17
+ daily_counts: build_daily_counts(issue_states),
18
+ correction_windows: issue_states.flat_map { |s| s[:correction_windows] }
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def build_column_map
25
+ map = {}
26
+ @columns.each_with_index do |column, index|
27
+ column.status_ids.each { |id| map[id] = index }
28
+ end
29
+ map
30
+ end
31
+
32
+ # Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
33
+ def process_issue issue, column_map
34
+ high_water_mark = nil
35
+ correction_open_since = nil
36
+ correction_windows = []
37
+ hwm_timeline = [] # sorted chronologically by date
38
+
39
+ issue.status_changes.each do |change|
40
+ col_index = column_map[change.value_id]
41
+ next if col_index.nil?
42
+
43
+ if high_water_mark.nil? || col_index > high_water_mark
44
+ # Forward movement: advance hwm, close any open correction window, record timeline entry
45
+ if correction_open_since
46
+ correction_windows << {
47
+ start_date: correction_open_since,
48
+ end_date: change.time.to_date,
49
+ column_index: high_water_mark
50
+ }
51
+ correction_open_since = nil
52
+ end
53
+ high_water_mark = col_index
54
+ hwm_timeline << [change.time.to_date, high_water_mark]
55
+ elsif col_index == high_water_mark && correction_open_since
56
+ # Same-column recovery: close the correction window without changing hwm or adding timeline entry
57
+ correction_windows << {
58
+ start_date: correction_open_since,
59
+ end_date: change.time.to_date,
60
+ column_index: high_water_mark
61
+ }
62
+ correction_open_since = nil
63
+ elsif col_index < high_water_mark
64
+ # Backwards movement: open correction window if not already open
65
+ correction_open_since ||= change.time.to_date
66
+ end
67
+ end
68
+
69
+ if correction_open_since
70
+ correction_windows << {
71
+ start_date: correction_open_since,
72
+ end_date: @date_range.end,
73
+ column_index: high_water_mark
74
+ }
75
+ end
76
+
77
+ { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
78
+ end
79
+
80
+ def hwm_at hwm_timeline, date
81
+ result = nil
82
+ hwm_timeline.each do |timeline_date, hwm|
83
+ break if timeline_date > date
84
+
85
+ result = hwm
86
+ end
87
+ result
88
+ end
89
+
90
+ def build_daily_counts issue_states
91
+ column_count = @columns.size
92
+ @date_range.each_with_object({}) do |date, result|
93
+ counts = Array.new(column_count, 0)
94
+ issue_states.each do |state|
95
+ hwm = hwm_at(state[:hwm_timeline], date)
96
+ next if hwm.nil?
97
+
98
+ (0..hwm).each { |i| counts[i] += 1 }
99
+ end
100
+ result[date] = counts
101
+ end
102
+ end
103
+ end
@@ -86,6 +86,12 @@ class ChartBase
86
86
  "#{hours} hour#{'s' unless hours == 1}"
87
87
  end
88
88
 
89
+ def label_minutes minutes
90
+ return 'unknown' if minutes.nil?
91
+
92
+ "#{minutes} minute#{'s' unless minutes == 1}"
93
+ end
94
+
89
95
  def label_issues count
90
96
  "#{count} issue#{'s' unless count == 1}"
91
97
  end
@@ -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
@@ -32,7 +32,7 @@ class CycletimeHistogram < TimeBasedHistogram
32
32
  stopped_issues = completed_issues_in_range include_unstarted: true
33
33
 
34
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
35
- stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
35
+ stopped_issues.select { |issue| issue.started_stopped_times.first }
36
36
  end
37
37
 
38
38
  def value_for_item issue
@@ -40,14 +40,14 @@ class CycletimeScatterplot < TimeBasedScatterplot
40
40
  end
41
41
 
42
42
  def x_value item
43
- item.board.cycletime.started_stopped_times(item).last
43
+ item.started_stopped_times.last
44
44
  end
45
45
 
46
46
  def y_value item
47
47
  item.board.cycletime.cycletime(item)
48
48
  end
49
49
 
50
- def title_value item
50
+ def title_value item, rules: nil
51
51
  hint = @issue_hints&.fetch(item, nil)
52
52
  "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
53
53
  end
@@ -36,7 +36,7 @@ class DailyView < ChartBase
36
36
 
37
37
  def select_aging_issues
38
38
  aging_issues = issues.select do |issue|
39
- started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
39
+ started_at, stopped_at = issue.started_stopped_times
40
40
  started_at && !stopped_at
41
41
  end
42
42
 
@@ -73,7 +73,7 @@ class DailyView < ChartBase
73
73
 
74
74
  def make_blocked_stalled_lines issue
75
75
  today = date_range.end
76
- started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
76
+ started_date = issue.started_stopped_times.first&.to_date
77
77
  return [] unless started_date
78
78
 
79
79
  blocked_stalled = issue.blocked_stalled_by_date(
@@ -256,7 +256,8 @@ class DailyView < ChartBase
256
256
  description = issue.raw['fields']['description']
257
257
  return [] unless description
258
258
 
259
- text = "<div class='foldable startFolded'>Description</div><div>#{atlassian_document_format.to_html(description)}</div>"
259
+ text = "<div class='foldable startFolded'>Description</div>" \
260
+ "<div>#{atlassian_document_format.to_html(description)}</div>"
260
261
  [[text]]
261
262
  end
262
263
 
@@ -49,7 +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)
52
+ started, stopped = issue.started_stopped_dates
53
53
 
54
54
  if stopped && started.nil? # We can't tell when it started
55
55
  @has_completed_but_not_started = true
@@ -39,7 +39,7 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
42
+ started, stopped = issue.started_stopped_times
43
43
  stopped_date = stopped&.to_date
44
44
  started_date = started&.to_date
45
45
 
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
26
26
  end
27
27
 
28
28
  def default_grouping_rules issue:, rules:
29
- parent = issue.parent&.key
29
+ parent = issue.parent
30
30
  if parent
31
- rules.label = parent
31
+ rules.label = parent.key
32
+ rules.label_hint = "#{parent.key} : #{parent.summary}"
32
33
  else
33
34
  rules.label = 'No parent'
35
+ rules.label_hint = 'No parent'
34
36
  rules.group_priority = 1000
35
37
  rules.color = '--body-background'
36
38
  end
@@ -104,7 +104,8 @@ class DailyWipChart < ChartBase
104
104
  .select { |_issue, rules| rules.group == grouping_rule.group }
105
105
  .sort_by { |issue, _rules| issue.key_as_i }
106
106
  .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
107
- title = ["#{display_label} (#{label_issues issue_strings.size})"] + issue_strings
107
+ title_label = grouping_rule.label_hint || display_label
108
+ title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
108
109
 
109
110
  {
110
111
  x: date,
@@ -123,6 +124,7 @@ class DailyWipChart < ChartBase
123
124
  {
124
125
  type: 'bar',
125
126
  label: display_label,
127
+ label_hint: grouping_rule.label_hint,
126
128
  data: data,
127
129
  backgroundColor: background_color,
128
130
  borderColor: CssVariable['--wip-chart-border-color'],
@@ -121,7 +121,7 @@ class DataQualityReport < ChartBase
121
121
 
122
122
  def initialize_entries
123
123
  @entries = @issues.filter_map do |issue|
124
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
124
+ started, stopped = issue.started_stopped_times
125
125
  next if stopped && stopped < time_range.begin
126
126
  next if started && started > time_range.end
127
127
 
@@ -274,7 +274,7 @@ class DataQualityReport < ChartBase
274
274
 
275
275
  started_subtasks = []
276
276
  entry.issue.subtasks.each do |subtask|
277
- started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
277
+ started_subtasks << subtask if subtask.started_stopped_times.first
278
278
  end
279
279
 
280
280
  return if started_subtasks.empty?
@@ -293,7 +293,7 @@ class DataQualityReport < ChartBase
293
293
  next unless settings['blocked_link_text'].include?(link.label)
294
294
 
295
295
  this_active = !entry.stopped
296
- other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
296
+ other_active = !link.other_issue.started_stopped_times.last
297
297
  next unless this_active && !other_active
298
298
 
299
299
  entry.report(
@@ -326,7 +326,7 @@ class DataQualityReport < ChartBase
326
326
  return unless entry.stopped
327
327
 
328
328
  subtask_labels = entry.issue.subtasks.filter_map do |subtask|
329
- subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
329
+ subtask_started, subtask_stopped = subtask.started_stopped_times
330
330
 
331
331
  if !subtask_started && !subtask_stopped
332
332
  "#{subtask_label subtask} (Not even started)"
@@ -231,7 +231,7 @@ class DependencyChart < ChartBase
231
231
  elsif is_done
232
232
  line2 << 'Done'
233
233
  else
234
- started_at = issue.board.cycletime.started_stopped_times(issue).first
234
+ started_at = issue.started_stopped_times.first
235
235
  if started_at.nil?
236
236
  line2 << 'Not started'
237
237
  else