Almirah 0.4.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46e67646a16db801d9dbe0cd2c2873a38e6e35068c5cde855179b9f5beb00089
4
- data.tar.gz: c4953874698c949e6711ebdfa0c84dc0b56d6291674a7127d6ccfbb31802cea4
3
+ metadata.gz: e7ecb1c0f1633226eaccd34b70d7c45c6d00e6f8ec4b605231f71b7bea725dcd
4
+ data.tar.gz: a223709af5754031b0efef8c443fd158fad6c2c3fde08fbba83233e82075d82a
5
5
  SHA512:
6
- metadata.gz: 1401a30fbde34dd6c21b77685e3e2506753e7b302d528189e5d3d129eff6f9a15dd169f756be53ea1467dc5805fe36d0ce0678d2a446a866b18e95c66e70de64
7
- data.tar.gz: 1db3dd4230d6b77154758bdbad3802fb121ba23c41a3ee33aeb8ba70ac0be4a7ac0d8e14ea05667869d2625fcc638ca2d900a47270f22ff9bac1077c932173ba
6
+ metadata.gz: 4d57afaec80f6e7370aec60e254f6650832ed2808a2b1550efc7a5271f69bd4e2bbce2e636eb2f7128b1a8ccee6e2bf978e0ca032ad275c399bbc334c12c57ab
7
+ data.tar.gz: a5e6de48b94a5de8a77ab8cb7e744116b3c9ecb5b92e3f81d1231c9436e412d9f1e53e97f2db9c2dc733f3dfdda6966e305b3b7faf9963453d1abff970358985
@@ -6,8 +6,10 @@ require_relative 'doc_types/coverage'
6
6
  require_relative 'doc_types/implementation'
7
7
  require_relative 'doc_types/traceability'
8
8
  require_relative 'doc_types/decision'
9
+ require_relative 'doc_types/risk_record'
10
+ require_relative 'doc_types/risk_registry_page'
11
+ require_relative 'doc_types/risks_overview'
9
12
  require_relative 'doc_types/decisions_overview'
10
- require_relative 'doc_types/critical_chain_page'
11
13
  require_relative 'doc_parser'
12
14
  require_relative 'source_file_parser'
13
15
  require_relative 'dom/document'
@@ -69,16 +71,26 @@ class DocFabric
69
71
  doc.extract_target_date
70
72
  doc.extract_target_release_version
71
73
  doc.extract_owners
72
- doc.extract_scope_table
73
74
  doc
74
75
  end
75
76
 
76
- def self.create_decisions_overview(project)
77
- DecisionsOverview.new project
77
+ def self.create_risk_record(path)
78
+ doc = RiskRecord.new path
79
+ DocFabric.parse_document doc
80
+ doc.extract_current_status
81
+ doc
78
82
  end
79
83
 
80
- def self.create_critical_chain_page(project)
81
- CriticalChainPage.new project
84
+ def self.create_risk_registry_page(registry, records, preface, columns, rpn_groups = [])
85
+ RiskRegistryPage.new registry, records, preface, columns, rpn_groups
86
+ end
87
+
88
+ def self.create_risks_overview(registries, configuration, prefaces = {})
89
+ RisksOverview.new registries, configuration, prefaces
90
+ end
91
+
92
+ def self.create_decisions_overview(project)
93
+ DecisionsOverview.new project
82
94
  end
83
95
 
84
96
  def self.create_source_file(repository_path, path, repository_name)
@@ -114,7 +114,7 @@ class ControlledParagraph < Paragraph
114
114
  if @decision_record_links.length == 1
115
115
  dr_doc = @decision_record_links[0].parent_doc
116
116
  s += "\t\t<td class=\"item_id\">\
117
- <a href=\"./../../decisions/#{dr_doc.html_rel_path}\" \
117
+ <a href=\"./../../#{dr_doc.output_rel_path}\" \
118
118
  class=\"external\" title=\"Decision Record\">#{dr_doc.id.upcase}</a></td>\n"
119
119
  else
