jirametrics 2.10 → 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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +62 -17
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +63 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +49 -19
- data/lib/jirametrics/chart_base.rb +147 -7
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +28 -8
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +12 -3
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +323 -63
- data/lib/jirametrics/html/index.erb +17 -19
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +347 -103
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +66 -6
|
@@ -1,48 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :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
|
-
def initialize raw:,
|
|
7
|
+
def initialize raw:, author_raw:, time:, artificial: false
|
|
8
8
|
@raw = raw
|
|
9
|
+
@author_raw = author_raw
|
|
9
10
|
@time = time
|
|
10
11
|
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
|
11
12
|
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
|
12
13
|
|
|
13
14
|
@field = @raw['field']
|
|
14
15
|
@value = @raw['toString']
|
|
15
|
-
@value_id = @raw['to'].to_i
|
|
16
16
|
@old_value = @raw['fromString']
|
|
17
|
-
|
|
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']
|
|
18
25
|
@artificial = artificial
|
|
19
|
-
@author = author
|
|
20
26
|
end
|
|
21
27
|
|
|
22
|
-
def
|
|
28
|
+
def author
|
|
29
|
+
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
|
30
|
+
end
|
|
23
31
|
|
|
24
|
-
def
|
|
32
|
+
def author_icon_url
|
|
33
|
+
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
|
34
|
+
end
|
|
25
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')
|
|
26
45
|
def priority? = (field == 'priority')
|
|
27
|
-
|
|
28
46
|
def resolution? = (field == 'resolution')
|
|
29
|
-
|
|
30
|
-
def artificial? = @artificial
|
|
31
|
-
|
|
32
47
|
def sprint? = (field == 'Sprint')
|
|
33
|
-
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
def link? = (field == 'Link')
|
|
37
|
-
|
|
38
|
-
def labels? = (field == 'labels')
|
|
48
|
+
def status? = (field == 'status')
|
|
49
|
+
def fix_version? = (field == 'Fix Version')
|
|
39
50
|
|
|
40
51
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
52
|
def to_time = @time
|
|
42
53
|
|
|
43
54
|
def to_s
|
|
44
55
|
message = +''
|
|
45
|
-
message << "ChangeItem(field: #{field.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
|
|
46
65
|
message << ', artificial' if artificial?
|
|
47
66
|
message << ')'
|
|
48
67
|
message
|
|
@@ -84,6 +103,17 @@ class ChangeItem
|
|
|
84
103
|
end
|
|
85
104
|
end
|
|
86
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
|
+
|
|
87
117
|
private
|
|
88
118
|
|
|
89
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,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
|
|
@@ -38,14 +58,13 @@ class ChartBase
|
|
|
38
58
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
39
59
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
40
60
|
|
|
41
|
-
# @html_directory = "#{pathname.dirname}/html"
|
|
42
61
|
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
43
62
|
erb.result(caller_binding)
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
def render_top_text caller_binding
|
|
47
66
|
result = +''
|
|
48
|
-
result << "<h1>#{@header_text}</h1>" if @header_text
|
|
67
|
+
result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
|
|
49
68
|
result << ERB.new(@description_text).result(caller_binding) if @description_text
|
|
50
69
|
result
|
|
51
70
|
end
|
|
@@ -67,13 +86,31 @@ class ChartBase
|
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
def label_days days
|
|
89
|
+
return 'unknown' if days.nil?
|
|
90
|
+
|
|
70
91
|
"#{days} day#{'s' unless days == 1}"
|
|
71
92
|
end
|
|
72
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
|
+
|
|
73
106
|
def label_issues count
|
|
74
107
|
"#{count} issue#{'s' unless count == 1}"
|
|
75
108
|
end
|
|
76
109
|
|
|
110
|
+
def to_human_readable number
|
|
111
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
112
|
+
end
|
|
113
|
+
|
|
77
114
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
78
115
|
{
|
|
79
116
|
type: 'bar',
|
|
@@ -145,6 +182,56 @@ class ChartBase
|
|
|
145
182
|
end.join
|
|
146
183
|
end
|
|
147
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
|
+
|
|
148
235
|
# Return only the board columns for the current board.
|
|
149
236
|
def current_board
|
|
150
237
|
if @board_id.nil?
|
|
@@ -227,14 +314,21 @@ class ChartBase
|
|
|
227
314
|
icon: ' 👀'
|
|
228
315
|
)
|
|
229
316
|
end
|
|
230
|
-
text = is_category ? status.category
|
|
231
|
-
"<span title='Category: #{status.category
|
|
317
|
+
text = is_category ? status.category : status
|
|
318
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
232
319
|
end
|
|
233
320
|
|
|
234
321
|
def icon_span title:, icon:
|
|
235
322
|
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
236
323
|
end
|
|
237
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
|
+
|
|
238
332
|
def status_category_color status
|
|
239
333
|
case status.category.key
|
|
240
334
|
when 'new' then CssVariable['--status-category-todo-color']
|
|
@@ -245,7 +339,8 @@ class ChartBase
|
|
|
245
339
|
end
|
|
246
340
|
|
|
247
341
|
def random_color
|
|
248
|
-
|
|
342
|
+
@palette_index = (@palette_index || -1) + 1
|
|
343
|
+
OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
|
|
249
344
|
end
|
|
250
345
|
|
|
251
346
|
def canvas width:, height:, responsive: true
|
|
@@ -260,7 +355,10 @@ class ChartBase
|
|
|
260
355
|
|
|
261
356
|
def color_block color, title: nil
|
|
262
357
|
result = +''
|
|
263
|
-
result << "<div class='color_block' style='
|
|
358
|
+
result << "<div class='color_block' style='"
|
|
359
|
+
result << "background: #{CssVariable[color]};" if color
|
|
360
|
+
result << 'visibility: hidden;' unless color
|
|
361
|
+
result << "'"
|
|
264
362
|
result << " title=#{title.inspect}" if title
|
|
265
363
|
result << '></div>'
|
|
266
364
|
result
|
|
@@ -274,4 +372,46 @@ class ChartBase
|
|
|
274
372
|
</div>
|
|
275
373
|
TEXT
|
|
276
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
|
|
277
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.
|
|
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, :
|
|
9
|
+
attr_reader :label, :settings, :file_system
|
|
10
10
|
|
|
11
|
-
def initialize
|
|
12
|
-
@
|
|
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 ;-)
|
|
@@ -59,10 +60,14 @@ class CycleTimeConfig
|
|
|
59
60
|
'from' => '0',
|
|
60
61
|
'fromString' => ''
|
|
61
62
|
}
|
|
62
|
-
ChangeItem.new raw: raw, time: time,
|
|
63
|
+
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
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]
|