jirametrics 2.22 → 2.27
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/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +20 -6
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +20 -8
- 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 +4 -3
- data/lib/jirametrics/chart_base.rb +94 -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} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
- data/lib/jirametrics/daily_view.rb +36 -12
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +29 -7
- data/lib/jirametrics/data_quality_report.rb +38 -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 +52 -10
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- 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 +29 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +4 -0
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/index.css +117 -0
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +52 -2
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +40 -9
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +23 -16
- data/lib/jirametrics/issue.rb +101 -96
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/mcp_server.rb +305 -0
- data/lib/jirametrics/project_config.rb +80 -7
- 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 +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +3 -1
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +7 -1
- 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.rb +28 -0
- metadata +47 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CfdDataBuilder
|
|
4
|
+
def initialize board:, issues:, date_range:, columns: nil
|
|
5
|
+
@board = board
|
|
6
|
+
@issues = issues
|
|
7
|
+
@date_range = date_range
|
|
8
|
+
@columns = columns || board.visible_columns
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
column_map = build_column_map
|
|
13
|
+
issue_states = @issues.map { |issue| process_issue(issue, column_map) }
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
columns: @columns.map(&:name),
|
|
17
|
+
daily_counts: build_daily_counts(issue_states),
|
|
18
|
+
correction_windows: issue_states.flat_map { |s| s[:correction_windows] }
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_column_map
|
|
25
|
+
map = {}
|
|
26
|
+
@columns.each_with_index do |column, index|
|
|
27
|
+
column.status_ids.each { |id| map[id] = index }
|
|
28
|
+
end
|
|
29
|
+
map
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
|
|
33
|
+
def process_issue issue, column_map
|
|
34
|
+
start_time = issue.started_stopped_times.first
|
|
35
|
+
return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
|
|
36
|
+
|
|
37
|
+
high_water_mark = nil
|
|
38
|
+
correction_open_since = nil
|
|
39
|
+
correction_windows = []
|
|
40
|
+
hwm_timeline = [] # sorted chronologically by date
|
|
41
|
+
|
|
42
|
+
issue.status_changes.each do |change|
|
|
43
|
+
next if change.time < start_time
|
|
44
|
+
|
|
45
|
+
col_index = column_map[change.value_id]
|
|
46
|
+
next if col_index.nil?
|
|
47
|
+
|
|
48
|
+
if high_water_mark.nil? || col_index > high_water_mark
|
|
49
|
+
# Forward movement: advance hwm, close any open correction window, record timeline entry
|
|
50
|
+
if correction_open_since
|
|
51
|
+
correction_windows << {
|
|
52
|
+
start_date: correction_open_since,
|
|
53
|
+
end_date: change.time.to_date,
|
|
54
|
+
column_index: high_water_mark
|
|
55
|
+
}
|
|
56
|
+
correction_open_since = nil
|
|
57
|
+
end
|
|
58
|
+
high_water_mark = col_index
|
|
59
|
+
hwm_timeline << [change.time.to_date, high_water_mark]
|
|
60
|
+
elsif col_index == high_water_mark && correction_open_since
|
|
61
|
+
# Same-column recovery: close the correction window without changing hwm or adding timeline entry
|
|
62
|
+
correction_windows << {
|
|
63
|
+
start_date: correction_open_since,
|
|
64
|
+
end_date: change.time.to_date,
|
|
65
|
+
column_index: high_water_mark
|
|
66
|
+
}
|
|
67
|
+
correction_open_since = nil
|
|
68
|
+
elsif col_index < high_water_mark
|
|
69
|
+
# Backwards movement: open correction window if not already open
|
|
70
|
+
correction_open_since ||= change.time.to_date
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if correction_open_since
|
|
75
|
+
correction_windows << {
|
|
76
|
+
start_date: correction_open_since,
|
|
77
|
+
end_date: @date_range.end,
|
|
78
|
+
column_index: high_water_mark
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ hwm_timeline: hwm_timeline, correction_windows: correction_windows }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hwm_at hwm_timeline, date
|
|
86
|
+
result = nil
|
|
87
|
+
hwm_timeline.each do |timeline_date, hwm|
|
|
88
|
+
break if timeline_date > date
|
|
89
|
+
|
|
90
|
+
result = hwm
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_daily_counts issue_states
|
|
96
|
+
column_count = @columns.size
|
|
97
|
+
@date_range.each_with_object({}) do |date, result|
|
|
98
|
+
counts = Array.new(column_count, 0)
|
|
99
|
+
issue_states.each do |state|
|
|
100
|
+
hwm = hwm_at(state[:hwm_timeline], date)
|
|
101
|
+
next if hwm.nil?
|
|
102
|
+
|
|
103
|
+
(0..hwm).each { |i| counts[i] += 1 }
|
|
104
|
+
end
|
|
105
|
+
result[date] = counts
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -18,7 +18,7 @@ class ChangeItem
|
|
|
18
18
|
@value_id = @raw['to'].split(', ').collect(&:to_i)
|
|
19
19
|
@old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
|
|
20
20
|
else
|
|
21
|
-
@value_id = @raw['to']
|
|
21
|
+
@value_id = @raw['to']&.to_i
|
|
22
22
|
@old_value_id = @raw['from']&.to_i
|
|
23
23
|
end
|
|
24
24
|
@field_id = @raw['fieldId']
|
|
@@ -46,6 +46,7 @@ class ChangeItem
|
|
|
46
46
|
def resolution? = (field == 'resolution')
|
|
47
47
|
def sprint? = (field == 'Sprint')
|
|
48
48
|
def status? = (field == 'status')
|
|
49
|
+
def fix_version? = (field == 'Fix Version')
|
|
49
50
|
|
|
50
51
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
51
52
|
def to_time = @time
|
|
@@ -54,10 +55,10 @@ class ChangeItem
|
|
|
54
55
|
message = +''
|
|
55
56
|
message << "ChangeItem(field: #{field.inspect}"
|
|
56
57
|
message << ", value: #{value.inspect}"
|
|
57
|
-
message << ':' << value_id.inspect if
|
|
58
|
+
message << ':' << value_id.inspect if value_id
|
|
58
59
|
if old_value
|
|
59
60
|
message << ", old_value: #{old_value.inspect}"
|
|
60
|
-
message << ':' << old_value_id.inspect if
|
|
61
|
+
message << ':' << old_value_id.inspect if old_value_id
|
|
61
62
|
end
|
|
62
63
|
message << ", time: #{time_to_s(@time).inspect}"
|
|
63
64
|
message << ", field_id: #{@field_id.inspect}" if @field_id
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
5
|
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
|
|
6
|
-
:atlassian_document_format
|
|
6
|
+
:atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
|
|
7
7
|
attr_writer :aggregated_project
|
|
8
8
|
attr_reader :canvas_width, :canvas_height
|
|
9
9
|
|
|
@@ -80,10 +80,26 @@ class ChartBase
|
|
|
80
80
|
"#{days} day#{'s' unless days == 1}"
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
def label_hours hours
|
|
84
|
+
return 'unknown' if hours.nil?
|
|
85
|
+
|
|
86
|
+
"#{hours} hour#{'s' unless hours == 1}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def label_minutes minutes
|
|
90
|
+
return 'unknown' if minutes.nil?
|
|
91
|
+
|
|
92
|
+
"#{minutes} minute#{'s' unless minutes == 1}"
|
|
93
|
+
end
|
|
94
|
+
|
|
83
95
|
def label_issues count
|
|
84
96
|
"#{count} issue#{'s' unless count == 1}"
|
|
85
97
|
end
|
|
86
98
|
|
|
99
|
+
def to_human_readable number
|
|
100
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
101
|
+
end
|
|
102
|
+
|
|
87
103
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
88
104
|
{
|
|
89
105
|
type: 'bar',
|
|
@@ -155,6 +171,56 @@ class ChartBase
|
|
|
155
171
|
end.join
|
|
156
172
|
end
|
|
157
173
|
|
|
174
|
+
LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
|
|
175
|
+
|
|
176
|
+
def date_annotation
|
|
177
|
+
annotations = settings['date_annotations'] || []
|
|
178
|
+
in_range = annotations
|
|
179
|
+
.map { |a| [a, normalize_annotation_datetime(a['date'])] }
|
|
180
|
+
.select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
|
|
181
|
+
.sort_by { |(_, dt)| dt }
|
|
182
|
+
|
|
183
|
+
positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
|
|
184
|
+
|
|
185
|
+
in_range.each_with_index.collect do |(a, normalized), index|
|
|
186
|
+
<<~TEXT
|
|
187
|
+
dateAnnotation#{index}: {
|
|
188
|
+
type: 'line',
|
|
189
|
+
xMin: #{normalized.to_json},
|
|
190
|
+
xMax: #{normalized.to_json},
|
|
191
|
+
borderColor: 'rgba(0,0,0,0.7)',
|
|
192
|
+
borderWidth: 1,
|
|
193
|
+
label: {
|
|
194
|
+
display: true,
|
|
195
|
+
content: #{a['label'].to_json},
|
|
196
|
+
position: #{positions[index].to_json}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
TEXT
|
|
200
|
+
end.join
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def stagger_label_positions datetimes
|
|
204
|
+
return [] if datetimes.empty?
|
|
205
|
+
|
|
206
|
+
threshold_days = (date_range.end - date_range.begin).to_f / 5.0
|
|
207
|
+
slot = 0
|
|
208
|
+
[LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
|
|
209
|
+
days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
|
|
210
|
+
slot = days_apart < threshold_days ? slot + 1 : 0
|
|
211
|
+
LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_annotation_datetime value
|
|
216
|
+
offset = timezone_offset || '+00:00'
|
|
217
|
+
if value.include?('T')
|
|
218
|
+
value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
|
|
219
|
+
else
|
|
220
|
+
"#{value}T00:00:00#{offset}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
158
224
|
# Return only the board columns for the current board.
|
|
159
225
|
def current_board
|
|
160
226
|
if @board_id.nil?
|
|
@@ -245,6 +311,13 @@ class ChartBase
|
|
|
245
311
|
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
246
312
|
end
|
|
247
313
|
|
|
314
|
+
def not_visible_text issue
|
|
315
|
+
reasons = issue.reasons_not_visible_on_board
|
|
316
|
+
return nil if reasons.empty?
|
|
317
|
+
|
|
318
|
+
"<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
|
|
319
|
+
end
|
|
320
|
+
|
|
248
321
|
def status_category_color status
|
|
249
322
|
case status.category.key
|
|
250
323
|
when 'new' then CssVariable['--status-category-todo-color']
|
|
@@ -304,10 +377,29 @@ class ChartBase
|
|
|
304
377
|
end
|
|
305
378
|
|
|
306
379
|
def seam_start type = 'chart'
|
|
307
|
-
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type}
|
|
380
|
+
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
|
|
308
381
|
end
|
|
309
382
|
|
|
310
383
|
def seam_end type = 'chart'
|
|
311
384
|
"\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
|
|
312
385
|
end
|
|
386
|
+
|
|
387
|
+
def render_axis_title axis_direction
|
|
388
|
+
text = case axis_direction
|
|
389
|
+
when :x
|
|
390
|
+
x_axis_title
|
|
391
|
+
when :y
|
|
392
|
+
y_axis_title
|
|
393
|
+
else
|
|
394
|
+
raise "Unexpected axis_direction: #{axis_direction}"
|
|
395
|
+
end
|
|
396
|
+
return '' unless text
|
|
397
|
+
|
|
398
|
+
<<~CONTENT
|
|
399
|
+
title: {
|
|
400
|
+
display: true,
|
|
401
|
+
text: "#{text}"
|
|
402
|
+
},
|
|
403
|
+
CONTENT
|
|
404
|
+
end
|
|
313
405
|
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 [how to read a CFD](https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/).
|
|
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,10 +6,9 @@ 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
11
|
def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
|
|
12
|
-
|
|
13
12
|
@possible_statuses = possible_statuses
|
|
14
13
|
@label = label
|
|
15
14
|
@today = today
|
|
@@ -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
|