jirametrics 2.5 → 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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +73 -20
  12. data/lib/jirametrics/board_config.rb +10 -2
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +8 -6
  30. data/lib/jirametrics/download_config.rb +17 -2
  31. data/lib/jirametrics/downloader.rb +177 -108
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +5 -8
  37. data/lib/jirametrics/examples/standard_project.rb +54 -38
  38. data/lib/jirametrics/expedited_chart.rb +10 -9
  39. data/lib/jirametrics/exporter.rb +51 -16
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +481 -97
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  80. data/lib/jirametrics/settings.json +7 -1
  81. data/lib/jirametrics/sprint.rb +13 -0
  82. data/lib/jirametrics/sprint_burndown.rb +47 -39
  83. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  84. data/lib/jirametrics/status.rb +84 -19
  85. data/lib/jirametrics/status_collection.rb +83 -38
  86. data/lib/jirametrics/stitcher.rb +81 -0
  87. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  88. data/lib/jirametrics/throughput_chart.rb +73 -23
  89. data/lib/jirametrics/time_based_histogram.rb +139 -0
  90. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  91. data/lib/jirametrics/user.rb +12 -0
  92. data/lib/jirametrics/value_equality.rb +2 -2
  93. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  94. data/lib/jirametrics.rb +101 -66
  95. metadata +72 -16
  96. data/lib/jirametrics/cycletime_config.rb +0 -69
  97. data/lib/jirametrics/discard_changes_before.rb +0 -37
  98. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  99. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -1,42 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author_raw, :field_id
5
5
  attr_accessor :value, :old_value, :time
6
6
 
7
- def initialize raw:, time:, author:, artificial: false
7
+ def initialize raw:, author_raw:, time:, artificial: false
8
8
  @raw = raw
9
+ @author_raw = author_raw
9
10
  @time = time
10
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
11
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
12
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
11
13
 
12
- @field = field || @raw['field']
13
- @value = value || @raw['toString']
14
- @value_id = @raw['to'].to_i
14
+ @field = @raw['field']
15
+ @value = @raw['toString']
15
16
  @old_value = @raw['fromString']
16
- @old_value_id = @raw['from']&.to_i
17
+ if sprint?
18
+ @value_id = @raw['to'].split(', ').collect(&:to_i)
19
+ @old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
20
+ else
21
+ @value_id = @raw['to']&.to_i
22
+ @old_value_id = @raw['from']&.to_i
23
+ end
24
+ @field_id = @raw['fieldId']
17
25
  @artificial = artificial
18
- @author = author
19
26
  end
20
27
 
21
- def status? = (field == 'status')
28
+ def author
29
+ @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
30
+ end
22
31
 
23
- def flagged? = (field == 'Flagged')
32
+ def author_icon_url
33
+ @author_raw&.[]('avatarUrls')&.[]('16x16')
34
+ end
24
35
 
36
+ def artificial? = @artificial
37
+ def assignee? = (field == 'assignee')
38
+ def comment? = (field == 'comment')
39
+ def description? = (field == 'description')
40
+ def due_date? = (field == 'duedate')
41
+ def flagged? = (field == 'Flagged')
42
+ def issue_type? = field == 'issuetype'
43
+ def labels? = (field == 'labels')
44
+ def link? = (field == 'Link')
25
45
  def priority? = (field == 'priority')
26
-
27
46
  def resolution? = (field == 'resolution')
28
-
29
- def artificial? = @artificial
30
-
31
47
  def sprint? = (field == 'Sprint')
48
+ def status? = (field == 'status')
49
+ def fix_version? = (field == 'Fix Version')
32
50
 
33
- def story_points? = (field == 'Story Points')
34
-
35
- def link? = (field == 'Link')
51
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
52
+ def to_time = @time
36
53
 
37
54
  def to_s
38
55
  message = +''
39
- message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
56
+ message << "ChangeItem(field: #{field.inspect}"
57
+ message << ", value: #{value.inspect}"
58
+ message << ':' << value_id.inspect if value_id
59
+ if old_value
60
+ message << ", old_value: #{old_value.inspect}"
61
+ message << ':' << old_value_id.inspect if old_value_id
62
+ end
63
+ message << ", time: #{time_to_s(@time).inspect}"
64
+ message << ", field_id: #{@field_id.inspect}" if @field_id
40
65
  message << ', artificial' if artificial?
