Almirah 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/almirah/doc_fabric.rb +0 -6
- data/lib/almirah/doc_items/controlled_table.rb +23 -4
- data/lib/almirah/doc_items/scope_table.rb +39 -88
- data/lib/almirah/doc_types/base_document.rb +14 -22
- 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/protocol.rb +4 -1
- data/lib/almirah/doc_types/risks_overview.rb +6 -6
- data/lib/almirah/doc_types/source_file.rb +2 -1
- data/lib/almirah/project/project_data.rb +1 -4
- data/lib/almirah/project.rb +22 -122
- data/lib/almirah/project_configuration.rb +5 -97
- data/lib/almirah/templates/css/main.css +13 -167
- metadata +1 -8
- data/lib/almirah/doc_items/work_item.rb +0 -129
- data/lib/almirah/doc_types/critical_chain_page.rb +0 -218
- data/lib/almirah/doc_types/decision_grouping.rb +0 -17
- data/lib/almirah/project/critical_chain.rb +0 -117
- data/lib/almirah/project/fever_chart.rb +0 -94
- data/lib/almirah/project/work_item_scheduler.rb +0 -167
- data/lib/almirah/project/working_calendar.rb +0 -112
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'set'
|
|
4
|
-
|
|
5
|
-
# Lays the WorkItem network (ADR-194) on an abstract day axis for the overview
|
|
6
|
-
# swimlane Gantt (ADR-198). It performs a deterministic forward pass over the
|
|
7
|
-
# dependency edges and then levels each owner's lane so two work items of the
|
|
8
|
-
# same owner never overlap. Levelling backfills: a row may be slotted into an
|
|
9
|
-
# idle gap left between rows placed earlier, so a low-priority short row does
|
|
10
|
-
# not queue behind the whole lane when a gap already fits it.
|
|
11
|
-
#
|
|
12
|
-
# Durations are a constant placeholder (3 days) while no per-row estimates
|
|
13
|
-
# exist; `duration_for` is the single hook ADR-195 overrides to feed real
|
|
14
|
-
# estimates without changing the scheduling logic.
|
|
15
|
-
#
|
|
16
|
-
# Days are 1-based. A work item starting on day `s` with duration `d` occupies
|
|
17
|
-
# the inclusive day span `s .. s + d - 1`; its *finish* (the next free day,
|
|
18
|
-
# returned in the ends map) is `s + d`, the earliest a successor or the same
|
|
19
|
-
# owner's next item may start.
|
|
20
|
-
class WorkItemScheduler
|
|
21
|
-
# Placeholder duration for every work item until ADR-195 supplies estimates.
|
|
22
|
-
DEFAULT_DURATION = 3
|
|
23
|
-
|
|
24
|
-
def initialize(work_items, duration: DEFAULT_DURATION)
|
|
25
|
-
@items = work_items
|
|
26
|
-
@duration = duration
|
|
27
|
-
@item_set = work_items.to_set
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# { work_item => start_day }, day index 1-based. Empty when there are no items.
|
|
31
|
-
def start_days
|
|
32
|
-
schedule unless @starts
|
|
33
|
-
@starts
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# The number of day columns the chart needs: the latest finish minus one
|
|
37
|
-
# (finishes are the exclusive next-free day). Zero when there are no items.
|
|
38
|
-
def day_count
|
|
39
|
-
schedule unless @ends
|
|
40
|
-
@ends.empty? ? 0 : (@ends.values.max - 1)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# The schedule length in working days (the latest finish, day 1 being the start).
|
|
44
|
-
def makespan
|
|
45
|
-
day_count
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# The critical chain: the row with the latest finish, traced back through its
|
|
49
|
-
# binding predecessors (the dependency and resource hand-offs that set each
|
|
50
|
-
# row's start), returned in start order. Empty when nothing is scheduled.
|
|
51
|
-
def critical_chain
|
|
52
|
-
schedule unless @starts
|
|
53
|
-
return [] if @ends.empty?
|
|
54
|
-
|
|
55
|
-
max_end = @ends.values.max
|
|
56
|
-
node = @items.select { |wi| @ends[wi] == max_end }.min_by { |wi| [wi.record_id, wi.step] }
|
|
57
|
-
chain = []
|
|
58
|
-
while node
|
|
59
|
-
chain.unshift(node)
|
|
60
|
-
node = @binding[node]
|
|
61
|
-
end
|
|
62
|
-
chain
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def duration_for(_work_item)
|
|
66
|
-
@duration
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
# Greedy list-scheduler. Items are processed in a deterministic priority order
|
|
72
|
-
# (resource-free earliest start, then activity rank, record id, step) so that
|
|
73
|
-
# every item's predecessors are placed before it. Each item starts at the
|
|
74
|
-
# earliest day at or after its dependency finish where its owner's lane has an
|
|
75
|
-
# idle gap wide enough for it (resource levelling with backfill).
|
|
76
|
-
def schedule
|
|
77
|
-
@starts = {}
|
|
78
|
-
@ends = {}
|
|
79
|
-
@binding = {}
|
|
80
|
-
@owner_rows = Hash.new { |hash, owner| hash[owner] = [] }
|
|
81
|
-
return if @items.empty?
|
|
82
|
-
|
|
83
|
-
priority_order.each { |wi| place(wi) }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Assigns one work item its start day, records the predecessor that bound that
|
|
87
|
-
# start, then marks the span as occupied in its owner's lane.
|
|
88
|
-
def place(work_item)
|
|
89
|
-
preds = scoped_predecessors(work_item)
|
|
90
|
-
dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
|
|
91
|
-
start, lane_pred = earliest_fit(work_item.owner, dep_finish, duration_for(work_item))
|
|
92
|
-
@starts[work_item] = start
|
|
93
|
-
@ends[work_item] = start + duration_for(work_item)
|
|
94
|
-
@binding[work_item] = binding_predecessor(preds, start, lane_pred)
|
|
95
|
-
@owner_rows[work_item.owner] << work_item unless work_item.owner.empty?
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# The earliest start at or after `from` where the owner's lane stays clear for
|
|
99
|
-
# `duration` days, plus the lane row whose finish that start had to wait behind
|
|
100
|
-
# (nil when the row starts at `from` itself). A blank owner holds no resource,
|
|
101
|
-
# so it never serialises.
|
|
102
|
-
def earliest_fit(owner, from, duration)
|
|
103
|
-
return [from, nil] if owner.empty?
|
|
104
|
-
|
|
105
|
-
start = from
|
|
106
|
-
lane_pred = nil
|
|
107
|
-
@owner_rows[owner].sort_by { |row| @starts[row] }.each do |row|
|
|
108
|
-
break if start + duration <= @starts[row]
|
|
109
|
-
next if @ends[row] <= start
|
|
110
|
-
|
|
111
|
-
start = @ends[row]
|
|
112
|
-
lane_pred = row
|
|
113
|
-
end
|
|
114
|
-
[start, lane_pred]
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# The already-placed predecessor whose finish coincides with this row's start --
|
|
118
|
-
# the dependency or same-owner hand-off the critical chain is traced back
|
|
119
|
-
# through. nil when the row starts at the origin with no such predecessor.
|
|
120
|
-
def binding_predecessor(preds, start, lane_pred)
|
|
121
|
-
candidates = preds.select { |p| @ends[p] == start }
|
|
122
|
-
candidates << lane_pred if lane_pred
|
|
123
|
-
candidates.min_by { |c| [c.record_id, c.step] }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def priority_order
|
|
127
|
-
memo = {}
|
|
128
|
-
@items.sort_by { |wi| priority_key(wi, memo) }
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# The deterministic scheduling priority for a row. Overridden by the critical-
|
|
132
|
-
# chain scheduler (ADR-195) to prioritise the longest downstream duration.
|
|
133
|
-
def priority_key(work_item, memo)
|
|
134
|
-
[dependency_start(work_item, memo, []), work_item.activity_rank, work_item.record_id, work_item.step]
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# The earliest day this item could start ignoring resource contention: 1 when
|
|
138
|
-
# it has no predecessors, else one past the latest predecessor finish. Memoised
|
|
139
|
-
# and cycle-guarded — a back-edge (which the DAG-by-construction network should
|
|
140
|
-
# never have) is treated as start 1 so scheduling still completes.
|
|
141
|
-
def dependency_start(work_item, memo, stack)
|
|
142
|
-
return memo[work_item] if memo.key?(work_item)
|
|
143
|
-
return 1 if stack.include?(work_item)
|
|
144
|
-
|
|
145
|
-
preds = scoped_predecessors(work_item)
|
|
146
|
-
return memo[work_item] = 1 if preds.empty?
|
|
147
|
-
|
|
148
|
-
stack.push(work_item)
|
|
149
|
-
earliest = preds.map { |p| dependency_start(p, memo, stack) + duration_for(p) }.max
|
|
150
|
-
stack.pop
|
|
151
|
-
memo[work_item] = earliest
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Predecessors inside this scheduler's own item set. A predecessor scheduled in
|
|
155
|
-
# another scope (e.g. a different decision group, ADR-201) is treated as an
|
|
156
|
-
# already-available external input: it is dropped here and imposes no finish
|
|
157
|
-
# constraint, so the dependent simply starts at day 1 with respect to it.
|
|
158
|
-
def scoped_predecessors(work_item)
|
|
159
|
-
work_item.predecessor_items.select { |p| @item_set.include?(p) }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Successors inside this scheduler's own item set (the mirror of
|
|
163
|
-
# scoped_predecessors), used by the critical-chain priority.
|
|
164
|
-
def scoped_successors(work_item)
|
|
165
|
-
work_item.successor_items.select { |s| @item_set.include?(s) }
|
|
166
|
-
end
|
|
167
|
-
end
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'date'
|
|
4
|
-
require 'set'
|
|
5
|
-
|
|
6
|
-
# Projects the working-day planning axis (ADR-198 / ADR-195) onto real calendar
|
|
7
|
-
# dates (ADR-205). Working day 1 is the first working date on or after the anchor;
|
|
8
|
-
# Saturdays, Sundays, and any configured holiday are non-working — skipped when
|
|
9
|
-
# counting working days, but still occupying calendar columns. This is a pure
|
|
10
|
-
# projection: it never changes the schedule, the chain, or the buffer.
|
|
11
|
-
class WorkingCalendar
|
|
12
|
-
SATURDAY = 6
|
|
13
|
-
SUNDAY = 0
|
|
14
|
-
FRIDAY = 5
|
|
15
|
-
|
|
16
|
-
def initialize(anchor: Date.today, holidays: [])
|
|
17
|
-
@holidays = holidays.to_set
|
|
18
|
-
@start = first_working_on_or_after(anchor)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# The calendar date of the n-th working day (1-based) counted from the anchor.
|
|
22
|
-
def date_for(working_day)
|
|
23
|
-
return @start if working_day <= 1
|
|
24
|
-
|
|
25
|
-
date = @start
|
|
26
|
-
remaining = working_day - 1
|
|
27
|
-
while remaining.positive?
|
|
28
|
-
date += 1
|
|
29
|
-
remaining -= 1 if working?(date)
|
|
30
|
-
end
|
|
31
|
-
date
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Every calendar date from working day 1 through the working_day_count-th working
|
|
35
|
-
# day inclusive, including the non-working dates in between. Empty for a count
|
|
36
|
-
# below 1.
|
|
37
|
-
def columns(working_day_count)
|
|
38
|
-
return [] if working_day_count < 1
|
|
39
|
-
|
|
40
|
-
(@start..date_for(working_day_count)).to_a
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# The 0-based calendar column index of the n-th working day within columns.
|
|
44
|
-
def column_index(working_day)
|
|
45
|
-
(date_for(working_day) - @start).to_i
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# The compact business-day axis (ADR-206): weekday dates from working day 1
|
|
49
|
-
# through the working_day_count-th working day, excluding Saturdays and Sundays
|
|
50
|
-
# but including weekday holidays. Empty for a count below 1.
|
|
51
|
-
def business_columns(working_day_count)
|
|
52
|
-
return [] if working_day_count < 1
|
|
53
|
-
|
|
54
|
-
(@start..date_for(working_day_count)).reject { |date| weekend?(date) }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# The 0-based business-column index of the n-th working day: the count of
|
|
58
|
-
# weekdays (holidays included, weekends excluded) from the anchor through it.
|
|
59
|
-
def business_index(working_day)
|
|
60
|
-
(@start..date_for(working_day)).count { |date| !weekend?(date) } - 1
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# The first `count` business-day (weekday) dates from the anchor, holidays
|
|
64
|
-
# included and weekends excluded — the calendar labels for an axis of `count`
|
|
65
|
-
# columns, even when it runs past the schedule to cover authored actuals
|
|
66
|
-
# (ADR-213). Empty for a count below 1.
|
|
67
|
-
def business_axis(count)
|
|
68
|
-
return [] if count < 1
|
|
69
|
-
|
|
70
|
-
dates = []
|
|
71
|
-
date = @start
|
|
72
|
-
while dates.length < count
|
|
73
|
-
dates << date unless weekend?(date)
|
|
74
|
-
date += 1
|
|
75
|
-
end
|
|
76
|
-
dates
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# The 0-based business-column index of a real calendar date relative to the
|
|
80
|
-
# anchor (ADR-213): the count of weekdays from the anchor through the date, minus
|
|
81
|
-
# one. A weekend date snaps to the preceding weekday's column; a date on or
|
|
82
|
-
# before the anchor clamps to column 0. Used to place authored committed/logged
|
|
83
|
-
# dates on the same business-day axis as the schedule.
|
|
84
|
-
def business_column_for(date)
|
|
85
|
-
return 0 if date <= @start
|
|
86
|
-
|
|
87
|
-
(@start..date).count { |d| !weekend?(d) } - 1
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def friday?(date)
|
|
91
|
-
date.wday == FRIDAY
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def working?(date)
|
|
95
|
-
!non_working?(date)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def non_working?(date)
|
|
99
|
-
weekend?(date) || @holidays.include?(date)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def weekend?(date)
|
|
103
|
-
[SATURDAY, SUNDAY].include?(date.wday)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
def first_working_on_or_after(date)
|
|
109
|
-
date += 1 while non_working?(date)
|
|
110
|
-
date
|
|
111
|
-
end
|
|
112
|
-
end
|