jirametrics 2.31 → 3.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -38,7 +38,7 @@ class ChartBase
38
38
  end
39
39
 
40
40
  def before_run
41
- @call_before_run_procs&.each { |proc| proc.call }
41
+ @call_before_run_procs&.each(&:call)
42
42
  end
43
43
 
44
44
  def aggregated_project?
@@ -53,12 +53,13 @@ class ChartBase
53
53
  def render caller_binding, file
54
54
  pathname = Pathname.new(File.realpath(file))
55
55
  basename = pathname.basename.to_s
56
- raise "Unexpected filename #{basename.inspect}" unless basename =~ /^(.+)\.rb$/
56
+ match = basename.match(/^(?<template_name>.+)\.rb$/)
57
+ raise "Unexpected filename #{basename.inspect}" unless match
57
58
 
58
59
  # Insert a incrementing chart_id so that all the chart names on the page are unique
59
60
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
60
61
 
61
- erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
62
+ erb = ERB.new file_system.load "#{html_directory}/#{match[:template_name]}.erb"
62
63
  erb.result(caller_binding)
63
64
  end
64
65
 
@@ -85,26 +86,14 @@ class ChartBase
85
86
  @chart_colors[type] ||= random_color
86
87
  end
87
88
 
88
- def label_days days
89
- return 'unknown' if days.nil?
89
+ # Defines label_days, label_hours, label_minutes and label_issues - each renders a pluralised count
90
+ # like "3 days" or "1 issue", or 'unknown' for a nil count. Generated because only the unit differs.
91
+ %w[day hour minute issue].each do |unit|
92
+ define_method "label_#{unit}s" do |count|
93
+ return 'unknown' if count.nil?
90
94
 
91
- "#{days} day#{'s' unless days == 1}"
92
- end
93
-
94
- def label_hours hours
95
- return 'unknown' if hours.nil?
96
-
97
- "#{hours} hour#{'s' unless hours == 1}"
98
- end
99
-
100
- def label_minutes minutes
101
- return 'unknown' if minutes.nil?
102
-
103
- "#{minutes} minute#{'s' unless minutes == 1}"
104
- end
105
-
106
- def label_issues count
107
- "#{count} issue#{'s' unless count == 1}"
95
+ "#{count} #{unit}#{'s' unless count == 1}"
96
+ end
108
97
  end
109
98
 
110
99
  def to_human_readable number
@@ -146,25 +135,17 @@ class ChartBase
146
135
  erb.result(binding)
147
136
  end
148
137
 
138
+ # Returns the non-working stretches (weekends and holidays) within the range, each as a Date range of
139
+ # consecutive days.
149
140
  def holidays date_range: @date_range
150
- result = []
151
- start_date = nil
152
- end_date = nil
153
-
154
- date_range.each do |date|
155
- if date.saturday? || date.sunday? || holiday_dates.include?(date)
156
- if start_date.nil?
157
- start_date = date
158
- else
159
- end_date = date
160
- end
161
- elsif start_date
162
- result << (start_date..(end_date || start_date))
163
- start_date = nil
164
- end_date = nil
165
- end
166
- end
167
- result
141
+ date_range
142
+ .select { |date| non_working_day?(date) }
143
+ .slice_when { |previous_date, date| date != previous_date + 1 }
144
+ .collect { |run| run.first..run.last }
145
+ end
146
+
147
+ def non_working_day? date
148
+ date.saturday? || date.sunday? || holiday_dates.include?(date)
168
149
  end
169
150
 
170
151
  def working_days_annotation
@@ -287,35 +268,40 @@ class ChartBase
287
268
  # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
288
269
  # Either way, is_category will format the category rather than the status
289
270
  def format_status object, board:, is_category: false, use_old_status: false
290
- status = nil
291
- error_message = nil
271
+ status, error_message = resolve_status object, board: board, use_old_status: use_old_status
272
+ return "<span style='color: red'>#{error_message}</span>" if error_message
292
273
 
274
+ color = status_category_color status
275
+ visibility = is_category ? '' : not_visible_icon(status, board)
276
+ text = is_category ? status.category : status
277
+ "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
278
+ end
279
+
280
+ # Resolves the object being formatted into [status, error_message]. A Status is itself; a ChangeItem is
281
+ # looked up by id, yielding an error message (the raw value) when the id isn't a known status.
282
+ def resolve_status object, board:, use_old_status:
293
283
  case object