41
66
  message << ')'
42
67
  message
@@ -78,6 +103,17 @@ class ChangeItem
78
103
  end
79
104
  end
80
105
 
106
+ def field_as_human_readable
107
+ case @field
108
+ when 'duedate' then 'Due date'
109
+ when 'timeestimate' then 'Time estimate'
110
+ when 'timeoriginalestimate' then 'Time original estimate'
111
+ when 'issuetype' then 'Issue type'
112
+ when 'IssueParentAssociation' then 'Issue parent association'
113
+ else @field.capitalize
114
+ end
115
+ end
116
+
81
117
  private
82
118
 
83
119
  def time_to_s time
@@ -1,8 +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
- :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system
16
+ :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
17
+ :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
6
18
  attr_writer :aggregated_project
7
19
  attr_reader :canvas_width, :canvas_height
8
20
 
@@ -21,10 +33,23 @@ class ChartBase
21
33
  @canvas_responsive = true
22
34
  end
23
35
 
36
+ def call_before_run &proc
37
+ (@call_before_run_procs ||= []) << proc
38
+ end
39
+
40
+ def before_run
41
+ @call_before_run_procs&.each { |proc| proc.call }
42
+ end
43
+
24
44
  def aggregated_project?
25
45
  @aggregated_project
26
46
  end
27
47
 
48
+ def html_directory
49
+ pathname = Pathname.new(File.realpath(__FILE__))
50
+ "#{pathname.dirname}/html"
51
+ end
52
+
28
53
  def render caller_binding, file
29
54
  pathname = Pathname.new(File.realpath(file))
30
55
  basename = pathname.basename.to_s
@@ -33,14 +58,13 @@ class ChartBase
33
58
  # Insert a incrementing chart_id so that all the chart names on the page are unique
34
59
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
35
60
 
36
- @html_directory = "#{pathname.dirname}/html"
37
- erb = ERB.new file_system.load "#{@html_directory}/#{$1}.erb"
61
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
38
62
  erb.result(caller_binding)
39
63
  end
40
64
 
41
65
  def render_top_text caller_binding
42
66
  result = +''
43
- result << "<h1>#{@header_text}</h1>" if @header_text
67
+ result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
44
68
  result << ERB.new(@description_text).result(caller_binding) if @description_text
45
69
  result
46
70
  end
@@ -62,13 +86,31 @@ class ChartBase
62
86
  end
63
87
 
64
88
  def label_days days
89
+ return 'unknown' if days.nil?
90
+
65
91
  "#{days} day#{'s' unless days == 1}"
66
92
  end
67
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
+
68
106
  def label_issues count
69
107
  "#{count} issue#{'s' unless count == 1}"
70
108
  end
71
109
 
110
+ def to_human_readable number
111
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
112
+ end
113
+
72
114
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
73
115
  {
74
116
  type: 'bar',
@@ -100,7 +142,7 @@ class ChartBase
100
142
  issues_id = next_id
101
143
 
102
144
  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
- erb = ERB.new file_system.load "#{@html_directory}/collapsible_issues_panel.erb"
145
+ erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
104
146
  erb.result(binding)
105
147
  end
106
148
 
@@ -125,6 +167,71 @@ class ChartBase
125
167
  result
126
168
  end
127
169
 
170
+ def working_days_annotation
171
+ holidays.each_with_index.collect do |range, index|
172
+ <<~TEXT
173
+ holiday#{index}: {
174
+ drawTime: 'beforeDraw',
175
+ type: 'box',
176
+ xMin: '#{range.begin}T00:00:00',
177
+ xMax: '#{range.end}T23:59:59',
178
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
179
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
180
+ },
181
+ TEXT
182
+ end.join
183
+ end
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
+
128
235
  # Return only the board columns for the current board.
129
236
  def current_board
130
237
  if @board_id.nil?
@@ -144,8 +251,7 @@ class ChartBase
144
251
  def completed_issues_in_range include_unstarted: false
145
252
  issues.select do |issue|
146
253
  cycletime = issue.board.cycletime
147
- stopped_time = cycletime.stopped_time(issue)
148
- started_time = cycletime.started_time(issue)
254
+ started_time, stopped_time = cycletime.started_stopped_times(issue)
149
255
 
