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
@@ -7,7 +7,7 @@ class DocSection
7
7
  @parent_section = nil
8
8
  end
9
9
 
10
- def to_html # rubocop:disable Metrics/MethodLength
10
+ def to_html
11
11
  s = ''
12
12
  s += "\t<li onclick=\"nav_toggle_expand_list(this, event)\">" \
13
13
  '<span class="fa-li"><i class="fa fa-minus-square-o"> </i></span>'
@@ -1,17 +1,13 @@
1
-
2
1
  class NavigationPane
2
+ attr_accessor :specifications
3
3
 
4
- attr_accessor :specifications
4
+ def initialize(specification)
5
+ @doc = specification
6
+ end
5
7
 
6
- def initialize(specification)
7
- @doc = specification
8
- end
8
+ def to_html
9
+ return @doc.dom.section_tree_to_html if @doc.dom
9
10
 
10
- def to_html
11
- if @doc.dom
12
- return @doc.dom.section_tree_to_html()
13
- else
14
- return ''
15
- end
16
- end
17
- end
11
+ ''
12
+ end
13
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'work_item_scheduler'
4
+
5
+ # Resource-levelled scheduler variant for the critical chain (ADR-195): each
6
+ # row's duration is its focused estimate, and rows are prioritised by the longest
7
+ # downstream chain of focused durations (ties: record sequence, then step). The
8
+ # forward pass, resource levelling, binding-predecessor tracking, and chain
9
+ # tracing are all inherited from WorkItemScheduler.
10
+ class ChainScheduler < WorkItemScheduler
11
+ def duration_for(work_item)
12
+ work_item.focused_estimate
13
+ end
14
+
15
+ private
16
+
17
+ def priority_key(work_item, memo)
18
+ [-downstream_length(work_item, memo, []), work_item.record_sequence, work_item.step]
19
+ end
20
+
21
+ # The longest path of focused durations from this row through its in-scope
22
+ # successors to a sink. Memoised and cycle-guarded.
23
+ def downstream_length(work_item, memo, stack)
24
+ return memo[work_item] if memo.key?(work_item)
25
+ return duration_for(work_item) if stack.include?(work_item)
26
+
27
+ stack.push(work_item)
28
+ tail = scoped_successors(work_item).map { |s| downstream_length(s, memo, stack) }.max || 0
29
+ stack.pop
30
+ memo[work_item] = duration_for(work_item) + tail
31
+ end
32
+ end
33
+
34
+ # The Gantt's scheduler (ADR-201/195): real focused-estimate durations like the
35
+ # critical-chain scheduler, but rounded up to whole day columns with a one-day
36
+ # minimum so an unestimated row (focused 0) still shows a visible bar. The chain
37
+ # and buffer keep their exact-duration math in ChainScheduler / CriticalChain.
38
+ class GanttScheduler < ChainScheduler
39
+ def duration_for(work_item)
40
+ [work_item.focused_estimate.ceil, 1].max
41
+ end
42
+ end
43
+
44
+ # The critical chain and project buffer for one decision group's Scope rows
45
+ # (ADR-195). This object serves two distinct jobs that want opposite treatment of
46
+ # completed work, so it keeps two views of the same Scope rows:
47
+ #
48
+ # - the *scheduling* chain (`chain` / `buffer` / `projected_duration`) excludes
49
+ # Done rows as finished work — you do not re-schedule what is already done — and
50
+ # reports the duration of the work still remaining;
51
+ # - the *baseline* chain (`baseline_chain` / `baseline_buffer`) keeps every row,
52
+ # Done included, as the plan was originally sized. Buffer-consumption accounting
53
+ # (the fever chart, ADR-196) reads this view so that a chain row which overran
54
+ # and then completed keeps consuming buffer instead of vanishing the moment it
55
+ # is marked Done, and so the consumption denominator stays a stable plan-time
56
+ # baseline rather than shrinking as rows finish (issue-207).
57
+ #
58
+ # A predecessor outside the given set (a row in another group) drops out and is
59
+ # treated as an already-available input. The buffer aggregates the safety
60
+ # (safe - focused, clamped at 0) along a chain and cuts it by buffer_ratio,
61
+ # rounded up.
62
+ class CriticalChain
63
+ def initialize(work_items, buffer_ratio: 0.5)
64
+ @all_items = work_items
65
+ @items = work_items.reject(&:done?)
66
+ @buffer_ratio = buffer_ratio
67
+ @scheduler = ChainScheduler.new(@items)
68
+ @baseline_scheduler = ChainScheduler.new(@all_items)
69
+ end
70
+
71
+ # The remaining chain (Done rows excluded) in start order — drives the chain
72
+ # table and the projected completion of the work still to do.
73
+ def chain
74
+ @scheduler.critical_chain
75
+ end
76
+
77
+ # The chain as originally planned, completed rows included (issue-207). Buffer
78
+ # consumption is accounted against this set so completed overruns stay visible.
79
+ def baseline_chain
80
+ @baseline_scheduler.critical_chain
81
+ end
82
+
83
+ # Chain length in working days (the latest finish).
84
+ def length
85
+ @scheduler.makespan
86
+ end
87
+
88
+ # ceil(buffer_ratio * Σ_chain max(safe - focused, 0)) over the remaining chain.
89
+ def buffer
90
+ buffer_for(chain)
91
+ end
92
+
93
+ # The plan-time buffer over the full baseline chain — the stable denominator for
94
+ # buffer-consumption %, unaffected by rows completing (issue-207).
95
+ def baseline_buffer
96
+ buffer_for(baseline_chain)
97
+ end
98
+
99
+ def projected_duration
100
+ length + buffer
101
+ end
102
+
103
+ # False when no row carries a positive focused estimate, so the overview marks
104
+ # the group "unestimated" rather than reporting a misleadingly short plan.
105
+ # Reads the baseline so a fully-completed group still reports as estimated and
106
+ # keeps its (100%-complete) fever chart instead of collapsing to "not sized".
107
+ def estimated?
108
+ @all_items.any? { |wi| wi.focused_estimate.positive? }
109
+ end
110
+
111
+ private
112
+
113
+ def buffer_for(rows)
114
+ safety = rows.sum { |wi| [wi.safe_estimate - wi.focused_estimate, 0].max }
115
+ (@buffer_ratio * safety).ceil
116
+ end
117
+ end
@@ -17,7 +17,7 @@ class DocLinker
17
17
  result
