jirametrics 2.14 → 2.22

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +176 -134
  3. data/lib/jirametrics/anonymizer.rb +8 -6
  4. data/lib/jirametrics/atlassian_document_format.rb +3 -3
  5. data/lib/jirametrics/bar_chart_range.rb +17 -0
  6. data/lib/jirametrics/board.rb +4 -0
  7. data/lib/jirametrics/board_config.rb +3 -1
  8. data/lib/jirametrics/change_item.rb +11 -4
  9. data/lib/jirametrics/chart_base.rb +34 -2
  10. data/lib/jirametrics/cycletime_config.rb +22 -4
  11. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  12. data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
  13. data/lib/jirametrics/daily_view.rb +6 -20
  14. data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
  15. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
  16. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  17. data/lib/jirametrics/data_quality_report.rb +8 -3
  18. data/lib/jirametrics/dependency_chart.rb +4 -1
  19. data/lib/jirametrics/downloader.rb +34 -99
  20. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  21. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  22. data/lib/jirametrics/examples/standard_project.rb +9 -9
  23. data/lib/jirametrics/expedited_chart.rb +1 -1
  24. data/lib/jirametrics/exporter.rb +12 -5
  25. data/lib/jirametrics/file_system.rb +24 -1
  26. data/lib/jirametrics/fix_version.rb +13 -0
  27. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  28. data/lib/jirametrics/groupable_issue_chart.rb +7 -1
  29. data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
  30. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  31. data/lib/jirametrics/html/aging_work_table.erb +2 -0
  32. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  33. data/lib/jirametrics/html/cycletime_histogram.erb +4 -2
  34. data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
  35. data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -1
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
  39. data/lib/jirametrics/html/index.css +16 -9
  40. data/lib/jirametrics/html/index.erb +3 -35
  41. data/lib/jirametrics/html/index.js +114 -0
  42. data/lib/jirametrics/html/sprint_burndown.erb +11 -3
  43. data/lib/jirametrics/html/throughput_chart.erb +2 -2
  44. data/lib/jirametrics/html_generator.rb +31 -0
  45. data/lib/jirametrics/html_report_config.rb +8 -25
  46. data/lib/jirametrics/issue.rb +125 -19
  47. data/lib/jirametrics/jira_gateway.rb +55 -17
  48. data/lib/jirametrics/project_config.rb +22 -2
  49. data/lib/jirametrics/raw_javascript.rb +13 -0
  50. data/lib/jirametrics/settings.json +3 -1
  51. data/lib/jirametrics/sprint.rb +12 -0
  52. data/lib/jirametrics/sprint_burndown.rb +6 -2
  53. data/lib/jirametrics/stitcher.rb +75 -0
  54. data/lib/jirametrics.rb +26 -70
  55. metadata +10 -3
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForDataCenter < Downloader
4
+ def jira_instance_type
5
+ 'Jira DataCenter'
6
+ end
7
+
8
+ def download_issues board:
9
+ log " Downloading primary issues for board #{board.id}", both: true
10
+ path = File.join(@target_path, "#{file_prefix}_issues/")
11
+ unless Dir.exist?(path)
12
+ log " Creating path #{path}"
13
+ Dir.mkdir(path)
14
+ end
15
+
16
+ filter_id = board_id_to_filter_id[board.id]
17
+ jql = make_jql(filter_id: filter_id)
18
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
19
+
20
+ log " Downloading linked issues for board #{board.id}", both: true
21
+ loop do
22
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
23
+ break if @issue_keys_pending_download.empty?
24
+
25
+ keys_to_request = @issue_keys_pending_download[0..99]
26
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
+ jql = "key in (#{keys_to_request.join(', ')})"
28
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
29
+ end
30
+ end
31
+
32
+ def jira_search_by_jql jql:, initial_query:, board:, path:
33
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
34
+ jql = intercept_jql.call jql if intercept_jql
35
+
36
+ log " JQL: #{jql}"
37
+ escaped_jql = CGI.escape jql
38
+
39
+ max_results = 100
40
+ start_at = 0
41
+ total = 1
42
+ while start_at < total
43
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
44
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
45
+
46
+ json['issues'].each do |issue_json|
47
+ issue_json['exporter'] = {
48
+ 'in_initial_query' => initial_query
49
+ }
50
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
51
+ file = "#{issue_json['key']}-#{board.id}.json"
52
+
53
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
54
+ end
55
+
56
+ total = json['total'].to_i
57
+ max_results = json['maxResults']
58
+
59
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
60
+ log message, both: true
61
+
62
+ start_at += json['issues'].size
63
+ end
64
+ end
65
+
66
+ def make_jql filter_id:, today: Date.today
67
+ segments = []
68
+ segments << "filter=#{filter_id}"
69
+
70
+ start_date = @download_config.start_date today: today
71
+
72
+ if start_date
73
+ @download_date_range = start_date..today.to_date
74
+
75
+ # For an incremental download, we want to query from the end of the previous one, not from the
76
+ # beginning of the full range.
77
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
78
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
79
+
80
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
81
+ # had an update during the range.
82
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
83
+
84
+ # Pick up any issues that had a status change in the range
85
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
86
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
87
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
88
+
89
+ segments << "(#{find_in_range} OR #{catch_all})"
90
+ end
91
+
92
+ segments.join ' AND '
93
+ end
94
+ end
@@ -15,15 +15,6 @@ class Exporter
15
15
  self.anonymize if anonymize