150
256
  stopped_time &&
151
257
  date_range.include?(stopped_time.to_date) && # Remove outside range
@@ -162,57 +268,79 @@ class ChartBase
162
268
  end
163
269
  end
164
270
 
165
- def header_text text = nil
166
- @header_text = text if text
271
+ def header_text text = :none
272
+ @header_text = text unless text == :none
167
273
  @header_text
168
274
  end
169
275
 
170
- def description_text text = nil
171
- @description_text = text if text
276
+ def description_text text = :none
277
+ @description_text = text unless text == :none
172
278
  @description_text
173
279
  end
174
280
 
281
+ # Convert a number like 1234567 into the string "1,234,567"
175
282
  def format_integer number
176
283
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
177
284
  end
178
285
 
179
- def format_status name_or_id, board:, is_category: false
180
- begin
181
- statuses = board.possible_statuses.expand_statuses([name_or_id])
182
- rescue StatusNotFoundError => e
183
- return "<span style='color: red'>#{name_or_id}</span>"
286
+ # object will be either a Status or a ChangeItem
287
+ # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
288
+ # Either way, is_category will format the category rather than the status
289
+ def format_status object, board:, is_category: false, use_old_status: false
290
+ status = nil
291
+ error_message = nil
292
+
293
+ case object
294
+ when ChangeItem
295
+ id = use_old_status ? object.old_value_id : object.value_id
296
+ 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
300
+ when Status
301
+ status = object
302
+ else
303
+ raise "Unexpected type: #{object.class}"
184
304
  end
185
305
 
186
- status = statuses.first
306
+ return "<span style='color: red'>#{error_message}</span>" if error_message
307
+
187
308
  color = status_category_color status
188
309
 
189
310
  visibility = ''
190
311
  if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
191
312
  visibility = icon_span(
192
- title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
193
- icon: ' 👀'
194
- )
195
-
313
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
314
+ icon: ' 👀'
315
+ )
196
316
  end
197
- text = is_category ? status.category_name : status.name
198
- "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
317
+ text = is_category ? status.category : status
318
+ "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
199
319
  end
200
320
 
201
321
  def icon_span title:, icon:
202
322
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
203
323
  end
204
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
+
205
332
  def status_category_color status
206
- case status.category_name
207
- when 'To Do' then CssVariable['--status-category-todo-color']
208
- when 'In Progress' then CssVariable['--status-category-inprogress-color']
209
- when 'Done' then CssVariable['--status-category-done-color']
210
- else 'black' # Theoretically impossible but seen in prod.
333
+ case status.category.key
334
+ when 'new' then CssVariable['--status-category-todo-color']
335
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
336
+ when 'done' then CssVariable['--status-category-done-color']
337
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
211
338
  end
212
339
  end
213
340
 
214
341
  def random_color
215
- "##{Random.bytes(3).unpack1('H*')}"
342
+ @palette_index = (@palette_index || -1) + 1
343
+ OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
216
344
  end
217
345
 
218
346
  def canvas width:, height:, responsive: true
@@ -227,7 +355,10 @@ class ChartBase
227
355
 
228
356
  def color_block color, title: nil
229
357
  result = +''
230
- result << "<div class='color_block' style='background: var(#{color});'"
358
+ result << "<div class='color_block' style='"
359
+ result << "background: #{CssVariable[color]};" if color
360
+ result << 'visibility: hidden;' unless color
361
+ result << "'"
231
362
  result << " title=#{title.inspect}" if title
232
363
  result << '></div>'
233
364
  result
@@ -241,4 +372,46 @@ class ChartBase
241
372
  </div>
242
373
  TEXT
243
374
  end
375
+
376
+ # Set a cycletime for just this one chart, overriding the one for the report.
377
+ def cycletime &block
378
+ call_before_run do
379
+ @cycletime = CycleTimeConfig.new(
380
+ possible_statuses: possible_statuses, label: nil, block: block, file_system: file_system,
381
+ settings: settings
382
+ )
383
+ end
384
+ end
385
+
386
+ # Returns the cycletime in use right now, which may be specific to the chart or across the report.
387
+ def cycletime_for_issue issue
388
+ @cycletime || issue.board.cycletime
389
+ end
390
+
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} -->"
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
244
417
  end
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -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