Almirah 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/almirah/doc_fabric.rb +0 -6
- data/lib/almirah/doc_items/scope_table.rb +39 -88
- data/lib/almirah/doc_types/base_document.rb +1 -11
- data/lib/almirah/doc_types/decision.rb +1 -126
- data/lib/almirah/doc_types/decisions_overview.rb +5 -555
- data/lib/almirah/doc_types/planning_dates.rb +2 -3
- data/lib/almirah/project/project_data.rb +1 -4
- data/lib/almirah/project.rb +0 -122
- data/lib/almirah/project_configuration.rb +0 -99
- data/lib/almirah/templates/css/main.css +0 -157
- metadata +1 -8
- data/lib/almirah/doc_items/work_item.rb +0 -129
- data/lib/almirah/doc_types/critical_chain_page.rb +0 -218
- data/lib/almirah/doc_types/decision_grouping.rb +0 -17
- data/lib/almirah/project/critical_chain.rb +0 -117
- data/lib/almirah/project/fever_chart.rb +0 -94
- data/lib/almirah/project/work_item_scheduler.rb +0 -167
- data/lib/almirah/project/working_calendar.rb +0 -112
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7ecb1c0f1633226eaccd34b70d7c45c6d00e6f8ec4b605231f71b7bea725dcd
|
|
4
|
+
data.tar.gz: a223709af5754031b0efef8c443fd158fad6c2c3fde08fbba83233e82075d82a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d57afaec80f6e7370aec60e254f6650832ed2808a2b1550efc7a5271f69bd4e2bbce2e636eb2f7128b1a8ccee6e2bf978e0ca032ad275c399bbc334c12c57ab
|
|
7
|
+
data.tar.gz: a5e6de48b94a5de8a77ab8cb7e744116b3c9ecb5b92e3f81d1231c9436e412d9f1e53e97f2db9c2dc733f3dfdda6966e305b3b7faf9963453d1abff970358985
|
data/lib/almirah/doc_fabric.rb
CHANGED
|
@@ -10,7 +10,6 @@ require_relative 'doc_types/risk_record'
|
|
|
10
10
|
require_relative 'doc_types/risk_registry_page'
|
|
11
11
|
require_relative 'doc_types/risks_overview'
|
|
12
12
|
require_relative 'doc_types/decisions_overview'
|
|
13
|
-
require_relative 'doc_types/critical_chain_page'
|
|
14
13
|
require_relative 'doc_parser'
|
|
15
14
|
require_relative 'source_file_parser'
|
|
16
15
|
require_relative 'dom/document'
|
|
@@ -72,7 +71,6 @@ class DocFabric
|
|
|
72
71
|
doc.extract_target_date
|
|
73
72
|
doc.extract_target_release_version
|
|
74
73
|
doc.extract_owners
|
|
75
|
-
doc.extract_scope_table
|
|
76
74
|
doc
|
|
77
75
|
end
|
|
78
76
|
|
|
@@ -95,10 +93,6 @@ class DocFabric
|
|
|
95
93
|
DecisionsOverview.new project
|
|
96
94
|
end
|
|
97
95
|
|
|
98
|
-
def self.create_critical_chain_page(project)
|
|
99
|
-
CriticalChainPage.new project
|
|
100
|
-
end
|
|
101
|
-
|
|
102
96
|
def self.create_source_file(repository_path, path, repository_name)
|
|
103
97
|
doc = SourceFile.new repository_path, path, repository_name
|
|
104
98
|
DocFabric.parse_source_file doc
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'date'
|
|
4
3
|
require_relative 'controlled_table'
|
|
5
|
-
require_relative '
|
|
4
|
+
require_relative '../relative_url'
|
|
6
5
|
|
|
7
6
|
# Purpose-built controlled table for a Decision Record's `# Scope` section
|
|
8
|
-
# (ADR-
|
|
9
|
-
# header text (not position)
|
|
10
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
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
|
-
@
|
|
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, @
|
|
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('#'),
|
|
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
|
-
@
|
|
76
|
-
|
|
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,
|
|
89
|
+
def row_html(cells, row)
|
|
141
90
|
s = +"\t<tr>\n"
|
|
142
|
-
cells.each_with_index { |value, index| s << cell_html(value, index,
|
|
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,
|
|
148
|
-
return depends_on_html(
|
|
149
|
-
return step_cell_html(value,
|
|
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 (
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
170
|
-
# record
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
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 =
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
@@ -35,8 +35,6 @@ 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
38
|
elsif instance_of?(RiskRegistryPage) || instance_of?(RisksOverview)
|
|
41
39
|
'overview.html'
|
|
42
40
|
elsif is_a? Decision # RiskRecord included
|
|
@@ -70,10 +68,7 @@ class BaseDocument
|
|
|
70
68
|
else
|
|
71
69
|
file.puts index_link(rel_to('index.html'))
|
|
72
70
|
end
|
|
73
|
-
if BaseDocument.show_decisions_link
|
|
74
|
-
file.puts decisions_link(rel_to('decisions/overview.html'))
|
|
75
|
-
file.puts critical_chain_link(rel_to('decisions/critical-chain.html'))
|
|
76
|
-
end
|
|
71
|
+
file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
|
|
77
72
|
file.puts risks_link(rel_to('risks/overview.html')) if BaseDocument.show_risks_link
|
|
78
73
|
elsif s.include?('{{GEM_VERSION}}')
|
|
79
74
|
file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
|
|
@@ -89,11 +84,6 @@ class BaseDocument
|
|
|
89
84
|
%(<a id="decisions_menu_item" href="#{href}">#{icon} Decision Records</a>)
|
|
90
85
|
end
|
|
91
86
|
|
|
92
|
-
def critical_chain_link(href)
|
|
93
|
-
icon = '<span><i class="fa fa-link" aria-hidden="true"></i></span>'
|
|
94
|
-
%(<a id="critical_chain_menu_item" href="#{href}">#{icon} Critical Chain</a>)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
87
|
def risks_link(href)
|
|
98
88
|
icon = '<span><i class="fa fa-exclamation-triangle" aria-hidden="true"></i></span>'
|
|
99
89
|
%(<a id="risks_menu_item" href="#{href}">#{icon} Risks</a>)
|
|
@@ -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
|
|
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
|