18
18
  end
19
19
 
20
- def self.link_protocol_to_spec(protocol, specification) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
20
+ def self.link_protocol_to_spec(protocol, specification)
21
21
  top_document = specification
22
22
  bottom_document = protocol
23
23
 
@@ -44,7 +44,7 @@ class DocLinker
44
44
  end
45
45
  end
46
46
 
47
- def self.link_decision_to_spec(decision, specification) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
47
+ def self.link_decision_to_spec(decision, specification)
48
48
  top_document = specification
49
49
  bottom_document = decision
50
50
 
@@ -68,7 +68,7 @@ class DocLinker
68
68
  end
69
69
  end
70
70
 
71
- def self.link_source_file_to_spec(source_file, specification) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
71
+ def self.link_source_file_to_spec(source_file, specification)
72
72
  top_document = specification
73
73
  bottom_document = source_file
74
74
 
@@ -92,7 +92,7 @@ class DocLinker
92
92
  end
93
93
  end
94
94
 
95
- def self.link_two_specifications(doc_a, doc_b) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
95
+ def self.link_two_specifications(doc_a, doc_b)
96
96
  if doc_b.up_link_docs.key?(doc_a.id.to_s)
97
97
  top_document = doc_a
98
98
  bottom_document = doc_b
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The buffer-consumption fever chart for one decision group (ADR-196): given the
4
+ # group's CriticalChain plan, the effort logged on its records, and the working-
5
+ # hours-per-day conversion, it produces the (completion%, consumption%) point and
6
+ # the historical trail the Critical Chain page plots.
7
+ #
8
+ # It tracks only chain rows with a positive focused estimate (a zero-estimate row
9
+ # carries no schedule weight). Per-row actual effort is read from the owning
10
+ # Decision's append-only # Effort log via row_actual_hours_on, converted to days;
11
+ # the chart never consults the record lifecycle status.
12
+ #
13
+ # It reads the plan's *baseline* chain and buffer (completed rows included,
14
+ # issue-207), not the remaining-work chain: a chain row that overran and then was
15
+ # marked Done has consumed real buffer that must stay accounted, and the
16
+ # consumption denominator must not shrink as rows finish. The live point still
17
+ # credits a Done row in full via its per-row Status (ADR-196); historical trail
18
+ # points reconstruct even a completed row's progress and overrun from its dated
19
+ # # Effort log, which a per-row Status (no dated history) could not.
20
+ class FeverChart
21
+ # record_lookup maps an upcased record id (e.g. "ADR-196") to its Decision.
22
+ def initialize(plan, record_lookup, hours_per_day: 8)
23
+ @rows = plan.baseline_chain.select { |wi| wi.focused_estimate.positive? }
24
+ @buffer = plan.baseline_buffer
25
+ @record_lookup = record_lookup
26
+ @hours_per_day = hours_per_day.to_f
27
+ end
28
+
29
+ # True when there is anything to plot (at least one positive-estimate chain row).
30
+ def plottable?
31
+ @rows.any?
32
+ end
33
+
34
+ # The live fever point [completion%, consumption%] as of `date`: a Done row
35
+ # credits full completion regardless of logged effort, matching the bounded
36
+ # per-row Status (ADR-193); other rows credit logged effort only.
37
+ def live_point(date)
38
+ [completion(date, live: true), consumption(date)]
39
+ end
40
+
41
+ # A historical point [completion%, consumption%] as of `date`, from logged
42
+ # effort only (the per-row Status has no dated history to replay).
43
+ def point_on(date)
44
+ [completion(date, live: false), consumption(date)]
45
+ end
46
+
47
+ # One historical point per date (recent Fridays), in the given order.
48
+ def trail(dates)
49
+ dates.map { |d| point_on(d) }
50
+ end
51
+
52
+ # The baseline buffer days these overruns have consumed as of `date` -- the
53
+ # numerator behind the consumption percentage (its denominator is the baseline
54
+ # buffer). Lets the Critical Chain page report "X of Y baseline days".
55
+ def consumed_days(date)
56
+ @rows.sum { |wi| [actual_days(wi, date) - wi.focused_estimate, 0].max }
57
+ end
58
+
59
+ private
60
+
61
+ def completion(date, live:)
62
+ total = @rows.sum(&:focused_estimate)
63
+ return 0.0 if total.zero?
64
+
65
+ credited = @rows.sum { |wi| credit(wi, date, live: live) * wi.focused_estimate }
66
+ 100.0 * credited / total
67
+ end
68
+
69
+ def credit(work_item, date, live:)
70
+ return 1.0 if live && work_item.done?
71
+
72
+ clamp01(actual_days(work_item, date) / work_item.focused_estimate)
73
+ end
74
+
75
+ # Percentage of the project buffer consumed: the aggregate amount by which chain
76
+ # rows overran their focused estimate, over the buffer. May exceed 100. A group
77
+ # whose chain carries no safety (buffer 0) has nothing to consume, so 0.
78
+ def consumption(date)
79
+ return 0.0 if @buffer.zero?
80
+
81
+ 100.0 * consumed_days(date) / @buffer
82
+ end
83
+
84
+ def actual_days(work_item, date)
85
+ record = @record_lookup[work_item.record_id.to_s.upcase]
86
+ return 0.0 if record.nil? || @hours_per_day.zero?
87
+
88
+ record.row_actual_hours_on(work_item.activity, date) / @hours_per_day
89
+ end
90
+
91
+ def clamp01(value)
92
+ value.clamp(0.0, 1.0)
93
+ end
94
+ end
@@ -3,9 +3,9 @@ require_relative '../link_registry'
3
3
  class ProjectData
