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 +4 -4
- data/lib/almirah/doc_fabric.rb +18 -6
- data/lib/almirah/doc_items/controlled_paragraph.rb +2 -2
- data/lib/almirah/doc_items/scope_table.rb +39 -88
- data/lib/almirah/doc_parser.rb +4 -3
- data/lib/almirah/doc_types/base_document.rb +9 -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/doc_types/risk_record.rb +77 -0
- data/lib/almirah/doc_types/risk_registry_page.rb +141 -0
- data/lib/almirah/doc_types/risks_overview.rb +102 -0
- data/lib/almirah/doc_types/rpn_rendering.rb +23 -0
- data/lib/almirah/project/project_data.rb +10 -4
- data/lib/almirah/project.rb +161 -122
- data/lib/almirah/project_configuration.rb +36 -85
- data/lib/almirah/templates/css/main.css +13 -158
- metadata +6 -9
- 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 -166
- data/lib/almirah/project/working_calendar.rb +0 -112
|
@@ -147,163 +147,6 @@ table.controlled td.item_text{
|
|
|
147
147
|
max-width: 100%;
|
|
148
148
|
max-height: 300px;
|
|
149
149
|
}
|
|
150
|
-
.workitem_gantt {
|
|
151
|
-
--gantt-owner-width: 120px;
|
|
152
|
-
--gantt-day-width: 26px;
|
|
153
|
-
--gantt-month-h: 18px;
|
|
154
|
-
overflow: auto;
|
|
155
|
-
max-height: 420px;
|
|
156
|
-
margin-bottom: 16px;
|
|
157
|
-
border: 1px solid #e1e4e8;
|
|
158
|
-
}
|
|
159
|
-
.workitem_gantt .gantt_grid {
|
|
160
|
-
display: grid;
|
|
161
|
-
gap: 0;
|
|
162
|
-
width: max-content;
|
|
163
|
-
padding: 4px;
|
|
164
|
-
}
|
|
165
|
-
.workitem_gantt .gantt_corner,
|
|
166
|
-
.workitem_gantt .gantt_owner,
|
|
167
|
-
.workitem_gantt .gantt_owner_tracking,
|
|
168
|
-
.workitem_gantt .gantt_band_corner,
|
|
169
|
-
.workitem_gantt .gantt_buffer {
|
|
170
|
-
position: sticky;
|
|
171
|
-
left: 0;
|
|
172
|
-
background: #fff;
|
|
173
|
-
padding: 2px 8px;
|
|
174
|
-
font-weight: 600;
|
|
175
|
-
white-space: nowrap;
|
|
176
|
-
border-right: 1px solid #e1e4e8;
|
|
177
|
-
z-index: 2;
|
|
178
|
-
}
|
|
179
|
-
.workitem_gantt .gantt_corner {
|
|
180
|
-
top: 0;
|
|
181
|
-
border-bottom: 1px solid #bbb;
|
|
182
|
-
z-index: 3;
|
|
183
|
-
}
|
|
184
|
-
.workitem_gantt .gantt_month_head {
|
|
185
|
-
position: sticky;
|
|
186
|
-
top: 0;
|
|
187
|
-
background: #fff;
|
|
188
|
-
text-align: center;
|
|
189
|
-
font-size: 11px;
|
|
190
|
-
font-weight: 600;
|
|
191
|
-
color: #57606a;
|
|
192
|
-
border-bottom: 1px solid #e1e4e8;
|
|
193
|
-
border-left: 1px solid #d0d7de;
|
|
194
|
-
white-space: nowrap;
|
|
195
|
-
overflow: hidden;
|
|
196
|
-
z-index: 1;
|
|
197
|
-
}
|
|
198
|
-
.workitem_gantt .gantt_day_head {
|
|
199
|
-
position: sticky;
|
|
200
|
-
top: var(--gantt-month-h);
|
|
201
|
-
background: #fff;
|
|
202
|
-
text-align: center;
|
|
203
|
-
font-size: 11px;
|
|
204
|
-
color: #57606a;
|
|
205
|
-
border-bottom: 1px solid #bbb;
|
|
206
|
-
z-index: 1;
|
|
207
|
-
}
|
|
208
|
-
/* Weekday-holiday highlight in the Chart.js palette red (ADR-205 amendment):
|
|
209
|
-
the column carries the buffer bar's diagonal stripe texture, in red; the
|
|
210
|
-
header stays a solid tint so the day number reads. */
|
|
211
|
-
.workitem_gantt .gantt_day_head.gantt_nonworking { background: rgba(255, 99, 132, 0.22); color: #b23a52; }
|
|
212
|
-
.workitem_gantt .gantt_day_head.gantt_friday { background: rgb(225, 241, 250); }
|
|
213
|
-
.workitem_gantt .gantt_nonworking_col {
|
|
214
|
-
background: repeating-linear-gradient(45deg,
|
|
215
|
-
rgba(255, 99, 132, 0.20), rgba(255, 99, 132, 0.20) 5px,
|
|
216
|
-
rgba(255, 99, 132, 0.07) 5px, rgba(255, 99, 132, 0.07) 10px);
|
|
217
|
-
}
|
|
218
|
-
.workitem_gantt .gantt_friday_col { background: rgb(225, 241, 250); }
|
|
219
|
-
.workitem_gantt .gantt_bar,
|
|
220
|
-
.workitem_gantt .gantt_track_bar {
|
|
221
|
-
font-size: 11px;
|
|
222
|
-
line-height: 1.5;
|
|
223
|
-
margin: 1px;
|
|
224
|
-
padding: 1px 5px;
|
|
225
|
-
border-radius: 3px;
|
|
226
|
-
overflow: hidden;
|
|
227
|
-
white-space: nowrap;
|
|
228
|
-
text-overflow: ellipsis;
|
|
229
|
-
color: #fff;
|
|
230
|
-
cursor: default;
|
|
231
|
-
}
|
|
232
|
-
.workitem_gantt .gantt_todo { background: #9aa5b1; }
|
|
233
|
-
.workitem_gantt .gantt_inprogress { background: #ffffdd; color: #333; box-shadow: inset 0 0 0 1px #bbb; }
|
|
234
|
-
.workitem_gantt .gantt_done { background: #cfc; color: #57606a; box-shadow: inset 0 0 0 1px #e1e4e8; }
|
|
235
|
-
.workitem_gantt .gantt_blocked { box-shadow: inset 0 0 0 1px #bbb; }
|
|
236
|
-
.workitem_gantt .gantt_critical { outline: 1px solid #9a6700; outline-offset: -1px; } /* buffer-bar amber (dark) */
|
|
237
|
-
.workitem_gantt .gantt_release_band {
|
|
238
|
-
text-align: center;
|
|
239
|
-
font-size: 11px;
|
|
240
|
-
font-weight: 600;
|
|
241
|
-
color: #57606a;
|
|
242
|
-
background: #eef1f4;
|
|
243
|
-
border-bottom: 1px solid #e1e4e8;
|
|
244
|
-
border-left: 1px solid #d0d7de;
|
|
245
|
-
padding: 2px 4px;
|
|
246
|
-
white-space: nowrap;
|
|
247
|
-
overflow: hidden;
|
|
248
|
-
text-overflow: ellipsis;
|
|
249
|
-
}
|
|
250
|
-
.workitem_gantt .gantt_buffer { color: #9a6700; }
|
|
251
|
-
.critical_chain { margin-bottom: 16px; }
|
|
252
|
-
.critical_chain h2 { font-size: 1.1em; margin-bottom: 6px; }
|
|
253
|
-
.critical_chain .cc_group { margin: 8px 0; }
|
|
254
|
-
.critical_chain .cc_group h3 { font-size: 0.95em; margin: 4px 0; }
|
|
255
|
-
.critical_chain table.cc_chain { border-collapse: collapse; margin: 4px 0; }
|
|
256
|
-
.critical_chain table.cc_chain th,
|
|
257
|
-
.critical_chain table.cc_chain td { border: 1px solid #e1e4e8; padding: 2px 8px; text-align: left; font-size: 12px; }
|
|
258
|
-
.critical_chain .cc_buffer,
|
|
259
|
-
.critical_chain .cc_consumed,
|
|
260
|
-
.critical_chain .cc_projected,
|
|
261
|
-
.critical_chain .cc_finish { margin: 2px 0; font-size: 12px; }
|
|
262
|
-
.critical_chain .cc_consumed { color: #57606a; }
|
|
263
|
-
.critical_chain .cc_finish { font-weight: 600; }
|
|
264
|
-
.critical_chain .cc_unestimated { color: #9a6700; font-style: italic; font-size: 12px; }
|
|
265
|
-
.critical_chain .cc_group_body { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; }
|
|
266
|
-
.critical_chain .cc_plan { flex: 1 1 320px; }
|
|
267
|
-
.critical_chain .cc_fever { flex: 1 1 360px; max-width: 480px; height: 320px; position: relative; }
|
|
268
|
-
.critical_chain .cc_fever .fever_canvas { width: 100%; height: 100%; }
|
|
269
|
-
.workitem_gantt .gantt_buffer_bar {
|
|
270
|
-
font-size: 11px;
|
|
271
|
-
line-height: 1.5;
|
|
272
|
-
margin: 1px;
|
|
273
|
-
padding: 1px 5px;
|
|
274
|
-
border-radius: 3px;
|
|
275
|
-
text-align: center;
|
|
276
|
-
overflow: hidden;
|
|
277
|
-
white-space: nowrap;
|
|
278
|
-
text-overflow: ellipsis;
|
|
279
|
-
color: #9a6700;
|
|
280
|
-
background: repeating-linear-gradient(45deg, #ffe9b3, #ffe9b3 5px, #fff3d4 5px, #fff3d4 10px);
|
|
281
|
-
box-shadow: inset 0 0 0 1px #e0c068;
|
|
282
|
-
cursor: default;
|
|
283
|
-
}
|
|
284
|
-
.workitem_gantt .gantt_today { border-left: 2px solid #d33; z-index: 5; pointer-events: none; }
|
|
285
|
-
/* Plan-vs-actual tracking lanes (ADR-213): hidden until the toolbar toggle adds
|
|
286
|
-
show-actuals; a hidden lane's grid tracks collapse to zero height. */
|
|
287
|
-
.workitem_gantt .gantt_tracking_lane { display: none; }
|
|
288
|
-
.workitem_gantt.show-actuals .gantt_tracking_lane { display: block; }
|
|
289
|
-
.workitem_gantt .gantt_owner_tracking { font-weight: 400; font-style: italic; color: #57606a; }
|
|
290
|
-
.workitem_gantt .gantt_track_committed { background: #b0b6bd; }
|
|
291
|
-
.workitem_gantt .gantt_track_logged { background: #36a2eb; }
|
|
292
|
-
.gantt_container .gantt_toolbar {
|
|
293
|
-
display: flex;
|
|
294
|
-
justify-content: flex-end;
|
|
295
|
-
gap: 6px;
|
|
296
|
-
padding: 4px 0;
|
|
297
|
-
}
|
|
298
|
-
.gantt_container .gantt_actuals_toggle {
|
|
299
|
-
font-size: 12px;
|
|
300
|
-
padding: 3px 10px;
|
|
301
|
-
border: 1px solid #d0d7de;
|
|
302
|
-
border-radius: 4px;
|
|
303
|
-
background: #f6f8fa;
|
|
304
|
-
cursor: pointer;
|
|
305
|
-
}
|
|
306
|
-
.gantt_container .gantt_actuals_toggle:hover { background: #eef1f4; }
|
|
307
150
|
table.controlled:not(.odd-even) tbody tr:nth-child(odd) { background-color:#f6f7f8; }
|
|
308
151
|
table.controlled:not(.odd-even) tbody tr:nth-child(even) { background-color: #fff; }
|
|
309
152
|
table.controlled:not(.odd-even) tbody tr:nth-child(odd):hover,
|
|
@@ -505,4 +348,16 @@ img{
|
|
|
505
348
|
padding: 5px;
|
|
506
349
|
text-align:center;
|
|
507
350
|
background:#fff;
|
|
508
|
-
}
|
|
351
|
+
}/* Risk register RPN threshold bands (ADR-217): acceptable / ALARP caution / unacceptable */
|
|
352
|
+
td.item_rpn {
|
|
353
|
+
text-align: center;
|
|
354
|
+
}
|
|
355
|
+
td.rpn_acceptable {
|
|
356
|
+
background-color: #d5f5e3;
|
|
357
|
+
}
|
|
358
|
+
td.rpn_caution {
|
|
359
|
+
background-color: #fdebd0;
|
|
360
|
+
}
|
|
361
|
+
td.rpn_unacceptable {
|
|
362
|
+
background-color: #fadbd8;
|
|
363
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: Almirah
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oleksandr Ivanov
|
|
@@ -77,19 +77,20 @@ files:
|
|
|
77
77
|
- lib/almirah/doc_items/source_code_paragraph.rb
|
|
78
78
|
- lib/almirah/doc_items/text_line.rb
|
|
79
79
|
- lib/almirah/doc_items/todo_block.rb
|
|
80
|
-
- lib/almirah/doc_items/work_item.rb
|
|
81
80
|
- lib/almirah/doc_parser.rb
|
|
82
81
|
- lib/almirah/doc_types/base_document.rb
|
|
83
82
|
- lib/almirah/doc_types/coverage.rb
|
|
84
|
-
- lib/almirah/doc_types/critical_chain_page.rb
|
|
85
83
|
- lib/almirah/doc_types/decision.rb
|
|
86
|
-
- lib/almirah/doc_types/decision_grouping.rb
|
|
87
84
|
- lib/almirah/doc_types/decisions_overview.rb
|
|
88
85
|
- lib/almirah/doc_types/implementation.rb
|
|
89
86
|
- lib/almirah/doc_types/index.rb
|
|
90
87
|
- lib/almirah/doc_types/persistent_document.rb
|
|
91
88
|
- lib/almirah/doc_types/planning_dates.rb
|
|
92
89
|
- lib/almirah/doc_types/protocol.rb
|
|
90
|
+
- lib/almirah/doc_types/risk_record.rb
|
|
91
|
+
- lib/almirah/doc_types/risk_registry_page.rb
|
|
92
|
+
- lib/almirah/doc_types/risks_overview.rb
|
|
93
|
+
- lib/almirah/doc_types/rpn_rendering.rb
|
|
93
94
|
- lib/almirah/doc_types/source_file.rb
|
|
94
95
|
- lib/almirah/doc_types/specification.rb
|
|
95
96
|
- lib/almirah/doc_types/traceability.rb
|
|
@@ -99,12 +100,8 @@ files:
|
|
|
99
100
|
- lib/almirah/link_registry.rb
|
|
100
101
|
- lib/almirah/navigation_pane.rb
|
|
101
102
|
- lib/almirah/project.rb
|
|
102
|
-
- lib/almirah/project/critical_chain.rb
|
|
103
103
|
- lib/almirah/project/doc_linker.rb
|
|
104
|
-
- lib/almirah/project/fever_chart.rb
|
|
105
104
|
- lib/almirah/project/project_data.rb
|
|
106
|
-
- lib/almirah/project/work_item_scheduler.rb
|
|
107
|
-
- lib/almirah/project/working_calendar.rb
|
|
108
105
|
- lib/almirah/project_configuration.rb
|
|
109
106
|
- lib/almirah/project_template.rb
|
|
110
107
|
- lib/almirah/project_utility.rb
|
|
@@ -134,7 +131,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
134
131
|
- !ruby/object:Gem::Version
|
|
135
132
|
version: '0'
|
|
136
133
|
requirements: []
|
|
137
|
-
rubygems_version: 4.0.
|
|
134
|
+
rubygems_version: 4.0.1
|
|
138
135
|
specification_version: 4
|
|
139
136
|
summary: Almirah
|
|
140
137
|
test_files: []
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# A single Scope-table row of a Decision Record, modelled as a node in the
|
|
4
|
-
# per-row dependency network (ADR-194). Each work item carries its canonical
|
|
5
|
-
# identity (`<record>.<step>.<activity>`), the bounded per-row Status it reads
|
|
6
|
-
# its readiness from (ADR-193), and predecessor / successor edges spanning both
|
|
7
|
-
# intra-record (step order) and cross-record (Depends On) links.
|
|
8
|
-
#
|
|
9
|
-
# `predecessors` / `successors` follow the house list-of-single-key-hashes shape
|
|
10
|
-
# (ADR-197): each entry maps a readable `record.step.activity` label to the
|
|
11
|
-
# resolved WorkItem. Every cross-record edge additionally records whether it
|
|
12
|
-
# stays within a decision_groups folder (in-group) or crosses to another
|
|
13
|
-
# (cross-group); that tag is inert here and consumed by the critical-chain step
|
|
14
|
-
# (ADR-195). Readiness ignores it — a cross-group predecessor blocks exactly
|
|
15
|
-
# like an in-group one.
|
|
16
|
-
class WorkItem
|
|
17
|
-
# Canonical phase order used only as the fallback for activity-type-aligned
|
|
18
|
-
# resolution when the prerequisite has no row of the dependent's exact Item.
|
|
19
|
-
ACTIVITY_ORDER = %w[Analysis Requirements Code Tests].freeze
|
|
20
|
-
|
|
21
|
-
attr_reader :record_id, :step, :activity, :owner, :status, :depends_on_refs,
|
|
22
|
-
:focused_estimate, :safe_estimate, :record_sequence, :start_date, :target_date
|
|
23
|
-
attr_accessor :predecessors, :successors, :cross_group_predecessor_labels, :resolved_dependencies
|
|
24
|
-
|
|
25
|
-
def initialize(record_id:, step:, activity:, owner:, status:, depends_on_refs:, # rubocop:disable Metrics/ParameterLists
|
|
26
|
-
focused_estimate: 0.0, safe_estimate: 0.0, record_sequence: 0,
|
|
27
|
-
start_date: nil, target_date: nil)
|
|
28
|
-
@record_id = record_id
|
|
29
|
-
@step = step
|
|
30
|
-
@activity = activity.to_s.strip
|
|
31
|
-
@owner = owner.to_s.strip
|
|
32
|
-
@status = status.to_s.strip
|
|
33
|
-
@depends_on_refs = depends_on_refs # array of record ref strings, e.g. ["ADR-193"]
|
|
34
|
-
# The row's authored committed window (ADR-213): the Scope Start Date and
|
|
35
|
-
# Target Date as parsed Dates (nil when absent/unparseable). Distinct from the
|
|
36
|
-
# computed schedule -- these are the author's intent, drawn on the tracking lane.
|
|
37
|
-
@start_date = start_date
|
|
38
|
-
@target_date = target_date
|
|
39
|
-
# Per-row scheduling estimates in working days (ADR-195); the focused estimate
|
|
40
|
-
# is the scheduling duration, safe - focused the safety aggregated into the
|
|
41
|
-
# project buffer. record_sequence is the owning record's number, used only as
|
|
42
|
-
# a deterministic priority tiebreak in the critical-chain scheduler.
|
|
43
|
-
@focused_estimate = focused_estimate
|
|
44
|
-
@safe_estimate = safe_estimate
|
|
45
|
-
@record_sequence = record_sequence
|
|
46
|
-
@predecessors = []
|
|
47
|
-
@successors = []
|
|
48
|
-
@cross_group_predecessor_labels = []
|
|
49
|
-
# One entry per resolved Depends On reference, keyed by the authored ref:
|
|
50
|
-
# { ref => { doc:, anchor:, label: } }. `anchor` is the target's resolved
|
|
51
|
-
# work-item row anchor (nil when the target has no `#` step column, so the
|
|
52
|
-
# link opens the record page); `label` is the resolved work item's id, for
|
|
53
|
-
# the link tooltip. Used to render the Depends On cell as deep links.
|
|
54
|
-
@resolved_dependencies = {}
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def id
|
|
58
|
-
"#{@record_id}.#{@step}.#{@activity}"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The anchor of this work item's row in its rendered Scope table — the deep-link
|
|
62
|
-
# target a dependent record points at. Namespaced with `.scope.` so it never
|
|
63
|
-
# collides with the Affected Documents controlled table, whose rows anchor on
|
|
64
|
-
# the same `<record>.<step>` scheme on the same page.
|
|
65
|
-
def row_anchor
|
|
66
|
-
"#{@record_id}.scope.#{@step}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def add_resolved_dependency(ref, doc, anchor, label)
|
|
70
|
-
@resolved_dependencies[ref] = { doc: doc, anchor: anchor, label: label }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def activity_rank
|
|
74
|
-
ACTIVITY_ORDER.index(@activity) || ACTIVITY_ORDER.length
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# A row is "started" once it leaves To Do; both In-Progress and Done count, so
|
|
78
|
-
# a Done row whose predecessor never finished is still flagged as a violation.
|
|
79
|
-
def started?
|
|
80
|
-
@status == 'In-Progress' || @status == 'Done'
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def done?
|
|
84
|
-
@status == 'Done'
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def add_predecessor(work_item, cross_group:)
|
|
88
|
-
@predecessors << { work_item.id => work_item }
|
|
89
|
-
@cross_group_predecessor_labels << work_item.id if cross_group
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def add_successor(work_item)
|
|
93
|
-
@successors << { work_item.id => work_item }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def predecessor_items
|
|
97
|
-
@predecessors.map { |edge| edge.values.first }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def successor_items
|
|
101
|
-
@successors.map { |edge| edge.values.first }
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Same-record, lower-numbered steps — the intra-record (phase-order) edges.
|
|
105
|
-
def intra_record_predecessors
|
|
106
|
-
predecessor_items.select { |p| p.record_id == @record_id }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Resolved Depends On edges into other records (in-group or cross-group alike).
|
|
110
|
-
def cross_record_predecessors
|
|
111
|
-
predecessor_items.reject { |p| p.record_id == @record_id }
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Kitted when every predecessor — intra- and cross-record, regardless of the
|
|
115
|
-
# in-group / cross-group tag — is Done; trivially kitted when it has none.
|
|
116
|
-
def fully_kitted?
|
|
117
|
-
predecessor_items.all?(&:done?)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# A started row with an unfinished lower-numbered step in the same record.
|
|
121
|
-
def phase_order_violation?
|
|
122
|
-
started? && intra_record_predecessors.any? { |p| !p.done? }
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# A started row whose resolved cross-record predecessor is not yet Done.
|
|
126
|
-
def cross_record_violation?
|
|
127
|
-
started? && cross_record_predecessors.any? { |p| !p.done? }
|
|
128
|
-
end
|
|
129
|
-
end
|
|
@@ -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
|