jirametrics 2.20.1 → 2.25
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 +189 -133
- 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/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +24 -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 +103 -0
- data/lib/jirametrics/change_item.rb +13 -5
- data/lib/jirametrics/chart_base.rb +124 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +200 -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 +15 -85
- data/lib/jirametrics/daily_view.rb +35 -11
- 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 +37 -11
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +76 -5
- data/lib/jirametrics/downloader_for_cloud.rb +39 -0
- 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 +1 -1
- data/lib/jirametrics/examples/standard_project.rb +28 -18
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +7 -3
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +106 -0
- data/lib/jirametrics/groupable_issue_chart.rb +9 -1
- data/lib/jirametrics/grouping_rules.rb +21 -3
- 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 +504 -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 +134 -0
- data/lib/jirametrics/html/index.erb +6 -1
- data/lib/jirametrics/html/index.js +76 -2
- 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} +8 -9
- data/lib/jirametrics/html_generator.rb +31 -0
- data/lib/jirametrics/html_report_config.rb +26 -39
- data/lib/jirametrics/issue.rb +186 -88
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +78 -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 +81 -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 +9 -3
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +76 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +56 -22
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- data/lib/jirametrics.rb +8 -1
- metadata +22 -5
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
HTML
|
|
68
|
+
instance_eval(&block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def column_rules &block
|
|
72
|
+
@column_rules_block = block
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def triangle_color color
|
|
76
|
+
@triangle_color = parse_theme_color(color)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def arrival_rate_line_color color
|
|
80
|
+
@arrival_rate_line_color = parse_theme_color(color)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def departure_rate_line_color color
|
|
84
|
+
@departure_rate_line_color = parse_theme_color(color)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run
|
|
88
|
+
all_columns = current_board.visible_columns
|
|
89
|
+
|
|
90
|
+
column_rules_list = all_columns.map do |column|
|
|
91
|
+
rules = CfdColumnRules.new
|
|
92
|
+
@column_rules_block&.call(column, rules)
|
|
93
|
+
rules
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
|
|
97
|
+
active_columns = active_pairs.map(&:first)
|
|
98
|
+
active_rules = active_pairs.map(&:last)
|
|
99
|
+
|
|
100
|
+
cfd = CfdDataBuilder.new(
|
|
101
|
+
board: current_board,
|
|
102
|
+
issues: issues,
|
|
103
|
+
date_range: date_range,
|
|
104
|
+
columns: active_columns
|
|
105
|
+
).run
|
|
106
|
+
|
|
107
|
+
columns = cfd[:columns]
|
|
108
|
+
daily_counts = cfd[:daily_counts]
|
|
109
|
+
correction_windows = cfd[:correction_windows]
|
|
110
|
+
column_count = columns.size
|
|
111
|
+
|
|
112
|
+
# Convert cumulative totals to marginal band heights for Chart.js stacking.
|
|
113
|
+
# cumulative[i] = issues that reached column i or further.
|
|
114
|
+
# marginal[i] = cumulative[i] - cumulative[i+1] (last column: marginal = cumulative)
|
|
115
|
+
daily_marginals = daily_counts.transform_values do |cumulative|
|
|
116
|
+
cumulative.each_with_index.map do |count, i|
|
|
117
|
+
i < column_count - 1 ? count - cumulative[i + 1] : count
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
border_colors = active_rules.map { |rules| rules.color || random_color }
|
|
122
|
+
|
|
123
|
+
fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
|
|
124
|
+
|
|
125
|
+
# Datasets in reversed order: rightmost column first (bottom of stack), leftmost last (top).
|
|
126
|
+
data_sets = columns.each_with_index.map do |name, col_index|
|
|
127
|
+
col_windows = correction_windows
|
|
128
|
+
.select { |w| w[:column_index] == col_index }
|
|
129
|
+
.map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
label: active_rules[col_index].label || name,
|
|
133
|
+
label_hint: active_rules[col_index].label_hint,
|
|
134
|
+
data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
|
|
135
|
+
backgroundColor: fill_colors[col_index],
|
|
136
|
+
borderColor: border_colors[col_index],
|
|
137
|
+
fill: true,
|
|
138
|
+
tension: 0,
|
|
139
|
+
segment: Segment.new(col_windows)
|
|
140
|
+
}
|
|
141
|
+
end.reverse
|
|
142
|
+
|
|
143
|
+
# Correction windows for the afterDraw hatch plugin, with dataset index in
|
|
144
|
+
# Chart.js dataset array (reversed: done column = index 0).
|
|
145
|
+
hatch_windows = correction_windows.map do |w|
|
|
146
|
+
{
|
|
147
|
+
dataset_index: column_count - 1 - w[:column_index],
|
|
148
|
+
start_date: w[:start_date].to_s,
|
|
149
|
+
end_date: w[:end_date].to_s,
|
|
150
|
+
color: border_colors[w[:column_index]],
|
|
151
|
+
fill_color: fill_colors[w[:column_index]]
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@triangle_color = parse_theme_color(['#333333', '#ffffff']) unless instance_variable_defined?(:@triangle_color)
|
|
156
|
+
unless instance_variable_defined?(:@arrival_rate_line_color)
|
|
157
|
+
@arrival_rate_line_color = 'rgba(255,138,101,0.85)'
|
|
158
|
+
end
|
|
159
|
+
unless instance_variable_defined?(:@departure_rate_line_color)
|
|
160
|
+
@departure_rate_line_color = 'rgba(128,203,196,0.85)'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
wrap_and_render(binding, __FILE__)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def parse_theme_color color
|
|
169
|
+
return color unless color.is_a?(Array)
|
|
170
|
+
|
|
171
|
+
raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
|
|
172
|
+
raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
|
|
173
|
+
|
|
174
|
+
if color.any? { |c| c.start_with?('--') }
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
'CSS variable references are not supported as color pair elements; use a literal color value instead'
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
light, dark = color
|
|
180
|
+
RawJavascript.new(
|
|
181
|
+
"(document.documentElement.dataset.theme === 'dark' || " \
|
|
182
|
+
'(!document.documentElement.dataset.theme && ' \
|
|
183
|
+
"window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
|
|
184
|
+
"? #{dark.to_json} : #{light.to_json}"
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def hex_to_rgba hex, alpha
|
|
189
|
+
r, g, b = hex.delete_prefix('#').scan(/../).map { |c| c.to_i(16) }
|
|
190
|
+
"rgba(#{r}, #{g}, #{b}, #{alpha})"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def fill_color_for rules, border
|
|
194
|
+
if rules.color.nil? || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
|
|
195
|
+
hex_to_rgba(border, 0.35)
|
|
196
|
+
else
|
|
197
|
+
rules.color
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
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
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
4
|
-
|
|
5
|
-
class CycletimeScatterplot < ChartBase
|
|
6
|
-
include GroupableIssueChart
|
|
3
|
+
require 'jirametrics/time_based_scatterplot'
|
|
7
4
|
|
|
5
|
+
class CycletimeScatterplot < TimeBasedScatterplot
|
|
8
6
|
attr_accessor :possible_statuses
|
|
9
7
|
|
|
10
8
|
def initialize block
|
|
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
|
|
|
26
24
|
</div>
|
|
27
25
|
#{describe_non_working_days}
|
|
28
26
|
HTML
|
|
27
|
+
@x_axis_title = 'Date completed'
|
|
28
|
+
@y_axis_title = 'Cycletime in days'
|
|
29
29
|
|
|
30
30
|
init_configuration_block block do
|
|
31
31
|
grouping_rules do |issue, rule|
|
|
@@ -33,95 +33,25 @@ class CycletimeScatterplot < ChartBase
|
|
|
33
33
|
rule.color = color_for type: issue.type
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
|
-
|
|
37
|
-
@percentage_lines = []
|
|
38
|
-
@highest_cycletime = 0
|
|
39
36
|
end
|
|
40
37
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
data_sets = create_datasets completed_issues
|
|
45
|
-
overall_percent_line = calculate_percent_line(completed_issues)
|
|
46
|
-
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
47
|
-
|
|
48
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
49
|
-
|
|
50
|
-
wrap_and_render(binding, __FILE__)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def create_datasets completed_issues
|
|
54
|
-
data_sets = []
|
|
55
|
-
|
|
56
|
-
group_issues(completed_issues).each do |rules, completed_issues_by_type|
|
|
57
|
-
label = rules.label
|
|
58
|
-
color = rules.color
|
|
59
|
-
percent_line = calculate_percent_line completed_issues_by_type
|
|
60
|
-
data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
|
|
61
|
-
data_sets << {
|
|
62
|
-
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
63
|
-
data: data,
|
|
64
|
-
fill: false,
|
|
65
|
-
showLine: false,
|
|
66
|
-
backgroundColor: color
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
70
|
-
|
|
71
|
-
@percentage_lines << [percent_line, color]
|
|
72
|
-
end
|
|
73
|
-
data_sets
|
|
38
|
+
def all_items
|
|
39
|
+
completed_issues_in_range include_unstarted: false
|
|
74
40
|
end
|
|
75
41
|
|
|
76
|
-
def
|
|
77
|
-
|
|
42
|
+
def x_value item
|
|
43
|
+
item.started_stopped_times.last
|
|
78
44
|
end
|
|
79
45
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# The trend calculation works with numbers only so convert Time to an int and back
|
|
86
|
-
calculator = TrendLineCalculator.new(points)
|
|
87
|
-
data_points = calculator.chart_datapoints(
|
|
88
|
-
range: time_range.begin.to_i..time_range.end.to_i,
|
|
89
|
-
max_y: @highest_cycletime
|
|
90
|
-
)
|
|
91
|
-
data_points.each do |point_hash|
|
|
92
|
-
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
type: 'line',
|
|
97
|
-
label: "#{label} Trendline",
|
|
98
|
-
data: data_points,
|
|
99
|
-
fill: false,
|
|
100
|
-
borderWidth: 1,
|
|
101
|
-
markerType: 'none',
|
|
102
|
-
borderColor: color,
|
|
103
|
-
borderDash: [6, 3],
|
|
104
|
-
pointStyle: 'dash',
|
|
105
|
-
hidden: !@show_trend_lines
|
|
106
|
-
}
|
|
46
|
+
def y_value item
|
|
47
|
+
item.board.cycletime.cycletime(item)
|
|
107
48
|
end
|
|
108
49
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@highest_cycletime = cycle_time if @highest_cycletime < cycle_time
|
|
114
|
-
|
|
115
|
-
{
|
|
116
|
-
y: cycle_time,
|
|
117
|
-
x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
|
|
118
|
-
title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
|
|
119
|
-
}
|
|
50
|
+
def title_value item, rules: nil
|
|
51
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
52
|
+
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
|
|
120
53
|
end
|
|
121
54
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
index = times.size * 85 / 100
|
|
125
|
-
times.sort[index]
|
|
126
|
-
end
|
|
55
|
+
# Kept for backwards compatibility with existing callers and specs
|
|
56
|
+
alias data_for_issue data_for_item
|
|
127
57
|
end
|
|
@@ -9,7 +9,8 @@ class DailyView < ChartBase
|
|
|
9
9
|
header_text 'Daily View'
|
|
10
10
|
description_text <<-HTML
|
|
11
11
|
<div class="p">
|
|
12
|
-
This view shows all the items you'll want to discuss during your daily
|
|
12
|
+
This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
|
|
13
|
+
coordination meeting
|
|
13
14
|
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
14
15
|
items are at the top, and the least at the bottom.
|
|
15
16
|
</div>
|
|
@@ -35,7 +36,7 @@ class DailyView < ChartBase
|
|
|
35
36
|
|
|
36
37
|
def select_aging_issues
|
|
37
38
|
aging_issues = issues.select do |issue|
|
|
38
|
-
started_at, stopped_at = issue.
|
|
39
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
39
40
|
started_at && !stopped_at
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -72,7 +73,7 @@ class DailyView < ChartBase
|
|
|
72
73
|
|
|
73
74
|
def make_blocked_stalled_lines issue
|
|
74
75
|
today = date_range.end
|
|
75
|
-
started_date = issue.
|
|
76
|
+
started_date = issue.started_stopped_times.first&.to_date
|
|
76
77
|
return [] unless started_date
|
|
77
78
|
|
|
78
79
|
blocked_stalled = issue.blocked_stalled_by_date(
|
|
@@ -86,9 +87,14 @@ class DailyView < ChartBase
|
|
|
86
87
|
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
87
88
|
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
88
89
|
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
89
|
-
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
90
90
|
blocking_issue = issues.find { |i| i.key == key }
|
|
91
|
-
|
|
91
|
+
if blocking_issue
|
|
92
|
+
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: #{key}</div>"
|
|
93
|
+
lines << blocking_issue
|
|
94
|
+
lines << '</section>'
|
|
95
|
+
else
|
|
96
|
+
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
97
|
+
end
|
|
92
98
|
end
|
|
93
99
|
elsif blocked_stalled.stalled_by_status?
|
|
94
100
|
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
@@ -146,7 +152,18 @@ class DailyView < ChartBase
|
|
|
146
152
|
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
147
153
|
end
|
|
148
154
|
|
|
149
|
-
|
|
155
|
+
if issue.due_date
|
|
156
|
+
today = date_range.end
|
|
157
|
+
days = (issue.due_date - today).to_i
|
|
158
|
+
relative =
|
|
159
|
+
if days.zero? then 'today'
|
|
160
|
+
elsif days.positive? then "in #{label_days days}"
|
|
161
|
+
else "#{label_days(-days)} ago"
|
|
162
|
+
end
|
|
163
|
+
content = "#{issue.due_date} (#{relative})"
|
|
164
|
+
content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
|
|
165
|
+
line << "Due: <b>#{content}</b>"
|
|
166
|
+
end
|
|
150
167
|
|
|
151
168
|
block = lambda do |collection, label|
|
|
152
169
|
unless collection.empty?
|
|
@@ -166,7 +183,7 @@ class DailyView < ChartBase
|
|
|
166
183
|
|
|
167
184
|
return lines if subtasks.empty?
|
|
168
185
|
|
|
169
|
-
lines <<
|
|
186
|
+
lines << "<section><div class=\"foldable startFolded\">Child issues (#{subtasks.count})</div>"
|
|
170
187
|
lines += subtasks
|
|
171
188
|
lines << '</section>'
|
|
172
189
|
|
|
@@ -237,9 +254,11 @@ class DailyView < ChartBase
|
|
|
237
254
|
|
|
238
255
|
def make_description_lines issue
|
|
239
256
|
description = issue.raw['fields']['description']
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
257
|
+
return [] unless description
|
|
258
|
+
|
|
259
|
+
text = "<div class='foldable startFolded'>Description</div>" \
|
|
260
|
+
"<div>#{atlassian_document_format.to_html(description)}</div>"
|
|
261
|
+
[[text]]
|
|
243
262
|
end
|
|
244
263
|
|
|
245
264
|
def assemble_issue_lines issue, child:
|
|
@@ -247,6 +266,7 @@ class DailyView < ChartBase
|
|
|
247
266
|
|
|
248
267
|
lines = []
|
|
249
268
|
lines << [make_title_line(issue: issue, done: done)]
|
|
269
|
+
lines << make_not_visible_line(issue)
|
|
250
270
|
lines += make_parent_lines(issue) unless child
|
|
251
271
|
lines += make_stats_lines(issue: issue, done: done)
|
|
252
272
|
unless done
|
|
@@ -256,7 +276,7 @@ class DailyView < ChartBase
|
|
|
256
276
|
lines += make_child_lines(issue)
|
|
257
277
|
lines += make_history_lines(issue)
|
|
258
278
|
end
|
|
259
|
-
lines
|
|
279
|
+
lines.compact
|
|
260
280
|
end
|
|
261
281
|
|
|
262
282
|
def render_issue issue, child:
|
|
@@ -278,4 +298,8 @@ class DailyView < ChartBase
|
|
|
278
298
|
end
|
|
279
299
|
result << '</div>'
|
|
280
300
|
end
|
|
301
|
+
|
|
302
|
+
def make_not_visible_line issue
|
|
303
|
+
not_visible_text issue
|
|
304
|
+
end
|
|
281
305
|
end
|
|
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
started, stopped = issue.
|
|
53
|
-
|
|
54
|
-
rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
|
|
52
|
+
started, stopped = issue.started_stopped_dates
|
|
55
53
|
|
|
56
54
|
if stopped && started.nil? # We can't tell when it started
|
|
57
55
|
@has_completed_but_not_started = true
|
|
@@ -72,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
72
70
|
rules.label = 'Start date unknown'
|
|
73
71
|
rules.color = '--body-background'
|
|
74
72
|
rules.group_priority = 11
|
|
75
|
-
created_days = rules.current_date - created
|
|
73
|
+
created_days = rules.current_date - created
|
|
76
74
|
rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
|
|
77
75
|
end
|
|
78
76
|
end
|
|
@@ -84,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def group_by_age started:, rules:
|
|
87
|
-
age = rules.current_date - started + 1
|
|
85
|
+
age = (rules.current_date - started).to_i + 1
|
|
86
|
+
rules.issue_hint = "(age: #{label_days age})"
|
|
88
87
|
|
|
89
88
|
case age
|
|
90
89
|
when 1
|