294
284
  when ChangeItem
295
285
  id = use_old_status ? object.old_value_id : object.value_id
296
286
  status = board.possible_statuses.find_by_id(id)
297
- if status.nil?
298
- error_message = use_old_status ? object.old_value : object.value
299
- end
287
+ return [status, nil] unless status.nil?
288
+
289
+ [nil, use_old_status ? object.old_value : object.value]
300
290
  when Status
301
- status = object
291
+ [object, nil]
302
292
  else
303
293
  raise "Unexpected type: #{object.class}"
304
294
  end
295
+ end
305
296
 
306
- return "<span style='color: red'>#{error_message}</span>" if error_message
307
-
308
- color = status_category_color status
297
+ # The 👀 marker for a status that isn't mapped to any column, or '' when it is visible.
298
+ def not_visible_icon status, board
299
+ return '' if board.visible_columns.any? { |column| column.status_ids.include? status.id }
309
300
 
310
- visibility = ''
311
- if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
312
- visibility = icon_span(
313
- title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
314
- icon: ' 👀'
315
- )
316
- end
317
- text = is_category ? status.category : status
318
- "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
301
+ icon_span(
302
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
303
+ icon: ' 👀'
304
+ )
319
305
  end
320
306
 
321
307
  def icon_span title:, icon:
@@ -388,12 +374,12 @@ class ChartBase
388
374
  @cycletime || issue.board.cycletime
389
375
  end
390
376
 
391
- def seam_start type = 'chart'
392
- "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
393
- end
394
-
395
- def seam_end type = 'chart'
396
- "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
377
+ # seam_start and seam_end bracket each chart's HTML with a marker comment the stitcher uses to
378
+ # reassemble the report. Generated because only the start/end word differs.
379
+ %w[start end].each do |position|
380
+ define_method "seam_#{position}" do |type = 'chart'|
381
+ "\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
382
+ end
397
383
  end
398
384
 
399
385
  def render_axis_title axis_direction
@@ -35,7 +35,8 @@ class CumulativeFlowDiagram < ChartBase
35
35
  end
36
36
  private_constant :CfdColumnRules
37
37
 
38
- def initialize block
38
+ # Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
39
+ def initialize block # rubocop:disable Metrics/MethodLength
39
40
  super()
40
41
  header_text 'Cumulative Flow Diagram'
41
42
  description_text <<~HTML
@@ -71,8 +72,14 @@ class CumulativeFlowDiagram < ChartBase
71
72
  </div>
72
73
  <div class="p">
73
74
  See also: This article on <a href="https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/">how to read a CFD</a>.
74
- </div>
75
+ </div>#{' '}
75
76
  HTML
77
+ # Defaults come from CSS variables (theme handled in CSS). The config block below can still
78
+ # override any of them, or set one to nil to switch off that line/triangle.
79
+ @triangle_color = CssVariable['--cfd-triangle-color']
80
+ @arrival_rate_line_color = CssVariable['--cfd-arrival-rate-line-color']
81
+ @departure_rate_line_color = CssVariable['--cfd-departure-rate-line-color']
82
+
76
83
  instance_eval(&block)
77
84
  end
78
85
 
@@ -93,17 +100,7 @@ class CumulativeFlowDiagram < ChartBase
93
100
  end
94
101
 
95
102
  def run
96
- all_columns = current_board.visible_columns
97
-
98
- column_rules_list = all_columns.map do |column|
99
- rules = CfdColumnRules.new
100
- @column_rules_block&.call(column, rules)
101
- rules
102
- end
103
-
104
- active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
105
- active_columns = active_pairs.map(&:first)
106
- active_rules = active_pairs.map(&:last)
103
+ active_columns, active_rules = active_columns_and_rules(current_board.visible_columns)
107
104
 
