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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/bin/almirah +2 -2
  3. data/lib/almirah/doc_fabric.rb +7 -0
  4. data/lib/almirah/doc_items/blockquote.rb +15 -16
  5. data/lib/almirah/doc_items/code_block.rb +19 -21
  6. data/lib/almirah/doc_items/controlled_paragraph.rb +2 -2
  7. data/lib/almirah/doc_items/controlled_table.rb +11 -11
  8. data/lib/almirah/doc_items/controlled_table_row.rb +15 -18
  9. data/lib/almirah/doc_items/doc_footer.rb +10 -13
  10. data/lib/almirah/doc_items/doc_item.rb +2 -2
  11. data/lib/almirah/doc_items/heading.rb +1 -1
  12. data/lib/almirah/doc_items/image.rb +25 -27
  13. data/lib/almirah/doc_items/markdown_list.rb +2 -2
  14. data/lib/almirah/doc_items/markdown_table.rb +9 -2
  15. data/lib/almirah/doc_items/scope_table.rb +188 -0
  16. data/lib/almirah/doc_items/text_line.rb +26 -22
  17. data/lib/almirah/doc_items/todo_block.rb +15 -16
  18. data/lib/almirah/doc_items/work_item.rb +129 -0
  19. data/lib/almirah/doc_parser.rb +14 -8
  20. data/lib/almirah/doc_types/base_document.rb +21 -5
  21. data/lib/almirah/doc_types/coverage.rb +3 -3
  22. data/lib/almirah/doc_types/critical_chain_page.rb +218 -0
  23. data/lib/almirah/doc_types/decision.rb +152 -7
  24. data/lib/almirah/doc_types/decision_grouping.rb +17 -0
  25. data/lib/almirah/doc_types/decisions_overview.rb +591 -31
  26. data/lib/almirah/doc_types/implementation.rb +98 -98
  27. data/lib/almirah/doc_types/index.rb +11 -12
  28. data/lib/almirah/doc_types/persistent_document.rb +1 -1
  29. data/lib/almirah/doc_types/planning_dates.rb +17 -0
  30. data/lib/almirah/doc_types/protocol.rb +16 -20
  31. data/lib/almirah/doc_types/source_file.rb +1 -1
  32. data/lib/almirah/doc_types/traceability.rb +124 -133
  33. data/lib/almirah/dom/doc_section.rb +1 -1
  34. data/lib/almirah/navigation_pane.rb +9 -13
  35. data/lib/almirah/project/critical_chain.rb +117 -0
  36. data/lib/almirah/project/doc_linker.rb +4 -4
  37. data/lib/almirah/project/fever_chart.rb +94 -0
  38. data/lib/almirah/project/project_data.rb +8 -2
  39. data/lib/almirah/project/work_item_scheduler.rb +166 -0
  40. data/lib/almirah/project/working_calendar.rb +112 -0
  41. data/lib/almirah/project.rb +146 -9
  42. data/lib/almirah/project_configuration.rb +126 -29
  43. data/lib/almirah/project_template.rb +6 -6
  44. data/lib/almirah/project_utility.rb +3 -5
  45. data/lib/almirah/search/specifications_db.rb +2 -2
  46. data/lib/almirah/source_file_parser.rb +2 -3
  47. data/lib/almirah/templates/css/main.css +160 -0
  48. data/lib/almirah/templates/scripts/main.js +3 -1
  49. data/lib/almirah.rb +1 -2
  50. 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 # rubocop:disable Style/Documentation,Metrics/ClassLength
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
- def effective_status_on(date) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
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:) # rubocop:disable Metrics/AbcSize
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) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
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.rows.filter_map { |row| parse_dd_mm_yyyy(row[col_index]) }
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