120
120
  s += "\t\t<td class=\"item_id\">"
@@ -125,7 +125,7 @@ class ControlledParagraph < Paragraph
125
125
  s += "<div id=\"DRS_#{@id}\" style=\"display: none;\">"
126
126
  @decision_record_links.each do |lnk|
127
127
  dr_doc = lnk.parent_doc
128
- s += "\t\t\t<a href=\"./../../decisions/#{dr_doc.html_rel_path}\" \
128
+ s += "\t\t\t<a href=\"./../../#{dr_doc.output_rel_path}\" \
129
129
  class=\"external\" title=\"Referenced in\">#{dr_doc.id.upcase}</a>\n<br>"
130
130
  end
131
131
  s += '</div>'
@@ -1,20 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'date'
4
3
  require_relative 'controlled_table'
5
- require_relative 'work_item'
4
+ require_relative '../relative_url'
6
5
 
7
6
  # Purpose-built controlled table for a Decision Record's `# Scope` section
8
- # (ADR-194). It is modelled on ControlledTable but addresses its columns by
9
- # header text (not position) and has no fixed link column: it locates `#`,
10
- # `Item`, `Owner`, `Depends On`, and `Status` wherever the author placed them.
7
+ # (ADR-210, slimmed to presentation only by ADR-222). It is modelled on
8
+ # ControlledTable but addresses its columns by header text (not position): it
9
+ # locates `#` and `Depends On` wherever the author placed them, along with the
10
+ # date columns whose cells are kept on one line (ENH-214).
11
11
  #
12
- # Each data row becomes a WorkItem (the dependency-network node). The table also
13
- # keeps a plain header-addressed cell grid (`cells`) so the ADR-193 Scope
14
- # readers (owners / WIP / dates) keep working unchanged after the Scope section
15
- # stopped being a plain MarkdownTable.
12
+ # Rendering is the table's whole job: the `#` column is an anchored row number
13
+ # so individual Scope rows can be linked, and each Depends On reference renders
14
+ # as a clickable link to the referenced record.
16
15
  class ScopeTable < ControlledTable
17
- attr_reader :cells, :work_items
16
+ attr_reader :cells
18
17
 
19
18
  def initialize(doc, markdown_table)
20
19
  @parent_doc = doc
@@ -22,7 +21,7 @@ class ScopeTable < ControlledTable
22
21
  @column_names = markdown_table.column_names
23
22
  @is_separator_detected = markdown_table.is_separator_detected
24
23
  @cells = []
25
- @work_items = []
24
+ @rows = []
26
25
  @col = locate_columns(@column_names)
27
26
  # The switch to ScopeTable happens on the first data row, so the source
28
27
  # MarkdownTable carries none yet; any it did are re-read for safety.
@@ -34,12 +33,6 @@ class ScopeTable < ControlledTable
34
33
  true
35
34
  end
36
35
 
37
- # True when the table has a leading `#` step column, hence a per-row anchor a
38
- # dependent record can deep-link to (ADR-194).
39
- def step_column?
40
- !@col[:step].nil?
41
- end
42
-
43
36
  # Render as a controlled table: an anchored, centered step number in the `#`
44
37
  # column (so rows are linkable, like the protocol table) and formatted text
45
38
  # for every other cell.
@@ -52,7 +45,7 @@ class ScopeTable < ControlledTable
52
45
  s << "<table class=\"markdown_table\">\n\t<thead>"
53
46
  @column_names.each { |h| s << " <th>#{format_string(h.strip)}</th>" }
54
47
  s << " </thead>\n"
55
- @cells.each_with_index { |cells, i| s << row_html(cells, @work_items[i]) }
48
+ @cells.each_with_index { |cells, i| s << row_html(cells, @rows[i]) }
56
49
  s << "</table>\n"
57
50
  s
58
51
  end
@@ -64,58 +57,14 @@ class ScopeTable < ControlledTable
64
57
  column_names.each_with_index { |h, i| return i if h.to_s.strip == name }
65
58
  nil
66
59
  end