108
105
  cfd = CfdDataBuilder.new(
109
106
  board: current_board,
@@ -117,36 +114,16 @@ class CumulativeFlowDiagram < ChartBase
117
114
  correction_windows = cfd[:correction_windows]
118
115
  column_count = columns.size
119
116
 
120
- # Convert cumulative totals to marginal band heights for Chart.js stacking.
121
- # cumulative[i] = issues that reached column i or further.
122
- # marginal[i] = cumulative[i] - cumulative[i+1] (last column: marginal = cumulative)
123
- daily_marginals = daily_counts.transform_values do |cumulative|
124
- cumulative.each_with_index.map do |count, i|
125
- i < column_count - 1 ? count - cumulative[i + 1] : count
126
- end
127
- end
117
+ daily_marginals = marginal_band_heights(daily_counts, column_count)
128
118
 
129
119
  border_colors = active_rules.map { |rules| rules.color || random_color }
130
120
 
131
121
  fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
132
122
 
133
- # Datasets in reversed order: rightmost column first (bottom of stack), leftmost last (top).
134
- data_sets = columns.each_with_index.map do |name, col_index|
135
- col_windows = correction_windows
136
- .select { |w| w[:column_index] == col_index }
137
- .map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
138
-
139
- {
140
- label: active_rules[col_index].label || name,
141
- label_hint: active_rules[col_index].label_hint,
142
- data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
143
- backgroundColor: fill_colors[col_index],
144
- borderColor: border_colors[col_index],
145
- fill: true,
146
- tension: 0,
147
- segment: Segment.new(col_windows)
148
- }
149
- end.reverse
123
+ data_sets = build_data_sets(
124
+ columns: columns, correction_windows: correction_windows, active_rules: active_rules,
125
+ daily_marginals: daily_marginals, fill_colors: fill_colors, border_colors: border_colors
126
+ )
150
127
 
151
128
  # Correction windows for the afterDraw hatch plugin, with dataset index in
152
129
  # Chart.js dataset array (reversed: done column = index 0).
@@ -160,19 +137,55 @@ class CumulativeFlowDiagram < ChartBase
160
137
  }
161
138
  end
162
139
 
163
- @triangle_color = parse_theme_color(['#333333', '#ffffff']) unless instance_variable_defined?(:@triangle_color)
164
- unless instance_variable_defined?(:@arrival_rate_line_color)
165
- @arrival_rate_line_color = 'rgba(255,138,101,0.85)'
166
- end
167
- unless instance_variable_defined?(:@departure_rate_line_color)
168
- @departure_rate_line_color = 'rgba(128,203,196,0.85)'
169
- end
170
-
171
140
  wrap_and_render(binding, __FILE__)
172
141
  end
173
142
 
174
143
  private
175
144
 
145
+ # Build each column's rules (through the config block, if one was given) and drop the ignored
146
+ # columns, returning the surviving columns and their rules as parallel arrays.
147
+ def active_columns_and_rules all_columns
148
+ column_rules_list = all_columns.map do |column|
149
+ rules = CfdColumnRules.new
150
+ @column_rules_block&.call(column, rules)
151
+ rules
152
+ end
153
+ active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
154
+ [active_pairs.map(&:first), active_pairs.map(&:last)]
155
+ end
156
+
157
+ # Convert per-date cumulative totals to marginal band heights for Chart.js stacking.
158
+ # cumulative[i] = issues that reached column i or further; marginal[i] = cumulative[i] -
159
+ # cumulative[i + 1] (the last column keeps its cumulative total).
160
+ def marginal_band_heights daily_counts, column_count
161
+ daily_counts.transform_values do |cumulative|
162
+ cumulative.each_with_index.map do |count, i|
163
+ i < column_count - 1 ? count - cumulative[i + 1] : count
164
+ end
165
+ end
166
+ end
167
+
168
+ # One Chart.js dataset per active column, in reversed order: rightmost column first (bottom of
169
+ # the stack), leftmost last (top).
170
+ def build_data_sets columns:, correction_windows:, active_rules:, daily_marginals:, fill_colors:, border_colors:
171
+ columns.each_with_index.map do |name, col_index|
172
+ col_windows = correction_windows
173
+ .select { |w| w[:column_index] == col_index }
174
+ .map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
175
+
176
+ {
177
+ label: active_rules[col_index].label || name,
178
+ label_hint: active_rules[col_index].label_hint,
179
+ data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
180
+ backgroundColor: fill_colors[col_index],
181
+ borderColor: border_colors[col_index],
182
+ fill: true,
183
+ tension: 0,
184
+ segment: Segment.new(col_windows)
185
+ }
186
+ end.reverse
187
+ end
188
+
176
189
  def parse_theme_color color