16
16
  self.settings.merge! settings
17
17
 
18
- status_category_mappings.each do |status, category|
19
- status_category_mapping status: status, category: category
20
- end
21
-
22
- download do
23
- self.rolling_date_count(rolling_date_count) if rolling_date_count
24
- self.no_earlier_than(no_earlier_than) if no_earlier_than
25
- end
26
-
27
18
  boards.each_key do |board_id|
28
19
  block = boards[board_id]
29
20
  if block == :default
@@ -37,6 +28,15 @@ class Exporter
37
28
  end
38
29
  end
39
30
 
31
+ status_category_mappings.each do |status, category|
32
+ status_category_mapping status: status, category: category
33
+ end
34
+
35
+ download do
36
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
37
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ end
39
+
40
40
  issues.reject! do |issue|
41
41
  ignore_types.include? issue.type
42
42
  end
@@ -48,7 +48,7 @@ class ExpeditedChart < ChartBase
48
48
  end
49
49
 
50
50
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
51
+ '<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
52
52
  else
53
53
  wrap_and_render(binding, __FILE__)
54
54
  end
@@ -50,24 +50,27 @@ class Exporter
50
50
  end
51
51
 
52
52
  project.download_config.run
53
- downloader = Downloader.new(
53
+ gateway = JiraGateway.new(
54
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
55
+ )
56
+ downloader = Downloader.create(
54
57
  download_config: project.download_config,
55
58
  file_system: file_system,
56
- jira_gateway: JiraGateway.new(file_system: file_system)
59
+ jira_gateway: gateway
57
60
  )
58
61
  downloader.run
59
62
  end
60
63
  puts "Full output from downloader in #{file_system.logfile_name}"
61
64
  end
62
65
 
63
- def info keys, name_filter:
66
+ def info key, name_filter:
64
67
  selected = []
65
68
  each_project_config(name_filter: name_filter) do |project|
66
69
  project.evaluate_next_level
67
70
 
68
71
  project.run load_only: true
69
72
  project.issues.each do |issue|
70
- selected << [project, issue] if keys.include? issue.key
73
+ selected << [project, issue] if key == issue.key
71
74
  end
72
75
  rescue => e # rubocop:disable Style/RescueStandardError
73
76
  # This happens when we're attempting to load an aggregated project because it hasn't been
@@ -76,7 +79,7 @@ class Exporter
76
79
  end
77
80
 
78
81
  if selected.empty?
79
- file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
82
+ file_system.log "No issues found to match #{key.inspect}"
80
83
  else
81
84
  selected.each do |project, issue|
82
85
  file_system.log "\nProject #{project.name}", also_write_to_stderr: true
@@ -85,6 +88,10 @@ class Exporter
85
88
  end
86
89
  end
87
90
 
91
+ def stitch stitch_file
92
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
93
+ end
94
+
88
95
  def each_project_config name_filter:
89
96
  @project_configs.each do |project|
90
97
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -5,6 +5,13 @@ require 'json'
5
5
  class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
+ def initialize
9
+ # In almost all cases, this will be immediately replaced in the Exporter
10
+ # but if we fail before we get that far, this will at least let a useful
11
+ # error show up on the console.
12
+ @logfile = $stdout
13
+ end
14
+
8
15
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
16
  def load filename, supress_deprecation: false
10
17
  if filename.end_with?('.json') && !supress_deprecation
@@ -31,6 +38,14 @@ class FileSystem
31
38
  File.write(filename, content)
32
39
  end
33
40
 
41
+ def mkdir path
42
+ FileUtils.mkdir_p path
43
+ end
44
+
45
+ def utime file:, time:
46
+ File.utime time, time, file
47
+ end
48
+
34
49
  def warning message, more: nil
35
50
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
51
  end
@@ -66,7 +81,15 @@ class FileSystem
66
81
  end
67
82
 
68
83
  def file_exist? filename
69
- File.exist? filename
84
+ File.exist?(filename) && File.file?(filename)
85
+ end
86
+
87
+ def dir_exist? path
88
+ File.exist?(path) && File.directory?(path)
89
+ end
90
+
91
+ def unlink filename
92
+ File.unlink filename
70
93
  end
71
94
 
72
95
  def deprecated message:, date:, depth: 2
@@ -11,11 +11,24 @@ class FixVersion
11
11
  @raw['name']
12
12
  end
13
13
 
14
+ def description
15
+ @raw['description']
16
+ end
17
+
14
18
  def id
15
19
  @raw['id'].to_i
16
20
  end
17
21
 
22
+ def release_date
23
+ text = @raw['releaseDate']
24
+ text.nil? ? nil : Date.parse(text)
25
+ end
26
+
18
27
  def released?
19
28
  @raw['released']
20
29
  end
30
+
31
+ def archived?
32
+ @raw['archived']
33
+ end
21
34
  end
@@ -60,7 +60,7 @@ class FlowEfficiencyScatterplot < ChartBase
60
60
  create_dataset(issues: issues, label: rules.label, color: rules.color)
61
61
  end
62
62
 
63
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
63
+ return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
64
64
 
65
65
  wrap_and_render(binding, __FILE__)
66
66
  end
@@ -15,14 +15,20 @@ module GroupableIssueChart
15
15
 
16
16
  def group_issues completed_issues
17
17
  result = {}
18
+ ignored_issues = []
18
19
  completed_issues.each do |issue|
19
20
  rules = GroupingRules.new
20
21
  @group_by_block.call(issue, rules)
21
- next if rules.ignored?
22
+ if rules.ignored?
23
+ ignored_issues << issue
24
+ next
25
+ end
22
26
 
23
27
  (result[rules] ||= []) << issue
24
28
  end
25
29
 
30
+ completed_issues.reject! { |issue| ignored_issues.include? issue }
31
+
26
32
  result.each_key do |rules|
27
33
  rules.color = random_color if rules.color.nil?
28
34
  end
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -66,4 +67,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
66
67
  }
