predictability-engine 0.6.6

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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/bin/predictability-engine +19 -0
  3. data/bin/predictability-engine.bat +2 -0
  4. data/bin/setup +47 -0
  5. data/data/samples/sample_data.csv +19 -0
  6. data/data/samples/sample_data_large.csv +201 -0
  7. data/data/samples/wip_data.csv +5 -0
  8. data/lib/predictability_engine/agents/assistant.rb +29 -0
  9. data/lib/predictability_engine/agents/tools.rb +98 -0
  10. data/lib/predictability_engine/calculators/aging.rb +36 -0
  11. data/lib/predictability_engine/calculators/cfd.rb +80 -0
  12. data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
  13. data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
  14. data/lib/predictability_engine/calculators/throughput.rb +42 -0
  15. data/lib/predictability_engine/cli.rb +414 -0
  16. data/lib/predictability_engine/config.rb +167 -0
  17. data/lib/predictability_engine/data_generator.rb +53 -0
  18. data/lib/predictability_engine/data_manager.rb +28 -0
  19. data/lib/predictability_engine/data_sources/base.rb +93 -0
  20. data/lib/predictability_engine/data_sources/csv.rb +62 -0
  21. data/lib/predictability_engine/data_sources/excel.rb +18 -0
  22. data/lib/predictability_engine/data_sources/factory.rb +20 -0
  23. data/lib/predictability_engine/data_sources/jira.rb +201 -0
  24. data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
  25. data/lib/predictability_engine/duration.rb +16 -0
  26. data/lib/predictability_engine/excel_exporter.rb +48 -0
  27. data/lib/predictability_engine/html_style.rb +63 -0
  28. data/lib/predictability_engine/html_templates.rb +70 -0
  29. data/lib/predictability_engine/jira_auth/base.rb +26 -0
  30. data/lib/predictability_engine/jira_auth/basic.rb +15 -0
  31. data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
  32. data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
  33. data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
  34. data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
  35. data/lib/predictability_engine/jira_auth.rb +22 -0
  36. data/lib/predictability_engine/jira_config_prompter.rb +48 -0
  37. data/lib/predictability_engine/jira_workflow.rb +137 -0
  38. data/lib/predictability_engine/logger.rb +45 -0
  39. data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
  40. data/lib/predictability_engine/models/work_item.rb +43 -0
  41. data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
  42. data/lib/predictability_engine/pdf_visualizer.rb +64 -0
  43. data/lib/predictability_engine/raw_data_exporter.rb +46 -0
  44. data/lib/predictability_engine/report/constants.rb +70 -0
  45. data/lib/predictability_engine/report/image_generator.rb +36 -0
  46. data/lib/predictability_engine/report/text_renderer.rb +84 -0
  47. data/lib/predictability_engine/report.rb +328 -0
  48. data/lib/predictability_engine/report_generator.rb +170 -0
  49. data/lib/predictability_engine/setup_manager.rb +127 -0
  50. data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
  51. data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
  52. data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
  53. data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
  54. data/lib/predictability_engine/summary_visualizer.rb +36 -0
  55. data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
  56. data/lib/predictability_engine/terminal_visualizer.rb +97 -0
  57. data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
  58. data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
  59. data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
  60. data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
  61. data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
  62. data/lib/predictability_engine/vega_visualizer.rb +106 -0
  63. data/lib/predictability_engine/version.rb +5 -0
  64. data/lib/predictability_engine/visualizer.rb +114 -0
  65. data/lib/predictability_engine.rb +117 -0
  66. metadata +566 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ class Report