177
190
  return color unless color.is_a?(Array)
178
191
 
@@ -39,63 +39,52 @@ class CycleTimeConfig
39
39
  started_stopped_times(issue).last
40
40
  end
41
41
 
42
- def started_time issue
43
- @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
44
- started_stopped_times(issue).first
45
- end
46
-
47
- def stopped_time issue
48
- @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
49
- started_stopped_times(issue).last
50
- end
51
-
52
- def fabricate_change_item time
53
- @file_system.deprecated(
54
- date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
55
- )
56
- raw = {
57
- 'field' => 'Fabricated change',
58
- 'to' => '0',
59
- 'toString' => '',
60
- 'from' => '0',
61
- 'fromString' => ''
62
- }
63
- ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
64
- end
65
-
66
42
  def started_stopped_changes issue
67
43
  cache_key = "#{issue.key}:#{issue.board.id}"
68
44
  last_result = (@cache ||= {})[cache_key]
69
45
  return *last_result if last_result && settings['cache_cycletime_calculations']
70
46
 
71
- started = @start_at.call(issue)
72
- stopped = @stop_at.call(issue)
73
-
74
- # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
75
- # If they are false then explicitly make them nil.
76
- started ||= nil
77
- stopped ||= nil
78
-
79
- # These are only here for backwards compatibility. Hopefully nobody will ever need them.
80
- started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
81
- stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
82
-
83
- # In the case where started and stopped are exactly the same time, we pretend that
84
- # it just stopped and never started. This allows us to have logic like 'in or right of'
85
- # for the start and not have it conflict.
86
- started = nil if started&.time == stopped&.time
47
+ started = resolve_change(@start_at, issue)
48
+ stopped = resolve_change(@stop_at, issue)
49
+ started = collapse_zero_length_start(started, stopped)
87
50
 
88
51
  result = [started, stopped]
89
- if last_result && result != last_result
90
- @file_system.error(
91
- "Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
92
- "previous=#{last_result.inspect}"
93
- )
94
- end
52
+ warn_on_cache_mismatch(issue: issue, result: result, last_result: last_result)
95
53
  @cache[cache_key] = result
96
54
  result
97
55
  end
98
56
 
57
+ # start_at/stop_at blocks must return a ChangeItem or nil. A legacy false (meaning "not found") is
58
+ # still tolerated as nil, but a bare Time -- once accepted and wrapped -- is no longer supported.
59
+ def resolve_change block, issue
60
+ change = block.call(issue)
61
+ return nil unless change
62
+
63
+ unless change.is_a?(ChangeItem)
64
+ raise "A start_at/stop_at block must return a ChangeItem or nil but returned a #{change.class}. " \
65
+ 'If you were relying on a bare time such as `created`, use the ChangeItem form (e.g. `time_created`).'
66
+ end
67
+
68
+ change
69
+ end
70
+
71
+ # When start and stop land on the same instant, treat the issue as stopped-but-never-started so a
72
+ # zero-length span doesn't conflict with 'in or right of' start logic.
73
+ def collapse_zero_length_start started, stopped
74
+ return nil if started&.time == stopped&.time
75
+
76
+ started
77
+ end
78
+
79
+ def warn_on_cache_mismatch issue:, result:, last_result:
80
+ return unless last_result && result != last_result
81
+
82
+ @file_system.error(
83
+ "Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
84
+ "previous=#{last_result.inspect}"
85
+ )
86
+ end
87
+
99
88
  def started_stopped_times issue
100
89
  started, stopped = started_stopped_changes(issue)
101
90
  [started&.time, stopped&.time]
@@ -28,6 +28,13 @@ class CycletimeHistogram < TimeBasedHistogram
28
28
  end
29
29
  end
30
30
 
31
+ # Days-only until the working-days cycletime engine grows sub-day resolution. Inherited from
32
+ # TimeBasedChart so the option exists, but the unbuilt path fails loudly rather than mislabelling
33
+ # the axis while the value stays in days. See jirametrics-en5.
34
+ def cycletime_unit unit
35
+ raise NotImplementedError, "#{self.class} only supports :days for now (see jirametrics-en5)" unless unit == :days
36
+ end
37
+
31
38
  def all_items
