jirametrics 2.22 → 2.30

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +26 -10
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  10. data/lib/jirametrics/board.rb +28 -8
  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 +108 -0
  14. data/lib/jirametrics/change_item.rb +4 -3
  15. data/lib/jirametrics/chart_base.rb +107 -3
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  21. data/lib/jirametrics/daily_view.rb +38 -13
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  26. data/lib/jirametrics/data_quality_report.rb +38 -12
  27. data/lib/jirametrics/dependency_chart.rb +2 -2
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +87 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  31. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  34. data/lib/jirametrics/examples/standard_project.rb +32 -19
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +15 -2
  37. data/lib/jirametrics/file_config.rb +9 -11
  38. data/lib/jirametrics/file_system.rb +35 -2
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +115 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  42. data/lib/jirametrics/grouping_rules.rb +26 -4
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  44. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  45. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  46. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  47. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  48. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  49. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  50. data/lib/jirametrics/html/index.css +228 -60
  51. data/lib/jirametrics/html/index.erb +6 -0
  52. data/lib/jirametrics/html/index.js +53 -3
  53. data/lib/jirametrics/html/legacy_colors.css +174 -0
  54. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  55. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  58. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  59. data/lib/jirametrics/html_generator.rb +2 -1
  60. data/lib/jirametrics/html_report_config.rb +45 -33
  61. data/lib/jirametrics/issue.rb +197 -99
  62. data/lib/jirametrics/issue_printer.rb +97 -0
  63. data/lib/jirametrics/jira_gateway.rb +32 -10
  64. data/lib/jirametrics/mcp_server.rb +531 -0
  65. data/lib/jirametrics/project_config.rb +87 -8
  66. data/lib/jirametrics/pull_request.rb +30 -0
  67. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  68. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  69. data/lib/jirametrics/pull_request_review.rb +13 -0
  70. data/lib/jirametrics/raw_javascript.rb +4 -0
  71. data/lib/jirametrics/settings.json +3 -1
  72. data/lib/jirametrics/sprint_burndown.rb +4 -2
  73. data/lib/jirametrics/status.rb +1 -1
  74. data/lib/jirametrics/stitcher.rb +7 -1
  75. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  76. data/lib/jirametrics/throughput_chart.rb +73 -23
  77. data/lib/jirametrics/time_based_histogram.rb +139 -0
  78. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  79. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  80. data/lib/jirametrics.rb +58 -0
  81. metadata +52 -5
@@ -4,18 +4,18 @@ class Board
4
4
  attr_reader :visible_columns, :raw, :possible_statuses, :sprints
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses:
7
+ def initialize raw:, possible_statuses:, features: []
8
8
  @raw = raw
9
9
  @possible_statuses = possible_statuses
10
10
  @sprints = []
11
+ @features = features
11
12
 
12
13
  columns = raw['columnConfig']['columns']
13
14
  ensure_uniqueness_of_column_names! columns
14
15
 
15
- # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
16
- # visible on the board. If the board is configured to have a kanban backlog then it will have
17
- # statuses matched to it and otherwise, there will be no statuses.
18
- columns = columns.drop(1) if kanban?
16
+ # For a classic Kanban board (type 'kanban'), the first column will always be called 'Backlog'
17
+ # and will NOT be visible on the board. This does not apply to team-managed boards (type 'simple').
18
+ columns = columns.drop(1) if board_type == 'kanban'
19
19
 
20
20
  @backlog_statuses = []
21
21
  @visible_columns = columns.filter_map do |column|
@@ -25,7 +25,7 @@ class Board
25
25
  end
26
26
 
27
27
  def backlog_statuses
28
- if @backlog_statuses.empty? && kanban?
28
+ if @backlog_statuses.empty? && board_type == 'kanban'
29
29
  status_ids = status_ids_from_column raw['columnConfig']['columns'].first
30
30
  @backlog_statuses = status_ids.filter_map do |id|
31
31
  @possible_statuses.find_by_id id
@@ -67,8 +67,28 @@ class Board
67
67
  end
68
68
 
69
69
  def board_type = raw['type']
