jirametrics 2.24 → 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/aging_work_bar_chart.rb +3 -3
- data/lib/jirametrics/aging_work_table.rb +3 -4
- data/lib/jirametrics/atlassian_document_format.rb +8 -19
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +103 -0
- data/lib/jirametrics/chart_base.rb +6 -0
- data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
- data/lib/jirametrics/cycletime_histogram.rb +1 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +2 -2
- data/lib/jirametrics/daily_view.rb +4 -3
- 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 +3 -1
- data/lib/jirametrics/data_quality_report.rb +4 -4
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +12 -13
- data/lib/jirametrics/expedited_chart.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -1
- data/lib/jirametrics/github_gateway.rb +1 -1
- data/lib/jirametrics/grouping_rules.rb +1 -1
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +33 -1
- data/lib/jirametrics/html/index.css +3 -0
- data/lib/jirametrics/html/throughput_chart.erb +35 -1
- data/lib/jirametrics/html/time_based_histogram.erb +2 -0
- data/lib/jirametrics/html_report_config.rb +19 -15
- data/lib/jirametrics/issue.rb +18 -2
- data/lib/jirametrics/issue_printer.rb +1 -1
- data/lib/jirametrics/project_config.rb +1 -1
- 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/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +50 -22
- data/lib/jirametrics/time_based_scatterplot.rb +3 -3
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615528aa77577881d7658b0ce059e9e3972ce4285aeb3c1081c4347dd1a20ca4
|
|
4
|
+
data.tar.gz: 483cc9b7535da95ca2249813e5ad8ff924334f65c8b7fa84c5705367a9d1b147
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36acebeef4d036c6ed043f0073177944c390d5c80ba6ef162319f6a9bbb67bb2762ca4a345f4160d0f1d3fdc7047e257474920c09da9c5770c8a0e45a4748e59
|
|
7
|
+
data.tar.gz: 2c6f92cdb1d61b49ed7b318bc5c2108649ad387e9af51c312ade43f1522f3cc842dc949dde7d0373d41dc25630bbf9e5a3f883fc9255423ecd6e19c9008b3a4f
|
|
@@ -66,7 +66,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
66
66
|
|
|
67
67
|
def adjust_time_date_ranges_to_start_from_earliest_issue_start aging_issues
|
|
68
68
|
earliest_start_time = aging_issues.collect do |issue|
|
|
69
|
-
issue.
|
|
69
|
+
issue.started_stopped_times.first
|
|
70
70
|
end.min
|
|
71
71
|
return if earliest_start_time.nil? || earliest_start_time >= @time_range.begin
|
|
72
72
|
|
|
@@ -102,7 +102,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
102
102
|
|
|
103
103
|
def select_aging_issues issues:
|
|
104
104
|
issues.select do |issue|
|
|
105
|
-
started_time, stopped_time = issue.
|
|
105
|
+
started_time, stopped_time = issue.started_stopped_times
|
|
106
106
|
next false unless started_time && stopped_time.nil?
|
|
107
107
|
|
|
108
108
|
age = (date_range.end - started_time.to_date).to_i + 1
|
|
@@ -128,7 +128,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
128
128
|
|
|
129
129
|
def collect_status_ranges issue:, now:
|
|
130
130
|
ranges = []
|
|
131
|
-
issue_started_time = issue.
|
|
131
|
+
issue_started_time = issue.started_stopped_times.first
|
|
132
132
|
previous_start = nil
|
|
133
133
|
previous_status = nil
|
|
134
134
|
issue.status_changes.each do |change|
|
|
@@ -50,15 +50,14 @@ class AgingWorkTable < ChartBase
|
|
|
50
50
|
|
|
51
51
|
def expedited_but_not_started
|
|
52
52
|
@issues.select do |issue|
|
|
53
|
-
started_time, stopped_time = issue.
|
|
53
|
+
started_time, stopped_time = issue.started_stopped_times
|
|
54
54
|
started_time.nil? && stopped_time.nil? && issue.expedited?
|
|
55
55
|
end.sort_by(&:created)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def select_aging_issues
|
|
59
59
|
aging_issues = @issues.select do |issue|
|
|
60
|
-
|
|
61
|
-
started, stopped = cycletime.started_stopped_times(issue)
|
|
60
|
+
started, stopped = issue.started_stopped_times
|
|
62
61
|
next false if started.nil? || stopped
|
|
63
62
|
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
64
63
|
|
|
@@ -77,7 +76,7 @@ class AgingWorkTable < ChartBase
|
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def blocked_text issue
|
|
80
|
-
started_time, _stopped_time = issue.
|
|
79
|
+
started_time, _stopped_time = issue.started_stopped_times
|
|
81
80
|
return nil if started_time.nil?
|
|
82
81
|
|
|
83
82
|
current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
|
|
@@ -44,9 +44,9 @@ class AtlassianDocumentFormat
|
|
|
44
44
|
when 'codeBlock' then ['<code>', '</code>']
|
|
45
45
|
when 'date'
|
|
46
46
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
47
|
-
when 'decisionItem'
|
|
47
|
+
when 'decisionItem', 'listItem' then ['<li>', '</li>']
|
|
48
48
|
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
49
|
-
when 'emoji'
|
|
49
|
+
when 'emoji', 'status' then [node_attrs['text'], nil]
|
|
50
50
|
when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
|
|
51
51
|
when 'hardBreak' then ['<br />', nil]
|
|
52
52
|
when 'heading'
|
|
@@ -55,7 +55,6 @@ class AtlassianDocumentFormat
|
|
|
55
55
|
when 'inlineCard'
|
|
56
56
|
url = node_attrs['url']
|
|
57
57
|
["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
|
|
58
|
-
when 'listItem' then ['<li>', '</li>']
|
|
59
58
|
when 'media'
|
|
60
59
|
text = node_attrs['alt'] || node_attrs['id']
|
|
61
60
|
["Media: #{text}", nil]
|
|
@@ -65,7 +64,6 @@ class AtlassianDocumentFormat
|
|
|
65
64
|
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
66
65
|
when 'paragraph' then ['<p>', '</p>']
|
|
67
66
|
when 'rule' then ['<hr />', nil]
|
|
68
|
-
when 'status' then [node_attrs['text'], nil]
|
|
69
67
|
when 'table' then ['<table>', '</table>']
|
|
70
68
|
when 'tableCell' then ['<td>', '</td>']
|
|
71
69
|
when 'tableHeader' then ['<th>', '</th>']
|
|
@@ -87,38 +85,29 @@ class AtlassianDocumentFormat
|
|
|
87
85
|
adf_node_render(node) do |n|
|
|
88
86
|
node_attrs = n['attrs']
|
|
89
87
|
case n['type']
|
|
90
|
-
when 'blockquote'
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
when 'blockquote', 'bulletList', 'codeBlock',
|
|
89
|
+
'mediaSingle', 'mediaGroup',
|
|
90
|
+
'orderedList', 'table', 'taskList' then ['', nil]
|
|
93
91
|
when 'date'
|
|
94
92
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
95
93
|
when 'decisionItem' then ['- ', "\n"]
|
|
96
94
|
when 'decisionList' then ["Decisions:\n", nil]
|
|
97
|
-
when 'emoji'
|
|
95
|
+
when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
|
|
98
96
|
when 'expand' then ["#{node_attrs['title']}\n", nil]
|
|
99
97
|
when 'hardBreak' then ["\n", nil]
|
|
100
|
-
when 'heading'
|
|
98
|
+
when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
|
|
101
99
|
when 'inlineCard' then [node_attrs['url'], nil]
|
|
102
100
|
when 'listItem' then ['- ', nil]
|
|
103
101
|
when 'media'
|
|
104
102
|
text = node_attrs['alt'] || node_attrs['id']
|
|
105
103
|
["Media: #{text}", nil]
|
|
106
|
-
when 'mediaSingle', 'mediaGroup' then ['', nil]
|
|
107
|
-
when 'mention' then [node_attrs['text'], nil]
|
|
108
|
-
when 'orderedList' then ['', nil]
|
|
109
104
|
when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
|
|
110
|
-
when 'paragraph' then ['', "\n"]
|
|
111
105
|
when 'rule' then ["---\n", nil]
|
|
112
|
-
when '
|
|
113
|
-
when 'table' then ['', nil]
|
|
114
|
-
when 'tableCell' then ['', "\t"]
|
|
115
|
-
when 'tableHeader' then ['', "\t"]
|
|
116
|
-
when 'tableRow' then ['', "\n"]
|
|
106
|
+
when 'tableCell', 'tableHeader' then ['', "\t"]
|
|
117
107
|
when 'text' then [n['text'], nil]
|
|
118
108
|
when 'taskItem'
|
|
119
109
|
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
120
110
|
["#{state} ", "\n"]
|
|
121
|
-
when 'taskList' then ['', nil]
|
|
122
111
|
else
|
|
123
112
|
["[Unparseable: #{n['type']}]\n", nil]
|
|
124
113
|
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,103 @@
|
|
|
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
|
+
high_water_mark = nil
|
|
35
|
+
correction_open_since = nil
|
|
36
|
+
correction_windows = []
|
|
37
|
+
hwm_timeline = [] # sorted chronologically by date
|
|
38
|
+
|
|
39
|
+
issue.status_changes.each do |change|
|
|
40
|
+
col_index = column_map[change.value_id]
|
|
41
|
+
next if col_index.nil?
|
|
42
|
+
|
|
43
|
+
if high_water_mark.nil? || col_index > high_water_mark
|
|
44
|
+
# Forward movement: advance hwm, close any open correction window, record timeline entry
|
|
45
|
+
if correction_open_since
|
|
46
|
+
correction_windows << {
|
|
47
|
+
start_date: correction_open_since,
|
|
48
|
+
end_date: change.time.to_date,
|
|
49
|
+
column_index: high_water_mark
|
|
50
|
+
}
|
|
51
|
+
correction_open_since = nil
|
|
52
|
+
end
|
|
53
|
+
high_water_mark = col_index
|
|
54
|
+
hwm_timeline << [change.time.to_date, high_water_mark]
|
|
55
|
+
elsif col_index == high_water_mark && correction_open_since
|
|
56
|
+
# Same-column recovery: close the correction window without changing hwm or adding timeline entry
|
|
57
|
+
correction_windows << {
|
|
58
|
+
start_date: correction_open_since,
|
|
59
|
+
end_date: change.time.to_date,
|
|
60
|
+
column_index: high_water_mark
|
|
61
|
+
}
|
|
62
|
+
correction_open_since = nil
|
|
63
|
+
elsif col_index < high_water_mark
|
|
64
|
+
# Backwards movement: open correction window if not already open
|
|
65
|
+
correction_open_since ||= change.time.to_date
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if correction_open_since
|
|
70
|
+
correction_windows << {
|
|
71
|
+
start_date: correction_open_since,
|
|
72
|
+
end_date: @date_range.end,
|
|
73
|
+
column_index: high_water_mark
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ hwm_timeline: hwm_timeline, correction_windows: correction_windows }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hwm_at hwm_timeline, date
|
|
81
|
+
result = nil
|
|
82
|
+
hwm_timeline.each do |timeline_date, hwm|
|
|
83
|
+
break if timeline_date > date
|
|
84
|
+
|
|
85
|
+
result = hwm
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_daily_counts issue_states
|
|
91
|
+
column_count = @columns.size
|
|
92
|
+
@date_range.each_with_object({}) do |date, result|
|
|
93
|
+
counts = Array.new(column_count, 0)
|
|
94
|
+
issue_states.each do |state|
|
|
95
|
+
hwm = hwm_at(state[:hwm_timeline], date)
|
|
96
|
+
next if hwm.nil?
|
|
97
|
+
|
|
98
|
+
(0..hwm).each { |i| counts[i] += 1 }
|
|
99
|
+
end
|
|
100
|
+
result[date] = counts
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -86,6 +86,12 @@ class ChartBase
|
|
|
86
86
|
"#{hours} hour#{'s' unless hours == 1}"
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
def label_minutes minutes
|
|
90
|
+
return 'unknown' if minutes.nil?
|
|
91
|
+
|
|
92
|
+
"#{minutes} minute#{'s' unless minutes == 1}"
|
|
93
|
+
end
|
|
94
|
+
|
|
89
95
|
def label_issues count
|
|
90
96
|
"#{count} issue#{'s' unless count == 1}"
|
|
91
97
|
end
|
|
@@ -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
|
|
@@ -32,7 +32,7 @@ class CycletimeHistogram < TimeBasedHistogram
|
|
|
32
32
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
33
33
|
|
|
34
34
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
35
|
-
stopped_issues.select { |issue| issue.
|
|
35
|
+
stopped_issues.select { |issue| issue.started_stopped_times.first }
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def value_for_item issue
|
|
@@ -40,14 +40,14 @@ class CycletimeScatterplot < TimeBasedScatterplot
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def x_value item
|
|
43
|
-
item.
|
|
43
|
+
item.started_stopped_times.last
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def y_value item
|
|
47
47
|
item.board.cycletime.cycletime(item)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def title_value item
|
|
50
|
+
def title_value item, rules: nil
|
|
51
51
|
hint = @issue_hints&.fetch(item, nil)
|
|
52
52
|
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
|
|
53
53
|
end
|
|
@@ -36,7 +36,7 @@ class DailyView < ChartBase
|
|
|
36
36
|
|
|
37
37
|
def select_aging_issues
|
|
38
38
|
aging_issues = issues.select do |issue|
|
|
39
|
-
started_at, stopped_at = issue.
|
|
39
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
40
40
|
started_at && !stopped_at
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -73,7 +73,7 @@ class DailyView < ChartBase
|
|
|
73
73
|
|
|
74
74
|
def make_blocked_stalled_lines issue
|
|
75
75
|
today = date_range.end
|
|
76
|
-
started_date = issue.
|
|
76
|
+
started_date = issue.started_stopped_times.first&.to_date
|
|
77
77
|
return [] unless started_date
|
|
78
78
|
|
|
79
79
|
blocked_stalled = issue.blocked_stalled_by_date(
|
|
@@ -256,7 +256,8 @@ class DailyView < ChartBase
|
|
|
256
256
|
description = issue.raw['fields']['description']
|
|
257
257
|
return [] unless description
|
|
258
258
|
|
|
259
|
-
text = "<div class='foldable startFolded'>Description</div
|
|
259
|
+
text = "<div class='foldable startFolded'>Description</div>" \
|
|
260
|
+
"<div>#{atlassian_document_format.to_html(description)}</div>"
|
|
260
261
|
[[text]]
|
|
261
262
|
end
|
|
262
263
|
|
|
@@ -49,7 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
started, stopped = issue.
|
|
52
|
+
started, stopped = issue.started_stopped_dates
|
|
53
53
|
|
|
54
54
|
if stopped && started.nil? # We can't tell when it started
|
|
55
55
|
@has_completed_but_not_started = true
|
|
@@ -39,7 +39,7 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
|
-
started, stopped = issue.
|
|
42
|
+
started, stopped = issue.started_stopped_times
|
|
43
43
|
stopped_date = stopped&.to_date
|
|
44
44
|
started_date = started&.to_date
|
|
45
45
|
|
|
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def default_grouping_rules issue:, rules:
|
|
29
|
-
parent = issue.parent
|
|
29
|
+
parent = issue.parent
|
|
30
30
|
if parent
|
|
31
|
-
rules.label = parent
|
|
31
|
+
rules.label = parent.key
|
|
32
|
+
rules.label_hint = "#{parent.key} : #{parent.summary}"
|
|
32
33
|
else
|
|
33
34
|
rules.label = 'No parent'
|
|
35
|
+
rules.label_hint = 'No parent'
|
|
34
36
|
rules.group_priority = 1000
|
|
35
37
|
rules.color = '--body-background'
|
|
36
38
|
end
|
|
@@ -104,7 +104,8 @@ class DailyWipChart < ChartBase
|
|
|
104
104
|
.select { |_issue, rules| rules.group == grouping_rule.group }
|
|
105
105
|
.sort_by { |issue, _rules| issue.key_as_i }
|
|
106
106
|
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
107
|
-
|
|
107
|
+
title_label = grouping_rule.label_hint || display_label
|
|
108
|
+
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
108
109
|
|
|
109
110
|
{
|
|
110
111
|
x: date,
|
|
@@ -123,6 +124,7 @@ class DailyWipChart < ChartBase
|
|
|
123
124
|
{
|
|
124
125
|
type: 'bar',
|
|
125
126
|
label: display_label,
|
|
127
|
+
label_hint: grouping_rule.label_hint,
|
|
126
128
|
data: data,
|
|
127
129
|
backgroundColor: background_color,
|
|
128
130
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
@@ -121,7 +121,7 @@ class DataQualityReport < ChartBase
|
|
|
121
121
|
|
|
122
122
|
def initialize_entries
|
|
123
123
|
@entries = @issues.filter_map do |issue|
|
|
124
|
-
started, stopped = issue.
|
|
124
|
+
started, stopped = issue.started_stopped_times
|
|
125
125
|
next if stopped && stopped < time_range.begin
|
|
126
126
|
next if started && started > time_range.end
|
|
127
127
|
|
|
@@ -274,7 +274,7 @@ class DataQualityReport < ChartBase
|
|
|
274
274
|
|
|
275
275
|
started_subtasks = []
|
|
276
276
|
entry.issue.subtasks.each do |subtask|
|
|
277
|
-
started_subtasks << subtask if subtask.
|
|
277
|
+
started_subtasks << subtask if subtask.started_stopped_times.first
|
|
278
278
|
end
|
|
279
279
|
|
|
280
280
|
return if started_subtasks.empty?
|
|
@@ -293,7 +293,7 @@ class DataQualityReport < ChartBase
|
|
|
293
293
|
next unless settings['blocked_link_text'].include?(link.label)
|
|
294
294
|
|
|
295
295
|
this_active = !entry.stopped
|
|
296
|
-
other_active = !link.other_issue.
|
|
296
|
+
other_active = !link.other_issue.started_stopped_times.last
|
|
297
297
|
next unless this_active && !other_active
|
|
298
298
|
|
|
299
299
|
entry.report(
|
|
@@ -326,7 +326,7 @@ class DataQualityReport < ChartBase
|
|
|
326
326
|
return unless entry.stopped
|
|
327
327
|
|
|
328
328
|
subtask_labels = entry.issue.subtasks.filter_map do |subtask|
|
|
329
|
-
subtask_started, subtask_stopped = subtask.
|
|
329
|
+
subtask_started, subtask_stopped = subtask.started_stopped_times
|
|
330
330
|
|
|
331
331
|
if !subtask_started && !subtask_stopped
|
|
332
332
|
"#{subtask_label subtask} (Not even started)"
|
|
@@ -231,7 +231,7 @@ class DependencyChart < ChartBase
|
|
|
231
231
|
elsif is_done
|
|
232
232
|
line2 << 'Done'
|
|
233
233
|
else
|
|
234
|
-
started_at = issue.
|
|
234
|
+
started_at = issue.started_stopped_times.first
|
|
235
235
|
if started_at.nil?
|
|
236
236
|
line2 << 'Not started'
|
|
237
237
|
else
|