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.
- checksums.yaml +4 -4
- data/bin/almirah +2 -2
- data/lib/almirah/doc_fabric.rb +7 -0
- data/lib/almirah/doc_items/blockquote.rb +15 -16
- data/lib/almirah/doc_items/code_block.rb +19 -21
- data/lib/almirah/doc_items/controlled_paragraph.rb +2 -2
- data/lib/almirah/doc_items/controlled_table.rb +11 -11
- data/lib/almirah/doc_items/controlled_table_row.rb +15 -18
- data/lib/almirah/doc_items/doc_footer.rb +10 -13
- data/lib/almirah/doc_items/doc_item.rb +2 -2
- data/lib/almirah/doc_items/heading.rb +1 -1
- data/lib/almirah/doc_items/image.rb +25 -27
- data/lib/almirah/doc_items/markdown_list.rb +2 -2
- data/lib/almirah/doc_items/markdown_table.rb +9 -2
- data/lib/almirah/doc_items/scope_table.rb +188 -0
- data/lib/almirah/doc_items/text_line.rb +26 -22
- data/lib/almirah/doc_items/todo_block.rb +15 -16
- data/lib/almirah/doc_items/work_item.rb +129 -0
- data/lib/almirah/doc_parser.rb +14 -8
- data/lib/almirah/doc_types/base_document.rb +21 -5
- data/lib/almirah/doc_types/coverage.rb +3 -3
- data/lib/almirah/doc_types/critical_chain_page.rb +218 -0
- data/lib/almirah/doc_types/decision.rb +152 -7
- data/lib/almirah/doc_types/decision_grouping.rb +17 -0
- data/lib/almirah/doc_types/decisions_overview.rb +591 -31
- data/lib/almirah/doc_types/implementation.rb +98 -98
- data/lib/almirah/doc_types/index.rb +11 -12
- data/lib/almirah/doc_types/persistent_document.rb +1 -1
- data/lib/almirah/doc_types/planning_dates.rb +17 -0
- data/lib/almirah/doc_types/protocol.rb +16 -20
- data/lib/almirah/doc_types/source_file.rb +1 -1
- data/lib/almirah/doc_types/traceability.rb +124 -133
- data/lib/almirah/dom/doc_section.rb +1 -1
- data/lib/almirah/navigation_pane.rb +9 -13
- data/lib/almirah/project/critical_chain.rb +117 -0
- data/lib/almirah/project/doc_linker.rb +4 -4
- data/lib/almirah/project/fever_chart.rb +94 -0
- data/lib/almirah/project/project_data.rb +8 -2
- data/lib/almirah/project/work_item_scheduler.rb +166 -0
- data/lib/almirah/project/working_calendar.rb +112 -0
- data/lib/almirah/project.rb +146 -9
- data/lib/almirah/project_configuration.rb +126 -29
- data/lib/almirah/project_template.rb +6 -6
- data/lib/almirah/project_utility.rb +3 -5
- data/lib/almirah/search/specifications_db.rb +2 -2
- data/lib/almirah/source_file_parser.rb +2 -3
- data/lib/almirah/templates/css/main.css +160 -0
- data/lib/almirah/templates/scripts/main.js +3 -1
- data/lib/almirah.rb +1 -2
- 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)
|
|
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\"
|
|
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
|
|
81
|
-
|
|
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="
|
|
545
|
+
\t\t<canvas id="decisions_wip_bar"></canvas>
|
|
95
546
|
\t\t<script>
|
|
96
|
-
\t\t\tnew Chart(document.getElementById('
|
|
97
|
-
\t\t\t\ttype: '
|
|
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\
|
|
102
|
-
\t\t\t\t\t\
|
|
103
|
-
\t\t\t\t\t
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|