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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- 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 +6 -4
- data/lib/jirametrics/board.rb +73 -20
- data/lib/jirametrics/board_config.rb +10 -2
- 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 +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +8 -6
- data/lib/jirametrics/download_config.rb +17 -2
- data/lib/jirametrics/downloader.rb +177 -108
- 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 +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +5 -8
- data/lib/jirametrics/examples/standard_project.rb +54 -38
- data/lib/jirametrics/expedited_chart.rb +10 -9
- data/lib/jirametrics/exporter.rb +51 -16
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- 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 +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- 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 +83 -76
- data/lib/jirametrics/issue.rb +481 -97
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- 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/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +7 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- 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/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- 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, :
|
|
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:,
|
|
7
|
+
def initialize raw:, author_raw:, time:, artificial: false
|
|
8
8
|
@raw = raw
|
|
9
|
+
@author_raw = author_raw
|
|
9
10
|
@time = time
|
|
10
|
-
raise
|
|
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 =
|
|
13
|
-
@value =
|
|
14
|
-
@value_id = @raw['to'].to_i
|
|
14
|
+
@field = @raw['field']
|
|
15
|
+
@value = @raw['toString']
|
|
15
16
|
@old_value = @raw['fromString']
|
|
16
|
-
|
|
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
|
|
28
|
+
def author
|
|
29
|
+
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
|
30
|
+
end
|
|
22
31
|
|
|
23
|
-
def
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
166
|
-
@header_text = 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 =
|
|
171
|
-
@description_text = 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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
198
|
-
"<span title='Category: #{status.
|
|
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.
|
|
207
|
-
when '
|
|
208
|
-
when '
|
|
209
|
-
when '
|
|
210
|
-
else '
|
|
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
|
-
|
|
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='
|
|
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.
|
|
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
|