jirametrics 2.27 → 2.29
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/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +5 -2
- data/lib/jirametrics/board.rb +9 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +1 -1
- data/lib/jirametrics/daily_view.rb +4 -3
- data/lib/jirametrics/downloader_for_cloud.rb +57 -14
- data/lib/jirametrics/examples/standard_project.rb +4 -1
- data/lib/jirametrics/html/index.css +18 -0
- data/lib/jirametrics/html/index.js +1 -1
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_report_config.rb +20 -18
- data/lib/jirametrics/issue.rb +85 -1
- data/lib/jirametrics/mcp_server.rb +238 -12
- data/lib/jirametrics/project_config.rb +7 -1
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +15 -2
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2cf59f19d1ee1de238db86ef01bd46d25c5741a6a85789d62f64c310d97055fd
|
|
4
|
+
data.tar.gz: 31a15ee2f64eef895dbfd95bd14004b70bec1be5810224f95fee39595022b84f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 812c19230db1c44dc41e3b99f32d6d65235de7e620d4faedb4b51ac41c4da220795013b1b80c058f0f85ef6ab6fab6146ad5ed7cb2c48abe6ccddaed92002968
|
|
7
|
+
data.tar.gz: 1895fc706f93d0ad4cc60a83fb1bc82fa416f389b3b620ca01f9fefb774e11a5f9c1f96d8f9b00a0b2cdd82ca5443a7dd21c4f069fdb45988a5de4669cebf7ae
|
data/bin/jirametrics-mcp
ADDED
|
@@ -6,6 +6,7 @@ require 'jirametrics/board_movement_calculator'
|
|
|
6
6
|
|
|
7
7
|
class AgingWorkInProgressChart < ChartBase
|
|
8
8
|
include GroupableIssueChart
|
|
9
|
+
|
|
9
10
|
attr_accessor :possible_statuses, :board_id
|
|
10
11
|
attr_reader :board_columns
|
|
11
12
|
|
|
@@ -55,7 +56,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
55
56
|
def run
|
|
56
57
|
determine_board_columns
|
|
57
58
|
|
|
58
|
-
@header_text += " on board: #{
|
|
59
|
+
@header_text += " on board: #{current_board.name}"
|
|
59
60
|
data_sets = make_data_sets
|
|
60
61
|
|
|
61
62
|
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
@@ -76,7 +77,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
76
77
|
|
|
77
78
|
@fake_column = BoardColumn.new({
|
|
78
79
|
'name' => '[Unmapped Statuses]',
|
|
79
|
-
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
|
80
|
+
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
|
80
81
|
})
|
|
81
82
|
@board_columns = columns + [@fake_column]
|
|
82
83
|
end
|
|
@@ -114,14 +115,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
114
115
|
|
|
115
116
|
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
116
117
|
|
|
117
|
-
column_indexes_to_remove =
|
|
118
|
-
unless @show_all_columns
|
|
119
|
-
column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
|
|
120
|
-
|
|
121
|
-
column_indexes_to_remove.reverse_each do |index|
|
|
122
|
-
@board_columns.delete_at index
|
|
123
|
-
end
|
|
124
|
-
end
|
|
118
|
+
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
125
119
|
|
|
126
120
|
@row_index_offset = data_sets.size
|
|
127
121
|
|
|
@@ -177,6 +171,44 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
177
171
|
result
|
|
178
172
|
end
|
|
179
173
|
|
|
174
|
+
def trim_board_columns data_sets:, calculator:
|
|
175
|
+
return [] if @show_all_columns
|
|
176
|
+
|
|
177
|
+
columns_with_aging_items = data_sets.flat_map do |set|
|
|
178
|
+
set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
|
|
179
|
+
end.uniq
|
|
180
|
+
|
|
181
|
+
# @fake_column is always the last element and is handled separately.
|
|
182
|
+
real_column_count = @board_columns.size - 1
|
|
183
|
+
|
|
184
|
+
# The last visible column always has artificially inflated age_data because
|
|
185
|
+
# ages_of_issues_when_leaving_column uses `today` as end_date when there is no
|
|
186
|
+
# next column. Exclude it from the right-boundary search so it is only kept when
|
|
187
|
+
# it has current aging items (handled by the last_aging fallback below).
|
|
188
|
+
age_data = calculator.age_data_for(percentage: 100)
|
|
189
|
+
last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
|
|
190
|
+
|
|
191
|
+
in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
|
|
192
|
+
first_aging = (0...real_column_count).find(&in_current)
|
|
193
|
+
last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
|
|
194
|
+
|
|
195
|
+
# Combine: include any column with age_data (up to but not including the last visible
|
|
196
|
+
# column) and any column with current aging items.
|
|
197
|
+
first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
|
|
198
|
+
left_bound = [first_data, first_aging].compact.min
|
|
199
|
+
right_bound = [last_data, last_aging].compact.max
|
|
200
|
+
|
|
201
|
+
indexes_to_remove =
|
|
202
|
+
if left_bound && right_bound
|
|
203
|
+
(0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
|
|
204
|
+
else
|
|
205
|
+
(0...real_column_count).to_a
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
|
|
209
|
+
indexes_to_remove
|
|
210
|
+
end
|
|
211
|
+
|
|
180
212
|
def column_for issue:
|
|
181
213
|
@board_columns.find do |board_column|
|
|
182
214
|
board_column.status_ids.include? issue.status.id
|
|
@@ -192,7 +224,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
192
224
|
end
|
|
193
225
|
end
|
|
194
226
|
|
|
195
|
-
if has_unmapped
|
|
227
|
+
if has_unmapped && @description_text
|
|
196
228
|
@description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
|
|
197
229
|
'board but are still active. Most likely everyone has forgotten about them.</p>'
|
|
198
230
|
else
|
|
@@ -45,7 +45,9 @@ class AgingWorkTable < ChartBase
|
|
|
45
45
|
# This is its own method simply so the tests can initialize the calculator without doing a full run.
|
|
46
46
|
def initialize_calculator
|
|
47
47
|
@today = date_range.end
|
|
48
|
-
@
|
|
48
|
+
@calculators = @all_boards.transform_values do |board|
|
|
49
|
+
BoardMovementCalculator.new board: board, issues: issues, today: @today
|
|
50
|
+
end
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def expedited_but_not_started
|
|
@@ -123,7 +125,8 @@ class AgingWorkTable < ChartBase
|
|
|
123
125
|
due = issue.due_date
|
|
124
126
|
message = nil
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
calculator = @calculators[issue.board.id]
|
|
129
|
+
days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
|
|
127
130
|
|
|
128
131
|
unless error
|
|
129
132
|
if due
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -72,7 +72,7 @@ class Board
|
|
|
72
72
|
return true if board_type == 'scrum'
|
|
73
73
|
return false unless board_type == 'simple'
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
has_sprints_feature?
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def kanban?
|
|
@@ -82,6 +82,14 @@ class Board
|
|
|
82
82
|
!scrum?
|
|
83
83
|
end
|
|
84
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
|
|
92
|
+
|
|
85
93
|
def id
|
|
86
94
|
@raw['id'].to_i
|
|
87
95
|
end
|
|
@@ -70,7 +70,7 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
70
70
|
CT and TP cannot be calculated and are hidden; only WIP is shown.
|
|
71
71
|
</div>
|
|
72
72
|
<div class="p">
|
|
73
|
-
See also: This article on
|
|
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
74
|
</div>
|
|
75
75
|
HTML
|
|
76
76
|
instance_eval(&block)
|
|
@@ -87,13 +87,14 @@ class DailyView < ChartBase
|
|
|
87
87
|
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
88
88
|
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
89
89
|
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
90
|
-
blocking_issue = issues.
|
|
90
|
+
blocking_issue = issues.find_by_key key: key, include_hidden: true
|
|
91
91
|
if blocking_issue
|
|
92
|
-
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue:
|
|
92
|
+
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
|
|
93
|
+
"#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
|
|
93
94
|
lines << blocking_issue
|
|
94
95
|
lines << '</section>'
|
|
95
96
|
else
|
|
96
|
-
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
97
|
+
lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
|
|
97
98
|
end
|
|
98
99
|
end
|
|
99
100
|
elsif blocked_stalled.stalled_by_status?
|
|
@@ -108,11 +108,24 @@ class DownloaderForCloud < Downloader
|
|
|
108
108
|
}
|
|
109
109
|
issue = Issue.new(raw: issue_json, board: board)
|
|
110
110
|
data = issue_datas.find { |d| d.key == issue.key }
|
|
111
|
+
unless data
|
|
112
|
+
log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
111
115
|
data.up_to_date = true
|
|
112
116
|
data.last_modified = issue.updated
|
|
113
117
|
data.issue = issue
|
|
114
118
|
end
|
|
115
119
|
|
|
120
|
+
# Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
|
|
121
|
+
# This happens when Jira returns a different key (moved issue) leaving the original unmatched.
|
|
122
|
+
issue_datas.each do |data|
|
|
123
|
+
next if data.up_to_date
|
|
124
|
+
|
|
125
|
+
log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
|
|
126
|
+
data.up_to_date = true
|
|
127
|
+
end
|
|
128
|
+
|
|
116
129
|
issue_datas
|
|
117
130
|
end
|
|
118
131
|
|
|
@@ -168,15 +181,20 @@ class DownloaderForCloud < Downloader
|
|
|
168
181
|
|
|
169
182
|
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
170
183
|
|
|
184
|
+
checked_for_related = Set.new
|
|
185
|
+
in_related_phase = false
|
|
186
|
+
|
|
171
187
|
loop do
|
|
172
188
|
related_issue_keys = Set.new
|
|
173
189
|
stale = issue_data_hash.values.reject { |data| data.up_to_date }
|
|
174
190
|
unless stale.empty?
|
|
175
|
-
log_start ' Downloading more issues '
|
|
191
|
+
log_start ' Downloading more issues ' unless in_related_phase
|
|
176
192
|
stale.each_slice(100) do |slice|
|
|
177
193
|
slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
|
|
178
194
|
progress_dot
|
|
179
195
|
slice.each do |data|
|
|
196
|
+
next unless data.issue
|
|
197
|
+
|
|
180
198
|
@file_system.save_json(
|
|
181
199
|
json: data.issue.raw, filename: data.cache_path
|
|
182
200
|
)
|
|
@@ -184,22 +202,25 @@ class DownloaderForCloud < Downloader
|
|
|
184
202
|
# to parse the file just to find the timestamp
|
|
185
203
|
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
186
204
|
|
|
187
|
-
issue
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
191
|
-
related_issue_keys << parent_key if parent_key
|
|
192
|
-
|
|
193
|
-
# Sub-tasks
|
|
194
|
-
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
195
|
-
related_issue_keys << raw_subtask['key']
|
|
196
|
-
end
|
|
205
|
+
collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
|
|
206
|
+
checked_for_related << data.key
|
|
197
207
|
end
|
|
198
208
|
end
|
|
199
|
-
end_progress
|
|
209
|
+
end_progress unless in_related_phase
|
|
200
210
|
end
|
|
201
211
|
|
|
202
|
-
#
|
|
212
|
+
# Also scan up-to-date cached issues we haven't checked yet — they may reference
|
|
213
|
+
# related issues that are not in the primary query result.
|
|
214
|
+
issue_data_hash.each_value do |data|
|
|
215
|
+
next if checked_for_related.include?(data.key)
|
|
216
|
+
next unless @file_system.file_exist?(data.cache_path)
|
|
217
|
+
|
|
218
|
+
checked_for_related << data.key
|
|
219
|
+
raw = @file_system.load_json(data.cache_path)
|
|
220
|
+
collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Remove all the ones we already have
|
|
203
224
|
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
204
225
|
|
|
205
226
|
related_issue_keys.each do |key|
|
|
@@ -211,9 +232,15 @@ class DownloaderForCloud < Downloader
|
|
|
211
232
|
end
|
|
212
233
|
break if related_issue_keys.empty?
|
|
213
234
|
|
|
214
|
-
|
|
235
|
+
unless in_related_phase
|
|
236
|
+
in_related_phase = true
|
|
237
|
+
log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
|
|
238
|
+
log_start ' Downloading more issues '
|
|
239
|
+
end
|
|
215
240
|
end
|
|
216
241
|
|
|
242
|
+
end_progress if in_related_phase
|
|
243
|
+
|
|
217
244
|
delete_issues_from_cache_that_are_not_in_server(
|
|
218
245
|
issue_data_hash: issue_data_hash, path: path
|
|
219
246
|
)
|
|
@@ -238,6 +265,22 @@ class DownloaderForCloud < Downloader
|
|
|
238
265
|
end
|
|
239
266
|
end
|
|
240
267
|
|
|
268
|
+
def collect_related_issue_keys issue:, related_issue_keys:
|
|
269
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
270
|
+
related_issue_keys << parent_key if parent_key
|
|
271
|
+
|
|
272
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
273
|
+
related_issue_keys << raw_subtask['key']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
277
|
+
next if link['type']['name'] == 'Cloners'
|
|
278
|
+
|
|
279
|
+
linked = link['inwardIssue'] || link['outwardIssue']
|
|
280
|
+
related_issue_keys << linked['key'] if linked
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
241
284
|
def last_modified filename:
|
|
242
285
|
File.mtime(filename) if File.exist?(filename)
|
|
243
286
|
end
|
|
@@ -9,7 +9,7 @@ class Exporter
|
|
|
9
9
|
show_experimental_charts: false, github_repos: nil
|
|
10
10
|
exporter = self
|
|
11
11
|
project name: name do
|
|
12
|
-
file_system.log name
|
|
12
|
+
file_system.log name, also_write_to_stderr: true
|
|
13
13
|
file_prefix file_prefix
|
|
14
14
|
|
|
15
15
|
self.anonymize if anonymize
|
|
@@ -82,6 +82,9 @@ class Exporter
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
aging_work_in_progress_chart
|
|
85
|
+
wip_by_column_chart do
|
|
86
|
+
show_recommendations
|
|
87
|
+
end
|
|
85
88
|
aging_work_bar_chart
|
|
86
89
|
aging_work_table
|
|
87
90
|
daily_wip_by_age_chart
|
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
--wip-chart-active-color: #326cff;
|
|
51
51
|
--wip-chart-border-color: gray;
|
|
52
52
|
|
|
53
|
+
--wip-by-column-chart-bar-fill-color: #0072B2; /* Okabe-Ito blue */
|
|
54
|
+
--wip-by-column-chart-bar-text-color: #ffffff;
|
|
55
|
+
--wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
|
|
56
|
+
--wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
|
|
57
|
+
|
|
53
58
|
--estimate-accuracy-chart-completed-fill-color: #00ff00;
|
|
54
59
|
--estimate-accuracy-chart-completed-border-color: green;
|
|
55
60
|
--estimate-accuracy-chart-active-fill-color: #FFCCCB;
|
|
@@ -233,6 +238,10 @@ html[data-theme="dark"] {
|
|
|
233
238
|
--wip-chart-active-color: #2551c1;
|
|
234
239
|
--status-category-inprogress-color: #1c49bb;
|
|
235
240
|
--hierarchy-table-inactive-item-text-color: #939393;
|
|
241
|
+
--wip-by-column-chart-bar-fill-color: #56B4E9; /* Okabe-Ito sky blue */
|
|
242
|
+
--wip-by-column-chart-bar-text-color: #000000;
|
|
243
|
+
--wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
|
|
244
|
+
--wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
|
|
236
245
|
--wip-chart-completed-color: #03cb03;
|
|
237
246
|
--wip-chart-duration-less-than-day-color: #d2d988;
|
|
238
247
|
--wip-chart-duration-week-or-less-color: #dfcd00;
|
|
@@ -274,6 +283,10 @@ html[data-theme="light"] {
|
|
|
274
283
|
--wip-chart-active-color: #326cff;
|
|
275
284
|
--status-category-inprogress-color: #2663ff;
|
|
276
285
|
--hierarchy-table-inactive-item-text-color: gray;
|
|
286
|
+
--wip-by-column-chart-bar-fill-color: #0072B2;
|
|
287
|
+
--wip-by-column-chart-bar-text-color: #ffffff;
|
|
288
|
+
--wip-by-column-chart-limit-line-color: #D55E00;
|
|
289
|
+
--wip-by-column-chart-recommendation-color: #009E73;
|
|
277
290
|
--wip-chart-completed-color: #00ff00;
|
|
278
291
|
--wip-chart-duration-less-than-day-color: #ffef41;
|
|
279
292
|
--wip-chart-duration-week-or-less-color: #dcc900;
|
|
@@ -352,6 +365,11 @@ html[data-theme="light"] {
|
|
|
352
365
|
|
|
353
366
|
--status-category-inprogress-color: #1c49bb;
|
|
354
367
|
|
|
368
|
+
--wip-by-column-chart-bar-fill-color: #56B4E9;
|
|
369
|
+
--wip-by-column-chart-bar-text-color: #000000;
|
|
370
|
+
--wip-by-column-chart-limit-line-color: #E69F00;
|
|
371
|
+
--wip-by-column-chart-recommendation-color: #2DCB9A;
|
|
372
|
+
|
|
355
373
|
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
356
374
|
|
|
357
375
|
--hierarchy-table-inactive-item-text-color: #939393;
|
|
@@ -35,7 +35,7 @@ function makeFoldable() {
|
|
|
35
35
|
const toggleButton = document.createElement(element.tagName); //'button');
|
|
36
36
|
toggleButton.id = toggleId;
|
|
37
37
|
toggleButton.className = 'foldable-toggle-btn';
|
|
38
|
-
toggleButton.innerHTML = '▼ ' + element.
|
|
38
|
+
toggleButton.innerHTML = '▼ ' + element.innerHTML;
|
|
39
39
|
|
|
40
40
|
// Create a content container
|
|
41
41
|
const contentContainer = document.createElement('div');
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
<%= seam_start %>
|
|
2
|
+
<div class="chart" style="position:relative;">
|
|
3
|
+
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
|
+
<div id="<%= chart_id %>-tooltip" style="
|
|
5
|
+
display:none; position:absolute; pointer-events:none;
|
|
6
|
+
background:rgba(0,0,0,0.75); color:#fff; border-radius:4px;
|
|
7
|
+
padding:4px 8px; font:12px sans-serif; white-space:nowrap;
|
|
8
|
+
"></div>
|
|
9
|
+
</div>
|
|
10
|
+
<script>
|
|
11
|
+
(function() {
|
|
12
|
+
var wipData = <%= @wip_data.to_json %>;
|
|
13
|
+
var wipLimits = <%= @wip_limits.to_json %>;
|
|
14
|
+
var recommendations = <%= @recommendations.to_json %>;
|
|
15
|
+
var maxWip = <%= @max_wip %>;
|
|
16
|
+
var gridColor = <%= CssVariable['--grid-line-color'].to_json %>;
|
|
17
|
+
var barFillColor = <%= CssVariable['--wip-by-column-chart-bar-fill-color'].to_json %>;
|
|
18
|
+
var barTextColor = <%= CssVariable['--wip-by-column-chart-bar-text-color'].to_json %>;
|
|
19
|
+
var limitColor = <%= CssVariable['--wip-by-column-chart-limit-line-color'].to_json %>;
|
|
20
|
+
var recColor = <%= CssVariable['--wip-by-column-chart-recommendation-color'].to_json %>;
|
|
21
|
+
var tooltipEl = document.getElementById(<%= "#{chart_id}-tooltip".inspect %>);
|
|
22
|
+
|
|
23
|
+
var hitAreas = [];
|
|
24
|
+
|
|
25
|
+
var rectPlugin = {
|
|
26
|
+
id: 'wipRects',
|
|
27
|
+
afterDraw: function(chart) {
|
|
28
|
+
var ctx = chart.ctx;
|
|
29
|
+
var xScale = chart.scales['x'];
|
|
30
|
+
var yScale = chart.scales['y'];
|
|
31
|
+
var slotWidth = xScale.width / Math.max(xScale.ticks.length, 1);
|
|
32
|
+
|
|
33
|
+
hitAreas = [];
|
|
34
|
+
|
|
35
|
+
// 1. Draw y-axis gridlines at integer band boundaries
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.beginPath();
|
|
38
|
+
ctx.rect(xScale.left, yScale.top, xScale.right - xScale.left, yScale.bottom - yScale.top);
|
|
39
|
+
ctx.clip();
|
|
40
|
+
ctx.strokeStyle = gridColor;
|
|
41
|
+
ctx.lineWidth = 1;
|
|
42
|
+
ctx.setLineDash([]);
|
|
43
|
+
for (var gi = 0; gi <= maxWip + 1; gi++) {
|
|
44
|
+
var gy = yScale.getPixelForValue(gi);
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
ctx.moveTo(xScale.left, gy);
|
|
47
|
+
ctx.lineTo(xScale.right, gy);
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
}
|
|
50
|
+
ctx.restore();
|
|
51
|
+
|
|
52
|
+
// 2. Draw WIP limit lines (behind rectangles)
|
|
53
|
+
wipLimits.forEach(function(limits, colIndex) {
|
|
54
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
55
|
+
var halfSlot = slotWidth * 0.45;
|
|
56
|
+
|
|
57
|
+
[['min', 'bottom'], ['max', 'top']].forEach(function(pair) {
|
|
58
|
+
var type = pair[0];
|
|
59
|
+
var baseline = pair[1];
|
|
60
|
+
var val = limits[type];
|
|
61
|
+
if (val === null || val === undefined) return;
|
|
62
|
+
|
|
63
|
+
var y = yScale.getPixelForValue(val + 0.5);
|
|
64
|
+
|
|
65
|
+
ctx.save();
|
|
66
|
+
ctx.strokeStyle = limitColor;
|
|
67
|
+
ctx.lineWidth = 2;
|
|
68
|
+
ctx.setLineDash([5, 3]);
|
|
69
|
+
ctx.beginPath();
|
|
70
|
+
ctx.moveTo(xCenter - halfSlot, y);
|
|
71
|
+
ctx.lineTo(xCenter + halfSlot, y);
|
|
72
|
+
ctx.stroke();
|
|
73
|
+
ctx.restore();
|
|
74
|
+
|
|
75
|
+
ctx.save();
|
|
76
|
+
ctx.fillStyle = limitColor;
|
|
77
|
+
ctx.font = 'bold 10px sans-serif';
|
|
78
|
+
ctx.textAlign = 'right';
|
|
79
|
+
ctx.textBaseline = baseline;
|
|
80
|
+
ctx.fillText(type + ': ' + val, xCenter + halfSlot, baseline === 'bottom' ? y - 2 : y + 2);
|
|
81
|
+
ctx.restore();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
<% if @show_recommendations %>
|
|
86
|
+
// 3. Draw recommendation lines (behind rectangles, label on left)
|
|
87
|
+
recommendations.forEach(function(rec, colIndex) {
|
|
88
|
+
if (rec === null || rec === undefined || rec === 0) return;
|
|
89
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
90
|
+
var halfSlot = slotWidth * 0.45;
|
|
91
|
+
var y = yScale.getPixelForValue(rec + 0.5);
|
|
92
|
+
|
|
93
|
+
ctx.save();
|
|
94
|
+
ctx.strokeStyle = recColor;
|
|
95
|
+
ctx.lineWidth = 2;
|
|
96
|
+
ctx.setLineDash([5, 3]);
|
|
97
|
+
ctx.beginPath();
|
|
98
|
+
ctx.moveTo(xCenter - halfSlot, y);
|
|
99
|
+
ctx.lineTo(xCenter + halfSlot, y);
|
|
100
|
+
ctx.stroke();
|
|
101
|
+
ctx.restore();
|
|
102
|
+
|
|
103
|
+
ctx.save();
|
|
104
|
+
ctx.fillStyle = recColor;
|
|
105
|
+
ctx.font = 'bold 10px sans-serif';
|
|
106
|
+
ctx.textAlign = 'left';
|
|
107
|
+
ctx.textBaseline = 'top';
|
|
108
|
+
ctx.fillText('rec: ' + rec, xCenter - halfSlot, y + 2);
|
|
109
|
+
ctx.restore();
|
|
110
|
+
});
|
|
111
|
+
<% end %>
|
|
112
|
+
|
|
113
|
+
// 4. Draw WIP rectangles centered in their bands (wip + 0.5)
|
|
114
|
+
var yStep = Math.abs(yScale.getPixelForValue(0.5) - yScale.getPixelForValue(1.5));
|
|
115
|
+
|
|
116
|
+
wipData.forEach(function(colData, colIndex) {
|
|
117
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
118
|
+
|
|
119
|
+
colData.forEach(function(entry) {
|
|
120
|
+
var wip = entry['wip'];
|
|
121
|
+
var pct = entry['pct'];
|
|
122
|
+
var rectWidth = slotWidth * pct / 100;
|
|
123
|
+
var rectHeight = yStep * 0.8;
|
|
124
|
+
var yCenter = yScale.getPixelForValue(wip + 0.5);
|
|
125
|
+
var x1 = xCenter - rectWidth / 2;
|
|
126
|
+
var y1 = yCenter - rectHeight / 2;
|
|
127
|
+
|
|
128
|
+
ctx.save();
|
|
129
|
+
ctx.fillStyle = barFillColor;
|
|
130
|
+
ctx.strokeStyle = barFillColor;
|
|
131
|
+
ctx.lineWidth = 1;
|
|
132
|
+
ctx.fillRect(x1, y1, rectWidth, rectHeight);
|
|
133
|
+
ctx.strokeRect(x1, y1, rectWidth, rectHeight);
|
|
134
|
+
|
|
135
|
+
ctx.fillStyle = barTextColor;
|
|
136
|
+
ctx.font = '11px sans-serif';
|
|
137
|
+
ctx.textAlign = 'center';
|
|
138
|
+
ctx.textBaseline = 'middle';
|
|
139
|
+
if (rectWidth > 25) {
|
|
140
|
+
ctx.fillText(pct + '%', xCenter, yCenter);
|
|
141
|
+
}
|
|
142
|
+
ctx.restore();
|
|
143
|
+
|
|
144
|
+
var hitWidth = Math.max(rectWidth, slotWidth);
|
|
145
|
+
hitAreas.push({
|
|
146
|
+
x1: xCenter - hitWidth / 2, y1: y1,
|
|
147
|
+
x2: xCenter + hitWidth / 2, y2: y1 + rectHeight,
|
|
148
|
+
label: 'WIP ' + wip + ': ' + pct + '%'
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
var canvas = document.getElementById(<%= chart_id.inspect %>);
|
|
156
|
+
|
|
157
|
+
canvas.addEventListener('mousemove', function(e) {
|
|
158
|
+
var rect = canvas.getBoundingClientRect();
|
|
159
|
+
var mx = e.clientX - rect.left;
|
|
160
|
+
var my = e.clientY - rect.top;
|
|
161
|
+
|
|
162
|
+
var hit = null;
|
|
163
|
+
for (var i = 0; i < hitAreas.length; i++) {
|
|
164
|
+
var a = hitAreas[i];
|
|
165
|
+
if (mx >= a.x1 && mx <= a.x2 && my >= a.y1 && my <= a.y2) {
|
|
166
|
+
hit = a;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (hit) {
|
|
172
|
+
tooltipEl.textContent = hit.label;
|
|
173
|
+
tooltipEl.style.display = 'block';
|
|
174
|
+
tooltipEl.style.left = (mx + 10) + 'px';
|
|
175
|
+
tooltipEl.style.top = (my - 20) + 'px';
|
|
176
|
+
} else {
|
|
177
|
+
tooltipEl.style.display = 'none';
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
canvas.addEventListener('mouseleave', function() {
|
|
182
|
+
tooltipEl.style.display = 'none';
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
new Chart(canvas.getContext('2d'),
|
|
186
|
+
{
|
|
187
|
+
type: 'bar',
|
|
188
|
+
plugins: [rectPlugin],
|
|
189
|
+
data: {
|
|
190
|
+
labels: <%= @column_names.to_json %>,
|
|
191
|
+
datasets: [{
|
|
192
|
+
data: [],
|
|
193
|
+
backgroundColor: 'transparent'
|
|
194
|
+
}]
|
|
195
|
+
},
|
|
196
|
+
options: {
|
|
197
|
+
responsive: <%= canvas_responsive? %>,
|
|
198
|
+
scales: {
|
|
199
|
+
x: {
|
|
200
|
+
grid: {
|
|
201
|
+
color: gridColor,
|
|
202
|
+
z: 1
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
y: {
|
|
206
|
+
title: {
|
|
207
|
+
display: true,
|
|
208
|
+
text: 'WIP'
|
|
209
|
+
},
|
|
210
|
+
grid: {
|
|
211
|
+
display: false
|
|
212
|
+
},
|
|
213
|
+
min: 0,
|
|
214
|
+
max: <%= @max_wip + 1 %>,
|
|
215
|
+
afterBuildTicks: function(scale) {
|
|
216
|
+
scale.ticks = [];
|
|
217
|
+
for (var i = 0; i <= maxWip; i++) {
|
|
218
|
+
scale.ticks.push({ value: i + 0.5 });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
ticks: {
|
|
222
|
+
callback: function(value) {
|
|
223
|
+
return Math.round(value - 0.5);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
plugins: {
|
|
229
|
+
legend: {
|
|
230
|
+
display: false
|
|
231
|
+
},
|
|
232
|
+
tooltip: {
|
|
233
|
+
enabled: false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
})();
|
|
239
|
+
</script>
|
|
240
|
+
<%= seam_end %>
|
|
241
|
+
<% unless @recommendation_texts.empty? %>
|
|
242
|
+
<div style="margin-top: 0.5em;">
|
|
243
|
+
<strong>WIP limit recommendations</strong>
|
|
244
|
+
<ul style="margin: 0.3em 0 0 0; padding-left: 1.5em;">
|
|
245
|
+
<% @recommendation_texts.each do |text| %>
|
|
246
|
+
<li><%= text %></li>
|
|
247
|
+
<% end %>
|
|
248
|
+
</ul>
|
|
249
|
+
</div>
|
|
250
|
+
<% end %>
|