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.
- checksums.yaml +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- 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
|