Almirah 0.4.4 → 0.4.5
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 +4 -4
- data/lib/almirah/doc_fabric.rb +0 -6
- data/lib/almirah/doc_items/scope_table.rb +39 -88
- data/lib/almirah/doc_types/base_document.rb +1 -11
- data/lib/almirah/doc_types/decision.rb +1 -126
- data/lib/almirah/doc_types/decisions_overview.rb +5 -555
- data/lib/almirah/doc_types/planning_dates.rb +2 -3
- data/lib/almirah/project/project_data.rb +1 -4
- data/lib/almirah/project.rb +0 -122
- data/lib/almirah/project_configuration.rb +0 -99
- data/lib/almirah/templates/css/main.css +0 -157
- metadata +1 -8
- data/lib/almirah/doc_items/work_item.rb +0 -129
- data/lib/almirah/doc_types/critical_chain_page.rb +0 -218
- data/lib/almirah/doc_types/decision_grouping.rb +0 -17
- data/lib/almirah/project/critical_chain.rb +0 -117
- data/lib/almirah/project/fever_chart.rb +0 -94
- data/lib/almirah/project/work_item_scheduler.rb +0 -167
- data/lib/almirah/project/working_calendar.rb +0 -112
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'date'
|
|
4
|
-
require 'json'
|
|
5
|
-
require_relative 'base_document'
|
|
6
|
-
require_relative 'decision_grouping'
|
|
7
|
-
require_relative 'planning_dates'
|
|
8
|
-
require_relative '../html_safe'
|
|
9
|
-
require_relative '../project/critical_chain'
|
|
10
|
-
require_relative '../project/fever_chart'
|
|
11
|
-
require_relative '../project/working_calendar'
|
|
12
|
-
|
|
13
|
-
# The dedicated Critical Chain & Project Buffer page (ENH-202): one block per
|
|
14
|
-
# decision group, each showing the ordered critical chain rows, the project
|
|
15
|
-
# buffer, and the projected duration; a group with no estimates is marked
|
|
16
|
-
# unestimated. The chain and buffer are ADR-195's CriticalChain, reused
|
|
17
|
-
# unchanged -- this page only relocates the rendering off the overview.
|
|
18
|
-
class CriticalChainPage < BaseDocument
|
|
19
|
-
include HtmlSafe
|
|
20
|
-
include DecisionGrouping
|
|
21
|
-
include PlanningDates
|
|
22
|
-
|
|
23
|
-
# Recent Fridays sampled for the fever-chart trail (ADR-196), matching the
|
|
24
|
-
# velocity chart's window.
|
|
25
|
-
FEVER_TRAIL_WEEKS = 6
|
|
26
|
-
|
|
27
|
-
attr_accessor :project
|
|
28
|
-
|
|
29
|
-
def initialize(project)
|
|
30
|
-
super()
|
|
31
|
-
@project = project
|
|
32
|
-
@title = 'Critical Chain & Project Buffer'
|
|
33
|
-
@id = 'critical-chain'
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def to_console
|
|
37
|
-
puts "\e[36mCritical Chain: #{@id}\e[0m"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def needs_chartjs?
|
|
41
|
-
true
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def to_html(output_file_path)
|
|
45
|
-
html_rows = ['', "<h1>#{@title}</h1>\n", render_critical_chain]
|
|
46
|
-
save_html_to_file(html_rows, nil, output_file_path)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def render_critical_chain
|
|
52
|
-
ratio = @project.configuration.get_buffer_ratio
|
|
53
|
-
hours_per_day = @project.configuration.get_hours_per_day
|
|
54
|
-
lookup = record_lookup
|
|
55
|
-
blocks = grouped_work_items.each_with_index.map do |(name, items), index|
|
|
56
|
-
critical_chain_block(name, items, ratio, lookup, hours_per_day, index)
|
|
57
|
-
end
|
|
58
|
-
blocks << %(\t<p class="cc_unestimated">No decision records to plan.</p>\n) if blocks.empty?
|
|
59
|
-
%(<div class="critical_chain">\n#{blocks.join}</div>\n)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# The calendar a group's projected completion date is measured on (ADR-211):
|
|
63
|
-
# one anchored at the group's declared planning.groups start date, or the
|
|
64
|
-
# shared project-anchored calendar when the group declares none. Memoised per
|
|
65
|
-
# group name so each group reuses one calendar.
|
|
66
|
-
def group_calendar(name)
|
|
67
|
-
@group_calendars ||= {}
|
|
68
|
-
@group_calendars[name] ||= begin
|
|
69
|
-
start = @project.configuration.get_group_start_dates[name]
|
|
70
|
-
start ? WorkingCalendar.new(anchor: start, holidays: @project.configuration.get_holidays) : working_calendar
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# An upcased-record-id => Decision map, so a chain row can reach its owning
|
|
75
|
-
# record's effort log (ADR-196).
|
|
76
|
-
def record_lookup
|
|
77
|
-
@project.project_data.decisions.to_h { |doc| [doc.id.to_s.upcase, doc] }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# The shared working calendar (ADR-205) projecting the working-day projected
|
|
81
|
-
# duration onto a real completion date.
|
|
82
|
-
def working_calendar
|
|
83
|
-
@working_calendar ||= WorkingCalendar.new(anchor: @project.configuration.get_start_date,
|
|
84
|
-
holidays: @project.configuration.get_holidays)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def critical_chain_block(name, items, ratio, lookup, hours_per_day, index) # rubocop:disable Metrics/ParameterLists
|
|
88
|
-
plan = CriticalChain.new(items, buffer_ratio: ratio)
|
|
89
|
-
header = %(\t<div class="cc_group">\n\t\t<h3>#{escape_text(name)}</h3>\n)
|
|
90
|
-
body = if plan.estimated?
|
|
91
|
-
cc_group_body(plan, lookup, hours_per_day, index, group_calendar(name))
|
|
92
|
-
else
|
|
93
|
-
%(\t\t<p class="cc_unestimated">No estimates — plan not sized.</p>\n)
|
|
94
|
-
end
|
|
95
|
-
"#{header}#{body}\t</div>\n"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# The estimated group's plan (chain table + buffer + projected duration) on the
|
|
99
|
-
# left and its buffer-consumption fever chart on the right, side by side
|
|
100
|
-
# (ADR-196). The chart is omitted when no chain row carries a positive estimate.
|
|
101
|
-
def cc_group_body(plan, lookup, hours_per_day, index, calendar)
|
|
102
|
-
fever = FeverChart.new(plan, lookup, hours_per_day: hours_per_day)
|
|
103
|
-
left = %(\t\t<div class="cc_plan">\n#{cc_chain_html(plan, fever, calendar)}\t\t</div>\n)
|
|
104
|
-
right = fever.plottable? ? %(\t\t<div class="cc_fever">\n#{fever_chart_html(fever, index)}\t\t</div>\n) : ''
|
|
105
|
-
%(\t\t<div class="cc_group_body">\n#{left}#{right}\t\t</div>\n)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def cc_chain_html(plan, fever, calendar)
|
|
109
|
-
lines = [%(\t\t<table class="cc_chain">\n),
|
|
110
|
-
"\t\t\t<thead><th>Record</th><th>Item</th><th>Owner</th><th>Duration</th></thead>\n"]
|
|
111
|
-
plan.chain.each { |wi| lines << cc_chain_row(wi) }
|
|
112
|
-
lines << "\t\t</table>\n"
|
|
113
|
-
projected = format_days(plan.projected_duration)
|
|
114
|
-
finish = calendar.date_for(plan.projected_duration)
|
|
115
|
-
lines << %(\t\t<p class="cc_buffer">Project buffer: #{plan.buffer} working days</p>\n)
|
|
116
|
-
lines << cc_consumed_html(plan, fever)
|
|
117
|
-
lines << %(\t\t<p class="cc_projected">Projected duration: #{projected} working days</p>\n)
|
|
118
|
-
lines << %(\t\t<p class="cc_finish">Projected completion: #{finish.strftime('%d-%m-%Y')}</p>\n)
|
|
119
|
-
lines.join
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# The live buffer-health line shown under the buffer figure: how much of the
|
|
123
|
-
# project buffer the chain's overruns have eaten so far, as a percentage and as
|
|
124
|
-
# days of the baseline buffer. The percentage and day count both come from the
|
|
125
|
-
# fever chart, so the figure and the chart's live point always agree, and the
|
|
126
|
-
# "of N baseline days" makes explicit that the denominator is the baseline
|
|
127
|
-
# buffer -- not the (remaining-work) "Project buffer" figure above it. Empty
|
|
128
|
-
# when the plan carries no buffer to consume (no positive-estimate chain row, or
|
|
129
|
-
# zero baseline buffer).
|
|
130
|
-
def cc_consumed_html(plan, fever)
|
|
131
|
-
return '' unless fever.plottable? && plan.baseline_buffer.positive?
|
|
132
|
-
|
|
133
|
-
today = Date.today
|
|
134
|
-
consumed_pct = fever.live_point(today).last.round
|
|
135
|
-
consumed_days = format_days(fever.consumed_days(today).round(1))
|
|
136
|
-
baseline = plan.baseline_buffer
|
|
137
|
-
unit = baseline == 1 ? 'day' : 'days'
|
|
138
|
-
detail = "#{consumed_days} of #{baseline} baseline #{unit}"
|
|
139
|
-
%(\t\t<p class="cc_consumed">Buffer consumed: #{consumed_pct}% (#{detail})</p>\n)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def cc_chain_row(work_item)
|
|
143
|
-
cells = [work_item.record_id.upcase, work_item.activity, work_item.owner, format_days(work_item.focused_estimate)]
|
|
144
|
-
"\t\t\t<tr>#{cells.map { |c| "<td>#{escape_text(c.to_s)}</td>" }.join}</tr>\n"
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# A working-day count without a trailing ".0" when it is whole.
|
|
148
|
-
def format_days(value)
|
|
149
|
-
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# The fever chart: the effort-only historical trail over recent Fridays plus the
|
|
153
|
-
# live point (which credits Done rows), drawn over the green/yellow/red zones.
|
|
154
|
-
def fever_chart_html(fever, index)
|
|
155
|
-
today = Date.today
|
|
156
|
-
fridays = recent_fridays(today, FEVER_TRAIL_WEEKS)
|
|
157
|
-
dates = fridays + [today]
|
|
158
|
-
points = fever.trail(fridays) + [fever.live_point(today)]
|
|
159
|
-
coords = points.zip(dates).map do |(completion, consumption), date|
|
|
160
|
-
{ x: completion.round(2), y: consumption.round(2), d: date.strftime('%b %-d') }
|
|
161
|
-
end
|
|
162
|
-
radii = Array.new(coords.length - 1, 3) + [6]
|
|
163
|
-
colors = points.map { |completion, consumption| zone_color(completion, consumption) }
|
|
164
|
-
fever_canvas_script("fever_chart_#{index}", coords, radii, colors)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def fever_canvas_script(canvas_id, coords, radii, colors)
|
|
168
|
-
<<~HTML
|
|
169
|
-
\t\t\t<canvas id="#{canvas_id}" class="fever_canvas"></canvas>
|
|
170
|
-
\t\t\t<script>
|
|
171
|
-
\t\t\t#{fever_zone_plugin_js}
|
|
172
|
-
\t\t\tnew Chart(document.getElementById('#{canvas_id}'), {
|
|
173
|
-
\t\t\t\ttype: 'scatter',
|
|
174
|
-
\t\t\t\tdata: { datasets: [{ label: 'Buffer health', data: #{coords.to_json}, showLine: true,
|
|
175
|
-
\t\t\t\t\tborderColor: 'rgba(80,80,80,0.7)', pointRadius: #{radii.to_json},
|
|
176
|
-
\t\t\t\t\tpointBackgroundColor: #{colors.to_json}, pointBorderColor: '#333' }] },
|
|
177
|
-
\t\t\t\toptions: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false },
|
|
178
|
-
\t\t\t\t\ttooltip: { callbacks: { label: (ctx) => ctx.raw.d + ': (' + ctx.parsed.x + ', ' + ctx.parsed.y + ')' } } },
|
|
179
|
-
\t\t\t\t\tscales: { x: { title: { display: true, text: 'Chain completion %' }, min: 0, max: 100 },
|
|
180
|
-
\t\t\t\t\t\ty: { title: { display: true, text: 'Buffer consumption %' }, min: 0, suggestedMax: 100 } } },
|
|
181
|
-
\t\t\t\tplugins: [window.feverZonesPlugin]
|
|
182
|
-
\t\t\t});
|
|
183
|
-
\t\t\t</script>
|
|
184
|
-
HTML
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# The conventional CCPM zone for a point: green below the lower one-third
|
|
188
|
-
# diagonal, red above the upper, yellow between (ADR-196).
|
|
189
|
-
def zone_color(completion, consumption)
|
|
190
|
-
lower = (2.0 / 3.0) * completion
|
|
191
|
-
upper = (100.0 / 3.0) + (2.0 / 3.0) * completion
|
|
192
|
-
return '#2e9e2e' if consumption <= lower
|
|
193
|
-
return '#e0a200' if consumption <= upper
|
|
194
|
-
|
|
195
|
-
'#d33'
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# A page-global Chart.js plugin (declared once, idempotently) that paints the
|
|
199
|
-
# three fever zones behind every fever chart on the page.
|
|
200
|
-
def fever_zone_plugin_js
|
|
201
|
-
<<~JS.strip
|
|
202
|
-
window.feverZonesPlugin = window.feverZonesPlugin || { id: 'feverZones', beforeDraw(chart) {
|
|
203
|
-
const a = chart.chartArea; if (!a) return; const x = chart.scales.x, y = chart.scales.y;
|
|
204
|
-
const px = v => x.getPixelForValue(v), py = v => y.getPixelForValue(v);
|
|
205
|
-
const lower = c => (2/3)*c, upper = c => 100/3 + (2/3)*c, ctx = chart.ctx;
|
|
206
|
-
ctx.save();
|
|
207
|
-
ctx.fillStyle = 'rgba(224,162,0,0.10)'; ctx.fillRect(a.left, a.top, a.width, a.height);
|
|
208
|
-
ctx.fillStyle = 'rgba(46,158,46,0.12)'; ctx.beginPath();
|
|
209
|
-
ctx.moveTo(px(0), py(0)); ctx.lineTo(px(100), py(lower(100)));
|
|
210
|
-
ctx.lineTo(px(100), py(y.min)); ctx.lineTo(px(0), py(y.min)); ctx.closePath(); ctx.fill();
|
|
211
|
-
ctx.fillStyle = 'rgba(221,51,51,0.12)'; ctx.beginPath();
|
|
212
|
-
ctx.moveTo(px(0), py(upper(0))); ctx.lineTo(px(100), py(100));
|
|
213
|
-
ctx.lineTo(px(100), py(y.max)); ctx.lineTo(px(0), py(y.max)); ctx.closePath(); ctx.fill();
|
|
214
|
-
ctx.restore();
|
|
215
|
-
} };
|
|
216
|
-
JS
|
|
217
|
-
end
|
|
218
|
-
end
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Shared helper for the planning views: the non-empty decision groups (ADR-197)
|
|
4
|
-
# paired with their Scope-row work items. Used by the overview's group-segmented
|
|
5
|
-
# Gantt (ADR-201) and the dedicated Critical Chain page (ADR-195 / ENH-202).
|
|
6
|
-
module DecisionGrouping
|
|
7
|
-
# [[group-name, [WorkItem, ...]], ...] in decision_groups (folder-encounter)
|
|
8
|
-
# order, only groups that have at least one work item.
|
|
9
|
-
def grouped_work_items
|
|
10
|
-
by_record = @project.project_data.work_items.values.group_by(&:record_id)
|
|
11
|
-
@project.project_data.decision_groups.filter_map do |group|
|
|
12
|
-
name = group.keys.first
|
|
13
|
-
items = group.values.first.flat_map { |doc| by_record[doc.id] || [] }
|
|
14
|
-
[name, items] unless items.empty?
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'work_item_scheduler'
|
|
4
|
-
|
|
5
|
-
# Resource-levelled scheduler variant for the critical chain (ADR-195): each
|
|
6
|
-
# row's duration is its focused estimate, and rows are prioritised by the longest
|
|
7
|
-
# downstream chain of focused durations (ties: record sequence, then step). The
|
|
8
|
-
# forward pass, resource levelling, binding-predecessor tracking, and chain
|
|
9
|
-
# tracing are all inherited from WorkItemScheduler.
|
|
10
|
-
class ChainScheduler < WorkItemScheduler
|
|
11
|
-
def duration_for(work_item)
|
|
12
|
-
work_item.focused_estimate
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def priority_key(work_item, memo)
|
|
18
|
-
[-downstream_length(work_item, memo, []), work_item.record_sequence, work_item.step]
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# The longest path of focused durations from this row through its in-scope
|
|
22
|
-
# successors to a sink. Memoised and cycle-guarded.
|
|
23
|
-
def downstream_length(work_item, memo, stack)
|
|
24
|
-
return memo[work_item] if memo.key?(work_item)
|
|
25
|
-
return duration_for(work_item) if stack.include?(work_item)
|
|
26
|
-
|
|
27
|
-
stack.push(work_item)
|
|
28
|
-
tail = scoped_successors(work_item).map { |s| downstream_length(s, memo, stack) }.max || 0
|
|
29
|
-
stack.pop
|
|
30
|
-
memo[work_item] = duration_for(work_item) + tail
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# The Gantt's scheduler (ADR-201/195): real focused-estimate durations like the
|
|
35
|
-
# critical-chain scheduler, but rounded up to whole day columns with a one-day
|
|
36
|
-
# minimum so an unestimated row (focused 0) still shows a visible bar. The chain
|
|
37
|
-
# and buffer keep their exact-duration math in ChainScheduler / CriticalChain.
|
|
38
|
-
class GanttScheduler < ChainScheduler
|
|
39
|
-
def duration_for(work_item)
|
|
40
|
-
[work_item.focused_estimate.ceil, 1].max
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# The critical chain and project buffer for one decision group's Scope rows
|
|
45
|
-
# (ADR-195). This object serves two distinct jobs that want opposite treatment of
|
|
46
|
-
# completed work, so it keeps two views of the same Scope rows:
|
|
47
|
-
#
|
|
48
|
-
# - the *scheduling* chain (`chain` / `buffer` / `projected_duration`) excludes
|
|
49
|
-
# Done rows as finished work — you do not re-schedule what is already done — and
|
|
50
|
-
# reports the duration of the work still remaining;
|
|
51
|
-
# - the *baseline* chain (`baseline_chain` / `baseline_buffer`) keeps every row,
|
|
52
|
-
# Done included, as the plan was originally sized. Buffer-consumption accounting
|
|
53
|
-
# (the fever chart, ADR-196) reads this view so that a chain row which overran
|
|
54
|
-
# and then completed keeps consuming buffer instead of vanishing the moment it
|
|
55
|
-
# is marked Done, and so the consumption denominator stays a stable plan-time
|
|
56
|
-
# baseline rather than shrinking as rows finish (issue-207).
|
|
57
|
-
#
|
|
58
|
-
# A predecessor outside the given set (a row in another group) drops out and is
|
|
59
|
-
# treated as an already-available input. The buffer aggregates the safety
|
|
60
|
-
# (safe - focused, clamped at 0) along a chain and cuts it by buffer_ratio,
|
|
61
|
-
# rounded up.
|
|
62
|
-
class CriticalChain
|
|
63
|
-
def initialize(work_items, buffer_ratio: 0.5)
|
|
64
|
-
@all_items = work_items
|
|
65
|
-
@items = work_items.reject(&:done?)
|
|
66
|
-
@buffer_ratio = buffer_ratio
|
|
67
|
-
@scheduler = ChainScheduler.new(@items)
|
|
68
|
-
@baseline_scheduler = ChainScheduler.new(@all_items)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# The remaining chain (Done rows excluded) in start order — drives the chain
|
|
72
|
-
# table and the projected completion of the work still to do.
|
|
73
|
-
def chain
|
|
74
|
-
@scheduler.critical_chain
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# The chain as originally planned, completed rows included (issue-207). Buffer
|
|
78
|
-
# consumption is accounted against this set so completed overruns stay visible.
|
|
79
|
-
def baseline_chain
|
|
80
|
-
@baseline_scheduler.critical_chain
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Chain length in working days (the latest finish).
|
|
84
|
-
def length
|
|
85
|
-
@scheduler.makespan
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# ceil(buffer_ratio * Σ_chain max(safe - focused, 0)) over the remaining chain.
|
|
89
|
-
def buffer
|
|
90
|
-
buffer_for(chain)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# The plan-time buffer over the full baseline chain — the stable denominator for
|
|
94
|
-
# buffer-consumption %, unaffected by rows completing (issue-207).
|
|
95
|
-
def baseline_buffer
|
|
96
|
-
buffer_for(baseline_chain)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def projected_duration
|
|
100
|
-
length + buffer
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# False when no row carries a positive focused estimate, so the overview marks
|
|
104
|
-
# the group "unestimated" rather than reporting a misleadingly short plan.
|
|
105
|
-
# Reads the baseline so a fully-completed group still reports as estimated and
|
|
106
|
-
# keeps its (100%-complete) fever chart instead of collapsing to "not sized".
|
|
107
|
-
def estimated?
|
|
108
|
-
@all_items.any? { |wi| wi.focused_estimate.positive? }
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
private
|
|
112
|
-
|
|
113
|
-
def buffer_for(rows)
|
|
114
|
-
safety = rows.sum { |wi| [wi.safe_estimate - wi.focused_estimate, 0].max }
|
|
115
|
-
(@buffer_ratio * safety).ceil
|
|
116
|
-
end
|
|
117
|
-
end
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# The buffer-consumption fever chart for one decision group (ADR-196): given the
|
|
4
|
-
# group's CriticalChain plan, the effort logged on its records, and the working-
|
|
5
|
-
# hours-per-day conversion, it produces the (completion%, consumption%) point and
|
|
6
|
-
# the historical trail the Critical Chain page plots.
|
|
7
|
-
#
|
|
8
|
-
# It tracks only chain rows with a positive focused estimate (a zero-estimate row
|
|
9
|
-
# carries no schedule weight). Per-row actual effort is read from the owning
|
|
10
|
-
# Decision's append-only # Effort log via row_actual_hours_on, converted to days;
|
|
11
|
-
# the chart never consults the record lifecycle status.
|
|
12
|
-
#
|
|
13
|
-
# It reads the plan's *baseline* chain and buffer (completed rows included,
|
|
14
|
-
# issue-207), not the remaining-work chain: a chain row that overran and then was
|
|
15
|
-
# marked Done has consumed real buffer that must stay accounted, and the
|
|
16
|
-
# consumption denominator must not shrink as rows finish. The live point still
|
|
17
|
-
# credits a Done row in full via its per-row Status (ADR-196); historical trail
|
|
18
|
-
# points reconstruct even a completed row's progress and overrun from its dated
|
|
19
|
-
# # Effort log, which a per-row Status (no dated history) could not.
|
|
20
|
-
class FeverChart
|
|
21
|
-
# record_lookup maps an upcased record id (e.g. "ADR-196") to its Decision.
|
|
22
|
-
def initialize(plan, record_lookup, hours_per_day: 8)
|
|
23
|
-
@rows = plan.baseline_chain.select { |wi| wi.focused_estimate.positive? }
|
|
24
|
-
@buffer = plan.baseline_buffer
|
|
25
|
-
@record_lookup = record_lookup
|
|
26
|
-
@hours_per_day = hours_per_day.to_f
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# True when there is anything to plot (at least one positive-estimate chain row).
|
|
30
|
-
def plottable?
|
|
31
|
-
@rows.any?
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# The live fever point [completion%, consumption%] as of `date`: a Done row
|
|
35
|
-
# credits full completion regardless of logged effort, matching the bounded
|
|
36
|
-
# per-row Status (ADR-193); other rows credit logged effort only.
|
|
37
|
-
def live_point(date)
|
|
38
|
-
[completion(date, live: true), consumption(date)]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# A historical point [completion%, consumption%] as of `date`, from logged
|
|
42
|
-
# effort only (the per-row Status has no dated history to replay).
|
|
43
|
-
def point_on(date)
|
|
44
|
-
[completion(date, live: false), consumption(date)]
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# One historical point per date (recent Fridays), in the given order.
|
|
48
|
-
def trail(dates)
|
|
49
|
-
dates.map { |d| point_on(d) }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# The baseline buffer days these overruns have consumed as of `date` -- the
|
|
53
|
-
# numerator behind the consumption percentage (its denominator is the baseline
|
|
54
|
-
# buffer). Lets the Critical Chain page report "X of Y baseline days".
|
|
55
|
-
def consumed_days(date)
|
|
56
|
-
@rows.sum { |wi| [actual_days(wi, date) - wi.focused_estimate, 0].max }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
60
|
-
|
|
61
|
-
def completion(date, live:)
|
|
62
|
-
total = @rows.sum(&:focused_estimate)
|
|
63
|
-
return 0.0 if total.zero?
|
|
64
|
-
|
|
65
|
-
credited = @rows.sum { |wi| credit(wi, date, live: live) * wi.focused_estimate }
|
|
66
|
-
100.0 * credited / total
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def credit(work_item, date, live:)
|
|
70
|
-
return 1.0 if live && work_item.done?
|
|
71
|
-
|
|
72
|
-
clamp01(actual_days(work_item, date) / work_item.focused_estimate)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Percentage of the project buffer consumed: the aggregate amount by which chain
|
|
76
|
-
# rows overran their focused estimate, over the buffer. May exceed 100. A group
|
|
77
|
-
# whose chain carries no safety (buffer 0) has nothing to consume, so 0.
|
|
78
|
-
def consumption(date)
|
|
79
|
-
return 0.0 if @buffer.zero?
|
|
80
|
-
|
|
81
|
-
100.0 * consumed_days(date) / @buffer
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def actual_days(work_item, date)
|
|
85
|
-
record = @record_lookup[work_item.record_id.to_s.upcase]
|
|
86
|
-
return 0.0 if record.nil? || @hours_per_day.zero?
|
|
87
|
-
|
|
88
|
-
record.row_actual_hours_on(work_item.activity, date) / @hours_per_day
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def clamp01(value)
|
|
92
|
-
value.clamp(0.0, 1.0)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'set'
|
|
4
|
-
|
|
5
|
-
# Lays the WorkItem network (ADR-194) on an abstract day axis for the overview
|
|
6
|
-
# swimlane Gantt (ADR-198). It performs a deterministic forward pass over the
|
|
7
|
-
# dependency edges and then levels each owner's lane so two work items of the
|
|
8
|
-
# same owner never overlap. Levelling backfills: a row may be slotted into an
|
|
9
|
-
# idle gap left between rows placed earlier, so a low-priority short row does
|
|
10
|
-
# not queue behind the whole lane when a gap already fits it.
|
|
11
|
-
#
|
|
12
|
-
# Durations are a constant placeholder (3 days) while no per-row estimates
|
|
13
|
-
# exist; `duration_for` is the single hook ADR-195 overrides to feed real
|
|
14
|
-
# estimates without changing the scheduling logic.
|
|
15
|
-
#
|
|
16
|
-
# Days are 1-based. A work item starting on day `s` with duration `d` occupies
|
|
17
|
-
# the inclusive day span `s .. s + d - 1`; its *finish* (the next free day,
|
|
18
|
-
# returned in the ends map) is `s + d`, the earliest a successor or the same
|
|
19
|
-
# owner's next item may start.
|
|
20
|
-
class WorkItemScheduler
|
|
21
|
-
# Placeholder duration for every work item until ADR-195 supplies estimates.
|
|
22
|
-
DEFAULT_DURATION = 3
|
|
23
|
-
|
|
24
|
-
def initialize(work_items, duration: DEFAULT_DURATION)
|
|
25
|
-
@items = work_items
|
|
26
|
-
@duration = duration
|
|
27
|
-
@item_set = work_items.to_set
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# { work_item => start_day }, day index 1-based. Empty when there are no items.
|
|
31
|
-
def start_days
|
|
32
|
-
schedule unless @starts
|
|
33
|
-
@starts
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# The number of day columns the chart needs: the latest finish minus one
|
|
37
|
-
# (finishes are the exclusive next-free day). Zero when there are no items.
|
|
38
|
-
def day_count
|
|
39
|
-
schedule unless @ends
|
|
40
|
-
@ends.empty? ? 0 : (@ends.values.max - 1)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# The schedule length in working days (the latest finish, day 1 being the start).
|
|
44
|
-
def makespan
|
|
45
|
-
day_count
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# The critical chain: the row with the latest finish, traced back through its
|
|
49
|
-
# binding predecessors (the dependency and resource hand-offs that set each
|
|
50
|
-
# row's start), returned in start order. Empty when nothing is scheduled.
|
|
51
|
-
def critical_chain
|
|
52
|
-
schedule unless @starts
|
|
53
|
-
return [] if @ends.empty?
|
|
54
|
-
|
|
55
|
-
max_end = @ends.values.max
|
|
56
|
-
node = @items.select { |wi| @ends[wi] == max_end }.min_by { |wi| [wi.record_id, wi.step] }
|
|
57
|
-
chain = []
|
|
58
|
-
while node
|
|
59
|
-
chain.unshift(node)
|
|
60
|
-
node = @binding[node]
|
|
61
|
-
end
|
|
62
|
-
chain
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def duration_for(_work_item)
|
|
66
|
-
@duration
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
# Greedy list-scheduler. Items are processed in a deterministic priority order
|
|
72
|
-
# (resource-free earliest start, then activity rank, record id, step) so that
|
|
73
|
-
# every item's predecessors are placed before it. Each item starts at the
|
|
74
|
-
# earliest day at or after its dependency finish where its owner's lane has an
|
|
75
|
-
# idle gap wide enough for it (resource levelling with backfill).
|
|
76
|
-
def schedule
|
|
77
|
-
@starts = {}
|
|
78
|
-
@ends = {}
|
|
79
|
-
@binding = {}
|
|
80
|
-
@owner_rows = Hash.new { |hash, owner| hash[owner] = [] }
|
|
81
|
-
return if @items.empty?
|
|
82
|
-
|
|
83
|
-
priority_order.each { |wi| place(wi) }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Assigns one work item its start day, records the predecessor that bound that
|
|
87
|
-
# start, then marks the span as occupied in its owner's lane.
|
|
88
|
-
def place(work_item)
|
|
89
|
-
preds = scoped_predecessors(work_item)
|
|
90
|
-
dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
|
|
91
|
-
start, lane_pred = earliest_fit(work_item.owner, dep_finish, duration_for(work_item))
|
|
92
|
-
@starts[work_item] = start
|
|
93
|
-
@ends[work_item] = start + duration_for(work_item)
|
|
94
|
-
@binding[work_item] = binding_predecessor(preds, start, lane_pred)
|
|
95
|
-
@owner_rows[work_item.owner] << work_item unless work_item.owner.empty?
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# The earliest start at or after `from` where the owner's lane stays clear for
|
|
99
|
-
# `duration` days, plus the lane row whose finish that start had to wait behind
|
|
100
|
-
# (nil when the row starts at `from` itself). A blank owner holds no resource,
|
|
101
|
-
# so it never serialises.
|
|
102
|
-
def earliest_fit(owner, from, duration)
|
|
103
|
-
return [from, nil] if owner.empty?
|
|
104
|
-
|
|
105
|
-
start = from
|
|
106
|
-
lane_pred = nil
|
|
107
|
-
@owner_rows[owner].sort_by { |row| @starts[row] }.each do |row|
|
|
108
|
-
break if start + duration <= @starts[row]
|
|
109
|
-
next if @ends[row] <= start
|
|
110
|
-
|
|
111
|
-
start = @ends[row]
|
|
112
|
-
lane_pred = row
|
|
113
|
-
end
|
|
114
|
-
[start, lane_pred]
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# The already-placed predecessor whose finish coincides with this row's start --
|
|
118
|
-
# the dependency or same-owner hand-off the critical chain is traced back
|
|
119
|
-
# through. nil when the row starts at the origin with no such predecessor.
|
|
120
|
-
def binding_predecessor(preds, start, lane_pred)
|
|
121
|
-
candidates = preds.select { |p| @ends[p] == start }
|
|
122
|
-
candidates << lane_pred if lane_pred
|
|
123
|
-
candidates.min_by { |c| [c.record_id, c.step] }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def priority_order
|
|
127
|
-
memo = {}
|
|
128
|
-
@items.sort_by { |wi| priority_key(wi, memo) }
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# The deterministic scheduling priority for a row. Overridden by the critical-
|
|
132
|
-
# chain scheduler (ADR-195) to prioritise the longest downstream duration.
|
|
133
|
-
def priority_key(work_item, memo)
|
|
134
|
-
[dependency_start(work_item, memo, []), work_item.activity_rank, work_item.record_id, work_item.step]
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# The earliest day this item could start ignoring resource contention: 1 when
|
|
138
|
-
# it has no predecessors, else one past the latest predecessor finish. Memoised
|
|
139
|
-
# and cycle-guarded — a back-edge (which the DAG-by-construction network should
|
|
140
|
-
# never have) is treated as start 1 so scheduling still completes.
|
|
141
|
-
def dependency_start(work_item, memo, stack)
|
|
142
|
-
return memo[work_item] if memo.key?(work_item)
|
|
143
|
-
return 1 if stack.include?(work_item)
|
|
144
|
-
|
|
145
|
-
preds = scoped_predecessors(work_item)
|
|
146
|
-
return memo[work_item] = 1 if preds.empty?
|
|
147
|
-
|
|
148
|
-
stack.push(work_item)
|
|
149
|
-
earliest = preds.map { |p| dependency_start(p, memo, stack) + duration_for(p) }.max
|
|
150
|
-
stack.pop
|
|
151
|
-
memo[work_item] = earliest
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Predecessors inside this scheduler's own item set. A predecessor scheduled in
|
|
155
|
-
# another scope (e.g. a different decision group, ADR-201) is treated as an
|
|
156
|
-
# already-available external input: it is dropped here and imposes no finish
|
|
157
|
-
# constraint, so the dependent simply starts at day 1 with respect to it.
|
|
158
|
-
def scoped_predecessors(work_item)
|
|
159
|
-
work_item.predecessor_items.select { |p| @item_set.include?(p) }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Successors inside this scheduler's own item set (the mirror of
|
|
163
|
-
# scoped_predecessors), used by the critical-chain priority.
|
|
164
|
-
def scoped_successors(work_item)
|
|
165
|
-
work_item.successor_items.select { |s| @item_set.include?(s) }
|
|
166
|
-
end
|
|
167
|
-
end
|