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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module PredictabilityEngine
7
+ # Editable mapping of Jira workflow statuses → arrival/departure roles.
8
+ # Extracted from Jira's status catalogue, persisted to YAML, and consumed
9
+ # by DataSources::Jira to drive start_date / end_date selection.
10
+ class JiraWorkflow
11
+ DEFAULT_PATH_TEMPLATE = '~/.config/jira/%s.workflow.yml'
12
+ CATEGORY_ROLE_DEFAULTS = { 'in progress' => 'arrival', 'done' => 'departure' }.freeze
13
+ ROLES = %w[arrival departure].freeze
14
+
15
+ attr_reader :profile, :project, :statuses
16
+
17
+ def initialize(profile: nil, project: nil, statuses: [])
18
+ @profile = profile
19
+ @project = project
20
+ @statuses = statuses.map { |s| self.class.normalize_status(s) }
21
+ end
22
+
23
+ def self.default_path(profile_name)
24
+ File.expand_path(format(DEFAULT_PATH_TEMPLATE, profile_name))
25
+ end
26
+
27
+ def self.load(path)
28
+ return nil unless path && File.exist?(path)
29
+
30
+ raw = Config.load_yaml_file(path) || {}
31
+ new(profile: raw['profile'], project: raw['project'], statuses: raw['statuses'] || [])
32
+ end
33
+
34
+ def self.extract(profile_name, client: nil)
35
+ client ||= Config.jira_client(profile_name)
36
+ project = Config.jira(profile_name)[:project]
37
+ statuses = fetch_statuses(client).map { |s| seed_role(s) }
38
+ new(profile: profile_name, project: project, statuses: statuses)
39
+ end
40
+
41
+ # Refresh an existing config against a fresh Jira fetch:
42
+ # - statuses the user already annotated keep their role
43
+ # - brand-new statuses are added with the seeded default role
44
+ # - statuses that disappeared from Jira are dropped
45
+ def refresh(fresh)
46
+ existing_roles = @statuses.to_h { |s| [s[:name], s[:role]] }
47
+ @statuses = fresh.statuses.map do |s|
48
+ s.merge(role: existing_roles.key?(s[:name]) ? existing_roles[s[:name]] : s[:role])
49
+ end
50
+ @profile ||= fresh.profile
51
+ @project ||= fresh.project
52
+ self
53
+ end
54
+
55
+ def self.merge(configs)
56
+ merged = {}
57
+ configs.each do |cfg|
58
+ cfg.statuses.each { |s| merge_status(merged, s) }
59
+ end
60
+ new(statuses: merged.values)
61
+ end
62
+
63
+ def self.normalize_status(hash)
64
+ h = hash.transform_keys(&:to_s)
65
+ role = h['role'].to_s.empty? ? nil : h['role'].to_s
66
+ { name: h['name'], category: h['category'], role: role }
67
+ end
68
+
69
+ def write(path)
70
+ PredictabilityEngine.write_file(path, to_hash.to_yaml)
71
+ path
72
+ end
73
+
74
+ def to_hash
75
+ {
76
+ 'profile' => @profile,
77
+ 'project' => @project,
78
+ 'statuses' => @statuses.map { |s| s.transform_keys(&:to_s) }
79
+ }.compact
80
+ end
81
+
82
+ def arrival_names
83
+ names_for('arrival')
84
+ end
85
+
86
+ def departure_names
87
+ names_for('departure')
88
+ end
89
+
90
+ def self.fetch_statuses(client)
91
+ entries = client.Status.all.map { |status| status_entry(status) }
92
+ entries.uniq { |s| s[:name] }
93
+ rescue StandardError => e
94
+ PredictabilityEngine.logger.warn { "Failed to fetch Jira statuses: #{e.message}" }
95
+ []
96
+ end
97
+
98
+ def self.status_entry(status)
99
+ category = begin
100
+ status.statusCategory['name'].to_s.downcase
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ { name: status.name, category: category }
105
+ end
106
+
107
+ def self.seed_role(status)
108
+ status.merge(role: CATEGORY_ROLE_DEFAULTS[status[:category]])
109
+ end
110
+
111
+ def self.merge_status(target, status)
112
+ existing = target[status[:name]]
113
+ if existing
114
+ warn_conflict(existing, status) if existing[:role] && status[:role] && existing[:role] != status[:role]
115
+ existing[:role] ||= status[:role]
116
+ existing[:category] ||= status[:category]
117
+ else
118
+ target[status[:name]] = status.dup
119
+ end
120
+ end
121
+
122
+ def self.warn_conflict(existing, incoming)
123
+ PredictabilityEngine.logger.warn do
124
+ "Workflow merge conflict for '#{existing[:name]}': role " \
125
+ "#{existing[:role]} vs #{incoming[:role]} (kept #{existing[:role]})"
126
+ end
127
+ end
128
+
129
+ private_class_method :fetch_statuses, :status_entry, :seed_role, :merge_status, :warn_conflict
130
+
131
+ private
132
+
133
+ def names_for(role)
134
+ @statuses.select { |s| s[:role] == role }.map { |s| s[:name] }
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'semantic_logger'
4
+ require 'fileutils'
5
+
6
+ module PredictabilityEngine
7
+ # Custom console formatter matching current terminal output:
8
+ # INFO → plain message (no prefix)
9
+ # WARN → yellow "Warning: …"
10
+ # ERROR → red "Error: …"
11
+ # DEBUG → gray "DEBUG: …"
12
+ class TerminalFormatter < SemanticLogger::Formatters::Base
13
+ def call(log, _logger)
14
+ msg = log.message.to_s
15
+ case log.level
16
+ when :error then "\e[31mError: #{msg}\e[0m\n"
17
+ when :warn then "\e[33mWarning: #{msg}\e[0m\n"
18
+ when :debug then "\e[90mDEBUG: #{msg}\e[0m\n"
19
+ else "#{msg}\n"
20
+ end
21
+ end
22
+ end
23
+
24
+ # Called from CliBase#initialize via --log-level / --log-file options.
25
+ # Removes only appenders previously added by this method (tracked in
26
+ # @_pe_appenders) so that external appenders — e.g. test StringIO captures —
27
+ # are never disturbed.
28
+ def self.setup_logging(level: 'info', log_file: nil)
29
+ SemanticLogger.default_level = level.to_sym
30
+ (@_pe_appenders || []).each { |a| SemanticLogger.remove_appender(a) }
31
+ @_pe_appenders = []
32
+ @_pe_appenders << SemanticLogger.add_appender(io: $stdout, formatter: TerminalFormatter.new)
33
+ return unless log_file
34
+
35
+ FileUtils.mkdir_p(File.dirname(log_file))
36
+ @_pe_appenders << SemanticLogger.add_appender(file_name: log_file, formatter: :json)
37
+ end
38
+
39
+ # Module-level logger named "PredictabilityEngine".
40
+ # Memoized so that all call sites share the same instance (required for RSpec
41
+ # `receive` mocks, and avoids allocating a new object on every log call).
42
+ def self.logger
43
+ @logger ||= SemanticLogger[self]
44
+ end
45
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module PredictabilityEngine
6
+ module MermaidVisualizer
7
+ def self.cfd_plot(items, **_opts)
8
+ data = Calculators::Cfd.calculate(items).last(20)
9
+ dates = data.map { |d| d[:date].to_s }
10
+ format_mermaid_xy("Cumulative Flow Diagram (Last #{dates.size} days)", dates, 'Items',
11
+ [data.map { |d| d[:arrived] }, data.map { |d| d[:departed] }],
12
+ labels: %w[Arrivals Departures])
13
+ end
14
+
15
+ def self.forecasted_cfd_plot(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **_opts)
16
+ data = Calculators::Cfd.forecast_series(items, percentiles: percentiles)
17
+ return cfd_plot(items) unless data
18
+
19
+ series = [data[:arrivals]]
20
+ labels = ['Arrivals']
21
+ hist_size = data[:departed].size
22
+ series << (data[:departed] + Array.new(data[:dates].size - hist_size, nil))
23
+ labels << 'Departures'
24
+ percentiles.each do |p|
25
+ series << data[:forecasts][p]
26
+ labels << "#{p}% Confidence"
27
+ series << build_vertical_rule(data, p, hist_size)
28
+ labels << "#{p}% Deadline"
29
+ end
30
+
31
+ format_mermaid_xy('Forecasted Cumulative Flow Diagram', data[:dates].map(&:to_s), 'Items',
32
+ series, labels: labels, thin: true)
33
+ end
34
+
35
+ def self.build_vertical_rule(data, percentile, hist_size)
36
+ days = data[:summary][:"p#{percentile}"]
37
+ index = hist_size - 1 + days
38
+ res = Array.new(data[:dates].size, nil)
39
+ res[index] = data[:summary][:total_items] if index < res.size
40
+ res
41
+ end
42
+
43
+ def self.aging_wip(items, **_opts)
44
+ data = Calculators::Aging.item_age_data(items)
45
+ format_mermaid_xy('Aging Work In Progress', data.map { |d| d[:id] }, 'Age (days)',
46
+ [data.map { |d| d[:age] }], labels: ['Age'], type: 'bar')
47
+ end
48
+
49
+ def self.throughput_histogram(items, **_opts)
50
+ counts = Calculators::Throughput.histogram_data(items)
51
+ format_mermaid_xy('Throughput Histogram', counts.map { |c| c[0] }, 'Frequency',
52
+ [counts.map { |c| c[1] }], labels: ['Frequency'], type: 'bar')
53
+ end
54
+
55
+ def self.cycle_time_scatter(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **_opts)
56
+ completed = Calculators::CycleTime.completed_sorted(items)
57
+ return '' if completed.empty?
58
+
59
+ dates = completed.map { |i| i.end_date.to_s }.uniq.last(20)
60
+ series = percentiles.map { |p| dates.map { |d| pct_at(completed, d, p) } }
61
+ labels = percentiles.map { |p| "#{p}th Percentile" }
62
+
63
+ format_mermaid_xy("Cycle Time Trend (Last #{dates.size} days)", dates, 'Cycle Time (days)',
64
+ series, labels: labels, thin: true)
65
+ end
66
+
67
+ def self.pct_at(items, date, pct)
68
+ PredictabilityEngine.cycle_time_percentile(items.select { |i| i.end_date <= Date.parse(date) }, pct)
69
+ end
70
+
71
+ def self.format_mermaid_xy(title, x_axis, y_label, series, opts = {})
72
+ ['xychart-beta', " title \"#{title}\"", " x-axis [\"#{format_x_axis(x_axis, opts).join('", "')}\"]",
73
+ " y-axis \"#{y_label}\"", *format_series(series, opts)].join("\n")
74
+ end
75
+
76
+ def self.format_x_axis(x_axis, opts)
77
+ x_axis.each_with_index.map do |x, i|
78
+ val = x.to_s.gsub('"', '')
79
+ opts[:thin] && (i % 7 != 0) ? ' ' : val
80
+ end
81
+ end
82
+
83
+ def self.format_series(series, opts)
84
+ type = opts[:type] || 'line'
85
+ labels = opts[:labels]
86
+ series.each_with_index.map do |s, i|
87
+ label = labels && labels[i] ? " \"#{labels[i]}\"" : ''
88
+ " #{type}#{label} [#{s.map { |v| v.nil? ? 'NaN' : v }.join(', ')}]"
89
+ end
90
+ end
91
+
92
+ private_class_method :format_x_axis, :format_series
93
+ end
94
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module Models
5
+ class WorkItem
6
+ attr_accessor :id, :title, :type, :priority, :start_date, :end_date, :url
7
+
8
+ def initialize(item_id:, title: nil, type: nil, priority: nil, start_date: nil, end_date: nil, url: nil) # rubocop:disable Metrics/ParameterLists
9
+ @id = item_id
10
+ @title = title
11
+ @type = type
12
+ @priority = priority
13
+ @start_date = start_date ? Date.parse(start_date.to_s) : nil
14
+ @end_date = end_date ? Date.parse(end_date.to_s) : nil
15
+ @url = url
16
+ end
17
+
18
+ def cycle_time
19
+ return nil unless completed?
20
+
21
+ (@end_date - @start_date).to_i + 1 # Include both start and end days
22
+ end
23
+
24
+ def age(date = PredictabilityEngine.today)
25
+ return nil unless in_progress?(date)
26
+
27
+ (date - @start_date).to_i + 1
28
+ end
29
+
30
+ def completed?
31
+ !@end_date.nil? && !@start_date.nil?
32
+ end
33
+
34
+ def in_progress?(date)
35
+ return false unless @start_date
36
+ return false if date < @start_date
37
+ return true if @end_date.nil? || date < @end_date
38
+
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module PdfVisualizer
5
+ module Primitives
6
+ def self.chart_width = 250
7
+ def self.chart_height = 100
8
+
9
+ def self.draw_line_chart(pdf, _labels, series)
10
+ max_y = series.map { |s| s[:values].max }.max || 1
11
+
12
+ draw_canvas(pdf) do
13
+ series.each { |s| draw_series(pdf, s, series.first[:values].size, max_y) }
14
+ end
15
+ draw_legend(pdf, series)
16
+ end
17
+
18
+ def self.draw_series(pdf, series_data, labels_count, max_y)
19
+ pdf.stroke_color series_data[:color]
20
+ points = series_data[:values].each_with_index.map do |v, i|
21
+ [x_coord(i, labels_count), y_coord(v, max_y)]
22
+ end
23
+
24
+ pdf.stroke do
25
+ pdf.move_to(*points[0])
26
+ points[1..].each { |p| pdf.line_to(*p) }
27
+ end
28
+ end
29
+
30
+ def self.draw_legend(pdf, series)
31
+ # Use smaller legend for dashboard
32
+ series.each do |s|
33
+ pdf.fill_color s[:color]
34
+ pdf.fill_rectangle [0, pdf.cursor], 8, 8
35
+ pdf.fill_color '000000'
36
+ pdf.draw_text s[:label], at: [12, pdf.cursor - 6], size: 6
37
+ pdf.move_down 10
38
+ end
39
+ end
40
+
41
+ def self.draw_bar_chart(pdf, labels, values)
42
+ max_y = values.max || 1
43
+ bar_width = labels.empty? ? chart_width : chart_width / labels.size.to_f
44
+
45
+ draw_canvas(pdf) do
46
+ values.each_with_index do |v, i|
47
+ h = (v.to_f / max_y) * chart_height
48
+ pdf.fill_color '3366CC'
49
+ pdf.fill_rectangle [i * bar_width, h], [bar_width - 2, 1].max, h
50
+ end
51
+ end
52
+ end
53
+
54
+ def self.draw_scatter_plot(pdf, labels, values)
55
+ max_y = values.max || 1
56
+
57
+ draw_canvas(pdf) do
58
+ values.each_with_index do |v, i|
59
+ pdf.fill_circle [x_coord(i, labels.size), y_coord(v, max_y)], 1.5
60
+ end
61
+ end
62
+ end
63
+
64
+ def self.x_coord(index, total)
65
+ total <= 1 ? 0 : (index.to_f / (total - 1)) * chart_width
66
+ end
67
+
68
+ def self.y_coord(value, max_y)
69
+ (value.to_f / max_y) * chart_height
70
+ end
71
+
72
+ def self.draw_canvas(pdf)
73
+ pdf.bounding_box([0, pdf.cursor], width: chart_width, height: chart_height) do
74
+ pdf.stroke_bounds
75
+ yield
76
+ pdf.stroke_color '000000'
77
+ pdf.fill_color '000000'
78
+ end
79
+ pdf.move_down 5
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pdf_visualizer/primitives'
4
+
5
+ module PredictabilityEngine
6
+ module PdfVisualizer
7
+ def self.draw_chart(pdf, chart_id, work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
8
+ case chart_id
9
+ when :cfd_plot then draw_cfd(pdf, work_items)
10
+ when :forecasted_cfd_plot then draw_forecasted_cfd(pdf, work_items, percentiles: percentiles)
11
+ when :throughput_histogram then draw_throughput(pdf, work_items)
12
+ when :cycle_time_scatter then draw_scatter(pdf, work_items, percentiles: percentiles)
13
+ when :aging_wip then draw_aging(pdf, work_items, percentiles: percentiles)
14
+ end
15
+ end
16
+
17
+ def self.draw_aging(pdf, work_items, **_opts)
18
+ data = Calculators::Aging.item_age_data(work_items)
19
+ Primitives.draw_bar_chart(pdf, data.map { |d| d[:id].to_s }, data.map { |d| d[:age] })
20
+ end
21
+
22
+ def self.draw_cfd(pdf, work_items)
23
+ cfd_data = Calculators::Cfd.calculate(work_items).last(30)
24
+ series = [
25
+ { label: 'Arrivals', values: cfd_data.map { |d| d[:arrived] }, color: '0000FF' },
26
+ { label: 'Departures', values: cfd_data.map { |d| d[:departed] }, color: '00FF00' }
27
+ ]
28
+ Primitives.draw_line_chart(pdf, cfd_data.map { |d| d[:date].to_s }, series)
29
+ end
30
+
31
+ def self.draw_throughput(pdf, work_items)
32
+ counts = Calculators::Throughput.histogram_data(work_items)
33
+ Primitives.draw_bar_chart(pdf, counts.map { |k, _v| k.to_s }, counts.map { |_k, v| v })
34
+ end
35
+
36
+ def self.draw_scatter(pdf, work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
37
+ data = Calculators::CycleTime.completed_sorted(work_items)
38
+ .map { |i| [i.end_date.to_s, i.cycle_time] }
39
+
40
+ Primitives.draw_scatter_plot(pdf, data.map(&:first), data.map { |d| d[1] })
41
+
42
+ pcts = PredictabilityEngine.mapped_percentiles(work_items, percentiles)
43
+ pdf.move_down 10
44
+ pdf.text "Percentiles: #{pcts.map { |p| "#{p[:label]}: #{p[:val]}" }.join(', ')}", size: 8
45
+ end
46
+
47
+ def self.draw_forecasted_cfd(pdf, work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
48
+ forecast = Calculators::Cfd.forecast_series(work_items, percentiles: percentiles)
49
+ return draw_cfd(pdf, work_items) unless forecast
50
+
51
+ series = [
52
+ { label: 'Arrivals', values: forecast[:arrivals], color: '0000FF' },
53
+ { label: 'Departures', values: forecast[:departed], color: '00FF00' }
54
+ ]
55
+
56
+ f_colors = %w[E6B800 CC0000 800080 008080 333333] # Darker versions of yellow, red, magenta, cyan, gray
57
+ percentiles.sort.each_with_index do |p, i|
58
+ series << { label: "#{p}% Conf.", values: forecast[:forecasts][p], color: f_colors[i % f_colors.size] }
59
+ end
60
+
61
+ Primitives.draw_line_chart(pdf, forecast[:dates].map(&:to_s), series)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module PredictabilityEngine
6
+ module RawDataExporter
7
+ DONE_THRESHOLDS = [1, 7, 14, 21, 28].freeze
8
+ DONE_THRESHOLD_LABELS = (DONE_THRESHOLDS.map { |d| d == 1 ? '≤ 1 day' : "≤ #{d} days" } +
9
+ ["> #{DONE_THRESHOLDS.last} days"]).freeze
10
+
11
+ HEADERS = [
12
+ 'ID', 'Title', 'Type', 'Priority',
13
+ 'Start Date', 'End Date', 'Status',
14
+ 'YYYY-Week', 'YYYY-MM', 'YYYY',
15
+ 'Cycle Time (days)', 'Current Age (days)', 'URL',
16
+ 'Done ≤ 1 day', 'Done ≤ 7 days', 'Done ≤ 14 days',
17
+ 'Done ≤ 21 days', 'Done ≤ 28 days'
18
+ ].freeze
19
+
20
+ def self.item_row(item)
21
+ today = PredictabilityEngine.today
22
+ ct = item.cycle_time
23
+ age = item.completed? ? nil : item.age(today)
24
+ flags = DONE_THRESHOLDS.map { |d| ct ? ct <= d : nil }
25
+ date = item.end_date
26
+ [item.id, item.title, item.type, item.priority,
27
+ item.start_date, item.end_date,
28
+ (item.completed? ? 'Done' : 'In Progress'),
29
+ PredictabilityEngine.format_year_week(date),
30
+ PredictabilityEngine.format_year_month(date),
31
+ date&.to_date&.year,
32
+ ct, age, item.url.to_s, *flags]
33
+ end
34
+
35
+ def self.threshold_index(cycle_time)
36
+ DONE_THRESHOLDS.index { |d| cycle_time <= d } || DONE_THRESHOLDS.size
37
+ end
38
+
39
+ def self.generate_csv(items)
40
+ CSV.generate do |csv|
41
+ csv << HEADERS
42
+ items.each { |item| csv << item_row(item) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ class Report
5
+ module Constants
6
+ PRIORITY_ORDER = %w[Highest High Medium Low Lowest].freeze
7
+
8
+ CHART_CONFIG = {
9
+ aging_wip: { title: 'Aging Work In Progress' },
10
+ forecasted_cfd_plot: { title: 'Forecasted Cumulative Flow Diagram', vega: :forecasted_cfd },
11
+ cfd_plot: { title: 'Cumulative Flow Diagram', vega: :cfd },
12
+ cycle_time_scatter: { title: 'Cycle Time Scatter Plot' },
13
+ throughput_histogram: { title: 'Throughput Histogram' },
14
+ cycle_time_bands: { title: 'Cycle Time Bands Over Time' }
15
+ }.freeze
16
+
17
+ FORMAT_CONFIG = {
18
+ terminal: { h1: ->(t) { "=== #{t} ===" }, h2: ->(t) { "\n=== #{t} ===" },
19
+ code: ->(_t, c) { c }, aliases: %i[console ascii], layout: :standard },
20
+ markdown: { h1: ->(t) { "# #{t}\n" }, h2: ->(t) { "\n## #{t}" },
21
+ code: ->(_t, c) { "```\n#{c}\n```" }, aliases: [:md], layout: :standard },
22
+ confluence: { h1: ->(t) { "h1. #{t}\n" }, h2: ->(t) { "\nh2. #{t}" },
23
+ code: ->(t, c) { "{code:title=#{t}}\n#{c}\n{code}" }, aliases: [:conf], layout: :standard },
24
+ html: { layout: :landscape },
25
+ pdf: { layout: :landscape },
26
+ png: { layout: :landscape },
27
+ ppt: { layout: :landscape },
28
+ landscape: { aliases: [:dashboard], layout: :landscape },
29
+ a3_landscape: { format: 'A3', landscape: true, layout: :landscape },
30
+ raw_csv: {},
31
+ xlsx: {}
32
+ }.freeze
33
+
34
+ FONT_PATHS = [
35
+ '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',
36
+ '/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf',
37
+ '/usr/share/fonts/TTF/DejaVuSansMono.ttf',
38
+ '/System/Library/Fonts/Supplemental/Courier New.ttf',
39
+ '/Library/Fonts/Courier New.ttf',
40
+ 'C:/Windows/Fonts/cour.ttf'
41
+ ].freeze
42
+
43
+ FONT_BOLD_PATHS = [
44
+ '/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf',
45
+ '/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf',
46
+ '/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf',
47
+ '/System/Library/Fonts/Supplemental/Courier New Bold.ttf',
48
+ '/Library/Fonts/Courier New Bold.ttf',
49
+ 'C:/Windows/Fonts/courbd.ttf'
50
+ ].freeze
51
+
52
+ RESOLUTION_CONFIG = {
53
+ '5k' => [5120, 2880],
54
+ '4k' => [3840, 2160],
55
+ 'hd' => [1920, 1080],
56
+ 'a0' => [7680, 5432],
57
+ 'a1' => [5432, 3838],
58
+ 'a2' => [3838, 2713],
59
+ 'a3' => [2713, 1918],
60
+ 'a4' => [1918, 1356],
61
+ 'a5' => [1356, 956],
62
+ 'a6' => [956, 678]
63
+ }.freeze
64
+
65
+ DEFAULT_SIZE = 'a4'
66
+ DEFAULT_FORECAST_HISTORY = '4w'
67
+ DEFAULT_HISTORICAL_RANGE = 'all'
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../report'
4
+
5
+ module PredictabilityEngine
6
+ class Report
7
+ module ImageGenerator
8
+ def self.generate(report, base_dir, width: 800, height: 600, scale: 1)
9
+ images_path = File.join(base_dir, 'images')
10
+ FileUtils.mkdir_p(images_path)
11
+ temp_html = "tmp/images_#{report.object_id}.html"
12
+ File.write(temp_html, report.render_html(layout: :standard))
13
+
14
+ require 'playwright'
15
+ Playwright.create(playwright_cli_executable_path: report.playwright_bin) do |playwright|
16
+ playwright.chromium.launch(**report.playwright_chromium_launch_opts) do |browser|
17
+ page = browser.new_page(viewport: { width: width, height: height },
18
+ deviceScaleFactor: scale)
19
+ page.goto("file://#{File.expand_path(temp_html)}")
20
+ sleep 2
21
+ Constants::CHART_CONFIG.each_key { |id| capture_chart(page, id, images_path) }
22
+ end
23
+ end
24
+ images_path
25
+ ensure
26
+ FileUtils.rm_f(temp_html) if temp_html
27
+ end
28
+
29
+ def self.capture_chart(page, chart_id, images_path)
30
+ title = Constants::CHART_CONFIG[chart_id][:title]
31
+ page.locator(".chart-panel:has(h2:text-is(\"#{title}\"))")
32
+ .screenshot(path: File.join(images_path, "#{chart_id}.png"))
33
+ end
34
+ end
35
+ end
36
+ end