jirametrics 3.2 → 3.3.1

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +111 -10
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +10 -6
  4. data/lib/jirametrics/aging_work_table.rb +22 -7
  5. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +15 -2
  6. data/lib/jirametrics/board_movement_calculator.rb +18 -7
  7. data/lib/jirametrics/chart_base.rb +39 -5
  8. data/lib/jirametrics/color_palette.rb +61 -0
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +9 -6
  10. data/lib/jirametrics/cycletime_scatterplot.rb +67 -8
  11. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  12. data/lib/jirametrics/dependency_chart.rb +106 -15
  13. data/lib/jirametrics/exporter.rb +77 -11
  14. data/lib/jirametrics/groupable_issue_chart.rb +27 -2
  15. data/lib/jirametrics/grouping_rules.rb +13 -1
  16. data/lib/jirametrics/html/aging_work_bar_chart.erb +16 -5
  17. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +1 -1
  18. data/lib/jirametrics/html/index.css +77 -5
  19. data/lib/jirametrics/html/index.erb +6 -2
  20. data/lib/jirametrics/html/index.js +16 -0
  21. data/lib/jirametrics/html/legacy_colors.css +18 -0
  22. data/lib/jirametrics/html/time_based_histogram.erb +34 -18
  23. data/lib/jirametrics/html/time_based_scatterplot.erb +29 -9
  24. data/lib/jirametrics/html_generator.rb +20 -1
  25. data/lib/jirametrics/html_report_config.rb +3 -3
  26. data/lib/jirametrics/percentile_validation.rb +26 -0
  27. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
  28. data/lib/jirametrics/settings.json +1 -0
  29. data/lib/jirametrics/time_based_histogram.rb +47 -15
  30. data/lib/jirametrics/time_based_scatterplot.rb +96 -14
  31. data/lib/jirametrics/trend_line_calculator.rb +5 -2
  32. data/lib/jirametrics/wip_by_column_chart.rb +1 -1
  33. metadata +3 -1
@@ -32,9 +32,40 @@ class DependencyChart < ChartBase
32
32
  attr_accessor :color, :label
33
33
  end
34
34
 
35
+ # A fill and the label colour that goes on top of it, kept together because they are not
36
+ # independent choices: some of these fills are light and take black text, others are dark and
37
+ # take white, and a node that took its fill from one and its label from another would be
38
+ # unreadable. Always hand them out as a pair.
39
+ Palette = Struct.new :fill, :label
40
+
41
+ def self.palette_entry name
42
+ Palette.new CssVariable["--dependency-chart-#{name}-color"],
43
+ CssVariable["--dependency-chart-#{name}-label-color"]
44
+ end
45
+
46
+ PALETTE = {
47
+ story: palette_entry('story'),
48
+ task: palette_entry('task'),
49
+ bug: palette_entry('bug'),
50
+ epic: palette_entry('epic'),
51
+ spike: palette_entry('spike')
52
+ }.freeze
53
+
35
54
  def initialize rules_block
36
55
  super()
37
56
 
57
+ # Not the inherited type colours, because these are fills with label text sitting on top of
58
+ # them and those are line colours, judged against the page rather than against the text. See
59
+ # index.css for which colours these are and why.
60
+ @palette_by_type = {
61
+ 'Story' => PALETTE[:story],
62
+ 'Task' => PALETTE[:task],
63
+ 'Bug' => PALETTE[:bug],
64
+ 'Defect' => PALETTE[:bug],
65
+ 'Epic' => PALETTE[:epic],
66
+ 'Spike' => PALETTE[:spike]
67
+ }
68
+
38
69
  header_text 'Dependencies'
39
70
  description_text <<-HTML
40
71
  <p>
@@ -57,7 +88,7 @@ class DependencyChart < ChartBase
57
88
  '<div>No data matched the selected criteria. Nothing to show.</div>'
58
89
  end
59
90
 
60
- svg = execute_graphviz(dot_graph.join("\n"))
91
+ svg = restore_css_variables execute_graphviz(dot_graph.join("\n"))
61
92
  "<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