70
- def kanban? = (board_type == 'kanban')
71
- def scrum? = (board_type == 'scrum')
70
+
71
+ def scrum?
72
+ return true if board_type == 'scrum'
73
+ return false unless board_type == 'simple'
74
+
75
+ has_sprints_feature?
76
+ end
77
+
78
+ def kanban?
79
+ return true if board_type == 'kanban'
80
+ return false unless board_type == 'simple'
81
+
82
+ !scrum?
83
+ end
84
+
85
+ def team_managed_kanban?
86
+ board_type == 'simple' && !has_sprints_feature?
87
+ end
88
+
89
+ def has_sprints_feature?
90
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
91
+ end
72
92
 
73
93
  def id
74
94
  @raw['id'].to_i
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardFeature
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def name = @raw['feature']
9
+ def enabled? = (@raw['state'] == 'ENABLED')
10
+
11
+ def self.from_raw features_json
12
+ features_json['features']&.map { |f| new(raw: f) } || []
13
+ end
14
+ 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,108 @@
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
+ start_time = issue.started_stopped_times.first
35
+ return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
36
+
37
+ high_water_mark = nil
38
+ correction_open_since = nil
39
+ correction_windows = []
40
+ hwm_timeline = [] # sorted chronologically by date
41
+
42
+ issue.status_changes.each do |change|
43
+ next if change.time < start_time
44
+
45
+ col_index = column_map[change.value_id]
46
+ next if col_index.nil?
47
+
48
+ if high_water_mark.nil? || col_index > high_water_mark
49
+ # Forward movement: advance hwm, close any open correction window, record timeline entry
50
+ if correction_open_since
51
+ correction_windows << {
52
+ start_date: correction_open_since,
53
+ end_date: change.time.to_date,
54
+ column_index: high_water_mark
55
+ }
56
+ correction_open_since = nil
57
+ end
58
+ high_water_mark = col_index
59
+ hwm_timeline << [change.time.to_date, high_water_mark]
60
+ elsif col_index == high_water_mark && correction_open_since
61
+ # Same-column recovery: close the correction window without changing hwm or adding timeline entry
62
+ correction_windows << {
63
+ start_date: correction_open_since,
64
+ end_date: change.time.to_date,
65
+ column_index: high_water_mark
66
+ }
67
+ correction_open_since = nil
68
+ elsif col_index < high_water_mark
69
+ # Backwards movement: open correction window if not already open
70
+ correction_open_since ||= change.time.to_date
71
+ end
72
+ end
73
+
74
+ if correction_open_since
75
+ correction_windows << {
76
+ start_date: correction_open_since,
77
+ end_date: @date_range.end,
78
+ column_index: high_water_mark
79
+ }
80
+ end
81
+
82
+ { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
83
+ end
84
+
85
+ def hwm_at hwm_timeline, date
86
+ result = nil
87
+ hwm_timeline.each do |timeline_date, hwm|
88
+ break if timeline_date > date
89
+
90
+ result = hwm
91
+ end
92
+ result
93
+ end
94
+
95
+ def build_daily_counts issue_states
96
+ column_count = @columns.size
97
+ @date_range.each_with_object({}) do |date, result|
98
+ counts = Array.new(column_count, 0)
99
+ issue_states.each do |state|
100
+ hwm = hwm_at(state[:hwm_timeline], date)
101
+ next if hwm.nil?
102
+
103
+ (0..hwm).each { |i| counts[i] += 1 }
104
+ end
105
+ result[date] = counts
106
+ end
107
+ end
108
+ end
@@ -18,7 +18,7 @@ class ChangeItem
18
18
  @value_id = @raw['to'].split(', ').collect(&:to_i)
19
19
  @old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
20
20
  else
21
- @value_id = @raw['to'].to_i
21
+ @value_id = @raw['to']&.to_i
22
22
  @old_value_id = @raw['from']&.to_i
23
23
  end
24
24
  @field_id = @raw['fieldId']
@@ -46,6 +46,7 @@ class ChangeItem
46
46
  def resolution? = (field == 'resolution')
47
47
  def sprint? = (field == 'Sprint')
48
48
  def status? = (field == 'status')
49
+ def fix_version? = (field == 'Fix Version')
49
50
 
50
51
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
51
52
  def to_time = @time
@@ -54,10 +55,10 @@ class ChangeItem
54
55
  message = +''
55
56
  message << "ChangeItem(field: #{field.inspect}"
56
57
  message << ", value: #{value.inspect}"
57
- message << ':' << value_id.inspect if status?
58
+ message << ':' << value_id.inspect if value_id
58
59
  if old_value
59
60
  message << ", old_value: #{old_value.inspect}"
60
- message << ':' << old_value_id.inspect if status?
61
+ message << ':' << old_value_id.inspect if old_value_id
61
62
  end
62
63
  message << ", time: #{time_to_s(@time).inspect}"
63
64
  message << ", field_id: #{@field_id.inspect}" if @field_id
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChartBase
4
+ # Okabe-Ito palette — perceptually distinct under the most common forms of colour blindness.
5
+ # Ordered from most- to least-commonly useful for chart series.
6
+ OKABE_ITO_PALETTE = %w[
7
+ #0072B2
8
+ #E69F00
9
+ #009E73
10
+ #56B4E9
11
+ #D55E00
12
+ #CC79A7
13
+ #F0E442
14
+ ].freeze
4
15
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
16
  :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
6
- :atlassian_document_format
17
+ :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
7
18
  attr_writer :aggregated_project
8
19
  attr_reader :canvas_width, :canvas_height
9
20
 
@@ -80,10 +91,26 @@ class ChartBase
80
91
  "#{days} day#{'s' unless days == 1}"
81
92
  end
82
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
+
83
106
  def label_issues count
84
107
  "#{count} issue#{'s' unless count == 1}"
85
108
  end
86
109
 
110
+ def to_human_readable number
111
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
112
+ end
113
+
87
114
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
88
115
  {
89
116
  type: 'bar',
@@ -155,6 +182,56 @@ class ChartBase
155
182
  end.join
156
183
  end
157
184
 
185
+ LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
186
+
187
+ def date_annotation
188
+ annotations = settings['date_annotations'] || []
189
+ in_range = annotations
190
+ .map { |a| [a, normalize_annotation_datetime(a['date'])] }
191
+ .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
192
+ .sort_by { |(_, dt)| dt }
193
+
194
+ positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
195
+
196
+ in_range.each_with_index.collect do |(a, normalized), index|
197
+ <<~TEXT
198
+ dateAnnotation#{index}: {
199
+ type: 'line',
200
+ xMin: #{normalized.to_json},
201
+ xMax: #{normalized.to_json},
202
+ borderColor: 'rgba(0,0,0,0.7)',
203
+ borderWidth: 1,
204
+ label: {
205
+ display: true,
206
+ content: #{a['label'].to_json},
207
+ position: #{positions[index].to_json}
208
+ }
209
+ },
210
+ TEXT
211
+ end.join
212
+ end
213
+
214
+ def stagger_label_positions datetimes
215
+ return [] if datetimes.empty?
216
+
217
+ threshold_days = (date_range.end - date_range.begin).to_f / 5.0
218
+ slot = 0
219
+ [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
220
+ days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
221
+ slot = days_apart < threshold_days ? slot + 1 : 0
222
+ LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
223
+ end
224
+ end
225
+
226
+ def normalize_annotation_datetime value
227
+ offset = timezone_offset || '+00:00'
228
+ if value.include?('T')
229
+ value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
230
+ else
231
+ "#{value}T00:00:00#{offset}"
232
+ end
233
+ end
234
+
158
235
  # Return only the board columns for the current board.
159
236
  def current_board
160
237
  if @board_id.nil?
@@ -245,6 +322,13 @@ class ChartBase
245
322
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
246
323
  end
247
324
 
325
+ def not_visible_text issue
326
+ reasons = issue.reasons_not_visible_on_board
327
+ return nil if reasons.empty?
328
+
329
+ "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
330
+ end
331
+
248
332
  def status_category_color status
249
333
  case status.category.key
250
334
  when 'new' then CssVariable['--status-category-todo-color']
@@ -255,7 +339,8 @@ class ChartBase
255
339
  end
256
340
 
257
341
  def random_color
258
- "##{Random.bytes(3).unpack1('H*')}"
342
+ @palette_index = (@palette_index || -1) + 1
343
+ OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
259
344
  end
260
345
 
261
346
  def canvas width:, height:, responsive: true
@@ -304,10 +389,29 @@ class ChartBase
304
389
  end
305
390
 
306
391
  def seam_start type = 'chart'
307
- "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
392
+ "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
308
393
  end
309
394
 
310
395
  def seam_end type = 'chart'
311
396
  "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
312
397
  end
398
+
399
+ def render_axis_title axis_direction
400
+ text = case axis_direction
401
+ when :x
402
+ x_axis_title
403
+ when :y
404
+ y_axis_title
405
+ else
406
+ raise "Unexpected axis_direction: #{axis_direction}"
407
+ end
408
+ return '' unless text
409
+
410
+ <<~CONTENT
411
+ title: {
412
+ display: true,
413
+ text: "#{text}"
414
+ },
415
+ CONTENT
416
+ end
313
417
  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
@@ -0,0 +1,208 @@
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
+ <div class="p">
68
+ CT and TP require a future point C where cumulative completions catch up to current arrivals.
69
+ When the cursor is near the right edge and that point falls outside the visible date range,
70
+ CT and TP cannot be calculated and are hidden; only WIP is shown.
71
+ </div>
72
+ <div class="p">
73
+ 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
+ HTML
76
+ instance_eval(&block)
77
+ end
78
+
79
+ def column_rules &block
80
+ @column_rules_block = block
81
+ end
82
+
83
+ def triangle_color color
84
+ @triangle_color = parse_theme_color(color)
85
+ end
86
+
87
+ def arrival_rate_line_color color
88
+ @arrival_rate_line_color = parse_theme_color(color)
89
+ end
90
+
91
+ def departure_rate_line_color color
92
+ @departure_rate_line_color = parse_theme_color(color)
93
+ end
94
+
95
+ 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)
107
+
108
+ cfd = CfdDataBuilder.new(
109
+ board: current_board,
110
+ issues: issues,
111
+ date_range: date_range,
112
+ columns: active_columns
113
+ ).run
114
+
115
+ columns = cfd[:columns]
116
+ daily_counts = cfd[:daily_counts]
117
+ correction_windows = cfd[:correction_windows]
118
+ column_count = columns.size
119
+
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
128
+
129
+ border_colors = active_rules.map { |rules| rules.color || random_color }
130
+
131
+ fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
132
+
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
150
+
151
+ # Correction windows for the afterDraw hatch plugin, with dataset index in
152
+ # Chart.js dataset array (reversed: done column = index 0).
153
+ hatch_windows = correction_windows.map do |w|
154
+ {
155
+ dataset_index: column_count - 1 - w[:column_index],
156
+ start_date: w[:start_date].to_s,
157
+ end_date: w[:end_date].to_s,
158
+ color: border_colors[w[:column_index]],
159
+ fill_color: fill_colors[w[:column_index]]
160
+ }
161
+ end
162
+
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
+ wrap_and_render(binding, __FILE__)
172
+ end
173
+
174
+ private
175
+
176
+ def parse_theme_color color
177
+ return color unless color.is_a?(Array)
178
+
179
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
180
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
181
+
182
+ if color.any? { |c| c.start_with?('--') }
183
+ raise ArgumentError,
184
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
185
+ end
186
+
187
+ light, dark = color
188
+ RawJavascript.new(
189
+ "(document.documentElement.dataset.theme === 'dark' || " \
190
+ '(!document.documentElement.dataset.theme && ' \
191
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
192
+ "? #{dark.to_json} : #{light.to_json}"
193
+ )
194
+ end
195
+
196
+ def hex_to_rgba hex, alpha
197
+ r, g, b = hex.delete_prefix('#').scan(/../).map { |c| c.to_i(16) }
198
+ "rgba(#{r}, #{g}, #{b}, #{alpha})"
199
+ end
200
+
201
+ def fill_color_for rules, border
202
+ if rules.color.nil? || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
203
+ hex_to_rgba(border, 0.35)
204
+ else
205
+ rules.color
206
+ end
207
+ end
208
+ end
@@ -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