jirametrics 2.13 → 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 +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +101 -97
- 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 +4 -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 +14 -6
- data/lib/jirametrics/chart_base.rb +141 -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} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +85 -53
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- 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 +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- 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 +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +249 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +304 -101
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +128 -12
- 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 +5 -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 +83 -69
- metadata +60 -6
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
class ThroughputChart < ChartBase
|
|
4
6
|
include GroupableIssueChart
|
|
5
7
|
|
|
@@ -10,42 +12,54 @@ class ThroughputChart < ChartBase
|
|
|
10
12
|
|
|
11
13
|
header_text 'Throughput Chart'
|
|
12
14
|
description_text <<-TEXT
|
|
13
|
-
<div
|
|
14
|
-
|
|
15
|
+
<div>Throughput data is very useful for#{' '}
|
|
16
|
+
<a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
|
|
17
|
+
to determine when we'll be done. Try it now with the
|
|
18
|
+
<a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
|
|
19
|
+
Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
|
|
20
|
+
<%= @not_started_count %> items you currently have in your backlog.
|
|
15
21
|
</div>
|
|
16
22
|
#{describe_non_working_days}
|
|
17
23
|
TEXT
|
|
24
|
+
@x_axis_title = nil
|
|
25
|
+
@y_axis_title = 'Count of items'
|
|
18
26
|
|
|
19
27
|
init_configuration_block(block) do
|
|
20
|
-
grouping_rules
|
|
21
|
-
rule.label = issue.type
|
|
22
|
-
rule.color = color_for type: issue.type
|
|
23
|
-
end
|
|
28
|
+
grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def run
|
|
33
|
+
# This is saved as an instance variable so that it's accessible later when rendering the description text
|
|
34
|
+
@not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
|
|
35
|
+
|
|
28
36
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
29
37
|
rules_to_issues = group_issues completed_issues
|
|
30
38
|
data_sets = []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
total_data_set = weekly_throughput_dataset(
|
|
40
|
+
completed_issues: completed_issues,
|
|
41
|
+
label: 'Totals',
|
|
42
|
+
color: CssVariable['--throughput_chart_total_line_color'],
|
|
43
|
+
dashed: true
|
|
44
|
+
)
|
|
45
|
+
@throughput_samples = total_data_set[:data].collect { |d| d[:y] }
|
|
46
|
+
data_sets << total_data_set if rules_to_issues.size > 1
|
|
39
47
|
|
|
40
48
|
rules_to_issues.each_key do |rules|
|
|
41
49
|
data_sets << weekly_throughput_dataset(
|
|
42
|
-
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
|
|
50
|
+
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
|
|
51
|
+
label_hint: rules.label_hint
|
|
43
52
|
)
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
wrap_and_render(binding, __FILE__)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def default_grouping_rules issue, rule
|
|
59
|
+
rule.label = issue.type
|
|
60
|
+
rule.color = color_for type: issue.type
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
def calculate_time_periods
|
|
50
64
|
first_day = @date_range.begin
|
|
51
65
|
first_day = case first_day.wday
|
|
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
|
|
|
65
79
|
end
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def
|
|
82
|
+
def calculate_custom_periods
|
|
83
|
+
last_days = @issue_periods.values.compact.uniq.sort
|
|
84
|
+
last_days.each_with_index.map do |last_day, i|
|
|
85
|
+
first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
|
|
86
|
+
first_day..last_day
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
|
|
91
|
+
periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
|
|
69
92
|
result = {
|
|
70
93
|
label: label,
|
|
71
|
-
|
|
94
|
+
label_hint: label_hint,
|
|
95
|
+
data: throughput_dataset(
|
|
96
|
+
periods: periods, completed_issues: completed_issues, label_hint: label_hint
|
|
97
|
+
),
|
|
72
98
|
fill: false,
|
|
73
99
|
showLine: true,
|
|
74
100
|
borderColor: color,
|
|
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
|
|
|
79
105
|
result
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
def
|
|
108
|
+
def throughput_forecaster_url
|
|
109
|
+
params = {
|
|
110
|
+
throughputMode: 'data',
|
|
111
|
+
samplesText: @throughput_samples.join(','),
|
|
112
|
+
storyLow: @not_started_count,
|
|
113
|
+
storyHigh: @not_started_count
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
117
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
121
|
+
custom_mode = @issue_periods&.values&.any?
|
|
83
122
|
periods.collect do |period|
|
|
84
123
|
closed_issues = completed_issues.filter_map do |issue|
|
|
85
|
-
stop_date = issue.
|
|
86
|
-
|
|
124
|
+
stop_date = issue.started_stopped_dates.last
|
|
125
|
+
next unless stop_date
|
|
126
|
+
|
|
127
|
+
if custom_mode
|
|
128
|
+
[stop_date, issue] if @issue_periods[issue] == period.end
|
|
129
|
+
elsif period.include?(stop_date)
|
|
130
|
+
[stop_date, issue]
|
|
131
|
+
end
|
|
87
132
|
end
|
|
88
133
|
|
|
89
134
|
date_label = "on #{period.end}"
|
|
90
135
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
91
136
|
|
|
92
|
-
{
|
|
137
|
+
with_label_hint = label_hint ? " with #{label_hint}" : ''
|
|
138
|
+
{
|
|
139
|
+
y: closed_issues.size,
|
|
93
140
|
x: "#{period.end}T23:59:59",
|
|
94
|
-
title: ["#{closed_issues.size} items
|
|
95
|
-
closed_issues.collect
|
|
141
|
+
title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
|
|
142
|
+
closed_issues.collect do |_stop_date, issue|
|
|
143
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
144
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
145
|
+
end
|
|
96
146
|
}
|
|
97
147
|
end
|
|
98
148
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class TimeBasedHistogram < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
attr_reader :show_stats
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super
|
|
12
|
+
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def percentiles percs = nil
|
|
18
|
+
@percentiles = percs unless percs.nil?
|
|
19
|
+
@percentiles
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def disable_stats
|
|
23
|
+
@show_stats = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
histogram_items = all_items
|
|
28
|
+
rules_to_items = group_issues histogram_items
|
|
29
|
+
|
|
30
|
+
the_stats = {}
|
|
31
|
+
|
|
32
|
+
overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
|
|
33
|
+
the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
|
|
34
|
+
data_sets = rules_to_items.keys.collect do |rules|
|
|
35
|
+
the_label = rules.label
|
|
36
|
+
the_histogram = histogram_data_for(items: rules_to_items[rules])
|
|
37
|
+
if @show_stats
|
|
38
|
+
the_stats[the_label] = stats_for(
|
|
39
|
+
histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
data_set_for(
|
|
44
|
+
histogram_data: the_histogram,
|
|
45
|
+
label: the_label,
|
|
46
|
+
color: rules.color
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if data_sets.empty?
|
|
51
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
52
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
wrap_and_render(binding, __FILE__)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def histogram_data_for items:
|
|
59
|
+
items_hash = {}
|
|
60
|
+
items.each do |item|
|
|
61
|
+
days = value_for_item item
|
|
62
|
+
(items_hash[days] ||= []) << item if days.positive?
|
|
63
|
+
end
|
|
64
|
+
items_hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stats_for histogram_data:, percentiles:
|
|
68
|
+
return {} if histogram_data.empty?
|
|
69
|
+
|
|
70
|
+
total_values = histogram_data.values.sum
|
|
71
|
+
|
|
72
|
+
# Calculate the average
|
|
73
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
74
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
75
|
+
|
|
76
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
77
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
78
|
+
max_freq = sorted_histogram[-1][1]
|
|
79
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
80
|
+
|
|
81
|
+
minmax = histogram_data.keys.minmax
|
|
82
|
+
|
|
83
|
+
# Calculate percentiles
|
|
84
|
+
sorted_values = histogram_data.keys.sort
|
|
85
|
+
cumulative_counts = {}
|
|
86
|
+
cumulative_sum = 0
|
|
87
|
+
|
|
88
|
+
sorted_values.each do |value|
|
|
89
|
+
cumulative_sum += histogram_data[value]
|
|
90
|
+
cumulative_counts[value] = cumulative_sum
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
percentile_results = {}
|
|
94
|
+
percentiles.each do |percentile|
|
|
95
|
+
rank = (percentile / 100.0) * total_values
|
|
96
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
97
|
+
percentile_results[percentile] = percentile_value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
average: average,
|
|
102
|
+
mode: mode.collect(&:first).sort,
|
|
103
|
+
min: minmax[0],
|
|
104
|
+
max: minmax[1],
|
|
105
|
+
percentiles: percentile_results
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sort_items items
|
|
110
|
+
items
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def label_for_item item, hint:
|
|
114
|
+
raise NotImplementedError, "#{self.class} must implement label_for_item"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def data_set_for histogram_data:, label:, color:
|
|
118
|
+
{
|
|
119
|
+
type: 'bar',
|
|
120
|
+
label: label,
|
|
121
|
+
data: histogram_data.keys.sort.filter_map do |days|
|
|
122
|
+
items = histogram_data[days]
|
|
123
|
+
next if items.empty?
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
x: days,
|
|
127
|
+
y: items.size,
|
|
128
|
+
title: [title_for_item(count: items.size, value: days)] +
|
|
129
|
+
sort_items(items).collect do |item|
|
|
130
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
131
|
+
label_for_item(item, hint: hint)
|
|
132
|
+
end
|
|
133
|
+
}
|
|
134
|
+
end,
|
|
135
|
+
backgroundColor: color,
|
|
136
|
+
borderRadius: 0
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class TimeBasedScatterplot < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
super
|
|
10
|
+
|
|
11
|
+
@percentage_lines = []
|
|
12
|
+
@highest_y_value = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
items = all_items
|
|
17
|
+
data_sets = create_datasets items
|
|
18
|
+
overall_percent_line = calculate_percent_line(items)
|
|
19
|
+
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
20
|
+
|
|
21
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
|
|
22
|
+
|
|
23
|
+
wrap_and_render(binding, __FILE__)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_datasets items
|
|
27
|
+
data_sets = []
|
|
28
|
+
|
|
29
|
+
group_issues(items).each do |rules, items_by_type|
|
|
30
|
+
label = rules.label
|
|
31
|
+
color = rules.color
|
|
32
|
+
percent_line = calculate_percent_line items_by_type
|
|
33
|
+
data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
|
|
34
|
+
data_sets << {
|
|
35
|
+
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
36
|
+
data: data,
|
|
37
|
+
fill: false,
|
|
38
|
+
showLine: false,
|
|
39
|
+
backgroundColor: color
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
43
|
+
|
|
44
|
+
@percentage_lines << [percent_line, color]
|
|
45
|
+
end
|
|
46
|
+
data_sets
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_trend_lines
|
|
50
|
+
@show_trend_lines = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trend_line_data_set label:, data:, color:
|
|
54
|
+
points = data.collect do |hash|
|
|
55
|
+
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The trend calculation works with numbers only so convert Time to an int and back
|
|
59
|
+
calculator = TrendLineCalculator.new(points)
|
|
60
|
+
data_points = calculator.chart_datapoints(
|
|
61
|
+
range: time_range.begin.to_i..time_range.end.to_i,
|
|
62
|
+
max_y: @highest_y_value
|
|
63
|
+
)
|
|
64
|
+
data_points.each do |point_hash|
|
|
65
|
+
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
type: 'line',
|
|
70
|
+
label: "#{label} Trendline",
|
|
71
|
+
data: data_points,
|
|
72
|
+
fill: false,
|
|
73
|
+
borderWidth: 1,
|
|
74
|
+
markerType: 'none',
|
|
75
|
+
borderColor: color,
|
|
76
|
+
borderDash: [6, 3],
|
|
77
|
+
pointStyle: 'dash',
|
|
78
|
+
hidden: !@show_trend_lines
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def minimum_y_value
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def data_for_item item, rules: nil
|
|
87
|
+
y = y_value(item)
|
|
88
|
+
min = minimum_y_value
|
|
89
|
+
return nil if min && y < min
|
|
90
|
+
|
|
91
|
+
@highest_y_value = y if @highest_y_value < y
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
y: y,
|
|
95
|
+
x: chart_format(x_value(item)),
|
|
96
|
+
title: [title_value(item, rules: rules)]
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def calculate_percent_line items
|
|
101
|
+
min = minimum_y_value
|
|
102
|
+
times = items.collect { |item| y_value(item) }
|
|
103
|
+
times.reject! { |y| min && y < min }
|
|
104
|
+
index = times.size * 85 / 100
|
|
105
|
+
times.sort[index]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/chart_base'
|
|
4
|
+
|
|
5
|
+
class WipByColumnChart < ChartBase
|
|
6
|
+
attr_accessor :possible_statuses, :board_id
|
|
7
|
+
|
|
8
|
+
ColumnStats = Struct.new(:name, :min_wip_limit, :max_wip_limit, :wip_history, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize block
|
|
11
|
+
super()
|
|
12
|
+
header_text 'WIP by column'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<p>
|
|
15
|
+
This chart shows how much time each board column has spent at different WIP (Work in Progress) levels.
|
|
16
|
+
</p>
|
|
17
|
+
<p>
|
|
18
|
+
Each row on the Y axis is a WIP level (the number of items in that column at the same time).
|
|
19
|
+
Each column on the X axis is a board column.
|
|
20
|
+
The horizontal bars show what percentage of the total time that column spent at that WIP level —
|
|
21
|
+
a wider bar means more time was spent there.
|
|
22
|
+
</p>
|
|
23
|
+
<p>
|
|
24
|
+
A column whose widest bar is at WIP 1 was almost always working on one item at a time, often called
|
|
25
|
+
single-piece-flow. This team is likely collaborating very well and might have been
|
|
26
|
+
<a href="https://blog.mikebowler.ca/2021/06/19/pair-programming/">pairing</a> or
|
|
27
|
+
<a href="https://blog.mikebowler.ca/2023/04/22/ensemble-programming/">mobbing/ensembling</a>
|
|
28
|
+
and these teams tend to be very effective.
|
|
29
|
+
</p>
|
|
30
|
+
<p>
|
|
31
|
+
A column with wide bars at high WIP levels usually indicates a team that is highly siloed. Where each person
|
|
32
|
+
is working by themselves.
|
|
33
|
+
</p>
|
|
34
|
+
<p>
|
|
35
|
+
The dashed lines show the minimum and maximum WIP limits configured on the board.
|
|
36
|
+
If the widest bar sits well above the maximum limit, the limit may be set too low or not being respected.
|
|
37
|
+
If the widest bar sits below the minimum limit, consider whether that limit is still meaningful.
|
|
38
|
+
</p>
|
|
39
|
+
<p>
|
|
40
|
+
Hover over any bar to see the exact percentage.
|
|
41
|
+
</p>
|
|
42
|
+
<% if @all_boards[@board_id].team_managed_kanban? %>
|
|
43
|
+
<p>
|
|
44
|
+
If the data looks a bit off then that's probably because you're using a Team Managed project in "kanban mode".
|
|
45
|
+
For this specific case, we are unable to tell if an item is actually visible on the board and so we may
|
|
46
|
+
be reporting more items started than you actually see on the board. See
|
|
47
|
+
<a href="https://jirametrics.org/faq/#team-managed-kanban-backlog">the FAQ</a>.
|
|
48
|
+
</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
HTML
|
|
51
|
+
|
|
52
|
+
instance_eval(&block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def show_recommendations
|
|
56
|
+
@show_recommendations = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run
|
|
60
|
+
@header_text += " on board: #{current_board.name}"
|
|
61
|
+
stats = column_stats
|
|
62
|
+
@column_names = stats.collect(&:name)
|
|
63
|
+
@wip_data = stats.collect do |stat|
|
|
64
|
+
total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
|
|
65
|
+
next [] if total.zero?
|
|
66
|
+
|
|
67
|
+
stat.wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
|
|
68
|
+
end
|
|
69
|
+
@max_wip = stats.flat_map { |s| s.wip_history.collect { |wip, _| wip } }.max || 0
|
|
70
|
+
@wip_limits = stats.collect { |s| { 'min' => s.min_wip_limit, 'max' => s.max_wip_limit } }
|
|
71
|
+
@recommendations = @show_recommendations ? compute_recommendations(stats) : Array.new(stats.size)
|
|
72
|
+
|
|
73
|
+
trim_zero_end_columns
|
|
74
|
+
@recommendation_texts = @show_recommendations ? build_recommendation_texts : []
|
|
75
|
+
|
|
76
|
+
wrap_and_render(binding, __FILE__)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def column_stats
|
|
80
|
+
board = current_board
|
|
81
|
+
columns = board.visible_columns
|
|
82
|
+
status_to_column = build_status_to_column_map(columns)
|
|
83
|
+
relevant_issues = @issues.select { |issue| issue.board.id == @board_id }
|
|
84
|
+
|
|
85
|
+
current_column = initial_column_state(relevant_issues, status_to_column)
|
|
86
|
+
events = events_within_range(relevant_issues, status_to_column)
|
|
87
|
+
column_wip_seconds = compute_wip_seconds(columns, current_column, events)
|
|
88
|
+
|
|
89
|
+
columns.collect.with_index do |column, index|
|
|
90
|
+
ColumnStats.new(
|
|
91
|
+
name: column.name,
|
|
92
|
+
min_wip_limit: column.min,
|
|
93
|
+
max_wip_limit: column.max,
|
|
94
|
+
wip_history: column_wip_seconds[index].sort.to_a
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def trim_zero_end_columns
|
|
102
|
+
all_zero = @wip_data.map { |col| col.none? { |e| e['wip'].positive? } }
|
|
103
|
+
first = all_zero.index(false)
|
|
104
|
+
return unless first
|
|
105
|
+
|
|
106
|
+
last = all_zero.rindex(false)
|
|
107
|
+
@column_names = @column_names[first..last]
|
|
108
|
+
@wip_data = @wip_data[first..last]
|
|
109
|
+
@wip_limits = @wip_limits[first..last]
|
|
110
|
+
@recommendations = @recommendations[first..last]
|
|
111
|
+
@max_wip = @wip_data.flat_map { |col| col.map { |e| e['wip'] } }.max || 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compute_recommendations stats
|
|
115
|
+
stats.collect do |stat|
|
|
116
|
+
next nil if stat.wip_history.empty?
|
|
117
|
+
|
|
118
|
+
total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
|
|
119
|
+
next nil if total.zero?
|
|
120
|
+
|
|
121
|
+
cumulative = 0
|
|
122
|
+
stat.wip_history.sort.find do |_wip, seconds|
|
|
123
|
+
cumulative += seconds
|
|
124
|
+
cumulative / total >= 0.85
|
|
125
|
+
end&.first
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_recommendation_texts
|
|
130
|
+
@column_names.each_with_index.filter_map do |name, i|
|
|
131
|
+
rec = @recommendations[i]
|
|
132
|
+
next if rec.nil?
|
|
133
|
+
|
|
134
|
+
next "Almost nothing passes through column '#{name}'. Do we still need it?" if rec.zero?
|
|
135
|
+
|
|
136
|
+
max = @wip_limits[i]['max']
|
|
137
|
+
if max.nil?
|
|
138
|
+
"Add a WIP limit to column '#{name}' — suggested maximum: #{rec}"
|
|
139
|
+
elsif rec < max
|
|
140
|
+
"Lower the WIP limit for '#{name}' from #{max} to #{rec}"
|
|
141
|
+
elsif rec > max
|
|
142
|
+
"Raise the WIP limit for '#{name}' from #{max} to #{rec}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def format_pct seconds, total
|
|
148
|
+
raw = seconds / total * 100.0
|
|
149
|
+
(1..10).each do |decimals|
|
|
150
|
+
rounded = raw.round(decimals)
|
|
151
|
+
next if rounded.zero? && raw.positive?
|
|
152
|
+
next if rounded >= 100.0 && raw < 100.0
|
|
153
|
+
|
|
154
|
+
return rounded
|
|
155
|
+
end
|
|
156
|
+
raw
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_status_to_column_map columns
|
|
160
|
+
columns.each_with_object({}).with_index do |(column, map), index|
|
|
161
|
+
column.status_ids.each { |id| map[id] = index }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def initial_column_state relevant_issues, status_to_column
|
|
166
|
+
relevant_issues.each_with_object({}) do |issue, hash|
|
|
167
|
+
started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
|
168
|
+
in_wip = started_time &&
|
|
169
|
+
started_time <= time_range.begin &&
|
|
170
|
+
(stopped_time.nil? || stopped_time > time_range.begin)
|
|
171
|
+
unless in_wip
|
|
172
|
+
hash[issue] = nil
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
last_change = issue.status_changes.reverse.find { |c| c.time <= time_range.begin }
|
|
177
|
+
hash[issue] = last_change ? status_to_column[last_change.value_id] : nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def events_within_range relevant_issues, status_to_column
|
|
182
|
+
events = []
|
|
183
|
+
relevant_issues.each do |issue|
|
|
184
|
+
started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
|
185
|
+
next unless started_time
|
|
186
|
+
|
|
187
|
+
# Issue starts within the window: add an explicit event to enter WIP in its current column
|
|
188
|
+
if started_time > time_range.begin && started_time <= time_range.end
|
|
189
|
+
last_change = issue.status_changes.reverse.find { |c| c.time <= started_time }
|
|
190
|
+
events << [started_time, issue, last_change ? status_to_column[last_change.value_id] : nil]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Status changes while the issue is actively in WIP and within the window
|
|
194
|
+
issue.status_changes.each do |change|
|
|
195
|
+
next unless change.time > time_range.begin
|
|
196
|
+
next if change.time > time_range.end
|
|
197
|
+
next unless change.time >= started_time
|
|
198
|
+
next if stopped_time && change.time >= stopped_time
|
|
199
|
+
|
|
200
|
+
events << [change.time, issue, status_to_column[change.value_id]]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Issue stops within the window: add an explicit event to exit WIP
|
|
204
|
+
if stopped_time && stopped_time > time_range.begin && stopped_time <= time_range.end
|
|
205
|
+
events << [stopped_time, issue, nil]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
events.sort_by!(&:first)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def compute_wip_seconds columns, current_column, events
|
|
212
|
+
wip_counts = Array.new(columns.size, 0)
|
|
213
|
+
current_column.each_value { |col| wip_counts[col] += 1 unless col.nil? }
|
|
214
|
+
|
|
215
|
+
column_wip_seconds = Array.new(columns.size) { Hash.new(0) }
|
|
216
|
+
prev_time = time_range.begin
|
|
217
|
+
|
|
218
|
+
events.each do |time, issue, new_col|
|
|
219
|
+
elapsed = (time - prev_time).to_i
|
|
220
|
+
if elapsed.positive?
|
|
221
|
+
wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
|
|
222
|
+
prev_time = time
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
old_col = current_column[issue]
|
|
226
|
+
wip_counts[old_col] -= 1 unless old_col.nil?
|
|
227
|
+
wip_counts[new_col] += 1 unless new_col.nil?
|
|
228
|
+
current_column[issue] = new_col
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
elapsed = (time_range.end - prev_time).to_i
|
|
232
|
+
wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed } if elapsed.positive?
|
|
233
|
+
|
|
234
|
+
column_wip_seconds
|
|
235
|
+
end
|
|
236
|
+
end
|