62
93
  end
63
94
 
@@ -77,6 +108,42 @@ class DependencyChart < ChartBase
77
108
  result
78
109
  end
79
110
 
111
+ # Graphviz has never heard of CSS variables. Handed one it emits a warning nobody sees and
112
+ # falls back to black, which is how a node ends up as black text on a black background. So a
113
+ # variable is swapped for a placeholder colour here and mapped back to the variable in the
114
+ # generated SVG, by #restore_css_variables. Anything that isn't a variable is left alone.
115
+ #
116
+ # The placeholders only need to be colours that nobody would ever choose deliberately, so that
117
+ # the CSS selectors matching them in the SVG cannot hit anything else.
118
+ def graphviz_color color
119
+ return color unless color.is_a? CssVariable
120
+
121
+ css_variable_placeholders[color.name] ||= format '#fe00%02x', css_variable_placeholders.size + 1
122
+ end
123
+
124
+ def css_variable_placeholders
125
+ @css_variable_placeholders ||= {}
126
+ end
127
+
128
+ # Turns the placeholders from #graphviz_color back into the variables they stood for, by way of
129
+ # a stylesheet that selects on the placeholder value. Rewriting the attributes in place would be
130
+ # the obvious move, but var() is only legal in a CSS value and not in an SVG presentation
131
+ # attribute, so the colour has to arrive as a real CSS rule. Author rules beat presentation
132
+ # attributes, so the placeholder never wins.
133
+ #
134
+ # fillcolor and fontcolor come out of graphviz as fill and color comes out as stroke, and an
135
+ # arrowhead uses both, so every placeholder gets a rule for each.
136
+ def restore_css_variables svg
137
+ return svg if css_variable_placeholders.empty?
138
+
139
+ rules = css_variable_placeholders.map do |name, placeholder|
140
+ %([fill="#{placeholder}"]{fill:var(#{name})}[stroke="#{placeholder}"]{stroke:var(#{name})})
141
+ end
142
+ svg.sub(/(?<opening_tag><svg\b[^>]*>)/) do
143
+ "#{Regexp.last_match[:opening_tag]}<style>#{rules.join}</style>"
144
+ end
145
+ end
146
+
80
147
  def make_dot_link issue_link:, link_rules:
81
148
  result = +''
82
149
  result << issue_link.origin.key.inspect
@@ -84,8 +151,9 @@ class DependencyChart < ChartBase
84
151
  result << issue_link.other_issue.key.inspect
85
152
  result << '['
86
153
  result << 'label=' << (link_rules.label || issue_link.label).inspect
87
- result << ',color=' << (link_rules.line_color || 'gray').inspect
88
- result << ',fontcolor=' << (link_rules.line_color || 'gray').inspect
154
+ line_color = graphviz_color(link_rules.line_color || default_link_color)
155
+ result << ',color=' << line_color.inspect
156
+ result << ',fontcolor=' << line_color.inspect
89
157
  result << ',dir=both' if link_rules.bidirectional_arrows?
90
158
  result << '];'
91
159
  result
@@ -102,24 +170,47 @@ class DependencyChart < ChartBase
102
170
  tooltip = "#{issue.key}: #{issue.summary}"
103
171
  result << ",tooltip=#{tooltip[0..80].inspect}"
104
172
  unless issue_rules.color == :none
105
- result << %(,style=filled,fillcolor="#{issue_rules.color || color_for(type: issue.type)}")
173
+ fill_color = graphviz_color(issue_rules.color || color_for(type: issue.type))
174
+ result << %(,style=filled,fillcolor="#{fill_color}")
106
175
  end
176
+ result << %(,fontcolor="#{graphviz_color label_color(issue: issue, issue_rules: issue_rules)}")
107
177
  result << ']'
108
178
  result
109
179
  end
110
180
 
111
- # This used to pull colours from chart_base but the migration to CSS colours kept breaking
112
- # this chart so we moved it here, until we're finished with the rest. TODO: Revisit whether
113
- # this can also use customizable CSS colours
181
+ # A filled node is its own background, so its label is measured against the fill rather than
182
+ # against the page.
183
+ def label_color issue:, issue_rules:
184
+ return CssVariable['--default-text-color'] if issue_rules.color == :none
185
+
186
+ # A colour somebody configured is one we know nothing about, so guessing that a particular
187
+ # type's label would suit it is worse than falling back to the general one.
188
+ return CssVariable['--dependency-chart-label-color'] if issue_rules.color
189
+
190
+ palette_for(issue.type).label
191
+ end
192
+
114
193
  def color_for type:
115
- @chart_colors = {
116
- 'Story' => '#90EE90',
117
- 'Task' => '#87CEFA',
118
- 'Bug' => '#ffdab9',
119
- 'Defect' => '#ffdab9',
120
- 'Epic' => '#fafad2',
121
- 'Spike' => '#DDA0DD' # light purple
122
- }[type] ||= random_color
194
+ palette_for(type).fill
195
+ end
196
+
197
+ def palette_for type
198
+ @palette_by_type[type] ||= next_palette_entry
199
+ end
200
+
201
+ def default_link_color
202
+ CssVariable['--dependency-chart-link-color']
203
+ end
204
+
205
+ # An issue type we have no colour for cannot come from the shared palette, the way it does on
206
+ # every other chart. That palette holds line colours, judged against the page, and its first slot
207
+ # is Okabe-Ito blue, which leaves black label text at 4.05:1 once it becomes the fill behind it.
208
+ # These rotate this chart's own entries, which are all known to be comfortable. Two unknown types
209
+ # can therefore land on the same colour as each other or as a known type, which costs little:
210
+ # every node already names its type in the label.
211
+ def next_palette_entry
212
+ @fallback_color_index = (@fallback_color_index || -1) + 1
213
+ PALETTE.values[@fallback_color_index % PALETTE.size]
123
214
  end
124
215
 
125
216
  def build_dot_graph
@@ -257,35 +257,89 @@ class Exporter
257
257
 
258
258
  def info key, name_filter:
259
259
  selected = []
260
+ searched = []
260
261
  file_system.log_only = true
261
262
  each_project_config(name_filter: name_filter) do |project|
262
263
  project.evaluate_next_level
263
264
 
264
265
  project.run load_only: true
265
- selected.concat matching_issues_in(project, key)
266
+ matches = matching_issues_in(project, key)
267
+ searched << describe_search(project: project, matches: matches.size)
268
+ selected.concat matches
266
269
  rescue => e # rubocop:disable Style/RescueStandardError
267
270
  # This happens when we're attempting to load an aggregated project because it hasn't been
268
271
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
269
272
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
273
+
274
+ searched << " #{project.name.inspect}: skipped, this is an aggregated project"
270
275
  end
271
276
  file_system.log_only = false
272
277
 
278
+ report_info_results key: key, selected: selected, where_we_looked: describe_where_we_looked(key, searched)
279
+ end
280
+
281
+ # Verbose in the log, sparse in the terminal. Where we looked is almost always what you need when
282
+ # an issue you know was downloaded cannot be found, and it is noise the rest of the time.
283
+ def describe_where_we_looked key, searched
284
+ return "Searched these projects for #{key.inspect}:\n#{searched.join "\n"}" unless searched.empty?
285
+
286
+ 'No project configurations were searched at all. Either none are defined in the config file ' \
287
+ 'or none of them matched the name filter.'
288
+ end
289
+
290
+ def report_info_results key:, selected:, where_we_looked:
273
291
  if selected.empty?
274
- file_system.log "No issues found to match #{key.inspect}"
275
- else
276
- selected.each do |project, issue|
277
- file_system.log "\nProject #{project.name}", also_write_to_stderr: true
278
- file_system.log issue.dump, also_write_to_stderr: true
279
- end
292
+ file_system.log(
293
+ "No issues found to match #{key.inspect}", more: where_we_looked, also_write_to_stderr: true
294
+ )
295
+ return
296
+ end
297
+
298
+ file_system.log where_we_looked
299
+ selected.each do |project, issue, ignored|
300
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
301
+ file_system.log(IGNORED_ISSUE_NOTE, also_write_to_stderr: true) if ignored
302
+ file_system.log issue.dump, also_write_to_stderr: true
280
303
  end
281
304
  end
282
305
 
306
+ # Only the unexpected case is announced. Not being filtered is the common one, and saying so
307
+ # every time would be noise on top of the dump that was actually asked for. Note the claim is
308
+ # about the filter, not about charts: an issue that survives filtering may still be absent from
309
+ # a given chart for its own reasons, so there is no matching note for the other case.
310
+ IGNORED_ISSUE_NOTE = 'IGNORED by a filter such as ignore_types or ignore_issues. No chart uses it.'
311
+
312
+ # One line per project saying where its issues were read from and what was there, so that a
313
+ # missing issue can be traced to the wrong directory rather than guessed at.
314
+ def describe_search project:, matches:
315
+ prefix = project.get_file_prefix raise_if_not_set: false
316
+ path = prefix.nil? ? '(no file_prefix set)' : File.join(project.target_path.to_s, "#{prefix}_issues")
317
+ counts =
318
+ begin
319
+ collection = project.issues
320
+ excluded = collection.hidden.size
321
+ detail = "#{collection.size} issues loaded"
322
+ detail << ", #{excluded} excluded by filters" if excluded.positive?
323
+ "#{detail}, #{matches} matched"
324
+ rescue StandardError
325
+ 'could not be read'
326
+ end
327
+ " #{project.name.inspect}: #{path} (#{counts})"
328
+ end
329
+
330
+ # Asking about a specific issue should always show that issue, whether or not the report used it.
331
+ # An issue removed by something like ignore_types is exactly when somebody runs info to find out
332
+ # what happened, and reporting it as missing sends them hunting for a download problem that is
333
+ # not there. The hidden list is searched first. Returns [project, issue, ignored] triples.
283
334
  def matching_issues_in project, key
284
335
  matches = []
285
- project.issues.each do |issue|
286
- matches << [project, issue] if key == issue.key
287
- issue.subtasks.each do |subtask|
288
- matches << [project, subtask] if key == subtask.key
336
+ collection = project.issues
337
+ [[collection.hidden, true], [collection, false]].each do |issues, ignored|
338
+ issues.each do |issue|
339
+ matches << [project, issue, ignored] if key == issue.key
340
+ issue.subtasks.each do |subtask|
341
+ matches << [project, subtask, ignored] if key == subtask.key
342
+ end
289
343
  end
290
344
  end
291
345
  matches
@@ -317,9 +371,21 @@ class Exporter
317
371
 
318
372
  def target_path path = nil
319
373
  unless path.nil?
374
+ previous = @target_path
320
375
  @target_path = path
321
376
  @target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
322
377
  FileUtils.mkdir_p @target_path
378
+ # A config is allowed to switch target directories partway through, and when it does, an
379
+ # issue can be downloaded into one and looked for in another. Recording every change makes
380
+ # that visible rather than something to guess at. See GitHub issue 77.
381
+ # The constructor seeds this with a bare '.', and the setter always appends a separator, so
382
+ # an unseparated '.' is the untouched default rather than something the config chose. Setting
383
+ # the same path twice is not a change and saying so would be actively misleading.
384
+ if previous == '.'
385
+ file_system.diagnostic "target_path set to #{@target_path.inspect}"
386
+ elsif previous != @target_path
387
+ file_system.diagnostic "target_path changed from #{previous.inspect} to #{@target_path.inspect}"
388
+ end
323
389
  end
324
390
  @target_path
325
391
  end
@@ -31,14 +31,39 @@ module GroupableIssueChart
31
31
 
32
32
  @issue_hints[issue] = rules.issue_hint
33
33
  @issue_periods[issue] = rules.last_day_of_period
34
- (result[rules] ||= []) << issue
34
+ accumulate_issue_for_group result, rules, issue
35
35
  end
36
36
 
37
37
  completed_issues.reject! { |issue| ignored_issues.include? issue }
38
38
 
39
39
  result.each_key do |rules|
40
- rules.color = random_color if rules.color.nil?
40
+ rules.color = next_palette_color if rules.color.nil?
41
41
  end
42
42
  result
43
43
  end
44
+
45
+ # Ruby's Hash keeps whichever key object it saw first, so a later issue whose rules object
46
+ # has different percentiles needs to be reconciled against that retained key rather than
47
+ # just appended under its own (discarded) key.
48
+ def accumulate_issue_for_group result, rules, issue
49
+ existing_key = result.keys.find { |key| key.eql? rules }
50
+ reconcile_percentiles existing_key, rules if existing_key
51
+ (result[existing_key || rules] ||= []) << issue
52
+ end
53
+
54
+ # The retained hash key is whichever rules object arrived first, so a later issue setting a
55
+ # different list would silently lose. That can only be a config error, so say so.
56
+ def reconcile_percentiles existing_key, rules
57
+ incoming = rules.percentiles
58
+ return if incoming.nil?
59
+
60
+ existing = existing_key.percentiles
61
+ if existing.nil?
62
+ existing_key.percentiles = incoming
63
+ elsif existing != incoming
64
+ raise ArgumentError,
65
+ "group #{existing_key.label.inspect} was given conflicting percentiles: " \
66
+ "#{existing.inspect} and #{incoming.inspect}"
67
+ end
68
+ end
44
69
  end
@@ -1,8 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'jirametrics/percentile_validation'
4
+
3
5
  class GroupingRules < Rules
6
+ include PercentileValidation
7
+
4
8
  attr_accessor :label, :issue_hint, :label_hint
5
- attr_reader :color, :last_day_of_period
9
+ attr_reader :color, :last_day_of_period, :percentiles
10
+
11
+ # nil means "inherit whatever the chart is configured with" and an empty list means "draw no
12
+ # lines for this group", so neither is validated. Everything else gets the same treatment as
13
+ # the chart level setter, because these numbers become JavaScript identifiers downstream and a
14
+ # bad one takes the whole chart out with a syntax error.
15
+ def percentiles= list
16
+ @percentiles = list.nil? ? nil : validate_percentiles(list)
17
+ end
6
18
 
7
19
  def last_day_of_period= value
8
20
  @last_day_of_period = value.is_a?(String) ? Date.parse(value) : value
@@ -40,15 +40,26 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
40
40
  annotations: {
41
41
  <%= working_days_annotation %>
42
42
 
43
- <% if percentage_line_x %>
44
- line: {
43
+ <% @percentage_lines.each do |percentage_line| %>
44
+ <%= percentage_line[:id].to_json %>: {
45
45
  type: 'line',
46
46
  scaleID: 'x',
47
- value: '<%= percentage_line_x %>',
47
+ value: '<%= percentage_line[:x] %>',
48
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
49
49
  borderWidth: 1,
50
- drawTime: 'afterDraw'
51
- }
50
+ hitTolerance: 6,
51
+ drawTime: 'afterDraw',
52
+ label: {
53
+ display: false,
54
+ content: <%= "#{ordinal percentage_line[:percentile]} percentile of completed work".to_json %>,
55
+ position: 'start',
56
+ backgroundColor: 'rgba(0,0,0,0.85)',
57
+ color: '#fff',
58
+ font: { size: 11 }
59
+ },
60
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
61
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
62
+ },
52
63
  <% end %>
53
64
  }
54
65
  },
@@ -57,7 +57,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
57
57
  }
58
58
  nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
59
59
 
60
- // Hide/show the 85% line for that dataset
60
+ // Hide/show the percentile lines for that dataset
61
61
  legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
62
62
 
63
63
  // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
@@ -8,6 +8,20 @@
8
8
  --cycletime-scatterplot-cap-rule-color: #555555;
9
9
  --cycletime-scatterplot-cap-gutter-color: rgba(0, 0, 0, 0.05);
10
10
 
11
+ /* Palette for things that need telling apart where no specific colour is wanted. Okabe-Ito,
12
+ chosen because it stays distinguishable under the common forms of colour vision deficiency.
13
+ If you need a SPECIFIC colour, configure it rather than relying on whichever slot comes up.
14
+ Add a slot by defining the next number; the count is read from this file, not hardcoded.
15
+ These deliberately have no dark mode overrides yet: lightening them for dark backgrounds
16
+ collapses blue into sky blue, and getting it right needs measuring. See jirametrics-60z. */
17
+ --palette-color-1: #0072B2; /* Okabe-Ito blue */
18
+ --palette-color-2: #E69F00; /* Okabe-Ito orange */
19
+ --palette-color-3: #009E73; /* Okabe-Ito bluish green */
20
+ --palette-color-4: #56B4E9; /* Okabe-Ito sky blue */
21
+ --palette-color-5: #D55E00; /* Okabe-Ito vermilion */
22
+ --palette-color-6: #CC79A7; /* Okabe-Ito reddish purple */
23
+ --palette-color-7: #F0E442; /* Okabe-Ito yellow */
24
+
11
25
  --non-working-days-color: #F0F0F0;
12
26
  --expedited-color: #D55E00; /* Okabe-Ito vermilion */
13
27
  --blocked-color: #D55E00; /* Okabe-Ito vermilion */
@@ -19,6 +33,41 @@
19
33
  --type-bug-color: #D55E00; /* Okabe-Ito vermilion */
20
34
  --type-spike-color: #CC79A7; /* Okabe-Ito reddish purple */
21
35
 
36
+ /* The dependency chart is drawn by graphviz, which sets the label text inside the node rather
37
+ than beside it, so unlike --type-*-color above these are judged against the text and not
38
+ against the page. They are opaque, so the fill is the background the text sits on and there
39
+ is nothing for a dark theme to override.
40
+
41
+ These are Okabe-Ito hues rather than Okabe-Ito itself. At full strength they sit in a middle
42
+ band of lightness where NEITHER black nor white text is comfortable on them, which is the
43
+ thing to understand before changing any of this. Contrast ratio alone will mislead you here:
44
+ two earlier attempts cleared 7:1 and then 9:1, both of which WCAG calls comfortable, and both
45
+ still read as muddy in a real report. A mid-toned fill with dark text on it looks wrong at
46
+ almost any ratio. Going DARK and putting white text on it is what actually reads, so most of
47
+ these fills are dark, and each carries its own label colour: they are a pair, and changing a
48
+ fill without rechecking its label is how you get unreadable nodes.
49
+
50
+ They are not all dark, because the lightness spread is what does most of the work of keeping
51
+ them apart. Levelling them all to the same darkness measures about 3, which is unusable;
52
+ pastelling them all measures about 4.7, which is what the original colours here did. Run
53
+ `rake check_colors` for what they currently measure, against the full Okabe-Ito set as a
54
+ reference ceiling. */
55
+ --dependency-chart-story-color: #015C41; /* Okabe-Ito bluish green, darkened */
56
+ --dependency-chart-story-label-color: white;
57
+ --dependency-chart-task-color: #56B4E9; /* Okabe-Ito sky blue, unchanged */
58
+ --dependency-chart-task-label-color: black;
59
+ --dependency-chart-bug-color: #783200; /* Okabe-Ito vermilion, darkened. Also Defect */
60
+ --dependency-chart-bug-label-color: white;
61
+ --dependency-chart-epic-color: #F0E442; /* Okabe-Ito yellow, unchanged */
62
+ --dependency-chart-epic-label-color: black;
63
+ --dependency-chart-spike-color: #762A58; /* Okabe-Ito reddish purple, darkened */
64
+ --dependency-chart-spike-label-color: white;
65
+
66
+ /* For a node whose fill was set in the config rather than coming from the palette above, where
67
+ we cannot know what would read on it. */
68
+ --dependency-chart-label-color: black;
69
+ --dependency-chart-link-color: gray;
70
+
22
71
  --status-category-todo-color: gray;
23
72
  --status-category-inprogress-color: #0072B2; /* Okabe-Ito blue */
24
73
  --status-category-done-color: #009E73; /* Okabe-Ito bluish green */
@@ -234,6 +283,10 @@ div.child_issue {
234
283
  html[data-theme="dark"] {
235
284
  --warning-banner: #9F2B00;
236
285
  --non-working-days-color: #2f2f2f;
286
+
287
+ /* See the matching comment in the prefers-color-scheme block below. */
288
+ --dependency-chart-link-color: #999999;
289
+
237
290
  --type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
238
291
  --type-task-color: #56B4E9; /* sky blue for dark bg */
239
292
  --type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
@@ -254,7 +307,14 @@ html[data-theme="dark"] {
254
307
  --wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
255
308
  --wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
256
309
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
257
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
310
+ /* Was inheriting the light #92D9C0, which sat too close to the completed colour above for
311
+ anyone with protanopia or deuteranopia. Those two mean "completed" and "completed but not
312
+ started", so they are exactly the pair a reader must separate. See jirametrics-3fg. */
313
+ --wip-chart-completed-but-not-started-color: #00A062;
314
+ /* Was #DE9AC4, a lightened reddish purple that collided with the sky blue used for the fastest
315
+ band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
316
+ stays in the same warm family so the ramp still reads cool to warm as work ages. */
317
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE;
258
318
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
259
319
  --estimate-accuracy-chart-active-border-color: #E69F00;
260
320
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
@@ -266,7 +326,10 @@ html[data-theme="dark"] {
266
326
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
267
327
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
268
328
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
269
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
329
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
330
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
331
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
332
+ background. See jirametrics-60z for the measurements. */
270
333
  --daily-view-selected-issue-background: #474747;
271
334
  --daily-view-issue-border: #2DCB9A;
272
335
  --daily-view-selected-issue-border: #E69F00;
@@ -376,6 +439,12 @@ html[data-theme="light"] {
376
439
  --warning-banner: #9F2B00;
377
440
 
378
441
  --non-working-days-color: #2f2f2f;
442
+
443
+ /* The only dependency chart colour needing a dark variant. The fills do not, because they
444
+ are opaque and are their own background, but link lines and their labels sit on the page.
445
+ Plain gray manages only 2.4:1 here against 3.94:1 in light mode, so it is lifted to match. */
446
+ --dependency-chart-link-color: #999999;
447
+
379
448
  --type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
380
449
  --type-task-color: #56B4E9; /* sky blue for dark bg */
381
450
  --type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
@@ -405,12 +474,12 @@ html[data-theme="light"] {
405
474
  --hierarchy-table-inactive-item-text-color: #939393;
406
475
 
407
476
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
408
- --wip-chart-completed-but-not-started-color: #92D9C0;
477
+ --wip-chart-completed-but-not-started-color: #00A062; /* see jirametrics-3fg */
409
478
  --wip-chart-duration-less-than-day-color: #56B4E9;
410
479
  --wip-chart-duration-week-or-less-color: #F0E442;
411
480
  --wip-chart-duration-two-weeks-or-less-color: #E69F00;
412
481
  --wip-chart-duration-four-weeks-or-less-color: #D55E00;
413
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
482
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
414
483
 
415
484
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
416
485
  --estimate-accuracy-chart-active-border-color: #E69F00;
@@ -425,7 +494,10 @@ html[data-theme="light"] {
425
494
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
426
495
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
427
496
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
428
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
497
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
498
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
499
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
500
+ background. See jirametrics-60z for the measurements. */
429
501
 
430
502
  --daily-view-selected-issue-background: #474747;
431
503
  --daily-view-issue-border: #2DCB9A;
@@ -4,9 +4,13 @@
4
4
  <title><%= project_name.empty? ? 'JiraMetrics' : "JiraMetrics - #{project_name}" %></title>
5
5
  <link rel="icon" type="image/png" href="https://github.com/mikebowler/jirametrics/blob/main/favicon.png?raw=true" />
6
6
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
7
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
+ <!-- Pinned to a major version, not to an exact one: patch and minor releases still arrive
8
+ automatically, but a new major cannot land in someone's report unannounced. The charts
9
+ depend on specific annotation plugin behaviour (hitTolerance, enter/leave, the label's own
10
+ drawTime), and nothing in the test suite reaches browser behaviour to catch a break. -->
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
8
12
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
9
- <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
13
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
10
14
  <script type="text/javascript">
11
15
  <%= javascript %>
12
16
  </script>
@@ -141,6 +141,22 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', eve
141
141
 
142
142
  // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
143
143
  // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
144
+ // Apply an alpha to a colour that may only be resolvable in the browser, such as one that came
145
+ // from a CSS variable. Ruby cannot do this arithmetic any more now that palette colours are
146
+ // variables rather than literals.
147
+ function withAlpha(color, alpha) {
148
+ const probe = document.createElement('canvas').getContext('2d')
149
+ probe.fillStyle = color
150
+ const resolved = probe.fillStyle // normalised by the browser to #rrggbb or rgba(...)
151
+ if (resolved.startsWith('#')) {
152
+ const r = parseInt(resolved.substr(1, 2), 16)
153
+ const g = parseInt(resolved.substr(3, 2), 16)
154
+ const b = parseInt(resolved.substr(5, 2), 16)
155
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
156
+ }
157
+ return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
158
+ }
159
+
144
160
  function createDiagonalPattern(color = 'black') {
145
161
  // create a 5x5 px canvas for the pattern's base shape
146
162
  let shape = document.createElement('canvas')
@@ -30,6 +30,22 @@ html[data-theme="light"] {
30
30
  --type-bug-color: orange;
31
31
  --type-spike-color: #9400D3;
32
32
 
33
+ /* The dependency chart's original pastels. They are pleasant but they are not safe: pale
34
+ colours sit close together, and green, peach and pale yellow are hard to separate for anyone
35
+ with red-green colour vision deficiency. Every one of them is light, so the label colours all
36
+ go back to black; the shipped set has dark fills that need white. */
37
+ --dependency-chart-story-color: #90EE90;
38
+ --dependency-chart-story-label-color: black;
39
+ --dependency-chart-task-color: #87CEFA;
40
+ --dependency-chart-task-label-color: black;
41
+ --dependency-chart-bug-color: #ffdab9;
42
+ --dependency-chart-bug-label-color: black;
43
+ --dependency-chart-epic-color: #fafad2;
44
+ --dependency-chart-epic-label-color: black;
45
+ --dependency-chart-spike-color: #DDA0DD;
46
+ --dependency-chart-spike-label-color: black;
47
+ --dependency-chart-link-color: gray;
48
+
33
49
  --status-category-todo-color: gray;
34
50
  --status-category-inprogress-color: #2663ff;
35
51
  --status-category-done-color: #00ff00;
@@ -106,6 +122,7 @@ html[data-theme="light"] {
106
122
  html[data-theme="dark"] {
107
123
  --warning-banner: #9F2B00;
108
124
  --non-working-days-color: #2f2f2f;
125
+ --dependency-chart-link-color: gray; /* the chart had no dark variant originally */
109
126
  --type-story-color: #6fb86f;
110
127
  --type-task-color: #0021b3;
111
128
  --type-bug-color: #bb5603;
@@ -139,6 +156,7 @@ html[data-theme="dark"] {
139
156
  :root {
140
157
  --warning-banner: #9F2B00;
141
158
  --non-working-days-color: #2f2f2f;
159
+ --dependency-chart-link-color: gray; /* the chart had no dark variant originally */
142
160
  --type-story-color: #6fb86f;
143
161
  --type-task-color: #0021b3;
144
162
  --type-bug-color: #bb5603;