32
39
  stopped_issues = completed_issues_in_range include_unstarted: true
33
40
 
@@ -35,6 +35,13 @@ class CycletimeScatterplot < TimeBasedScatterplot
35
35
  end
36
36
  end
37
37
 
38
+ # Days-only until the working-days cycletime engine grows sub-day resolution. Inherited from
39
+ # TimeBasedChart so the option exists, but the unbuilt path fails loudly rather than mislabelling
40
+ # the axis while the value stays in days. See jirametrics-en5.
41
+ def cycletime_unit unit
42
+ raise NotImplementedError, "#{self.class} only supports :days for now (see jirametrics-en5)" unless unit == :days
43
+ end
44
+
38
45
  def minimum_y_value
39
46
  1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
40
47
  end
@@ -24,7 +24,9 @@ class DailyView < ChartBase
24
24
  def run
25
25
  aging_issues = select_aging_issues
26
26
 
27
- return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>" if aging_issues.empty?
27
+ if aging_issues.empty?
28
+ return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>"
29
+ end
28
30
 
29
31
  result = +''
30
32
  result << render_top_text(binding)
@@ -35,10 +37,7 @@ class DailyView < ChartBase
35
37
  end
36
38
 
37
39
  def select_aging_issues
38
- aging_issues = issues.select do |issue|
39
- started_at, stopped_at = issue.started_stopped_times
40
- started_at && !stopped_at
41
- end
40
+ aging_issues = issues.select { |issue| issue.board.cycletime.in_progress?(issue) }
42
41
 
43
42
  today = date_range.end
44
43
  aging_issues.collect do |issue|
@@ -81,30 +80,40 @@ class DailyView < ChartBase
81
80
  )[today]
82
81
  return [] if blocked_stalled.active?
83
82
 
84
- lines = []
85
83
  if blocked_stalled.blocked?
86
- marker = color_block '--blocked-color'
87
- lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
88
- lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
89
- blocked_stalled.blocking_issue_keys&.each do |key|
90
- blocking_issue = issues.find_by_key key: key, include_hidden: true
91
- if blocking_issue
92
- lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
93
- "#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
94
- lines << blocking_issue
95
- lines << '</section>'
96
- else
97
- lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
98
- end
99
- end
84
+ blocked_lines blocked_stalled
100
85
  elsif blocked_stalled.stalled_by_status?
