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
@@ -3,8 +3,18 @@
3
3
  require 'date'
4
4
  require 'json'
5
5
  require_relative 'base_document'
6
+ require_relative 'decision_grouping'
7
+ require_relative 'planning_dates'
8
+ require_relative '../html_safe'
9
+ require_relative '../project/work_item_scheduler'
10
+ require_relative '../project/critical_chain'
11
+ require_relative '../project/working_calendar'
12
+
13
+ class DecisionsOverview < BaseDocument
14
+ include HtmlSafe
15
+ include DecisionGrouping
16
+ include PlanningDates
6
17
 
7
- class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Metrics/ClassLength
8
18
  attr_accessor :project
9
19
 
10
20
  def initialize(project)
@@ -14,16 +24,21 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
14
24
  @id = 'overview'
15
25
  end
16
26
 
27
+ def needs_chartjs?
28
+ true
29
+ end
30
+
17
31
  def to_console
18
32
  puts "\e[36mDecisions Overview: #{@id}\e[0m"
19
33
  end
20
34
 
21
- def to_html(output_file_path) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
35
+ def to_html(output_file_path)
22
36
  html_rows = []
23
37
  html_rows.append('')
24
38
  html_rows.append "<h1>#{@title}</h1>\n"
25
39
 
26
40
  html_rows.append render_charts_grid
41
+ html_rows.append render_workitem_gantt
27
42
 
28
43
  html_rows.append "<table class=\"controlled decisions_overview\">\n"
29
44
  html_rows.append "\t<thead>\n"
@@ -35,6 +50,7 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
35
50
  html_rows.append "\t\t<th>Target Date</th>\n"
36
51
  html_rows.append "\t\t<th title=\"Target Release Version\">Release</th>\n"
37
52
  html_rows.append "\t\t<th>Owner</th>\n"
53
+ html_rows.append "\t\t<th title=\"Cross-record full-kit readiness\">Kit</th>\n"
38
54
  html_rows.append "</thead>\n"
39
55
 
40
56
  sorted_items = @project.project_data.decisions.sort_by do |d|
@@ -57,7 +73,8 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
57
73
  target_date_html = doc.target_date ? doc.target_date.strftime('%d-%m-%Y') : ''
58
74
  s += "\t\t<td class=\"item_meta\">#{target_date_html}</td>\n"
59
75
  s += "\t\t<td class=\"item_meta\">#{doc.target_release_version}</td>\n"
60
- s += "\t\t<td class=\"item_meta\"></td>\n"
76
+ s += "\t\t<td class=\"item_meta\">#{doc.owners.join(', ')}</td>\n"
77
+ s += kit_overview_cell(doc)
61
78
  s += "</tr>\n"
62
79
  html_rows.append s
63
80
  end
@@ -68,6 +85,446 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
68
85
 
69
86
  private
70
87
 
