jirametrics 2.13 → 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 (87) 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 +191 -133
  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 +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +101 -97
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +4 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +141 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +85 -53
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  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 +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +249 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +304 -101
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +128 -12
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -6
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
5
- attr_accessor :value, :old_value
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author_raw, :field_id
5
+ attr_accessor :value, :old_value, :time
6
6
 
7
7
  def initialize raw:, author_raw:, time:, artificial: false
8
8
  @raw = raw
@@ -13,9 +13,15 @@ class ChangeItem
13
13
 
14
14
  @field = @raw['field']
15
15
  @value = @raw['toString']
16
- @value_id = @raw['to'].to_i
17
16
  @old_value = @raw['fromString']
18
- @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']
19
25
  @artificial = artificial
20
26
  end
21
27
 
@@ -40,6 +46,7 @@ class ChangeItem
40
46
  def resolution? = (field == 'resolution')
41
47
  def sprint? = (field == 'Sprint')
42
48
  def status? = (field == 'status')
49
+ def fix_version? = (field == 'Fix Version')
43
50
 
44
51
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
45
52
  def to_time = @time
@@ -48,12 +55,13 @@ class ChangeItem
48
55
  message = +''
49
56
  message << "ChangeItem(field: #{field.inspect}"
50
57
  message << ", value: #{value.inspect}"
51
- message << ':' << value_id.inspect if status?
58
+ message << ':' << value_id.inspect if value_id
52
59
  if old_value
53
60
  message << ", old_value: #{old_value.inspect}"
54
- message << ':' << old_value_id.inspect if status?
61
+ message << ':' << old_value_id.inspect if old_value_id
55
62
  end
56
63
  message << ", time: #{time_to_s(@time).inspect}"
64
+ message << ", field_id: #{@field_id.inspect}" if @field_id
57
65
  message << ', artificial' if artificial?
58
66
  message << ')'
59
67
  message
@@ -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, :users
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,6 +33,14 @@ 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
@@ -44,7 +64,7 @@ class ChartBase
44
64
 
45
65
  def render_top_text caller_binding
46
66
  result = +''
47
- result << "<h1>#{@header_text}</h1>" if @header_text
67
+ result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
48
68
  result << ERB.new(@description_text).result(caller_binding) if @description_text
49
69
  result
50
70
  end
@@ -66,13 +86,31 @@ class ChartBase
66
86
  end
67
87
 
68
88
  def label_days days
89
+ return 'unknown' if days.nil?
90
+
69
91
  "#{days} day#{'s' unless days == 1}"
70
92
  end
71
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
+
72
106
  def label_issues count
73
107
  "#{count} issue#{'s' unless count == 1}"
74
108
  end
75
109
 