67
68
  });
68
69
  </script>
69
-
70
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -40,7 +41,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
40
41
  color: <%= CssVariable['--grid-line-color'].to_json %>,
41
42
  z: 1 // draw the grid lines on top of the bars
42
43
  },
43
- stacked: true,
44
+ stacked: false,
44
45
  max: <%= (@max_age * 1.1).to_i %>
45
46
  }
46
47
  },
@@ -73,3 +74,4 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
73
74
  }
74
75
  });
75
76
  </script>
77
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <table class='standard'>
2
3
  <thead>
3
4
  <tr>
@@ -54,3 +55,4 @@
54
55
  <% end %>
55
56
  </tbody>
56
57
  </table>
58
+ <%= seam_end %>
@@ -1,5 +1,5 @@
1
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
2
- <table class='standard' id='<%= issues_id %>' style='display: none;'>
1
+ <div class='foldable startFolded'>Show details</div>
2
+ <table class='standard' id='<%= issues_id %>'>
3
3
  <thead>
4
4
  <tr>
5
5
  <th>Issue</th>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -6,8 +7,8 @@ if show_stats
6
7
  link_id = next_id
7
8
  issues_id = next_id
8
9
  %>
9
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
- <div id="<%= issues_id %>" style="display: none;">
10
+ <div class='foldable' style="padding-left: 1em;">Statistics</div>
11
+ <div id="<%= issues_id %>" style="padding-left: 1em;">
11
12
  <div>
12
13
  <table class="standard">
13
14
  <tr>
@@ -119,3 +120,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
119
120
  }
120
121
  });
121
122
  </script>
123
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -10,15 +11,14 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
10
11
  options: {
11
12
  title: {
12
13
  display: true,
13
- text: "Cycletime Scatterplot"
14
+ text: "<%= @header_text %>"
14
15
  },
15
16
  responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
16
17
  scales: {
17
18
  x: {
18
19
  type: "time",
19
20
  scaleLabel: {
20
- display: true,
21
- labelString: 'Date Completed'
21
+ display: true
22
22
  },
23
23
  grid: {
24
24
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -29,13 +29,12 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
29
29
  y: {
30
30
  scaleLabel: {
31
31
  display: true,
32
- labelString: 'Days',
33
32
  min: 0,
34
- max: <%= @highest_cycletime %>
33
+ max: <%= @highest_y_value %>
35
34
  },
36
35
  title: {
37
36
  display: true,
38
- text: 'Cycle time in days'
37
+ text: '<%= y_axis_heading %>'
39
38
  },
40
39
  grid: {
41
40
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -98,3 +97,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
98
97
  }
99
98
  });
100
99
  </script>
100
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -65,3 +66,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
65
66
  }
66
67
  });
