jirametrics 2.20.1 → 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 +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +2 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +13 -5
- data/lib/jirametrics/chart_base.rb +137 -2
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +38 -13
- 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 +40 -12
- data/lib/jirametrics/dependency_chart.rb +2 -2
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +87 -5
- data/lib/jirametrics/downloader_for_cloud.rb +107 -22
- data/lib/jirametrics/downloader_for_data_center.rb +3 -2
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +32 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +19 -4
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- 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 +2 -0
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- 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 +244 -59
- data/lib/jirametrics/html/index.erb +7 -1
- data/lib/jirametrics/html/index.js +77 -3
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +12 -12
- 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 +49 -56
- data/lib/jirametrics/issue.rb +282 -91
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +32 -10
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +98 -9
- 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 +3 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +66 -1
- metadata +56 -5
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
|
+
# Okabe-Ito palette — perceptually distinct under the most common forms of colour blindness.
|
|
5
|
+
# Ordered from most- to least-commonly useful for chart series.
|
|
6
|
+
OKABE_ITO_PALETTE = %w[
|
|
7
|
+
#0072B2
|
|
8
|
+
#E69F00
|
|
9
|
+
#009E73
|
|
10
|
+
#56B4E9
|
|
11
|
+
#D55E00
|
|
12
|
+
#CC79A7
|
|
13
|
+
#F0E442
|
|
14
|
+
].freeze
|
|
4
15
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
16
|
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
|
|
6
|
-
:atlassian_document_format
|
|
17
|
+
:atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
|
|
7
18
|
attr_writer :aggregated_project
|
|
8
19
|
attr_reader :canvas_width, :canvas_height
|
|
9
20
|
|
|
@@ -22,6 +33,14 @@ class ChartBase
|
|
|
22
33
|
@canvas_responsive = true
|
|
23
34
|
end
|
|
24
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
|
+
|
|
25
44
|
def aggregated_project?
|
|
26
45
|
@aggregated_project
|
|
27
46
|
end
|
|
@@ -72,10 +91,26 @@ class ChartBase
|
|
|
72
91
|
"#{days} day#{'s' unless days == 1}"
|
|
73
92
|
end
|
|
74
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
|
+
|
|
75
106
|
def label_issues count
|
|
76
107
|
"#{count} issue#{'s' unless count == 1}"
|
|
77
108
|
end
|
|
78
109
|
|
|
110
|
+
def to_human_readable number
|
|
111
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
112
|
+
end
|
|
113
|
+
|
|
79
114
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
80
115
|
{
|
|
81
116
|
type: 'bar',
|
|
@@ -147,6 +182,56 @@ class ChartBase
|
|
|
147
182
|
end.join
|
|
148
183
|
end
|
|
149
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
|
+
|
|
150
235
|
# Return only the board columns for the current board.
|
|
151
236
|
def current_board
|
|
152
237
|
if @board_id.nil?
|
|
@@ -237,6 +322,13 @@ class ChartBase
|
|
|
237
322
|
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
238
323
|
end
|
|
239
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
|
+
|
|
240
332
|
def status_category_color status
|
|
241
333
|
case status.category.key
|
|
242
334
|
when 'new' then CssVariable['--status-category-todo-color']
|
|
@@ -247,7 +339,8 @@ class ChartBase
|
|
|
247
339
|
end
|
|
248
340
|
|
|
249
341
|
def random_color
|
|
250
|
-
|
|
342
|
+
@palette_index = (@palette_index || -1) + 1
|
|
343
|
+
OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
|
|
251
344
|
end
|
|
252
345
|
|
|
253
346
|
def canvas width:, height:, responsive: true
|
|
@@ -279,4 +372,46 @@ class ChartBase
|
|
|
279
372
|
</div>
|
|
280
373
|
TEXT
|
|
281
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
|
|
282
417
|
end
|
|
@@ -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,15 +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
|
-
|
|
13
|
-
@parent_config = parent_config
|
|
11
|
+
def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
|
|
12
|
+
@possible_statuses = possible_statuses
|
|
14
13
|
@label = label
|
|
15
14
|
@today = today
|
|
16
15
|
@settings = settings
|
|
17
|
-
@cache_cycletime_calculations = settings['cache_cycletime_calculations']
|
|
18
16
|
|
|
19
17
|
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
20
18
|
# may make it easier to find problems in the test code ;-)
|
|
@@ -68,7 +66,7 @@ class CycleTimeConfig
|
|
|
68
66
|
def started_stopped_changes issue
|
|
69
67
|
cache_key = "#{issue.key}:#{issue.board.id}"
|
|
70
68
|
last_result = (@cache ||= {})[cache_key]
|
|
71
|
-
return *last_result if last_result &&
|
|
69
|
+
return *last_result if last_result && settings['cache_cycletime_calculations']
|
|
72
70
|
|
|
73
71
|
started = @start_at.call(issue)
|
|
74
72
|
stopped = @stop_at.call(issue)
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
3
|
+
require 'jirametrics/time_based_histogram'
|
|
4
4
|
|
|
5
|
-
class CycletimeHistogram <
|
|
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
|
-
|
|
14
|
-
@
|
|
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,112 +28,26 @@ class CycletimeHistogram < ChartBase
|
|
|
30
28
|
end
|
|
31
29
|
end
|
|
32
30
|
|
|
33
|
-
def
|
|
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
|
-
|
|
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
|
-
if data_sets.empty?
|
|
66
|
-
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
wrap_and_render(binding, __FILE__)
|
|
35
|
+
stopped_issues.select { |issue| issue.started_stopped_times.first }
|
|
70
36
|
end
|
|
71
37
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
issues.each do |issue|
|
|
75
|
-
days = issue.board.cycletime.cycletime(issue)
|
|
76
|
-
count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
|
|
77
|
-
end
|
|
78
|
-
count_hash
|
|
38
|
+
def value_for_item issue
|
|
39
|
+
issue.board.cycletime.cycletime(issue)
|
|
79
40
|
end
|
|
80
41
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
total_values = histogram_data.values.sum
|
|
85
|
-
|
|
86
|
-
# Calculate the average
|
|
87
|
-
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
88
|
-
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
89
|
-
|
|
90
|
-
# Find the mode (or modes!) and the spread of the distribution
|
|
91
|
-
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
92
|
-
max_freq = sorted_histogram[-1][1]
|
|
93
|
-
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
94
|
-
|
|
95
|
-
minmax = histogram_data.keys.minmax
|
|
96
|
-
|
|
97
|
-
# Calculate percentiles
|
|
98
|
-
sorted_values = histogram_data.keys.sort
|
|
99
|
-
cumulative_counts = {}
|
|
100
|
-
cumulative_sum = 0
|
|
101
|
-
|
|
102
|
-
sorted_values.each do |value|
|
|
103
|
-
cumulative_sum += histogram_data[value]
|
|
104
|
-
cumulative_counts[value] = cumulative_sum
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
percentile_results = {}
|
|
108
|
-
percentiles.each do |percentile|
|
|
109
|
-
rank = (percentile / 100.0) * total_values
|
|
110
|
-
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
111
|
-
percentile_results[percentile] = percentile_value
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
average: average,
|
|
116
|
-
mode: mode.collect(&:first).sort,
|
|
117
|
-
min: minmax[0],
|
|
118
|
-
max: minmax[1],
|
|
119
|
-
percentiles: percentile_results
|
|
120
|
-
}
|
|
42
|
+
def title_for_item count:, value:
|
|
43
|
+
"#{count} items completed in #{label_days value}"
|
|
121
44
|
end
|
|
122
45
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
type: 'bar',
|
|
127
|
-
label: label,
|
|
128
|
-
data: keys.sort.filter_map do |key|
|
|
129
|
-
next if histogram_data[key].zero?
|
|
46
|
+
def sort_items items
|
|
47
|
+
items.sort_by(&:key_as_i)
|
|
48
|
+
end
|
|
130
49
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
y: histogram_data[key],
|
|
134
|
-
title: "#{histogram_data[key]} items completed in #{label_days key}"
|
|
135
|
-
}
|
|
136
|
-
end,
|
|
137
|
-
backgroundColor: color,
|
|
138
|
-
borderRadius: 0
|
|
139
|
-
}
|
|
50
|
+
def label_for_item issue, hint:
|
|
51
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
140
52
|
end
|
|
141
53
|
end
|