101
- lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
86
+ [["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]]
102
87
  else
103
- lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
88
+ [["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]]
89
+ end
90
+ end
91
+
92
+ def blocked_lines blocked_stalled
93
+ marker = color_block '--blocked-color'
94
+ lines = []
95
+ lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
96
+ lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
97
+ blocked_stalled.blocking_issue_keys&.each do |key|
98
+ lines.concat blocking_issue_lines(key, marker)
104
99
  end
105
100
  lines
106
101
  end
107
102
 
103
+ # The lines for one blocking issue: a foldable section embedding the issue when we have it, or a plain
104
+ # "no description found" line when we only know its key.
105
+ def blocking_issue_lines key, marker
106
+ blocking_issue = issues.find_by_key key: key, include_hidden: true
107
+ return [["#{marker} Blocked by issue: #{key} (no description found)"]] unless blocking_issue
108
+
109
+ [
110
+ "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
111
+ "#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>",
112
+ blocking_issue,
113
+ '</section>'
114
+ ]
115
+ end
116
+
108
117
  def make_issue_label issue:, done:
109
118
  label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
110
119
  label << '<s>' if done
@@ -133,17 +142,8 @@ class DailyView < ChartBase
133
142
 
134
143
  def make_stats_lines issue:, done:
135
144
  line = []
136
-
137
145
  line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
138
-
139
- if done
140
- cycletime = issue.board.cycletime.cycletime(issue)
141
-
142
- line << "Cycletime: <b>#{label_days cycletime}</b>"
143
- else
144
- age = issue.board.cycletime.age(issue, today: date_range.end)
145
- line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
146
- end
146
+ line << progress_line(issue, done)
147
147
  line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
148
148
 
149
149
  column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
@@ -153,29 +153,47 @@ class DailyView < ChartBase
153
153
  line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
154
154
  end
155
155
 
156
- if issue.due_date
157
- today = date_range.end
158
- days = (issue.due_date - today).to_i
159
- relative =
160
- if days.zero? then 'today'
161
- elsif days.positive? then "in #{label_days days}"
162
- else "#{label_days(-days)} ago"
163
- end
164
- content = "#{issue.due_date} (#{relative})"
165
- content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
166
- line << "Due: <b>#{content}</b>"
156
+ due = due_date_line(issue)
157
+ line << due if due
158
+ line.concat label_lines(issue)
159
+
160
+ [line]
161
+ end
162
+
163
+ # 'Cycletime: ...' for finished issues, otherwise 'Age: ...' (or '(Not Started)' when it hasn't started).
164
+ def progress_line issue, done
165
+ if done
166
+ "Cycletime: <b>#{label_days issue.board.cycletime.cycletime(issue)}</b>"
167
+ else
168
+ age = issue.board.cycletime.age(issue, today: date_range.end)
169
+ "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
167
170
  end
171
+ end
172
+
173
+ # 'Due: ...' with a relative 'today/in N days/N days ago' hint, highlighted when overdue. nil when
174
+ # the issue has no due date.
175
+ def due_date_line issue
176
+ return nil unless issue.due_date
168
177
 
169
- block = lambda do |collection, label|
170
- unless collection.empty?
171
- text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
172
- line << "#{label} #{text}"
178
+ days = (issue.due_date - date_range.end).to_i
179
+ relative =
180
+ if days.zero? then 'today'
181
+ elsif days.positive? then "in #{label_days days}"
182
+ else "#{label_days(-days)} ago"
173
183
  end
174
- end
175
- block.call issue.labels, 'Labels:'
176
- block.call issue.component_names, 'Components:'
184
+ content = "#{issue.due_date} (#{relative})"
185
+ content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
186
+ "Due: <b>#{content}</b>"
187
+ end
177
188
 
178
- [line]
189
+ # One 'Labels: ...' and/or 'Components: ...' line, each listing its non-empty collection as label spans.
190
+ def label_lines issue
191
+ { 'Labels:' => issue.labels, 'Components:' => issue.component_names }.filter_map do |label, collection|
192
+ next if collection.empty?
193
+
194
+ spans = collection.collect { |item| "<span class='label'>#{item}</span>" }.join(' ')
195
+ "#{label} #{spans}"
196
+ end
179
197
  end
180
198
 
181
199
  def make_child_lines issue
@@ -215,19 +233,10 @@ class DailyView < ChartBase
215
233
  end
216
234
 
217
235
  def history_text change:, board:
218
- convertor = ->(value, _id) { value.inspect }
219
- convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
220
-
221
236
  if change.comment? || change.description?
222
237
  atlassian_document_format.to_html(change.value)
223
238
  elsif %w[status priority assignee duedate issuetype].include?(change.field)
224
- to = convertor.call(change.value, change.value_id)
225
- if change.old_value
226
- from = convertor.call(change.old_value, change.old_value_id)
227
- "Changed from #{from} to #{to}"
228
- else
229
- "Set to #{to}"
230
- end
239
+ field_change_text change, board
231
240
  elsif change.flagged?
232
241
  change.value == '' ? 'Off' : 'On'
233
242
  else
@@ -235,6 +244,19 @@ class DailyView < ChartBase
235
244
  end
236
245
  end
237
246
 
247
+ # 'Set to X' or 'Changed from Y to X' for a status/priority/assignee/duedate/issuetype change. Status
248
+ # values are rendered as coloured status labels; everything else is shown inspected.
249
+ def field_change_text change, board
250
+ convertor = ->(value, _id) { value.inspect }
251
+ convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
252
+
253
+ to = convertor.call(change.value, change.value_id)
254
+ return "Set to #{to}" unless change.old_value
255
+
256
+ from = convertor.call(change.old_value, change.old_value_id)
257
+ "Changed from #{from} to #{to}"
258
+ end
259
+
238
260
  def make_sprints_lines issue
239
261
  return [] unless issue.board.scrum?
240
262