Almirah 0.4.2 → 0.4.3
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/bin/almirah +2 -2
- data/lib/almirah/doc_fabric.rb +7 -0
- data/lib/almirah/doc_items/blockquote.rb +15 -16
- data/lib/almirah/doc_items/code_block.rb +19 -21
- data/lib/almirah/doc_items/controlled_paragraph.rb +2 -2
- data/lib/almirah/doc_items/controlled_table.rb +11 -11
- data/lib/almirah/doc_items/controlled_table_row.rb +15 -18
- data/lib/almirah/doc_items/doc_footer.rb +10 -13
- data/lib/almirah/doc_items/doc_item.rb +2 -2
- data/lib/almirah/doc_items/heading.rb +1 -1
- data/lib/almirah/doc_items/image.rb +25 -27
- data/lib/almirah/doc_items/markdown_list.rb +2 -2
- data/lib/almirah/doc_items/markdown_table.rb +9 -2
- data/lib/almirah/doc_items/scope_table.rb +188 -0
- data/lib/almirah/doc_items/text_line.rb +26 -22
- data/lib/almirah/doc_items/todo_block.rb +15 -16
- data/lib/almirah/doc_items/work_item.rb +129 -0
- data/lib/almirah/doc_parser.rb +14 -8
- data/lib/almirah/doc_types/base_document.rb +21 -5
- data/lib/almirah/doc_types/coverage.rb +3 -3
- data/lib/almirah/doc_types/critical_chain_page.rb +218 -0
- data/lib/almirah/doc_types/decision.rb +152 -7
- data/lib/almirah/doc_types/decision_grouping.rb +17 -0
- data/lib/almirah/doc_types/decisions_overview.rb +591 -31
- data/lib/almirah/doc_types/implementation.rb +98 -98
- data/lib/almirah/doc_types/index.rb +11 -12
- data/lib/almirah/doc_types/persistent_document.rb +1 -1
- data/lib/almirah/doc_types/planning_dates.rb +17 -0
- data/lib/almirah/doc_types/protocol.rb +16 -20
- data/lib/almirah/doc_types/source_file.rb +1 -1
- data/lib/almirah/doc_types/traceability.rb +124 -133
- data/lib/almirah/dom/doc_section.rb +1 -1
- data/lib/almirah/navigation_pane.rb +9 -13
- data/lib/almirah/project/critical_chain.rb +117 -0
- data/lib/almirah/project/doc_linker.rb +4 -4
- data/lib/almirah/project/fever_chart.rb +94 -0
- data/lib/almirah/project/project_data.rb +8 -2
- data/lib/almirah/project/work_item_scheduler.rb +166 -0
- data/lib/almirah/project/working_calendar.rb +112 -0
- data/lib/almirah/project.rb +146 -9
- data/lib/almirah/project_configuration.rb +126 -29
- data/lib/almirah/project_template.rb +6 -6
- data/lib/almirah/project_utility.rb +3 -5
- data/lib/almirah/search/specifications_db.rb +2 -2
- data/lib/almirah/source_file_parser.rb +2 -3
- data/lib/almirah/templates/css/main.css +160 -0
- data/lib/almirah/templates/scripts/main.js +3 -1
- data/lib/almirah.rb +1 -2
- metadata +11 -2
|
@@ -0,0 +1,218 @@
|
|
|
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
|
|
@@ -4,10 +4,12 @@ require 'date'
|
|
|
4
4
|
require_relative 'persistent_document'
|
|
5
5
|
require_relative '../doc_items/heading'
|
|
6
6
|
require_relative '../doc_items/markdown_table'
|
|
7
|
+
require_relative '../doc_items/scope_table'
|
|
7
8
|
|
|
8
|
-
class Decision < PersistentDocument
|
|
9
|
+
class Decision < PersistentDocument
|
|
9
10
|
attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
|
|
10
|
-
:start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash
|
|
11
|
+
:start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash,
|
|
12
|
+
:owners, :scope_table
|
|
11
13
|
|
|
12
14
|
def initialize(file_path)
|
|
13
15
|
super
|
|
@@ -19,6 +21,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
19
21
|
@target_date = nil
|
|
20
22
|
@target_release_version = nil
|
|
21
23
|
@wrong_links_hash = {}
|
|
24
|
+
@owners = []
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def to_console
|
|
@@ -64,7 +67,73 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
64
67
|
)
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
# The distinct, first-seen-ordered list of non-empty Owner cells in the Scope
|
|
71
|
+
# table. Empty when there is no Scope table, no Owner column, or no owner values.
|
|
72
|
+
# The Owner column is located by header text, not position.
|
|
73
|
+
def extract_owners
|
|
74
|
+
@owners = []
|
|
75
|
+
table = find_section_table('Scope')
|
|
76
|
+
return if table.nil?
|
|
77
|
+
|
|
78
|
+
owner_idx = column_index(table, 'Owner')
|
|
79
|
+
return if owner_idx.nil?
|
|
80
|
+
|
|
81
|
+
table.cells.each do |row|
|
|
82
|
+
owner = row[owner_idx].to_s.strip
|
|
83
|
+
@owners << owner unless owner.empty? || @owners.include?(owner)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The parsed Scope ScopeTable (ADR-194), or nil when the record has no Scope
|
|
88
|
+
# section. Memoised so the work-item network and the readers share one table.
|
|
89
|
+
def extract_scope_table
|
|
90
|
+
table = find_section_table('Scope')
|
|
91
|
+
@scope_table = table.is_a?(ScopeTable) ? table : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The Scope rows as WorkItem nodes (ADR-194); empty when there is no ScopeTable.
|
|
95
|
+
def scope_work_items
|
|
96
|
+
@scope_table ? @scope_table.work_items : []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A record declares prerequisites when any of its rows carries a Depends On
|
|
100
|
+
# reference; the overview Kit column is empty otherwise.
|
|
101
|
+
def declared_dependencies?
|
|
102
|
+
scope_work_items.any? { |wi| wi.depends_on_refs.any? }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Kitted when every one of its work items is (a record with no rows is kitted).
|
|
106
|
+
def fully_kitted?
|
|
107
|
+
scope_work_items.all?(&:fully_kitted?)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A started row blocked by an unsatisfied cross-record predecessor — the
|
|
111
|
+
# overview emphasises such a record, matching the console warning.
|
|
112
|
+
def kit_started_violation?
|
|
113
|
+
scope_work_items.any?(&:cross_record_violation?)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One entry per Scope row whose row Status is In-Progress, yielding that row's
|
|
117
|
+
# owner. Rows without an owner, or not In-Progress, contribute nothing. The
|
|
118
|
+
# per-row Status is read directly and is independent of the record's lifecycle
|
|
119
|
+
# status. Owner and Status columns are located by header text, not position.
|
|
120
|
+
def in_progress_owner_tally
|
|
121
|
+
table = find_section_table('Scope')
|
|
122
|
+
return [] if table.nil?
|
|
123
|
+
|
|
124
|
+
owner_idx = column_index(table, 'Owner')
|
|
125
|
+
status_idx = column_index(table, 'Status')
|
|
126
|
+
return [] if owner_idx.nil? || status_idx.nil?
|
|
127
|
+
|
|
128
|
+
table.cells.filter_map do |row|
|
|
129
|
+
owner = row[owner_idx].to_s.strip
|
|
130
|
+
next if owner.empty?
|
|
131
|
+
|
|
132
|
+
owner if row[status_idx].to_s.strip == 'In-Progress'
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def effective_status_on(date)
|
|
68
137
|
table = find_section_table('Status')
|
|
69
138
|
return nil if table.nil?
|
|
70
139
|
|
|
@@ -89,9 +158,46 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
89
158
|
status.empty? ? nil : status
|
|
90
159
|
end
|
|
91
160
|
|
|
161
|
+
# Total logged effort in hours across the # Effort table (ADR-196); 0 when the
|
|
162
|
+
# record has no Effort section. Undated and unparseable entries still count
|
|
163
|
+
# toward the record total.
|
|
164
|
+
def actual_hours
|
|
165
|
+
effort_entries.sum { |e| e[:hours] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Logged hours dated on or before `date` (ADR-196). Undated entries are
|
|
169
|
+
# excluded since they cannot be placed on the timeline.
|
|
170
|
+
def actual_hours_on(date)
|
|
171
|
+
effort_entries.sum { |e| e[:date] && e[:date] <= date ? e[:hours] : 0.0 }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Logged hours dated on or before `date` for the Scope row whose Item matches
|
|
175
|
+
# `item` (case-insensitive) — the per-chain-row actual the fever chart credits
|
|
176
|
+
# (ADR-196). Entries with no Item credit no row and are excluded here.
|
|
177
|
+
def row_actual_hours_on(item, date)
|
|
178
|
+
key = item.to_s.strip.downcase
|
|
179
|
+
return 0.0 if key.empty?
|
|
180
|
+
|
|
181
|
+
effort_entries.sum do |e|
|
|
182
|
+
e[:item] == key && e[:date] && e[:date] <= date ? e[:hours] : 0.0
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The [earliest, latest] dated # Effort entry crediting the Scope row whose Item
|
|
187
|
+
# matches `item` (case-insensitive) — the real logged span the Gantt's tracking
|
|
188
|
+
# lane draws (ADR-213). nil when the row has no dated effort. Undated entries are
|
|
189
|
+
# excluded since they cannot be placed on the timeline.
|
|
190
|
+
def effort_date_range(item)
|
|
191
|
+
key = item.to_s.strip.downcase
|
|
192
|
+
return nil if key.empty?
|
|
193
|
+
|
|
194
|
+
dates = effort_entries.filter_map { |e| e[:date] if e[:item] == key }
|
|
195
|
+
dates.empty? ? nil : dates.minmax
|
|
196
|
+
end
|
|
197
|
+
|
|
92
198
|
private
|
|
93
199
|
|
|
94
|
-
def lookup_cell(section_name:, key_column:, value_column:, key:)
|
|
200
|
+
def lookup_cell(section_name:, key_column:, value_column:, key:)
|
|
95
201
|
table = find_section_table(section_name)
|
|
96
202
|
return nil if table.nil?
|
|
97
203
|
|
|
@@ -106,7 +212,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
106
212
|
cell.empty? ? nil : cell
|
|
107
213
|
end
|
|
108
214
|
|
|
109
|
-
def find_section_table(section_name)
|
|
215
|
+
def find_section_table(section_name)
|
|
110
216
|
in_section = false
|
|
111
217
|
section_level = nil
|
|
112
218
|
@items.each do |item|
|
|
@@ -117,7 +223,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
117
223
|
elsif in_section && item.level <= section_level
|
|
118
224
|
return nil
|
|
119
225
|
end
|
|
120
|
-
elsif in_section && item.is_a?(MarkdownTable)
|
|
226
|
+
elsif in_section && (item.is_a?(MarkdownTable) || item.is_a?(ScopeTable))
|
|
121
227
|
return item
|
|
122
228
|
end
|
|
123
229
|
end
|
|
@@ -131,7 +237,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
131
237
|
col_index = column_index(table, column_name)
|
|
132
238
|
return [] if col_index.nil?
|
|
133
239
|
|
|
134
|
-
table.
|
|
240
|
+
table.cells.filter_map { |row| parse_dd_mm_yyyy(row[col_index]) }
|
|
135
241
|
end
|
|
136
242
|
|
|
137
243
|
def column_index(table, column_name)
|
|
@@ -152,6 +258,45 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
152
258
|
nil
|
|
153
259
|
end
|
|
154
260
|
|
|
261
|
+
# Parsed # Effort rows (ADR-196): [{ date:, item:, hours: }, ...]. The section
|
|
262
|
+
# is located by heading text and its columns addressed by header (Date / Item /
|
|
263
|
+
# Hours), like Status and Scope. Hours parse as non-negative floats (blank,
|
|
264
|
+
# negative, or unparseable -> 0); Item is downcased for case-insensitive row
|
|
265
|
+
# matching, or nil when absent. Memoised so the readers share one parse.
|
|
266
|
+
def effort_entries
|
|
267
|
+
return @effort_entries if defined?(@effort_entries)
|
|
268
|
+
|
|
269
|
+
@effort_entries = parse_effort_entries
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def parse_effort_entries
|
|
273
|
+
table = find_section_table('Effort')
|
|
274
|
+
return [] if table.nil?
|
|
275
|
+
|
|
276
|
+
date_idx = column_index(table, 'Date')
|
|
277
|
+
hours_idx = column_index(table, 'Hours')
|
|
278
|
+
return [] if date_idx.nil? || hours_idx.nil?
|
|
279
|
+
|
|
280
|
+
item_idx = column_index(table, 'Item')
|
|
281
|
+
table.cells.map { |row| effort_entry(row, date_idx, item_idx, hours_idx) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def effort_entry(row, date_idx, item_idx, hours_idx)
|
|
285
|
+
{ date: parse_dd_mm_yyyy(row[date_idx]),
|
|
286
|
+
item: item_idx ? normalize_item(row[item_idx]) : nil,
|
|
287
|
+
hours: parse_hours(row[hours_idx]) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def normalize_item(value)
|
|
291
|
+
text = value.to_s.strip.downcase
|
|
292
|
+
text.empty? ? nil : text
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_hours(value)
|
|
296
|
+
hours = Float(value.to_s.strip, exception: false)
|
|
297
|
+
hours.nil? || hours.negative? ? 0.0 : hours
|
|
298
|
+
end
|
|
299
|
+
|
|
155
300
|
def assign_id_parts(stem)
|
|
156
301
|
match = stem.match(/\A([A-Za-z]+)-(\d+)/)
|
|
157
302
|
if match
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|