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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module VegaVisualizer
5
+ module BasicCharts
6
+ def self.cycle_time_scatter(items, percentiles, title: 'Cycle Time Scatter Plot')
7
+ completed = PredictabilityEngine.completed_items(items)
8
+ data = completed.map do |i|
9
+ { date: PredictabilityEngine.format_date(i.end_date), cycle_time: i.cycle_time, id: i.id,
10
+ title: i.title, title_display: VegaVisualizer.wrap_tooltip_title(i.title), url: i.url }
11
+ end
12
+ pct_data = PredictabilityEngine.mapped_percentiles(items, percentiles)
13
+ VegaVisualizer.apply_standard_dims(
14
+ Vega.lite.data(data + pct_data.map { |p| { type: p[:label], val: p[:val], p: p[:p] } })
15
+ .layer([scatter_points_layer, scatter_rules_layer(pct_data)]),
16
+ title: title
17
+ )
18
+ end
19
+
20
+ def self.scatter_points_layer
21
+ x_axis = VegaVisualizer.date_x_axis(title: 'Completion Date',
22
+ tickCount: { interval: 'week' })
23
+ { mark: { type: 'point', opacity: 0.6, size: 20 },
24
+ encoding: { x: x_axis,
25
+ y: VegaVisualizer.quantitative_y_axis('cycle_time', title: 'Cycle Time (days)'),
26
+ color: { value: '#4c78a8' },
27
+ **VegaVisualizer.item_href_and_tooltip(
28
+ [{ field: 'date', type: 'temporal', title: 'Completion Date' },
29
+ VegaVisualizer.cycle_time_tooltip_field]
30
+ ) } }
31
+ end
32
+
33
+ def self.scatter_rules_layer(pct_data)
34
+ count = pct_data.size
35
+ palette = ['#72b7b2', '#e45756', '#b279a2', '#ff9da7', '#ad494a', '#8ca27a']
36
+ # More distinct dash styles and thicker lines
37
+ dash_map = { 50 => [], 75 => [8, 4], 85 => [4, 4], 95 => [2, 2], 98 => [1, 1] }
38
+ width_map = { 50 => 1.5, 75 => 2, 85 => 2.5, 95 => 3, 98 => 3.5 }
39
+
40
+ dash_condition = dash_map.map { |p, dash| { test: "datum.p == #{p}", value: dash } }
41
+ width_condition = width_map.map { |p, w| { test: "datum.p == #{p}", value: w } }
42
+
43
+ { transform: [{ filter: 'datum.type != null' }],
44
+ mark: { type: 'rule' },
45
+ encoding: { y: VegaVisualizer.quantitative_y_axis('val', title: 'Cycle Time (days)'),
46
+ strokeDash: { condition: dash_condition, value: [4, 4] },
47
+ strokeWidth: { condition: width_condition, value: 1 },
48
+ color: { field: 'type', type: 'nominal', title: 'Percentiles',
49
+ scale: { range: palette.take(count) },
50
+ legend: { orient: 'bottom', columns: 3 } },
51
+ tooltip: [{ field: 'type', type: 'nominal', title: 'Percentile' },
52
+ VegaVisualizer.cycle_time_tooltip_field(field: 'val')] } }
53
+ end
54
+
55
+ def self.throughput_histogram(items, title: 'Throughput Histogram')
56
+ data = Calculators::Throughput.daily(items).values.map { |v| { throughput: v } }
57
+ bar_chart(data, title: title,
58
+ x: VegaVisualizer.quantitative_x_axis('throughput', bin: true, title: 'Items per Day'),
59
+ y: VegaVisualizer.quantitative_y_axis('count', aggregate: 'count', title: 'Frequency'))
60
+ end
61
+
62
+ BAND_COLORS = %w[#2ca02c #98df8a #ffdd57 #ff7f0e #d62728 #7b0000].freeze
63
+
64
+ GRANULARITY_PARAM = {
65
+ name: 'granularity',
66
+ value: 'yearweek',
67
+ bind: { input: 'select',
68
+ options: %w[yearday yearweek yearmonth],
69
+ labels: %w[Daily Weekly Monthly],
70
+ name: 'Group by: ' }
71
+ }.freeze
72
+
73
+ PERIOD_EXPR = "granularity === 'yearmonth' ? datum.date_month : " \
74
+ "(granularity === 'yearday' ? datum.date : datum.date_week)"
75
+
76
+ def self.cycle_time_bands(items, title: 'Cycle Time Bands Over Time', **)
77
+ labels = RawDataExporter::DONE_THRESHOLD_LABELS
78
+ completed = PredictabilityEngine.completed_items(items)
79
+ return Vega.lite.data([]).title(title) if completed.empty?
80
+
81
+ data = completed.map do |item|
82
+ idx = RawDataExporter.threshold_index(item.cycle_time)
83
+ { date: PredictabilityEngine.format_date(item.end_date),
84
+ date_week: PredictabilityEngine.format_year_week(item.end_date),
85
+ date_month: PredictabilityEngine.format_year_month(item.end_date),
86
+ band: labels[idx], band_order: idx }
87
+ end
88
+ VegaVisualizer.apply_standard_dims(
89
+ Vega.lite.data(data)
90
+ .params([GRANULARITY_PARAM])
91
+ .transform([{ calculate: PERIOD_EXPR, as: 'period' }])
92
+ .mark(type: 'area')
93
+ .encoding(
94
+ x: { field: 'period', type: 'ordinal', sort: 'ascending',
95
+ title: nil, axis: VegaVisualizer::LABEL_AXIS },
96
+ y: { aggregate: 'count', type: 'quantitative', title: 'Items Completed' },
97
+ color: { field: 'band', type: 'ordinal', sort: labels,
98
+ scale: { domain: labels, range: BAND_COLORS },
99
+ legend: { title: 'Cycle Time', orient: 'bottom', columns: labels.size } },
100
+ order: { field: 'band_order', type: 'quantitative' },
101
+ tooltip: [
102
+ { field: 'period', type: 'ordinal', title: 'Period' },
103
+ { field: 'band', type: 'ordinal', title: 'Cycle Time' },
104
+ { aggregate: 'count', type: 'quantitative', title: 'Items' }
105
+ ]
106
+ ),
107
+ title: title
108
+ )
109
+ end
110
+
111
+ def self.bar_chart(data, title:, **encoding)
112
+ VegaVisualizer.apply_standard_dims(
113
+ Vega.lite.data(data).mark(type: 'bar', tooltip: true).encoding(**encoding),
114
+ title: title
115
+ )
116
+ end
117
+
118
+ private_class_method :bar_chart
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module VegaVisualizer
5
+ module CfdCharts
6
+ def self.cfd(work_items, title: 'Cumulative Flow Diagram', history_range: nil)
7
+ data = Calculators::Cfd.calculate(work_items)
8
+ days = Duration.parse(history_range)
9
+ data = data.last(days) if days
10
+ formatted = VegaVisualizer.format_cfd_data(data)
11
+ render_cfd(formatted, [], title)
12
+ end
13
+
14
+ def self.forecasted_cfd(work_items, percentiles, title, history_range: nil)
15
+ data = Calculators::Cfd.forecast_series(work_items, percentiles: percentiles, history_range: history_range)
16
+ return cfd(work_items, title: title) unless data
17
+
18
+ unified = VegaVisualizer.build_cfd_unified_data(data, percentiles)
19
+ render_cfd(unified, percentiles, title, forecast: data)
20
+ end
21
+
22
+ def self.render_cfd(data, percentiles, title, forecast: nil)
23
+ dom, range = VegaVisualizer.cfd_color_scale(percentiles)
24
+ chart = base_cfd_chart(data, dom, range)
25
+ .layer(cfd_layers(percentiles, forecast))
26
+ resolved_title = forecast ? forecast_title(title) : title
27
+ padding = forecast ? { right: 80, top: 30 } : nil
28
+ VegaVisualizer.apply_standard_dims(chart, title: resolved_title, padding: padding)
29
+ end
30
+
31
+ def self.forecast_title(text)
32
+ { text: text,
33
+ subtitle: [
34
+ 'Each X% Confidence surface = forecast of cumulative departures if backlog completes at that confidence',
35
+ '(Monte Carlo on historical throughput; NOT cycle-time percentile bands)'
36
+ ] }
37
+ end
38
+
39
+ def self.cfd_layers(percentiles, forecast)
40
+ layers = [VegaVisualizer.cfd_area_layer(percentiles, legend: !percentiles.empty?)]
41
+ return layers unless forecast
42
+
43
+ layers + [VegaVisualizer.cfd_line_layer(percentiles),
44
+ *VegaVisualizer.cfd_vert_layers(forecast, percentiles)]
45
+ end
46
+
47
+ def self.base_cfd_chart(data, dom, range)
48
+ # Find last day to ensure it can be labeled
49
+ dates = data.map { |d| PredictabilityEngine.format_date(d[:date]) }.compact.uniq.sort
50
+ first_date = Date.parse(dates.first)
51
+ last_date = Date.parse(dates.last)
52
+
53
+ # Major ticks every week, starting from first_date, plus exactly last_date
54
+ major_ticks = []
55
+ curr = first_date
56
+ while curr < last_date
57
+ major_ticks << PredictabilityEngine.format_date(curr)
58
+ curr += 7
59
+ end
60
+ major_ticks << PredictabilityEngine.format_date(last_date)
61
+ major_ticks.uniq!
62
+
63
+ # Use axis options directly in date_x_axis to avoid hash merge overwrite
64
+ Vega.lite.data(data)
65
+ .encoding(
66
+ x: VegaVisualizer.date_x_axis(
67
+ values: major_ticks,
68
+ labelFlush: true,
69
+ labelOverlap: 'parity',
70
+ minorTicks: true,
71
+ tickSize: 8,
72
+ minorTickSize: 4
73
+ ),
74
+ y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
75
+ color: { field: 'type', type: 'nominal', scale: { domain: dom, range: range } }
76
+ )
77
+ end
78
+
79
+ private_class_method :base_cfd_chart, :forecast_title
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module VegaVisualizer
5
+ # Layout and data logic for CFD charts in Vega-Lite.
6
+ module CfdLayout
7
+ def self.build_unified_data(data, percentiles)
8
+ res = []
9
+ sorted_pcts = percentiles.sort
10
+ data[:dates].each_with_index do |date, i|
11
+ res << { date: PredictabilityEngine.format_date(date), count: data[:arrivals][i], type: 'Arrivals', order: 0 }
12
+ sorted_pcts.each_with_index do |p, pi|
13
+ res << { date: PredictabilityEngine.format_date(date), count: data[:forecasts][p][i],
14
+ type: "#{p}% Confidence", order: pi + 1 }
15
+ end
16
+ if i < data[:departed].size
17
+ res << { date: PredictabilityEngine.format_date(date), count: data[:departed][i], type: 'Departures',
18
+ order: sorted_pcts.size + 1 }
19
+ end
20
+ end
21
+ res
22
+ end
23
+
24
+ def self.color_scale(pcts)
25
+ sorted_pcts = pcts.sort
26
+ dom = ['Arrivals'] + sorted_pcts.map { |p| "#{p}% Confidence" } + ['Departures']
27
+ palette = ['#72b7b2', '#e45756', '#b279a2', '#ff9da7', '#ad494a', '#8ca27a']
28
+ range = ['#4c78a8'] + palette.take(sorted_pcts.size) + ['#59a14f']
29
+ [dom, range]
30
+ end
31
+
32
+ def self.area_layer(pcts, legend: true)
33
+ cfg = { field: 'type', type: 'nominal' }
34
+ cfg[:legend] = { title: 'Flow & Forecast', orient: 'bottom', columns: 3 } if legend && !pcts.empty?
35
+ { mark: { type: 'area' },
36
+ encoding: { y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items', stack: nil),
37
+ color: cfg,
38
+ order: { field: 'order', type: 'quantitative' },
39
+ tooltip: VegaVisualizer.cfd_tooltip_fields } }
40
+ end
41
+
42
+ def self.line_layer
43
+ { mark: { type: 'line' },
44
+ encoding: { y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
45
+ strokeDash: {
46
+ condition: { test: "datum.type == 'Arrivals' || datum.type == 'Departures'", value: [] },
47
+ value: [4, 4]
48
+ },
49
+ tooltip: VegaVisualizer.cfd_tooltip_fields } }
50
+ end
51
+
52
+ def self.vert_layers(forecast, percentiles)
53
+ data = vert_data(forecast, percentiles)
54
+ [rule_layer(data), text_layer(data)]
55
+ end
56
+
57
+ def self.rule_layer(data)
58
+ base_layer(data).merge(
59
+ mark: { type: 'rule', strokeDash: [4, 2], strokeWidth: 2, tooltip: true },
60
+ encoding: vert_encoding(y: { datum: 0 }, y2: VegaVisualizer.quantitative_y_axis('count', title: nil))
61
+ )
62
+ end
63
+
64
+ def self.text_layer(data)
65
+ base_layer(data).merge(
66
+ mark: { type: 'text', align: 'left', baseline: 'middle',
67
+ fontWeight: 'bold', fontSize: 10, angle: -45, dx: 5,
68
+ clip: false, tooltip: true },
69
+ encoding: vert_encoding(y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
70
+ text: { field: 'label' })
71
+ )
72
+ end
73
+
74
+ def self.vert_encoding(**opts)
75
+ { x: VegaVisualizer.date_axis_base, tooltip: tooltip_field, color: { value: '#e45756' } }.merge(opts)
76
+ end
77
+
78
+ def self.base_layer(data)
79
+ { data: { values: data } }
80
+ end
81
+
82
+ def self.tooltip_field
83
+ { field: 'tooltip', type: 'nominal' }
84
+ end
85
+
86
+ def self.vert_data(forecast, percentiles)
87
+ sorted_pcts = percentiles.sort
88
+ data_by_date = group_pcts_by_date(forecast, sorted_pcts)
89
+
90
+ # IMMUTABLE invariant — see CLAUDE.md §"Forecast alignment invariant".
91
+ # Rule height = percentile-surface plateau (departed_so_far + wip), so each
92
+ # vertical rule hits the top-right corner of its p% surface exactly.
93
+ plateau = forecast[:summary][:departed_so_far] + forecast[:summary][:wip]
94
+
95
+ data_by_date.sort_by { |date, _| date }.map do |date, p_list|
96
+ date_str = PredictabilityEngine.format_date(date)
97
+ label = "#{p_list.sort.map { |p| "#{p}%" }.join(', ')} (#{date_str})"
98
+
99
+ { date: date_str, label: label,
100
+ tooltip: p_list.map { |p| "#{p}% Confidence (#{date_str})" }.join("\n"),
101
+ count: plateau }
102
+ end
103
+ end
104
+
105
+ def self.group_pcts_by_date(forecast, sorted_pcts)
106
+ groups = []
107
+ today = forecast[:summary][:today]
108
+
109
+ sorted_pcts.each do |p|
110
+ days = forecast[:summary][:"p#{p}"]
111
+ next unless days
112
+
113
+ date = today + days
114
+
115
+ # Collision avoidance: if date is close to an existing group, add to it
116
+ # Threshold: 2 days seems reasonable for collision avoidance
117
+ matched_group = groups.find { |g| (g[:date] - date).abs <= 2 }
118
+ if matched_group
119
+ matched_group[:pcts] << p
120
+ else
121
+ groups << { date: date, pcts: [p] }
122
+ end
123
+ end
124
+
125
+ groups.to_h { |g| [PredictabilityEngine.format_date(g[:date]), g[:pcts]] }
126
+ end
127
+
128
+ private_class_method :rule_layer, :text_layer, :tooltip_field,
129
+ :group_pcts_by_date, :base_layer, :vert_encoding
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module VegaVisualizer
5
+ module TooltipHelpers
6
+ TOOLTIP_WRAP_WIDTH = 40
7
+
8
+ def item_id_tooltip_field = { field: 'id', type: 'nominal', title: 'Work Item ID' }
9
+ def title_tooltip_field = { field: 'title_display', type: 'nominal', title: 'Title' }
10
+ def standard_item_tooltip_fields = [item_id_tooltip_field, title_tooltip_field]
11
+ def item_href_and_tooltip(extra) = { href: url_href, tooltip: standard_item_tooltip_fields + extra }
12
+ def url_href = { field: 'url', type: 'nominal' }
13
+
14
+ def cycle_time_tooltip_field(field: 'cycle_time')
15
+ { field: field, type: 'quantitative', title: 'Cycle Time (days)' }
16
+ end
17
+
18
+ def cfd_tooltip_fields
19
+ [{ field: 'date', type: 'temporal', title: 'Date' }, { field: 'type', type: 'nominal', title: 'Type' },
20
+ { field: 'count', type: 'quantitative', title: 'Items' }]
21
+ end
22
+
23
+ def wrap_tooltip_title(text, width: TOOLTIP_WRAP_WIDTH)
24
+ str = text.to_s
25
+ return str if str.length <= width
26
+
27
+ str.split.each_with_object(['']) do |word, lines|
28
+ lines << '' if "#{lines.last} #{word}".strip.length > width
29
+ lines[-1] = "#{lines.last} #{word}".strip
30
+ end.join("\n")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vega'
4
+ require_relative 'vega_visualizer/tooltip_helpers'
5
+ require_relative 'vega_visualizer/basic_charts'
6
+ require_relative 'vega_visualizer/cfd_charts'
7
+ require_relative 'vega_visualizer/aging_wip_visualizer'
8
+ require_relative 'vega_visualizer/cfd_layout'
9
+
10
+ module PredictabilityEngine
11
+ module VegaVisualizer
12
+ CHART_WIDTH = 500
13
+ CHART_HEIGHT = 300
14
+ LABEL_AXIS = { labelAngle: -45, labelOverlap: 'parity' }.freeze
15
+
16
+ extend TooltipHelpers
17
+
18
+ def self.apply_standard_dims(chart, title: nil, padding: nil)
19
+ chart = chart.title(title) if title
20
+ cfg = { autosize: { type: 'fit', contains: 'padding' }, axis: { grid: false } }
21
+ cfg[:padding] = padding if padding
22
+ chart.width('container').height('container').config(cfg)
23
+ end
24
+
25
+ def self.date_axis_base(title: 'Date')
26
+ { field: 'date', type: 'temporal', title: title, axis: { format: '%Y-%m-%d' } }
27
+ end
28
+
29
+ def self.date_x_axis(title: 'Date', **opts)
30
+ base = date_axis_base(title: title)
31
+ base[:axis] = base[:axis].merge(labelAngle: -45).merge(opts)
32
+ base
33
+ end
34
+
35
+ def self.quantitative_y_axis(...) = quantitative_axis(...)
36
+ def self.quantitative_x_axis(...) = quantitative_axis(...)
37
+
38
+ def self.quantitative_axis(field, title: :auto, **opts)
39
+ res = { field: field.to_s, type: 'quantitative' }
40
+ res[:title] = title == :auto ? field.to_s.capitalize : title
41
+ res.merge(opts)
42
+ end
43
+ private_class_method :quantitative_axis
44
+
45
+ def self.cycle_time_scatter(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
46
+ title: 'Cycle Time Scatter Plot', **)
47
+ BasicCharts.cycle_time_scatter(items, percentiles, title: title)
48
+ end
49
+
50
+ def self.throughput_histogram(items, title: 'Throughput Histogram', **)
51
+ BasicCharts.throughput_histogram(items, title: title)
52
+ end
53
+
54
+ def self.cycle_time_bands(items, title: 'Cycle Time Bands Over Time', **)
55
+ BasicCharts.cycle_time_bands(items, title: title)
56
+ end
57
+
58
+ def self.aging_wip(items, title: 'Aging Work In Progress',
59
+ percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **)
60
+ AgingWipVisualizer.aging_wip(items, title: title, percentiles: percentiles, **)
61
+ end
62
+
63
+ def self.cfd(work_items, title: 'Cumulative Flow Diagram', history_range: nil, **_opts)
64
+ CfdCharts.cfd(work_items, title: title, history_range: history_range)
65
+ end
66
+
67
+ def self.forecasted_cfd(work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
68
+ title: 'Forecasted Cumulative Flow Diagram', history_range: nil, **_opts)
69
+ CfdCharts.forecasted_cfd(work_items, percentiles, title, history_range: history_range)
70
+ end
71
+
72
+ def self.build_cfd_unified_data(data, percentiles)
73
+ CfdLayout.build_unified_data(data, percentiles)
74
+ end
75
+
76
+ def self.cfd_color_scale(pcts)
77
+ CfdLayout.color_scale(pcts)
78
+ end
79
+
80
+ def self.cfd_area_layer(pcts, legend: true)
81
+ CfdLayout.area_layer(pcts, legend: legend)
82
+ end
83
+
84
+ def self.cfd_line_layer(_pcts)
85
+ CfdLayout.line_layer
86
+ end
87
+
88
+ def self.cfd_vert_layers(forecast, percentiles)
89
+ CfdLayout.vert_layers(forecast, percentiles)
90
+ end
91
+
92
+ def self.format_cfd_data(cfd)
93
+ cfd.flat_map do |d|
94
+ [{ date: PredictabilityEngine.format_date(d[:date]), count: d[:arrived], type: 'Arrivals', order: 0 },
95
+ { date: PredictabilityEngine.format_date(d[:date]), count: d[:departed], type: 'Departures', order: 1 }]
96
+ end
97
+ end
98
+
99
+ def self.dashboard(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
100
+ charts = [aging_wip(items), forecasted_cfd(items, percentiles: percentiles), cfd(items),
101
+ cycle_time_scatter(items, percentiles: percentiles),
102
+ throughput_histogram(items)]
103
+ Vega.lite.vconcat(charts.map { |c| c.spec.except('$schema') })
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ VERSION = '0.6.6'
5
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'terminal_visualizer'
4
+ require_relative 'vega_visualizer'
5
+ require_relative 'summary_visualizer'
6
+ require_relative 'html_style'
7
+ require_relative 'html_templates'
8
+
9
+ module PredictabilityEngine
10
+ class Visualizer
11
+ def self.cycle_time_scatter(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
12
+ TerminalVisualizer.cycle_time_scatter(items, color: color, percentiles: percentiles)
13
+ end
14
+
15
+ def self.throughput_histogram(items, color: false)
16
+ TerminalVisualizer.throughput_histogram(items, color: color)
17
+ end
18
+
19
+ def self.cfd_plot(items, color: false)
20
+ TerminalVisualizer.cfd_plot(items, color: color)
21
+ end
22
+
23
+ def self.forecasted_cfd_plot(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
24
+ TerminalVisualizer.forecasted_cfd_plot(items, color: color, percentiles: percentiles)
25
+ end
26
+
27
+ def self.aging_wip(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
28
+ TerminalVisualizer.aging_wip(items, color: color, percentiles: percentiles)
29
+ end
30
+
31
+ %i[cycle_time_scatter throughput_histogram cfd forecasted_cfd aging_wip dashboard].each do |m|
32
+ define_singleton_method("vega_#{m}") { |items, **opts| VegaVisualizer.send(m, items, **opts) }
33
+ end
34
+
35
+ def self.to_full_html(content_or_chart, work_items = nil, **opts)
36
+ title = opts.fetch(:title, 'Predictability Engine Dashboard')
37
+ percentiles = opts.fetch(:percentiles, PredictabilityEngine::DEFAULT_PERCENTILES)
38
+ sub_reports = opts.fetch(:sub_reports, nil)
39
+
40
+ style = HTML_STYLE_LANDSCAPE
41
+ body = HTML_LANDSCAPE_BODY
42
+ summary = work_items ? SummaryVisualizer.metrics_html(work_items, percentiles: percentiles) : ''
43
+
44
+ nav_bar = build_nav_bar(sub_reports)
45
+
46
+ html = HTML_BASE.gsub('{{STYLE}}', style).gsub('{{BODY}}', body)
47
+ html.gsub!('{{TITLE}}', title)
48
+ html.gsub!('{{DATE}}', PredictabilityEngine.format_datetime(Time.now))
49
+ html.gsub!('{{SUMMARY_CONTENT}}', summary)
50
+ html.gsub!('{{NAV_BAR}}', nav_bar)
51
+
52
+ content = prepare_html_content(content_or_chart, :landscape, html)
53
+ # If it was a single chart, it might still have {{CHART_PANELS}} placeholder
54
+ if html.include?('{{CHART_PANELS}}')
55
+ panel = "<div class='chart-panel' style='grid-column: span 2; grid-row: span 2;'>" \
56
+ "<div class='panel-header'>" \
57
+ "<button class='chart-expand' onclick='toggleFullscreen(this)' title='Expand'></button></div>" \
58
+ "<div class='chart-container'>#{content}</div></div>"
59
+ html.gsub!('{{CHART_PANELS}}', panel)
60
+ end
61
+ html
62
+ end
63
+
64
+ def self.build_nav_bar(sub_reports)
65
+ return '' unless sub_reports&.any?
66
+
67
+ view_items, dl_items = sub_reports.partition { |r| !r[:download] }
68
+ html = view_nav_section(view_items) + export_nav_section(dl_items, view_items.any?)
69
+ "<ul class='nav-links'>#{html}</ul>"
70
+ end
71
+
72
+ def self.view_nav_section(view_items)
73
+ return '' unless view_items.any?
74
+
75
+ "<li><strong>View:</strong></li>#{render_nav_items(view_items)}"
76
+ end
77
+
78
+ def self.export_nav_section(dl_items, has_view)
79
+ return '' unless dl_items.any?
80
+
81
+ sep = has_view ? "<li class='nav-sep' aria-hidden='true'>|</li>" : ''
82
+ "#{sep}<li><strong>Export:</strong></li>#{render_nav_items(dl_items)}"
83
+ end
84
+
85
+ def self.render_nav_items(items)
86
+ items.map { |r| nav_item(r) }.join
87
+ end
88
+
89
+ def self.nav_item(entry)
90
+ return "<li class='nav-sep' aria-hidden='true'>|</li>" if entry[:separator]
91
+
92
+ dl = entry[:download] ? ' download' : ''
93
+ "<li><a href='#{entry[:url]}' class='#{'active' if entry[:active]}'#{dl}>#{entry[:label]}</a></li>"
94
+ end
95
+
96
+ def self.prepare_html_content(content_or_chart, layout, html)
97
+ if layout == :landscape && content_or_chart.is_a?(Array)
98
+ panels = content_or_chart.map do |cfg|
99
+ "<div class='chart-panel'>" \
100
+ "<div class='panel-header'><h2>#{cfg[:title]}</h2>" \
101
+ "<button class='chart-expand' onclick='toggleFullscreen(this)' title='Expand'></button></div>" \
102
+ "<div class='chart-container'>#{cfg[:chart].to_html}</div></div>"
103
+ end.join("\n")
104
+ html.gsub!('{{CHART_PANELS}}', panels)
105
+ ''
106
+ else
107
+ content = content_or_chart.respond_to?(:to_html) ? content_or_chart.to_html : content_or_chart
108
+ content.is_a?(Array) ? content.join("\n") : content
109
+ end
110
+ end
111
+
112
+ private_class_method :prepare_html_content, :view_nav_section, :export_nav_section, :render_nav_items
113
+ end
114
+ end