jirametrics 2.22 → 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 +26 -10
- 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/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +28 -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 +107 -3
- 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 +38 -13
- 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 +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 +15 -2
- data/lib/jirametrics/file_config.rb +9 -11
- 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 +228 -60
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +53 -3
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- 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/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +45 -33
- data/lib/jirametrics/issue.rb +197 -99
- 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 +87 -8
- 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 +4 -2
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +58 -0
- metadata +52 -5
data/lib/jirametrics/board.rb
CHANGED
|
@@ -4,18 +4,18 @@ class Board
|
|
|
4
4
|
attr_reader :visible_columns, :raw, :possible_statuses, :sprints
|
|
5
5
|
attr_accessor :cycletime, :project_config
|
|
6
6
|
|
|
7
|
-
def initialize raw:, possible_statuses:
|
|
7
|
+
def initialize raw:, possible_statuses:, features: []
|
|
8
8
|
@raw = raw
|
|
9
9
|
@possible_statuses = possible_statuses
|
|
10
10
|
@sprints = []
|
|
11
|
+
@features = features
|
|
11
12
|
|
|
12
13
|
columns = raw['columnConfig']['columns']
|
|
13
14
|
ensure_uniqueness_of_column_names! columns
|
|
14
15
|
|
|
15
|
-
# For a Kanban board, the first column
|
|
16
|
-
# visible on the board.
|
|
17
|
-
|
|
18
|
-
columns = columns.drop(1) if kanban?
|
|
16
|
+
# For a classic Kanban board (type 'kanban'), the first column will always be called 'Backlog'
|
|
17
|
+
# and will NOT be visible on the board. This does not apply to team-managed boards (type 'simple').
|
|
18
|
+
columns = columns.drop(1) if board_type == 'kanban'
|
|
19
19
|
|
|
20
20
|
@backlog_statuses = []
|
|
21
21
|
@visible_columns = columns.filter_map do |column|
|
|
@@ -25,7 +25,7 @@ class Board
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def backlog_statuses
|
|
28
|
-
if @backlog_statuses.empty? && kanban
|
|
28
|
+
if @backlog_statuses.empty? && board_type == 'kanban'
|
|
29
29
|
status_ids = status_ids_from_column raw['columnConfig']['columns'].first
|
|
30
30
|
@backlog_statuses = status_ids.filter_map do |id|
|
|
31
31
|
@possible_statuses.find_by_id id
|
|
@@ -67,8 +67,28 @@ class Board
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def board_type = raw['type']
|
|
70
|
-
|
|
71
|
-
def scrum?
|
|
70
|
+
|
|
71
|
+
def scrum?
|
|
72
|
+
return true if board_type == 'scrum'
|
|
73
|
+
return false unless board_type == 'simple'
|
|
74
|
+
|
|
75
|
+
has_sprints_feature?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def kanban?
|
|
79
|
+
return true if board_type == 'kanban'
|
|
80
|
+
return false unless board_type == 'simple'
|
|
81
|
+
|
|
82
|
+
!scrum?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def team_managed_kanban?
|
|
86
|
+
board_type == 'simple' && !has_sprints_feature?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def has_sprints_feature?
|
|
90
|
+
@features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
91
|
+
end
|
|
72
92
|
|
|
73
93
|
def id
|
|
74
94
|
@raw['id'].to_i
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class BoardFeature
|
|
4
|
+
def initialize raw:
|
|
5
|
+
@raw = raw
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def name = @raw['feature']
|
|
9
|
+
def enabled? = (@raw['state'] == 'ENABLED')
|
|
10
|
+
|
|
11
|
+
def self.from_raw features_json
|
|
12
|
+
features_json['features']&.map { |f| new(raw: f) } || []
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -10,7 +10,7 @@ class BoardMovementCalculator
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def moves_backwards? issue
|
|
13
|
-
started, stopped = issue.
|
|
13
|
+
started, stopped = issue.started_stopped_times
|
|
14
14
|
return false unless started
|
|
15
15
|
|
|
16
16
|
previous_column = nil
|
|
@@ -70,7 +70,7 @@ class BoardMovementCalculator
|
|
|
70
70
|
@issues.filter_map do |issue|
|
|
71
71
|
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
72
|
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
-
issue_start, issue_done = issue.
|
|
73
|
+
issue_start, issue_done = issue.started_stopped_times
|
|
74
74
|
|
|
75
75
|
# Skip if we can't tell when it started.
|
|
76
76
|
next if issue_start.nil?
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -80,10 +91,26 @@ class ChartBase
|
|
|
80
91
|
"#{days} day#{'s' unless days == 1}"
|
|
81
92
|
end
|
|
82
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
|
+
|
|
83
106
|
def label_issues count
|
|
84
107
|
"#{count} issue#{'s' unless count == 1}"
|
|
85
108
|
end
|
|
86
109
|
|
|
110
|
+
def to_human_readable number
|
|
111
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
112
|
+
end
|
|
113
|
+
|
|
87
114
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
88
115
|
{
|
|
89
116
|
type: 'bar',
|
|
@@ -155,6 +182,56 @@ class ChartBase
|
|
|
155
182
|
end.join
|
|
156
183
|
end
|
|
157
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
|
+
|
|
158
235
|
# Return only the board columns for the current board.
|
|
159
236
|
def current_board
|
|
160
237
|
if @board_id.nil?
|
|
@@ -245,6 +322,13 @@ class ChartBase
|
|
|
245
322
|
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
246
323
|
end
|
|
247
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
|
+
|
|
248
332
|
def status_category_color status
|
|
249
333
|
case status.category.key
|
|
250
334
|
when 'new' then CssVariable['--status-category-todo-color']
|
|
@@ -255,7 +339,8 @@ class ChartBase
|
|
|
255
339
|
end
|
|
256
340
|
|
|
257
341
|
def random_color
|
|
258
|
-
|
|
342
|
+
@palette_index = (@palette_index || -1) + 1
|
|
343
|
+
OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
|
|
259
344
|
end
|
|
260
345
|
|
|
261
346
|
def canvas width:, height:, responsive: true
|
|
@@ -304,10 +389,29 @@ class ChartBase
|
|
|
304
389
|
end
|
|
305
390
|
|
|
306
391
|
def seam_start type = 'chart'
|
|
307
|
-
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type}
|
|
392
|
+
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
|
|
308
393
|
end
|
|
309
394
|
|
|
310
395
|
def seam_end type = 'chart'
|
|
311
396
|
"\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
|
|
312
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
|
|
313
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,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
|