110
+ def to_human_readable number
111
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
112
+ end
113
+
76
114
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
77
115
  {
78
116
  type: 'bar',
@@ -144,6 +182,56 @@ class ChartBase
144
182
  end.join
145
183
  end
146
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
+
147
235
  # Return only the board columns for the current board.
148
236
  def current_board
149
237
  if @board_id.nil?
@@ -234,6 +322,13 @@ class ChartBase
234
322
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
235
323
  end
236
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
+
237
332
  def status_category_color status
238
333
  case status.category.key
239
334
  when 'new' then CssVariable['--status-category-todo-color']
@@ -244,7 +339,8 @@ class ChartBase
244
339
  end
245
340
 
246
341
  def random_color
247
- "##{Random.bytes(3).unpack1('H*')}"
342
+ @palette_index = (@palette_index || -1) + 1
343
+ OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
248
344
  end
249
345
 
250
346
  def canvas width:, height:, responsive: true
@@ -276,4 +372,46 @@ class ChartBase
276
372
  </div>
277
373
  TEXT
278
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
279
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,12 +6,13 @@ require 'date'
6
6
  class CycleTimeConfig
7
7
  include SelfOrIssueDispatcher
8
8
 
9
- attr_reader :label, :parent_config
9
+ attr_reader :label, :settings, :file_system
10
10
 
11
- def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
12
- @parent_config = parent_config
11
+ def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
12
+ @possible_statuses = possible_statuses
13
13
  @label = label
14
14
  @today = today
15
+ @settings = settings
15
16
 
16
17
  # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
17
18
  # may make it easier to find problems in the test code ;-)
@@ -63,6 +64,10 @@ class CycleTimeConfig
63
64
  end
64
65
 
65
66
  def started_stopped_changes issue
67
+ cache_key = "#{issue.key}:#{issue.board.id}"
68
+ last_result = (@cache ||= {})[cache_key]
69
+ return *last_result if last_result && settings['cache_cycletime_calculations']
70
+
66
71
  started = @start_at.call(issue)
67
72
  stopped = @stop_at.call(issue)
68
73
 
@@ -80,7 +85,15 @@ class CycleTimeConfig
80
85
  # for the start and not have it conflict.
81
86
  started = nil if started&.time == stopped&.time
82
87
 
83
- [started, stopped]
88
+ 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
95
+ @cache[cache_key] = result
96
+ result
84
97
  end
85
98
 
86
99
  def started_stopped_times issue
@@ -88,6 +101,10 @@ class CycleTimeConfig
88
101
  [started&.time, stopped&.time]
89
102
  end
90
103
 
104
+ def flush_cache
105
+ @cache = nil
106
+ end
107
+
91
108
  def started_stopped_dates issue
92
109
  started_time, stopped_time = started_stopped_times(issue)
93
110
  [started_time&.to_date, stopped_time&.to_date]
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
3
+ require 'jirametrics/time_based_histogram'
4
4
 
5
- class CycletimeHistogram < ChartBase
6
- include GroupableIssueChart
5
+ class CycletimeHistogram < TimeBasedHistogram
7
6
  attr_accessor :possible_statuses
8
- attr_reader :show_stats
9
7
 
10
8
  def initialize block
11
9
  super()
12
10
 
13
- percentiles [50, 85, 98]
14
- @show_stats = true
11
+ @x_axis_title = 'Cycletime in days'
12
+ @y_axis_title = 'Count'
15
13
 
16
14
  header_text 'Cycletime Histogram'
17
15
  description_text <<-HTML
@@ -30,110 +28,26 @@ class CycletimeHistogram < ChartBase
30
28
  end
31
29
  end
32
30
 
33
- def percentiles percs = nil
34
- @percentiles = percs unless percs.nil?
35
- @percentiles
36
- end
37
-
38
- def disable_stats
39
- @show_stats = false
40
- end
41
-
42
- def run
31
+ def all_items
43
32
  stopped_issues = completed_issues_in_range include_unstarted: true
44
33
 
45
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
46
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
47
- rules_to_issues = group_issues histogram_issues
48
-
49
- the_stats = {}
50
-
51
- overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
- the_stats[:all] = overall_stats
53
- data_sets = rules_to_issues.keys.collect do |rules|
54
- the_issue_type = rules.label
55
- the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
- the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
-
58
- data_set_for(
59
- histogram_data: the_histogram,
60
- label: the_issue_type,
61
- color: rules.color
62
- )
63
- end
64
-
65
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
66
-
67
- wrap_and_render(binding, __FILE__)
35
+ stopped_issues.select { |issue| issue.started_stopped_times.first }
68
36
  end
69
37
 
70
- def histogram_data_for issues:
71
- count_hash = {}
72
- issues.each do |issue|
73
- days = issue.board.cycletime.cycletime(issue)
74
- count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
75
- end
76
- count_hash
38
+ def value_for_item issue
39
+ issue.board.cycletime.cycletime(issue)
77
40
  end
78
41
 
79
- def stats_for histogram_data:, percentiles:
80
- return {} if histogram_data.empty?
81
-
82
- total_values = histogram_data.values.sum
83
-
84
- # Calculate the average
85
- weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
- average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
-
88
- # Find the mode (or modes!) and the spread of the distribution
89
- sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
- max_freq = sorted_histogram[-1][1]
91
- mode = sorted_histogram.select { |_v, f| f == max_freq }
92
-
93
- minmax = histogram_data.keys.minmax
94
-
95
- # Calculate percentiles
96
- sorted_values = histogram_data.keys.sort
97
- cumulative_counts = {}
98
- cumulative_sum = 0
99
-
100
- sorted_values.each do |value|
101
- cumulative_sum += histogram_data[value]
102
- cumulative_counts[value] = cumulative_sum
103
- end
104
-
105
- percentile_results = {}
106
- percentiles.each do |percentile|
107
- rank = (percentile / 100.0) * total_values
108
- percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
- percentile_results[percentile] = percentile_value
110
- end
111
-
112
- {
113
- average: average,
114
- mode: mode.collect(&:first).sort,
115
- min: minmax[0],
116
- max: minmax[1],
117
- percentiles: percentile_results
118
- }
42
+ def title_for_item count:, value:
43
+ "#{count} items completed in #{label_days value}"
119
44
  end
120
45
 
121
- def data_set_for histogram_data:, label:, color:
122
- keys = histogram_data.keys.sort
123
- {
124
- type: 'bar',
125
- label: label,
126
- data: keys.sort.filter_map do |key|
127
- next if histogram_data[key].zero?
46
+ def sort_items items
47
+ items.sort_by(&:key_as_i)
48
+ end
128
49
 
129
- {
130
- x: key,
131
- y: histogram_data[key],
132
- title: "#{histogram_data[key]} items completed in #{label_days key}"
133
- }
134
- end,
135
- backgroundColor: color,
136
- borderRadius: 0
137
- }
50
+ def label_for_item issue, hint:
51
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
138
52
  end
139
53
  end