67
68
  </script>
69
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -60,3 +61,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
60
61
  }
61
62
  });
62
63
  </script>
64
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -61,4 +62,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
61
62
  }
62
63
  }
63
64
  });
64
- </script>
65
+ </script>
66
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -83,3 +84,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
83
84
  }
84
85
  });
85
86
  </script>
87
+ <%= seam_start %>
@@ -67,10 +67,21 @@
67
67
  --sprint-burndown-sprint-color-4: red;
68
68
  --sprint-burndown-sprint-color-5: brown;
69
69
 
70
+ --sprint-color: lightblue;
71
+
70
72
  --daily-view-selected-issue-background: lightgray;
71
73
  --daily-view-issue-border: green;
72
74
  --daily-view-selected-issue-border: red;
73
75
 
76
+ /* The first five are the standard priorities that Jira creates by default. */
77
+ --priority-color-highest: #dc2626; /* red-600 - urgent red */
78
+ --priority-color-high: #ea580c; /* orange-600 - warning orange */
79
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
80
+ --priority-color-low: #0891b2; /* cyan-600 - calm blue */
81
+ --priority-color-lowest: #64748b; /* slate-500 - muted slate */
82
+ /* Then here are some values we've seen in multiple instances. */
83
+ --priority-color-notset: gray;
84
+ --priority-color-critical: red;
74
85
  }
75
86
 
76
87
  body {
@@ -78,11 +89,6 @@ body {
78
89
  color: var(--default-text-color);
79
90
  }
80
91
 
81
- h1 {
82
- border: 1px solid black;
83
- background: lightgray;
84
- padding-left: 0.2em;
85
- }
86
92
  dl, dd, dt {
87
93
  padding: 0;
88
94
  margin: 0;
@@ -242,11 +248,12 @@ div.child_issue {
242
248
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
243
249
 
244
250
  --daily-view-selected-issue-background: #474747;
245
- }
246
251
 
247
- h1 {
248
- color: #e0e0e0;
249
- background-color: #656565;
252
+ --priority-color-highest: #ef4444; /* red-500 - bright urgent red */
253
+ --priority-color-high: #f97316; /* orange-500 - bright orange */
254
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
255
+ --priority-color-low: #06b6d4; /* cyan-500 - bright calm blue */
256
+ --priority-color-lowest: #94a3b8; /* slate-400 - muted light slate */
250
257
  }
251
258
 
252
259
  a[href] {
@@ -5,41 +5,9 @@
5
5
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
6
6
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
7
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
8
- <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.2.2/chartjs-plugin-annotation.min.js" integrity="sha512-HycvvBSFvDEVyJ0tjE2rPmymkt6XqsP/Zo96XgLRjXwn6SecQqsn+6V/7KYev66OshZZ9+f9AttCGmYqmzytiw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
9
9
  <script type="text/javascript">
10
- function expand_collapse(link_id, issues_id) {
11
- link_text = document.getElementById(link_id).textContent
12
- if( link_text == 'Show details') {
13
- document.getElementById(link_id).textContent = 'Hide details'
14
- document.getElementById(issues_id).style.display = 'block'
15
- }
16
- else {
17
- document.getElementById(link_id).textContent = 'Show details'
18
- document.getElementById(issues_id).style.display = 'none'
19
- }
20
- }
21
-
22
- function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
23
- let open_link = document.getElementById(open_link_id)
24
- let close_link = document.getElementById(close_link_id)
25
- let toggleable_element = document.getElementById(toggleable_id)
26
-
27
- if(open_link.style.display == 'none') {
28
- open_link.style.display = 'block'
29
- close_link.style.display = 'none'
30
- toggleable_element.style.display = 'none'
31
- }
32
- else {
33
- open_link.style.display = 'none'
34
- close_link.style.display = 'block'
35
- toggleable_element.style.display = 'block'
36
- }
37
- }
38
- // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
39
- // in the other colour scheme.
40
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
41
- location.reload()
42
- })
10
+ <%= javascript %>
43
11
  </script>
