sof-cycle 0.1.0
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 +7 -0
- data/.rspec +2 -0
- data/.simplecov +4 -0
- data/CHANGELOG.md +12 -0
- data/README.md +27 -0
- data/Rakefile +17 -0
- data/lib/sof/cycle/parser.rb +107 -0
- data/lib/sof/cycle/time_span.rb +236 -0
- data/lib/sof/cycle/version.rb +7 -0
- data/lib/sof/cycle.rb +353 -0
- data/lib/sof-cycle.rb +1 -0
- metadata +82 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 416c2fd3c9e369ece57bafe7cbf5a58f07b9e30177c04b7253059cb87d6e32dc
|
4
|
+
data.tar.gz: 00fd30f51d4efb2eeabbe7eef1b8fdd58a6290a08579ae661984b2de359585e4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 582f1597715e073ea8d517b8236ad3daf81fcdf9ce023c860a77ed2ddb4847e4df027b606764915d816d7d5ddd06ec383bcae58fb0174e8e1e6f02d97265ed71
|
7
|
+
data.tar.gz: 8d9e747a1ac2d0a649f911f6c7de8e81dc7a8139294c41c37eb20b8d9cc67e52e5242f61df1315b74aab24127f5c0c7ea7738a9d9fc7e1130ddc94dec9de6445
|
data/.rspec
ADDED
data/.simplecov
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# CHANGELOG
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [0.1.0] - 2024-07-09
|
9
|
+
|
10
|
+
### Added
|
11
|
+
|
12
|
+
- Initial extraction
|
data/README.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# SOF::Cycle
|
2
|
+
|
3
|
+
Parse and interact with SOF cycle notation.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install the gem and add to the application's Gemfile by executing:
|
8
|
+
|
9
|
+
$ bundle add sof-cycle
|
10
|
+
|
11
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
12
|
+
|
13
|
+
$ gem install sof-cycle
|
14
|
+
|
15
|
+
## Usage
|
16
|
+
|
17
|
+
TODO: Write usage instructions here
|
18
|
+
|
19
|
+
## Development
|
20
|
+
|
21
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
22
|
+
|
23
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
24
|
+
|
25
|
+
## Contributing
|
26
|
+
|
27
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/sof-cycle.
|
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
|
5
|
+
require "rspec/core/rake_task"
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
8
|
+
t.pattern = "spec/**/*_spec.rb"
|
9
|
+
end
|
10
|
+
|
11
|
+
task default: :spec
|
12
|
+
|
13
|
+
require "reissue/gem"
|
14
|
+
|
15
|
+
Reissue::Task.create do |task|
|
16
|
+
task.version_file = "lib/sof/cycle/version.rb"
|
17
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../cycle"
|
4
|
+
require "active_support/core_ext/hash/keys"
|
5
|
+
require "active_support/core_ext/object/blank"
|
6
|
+
require "active_support/core_ext/object/inclusion"
|
7
|
+
require "active_support/core_ext/hash/reverse_merge"
|
8
|
+
require "active_support/isolated_execution_state"
|
9
|
+
|
10
|
+
module SOF
|
11
|
+
# This class is not intended to be referenced directly.
|
12
|
+
# This is an internal implementation of Cycle behavior.
|
13
|
+
class Cycle::Parser
|
14
|
+
extend Forwardable
|
15
|
+
PARTS_REGEX = /
|
16
|
+
^(?<vol>V(?<volume>\d*))? # optional volume
|
17
|
+
(?<set>(?<kind>L|C|W) # kind
|
18
|
+
(?<period_count>\d+) # period count
|
19
|
+
(?<period_key>D|W|M|Q|Y)?)? # period_key
|
20
|
+
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
|
21
|
+
/ix
|
22
|
+
|
23
|
+
def self.dormant_capable_kinds = %w[W]
|
24
|
+
|
25
|
+
def self.for(str_or_notation)
|
26
|
+
return str_or_notation if str_or_notation.is_a? self
|
27
|
+
|
28
|
+
new(str_or_notation)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.load(hash)
|
32
|
+
hash.symbolize_keys!
|
33
|
+
hash.reverse_merge!(volume: 1)
|
34
|
+
keys = %i[volume kind period_count period_key]
|
35
|
+
str = "V#{hash.values_at(*keys).join}"
|
36
|
+
return new(str) unless hash[:from_date]
|
37
|
+
|
38
|
+
new([str, "F#{hash[:from_date]}"].join)
|
39
|
+
end
|
40
|
+
|
41
|
+
def initialize(notation)
|
42
|
+
@notation = notation&.upcase
|
43
|
+
@match = @notation&.match(PARTS_REGEX)
|
44
|
+
end
|
45
|
+
|
46
|
+
attr_reader :match, :notation
|
47
|
+
|
48
|
+
delegate [:dormant_capable_kinds] => "self.class"
|
49
|
+
delegate [:period, :humanized_period] => :time_span
|
50
|
+
|
51
|
+
# Return a TimeSpan object for the period and period_count
|
52
|
+
def time_span
|
53
|
+
@time_span ||= Cycle::TimeSpan.for(period_count, period_key)
|
54
|
+
end
|
55
|
+
|
56
|
+
def valid? = match.present?
|
57
|
+
|
58
|
+
def inspect = notation
|
59
|
+
alias_method :to_s, :inspect
|
60
|
+
|
61
|
+
def activated_notation(date)
|
62
|
+
return notation unless dormant_capable?
|
63
|
+
|
64
|
+
self.class.load(to_h.merge(from_date: date.to_date)).notation
|
65
|
+
end
|
66
|
+
|
67
|
+
def ==(other) = other.to_h == to_h
|
68
|
+
|
69
|
+
def to_h
|
70
|
+
{
|
71
|
+
volume:,
|
72
|
+
kind:,
|
73
|
+
period_count:,
|
74
|
+
period_key:,
|
75
|
+
from_date:
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
def parses?(notation_id) = kind == notation_id
|
80
|
+
|
81
|
+
def active? = !dormant?
|
82
|
+
|
83
|
+
def dormant? = dormant_capable? && from_date.nil?
|
84
|
+
|
85
|
+
def dormant_capable? = kind.in?(dormant_capable_kinds)
|
86
|
+
|
87
|
+
def period_count = match[:period_count]
|
88
|
+
|
89
|
+
def period_key = match[:period_key]
|
90
|
+
|
91
|
+
def vol = match[:vol] || "V1"
|
92
|
+
|
93
|
+
def volume = (match[:volume] || 1).to_i
|
94
|
+
|
95
|
+
def from_data
|
96
|
+
return {} unless from
|
97
|
+
|
98
|
+
{from: from}
|
99
|
+
end
|
100
|
+
|
101
|
+
def from_date = match[:from_date]
|
102
|
+
|
103
|
+
def from = match[:from]
|
104
|
+
|
105
|
+
def kind = match[:kind]
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,236 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../cycle"
|
4
|
+
require "active_support/deprecator"
|
5
|
+
require "active_support/deprecation"
|
6
|
+
require "active_support/core_ext/numeric/time"
|
7
|
+
require "active_support/core_ext/integer/time"
|
8
|
+
require "active_support/core_ext/string/conversions"
|
9
|
+
module SOF
|
10
|
+
# This class is not intended to be referenced directly.
|
11
|
+
# This is an internal implementation of Cycle behavior.
|
12
|
+
class Cycle::TimeSpan
|
13
|
+
extend Forwardable
|
14
|
+
# TimeSpan objects map Cycle notations to behaviors for their periods
|
15
|
+
#
|
16
|
+
# For example:
|
17
|
+
# 'M' => TimeSpan::DatePeriod::Month
|
18
|
+
# 'Y' => TimeSpan::DatePeriod::Year
|
19
|
+
# Read each DatePeriod subclass for more information.
|
20
|
+
#
|
21
|
+
class InvalidPeriod < StandardError; end
|
22
|
+
|
23
|
+
class << self
|
24
|
+
# Return a time_span for the given count and period
|
25
|
+
def for(count, period)
|
26
|
+
case count.to_i
|
27
|
+
when 0
|
28
|
+
TimeSpanNothing
|
29
|
+
when 1
|
30
|
+
TimeSpanOne
|
31
|
+
else
|
32
|
+
self
|
33
|
+
end.new(count, period)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Return a notation string from a hash
|
37
|
+
def notation(hash)
|
38
|
+
return unless hash.key?(:period)
|
39
|
+
|
40
|
+
[
|
41
|
+
hash.fetch(:period_count) { 1 },
|
42
|
+
notation_id_from_name(hash[:period])
|
43
|
+
].compact.join
|
44
|
+
end
|
45
|
+
|
46
|
+
# Return the notation character for the given period name
|
47
|
+
def notation_id_from_name(name)
|
48
|
+
type = DatePeriod.types.find do |klass|
|
49
|
+
klass.period.to_s == name.to_s
|
50
|
+
end
|
51
|
+
|
52
|
+
raise InvalidPeriod, "'#{name}' is not a valid period" unless type
|
53
|
+
|
54
|
+
type.code
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Class used to calculate the windows of time so that
|
59
|
+
# a TimeSpan object will know the correct end of year,
|
60
|
+
# quarter, etc.
|
61
|
+
class DatePeriod
|
62
|
+
extend Forwardable
|
63
|
+
class << self
|
64
|
+
def for(count, period_notation)
|
65
|
+
@cached_periods ||= {}
|
66
|
+
@cached_periods[period_notation] ||= {}
|
67
|
+
@cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count)
|
68
|
+
@cached_periods[period_notation][count]
|
69
|
+
end
|
70
|
+
|
71
|
+
def for_notation(notation)
|
72
|
+
types.find do |klass|
|
73
|
+
klass.code == notation.to_s.upcase
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def types = @types ||= Set.new
|
78
|
+
|
79
|
+
def inherited(klass)
|
80
|
+
DatePeriod.types << klass
|
81
|
+
end
|
82
|
+
|
83
|
+
@period = nil
|
84
|
+
@code = nil
|
85
|
+
@interval = nil
|
86
|
+
attr_reader :period, :code, :interval
|
87
|
+
end
|
88
|
+
|
89
|
+
delegate [:period, :code, :interval] => "self.class"
|
90
|
+
|
91
|
+
def initialize(count)
|
92
|
+
@count = count
|
93
|
+
end
|
94
|
+
attr_reader :count
|
95
|
+
|
96
|
+
def end_date(date)
|
97
|
+
@end_date ||= {}
|
98
|
+
@end_date[date] ||= date + duration
|
99
|
+
end
|
100
|
+
|
101
|
+
def begin_date(date)
|
102
|
+
@begin_date ||= {}
|
103
|
+
@begin_date[date] ||= date - duration
|
104
|
+
end
|
105
|
+
|
106
|
+
def duration = count.send(period)
|
107
|
+
|
108
|
+
def end_of_period(_) = nil
|
109
|
+
|
110
|
+
def humanized_period
|
111
|
+
return period if count == 1
|
112
|
+
|
113
|
+
"#{period}s"
|
114
|
+
end
|
115
|
+
|
116
|
+
class Year < self
|
117
|
+
@period = :year
|
118
|
+
@code = "Y"
|
119
|
+
@interval = "years"
|
120
|
+
|
121
|
+
def end_of_period(date)
|
122
|
+
date.end_of_year
|
123
|
+
end
|
124
|
+
|
125
|
+
def beginning_of_period(date)
|
126
|
+
date.beginning_of_year
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
class Quarter < self
|
131
|
+
@period = :quarter
|
132
|
+
@code = "Q"
|
133
|
+
@interval = "quarters"
|
134
|
+
|
135
|
+
def duration
|
136
|
+
(count * 3).months
|
137
|
+
end
|
138
|
+
|
139
|
+
def end_of_period(date)
|
140
|
+
date.end_of_quarter
|
141
|
+
end
|
142
|
+
|
143
|
+
def beginning_of_period(date)
|
144
|
+
date.beginning_of_quarter
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
class Month < self
|
149
|
+
@period = :month
|
150
|
+
@code = "M"
|
151
|
+
@interval = "months"
|
152
|
+
|
153
|
+
def end_of_period(date)
|
154
|
+
date.end_of_month
|
155
|
+
end
|
156
|
+
|
157
|
+
def beginning_of_period(date)
|
158
|
+
date.beginning_of_month
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
class Week < self
|
163
|
+
@period = :week
|
164
|
+
@code = "W"
|
165
|
+
@interval = "weeks"
|
166
|
+
|
167
|
+
def end_of_period(date)
|
168
|
+
date.end_of_week
|
169
|
+
end
|
170
|
+
|
171
|
+
def beginning_of_period(date)
|
172
|
+
date.beginning_of_week
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
class Day < self
|
177
|
+
@period = :day
|
178
|
+
@code = "D"
|
179
|
+
@interval = "days"
|
180
|
+
|
181
|
+
def end_of_period(date)
|
182
|
+
date
|
183
|
+
end
|
184
|
+
|
185
|
+
def beginning_of_period(date)
|
186
|
+
date
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
private_constant :DatePeriod
|
191
|
+
|
192
|
+
def initialize(count, period_id)
|
193
|
+
@count = Integer(count, exception: false)
|
194
|
+
@window = DatePeriod.for(period_count, period_id)
|
195
|
+
end
|
196
|
+
attr_reader :window
|
197
|
+
|
198
|
+
delegate [:end_date, :begin_date] => :window
|
199
|
+
|
200
|
+
def end_date_of_period(date)
|
201
|
+
window.end_of_period(date)
|
202
|
+
end
|
203
|
+
|
204
|
+
def begin_date_of_period(date)
|
205
|
+
window.beginning_of_period(date)
|
206
|
+
end
|
207
|
+
|
208
|
+
# Integer value for the period count or nil
|
209
|
+
def period_count
|
210
|
+
@count
|
211
|
+
end
|
212
|
+
|
213
|
+
delegate [:period, :duration, :interval, :humanized_period] => :window
|
214
|
+
|
215
|
+
# Return a date according to the rules of the time_span
|
216
|
+
def final_date(date)
|
217
|
+
return unless period
|
218
|
+
|
219
|
+
window.end_date(date.to_date)
|
220
|
+
end
|
221
|
+
|
222
|
+
def to_h
|
223
|
+
{
|
224
|
+
period:,
|
225
|
+
period_count:
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
class TimeSpanNothing < self
|
230
|
+
end
|
231
|
+
|
232
|
+
class TimeSpanOne < self
|
233
|
+
def interval = humanized_period
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
data/lib/sof/cycle.rb
ADDED
@@ -0,0 +1,353 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "cycle/version"
|
4
|
+
require "forwardable"
|
5
|
+
require_relative "cycle/parser"
|
6
|
+
require_relative "cycle/time_span"
|
7
|
+
require "active_support/core_ext/date/conversions"
|
8
|
+
require "active_support/core_ext/string/filters"
|
9
|
+
|
10
|
+
module SOF
|
11
|
+
class Cycle
|
12
|
+
extend Forwardable
|
13
|
+
class InvalidInput < StandardError; end
|
14
|
+
|
15
|
+
class InvalidPeriod < InvalidInput; end
|
16
|
+
|
17
|
+
class InvalidKind < InvalidInput; end
|
18
|
+
|
19
|
+
def initialize(notation, parser: Parser.new(notation))
|
20
|
+
@notation = notation
|
21
|
+
@parser = parser
|
22
|
+
validate_period
|
23
|
+
|
24
|
+
return if @parser.valid?
|
25
|
+
|
26
|
+
raise InvalidInput, "'#{notation}' is not a valid input"
|
27
|
+
end
|
28
|
+
|
29
|
+
attr_reader :parser
|
30
|
+
|
31
|
+
delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
|
32
|
+
:humanized_period, :period_key, :active?] => :@parser
|
33
|
+
delegate [:kind, :volume_only?, :valid_periods] => "self.class"
|
34
|
+
delegate [:period_count, :duration] => :time_span
|
35
|
+
|
36
|
+
# Turn a cycle or notation string into a hash
|
37
|
+
def self.dump(cycle_or_string)
|
38
|
+
if cycle_or_string.is_a? Cycle
|
39
|
+
cycle_or_string
|
40
|
+
else
|
41
|
+
Cycle.for(cycle_or_string)
|
42
|
+
end.to_h
|
43
|
+
end
|
44
|
+
|
45
|
+
# Return a Cycle object from a hash
|
46
|
+
def self.load(hash)
|
47
|
+
symbolized_hash = hash.symbolize_keys
|
48
|
+
cycle_class = class_for_kind(symbolized_hash[:kind])
|
49
|
+
|
50
|
+
unless cycle_class.valid_periods.empty?
|
51
|
+
cycle_class.validate_period(
|
52
|
+
TimeSpan.notation_id_from_name(symbolized_hash[:period])
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
Cycle.for notation(symbolized_hash)
|
57
|
+
rescue TimeSpan::InvalidPeriod => exc
|
58
|
+
raise InvalidPeriod, exc.message
|
59
|
+
end
|
60
|
+
|
61
|
+
# Retun a notation string from a hash
|
62
|
+
#
|
63
|
+
# @param hash [Hash] hash of data for a valid Cycle
|
64
|
+
# @return [String] string representation of a Cycle
|
65
|
+
def self.notation(hash)
|
66
|
+
volume_notation = "V#{hash.fetch(:volume) { 1 }}"
|
67
|
+
return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only
|
68
|
+
|
69
|
+
cycle_class = class_for_kind(hash[:kind].to_sym)
|
70
|
+
[
|
71
|
+
volume_notation,
|
72
|
+
cycle_class.notation_id,
|
73
|
+
TimeSpan.notation(hash.slice(:period, :period_count)),
|
74
|
+
hash.fetch(:from, nil)
|
75
|
+
].compact.join
|
76
|
+
end
|
77
|
+
|
78
|
+
# Return a Cycle object from a notation string
|
79
|
+
#
|
80
|
+
# @param notation [String] a string notation representing a Cycle
|
81
|
+
# @example
|
82
|
+
# Cycle.for('V2C1Y)
|
83
|
+
# @return [Cycle] a Cycle object representing the provide string notation
|
84
|
+
def self.for(notation)
|
85
|
+
return notation if notation.is_a? Cycle
|
86
|
+
return notation if notation.is_a? Cycle::Dormant
|
87
|
+
parser = Parser.new(notation)
|
88
|
+
unless parser.valid?
|
89
|
+
raise InvalidInput, "'#{notation}' is not a valid input"
|
90
|
+
end
|
91
|
+
|
92
|
+
cycle = cycle_handlers.find do |klass|
|
93
|
+
parser.parses?(klass.notation_id)
|
94
|
+
end.new(notation, parser:)
|
95
|
+
return cycle if parser.active?
|
96
|
+
|
97
|
+
Cycle::Dormant.new(cycle, parser:)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Return the appropriate class for the give notation id
|
101
|
+
#
|
102
|
+
# @param notation [String] notation id matching the kind of Cycle class
|
103
|
+
# @example
|
104
|
+
# class_for_notation_id('L')
|
105
|
+
#
|
106
|
+
def self.class_for_notation_id(notation_id)
|
107
|
+
cycle_handlers.find do |klass|
|
108
|
+
klass.notation_id == notation_id
|
109
|
+
end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
|
110
|
+
end
|
111
|
+
|
112
|
+
# Return the class handling the kind
|
113
|
+
#
|
114
|
+
# @param sym [Symbol] symbol matching the kind of Cycle class
|
115
|
+
# @example
|
116
|
+
# class_for_kind(:lookback)
|
117
|
+
def self.class_for_kind(sym)
|
118
|
+
Cycle.cycle_handlers.find do |klass|
|
119
|
+
klass.handles?(sym)
|
120
|
+
end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.cycle_handlers = @cycle_handlers ||= Set.new
|
124
|
+
|
125
|
+
def self.inherited(klass) = cycle_handlers << klass
|
126
|
+
|
127
|
+
def self.handles?(sym)
|
128
|
+
sym && kind == sym.to_sym
|
129
|
+
end
|
130
|
+
|
131
|
+
@volume_only = false
|
132
|
+
@notation_id = nil
|
133
|
+
@kind = nil
|
134
|
+
@valid_periods = []
|
135
|
+
|
136
|
+
def self.volume_only? = @volume_only
|
137
|
+
|
138
|
+
class << self
|
139
|
+
attr_reader :notation_id, :kind, :valid_periods
|
140
|
+
end
|
141
|
+
|
142
|
+
# Raises an error if the given period isn't in the list of valid periods.
|
143
|
+
#
|
144
|
+
# @param period [String] period matching the class valid periods
|
145
|
+
# @raise [InvalidPeriod]
|
146
|
+
def self.validate_period(period)
|
147
|
+
raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
|
148
|
+
Invalid period value of '#{period}' provided. Valid periods are:
|
149
|
+
#{valid_periods.join(", ")}
|
150
|
+
ERR
|
151
|
+
end
|
152
|
+
|
153
|
+
def validate_period
|
154
|
+
return if valid_periods.empty?
|
155
|
+
|
156
|
+
self.class.validate_period(period_key)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Return the cycle representation as a notation string
|
160
|
+
def notation = self.class.notation(to_h)
|
161
|
+
|
162
|
+
# Cycles are considered equal if their hash representations are equal
|
163
|
+
def ==(other) = to_h == other.to_h
|
164
|
+
|
165
|
+
# From the supplied anchor date, are there enough in-window completions to
|
166
|
+
# satisfy the cycle?
|
167
|
+
#
|
168
|
+
# @return [Boolean] true if the cycle is satisfied, false otherwise
|
169
|
+
def satisfied_by?(completion_dates, anchor: Date.current)
|
170
|
+
covered_dates(completion_dates, anchor:).size >= volume
|
171
|
+
end
|
172
|
+
|
173
|
+
def covered_dates(dates, anchor: Date.current)
|
174
|
+
dates.select do |date|
|
175
|
+
cover?(date, anchor:)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def cover?(date, anchor: Date.current)
|
180
|
+
range(anchor).cover?(date)
|
181
|
+
end
|
182
|
+
|
183
|
+
def range(anchor) = start_date(anchor)..final_date(anchor)
|
184
|
+
|
185
|
+
def humanized_span = [period_count, humanized_period].join(" ")
|
186
|
+
|
187
|
+
# Return the final date of the cycle
|
188
|
+
def final_date(_anchor) = nil
|
189
|
+
|
190
|
+
def expiration_of(_completion_dates, anchor: Date.current) = nil
|
191
|
+
|
192
|
+
def volume_to_delay_expiration(_completion_dates, anchor:) = 0
|
193
|
+
|
194
|
+
def to_h
|
195
|
+
{
|
196
|
+
kind:,
|
197
|
+
volume:,
|
198
|
+
period:,
|
199
|
+
period_count:,
|
200
|
+
**from_data
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
def from_data
|
205
|
+
return {} unless from
|
206
|
+
|
207
|
+
{from: from}
|
208
|
+
end
|
209
|
+
|
210
|
+
def as_json(...) = notation
|
211
|
+
|
212
|
+
class Dormant
|
213
|
+
def initialize(cycle, parser:)
|
214
|
+
@cycle = cycle
|
215
|
+
@parser = parser
|
216
|
+
end
|
217
|
+
|
218
|
+
attr_reader :cycle, :parser
|
219
|
+
|
220
|
+
def to_s
|
221
|
+
cycle.to_s + " (dormant)"
|
222
|
+
end
|
223
|
+
|
224
|
+
def covered_dates(...) = []
|
225
|
+
|
226
|
+
def expiration_of(...) = nil
|
227
|
+
|
228
|
+
def satisfied_by?(...) = false
|
229
|
+
|
230
|
+
def cover?(...) = false
|
231
|
+
|
232
|
+
def method_missing(method, ...) = cycle.send(method, ...)
|
233
|
+
|
234
|
+
def respond_to_missing?(method, include_private = false)
|
235
|
+
cycle.respond_to?(method, include_private)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
class Within < self
|
240
|
+
@volume_only = false
|
241
|
+
@notation_id = "W"
|
242
|
+
@kind = :within
|
243
|
+
@valid_periods = %w[D W M Y]
|
244
|
+
|
245
|
+
def to_s = "#{volume}x within #{date_range}"
|
246
|
+
|
247
|
+
def date_range
|
248
|
+
return humanized_span unless active?
|
249
|
+
|
250
|
+
[start_date, final_date].map { _1.to_fs(:american) }.join(" - ")
|
251
|
+
end
|
252
|
+
|
253
|
+
def final_date(_ = nil) = time_span.end_date(start_date)
|
254
|
+
|
255
|
+
def start_date(_ = nil) = from_date.to_date
|
256
|
+
end
|
257
|
+
|
258
|
+
class VolumeOnly < self
|
259
|
+
@volume_only = true
|
260
|
+
@notation_id = nil
|
261
|
+
@kind = :volume_only
|
262
|
+
@valid_periods = []
|
263
|
+
|
264
|
+
class << self
|
265
|
+
def handles?(sym) = sym.nil? || super
|
266
|
+
|
267
|
+
def validate_period(period)
|
268
|
+
raise InvalidPeriod, <<~ERR.squish unless period.nil?
|
269
|
+
Invalid period value of '#{period}' provided. Valid periods are:
|
270
|
+
#{valid_periods.join(", ")}
|
271
|
+
ERR
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def to_s = "#{volume}x total"
|
276
|
+
|
277
|
+
def covered_dates(dates, ...) = dates
|
278
|
+
|
279
|
+
def cover?(...) = true
|
280
|
+
end
|
281
|
+
|
282
|
+
class Lookback < self
|
283
|
+
@volume_only = false
|
284
|
+
@notation_id = "L"
|
285
|
+
@kind = :lookback
|
286
|
+
@valid_periods = %w[D W M Y]
|
287
|
+
|
288
|
+
def to_s = "#{volume}x in the prior #{period_count} #{humanized_period}"
|
289
|
+
|
290
|
+
def volume_to_delay_expiration(completion_dates, anchor:)
|
291
|
+
oldest_relevant_completion = completion_dates.min
|
292
|
+
[completion_dates.count(oldest_relevant_completion), volume].min
|
293
|
+
end
|
294
|
+
|
295
|
+
# "Absent further completions, you go red on this date"
|
296
|
+
# @return [Date, nil] the date on which the cycle will expire given the
|
297
|
+
# provided completion dates. Returns nil if the cycle is already unsatisfied.
|
298
|
+
def expiration_of(completion_dates)
|
299
|
+
anchor = completion_dates.max_by(volume) { _1 }.min
|
300
|
+
return unless satisfied_by?(completion_dates, anchor:)
|
301
|
+
|
302
|
+
window_end anchor
|
303
|
+
end
|
304
|
+
|
305
|
+
def final_date(anchor)
|
306
|
+
return if anchor.nil?
|
307
|
+
|
308
|
+
time_span.end_date(anchor.to_date)
|
309
|
+
end
|
310
|
+
alias_method :window_end, :final_date
|
311
|
+
|
312
|
+
def start_date(anchor)
|
313
|
+
time_span.begin_date(anchor.to_date)
|
314
|
+
end
|
315
|
+
alias_method :window_start, :start_date
|
316
|
+
end
|
317
|
+
|
318
|
+
class Calendar < self
|
319
|
+
@volume_only = false
|
320
|
+
@notation_id = "C"
|
321
|
+
@kind = :calendar
|
322
|
+
@valid_periods = %w[M Q Y]
|
323
|
+
|
324
|
+
class << self
|
325
|
+
def frame_of_reference = "total"
|
326
|
+
end
|
327
|
+
|
328
|
+
def to_s
|
329
|
+
"#{volume}x every #{period_count} calendar #{humanized_period}"
|
330
|
+
end
|
331
|
+
|
332
|
+
# "Absent further completions, you go red on this date"
|
333
|
+
# @return [Date, nil] the date on which the cycle will expire given the
|
334
|
+
# provided completion dates. Returns nil if the cycle is already unsatisfied.
|
335
|
+
def expiration_of(completion_dates)
|
336
|
+
anchor = completion_dates.max_by(volume) { _1 }.min
|
337
|
+
return unless satisfied_by?(completion_dates, anchor:)
|
338
|
+
|
339
|
+
window_end(anchor) + duration
|
340
|
+
end
|
341
|
+
|
342
|
+
def final_date(anchor)
|
343
|
+
return if anchor.nil?
|
344
|
+
time_span.end_date_of_period(anchor.to_date)
|
345
|
+
end
|
346
|
+
alias_method :window_end, :final_date
|
347
|
+
|
348
|
+
def start_date(anchor)
|
349
|
+
time_span.begin_date_of_period(anchor.to_date)
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
data/lib/sof-cycle.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require_relative "sof/cycle"
|
metadata
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sof-cycle
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jim Gay
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-07-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: forwardable
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- jim@saturnflyer.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".rspec"
|
49
|
+
- ".simplecov"
|
50
|
+
- CHANGELOG.md
|
51
|
+
- README.md
|
52
|
+
- Rakefile
|
53
|
+
- lib/sof-cycle.rb
|
54
|
+
- lib/sof/cycle.rb
|
55
|
+
- lib/sof/cycle/parser.rb
|
56
|
+
- lib/sof/cycle/time_span.rb
|
57
|
+
- lib/sof/cycle/version.rb
|
58
|
+
homepage: https://github.com/SOFware/sof-cycle
|
59
|
+
licenses: []
|
60
|
+
metadata:
|
61
|
+
homepage_uri: https://github.com/SOFware/sof-cycle
|
62
|
+
changelog_uri: https://github.com/SOFware/sof-cycle/blob/main/CHANGELOG.md
|
63
|
+
post_install_message:
|
64
|
+
rdoc_options: []
|
65
|
+
require_paths:
|
66
|
+
- lib
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: 3.0.0
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
requirements: []
|
78
|
+
rubygems_version: 3.5.9
|
79
|
+
signing_key:
|
80
|
+
specification_version: 4
|
81
|
+
summary: Parse and interact with SOF cycle notation.
|
82
|
+
test_files: []
|