88
+ # Cross-record full-kit readiness for a record (ADR-194), rendered as text:
89
+ # empty when the record declares no Depends On prerequisites, "Ready" when
90
+ # every Scope row is kitted, "Blocked" (warning colour) otherwise. A record
91
+ # blocked while having a started row (a cross-record violation) is emphasised,
92
+ # matching the console warning.
93
+ def kit_overview_cell(doc)
94
+ return "\t\t<td class=\"item_kit\"></td>\n" unless doc.declared_dependencies?
95
+ return "\t\t<td class=\"item_kit kit_ready\">Ready</td>\n" if doc.fully_kitted?
96
+
97
+ weight = doc.kit_started_violation? ? ' font-weight: bold;' : ''
98
+ "\t\t<td class=\"item_kit kit_blocked\" style=\"color: #c0392b;#{weight}\">Blocked</td>\n"
99
+ end
100
+
101
+ # Gutter columns between adjacent group blocks so they never abut (ADR-201).
102
+ GANTT_GUTTER_DAYS = 1
103
+
104
+ # The group-segmented resource-swimlane Gantt of the WorkItem network (ADR-198,
105
+ # segmented by ADR-201), placed between the charts grid and the records table.
106
+ # Each decision group (ADR-197) becomes a block laid left to right; within a
107
+ # block, one lane per owner (the shared WIP roster) carries a constant-duration
108
+ # bar per work item positioned by WorkItemScheduler. A group band row labels
109
+ # each block and a Buffer lane closes the grid below the owner lanes. Omitted
110
+ # when there is nothing to schedule.
111
+ def render_workitem_gantt
112
+ blocks = gantt_blocks
113
+ owners = consensus_owner_order
114
+ return '' if blocks.empty? || owners.empty?
115
+
116
+ total_days = blocks.map { |b| b[:offset] + b[:cal_width] }.max
117
+ grid = gantt_grid(owners, blocks, total_days)
118
+ grid += gantt_pulse_script if gantt_any_blocked?(blocks)
119
+ %(<div class="gantt_container">\n) + gantt_toolbar + grid + %(</div>\n)
120
+ end
121
+
122
+ # The control strip above the chart (ADR-213): a right-aligned button that
123
+ # toggles the tracking lanes by flipping the `show-actuals` class on the chart
124
+ # and swapping its own label. Sits outside the scrolling grid so it stays in
125
+ # view; future Gantt controls share this strip.
126
+ def gantt_toolbar
127
+ toggle = "var g=this.closest('.gantt_container').querySelector('.workitem_gantt');" \
128
+ "this.textContent=g.classList.toggle('show-actuals')?'Hide Actuals':'Show Actuals';"
129
+ %(\t<div class="gantt_toolbar">\n) +
130
+ %(\t\t<button type="button" class="gantt_actuals_toggle" onclick="#{toggle}">Show Actuals</button>\n) +
131
+ %(\t</div>\n)
132
+ end
133
+
134
+ # True when any scheduled work item is a started-but-blocked cross-record
135
+ # violation, so the pulse script is worth emitting.
136
+ def gantt_any_blocked?(blocks)
137
+ blocks.any? { |b| b[:items].any?(&:cross_record_violation?) }
138
+ end
139
+
140
+ # One block per decision group (ADR-197) that has work items, in decision_groups
141
+ # (folder-encounter) order. Each block runs WorkItemScheduler over only its own
142
+ # items, so a cross-group predecessor falls away as an already-available input
143
+ # (ADR-201); blocks are laid out left to right with a one-column gutter between
144
+ # them, never overlapping. Each block's calendar is anchored at its group's
145
+ # configured planning.groups start date, falling back to the project start date,
146
+ # so the block's day columns are labelled with that group's real dates (ADR-211).
147
+ def gantt_blocks
148
+ ratio = @project.configuration.get_buffer_ratio
149
+ holidays = @project.configuration.get_holidays
150
+ group_starts = @project.configuration.get_group_start_dates
151
+ origin = @project.configuration.get_start_date
152
+ offset = 0
153
+ grouped_work_items.each_with_object([]) do |(name, items), blocks|
154
+ scheduler = GanttScheduler.new(items)
155
+ next if scheduler.day_count.zero?
156
+
157
+ calendar = WorkingCalendar.new(anchor: group_starts[name] || origin, holidays: holidays)
158
+ offset += GANTT_GUTTER_DAYS unless blocks.empty?
159
+ block = gantt_block(name, items, scheduler, offset, ratio, calendar)
160
+ blocks << block
161
+ offset += block[:cal_width]
162
+ end
163
+ end
164
+
165
+ # One block descriptor: its group name, work items, schedule, per-work-item
166
+ # start days, the set of work items on the schedule's critical chain (ADR-212),
167
+ # the working span and computed project buffer (ADR-195), the shared working
168
+ # calendar (ADR-205), the business-day column dates and their count (ADR-206,
169
+ # extended past the schedule to cover authored actuals, ADR-213), and the
170
+ # left-to-right column offset of the block's first day.
171
+ def gantt_block(name, items, scheduler, offset, ratio, calendar) # rubocop:disable Metrics/ParameterLists
172
+ work_days = scheduler.day_count
173
+ buffer = CriticalChain.new(items, buffer_ratio: ratio).buffer
174
+ width = work_days + buffer
175
+ columns = calendar.business_axis(block_column_count(items, calendar, width))
176
+ { name:, items:, scheduler:, starts: scheduler.start_days, chain: scheduler.critical_chain.to_set,
177
+ work_days:, buffer:, width:, calendar:, columns:, cal_width: columns.length, offset: }
178
+ end
179
+
180
+ # The number of business-day columns the block needs: enough for the computed
181
+ # schedule plus buffer, widened so the latest authored actual date (a committed
182
+ # window end or a logged-effort date, ADR-213) still falls on a real column
183
+ # rather than being clipped at the buffer edge.
184
+ def block_column_count(items, calendar, width)
185
+ planned = calendar.business_columns(width).length
186
+ latest = actual_dates(items).max
187
+ actual = latest ? calendar.business_column_for(latest) + 1 : 0
188
+ [planned, actual].max
189
+ end
190
+
191
+ # Slow background pulse for blocked bars (cosmetic; no decision record). A
192
+ # small inline script eases each blocked bar between its own status colour and
193
+ # the Chart.js red over a 2.4s, six-phase cycle. The bar's border is untouched,
194
+ # and the pulse is skipped under prefers-reduced-motion.
195
+ def gantt_pulse_script
196
+ <<~'JS'
197
+ <script>
198
+ (function () {
199
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }
200
+ var target = [255, 99, 132];
201
+ var steps = [0, 1 / 5, 1 / 3, 1, 1 / 3, 1 / 5];
202
+ var bars = document.querySelectorAll('.workitem_gantt .gantt_blocked');
203
+ if (!bars.length) { return; }
204
+ var items = [];
205
+ bars.forEach(function (el) {
206
+ var m = getComputedStyle(el).backgroundColor.match(/\d+/g);
207
+ if (!m) { return; }
208
+ var base = [parseInt(m[0], 10), parseInt(m[1], 10), parseInt(m[2], 10)];
209
+ var phases = steps.map(function (t) {
210
+ return 'rgb(' + Math.round(base[0] + (target[0] - base[0]) * t) + ',' +
211
+ Math.round(base[1] + (target[1] - base[1]) * t) + ',' +
212
+ Math.round(base[2] + (target[2] - base[2]) * t) + ')';
213
+ });
214
+ el.style.transition = 'background-color 0.4s linear';
215
+ items.push({ el: el, phases: phases });
216
+ });
217
+ var i = 0;
218
+ setInterval(function () {
219
+ i = (i + 1) % 6;
220
+ items.forEach(function (it) { it.el.style.backgroundColor = it.phases[i]; });
221
+ }, 400);
222
+ })();
223
+ </script>
224
+ JS
225
+ end
226
+
227
+ # Each owner occupies two adjacent lanes (ADR-213): the planned lane and, right
228
+ # below it, the hidden tracking lane. The three header rows (months, days, group
229
+ # band) precede them, and the Buffer lane closes the grid, so the total row count
230
+ # is owners.length * 2 + 4. The full-height background and today rules span every
231
+ # row; a hidden tracking lane collapses to a zero-height track they cross harmlessly.
232
+ def gantt_grid(owners, blocks, total_days)
233
+ cols = "var(--gantt-owner-width) repeat(#{total_days}, var(--gantt-day-width))"
234
+ rows = [%(<div class="workitem_gantt">\n), %(\t<div class="gantt_grid" style="grid-template-columns: #{cols};">\n)]
235
+ total_rows = owners.length * 2 + 4
236
+ rows.concat(gantt_background_columns(blocks, total_rows))
237
+ rows.concat(gantt_month_header(blocks))
238
+ rows.concat(gantt_day_header(blocks))
239
+ rows.concat(gantt_group_band(blocks))
240
+ owners.each_with_index do |owner, i|
241
+ rows.concat(gantt_lane(owner, 4 + i * 2, blocks))
242
+ rows.concat(gantt_tracking_lane(owner, 5 + i * 2, blocks))
243
+ end
244
+ rows.concat(gantt_buffer_lane(blocks, owners.length * 2 + 4))
245
+ rows.concat(gantt_today_lines(blocks, total_rows))
246
+ rows << "\t</div>\n" << "</div>\n"
247
+ rows.join
248
+ end
249
+
250
+ # The "today" marker (ADR-205 / ADR-211): a thin full-height vertical rule drawn
251
+ # only in the block whose calendar range actually contains the current date, at
252
+ # that date's business-day column. Blocks the current date falls outside (past or
253
+ # future groups) get no rule, so the sequential blocks never sprout edge-clamped
254
+ # lines mid-chart. Emitted last so it paints over the bars; it is inert
255
+ # (pointer-events: none) and never blocks a bar's tooltip.
256
+ def gantt_today_lines(blocks, total_rows)
257
+ today = Date.today
258
+ blocks.filter_map do |b|
259
+ col = gantt_today_column(b, today)
260
+ next unless col
261
+
262
+ style = %(grid-row: 1 / span #{total_rows}; grid-column: #{col};)
263
+ %(\t\t<div class="gantt_today" style="#{style}"></div>\n)
264
+ end
265
+ end
266
+
267
+ # The grid-column of today's business-day column within a block, or nil when the
268
+ # block's calendar range does not contain today (today before its first or after
269
+ # its last column, or the block has no columns).
270
+ def gantt_today_column(block, today)
271
+ dates = block[:columns]
272
+ return nil if dates.empty? || today < dates.first || today > dates.last
273
+
274
+ block[:offset] + dates.index { |d| d >= today } + 2
275
+ end
276
+
277
+ # Full-height background columns behind the business-day axis (ADR-206),
278
+ # spanning the lane rows so the shading shows under the bars: weekday holidays
279
+ # keep the grey non-working shade, working Fridays get the week-rhythm tint.
280
+ # Emitted first so the bars paint on top.
281
+ def gantt_background_columns(blocks, total_rows)
282
+ cells = []
283
+ blocks.each do |b|
284
+ b[:columns].each_with_index do |date, i|
285
+ klass = background_column_class(b[:calendar], date)
286
+ next unless klass
287
+
288
+ style = %(grid-row: 3 / span #{total_rows - 2}; grid-column: #{b[:offset] + i + 2};)
289
+ cells << %(\t\t<div class="#{klass}" style="#{style}"></div>\n)
290
+ end
291
+ end
292
+ cells
293
+ end
294
+
295
+ # Grey for a weekday holiday, the Friday tint for a working Friday, nil
296
+ # otherwise. Weekends never reach here (they are off the business-day axis).
297
+ def background_column_class(calendar, date)
298
+ return 'gantt_nonworking_col' if calendar.non_working?(date)
299
+ return 'gantt_friday_col' if calendar.friday?(date)
300
+
301
+ nil
302
+ end
303
+
304
+ # Month band (row 1): the sticky corner over the Owner column spanning both
305
+ # header rows, then one cell per month spanning that month's business-day
306
+ # columns within each block (ADR-206).
307
+ def gantt_month_header(blocks)
308
+ cells = [%(\t\t<div class="gantt_corner" style="grid-row: 1 / span 2; grid-column: 1;">Owner</div>\n)]
309
+ blocks.each do |b|
310
+ month_spans(b[:columns]).each do |label, start_i, len|
311
+ style = %(grid-row: 1; grid-column: #{b[:offset] + start_i + 2} / span #{len};)
312
+ cells << %(\t\t<div class="gantt_month_head" style="#{style}">#{label}</div>\n)
313
+ end
314
+ end
315
+ cells
316
+ end
317
+
318
+ # Day-of-month row (row 2): one numbered cell per business-day column within
319
+ # each block (weekends omitted, ADR-206), flagged non-working for weekday
320
+ # holidays and tinted for working Fridays.
321
+ def gantt_day_header(blocks)
322
+ cells = []
323
+ blocks.each do |b|
324
+ b[:columns].each_with_index do |date, i|
325
+ col = b[:offset] + i + 2
326
+ klass = "gantt_day_head#{day_head_modifier(b[:calendar], date)}"
327
+ cells << %(\t\t<div class="#{klass}" style="grid-row: 2; grid-column: #{col};">#{date.day}</div>\n)
328
+ end
329
+ end
330
+ cells
331
+ end
332
+
333
+ # The extra class on a day-of-month header cell: non-working for a weekday
334
+ # holiday, the Friday tint for a working Friday, none otherwise.
335
+ def day_head_modifier(calendar, date)
336
+ return ' gantt_nonworking' if calendar.non_working?(date)
337
+ return ' gantt_friday' if calendar.friday?(date)
338
+
339
+ ''
340
+ end
341
+
342
+ # Group consecutive calendar dates by month into [label, start_index, length].
343
+ def month_spans(dates)
344
+ dates.each_with_index.each_with_object([]) do |(date, i), spans|
345
+ label = date.strftime('%b %Y')
346
+ if spans.last && spans.last[0] == label
347
+ spans.last[2] += 1
348
+ else
349
+ spans << [label, i, 1]
350
+ end
351
+ end
352
+ end
353
+
354
+ # The group band row (ADR-201), now row 3 below the two calendar header rows:
355
+ # one labelled cell per block spanning that block's calendar columns, with a
356
+ # sticky-left corner over the Owner column.
357
+ def gantt_group_band(blocks)
358
+ cells = [%(\t\t<div class="gantt_band_corner" style="grid-row: 3; grid-column: 1;"></div>\n)]
359
+ blocks.each do |b|
360
+ style = %(grid-row: 3; grid-column: #{b[:offset] + 2} / span #{b[:cal_width]};)
361
+ cells << %(\t\t<div class="gantt_release_band" style="#{style}">#{escape_text(b[:name])}</div>\n)
362
+ end
363
+ cells
364
+ end
365
+
366
+ # The Buffer lane (ADR-201), the last row below the owner lanes: one buffer bar
367
+ # per block, placed on the calendar columns of the working days immediately
368
+ # after that group's last work item and spanning its buffer (ADR-195/205).
369
+ def gantt_buffer_lane(blocks, row)
370
+ cells = [%(\t\t<div class="gantt_buffer" style="grid-row: #{row}; grid-column: 1;">Buffer</div>\n)]
371
+ blocks.each do |b|
372
+ next if b[:buffer].zero?
373
+
374
+ grid_col, span = calendar_span(b, b[:work_days] + 1, b[:buffer])
375
+ tip = "Project buffer (#{b[:buffer]} working days)"
376
+ style = %(grid-row: #{row}; grid-column: #{grid_col} / span #{span};)
377
+ cells << %(\t\t<div class="gantt_buffer_bar" style="#{style}" title="#{escape_attr(tip)}">buffer</div>\n)
378
+ end
379
+ cells
380
+ end
381
+
382
+ # One owner lane across all blocks: the sticky owner label plus that owner's
383
+ # scheduled bars in each block, offset into the block's day columns.
384
+ def gantt_lane(owner, row, blocks)
385
+ cells = [%(\t\t<div class="gantt_owner" style="grid-row: #{row}; grid-column: 1;">#{escape_text(owner)}</div>\n)]
386
+ blocks.each do |b|
387
+ b[:items].select { |wi| wi.owner == owner }.each do |wi|
388
+ cells << gantt_bar(wi, row, b)
389
+ end
390
+ end
391
+ cells
392
+ end
393
+
394
+ # The owner's hidden tracking lane (ADR-213), directly below its planned lane:
395
+ # a "(tracking)" label and, per work item, a grey committed-window layer and a
396
+ # blue logged-effort layer. Every cell carries gantt_tracking_lane so the toolbar
397
+ # toggle shows or collapses the whole lane as one.
398
+ def gantt_tracking_lane(owner, row, blocks)
399
+ label = "#{owner} (tracking)"
400
+ cells = [%(\t\t<div class="gantt_owner_tracking gantt_tracking_lane" style="grid-row: #{row}; ) +
401
+ %(grid-column: 1;">#{escape_text(label)}</div>\n)]
402
+ blocks.each do |b|
403
+ b[:items].select { |wi| wi.owner == owner }.each do |wi|
404
+ cells.concat(tracking_bars(wi, row, b))
405
+ end
406
+ end
407
+ cells
408
+ end
409
+
410
+ # The 0-2 tracking layers for one work item: the grey committed window (drawn
411
+ # behind) and the blue logged span (drawn in front, second so it paints on top).
412
+ # Each is omitted when its dates are absent (ADR-213).
413
+ def tracking_bars(work_item, row, block)
414
+ bars = []
415
+ cs, ce = committed_window(work_item)
416
+ bars << tracking_bar(work_item, row, block, cs, ce, 'gantt_track_committed', 'Committed') if cs
417
+ ls, le = logged_window(work_item)
418
+ bars << tracking_bar(work_item, row, block, ls, le, 'gantt_track_logged', 'Logged') if ls
419
+ bars
420
+ end
421
+
422
+ def tracking_bar(work_item, row, block, start_date, end_date, klass, kind) # rubocop:disable Metrics/ParameterLists
423
+ grid_col, span = date_span(block, start_date, end_date)
424
+ label = "#{work_item.record_id.upcase} #{work_item.activity}"
425
+ tip = "#{work_item_tip_label(work_item)}\n#{kind}: " \
426
+ "#{start_date.strftime('%d-%m-%Y')} to #{end_date.strftime('%d-%m-%Y')}"
427
+ style = %(grid-row: #{row}; grid-column: #{grid_col} / span #{span};)
428
+ %(\t\t<div class="gantt_track_bar gantt_tracking_lane #{klass}" style="#{style}" ) +
429
+ %(title="#{escape_attr(tip)}">#{escape_text(label)}</div>\n)
430
+ end
431
+
432
+ # The committed window (ADR-213) of a work item as [start, finish] real dates, or
433
+ # [nil, nil] when it has no Scope Start Date. A row with a start but no target is
434
+ # an open commitment: it runs to today (or to its start if that is still ahead),
435
+ # so a missing target reads as an overrun rather than a point.
436
+ def committed_window(work_item)
437
+ return [nil, nil] unless work_item.start_date
438
+
439
+ finish = work_item.target_date || [Date.today, work_item.start_date].max
440
+ [work_item.start_date, finish]
441
+ end
442
+
443
+ # The logged span (ADR-213) of a work item as [earliest, latest] dated effort
444
+ # crediting its Scope Item, recovered through its owning record, or [nil, nil]
445
+ # when none is logged.
446
+ def logged_window(work_item)
447
+ doc = decisions_by_id[work_item.record_id]
448
+ doc&.effort_date_range(work_item.activity) || [nil, nil]
449
+ end
450
+
451
+ # Every real actual date the block must keep on the axis (ADR-213): each item's
452
+ # committed-window end and its logged span. Used to widen the block's columns.
453
+ def actual_dates(items)
454
+ items.flat_map do |wi|
455
+ [committed_window(wi)[1], *logged_window(wi)]
456
+ end.compact
457
+ end
458
+
459
+ # Decision records keyed by id, so a work item (which carries only its record_id)
460
+ # can reach its record's effort log (ADR-213).
461
+ def decisions_by_id
462
+ @decisions_by_id ||= @project.project_data.decisions.to_h { |d| [d.id, d] }
463
+ end
464
+
465
+ # The [grid-column start, span] covering the business-day columns from start_date
466
+ # to end_date inclusive (ADR-213). Both dates snap to a business column via the
467
+ # calendar; an end before the start collapses to a single column.
468
+ def date_span(block, start_date, end_date)
469
+ col_start = block[:calendar].business_column_for(start_date)
470
+ col_end = [block[:calendar].business_column_for(end_date), col_start].max
471
+ [block[:offset] + col_start + 2, col_end - col_start + 1]
472
+ end
473
+
474
+ # A single work-item bar spanning, on the business-day axis (ADR-206), from its
475
+ # first to its last working day inclusive -- covering any weekday-holiday columns
476
+ # it crosses without counting them. Coloured by row Status, outlined when it lies
477
+ # on the group's critical chain (ADR-212), and emphasised when it is a
478
+ # started-but-blocked cross-record violation.
479
+ def gantt_bar(work_item, row, block)
480
+ grid_col, span = calendar_span(block, block[:starts][work_item], block[:scheduler].duration_for(work_item))
481
+ classes = ['gantt_bar', gantt_status_class(work_item)]
482
+ classes << 'gantt_critical' if block[:chain].include?(work_item)
483
+ classes << 'gantt_blocked' if work_item.cross_record_violation?
484
+ label = "#{work_item.record_id.upcase} #{work_item.activity}"
485
+ style = %(grid-row: #{row}; grid-column: #{grid_col} / span #{span};)
486
+ %(\t\t<div class="#{classes.join(' ')}" style="#{style}" title="#{escape_attr(bar_tooltip(work_item))}">) +
487
+ %(#{escape_text(label)}</div>\n)
488
+ end
489
+
490
+ # The dependency hint shown on a bar's native title tooltip: the work item's own
491
+ # name, a blank line, then an "After:" list of every predecessor (ADR-194) it
492
+ # waits on -- internal (intra-record phase order) first, then external
493
+ # (cross-record Depends On) -- each as RECORD-ID.Activity on its own line. The
494
+ # blank line separates the item from its prerequisites for readability; newlines
495
+ # render as line breaks in the native title tooltip.
496
+ def bar_tooltip(work_item)
497
+ name = work_item_tip_label(work_item)
498
+ preds = work_item.intra_record_predecessors + work_item.cross_record_predecessors
499
+ deps = preds.map { |p| work_item_tip_label(p) }.uniq
500
+ return name if deps.empty?
501
+
502
+ "#{name}\n\nAfter:\n#{deps.join("\n")}"
503
+ end
504
+
505
+ # A work item named for the tooltip as RECORD-ID (upper-case) dot activity,
506
+ # e.g. "ADR-201.Analysis" -- the step number is intentionally omitted.
507
+ def work_item_tip_label(work_item)
508
+ "#{work_item.record_id.upcase}.#{work_item.activity}"
509
+ end
510
+
511
+ # The [grid-column start, span] of a run of `duration` working days beginning at
512
+ # working day start_wd, projected onto the block's business-day columns so the
513
+ # run covers any weekday-holiday columns it crosses, but not weekends (ADR-206).
514
+ def calendar_span(block, start_wd, duration)
515
+ col_start = block[:calendar].business_index(start_wd)
516
+ span = block[:calendar].business_index(start_wd + duration - 1) - col_start + 1
517
+ [block[:offset] + col_start + 2, span]
518
+ end
519
+
520
+ def gantt_status_class(work_item)
521
+ case work_item.status
522
+ when 'Done' then 'gantt_done'
523
+ when 'In-Progress' then 'gantt_inprogress'
524
+ else 'gantt_todo'
525
+ end
526
+ end
527
+
71
528
  CHART_PALETTE = [
72
529
  [54, 162, 235], [255, 99, 132], [255, 159, 64], [255, 205, 86],
73
530
  [75, 192, 192], [153, 102, 255], [201, 203, 207]
@@ -77,34 +534,29 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
77
534
  # this category as a data-quality indicator rather than being silently dropped.
78
535
  UNDEFINED_STATUS_LABEL = 'Undefined'
79
536
 
80
- def render_charts_grid # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
81
- counts = @project.project_data.decisions.each_with_object(Hash.new(0)) do |item, cntr|
82
- cntr[item.record_type] += 1 if item.record_type
83
- end
84
- labels = counts.keys.sort
85
- data = labels.map { |k| counts[k] }
86
- pie_colors = labels.each_with_index.map { |_, i| palette_rgba(i, 0.5) }
87
-
537
+ def render_charts_grid
538
+ wip = wip_by_owner_chart_data
88
539
  velocity = velocity_chart_data
89
540
  status_dist = status_distribution_chart_data
90
541
 
91
542
  <<~HTML
92
543
  <div class="decisions_overview_charts">
93
544
  \t<div class="chart_cell">
94
- \t\t<canvas id="decisions_type_pie"></canvas>
545
+ \t\t<canvas id="decisions_wip_bar"></canvas>
95
546
  \t\t<script>
96
- \t\t\tnew Chart(document.getElementById('decisions_type_pie'), {
97
- \t\t\t\ttype: 'pie',
547
+ \t\t\tnew Chart(document.getElementById('decisions_wip_bar'), {
548
+ \t\t\t\ttype: 'bar',
98
549
  \t\t\t\tdata: {
99
- \t\t\t\t\tlabels: #{labels.to_json},
100
- \t\t\t\t\tdatasets: [{
101
- \t\t\t\t\t\tlabel: 'Decision records',
102
- \t\t\t\t\t\tdata: #{data.to_json},
103
- \t\t\t\t\t\tbackgroundColor: #{pie_colors.to_json},
104
- \t\t\t\t\t\tborderWidth: 0
105
- \t\t\t\t\t}]
550
+ \t\t\t\t\tlabels: #{wip[:labels].to_json},
551
+ \t\t\t\t\tdatasets: [
552
+ \t\t\t\t\t\t{ label: 'In-progress items', data: #{wip[:bars].to_json}, backgroundColor: #{wip[:bar_colors].to_json}, borderWidth: 0, order: 2 },
553
+ \t\t\t\t\t\t{ type: 'line', label: 'WIP limit', data: #{wip[:limit_line].to_json}, borderColor: 'rgba(255, 99, 132, 1)', borderDash: [6, 4], pointRadius: 0, fill: false, order: 1 }
554
+ \t\t\t\t\t]
106
555
  \t\t\t\t},
107
- \t\t\t\toptions: { plugins: { title: { display: true, text: 'Decision Records by Type' } } }
556
+ \t\t\t\toptions: {
557
+ \t\t\t\t\tplugins: { title: { display: true, text: 'Work In Progress by Owner' } },
558
+ \t\t\t\t\tscales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
559
+ \t\t\t\t}
108
560
  \t\t\t});
109
561
  \t\t</script>
110
562
  \t</div>
@@ -139,7 +591,122 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
139
591
  HTML
140
592
  end
141
593
 
142
- def velocity_chart_data(reference_date: Date.today, weeks: 6) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
594
+ # Show a bar for EVERY owner named in any decision record's Scope table, its
595
+ # height being that owner's count of In-Progress Scope rows across all records
596
+ # (zero when the owner has none). Rendering the full roster of roles keeps the
597
+ # freeze line spanning the chart even when only one owner is currently busy --
598
+ # a single bar would otherwise leave the category-aligned limit line with one
599
+ # point and nothing to draw. Bars are ordered by descending count, idle owners
600
+ # (tied at zero) last in first-seen order; an owner over the limit uses the
601
+ # warning colour. The limit is a flat line dataset so the threshold needs no plugin.
602
+ def wip_by_owner_chart_data
603
+ tally = in_progress_tally
604
+ owners = ordered_owners(tally)
605
+ limit = @project.configuration.get_wip_limit
606
+ {
607
+ labels: owners,
608
+ bars: owners.map { |owner| tally[owner] },
609
+ bar_colors: owners.map { |owner| tally[owner] > limit ? palette_rgba(1, 0.7) : palette_rgba(0, 0.5) },
610
+ limit_line: Array.new(owners.length, limit)
611
+ }
612
+ end
613
+
614
+ # In-Progress Scope-row count per owner, across all decision records.
615
+ def in_progress_tally
616
+ tally = Hash.new(0)
617
+ @project.project_data.decisions.each do |doc|
618
+ doc.in_progress_owner_tally.each { |owner| tally[owner] += 1 }
619
+ end
620
+ tally
621
+ end
622
+
623
+ # Every owner named in any Scope table, ordered by descending in-progress count;
624
+ # idle owners (tied at zero) fall to the end in first-seen order.
625
+ def ordered_owners(tally)
626
+ first_seen = []
627
+ @project.project_data.decisions.each do |doc|
628
+ doc.owners.each { |owner| first_seen << owner unless first_seen.include?(owner) }
629
+ end
630
+ first_seen.sort_by { |owner| [-tally[owner], first_seen.index(owner)] }
631
+ end
632
+
633
+ # The owner order for the Gantt lanes (ADR-204): the workflow sequence the
634
+ # records collectively describe, rather than the heatmap's current-load order.
635
+ # Each record's distinct owner sequence (doc.owners) votes on every ordered
636
+ # owner pair into a pairwise-precedence tally (before[[a, b]] = records placing
637
+ # a before b); owners are then ranked by Copeland score, with the first-seen
638
+ # order as a deterministic tiebreak so the lanes are identical across runs.
639
+ # Opposite-order records are simply outvoted, a missing role abstains, and an
640
+ # added role is placed by the pairs it does appear in -- no special-casing.
641
+ def consensus_owner_order
642
+ first_seen, before = owner_precedence
643
+ first_seen.sort_by { |owner| [-copeland_score(owner, first_seen, before), first_seen.index(owner)] }
644
+ end
645
+
646
+ # One pass over the records yielding both the first-seen owner roster and the
647
+ # pairwise-precedence tally before[[a, b]] -- the number of records placing a
648
+ # before b across every ordered pair of a record's distinct owners. Shared so
649
+ # the consensus order needs no second iteration over the records (ADR-204).
650
+ def owner_precedence
651
+ first_seen = []
652
+ before = Hash.new(0)
653
+ @project.project_data.decisions.each do |doc|
654
+ owners = doc.owners
655
+ owners.each_with_index do |owner, i|
656
+ first_seen << owner unless first_seen.include?(owner)
657
+ owners.drop(i + 1).each { |later| before[[owner, later]] += 1 }
658
+ end
659
+ end
660
+ [first_seen, before]
661
+ end
662
+
663
+ # Copeland score for one owner against the roster: +1 for every other owner it
664
+ # precedes more often than it follows, -1 for the reverse, 0 when tied. One
665
+ # integer per owner keeps the sort well-defined even if the pairwise majorities
666
+ # form a cycle, so no cycle detection is needed (ADR-204).
667
+ def copeland_score(owner, roster, before)
668
+ roster.sum do |other|
669
+ next 0 if other == owner
670
+
671
+ before[[owner, other]] <=> before[[other, owner]]
672
+ end
673
+ end
674
+
675
+ # Retained for re-enablement: the original "Decision Records by Type" pie chart.
676
+ # Its cell is no longer emitted into the charts grid (the WIP-by-Owner chart took
677
+ # the left slot), but the builder is kept intact so the pie can be restored by
678
+ # emitting this cell again, with no data work.
679
+ def pie_chart_cell
680
+ counts = @project.project_data.decisions.each_with_object(Hash.new(0)) do |item, cntr|
681
+ cntr[item.record_type] += 1 if item.record_type
682
+ end
683
+ labels = counts.keys.sort
684
+ data = labels.map { |k| counts[k] }
685
+ pie_colors = labels.each_with_index.map { |_, i| palette_rgba(i, 0.5) }
686
+
687
+ <<~HTML
688
+ \t<div class="chart_cell">
689
+ \t\t<canvas id="decisions_type_pie"></canvas>
690
+ \t\t<script>
691
+ \t\t\tnew Chart(document.getElementById('decisions_type_pie'), {
692
+ \t\t\t\ttype: 'pie',
693
+ \t\t\t\tdata: {
694
+ \t\t\t\t\tlabels: #{labels.to_json},
695
+ \t\t\t\t\tdatasets: [{
696
+ \t\t\t\t\t\tlabel: 'Decision records',
697
+ \t\t\t\t\t\tdata: #{data.to_json},
698
+ \t\t\t\t\t\tbackgroundColor: #{pie_colors.to_json},
699
+ \t\t\t\t\t\tborderWidth: 0
700
+ \t\t\t\t\t}]
701
+ \t\t\t\t},
702
+ \t\t\t\toptions: { plugins: { title: { display: true, text: 'Decision Records by Type' } } }
703
+ \t\t\t});
704
+ \t\t</script>
705
+ \t</div>
706
+ HTML
707
+ end
708
+
709
+ def velocity_chart_data(reference_date: Date.today, weeks: 6)
143
710
  fridays = recent_fridays(reference_date, weeks)
144
711
  segments = []
145
712
  counts = {}
@@ -168,7 +735,7 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
168
735
  # is ordered last; real statuses keep first-seen order. The count is baked into
169
736
  # each label so small categories stay legible on a linear axis next to a
170
737
  # dominant one.
171
- def status_distribution_chart_data # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
738
+ def status_distribution_chart_data
172
739
  counts = Hash.new(0)
173
740
  keys = []
174
741
  @project.project_data.decisions.each do |doc|
@@ -193,11 +760,4 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
193
760
  r, g, b = CHART_PALETTE[index % CHART_PALETTE.length]
194
761
  "rgba(#{r}, #{g}, #{b}, #{alpha})"
195
762
  end
196
-
197
- def recent_fridays(reference_date, count)
198
- friday_wday = 5
199
- days_back = (reference_date.wday - friday_wday) % 7
200
- most_recent = reference_date - days_back
201
- (0...count).to_a.reverse.map { |i| most_recent - (7 * i) }
202
- end
203
763
  end