5
+ module TextRenderer
6
+ def self.render(report, fmt, color: false, layout: :standard, **)
7
+ config = Constants::FORMAT_CONFIG[fmt]
8
+ header = config[:h1].call(report.title)
9
+ if layout.to_sym == :landscape
10
+ return [header, '',
11
+ render_landscape(report, fmt, color: color, **)].join("\n")
12
+ end
13
+
14
+ [header, SummaryVisualizer.render(report.items, fmt, color: color, percentiles: report.percentiles),
15
+ *Constants::CHART_CONFIG.map do |id, cfg|
16
+ render_section(report, id, cfg, fmt, color: color, **)
17
+ end].join("\n")
18
+ end
19
+
20
+ def self.render_landscape(report, fmt, color: false, **opts)
21
+ sum = SummaryVisualizer.render(report.items, fmt, color: color, percentiles: report.percentiles)
22
+ section_opts = opts.merge(color: color, table: true)
23
+ ch = Constants::CHART_CONFIG.map { |id, c| render_section(report, id, c, fmt, section_opts) }
24
+ br = fmt == :confluence ? ' \\\\ ' : '<br>'
25
+ bold = fmt == :confluence ? '*' : '**'
26
+ h_regex = fmt == :confluence ? /^h\d\.\s*(.*)$/ : /^#+\s*(.*)$/
27
+ clean_sum = sum.gsub(h_regex, "#{bold}\\1#{bold}").gsub("\n\n", br).gsub("\n", br)
28
+ clean_ch = ch.map { |c| c.gsub("\n", br) }
29
+
30
+ build_table(fmt, clean_sum, clean_ch)
31
+ end
32
+
33
+ def self.build_table(fmt, sum, charts)
34
+ row1 = "| #{sum} | #{charts[0]} | #{charts[1]} | #{charts[2]} |"
35
+ span = fmt == :confluence ? '^' : nil
36
+ row2 = "| #{span} | #{charts[3]} | #{charts[4]} | #{charts[5]} |"
37
+ md_hdr = "| | | | |\n| :--- | :--- | :--- | :--- |"
38
+ [(fmt == :markdown ? md_hdr : nil), row1, row2].compact.join("\n")
39
+ end
40
+
41
+ def self.render_section(report, chart_id, cfg, fmt, options = {})
42
+ title = section_title(cfg[:title], fmt, options[:table])
43
+ if fmt != :terminal && image_available?(report, chart_id)
44
+ [title, report.render_image_link(chart_id, fmt)].join("\n")
45
+ elsif %i[markdown confluence].include?(fmt) && MermaidVisualizer.respond_to?(chart_id)
46
+ render_mermaid(report, chart_id, fmt, title)
47
+ else
48
+ render_ascii(report, chart_id, Constants::FORMAT_CONFIG[fmt], options[:color], title,
49
+ **options.except(:color, :table))
50
+ end
51
+ end
52
+
53
+ def self.section_title(title, fmt, table)
54
+ bold = fmt == :confluence ? '*' : '**'
55
+ table ? "#{bold}#{title}#{bold}" : Constants::FORMAT_CONFIG[fmt][:h2].call(title)
56
+ end
57
+
58
+ def self.image_available?(report, chart_id)
59
+ report.images_path && File.exist?(File.join(report.images_path, "#{chart_id}.png"))
60
+ end
61
+
62
+ def self.render_mermaid(report, chart_id, fmt, title)
63
+ wrap = fmt == :confluence ? ['{mermaid}', '{mermaid}'] : ['```mermaid', '```']
64
+ [title, wrap[0], MermaidVisualizer.send(chart_id, report.items, percentiles: report.percentiles),
65
+ wrap[1], render_legend(chart_id, report.percentiles)].compact.join("\n")
66
+ end
67
+
68
+ def self.render_ascii(report, chart_id, config, color, title, **opts) # rubocop:disable Metrics/ParameterLists
69
+ call_opts = opts.merge(percentiles: report.percentiles, color: color || false)
70
+ [title, config[:code].call(title, TerminalVisualizer.send(chart_id, report.items, **call_opts))].join("\n")
71
+ end
72
+
73
+ def self.render_legend(chart_id, pcts)
74
+ return nil unless chart_id == :forecasted_cfd_plot
75
+
76
+ "\n**Legend:** Arrivals (blue), Departures (green), Projections (various colors). " \
77
+ "Vertical lines for: #{pcts.join('%, ')}% confidence."
78
+ end
79
+
80
+ private_class_method :render_landscape, :build_table, :render_section, :section_title,
81
+ :image_available?, :render_mermaid, :render_ascii, :render_legend
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'report/constants'
5
+ require_relative 'report/image_generator'
6
+ require_relative 'report/text_renderer'
7
+
8
+ module PredictabilityEngine
9
+ class Report # rubocop:disable Metrics/ClassLength
10
+ include Constants
11
+
12
+ PRIORITY_SORT = lambda do |values|
13
+ values.sort_by { |v| [Constants::PRIORITY_ORDER.index(v) || Constants::PRIORITY_ORDER.size, v] }
14
+ end
15
+
16
+ FACETS = [
17
+ { key: :priority, label: 'Priority', accessor: :priority, dirname: 'priorities',
18
+ sort: PRIORITY_SORT },
19
+ { key: :type, label: 'Type', accessor: :type, dirname: 'types',
20
+ sort: lambda(&:sort) }
21
+ ].freeze
22
+
23
+ attr_reader :items, :title, :percentiles, :images_path, :type, :priority
24
+
25
+ def initialize(items, title: 'Predictability Report', percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
26
+ type: nil, priority: nil)
27
+ @type = type
28
+ @priority = priority
29
+ @items = filter_by_facets(items)
30
+ @title = title
31
+ @percentiles = percentiles
32
+ @images_path = nil
33
+ end
34
+
35
+ def self.generate_all(items)
36
+ reports = { all: new(items, title: 'Full Predictability Dashboard') }
37
+ FACETS.each do |facet|
38
+ values = facet_values(items, facet)
39
+ next unless values.size > 1
40
+
41
+ reports[facet[:key]] = values.to_h do |val|
42
+ [val, new(items, title: "Dashboard: #{val}", facet[:key] => val)]
43
+ end
44
+ end
45
+ reports
46
+ end
47
+
48
+ def self.facet_values(items, facet)
49
+ raw = items.map { |i| i.public_send(facet[:accessor]) || 'Unspecified' }.uniq
50
+ facet[:sort].call(raw)
51
+ end
52
+
53
+ def render(format, layout: nil, color: false, **extra_opts)
54
+ config = find_format_config(format)&.last
55
+ method_name = "render_#{find_format_config(format)&.first || format.to_sym}"
56
+ raise ArgumentError, "Unsupported format: #{format}" unless respond_to?(method_name, true)
57
+
58
+ opts = { layout: effective_layout(layout, config), color: color }.merge(extra_opts)
59
+ opts[:format] = config[:format] if config&.key?(:format)
60
+ opts[:landscape] = config[:landscape] if config&.key?(:landscape)
61
+ send(method_name, **opts)
62
+ end
63
+
64
+ def generate_chart_images(base_dir, **)
65
+ @images_path = ImageGenerator.generate(self, base_dir, **)
66
+ end
67
+
68
+ def render_html(layout: :landscape, sub_reports: nil, **)
69
+ charts = CHART_CONFIG.map do |id, cfg|
70
+ chart = VegaVisualizer.send(cfg[:vega] || id, @items,
71
+ title: nil,
72
+ percentiles: @percentiles)
73
+ { title: cfg[:title], chart: chart }
74
+ end
75
+ Visualizer.to_full_html(charts, @items, title: @title, layout: layout, percentiles: @percentiles,
76
+ sub_reports: sub_reports)
77
+ end
78
+
79
+ def with_report_temp_html(layout: :landscape)
80
+ temp_html = "tmp/report_#{object_id}.html"
81
+ FileUtils.mkdir_p('tmp')
82
+ File.write(temp_html, render_html(layout: layout))
83
+ yield temp_html
84
+ ensure
85
+ FileUtils.rm_f(temp_html) if temp_html
86
+ end
87
+
88
+ def playwright_bin
89
+ root = File.expand_path('../..', __dir__)
90
+ unless ENV['PLAYWRIGHT_BROWSERS_PATH']
91
+ require 'etc'
92
+ real_home = begin
93
+ Etc.getpwuid.dir
94
+ rescue StandardError
95
+ Dir.home
96
+ end
97
+ ENV['PLAYWRIGHT_BROWSERS_PATH'] = File.expand_path('.cache/ms-playwright', real_home)
98
+ end
99
+ File.exist?("#{root}/node_modules/.bin/playwright") ? "#{root}/node_modules/.bin/playwright" : 'npx playwright'
100
+ end
101
+
102
+ def playwright_chromium_launch_opts
103
+ exe = ENV.fetch('PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH', nil)
104
+ return {} unless exe
105
+
106
+ # System Chromium (e.g. Alpine apk) needs --no-sandbox in Docker containers.
107
+ { executablePath: exe, chromiumSandbox: false }
108
+ end
109
+
110
+ def pdf_viewport_size(format, landscape)
111
+ res = RESOLUTION_CONFIG[format.to_s.downcase]
112
+ if res
113
+ return landscape ? res : [res[1], res[0]]
114
+ end
115
+
116
+ sizes = { 'A4' => [794, 1123], 'A3' => [1123, 1587] }
117
+ w, h = sizes[format.to_s.upcase] || sizes[DEFAULT_SIZE.to_s.upcase]
118
+ landscape ? [h, w] : [w, h]
119
+ end
120
+
121
+ def render_image_link(chart_id, fmt)
122
+ rel_path = "images/#{chart_id}.png"
123
+ fmt == :confluence ? "!#{rel_path}!" : "![](#{rel_path})"
124
+ end
125
+
126
+ private
127
+
128
+ def filter_by_facets(items)
129
+ FACETS.reduce(items) do |acc, facet|
130
+ filter_val = instance_variable_get("@#{facet[:key]}")
131
+ next acc unless filter_val
132
+
133
+ acc.select { |i| (i.public_send(facet[:accessor]) || 'Unspecified') == filter_val }
134
+ end
135
+ end
136
+
137
+ def render_ppt_multi_slide
138
+ require 'powerpoint'
139
+ base_dir = "tmp/ppt_#{object_id}"
140
+ FileUtils.mkdir_p(base_dir)
141
+ generate_chart_images_or_warn(base_dir)
142
+
143
+ deck = Powerpoint::Presentation.new
144
+ deck.add_intro(@title, "Generated on #{PredictabilityEngine.format_datetime(Time.now)}")
145
+
146
+ metrics = SummaryVisualizer.metrics_terminal(@items, color: false, percentiles: @percentiles)
147
+ deck.add_textual_slide('Flow Metrics Summary', metrics.split("\n").map(&:strip).reject(&:empty?))
148
+
149
+ CHART_CONFIG.each_key do |id|
150
+ img = File.join(@images_path, "#{id}.png") if @images_path
151
+ deck.add_pictorial_slide(CHART_CONFIG[id][:title], img) if img && File.exist?(img)
152
+ end
153
+
154
+ ppt_file = File.join(base_dir, 'dashboard.pptx')
155
+ deck.save(ppt_file)
156
+ File.binread(ppt_file)
157
+ ensure
158
+ FileUtils.rm_rf(base_dir) if base_dir
159
+ end
160
+
161
+ def generate_chart_images_or_warn(base_dir, **)
162
+ generate_chart_images(base_dir, **)
163
+ rescue StandardError => e
164
+ PredictabilityEngine.warn_chart_failure(e, context: 'Slides will be text-only.')
165
+ @images_path = nil
166
+ end
167
+
168
+ def find_format_config(format)
169
+ fmt = format.to_sym
170
+ FORMAT_CONFIG.find { |k, v| k == fmt || v[:aliases]&.include?(fmt) }
171
+ end
172
+
173
+ def effective_layout(layout, config) = (layout || config&.dig(:layout) || :landscape).to_sym
174
+
175
+ %i[terminal markdown confluence].each do |f|
176
+ define_method("render_#{f}") { |**o| TextRenderer.render(self, f, **o) }
177
+ end
178
+
179
+ def render_raw_csv(**)
180
+ require_relative 'raw_data_exporter'
181
+ RawDataExporter.generate_csv(@items)
182
+ end
183
+
184
+ def render_xlsx(**)
185
+ require_relative 'excel_exporter'
186
+ Dir.mktmpdir('pe_xlsx_') do |dir|
187
+ generate_chart_images(dir, width: ExcelExporter::CAPTURE_WIDTH,
188
+ height: ExcelExporter::CAPTURE_HEIGHT,
189
+ scale: ExcelExporter::CHART_SCALE)
190
+ ExcelExporter.generate(@items, images_path: @images_path)
191
+ end
192
+ end
193
+
194
+ def render_landscape(layout: :landscape, **) = render_html(layout: layout, **)
195
+ def render_a3_landscape(**) = render_pdf(**)
196
+
197
+ def render_png(layout: :landscape, size: DEFAULT_SIZE, **)
198
+ res = RESOLUTION_CONFIG[size.to_s.downcase] || RESOLUTION_CONFIG[DEFAULT_SIZE]
199
+ with_report_temp_html(layout: layout) do |temp_html|
200
+ png_data = nil
201
+ with_playwright_page(temp_html, width: res[0], height: res[1]) do |page|
202
+ png_data = page.screenshot
203
+ end
204
+ png_data
205
+ end
206
+ end
207
+
208
+ def render_ppt(size: DEFAULT_SIZE, **_opts)
209
+ require 'powerpoint'
210
+ res = RESOLUTION_CONFIG[size.to_s.downcase] || RESOLUTION_CONFIG[DEFAULT_SIZE]
211
+ temp_img = "tmp/dashboard_#{object_id}.png"
212
+ ppt_file = "tmp/dashboard_#{object_id}.pptx"
213
+
214
+ with_report_temp_html(layout: :landscape) do |temp_html|
215
+ capture_screenshot(temp_html, temp_img, width: res[0], height: res[1])
216
+
217
+ deck = Powerpoint::Presentation.new
218
+ deck.add_pictorial_slide(@title, temp_img)
219
+ deck.save(ppt_file)
220
+ File.binread(ppt_file)
221
+ end
222
+ rescue StandardError => e
223
+ PredictabilityEngine.logger.warn do
224
+ "High-fidelity PPT generation failed: #{e.message}. Falling back to multi-slide."
225
+ end
226
+ render_ppt_multi_slide
227
+ ensure
228
+ FileUtils.rm_f(temp_img) if temp_img
229
+ FileUtils.rm_f(ppt_file) if ppt_file
230
+ end
231
+
232
+ def capture_screenshot(html_path, img_path, width: 1280, height: 720)
233
+ with_playwright_page(html_path, width: width, height: height) do |page|
234
+ page.screenshot(path: img_path, fullPage: true)
235
+ end
236
+ end
237
+
238
+ def render_pdf(layout: :landscape, high_fidelity: true, format: nil, size: DEFAULT_SIZE, landscape: true, **_opts) # rubocop:disable Metrics/ParameterLists
239
+ fmt = format || size
240
+ high_fidelity ? render_pdf_playwright(layout: layout, format: fmt, landscape: landscape) : render_pdf_prawn
241
+ rescue StandardError => e
242
+ PredictabilityEngine.logger.warn { "High-fidelity PDF generation failed: #{e.message}. Falling back to Prawn." }
243
+ render_pdf_prawn
244
+ end
245
+
246
+ def render_pdf_playwright(layout: :landscape, format: DEFAULT_SIZE, landscape: true)
247
+ with_report_temp_html(layout: layout) do |temp_html|
248
+ capture_pdf(temp_html, format, landscape)
249
+ end
250
+ end
251
+
252
+ def capture_pdf(html_path, format, landscape)
253
+ w, h = pdf_viewport_size(format, landscape)
254
+ pdf_data = nil
255
+ with_playwright_page(html_path, width: w, height: h) do |page|
256
+ pdf_opts = { printBackground: true,
257
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
258
+ width: "#{w}px", height: "#{h}px" }
259
+ pdf_data = page.pdf(**pdf_opts)
260
+ end
261
+ pdf_data
262
+ end
263
+
264
+ def with_playwright_page(html_path, width: 1280, height: 720)
265
+ require 'playwright'
266
+ Playwright.create(playwright_cli_executable_path: playwright_bin) do |p|
267
+ p.chromium.launch(**playwright_chromium_launch_opts) do |browser|
268
+ page = browser.new_page(viewport: { width: width, height: height })
269
+ route_vega_cdn(page)
270
+ page.goto("file://#{File.expand_path(html_path)}")
271
+ sleep 2 # wait for Vega to render
272
+ yield page
273
+ end
274
+ end
275
+ end
276
+
277
+ def route_vega_cdn(page)
278
+ js_dir = File.join(Gem.loaded_specs['vega'].gem_dir, 'vendor', 'assets', 'javascripts')
279
+ { '**/npm/vega@5**' => 'vega.js', '**/npm/vega-lite@5**' => 'vega-lite.js',
280
+ '**/npm/vega-embed@6**' => 'vega-embed.js' }.each do |pattern, filename|
281
+ content = File.binread(File.join(js_dir, filename)).force_encoding('UTF-8')
282
+ page.route(pattern, ->(route, _req) { route.fulfill(body: content, contentType: 'application/javascript') })
283
+ end
284
+ end
285
+
286
+ def render_pdf_prawn(**)
287
+ require 'prawn'
288
+ Prawn::Document.new(page_layout: :landscape, margin: 30) do |pdf|
289
+ Report.send(:setup_pdf_font_on_doc, pdf)
290
+ pdf.text @title, size: 20, style: :bold
291
+ pdf.move_down 15
292
+
293
+ pdf.define_grid(columns: 3, rows: 2, gutter: 15)
294
+
295
+ pdf.grid([0, 0], [1, 0]).bounding_box do
296
+ pdf.text 'Flow Metrics Summary', size: 14, style: :bold
297
+ pdf.move_down 10
298
+ pdf.text SummaryVisualizer.metrics_terminal(@items, color: false, percentiles: @percentiles), size: 8
299
+ end
300
+
301
+ charts = CHART_CONFIG.keys
302
+ [[0, 1], [0, 2], [1, 1], [1, 2]].each_with_index do |grid_pos, i|
303
+ id = charts[i]
304
+ next unless id
305
+
306
+ pdf.grid(*grid_pos).bounding_box do
307
+ pdf.text CHART_CONFIG[id][:title], size: 10, style: :bold
308
+ pdf.move_down 5
309
+ PdfVisualizer.draw_chart(pdf, id, @items, percentiles: @percentiles)
310
+ end
311
+ end
312
+ end.render
313
+ end
314
+
315
+ def self.setup_pdf_font_on_doc(pdf)
316
+ mono = find_font(Constants::FONT_PATHS)
317
+ bold = find_font(Constants::FONT_BOLD_PATHS)
318
+ return unless mono && bold
319
+
320
+ pdf.font_families.update('CustomMono' => { normal: mono, bold: bold })
321
+ pdf.font 'CustomMono'
322
+ end
323
+
324
+ def self.find_font(paths) = paths.find { |p| File.exist?(p) }
325
+
326
+ private_class_method :setup_pdf_font_on_doc, :find_font
327
+ end
328
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module PredictabilityEngine
6
+ # Logic for generating and writing reports.
7
+ module ReportGenerator # rubocop:disable Metrics/ModuleLength
8
+ def self.run_report(file, format, items: nil, reports: nil, **)
9
+ items ||= PredictabilityEngine.load_items(file, **)
10
+ reports ||= Report.generate_all(items)
11
+
12
+ if facet_total(reports).zero? || format.to_sym == :terminal
13
+ generate_single_report(file, format, reports[:all], **)
14
+ else
15
+ generate_multi_reports(file, format, reports, **)
16
+ end
17
+ end
18
+
19
+ def self.facet_total(reports)
20
+ Report::FACETS.sum { |f| (reports[f[:key]] || {}).size }
21
+ end
22
+
23
+ def self.each_facet_entry(reports)
24
+ yield :all, reports[:all]
25
+ Report::FACETS.each do |facet|
26
+ (reports[facet[:key]] || {}).each { |value, report| yield [facet[:key], value], report }
27
+ end
28
+ end
29
+
30
+ def self.generate_single_report(file, format, report, **opts)
31
+ fmt = format.to_sym
32
+ generate_images_if_needed(file, fmt, report, **opts)
33
+ opts = opts.merge(sub_reports: export_links_for(:all)) if html_format?(fmt) && !opts[:sub_reports]
34
+
35
+ content = report.render(fmt, **opts)
36
+ if opts[:output] || fmt != :terminal
37
+ write_report(file, format, content, opts[:output], **opts)
38
+ else
39
+ content
40
+ end
41
+ end
42
+
43
+ def self.generate_multi_reports(file, format, reports, **opts)
44
+ fmt = format.to_sym
45
+ msgs = []
46
+ each_facet_entry(reports) do |slot, report|
47
+ generate_images_if_needed(file, fmt, report, **opts)
48
+ links = build_nav_links(fmt, reports, slot)
49
+ content = report.render(fmt, sub_reports: links, **opts)
50
+ msg = write_report(file, format, content, opts[:output], slot: slot, **opts)
51
+ PredictabilityEngine.logger.info { msg }
52
+ msgs << msg
53
+ end
54
+ "#{msgs.size} reports generated"
55
+ end
56
+
57
+ def self.generate_images_if_needed(file, format, report, **)
58
+ return unless %i[markdown md confluence conf].include?(format)
59
+
60
+ dir = report_dir(file, **)
61
+ report.generate_chart_images(dir)
62
+ rescue StandardError => e
63
+ PredictabilityEngine.warn_chart_failure(e, context: 'Inline images will be omitted.')
64
+ end
65
+
66
+ def self.build_nav_links(format, reports, current_slot)
67
+ return unless html_format?(format)
68
+
69
+ links = [nav_entry(:all, current_slot, label: 'All')]
70
+ Report::FACETS.each do |facet|
71
+ values = (reports[facet[:key]] || {}).keys
72
+ next if values.empty?
73
+
74
+ links << { separator: true }
75
+ values.each { |value| links << nav_entry([facet[:key], value], current_slot, label: value) }
76
+ end
77
+ links + export_links_for(current_slot)
78
+ end
79
+
80
+ def self.html_format?(format)
81
+ %i[html landscape].include?(format)
82
+ end
83
+
84
+ def self.export_links_for(slot)
85
+ prefix = slot.is_a?(Array) ? '../' : ''
86
+ [{ label: 'CSV', url: "#{prefix}dashboard.csv", download: true },
87
+ { label: 'XLSX', url: "#{prefix}dashboard.xlsx", download: true }]
88
+ end
89
+
90
+ def self.nav_entry(slot, current_slot, label:)
91
+ { label: label, url: nav_url(slot, current_slot), active: slot == current_slot }
92
+ end
93
+
94
+ def self.nav_url(slot, current_slot)
95
+ return main_dashboard_url(current_slot) if slot == :all
96
+
97
+ target_facet_key, value = slot
98
+ target_dir = facet_dirname(target_facet_key)
99
+ return "#{value}.html" if current_slot.is_a?(Array) && current_slot[0] == target_facet_key
100
+ return "../#{target_dir}/#{value}.html" if current_slot.is_a?(Array)
101
+
102
+ "#{target_dir}/#{value}.html"
103
+ end
104
+
105
+ def self.main_dashboard_url(current_slot)
106
+ current_slot.is_a?(Array) ? '../dashboard.html' : 'dashboard.html'
107
+ end
108
+
109
+ def self.facet_dirname(facet_key)
110
+ Report::FACETS.find { |f| f[:key] == facet_key }[:dirname]
111
+ end
112
+
113
+ def self.write_report(file, format, content, output, slot: :all, **) # rubocop:disable Metrics/ParameterLists
114
+ ext = format_to_ext(format.to_sym)
115
+ dir = report_dir(file, **)
116
+
117
+ if slot == :all
118
+ FileUtils.mkdir_p(dir) unless output || File.exist?(dir)
119
+ output ||= File.join(dir, dashboard_filename(format.to_sym, ext))
120
+ else
121
+ facet_key, value = slot
122
+ dir = File.join(dir, facet_dirname(facet_key))
123
+ FileUtils.mkdir_p(dir)
124
+ output = File.join(dir, "#{value}.#{ext}")
125
+ end
126
+
127
+ File.binwrite(output, content)
128
+ "Report generated at #{output}"
129
+ end
130
+
131
+ def self.report_dir(file, **opts)
132
+ base_dir = if opts[:output_dir]
133
+ opts[:output_dir]
134
+ else
135
+ input_dir = File.dirname(file)
136
+ input_dir == '.' ? 'reports' : File.join(input_dir, 'reports')
137
+ end
138
+ Pathname.new(File.join(base_dir, File.basename(file, '.*'))).cleanpath.to_s
139
+ end
140
+
141
+ def self.clean_report_dir(file, **)
142
+ dir = report_dir(file, **)
143
+ FileUtils.rm_rf(dir)
144
+ end
145
+
146
+ def self.dashboard_filename(format, ext)
147
+ case format
148
+ when :html, :landscape, :dashboard then 'dashboard.html'
149
+ when :a3_landscape then 'dashboard_a3.pdf'
150
+ when :png then 'dashboard.png'
151
+ else "dashboard.#{ext}"
152
+ end
153
+ end
154
+
155
+ FORMAT_TO_EXT = {
156
+ markdown: 'md', md: 'md',
157
+ confluence: 'conf', conf: 'conf',
158
+ landscape: 'html', dashboard: 'html',
159
+ a3_landscape: 'pdf',
160
+ ppt: 'pptx',
161
+ png: 'png',
162
+ raw_csv: 'csv',
163
+ xlsx: 'xlsx'
164
+ }.freeze
165
+
166
+ def self.format_to_ext(format)
167
+ FORMAT_TO_EXT[format] || format.to_s
168
+ end
169
+ end
170
+ end