67
- { step: index_of.call('#'), item: index_of.call('Item'), owner: index_of.call('Owner'),
68
- depends_on: index_of.call('Depends On'), status: index_of.call('Status'),
69
- est_focused: index_of.call('Est (focused)'), est_safe: index_of.call('Est (safe)'),
60
+ { step: index_of.call('#'), depends_on: index_of.call('Depends On'),
70
61
  start_date: index_of.call('Start Date'), target_date: index_of.call('Target Date') }
71
62
  end
72
63
 
73
64
  def append_cells(cells)
74
65
  @cells << cells
75
- @work_items << build_work_item(cells)
76
- end
77
-
78
- def build_work_item(cells)
79
- WorkItem.new(
80
- record_id: @parent_doc.id,
81
- step: step_number(cells, @cells.length), # 1-based row order fallback
82
- activity: cell(cells, @col[:item]),
83
- owner: cell(cells, @col[:owner]),
84
- status: cell(cells, @col[:status]),
85
- depends_on_refs: parse_depends_on(cell(cells, @col[:depends_on])),
86
- start_date: parse_date(cell(cells, @col[:start_date])),
87
- target_date: parse_date(cell(cells, @col[:target_date])),
88
- **estimate_attrs(cells)
89
- )
90
- end
91
-
92
- # A Scope Start/Target Date cell as a Date (ADR-213), or nil when blank or not
93
- # in the DD-MM-YYYY form the rest of the planning views read (ADR-205).
94
- def parse_date(text)
95
- match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(text.to_s.strip)
96
- return nil unless match
97
-
98
- Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
99
- rescue ArgumentError
100
- nil
101
- end
102
-
103
- # The ADR-195 estimate-derived attributes: focused and safe working-day
104
- # estimates (blank/unparseable -> 0) and the owning record's sequence number
105
- # (the critical-chain priority tiebreak).
106
- def estimate_attrs(cells)
107
- {
108
- focused_estimate: parse_estimate(cell(cells, @col[:est_focused])),
109
- safe_estimate: parse_estimate(cell(cells, @col[:est_safe])),
110
- record_sequence: @parent_doc.sequence_number.to_i
111
- }
112
- end
113
-
114
- # A Scope estimate cell as a non-negative number of working days (decimals
115
- # allowed); blank, unparseable, or negative values count as 0 (ADR-195).
116
- def parse_estimate(text)
117
- value = Float(text.to_s.strip, exception: false)
118
- value&.positive? ? value : 0.0
66
+ @rows << { step: step_number(cells, @cells.length),
67
+ depends_on: parse_depends_on(cell(cells, @col[:depends_on])) }
119
68
  end
120
69
 
121
70
  # The `#` column's integer when present; otherwise the row's 1-based position,
@@ -137,52 +86,54 @@ class ScopeTable < ControlledTable
137
86
  cells[index].to_s.strip
138
87
  end
139
88
 
140
- def row_html(cells, work_item)
89
+ def row_html(cells, row)
141
90
  s = +"\t<tr>\n"
142
- cells.each_with_index { |value, index| s << cell_html(value, index, work_item) }
91
+ cells.each_with_index { |value, index| s << cell_html(value, index, row) }
143
92
  s << "\t</tr>\n"
144
93
  s
145
94
  end
146
95
 
147
- def cell_html(value, index, work_item)
148
- return depends_on_html(work_item) if index == @col[:depends_on]
149
- return step_cell_html(value, work_item) if index == @col[:step]
96
+ def cell_html(value, index, row)
97
+ return depends_on_html(row) if index == @col[:depends_on]
98
+ return step_cell_html(value, row) if index == @col[:step]
150
99
  return date_cell_html(value) if [@col[:start_date], @col[:target_date]].include?(index)
151
100
 
152
101
  "\t\t<td>#{format_string(value.strip)}</td>\n"
153
102
  end
154
103
 
155
104
  # The Start/Target Date cells are kept on one line so a DD-MM-YYYY date never
156
- # wraps at its hyphens in a narrow column (ADR-213).
105
+ # wraps at its hyphens in a narrow column (ENH-214).
157
106
  def date_cell_html(value)
158
107
  "\t\t<td class=\"scope_date\">#{format_string(value.strip)}</td>\n"
159
108
  end
160
109
 
161
110
  # The `#` cell is a named anchor (id + name), mirroring the test-step number
162
- # column, so a dependent record's Depends On link can target this row.
163
- def step_cell_html(value, work_item)
164
- anchor = work_item.row_anchor
111
+ # column, so a Scope row can be deep-linked. Namespaced with `.scope.` so it
112
+ # never collides with the Affected Documents controlled table, whose rows
113
+ # anchor on the same `<record>.<step>` scheme on the same page.
114
+ def step_cell_html(value, row)
115
+ anchor = "#{@parent_doc.id}.scope.#{row[:step]}"
165
116
  "\t\t<td style=\"text-align: center;\">" \
166
117
  "<a name=\"#{anchor}\" id=\"#{anchor}\" href=\"##{anchor}\">#{format_string(value.strip)}</a></td>\n"
167
118
  end
168
119
 
169
- # Render each authored Depends On reference as a link to the resolved target
170
- # record. When the target record has a `#` step column, the link deep-jumps to
171
- # the aligned activity row (its step anchor); otherwise it opens the record
172
- # page (no such anchor exists). Unresolved references render as broken-link
173
- # spans (ADR-194 also reports them on the console).
174
- def depends_on_html(work_item)
175
- return "\t\t<td></td>\n" if work_item.depends_on_refs.empty?
120
+ # Render each authored Depends On reference as a link to the referenced
121
+ # record's page, resolved against the project-wide link registry. Unresolved
122
+ # references render as broken-link spans.
123
+ def depends_on_html(row)
124
+ return "\t\t<td></td>\n" if row[:depends_on].empty?
176
125
 
177
- links = work_item.depends_on_refs.map { |ref| dependency_ref_html(ref, work_item) }
126
+ links = row[:depends_on].map { |ref| dependency_ref_html(ref) }
178
127
  "\t\t<td>#{links.join(', ')}</td>\n"
179
128
  end
180
129
 
181
- def dependency_ref_html(ref, work_item)
182
- dep = work_item.resolved_dependencies[ref]
183
- return "<span class=\"broken_link\" title=\"Unresolved Depends On\">#{ref}</span>" if dep.nil?
184
-
185
- href = RelativeUrl.between(@parent_doc.output_rel_path, dep[:doc].output_rel_path, fragment: dep[:anchor])
186
- "<a href=\"#{href}\" class=\"external\" title=\"Depends on #{dep[:label]}\">#{ref}</a>"
130
+ def dependency_ref_html(ref)
131
+ target = TextLine.link_registry&.find_by_id(ref)
132
+ if target.nil? || target.output_rel_path.nil?
133
+ "<span class=\"broken_link\" title=\"Unresolved Depends On\">#{ref}</span>"
134
+ else
135
+ href = RelativeUrl.between(@parent_doc.output_rel_path, target.output_rel_path)
136
+ "<a href=\"#{href}\" class=\"external\" title=\"Depends on #{target.id}\">#{ref}</a>"
137
+ end
187
138
  end
188
139
  end
@@ -250,13 +250,14 @@ class DocParser
250
250
  if temp_md_table
251
251
  if temp_md_table.is_separator_detected # if there is a separator
252
252
  # A Decision Record's Scope table is parsed into a purpose-built
253
- # ScopeTable (ADR-194); a Protocol, or a Decision inside an
254
- # "Affected Documents" section, into a ControlledTable.
253
+ # ScopeTable (ADR-194); a Protocol, or a Decision or RiskRecord
254
+ # inside an "Affected Documents" section, into a ControlledTable
255
+ # (ADR-218 extends the Decision path to risk records).
255
256
  if doc.instance_of?(Decision) && in_section?(doc, 'Scope') &&
256
257
  temp_md_table.instance_of?(MarkdownTable)
257
258
  temp_md_table = ScopeTable.new(doc, temp_md_table)
258
259
  elsif doc.instance_of?(Protocol) ||
259
- (doc.instance_of?(Decision) && in_section?(doc, 'Affected Documents'))
260
+ (doc.is_a?(Decision) && in_section?(doc, 'Affected Documents'))
260
261
  # check if it is a controlled table
261
262
  tmp = /(.*)\s+>\[(\S*)\]/.match(row)
262
263
  if tmp && (temp_md_table.instance_of? MarkdownTable)
@@ -6,7 +6,7 @@ class BaseDocument
6
6
  attr_accessor :title, :id, :dom, :headings, :output_rel_path
7
7
 
8
8
  class << self
9
- attr_accessor :show_decisions_link
9
+ attr_accessor :show_decisions_link, :show_risks_link
10
10
  end
11
11
 
12
12
  def initialize
@@ -35,9 +35,9 @@ class BaseDocument
35
35
  "#{@id}.html"
36
36
  elsif instance_of? DecisionsOverview
37
37
  'overview.html'
38
- elsif instance_of? CriticalChainPage
39
- 'critical-chain.html'
40
- elsif instance_of? Decision
38
+ elsif instance_of?(RiskRegistryPage) || instance_of?(RisksOverview)
39
+ 'overview.html'
40
+ elsif is_a? Decision # RiskRecord included
41
41
  "#{@id}.html"
42
42
  else
43
43
  "#{@id}/#{@id}.html"
@@ -68,10 +68,8 @@ class BaseDocument
68
68
  else
69
69
  file.puts index_link(rel_to('index.html'))
70
70
  end
71
- if BaseDocument.show_decisions_link
72
- file.puts decisions_link(rel_to('decisions/overview.html'))
73
- file.puts critical_chain_link(rel_to('decisions/critical-chain.html'))
74
- end
71
+ file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
72
+ file.puts risks_link(rel_to('risks/overview.html')) if BaseDocument.show_risks_link
75
73
  elsif s.include?('{{GEM_VERSION}}')
76
74
  file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
77
75
  else
@@ -86,9 +84,9 @@ class BaseDocument
86
84
  %(<a id="decisions_menu_item" href="#{href}">#{icon}&nbsp;Decision Records</a>)
87
85
  end
88
86
 
89
- def critical_chain_link(href)
90
- icon = '<span><i class="fa fa-link" aria-hidden="true"></i></span>'
91
- %(<a id="critical_chain_menu_item" href="#{href}">#{icon}&nbsp;Critical Chain</a>)
87
+ def risks_link(href)
88
+ icon = '<span><i class="fa fa-exclamation-triangle" aria-hidden="true"></i></span>'
89
+ %(<a id="risks_menu_item" href="#{href}">#{icon}&nbsp;Risks</a>)
92
90
  end
93
91
 
94
92
  def index_link(href)
@@ -9,7 +9,7 @@ require_relative '../doc_items/scope_table'
9
9
  class Decision < PersistentDocument
10
10
  attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
11
11
  :start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash,
12
- :owners, :scope_table
12
+ :owners
13
13
 
14
14
  def initialize(file_path)
15
15
  super
@@ -84,55 +84,6 @@ class Decision < PersistentDocument
84
84
  end
85
85
  end
86
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
87
  def effective_status_on(date)
137
88
  table = find_section_table('Status')
138
89
  return nil if table.nil?
@@ -158,43 +109,6 @@ class Decision < PersistentDocument
158
109
  status.empty? ? nil : status
159
110
  end
160
111
 
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
-
198
112
  private
199
113
 
200
114
  def lookup_cell(section_name:, key_column:, value_column:, key:)
@@ -258,45 +172,6 @@ class Decision < PersistentDocument
258
172
  nil
259
173
  end
260
174
 
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
-
300
175
  def assign_id_parts(stem)
301
176
  match = stem.match(/\A([A-Za-z]+)-(\d+)/)
302
177
  if match