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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c87212eebf4e22145b8e66741174658bffc3cb1e47bd45bfe3f7590586a42200
4
- data.tar.gz: 00fb6c4375e7e6858359fa754af42c697028198dedd2ae8100c98d0564f59db8
3
+ metadata.gz: 2cf59f19d1ee1de238db86ef01bd46d25c5741a6a85789d62f64c310d97055fd
4
+ data.tar.gz: 31a15ee2f64eef895dbfd95bd14004b70bec1be5810224f95fee39595022b84f
5
5
  SHA512:
6
- metadata.gz: aad3747a78dcf530df02244720c676632816a30688045d95c9a701ba946a5573f81a2e3955c5035e745fb197f5c6cff58491a63d35db68b3abf35817c78dc0b8
7
- data.tar.gz: fcf17a8c2a4c91e4de45ff15150d1de9147c890d9e80354b490bec2b27fb17ef2b20e7bc6073c503e88fd0ad188c414002d8312500b22dd5207b043d3c253be7
6
+ metadata.gz: 812c19230db1c44dc41e3b99f32d6d65235de7e620d4faedb4b51ac41c4da220795013b1b80c058f0f85ef6ab6fab6146ad5ed7cb2c48abe6ccddaed92002968
7
+ data.tar.gz: 1895fc706f93d0ad4cc60a83fb1bc82fa416f389b3b620ca01f9fefb774e11a5f9c1f96d8f9b00a0b2cdd82ca5443a7dd21c4f069fdb45988a5de4669cebf7ae
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'jirametrics'
5
+ JiraMetrics.start(['mcp'] + ARGV)
@@ -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: #{@all_boards[@board_id].name}"
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 # rubocop:disable Performance/ChainArrayAllocation
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
- @calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
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
- days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
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
@@ -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
- @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
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 [how to read a CFD](https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/).
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.find { |i| i.key == key }
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: #{key}</div>"
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 = data.issue
188
- next unless issue
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
- # Remove all the ones we already downloaded
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
- log " Downloading linked issues for board #{board.id}", both: true
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.textContent;
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 %>