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.
@@ -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