44
12
  <style>
45
13
  <%= css %>
@@ -55,6 +23,6 @@
55
23
  </div>
56
24
  </noscript>
57
25
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
58
- <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
26
+ <%= "\n" + @sections.collect { |text, type| text if type != :header }.compact.join("\n\n") %>
59
27
  </body>
60
28
  </html>
@@ -0,0 +1,114 @@
1
+ function makeFoldable() {
2
+ // Get all elements with the "foldable" class
3
+ const foldableElements = document.querySelectorAll('.foldable');
4
+
5
+ if (foldableElements.length === 0) {
6
+ return; // No foldable elements found
7
+ }
8
+
9
+ // Process each foldable element
10
+ foldableElements.forEach((element, index) => {
11
+ // Skip if this is the footer element
12
+ if (element.id === 'footer') {
13
+ return;
14
+ }
15
+
16
+ // Create a unique ID for this section
17
+ const sectionId = `foldable-section-${index}`;
18
+ const toggleId = `foldable-toggle-${index}`;
19
+
20
+ // Create a container div for the foldable element and its content
21
+ const container = document.createElement('div');
22
+ container.className = 'foldable-section';
23
+ container.id = sectionId;
24
+
25
+ // Create a toggle button
26
+ const toggleButton = document.createElement(element.tagName); //'button');
27
+ toggleButton.id = toggleId;
28
+ toggleButton.className = 'foldable-toggle-btn';
29
+ toggleButton.innerHTML = '▼ ' + element.textContent;
30
+
31
+ // Create a content container
32
+ const contentContainer = document.createElement('div');
33
+ contentContainer.className = 'foldable-content';
34
+ contentContainer.style.cssText = `
35
+ border-left: 2px solid #ccc;
36
+ padding-left: 15px;
37
+ `;
38
+
39
+ // Move the foldable element into the container and replace it with the toggle button
40
+ element.parentNode.insertBefore(container, element);
41
+ container.appendChild(toggleButton);
42
+ container.appendChild(contentContainer);
43
+
44
+ // Move all elements between this foldable element and the next foldable element (or end of document) into the content container
45
+ let nextElement = element.nextElementSibling;
46
+ while (nextElement && !nextElement.classList.contains('foldable')) {
47
+ // Skip the footer element
48
+ if (nextElement.id === 'footer') {
49
+ break;
50
+ }
51
+
52
+ const temp = nextElement.nextElementSibling;
53
+ contentContainer.appendChild(nextElement);
54
+ nextElement = temp;
55
+ }
56
+
57
+ // Remove the original foldable element
58
+ element.remove();
59
+
60
+ // Add click event to toggle visibility
61
+ toggleButton.addEventListener('click', function() {
62
+ const content = this.nextElementSibling;
63
+ if (content.style.display === 'none') {
64
+ content.style.display = 'block';
65
+ this.innerHTML = '▼ ' + this.innerHTML.substring(2);
66
+ } else {
67
+ content.style.display = 'none';
68
+ this.innerHTML = '▶ ' + this.innerHTML.substring(2);
69
+ }
70
+ });
71
+
72
+ // Initially show the content (you can change this to 'none' if you want sections collapsed by default)
73
+ contentContainer.style.display = 'block';
74
+ if(element.classList.contains('startFolded')) {
75
+ toggleButton.click();
76
+ }
77
+ });
78
+ }
79
+
80
+ // Auto-initialize when DOM is loaded
81
+ document.addEventListener('DOMContentLoaded', function() {
82
+ makeFoldable();
83
+ });
84
+
85
+
86
+ // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
87
+ // in the other colour scheme.
88
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
89
+ location.reload()
90
+ })
91
+
92
+ // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
93
+ // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
94
+ function createDiagonalPattern(color = 'black') {
95
+ // create a 5x5 px canvas for the pattern's base shape
96
+ let shape = document.createElement('canvas')
97
+ shape.width = 5
98
+ shape.height = 5
99
+ // get the context for drawing
100
+ let c = shape.getContext('2d')
101
+ // draw 1st line of the shape
102
+ c.strokeStyle = color
103
+ c.beginPath()
104
+ c.moveTo(1, 0)
105
+ c.lineTo(5, 4)
106
+ c.stroke()
107
+ // draw 2nd line of the shape
108
+ c.beginPath()
109
+ c.moveTo(0, 4)
110
+ c.lineTo(1, 5)
111
+ c.stroke()
112
+ // create the pattern from the shape
113
+ return c.createPattern(shape, 'repeat')
114
+ }