4
4
  attr_reader :specifications, :protocols, :traceability_matrices, :coverage_matrices, :source_files,
5
5
  :specifications_dictionary, :covered_specifications_dictionary, :implemented_specifications_dictionary,
6
- :implementation_matrices, :decisions, :link_registry
6
+ :implementation_matrices, :decisions, :decision_groups, :work_items, :link_registry
7
7
 
8
- def initialize # rubocop:disable Metrics/MethodLength
8
+ def initialize
9
9
  @specifications = []
10
10
  @protocols = []
11
11
  @traceability_matrices = []
@@ -13,6 +13,12 @@ class ProjectData
13
13
  @source_files = []
14
14
  @implementation_matrices = []
15
15
  @decisions = []
16
+ # Insertion-ordered list of single-key hashes { "<first-level folder>" => [Decision, ...] },
17
+ # grouping decision records by the planning folder they live in (see ADR-197).
18
+ @decision_groups = []
19
+ # Every Scope-row WorkItem across all decision records, keyed by its canonical
20
+ # "<record>.<step>.<activity>" id (see ADR-194), populated by link_work_items.
21
+ @work_items = {}
16
22
 
17
23
  @specifications_dictionary = {}
18
24
  @covered_specifications_dictionary = {}
@@ -0,0 +1,166 @@
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.
9
+ #
10
+ # Durations are a constant placeholder (3 days) while no per-row estimates
11
+ # exist; `duration_for` is the single hook ADR-195 overrides to feed real
12
+ # estimates without changing the scheduling logic.
13
+ #
14
+ # Days are 1-based. A work item starting on day `s` with duration `d` occupies
15
+ # the inclusive day span `s .. s + d - 1`; its *finish* (the next free day,
16
+ # returned in the ends map) is `s + d`, the earliest a successor or the same
17
+ # owner's next item may start.
18
+ class WorkItemScheduler
19
+ # Placeholder duration for every work item until ADR-195 supplies estimates.
20
+ DEFAULT_DURATION = 3
21
+
22
+ def initialize(work_items, duration: DEFAULT_DURATION)
23
+ @items = work_items
24
+ @duration = duration
25
+ @item_set = work_items.to_set
26
+ end
27
+
28
+ # { work_item => start_day }, day index 1-based. Empty when there are no items.
29
+ def start_days
30
+ schedule unless @starts
31
+ @starts
32
+ end
33
+
34
+ # The number of day columns the chart needs: the latest finish minus one
35
+ # (finishes are the exclusive next-free day). Zero when there are no items.
36
+ def day_count
37
+ schedule unless @ends
38
+ @ends.empty? ? 0 : (@ends.values.max - 1)
39
+ end
40
+
41
+ # The schedule length in working days (the latest finish, day 1 being the start).
42
+ def makespan
43
+ day_count
44
+ end
45
+
46
+ # The critical chain: the row with the latest finish, traced back through its
47
+ # binding predecessors (the dependency and resource hand-offs that set each
48
+ # row's start), returned in start order. Empty when nothing is scheduled.
49
+ def critical_chain
50
+ schedule unless @starts
51
+ return [] if @ends.empty?
52
+
53
+ max_end = @ends.values.max
54
+ node = @items.select { |wi| @ends[wi] == max_end }.min_by { |wi| [wi.record_id, wi.step] }
55
+ chain = []
56
+ while node
57
+ chain.unshift(node)
58
+ node = @binding[node]
59
+ end
60
+ chain
61
+ end
62
+
63
+ def duration_for(_work_item)
64
+ @duration
65
+ end
66
+
67
+ private
68
+
69
+ # Greedy list-scheduler. Items are processed in a deterministic priority order
70
+ # (resource-free earliest start, then activity rank, record id, step) so that
71
+ # every item's predecessors are placed before it. Each item starts at the later
72
+ # of its dependency finish and its owner's next free day; the owner's cursor
73
+ # then advances past it, serialising the lane (resource levelling).
74
+ def schedule
75
+ @starts = {}
76
+ @ends = {}
77
+ @binding = {}
78
+ @owner_last = {}
79
+ return if @items.empty?
80
+
81
+ owner_free = Hash.new(1)
82
+ priority_order.each { |wi| place(wi, owner_free) }
83
+ end
84
+
85
+ # Assigns one work item its start day at the later of its dependency finish and
86
+ # its owner's next free day, records the predecessor that bound that start, then
87
+ # advances that owner's free cursor past it.
88
+ def place(work_item, owner_free)
89
+ owner = work_item.owner
90
+ preds = scoped_predecessors(work_item)
91
+ start = start_day(preds, owner_free[owner])
92
+ @starts[work_item] = start
93
+ @ends[work_item] = start + duration_for(work_item)
94
+ @binding[work_item] = binding_predecessor(work_item, preds, start, owner)
95
+ advance_owner(owner, work_item, owner_free)
96
+ end
97
+
98
+ # The earliest day a row may start: the later of its in-scope predecessors'
99
+ # latest finish and its owner's next free day.
100
+ def start_day(preds, owner_free_day)
101
+ dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
102
+ [dep_finish, owner_free_day].max
103
+ end
104
+
105
+ # Advance an owner's free cursor past the just-placed row, and remember it as
106
+ # that owner's most recent row (the resource hand-off candidate). A blank owner
107
+ # holds no resource, so it never serialises.
108
+ def advance_owner(owner, work_item, owner_free)
109
+ return if owner.empty?
110
+
111
+ owner_free[owner] = @ends[work_item]
112
+ @owner_last[owner] = work_item
113
+ end
114
+
115
+ # The already-placed predecessor whose finish coincides with this row's start --
116
+ # the dependency or same-owner hand-off the critical chain is traced back
117
+ # through. nil when the row starts at the origin with no such predecessor.
118
+ def binding_predecessor(_work_item, preds, start, owner)
119
+ candidates = preds.select { |p| @ends[p] == start }
120
+ resource_pred = @owner_last[owner]
121
+ candidates << resource_pred if resource_pred && @ends[resource_pred] == start
122
+ candidates.min_by { |c| [c.record_id, c.step] }
123
+ end
124
+
125
+ def priority_order
126
+ memo = {}
127
+ @items.sort_by { |wi| priority_key(wi, memo) }
128
+ end
129
+
130
+ # The deterministic scheduling priority for a row. Overridden by the critical-
131
+ # chain scheduler (ADR-195) to prioritise the longest downstream duration.
132
+ def priority_key(work_item, memo)
133
+ [dependency_start(work_item, memo, []), work_item.activity_rank, work_item.record_id, work_item.step]
134
+ end
135
+
136
+ # The earliest day this item could start ignoring resource contention: 1 when
137
+ # it has no predecessors, else one past the latest predecessor finish. Memoised
138
+ # and cycle-guarded — a back-edge (which the DAG-by-construction network should
139
+ # never have) is treated as start 1 so scheduling still completes.
140
+ def dependency_start(work_item, memo, stack)
141
+ return memo[work_item] if memo.key?(work_item)
142
+ return 1 if stack.include?(work_item)
143
+
144
+ preds = scoped_predecessors(work_item)
145
+ return memo[work_item] = 1 if preds.empty?
146
+
147
+ stack.push(work_item)
148
+ earliest = preds.map { |p| dependency_start(p, memo, stack) + duration_for(p) }.max
149
+ stack.pop
150
+ memo[work_item] = earliest
151
+ end
152
+
153
+ # Predecessors inside this scheduler's own item set. A predecessor scheduled in
154
+ # another scope (e.g. a different decision group, ADR-201) is treated as an
155
+ # already-available external input: it is dropped here and imposes no finish
156
+ # constraint, so the dependent simply starts at day 1 with respect to it.
157
+ def scoped_predecessors(work_item)
158
+ work_item.predecessor_items.select { |p| @item_set.include?(p) }
159
+ end
160
+
161
+ # Successors inside this scheduler's own item set (the mirror of
162
+ # scoped_predecessors), used by the critical-chain priority.
163
+ def scoped_successors(work_item)
164
+ work_item.successor_items.select { |s| @item_set.include?(s) }
165
+ end
166
+ end
@@ -0,0 +1,112 @@
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