sof-cycle 0.1.14 → 0.1.15
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/CHANGELOG.md +2 -14
- data/checksums/sof-cycle-0.1.13.gem.sha512 +1 -0
- data/checksums/sof-cycle-0.1.14.gem.sha512 +1 -0
- data/checksums/sof-cycle-0.1.3.gem.sha512 +1 -0
- data/checksums/sof-cycle-0.1.4.gem.sha512 +1 -0
- data/checksums/sof-cycle-0.1.5.gem.sha512 +1 -0
- data/lib/sof/cycle/parser.rb +127 -0
- data/lib/sof/cycle/registry.rb +86 -0
- data/lib/sof/cycle/time_span.rb +317 -0
- data/lib/sof/cycle/version.rb +1 -1
- data/lib/sof/cycle.rb +114 -30
- data/lib/sof/cycles/calendar.rb +1 -4
- data/lib/sof/cycles/dormant.rb +4 -0
- data/lib/sof/cycles/end_of.rb +1 -4
- data/lib/sof/cycles/interval.rb +1 -4
- data/lib/sof/cycles/lookback.rb +1 -4
- data/lib/sof/cycles/lookback_end_of.rb +1 -4
- data/lib/sof/cycles/volume_only.rb +1 -4
- data/lib/sof/cycles/within.rb +2 -5
- data/lib/sof-cycle.rb +1 -1
- metadata +9 -3
- data/lib/sof/parser.rb +0 -110
- data/lib/sof/time_span.rb +0 -236
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ace7e7bdb57808f1c633cf78aaab4fc7949e6820e86089c283926e0c22558bbd
|
|
4
|
+
data.tar.gz: 5c12b7d8d840f2afe367d13cc7e5864fd835d62e2f0b9bc480d17873e706182c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1c8dda915931d2a5cd860ebf1ad79d2c6cc353d8794a159cc52720bde02f2e3e3aa7001fbb7af240dbf16c1b212f44ce28eacb7ec10fa1868dd47c7860406ae
|
|
7
|
+
data.tar.gz: ea2ca6604e38d85894d0df757010449f839040b28c01de608270cdffc536ed5f8e62125ecea7ec59f9100106eae6a3ab2a3243b68b4218a7269b671f483e9bad
|
data/CHANGELOG.md
CHANGED
|
@@ -5,18 +5,6 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
9
|
-
|
|
10
|
-
## [0.1.13] - 2026-03-20
|
|
11
|
-
|
|
12
|
-
### Added
|
|
13
|
-
|
|
14
|
-
- Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)
|
|
8
|
+
## [0.1.15] - 2026-07-27
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds`
|
|
19
|
-
|
|
20
|
-
### Fixed
|
|
21
|
-
|
|
22
|
-
- EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th
|
|
10
|
+
## [0.1.14] - 2026-04-27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bb21b935b95e99c2616c397155c6354976d343e669c34a7c36a319c495bdfda5b3a24c5d6042d891672215f90e405703a3ee24f79623e129c17bdd9967190a29
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
33dfd415ab31a635f8af0e515187f22f958227d26cf9c27f087dccc03c4c8ff4b7c38f9f303f5f134597b652aafe8363d8683d82ccb93492e3bb4310f218f50d
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0c1dbd71ae4ba8c8dd68e6285acd567ebe5f715c88a6a25c16e9abc1baa48a02618e006a5b1234b0d2c97bacdef53d1a0deb0691a8659474eeb9304c95764289
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
8692e06774fdb76269fa438824887f87991703885a2cf2cb7dc5bd77ba44b2e0ddce1a78c40e5411da8be2b88dfb0c875900b42f8a784a4060db7d4f4c4d4f1b
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
7c06ae8b1c4eaafac55f16143c7f60b4ce3c613a83d75859e2f798e850a90190d1f26109057e349c42f03fcce8c0f81a85904bab3ca96877b60c27ad52ee1517
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
class Cycle
|
|
12
|
+
# This class is not intended to be referenced directly.
|
|
13
|
+
# This is an internal implementation of Cycle behavior.
|
|
14
|
+
class Parser
|
|
15
|
+
extend Forwardable
|
|
16
|
+
|
|
17
|
+
# Built from the registry rather than written out, so an application
|
|
18
|
+
# that declares its own kind has its notation recognised too. Rebuilt
|
|
19
|
+
# whenever a class registers; cached in between, since parsing is hot.
|
|
20
|
+
def self.parts_regex
|
|
21
|
+
pattern = [Cycle.registry.notation_pattern, Cycle::TimeSpan.period_registry.code_pattern]
|
|
22
|
+
return @parts_regex if @parts_regex && @parts_regex_pattern == pattern
|
|
23
|
+
|
|
24
|
+
@parts_regex_pattern = pattern
|
|
25
|
+
kinds, periods = pattern
|
|
26
|
+
@parts_regex = /
|
|
27
|
+
^(?<vol>V(?<volume>\d*))? # optional volume
|
|
28
|
+
(?<set>(?<kind>#{kinds}) # kind
|
|
29
|
+
(?<period_count>\d+) # period count
|
|
30
|
+
(?<period_key>#{periods})?)? # period_key
|
|
31
|
+
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
|
|
32
|
+
/ix
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deliberately a method, not a constant: handlers register as each file
|
|
36
|
+
# in sof/cycles loads, which happens after this file. A constant would
|
|
37
|
+
# capture the empty set and leave every dormant-capable kind
|
|
38
|
+
# unrecognized.
|
|
39
|
+
def self.dormant_capable_kinds
|
|
40
|
+
Cycle.registry.cycle_classes.select(&:dormant_capable?).map(&:notation_id).compact.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.for(notation_or_parser)
|
|
44
|
+
return notation_or_parser if notation_or_parser.is_a? self
|
|
45
|
+
|
|
46
|
+
new(notation_or_parser)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.load(hash)
|
|
50
|
+
hash.symbolize_keys!
|
|
51
|
+
hash.reverse_merge!(volume: 1)
|
|
52
|
+
keys = %i[volume kind period_count period_key]
|
|
53
|
+
str = "V#{hash.values_at(*keys).join}"
|
|
54
|
+
return new(str) unless hash[:from_date]
|
|
55
|
+
|
|
56
|
+
new([str, "F#{hash[:from_date]}"].join)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(notation)
|
|
60
|
+
@notation = notation&.upcase
|
|
61
|
+
@match = @notation&.match(self.class.parts_regex)
|
|
62
|
+
@time_span = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :match, :notation
|
|
66
|
+
|
|
67
|
+
delegate [:dormant_capable_kinds] => "self.class"
|
|
68
|
+
delegate [:period, :humanized_period] => :time_span
|
|
69
|
+
|
|
70
|
+
# Return a TimeSpan object for the period and period_count
|
|
71
|
+
def time_span
|
|
72
|
+
@time_span ||= TimeSpan.for(period_count, period_key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def valid? = match.present?
|
|
76
|
+
|
|
77
|
+
def inspect = notation
|
|
78
|
+
alias_method :to_s, :inspect
|
|
79
|
+
|
|
80
|
+
def activated_notation(date)
|
|
81
|
+
return notation unless dormant_capable?
|
|
82
|
+
|
|
83
|
+
self.class.load(to_h.merge(from_date: date.to_date)).notation
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ==(other) = other.to_h == to_h
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
{
|
|
90
|
+
volume:,
|
|
91
|
+
kind:,
|
|
92
|
+
period_count:,
|
|
93
|
+
period_key:,
|
|
94
|
+
from_date:
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parses?(notation_id) = kind == notation_id
|
|
99
|
+
|
|
100
|
+
def active? = !dormant?
|
|
101
|
+
|
|
102
|
+
def dormant? = dormant_capable? && from_date.nil?
|
|
103
|
+
|
|
104
|
+
def dormant_capable? = kind.in?(dormant_capable_kinds)
|
|
105
|
+
|
|
106
|
+
def period_count = match[:period_count]
|
|
107
|
+
|
|
108
|
+
def period_key = match[:period_key]
|
|
109
|
+
|
|
110
|
+
def vol = match[:vol] || "V1"
|
|
111
|
+
|
|
112
|
+
def volume = (match[:volume] || 1).to_i
|
|
113
|
+
|
|
114
|
+
def from_data
|
|
115
|
+
return {} unless from
|
|
116
|
+
|
|
117
|
+
{from: from}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def from_date = match[:from_date]
|
|
121
|
+
|
|
122
|
+
def from = match[:from]
|
|
123
|
+
|
|
124
|
+
def kind = match[:kind]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SOF
|
|
4
|
+
class Cycle
|
|
5
|
+
# The set of classes that handle cycle kinds.
|
|
6
|
+
#
|
|
7
|
+
# Registration is explicit — a class joins by declaring what it handles
|
|
8
|
+
# (see Cycle.handles), not by subclassing. Inheriting from Cycle for any
|
|
9
|
+
# other reason, such as a test double or an abstract intermediate, then
|
|
10
|
+
# carries no hidden side effect.
|
|
11
|
+
#
|
|
12
|
+
# The registry is also what the parser reads to recognise notations, so an
|
|
13
|
+
# application can add a kind of its own and have its notation parse without
|
|
14
|
+
# reopening this gem.
|
|
15
|
+
class Registry
|
|
16
|
+
def self.instance = @instance ||= new
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@cycle_classes = []
|
|
20
|
+
@notation_pattern = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Add a handler, displacing any already handling the same kind or
|
|
24
|
+
# notation id. That is what lets an application override a built-in
|
|
25
|
+
# kind — declare a class handling :lookback and it takes over "L" —
|
|
26
|
+
# as well as add one of its own. Re-registering the same class is a
|
|
27
|
+
# no-op, so a reloaded file accumulates no duplicates.
|
|
28
|
+
#
|
|
29
|
+
# Returns the class, so a declaration can use the result.
|
|
30
|
+
def register(cycle_class)
|
|
31
|
+
displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) }
|
|
32
|
+
.select { |klass| conflicts?(klass, cycle_class) }
|
|
33
|
+
@cycle_classes -= displaced
|
|
34
|
+
@cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class)
|
|
35
|
+
@notation_pattern = nil
|
|
36
|
+
cycle_class
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Drop a handler. Mainly for an application undoing an override, and for
|
|
40
|
+
# tests that register a throwaway kind.
|
|
41
|
+
def unregister(cycle_class)
|
|
42
|
+
@cycle_classes.delete(cycle_class)
|
|
43
|
+
@notation_pattern = nil
|
|
44
|
+
cycle_class
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cycle_classes = @cycle_classes.dup
|
|
48
|
+
|
|
49
|
+
# The class declaring `kind`.
|
|
50
|
+
def handling(kind)
|
|
51
|
+
@cycle_classes.find { |klass| klass.handles?(kind) } ||
|
|
52
|
+
raise(InvalidKind, "':#{kind}' is not a valid kind of Cycle")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The class declaring `notation_id` — the letter(s) that open a cycle's
|
|
56
|
+
# notation, such as "L" or "LE".
|
|
57
|
+
def for_notation_id(notation_id)
|
|
58
|
+
@cycle_classes.find { |klass| klass.notation_id == notation_id } ||
|
|
59
|
+
raise(InvalidKind, "'#{notation_id}' is not a valid kind of Cycle")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Registered notation ids, longest first. A volume-only cycle has none,
|
|
63
|
+
# so it contributes nothing to parse.
|
|
64
|
+
def notation_ids
|
|
65
|
+
@cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The alternation the parser splices into its pattern. Rebuilt whenever a
|
|
69
|
+
# class registers, so a kind added at runtime is recognised from then on.
|
|
70
|
+
def notation_pattern
|
|
71
|
+
@notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Two handlers conflict when they answer to the same kind, or claim the
|
|
77
|
+
# same notation id. A nil notation id claims nothing.
|
|
78
|
+
def conflicts?(existing, incoming)
|
|
79
|
+
return true if existing.kind == incoming.kind
|
|
80
|
+
return false if incoming.notation_id.nil?
|
|
81
|
+
|
|
82
|
+
existing.notation_id == incoming.notation_id
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SOF
|
|
4
|
+
class Cycle
|
|
5
|
+
# This class is not intended to be referenced directly.
|
|
6
|
+
# This is an internal implementation of Cycle behavior.
|
|
7
|
+
class TimeSpan
|
|
8
|
+
extend Forwardable
|
|
9
|
+
|
|
10
|
+
# TimeSpan objects map Cycle notations to behaviors for their periods
|
|
11
|
+
#
|
|
12
|
+
# For example:
|
|
13
|
+
# 'M' => TimeSpan::DatePeriod::Month
|
|
14
|
+
# 'Y' => TimeSpan::DatePeriod::Year
|
|
15
|
+
# Read each DatePeriod subclass for more information.
|
|
16
|
+
#
|
|
17
|
+
class InvalidPeriod < StandardError; end
|
|
18
|
+
|
|
19
|
+
# The set of classes measuring a period of time. Registration is
|
|
20
|
+
# explicit — a class joins by declaring what it measures, not by
|
|
21
|
+
# subclassing — which is what removes the inherited hook and lets the
|
|
22
|
+
# parser build its period alternation from what is registered.
|
|
23
|
+
#
|
|
24
|
+
# Internal, like DatePeriod itself: periods are not an extension point.
|
|
25
|
+
# Reach it through TimeSpan.period_registry.
|
|
26
|
+
class PeriodRegistry
|
|
27
|
+
def self.instance = @instance ||= new
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@period_classes = []
|
|
31
|
+
@code_pattern = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Add a period class, displacing any already measuring the same period
|
|
35
|
+
# or claiming the same code, so an application can replace a built-in as
|
|
36
|
+
# well as add to it. Returns the class.
|
|
37
|
+
def register(period_class)
|
|
38
|
+
displaced = @period_classes.reject { |klass| klass.equal?(period_class) }
|
|
39
|
+
.select { |klass| conflicts?(klass, period_class) }
|
|
40
|
+
@period_classes -= displaced
|
|
41
|
+
@period_classes << period_class unless @period_classes.include?(period_class)
|
|
42
|
+
@code_pattern = nil
|
|
43
|
+
period_class
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unregister(period_class)
|
|
47
|
+
@period_classes.delete(period_class)
|
|
48
|
+
@code_pattern = nil
|
|
49
|
+
period_class
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def period_classes = @period_classes.dup
|
|
53
|
+
|
|
54
|
+
# The class whose notation code this is, e.g. "M". Case-insensitive,
|
|
55
|
+
# since a notation may be written either way. Nil when unrecognised —
|
|
56
|
+
# DatePeriod.for falls back to its own default.
|
|
57
|
+
def for_code(code)
|
|
58
|
+
return if code.nil?
|
|
59
|
+
|
|
60
|
+
normalized = code.to_s.upcase
|
|
61
|
+
@period_classes.find { |klass| klass.code == normalized }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The class measuring this period, e.g. :month. Nil when unrecognised.
|
|
65
|
+
def for_period(period)
|
|
66
|
+
@period_classes.find { |klass| klass.period.to_s == period.to_s }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registered codes, longest first so a longer code is never shadowed by
|
|
70
|
+
# its own prefix.
|
|
71
|
+
def codes
|
|
72
|
+
@period_classes.filter_map(&:code).uniq.sort_by { |code| [-code.length, code] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The alternation the parser splices in for the period key. Rebuilt
|
|
76
|
+
# whenever a class registers.
|
|
77
|
+
def code_pattern
|
|
78
|
+
@code_pattern ||= codes.map { Regexp.escape(it) }.join("|")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def conflicts?(existing, incoming)
|
|
84
|
+
return true if existing.period == incoming.period
|
|
85
|
+
return false if incoming.code.nil?
|
|
86
|
+
|
|
87
|
+
existing.code == incoming.code
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# Return a time_span for the given count and period
|
|
93
|
+
def for(count, period)
|
|
94
|
+
case count.to_i
|
|
95
|
+
when 0
|
|
96
|
+
TimeSpanNothing
|
|
97
|
+
when 1
|
|
98
|
+
TimeSpanOne
|
|
99
|
+
else
|
|
100
|
+
self
|
|
101
|
+
end.new(count, period)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Return a notation string from a hash
|
|
105
|
+
def notation(hash)
|
|
106
|
+
return unless hash.key?(:period) && hash[:period].present?
|
|
107
|
+
|
|
108
|
+
[
|
|
109
|
+
hash.fetch(:period_count) { 1 },
|
|
110
|
+
notation_id_from_name(hash[:period])
|
|
111
|
+
].compact.join
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The registry of period classes, owned by DatePeriod. Exposed here
|
|
115
|
+
# because DatePeriod is a private constant, so callers outside cannot
|
|
116
|
+
# name it — the parser needs the code alternation to build its pattern.
|
|
117
|
+
def period_registry = PeriodRegistry.instance
|
|
118
|
+
|
|
119
|
+
# Return the notation character for the given period name
|
|
120
|
+
def notation_id_from_name(name)
|
|
121
|
+
period_registry.for_period(name)&.code ||
|
|
122
|
+
raise(InvalidPeriod, "'#{name}' is not a valid period")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def notation
|
|
127
|
+
[period_count, code].join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Class used to calculate the windows of time so that
|
|
131
|
+
# a TimeSpan object will know the correct end of year,
|
|
132
|
+
# quarter, etc.
|
|
133
|
+
class DatePeriod
|
|
134
|
+
extend Forwardable
|
|
135
|
+
|
|
136
|
+
class << self
|
|
137
|
+
def for(count, period_notation)
|
|
138
|
+
@cached_periods ||= {}
|
|
139
|
+
@cached_periods[period_notation] ||= {}
|
|
140
|
+
@cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count)
|
|
141
|
+
@cached_periods[period_notation][count]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def for_notation(notation) = registry.for_code(notation)
|
|
145
|
+
|
|
146
|
+
def types = registry.period_classes
|
|
147
|
+
|
|
148
|
+
def registry = TimeSpan.period_registry
|
|
149
|
+
|
|
150
|
+
# Declare the period this class measures, and register it.
|
|
151
|
+
#
|
|
152
|
+
# As with Cycle.handles, registration follows from declaring, so
|
|
153
|
+
# subclassing DatePeriod on its own registers nothing, and an
|
|
154
|
+
# application can add a period of its own — its code is recognised
|
|
155
|
+
# because the parser builds that alternation from the registry.
|
|
156
|
+
#
|
|
157
|
+
# @param period [Symbol] the period measured, e.g. :month
|
|
158
|
+
# @param code [String] the notation character, e.g. "M"
|
|
159
|
+
# @param interval [String] the plural name used in descriptions
|
|
160
|
+
#
|
|
161
|
+
# @example
|
|
162
|
+
# class Fortnight < DatePeriod
|
|
163
|
+
# measures :fortnight, code: "N", interval: "fortnights"
|
|
164
|
+
# def duration = (count * 2).weeks
|
|
165
|
+
# end
|
|
166
|
+
def measures(period, code:, interval:)
|
|
167
|
+
@period = period
|
|
168
|
+
@code = code
|
|
169
|
+
@interval = interval
|
|
170
|
+
registry.register(self)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@period = nil
|
|
174
|
+
@code = nil
|
|
175
|
+
@interval = nil
|
|
176
|
+
attr_reader :period, :code, :interval
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
delegate [:period, :code, :interval] => "self.class"
|
|
180
|
+
|
|
181
|
+
def initialize(count)
|
|
182
|
+
@count = count
|
|
183
|
+
@end_date = {}
|
|
184
|
+
@begin_date = {}
|
|
185
|
+
end
|
|
186
|
+
attr_reader :count
|
|
187
|
+
|
|
188
|
+
def end_date(date)
|
|
189
|
+
@end_date[date] ||= date + duration
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def begin_date(date)
|
|
193
|
+
@begin_date[date] ||= date - duration
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def duration = count.send(period)
|
|
197
|
+
|
|
198
|
+
def end_of_period(_) = nil
|
|
199
|
+
|
|
200
|
+
def humanized_period
|
|
201
|
+
return period if count == 1
|
|
202
|
+
|
|
203
|
+
"#{period}s"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
class Year < self
|
|
207
|
+
measures :year, code: "Y", interval: "years"
|
|
208
|
+
|
|
209
|
+
def end_of_period(date)
|
|
210
|
+
date.end_of_year
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def beginning_of_period(date)
|
|
214
|
+
date.beginning_of_year
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
class Quarter < self
|
|
219
|
+
measures :quarter, code: "Q", interval: "quarters"
|
|
220
|
+
|
|
221
|
+
def duration
|
|
222
|
+
(count * 3).months
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def end_of_period(date)
|
|
226
|
+
date.end_of_quarter
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def beginning_of_period(date)
|
|
230
|
+
date.beginning_of_quarter
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
class Month < self
|
|
235
|
+
measures :month, code: "M", interval: "months"
|
|
236
|
+
|
|
237
|
+
def end_of_period(date)
|
|
238
|
+
date.end_of_month
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def beginning_of_period(date)
|
|
242
|
+
date.beginning_of_month
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
class Week < self
|
|
247
|
+
measures :week, code: "W", interval: "weeks"
|
|
248
|
+
|
|
249
|
+
def end_of_period(date)
|
|
250
|
+
date.end_of_week
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def beginning_of_period(date)
|
|
254
|
+
date.beginning_of_week
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
class Day < self
|
|
259
|
+
measures :day, code: "D", interval: "days"
|
|
260
|
+
|
|
261
|
+
def end_of_period(date)
|
|
262
|
+
date
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def beginning_of_period(date)
|
|
266
|
+
date
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
private_constant :DatePeriod, :PeriodRegistry
|
|
271
|
+
|
|
272
|
+
def initialize(count, period_id)
|
|
273
|
+
@count = Integer(count, exception: false)
|
|
274
|
+
@window = DatePeriod.for(period_count, period_id)
|
|
275
|
+
end
|
|
276
|
+
attr_reader :window
|
|
277
|
+
|
|
278
|
+
delegate [:end_date, :begin_date, :code] => :window
|
|
279
|
+
|
|
280
|
+
def end_date_of_period(date)
|
|
281
|
+
window.end_of_period(date)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def begin_date_of_period(date)
|
|
285
|
+
window.beginning_of_period(date)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Integer value for the period count or nil
|
|
289
|
+
def period_count
|
|
290
|
+
@count
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
delegate [:period, :duration, :interval, :humanized_period] => :window
|
|
294
|
+
|
|
295
|
+
# Return a date according to the rules of the time_span
|
|
296
|
+
def final_date(date)
|
|
297
|
+
return unless period
|
|
298
|
+
|
|
299
|
+
window.end_date(date.to_date)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def to_h
|
|
303
|
+
{
|
|
304
|
+
period:,
|
|
305
|
+
period_count:
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
class TimeSpanNothing < self
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class TimeSpanOne < self
|
|
313
|
+
def interval = humanized_period
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
data/lib/sof/cycle/version.rb
CHANGED
data/lib/sof/cycle.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "forwardable"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "cycle/registry"
|
|
5
|
+
require_relative "cycle/parser"
|
|
5
6
|
|
|
6
7
|
module SOF
|
|
7
8
|
class Cycle
|
|
@@ -70,9 +71,7 @@ module SOF
|
|
|
70
71
|
raise InvalidInput, "'#{notation}' is not a valid input"
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
cycle =
|
|
74
|
-
parser.parses?(klass.notation_id)
|
|
75
|
-
end.new(notation, parser:)
|
|
74
|
+
cycle = registry.for_notation_id(parser.kind).new(notation, parser:)
|
|
76
75
|
return cycle if parser.active?
|
|
77
76
|
|
|
78
77
|
Cycles::Dormant.new(cycle, parser:)
|
|
@@ -84,22 +83,14 @@ module SOF
|
|
|
84
83
|
# @example
|
|
85
84
|
# class_for_notation_id('L')
|
|
86
85
|
#
|
|
87
|
-
def class_for_notation_id(notation_id)
|
|
88
|
-
Cycle.cycle_handlers.find do |klass|
|
|
89
|
-
klass.notation_id == notation_id
|
|
90
|
-
end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
|
|
91
|
-
end
|
|
86
|
+
def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id)
|
|
92
87
|
|
|
93
88
|
# Return the class handling the kind
|
|
94
89
|
#
|
|
95
90
|
# @param sym [Symbol] symbol matching the kind of Cycle class
|
|
96
91
|
# @example
|
|
97
92
|
# class_for_kind(:lookback)
|
|
98
|
-
def class_for_kind(sym)
|
|
99
|
-
Cycle.cycle_handlers.find do |klass|
|
|
100
|
-
klass.handles?(sym)
|
|
101
|
-
end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
|
|
102
|
-
end
|
|
93
|
+
def class_for_kind(sym) = registry.handling(sym)
|
|
103
94
|
|
|
104
95
|
# Return a legend explaining all notation components
|
|
105
96
|
#
|
|
@@ -123,6 +114,8 @@ module SOF
|
|
|
123
114
|
}
|
|
124
115
|
end
|
|
125
116
|
|
|
117
|
+
# Defaults for the base class itself, which handles no kind and so is
|
|
118
|
+
# never registered.
|
|
126
119
|
@volume_only = false
|
|
127
120
|
@notation_id = nil
|
|
128
121
|
@kind = nil
|
|
@@ -150,23 +143,77 @@ module SOF
|
|
|
150
143
|
kind.to_s == sym.to_s
|
|
151
144
|
end
|
|
152
145
|
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
# Declare what kind of cycle this class handles, and register it.
|
|
147
|
+
#
|
|
148
|
+
# Registration is a consequence of declaring, so the two can never
|
|
149
|
+
# disagree, and subclassing Cycle for any other reason — an abstract
|
|
150
|
+
# intermediate, a test double — registers nothing.
|
|
151
|
+
#
|
|
152
|
+
# An application can declare its own kind this way and the parser will
|
|
153
|
+
# recognise its notation, because the pattern is built from what is
|
|
154
|
+
# registered.
|
|
155
|
+
#
|
|
156
|
+
# @param kind [Symbol] the kind this class handles, e.g. :lookback
|
|
157
|
+
# @param notation [String, nil] the notation id opening the cycle, e.g.
|
|
158
|
+
# "L". Omit for a cycle with no notation of its own, such as
|
|
159
|
+
# volume-only.
|
|
160
|
+
# @param periods [Array<String>] period keys this kind accepts. Empty
|
|
161
|
+
# means the kind takes no period.
|
|
162
|
+
# @param volume_only [Boolean] whether this kind carries volume alone.
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# class Fortnightly < SOF::Cycle
|
|
166
|
+
# handles :fortnightly, notation: "X", periods: %w[D W]
|
|
167
|
+
# def self.recurring? = true
|
|
168
|
+
# end
|
|
169
|
+
def handles(kind, notation: nil, periods: [], volume_only: false)
|
|
170
|
+
@kind = kind
|
|
171
|
+
@notation_id = notation
|
|
172
|
+
@valid_periods = periods
|
|
173
|
+
@volume_only = volume_only
|
|
174
|
+
register(self)
|
|
155
175
|
end
|
|
156
176
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
177
|
+
# Register a handler that does not inherit from Cycle. Inheriting and
|
|
178
|
+
# declaring with `handles` is the easier path, because it supplies the
|
|
179
|
+
# behaviour Cycle.for goes on to use; this is for a class that would
|
|
180
|
+
# rather implement that itself.
|
|
181
|
+
#
|
|
182
|
+
# The class must answer, at the class level:
|
|
183
|
+
#
|
|
184
|
+
# kind the kind it handles, e.g. :lookback
|
|
185
|
+
# notation_id the notation opening it, e.g. "L", or nil
|
|
186
|
+
# valid_periods period keys it accepts, e.g. %w[D W M Y]
|
|
187
|
+
# volume_only? whether it carries volume alone
|
|
188
|
+
# handles?(kind) whether it answers to a kind
|
|
189
|
+
# recurring? whether the window repeats
|
|
190
|
+
# dormant_capable? whether it can be written without a from date
|
|
191
|
+
# validate_period(key)
|
|
192
|
+
# description, examples (for the legend)
|
|
193
|
+
#
|
|
194
|
+
# and instances are built as `new(notation, parser:)`.
|
|
195
|
+
#
|
|
196
|
+
# Registering displaces any handler already answering to the same kind
|
|
197
|
+
# or notation id, so this replaces a built-in as readily as it adds one.
|
|
198
|
+
#
|
|
199
|
+
# @param cycle_class [Class] the handler to register
|
|
200
|
+
# @return [Class] the class registered
|
|
201
|
+
def register(cycle_class) = registry.register(cycle_class)
|
|
202
|
+
|
|
203
|
+
# Drop a handler, undoing `register` or a `handles` declaration.
|
|
204
|
+
def unregister(cycle_class) = registry.unregister(cycle_class)
|
|
205
|
+
|
|
206
|
+
def registry = Registry.instance
|
|
160
207
|
|
|
161
208
|
private
|
|
162
209
|
|
|
163
210
|
def build_kind_legend
|
|
164
211
|
legend = {}
|
|
165
|
-
|
|
212
|
+
registry.cycle_classes.each do |handler|
|
|
166
213
|
# Skip volume_only since it doesn't have a notation_id
|
|
167
|
-
next if handler.
|
|
214
|
+
next if handler.volume_only?
|
|
168
215
|
|
|
169
|
-
notation_id = handler.
|
|
216
|
+
notation_id = handler.notation_id
|
|
170
217
|
next unless notation_id
|
|
171
218
|
|
|
172
219
|
legend[notation_id] = {
|
|
@@ -179,14 +226,10 @@ module SOF
|
|
|
179
226
|
|
|
180
227
|
def build_period_legend
|
|
181
228
|
legend = {}
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
"M" => "month",
|
|
187
|
-
"Q" => "quarter",
|
|
188
|
-
"Y" => "year"
|
|
189
|
-
}
|
|
229
|
+
# Built from the registered periods, so one an application adds shows
|
|
230
|
+
# up here too.
|
|
231
|
+
period_mappings = TimeSpan.period_registry.period_classes
|
|
232
|
+
.to_h { |klass| [klass.code, klass.period.to_s] }
|
|
190
233
|
|
|
191
234
|
period_mappings.each do |code, period_name|
|
|
192
235
|
legend[code] = {
|
|
@@ -253,6 +296,39 @@ module SOF
|
|
|
253
296
|
|
|
254
297
|
def extend_period(_ = nil) = self
|
|
255
298
|
|
|
299
|
+
# Whether this kind of cycle can be written without a from date, and so
|
|
300
|
+
# can be anchored later. Answers on the instance so a dormant cycle can
|
|
301
|
+
# be asked too — `cycle.class` is Cycles::Dormant there, which knows
|
|
302
|
+
# nothing of the kind it wraps.
|
|
303
|
+
def dormant_capable? = self.class.dormant_capable?
|
|
304
|
+
|
|
305
|
+
# The cycle restarted from the latest of `dates` past its current anchor.
|
|
306
|
+
#
|
|
307
|
+
# A recurring, dormant-capable window (E and I) is reset by the act that
|
|
308
|
+
# satisfies it — "complete 1 by that date to reset the cycle". Nothing in
|
|
309
|
+
# the notation does that on its own, so this used to fall to each
|
|
310
|
+
# consuming app.
|
|
311
|
+
#
|
|
312
|
+
# Dates on or before the current anchor are ignored: a back-dated act
|
|
313
|
+
# must not drag a forward-running window backwards. Every other kind
|
|
314
|
+
# returns itself — a lookback window already slides, a calendar window is
|
|
315
|
+
# pinned to the calendar, a one-shot window does not repeat, and an
|
|
316
|
+
# un-anchored one has no window to move until it is activated.
|
|
317
|
+
#
|
|
318
|
+
# @param dates [Array<Date, Time, nil>] the acts that satisfy this cycle
|
|
319
|
+
# @return [Cycle] the reset cycle, or self when nothing resets it
|
|
320
|
+
#
|
|
321
|
+
# @example
|
|
322
|
+
# Cycle.for("V1E18MF2026-02-01").reset_by([Date.new(2026, 9, 15)])
|
|
323
|
+
# # => Cycle.for("V1E18MF2026-09-15")
|
|
324
|
+
def reset_by(dates)
|
|
325
|
+
return self unless recurring? && dormant_capable?
|
|
326
|
+
return self if from_date.nil?
|
|
327
|
+
|
|
328
|
+
reset = reset_date(dates)
|
|
329
|
+
reset ? Cycle.for(activated_notation(reset)) : self
|
|
330
|
+
end
|
|
331
|
+
|
|
256
332
|
# From the supplied anchor date, are there enough in-window completions to
|
|
257
333
|
# satisfy the cycle?
|
|
258
334
|
#
|
|
@@ -303,5 +379,13 @@ module SOF
|
|
|
303
379
|
end
|
|
304
380
|
|
|
305
381
|
def as_json(...) = notation
|
|
382
|
+
|
|
383
|
+
private
|
|
384
|
+
|
|
385
|
+
# The latest date that moves this cycle's window forward, or nil.
|
|
386
|
+
def reset_date(dates)
|
|
387
|
+
anchored_on = from_date.to_date
|
|
388
|
+
Array(dates).filter_map { it&.to_date }.select { it > anchored_on }.max
|
|
389
|
+
end
|
|
306
390
|
end
|
|
307
391
|
end
|
data/lib/sof/cycles/calendar.rb
CHANGED
data/lib/sof/cycles/dormant.rb
CHANGED
|
@@ -22,6 +22,10 @@ module SOF
|
|
|
22
22
|
|
|
23
23
|
def covered_dates(...) = []
|
|
24
24
|
|
|
25
|
+
# No anchor yet, so nothing to move: a dormant cycle is reset by being
|
|
26
|
+
# activated, not by the acts that satisfy it.
|
|
27
|
+
def reset_by(...) = self
|
|
28
|
+
|
|
25
29
|
def expiration_of(...) = nil
|
|
26
30
|
|
|
27
31
|
def satisfied_by?(...) = false
|
data/lib/sof/cycles/end_of.rb
CHANGED
data/lib/sof/cycles/interval.rb
CHANGED
data/lib/sof/cycles/lookback.rb
CHANGED
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
module SOF
|
|
4
4
|
module Cycles
|
|
5
5
|
class LookbackEndOf < Cycle
|
|
6
|
-
|
|
7
|
-
@notation_id = "LE"
|
|
8
|
-
@kind = :lookback_end_of
|
|
9
|
-
@valid_periods = %w[D W M Q Y]
|
|
6
|
+
handles :lookback_end_of, notation: "LE", periods: %w[D W M Q Y]
|
|
10
7
|
|
|
11
8
|
def self.recurring? = true
|
|
12
9
|
|
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
module SOF
|
|
4
4
|
module Cycles
|
|
5
5
|
class VolumeOnly < Cycle
|
|
6
|
-
|
|
7
|
-
@notation_id = nil
|
|
8
|
-
@kind = :volume_only
|
|
9
|
-
@valid_periods = []
|
|
6
|
+
handles :volume_only, volume_only: true
|
|
10
7
|
|
|
11
8
|
class << self
|
|
12
9
|
def handles?(sym) = sym.nil? || sym.to_s == "volume_only"
|
data/lib/sof/cycles/within.rb
CHANGED
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
module SOF
|
|
4
4
|
module Cycles
|
|
5
5
|
class Within < Cycle
|
|
6
|
-
|
|
7
|
-
@notation_id = "W"
|
|
8
|
-
@kind = :within
|
|
9
|
-
@valid_periods = %w[D W M Y]
|
|
6
|
+
handles :within, notation: "W", periods: %w[D W M Y]
|
|
10
7
|
|
|
11
8
|
def self.recurring? = false
|
|
12
9
|
|
|
@@ -24,7 +21,7 @@ module SOF
|
|
|
24
21
|
|
|
25
22
|
def extend_period(count)
|
|
26
23
|
Cycle.for(
|
|
27
|
-
Parser.load(
|
|
24
|
+
Cycle::Parser.load(
|
|
28
25
|
parser.to_h.merge(period_count: period_count + count)
|
|
29
26
|
).to_s
|
|
30
27
|
)
|
data/lib/sof-cycle.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sof-cycle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jim Gay
|
|
@@ -55,13 +55,21 @@ files:
|
|
|
55
55
|
- checksums/sof-cycle-0.1.10.gem.sha512
|
|
56
56
|
- checksums/sof-cycle-0.1.11.gem.sha512
|
|
57
57
|
- checksums/sof-cycle-0.1.12.gem.sha512
|
|
58
|
+
- checksums/sof-cycle-0.1.13.gem.sha512
|
|
59
|
+
- checksums/sof-cycle-0.1.14.gem.sha512
|
|
58
60
|
- checksums/sof-cycle-0.1.2.gem.sha512
|
|
61
|
+
- checksums/sof-cycle-0.1.3.gem.sha512
|
|
62
|
+
- checksums/sof-cycle-0.1.4.gem.sha512
|
|
63
|
+
- checksums/sof-cycle-0.1.5.gem.sha512
|
|
59
64
|
- checksums/sof-cycle-0.1.6.gem.sha512
|
|
60
65
|
- checksums/sof-cycle-0.1.7.gem.sha512
|
|
61
66
|
- checksums/sof-cycle-0.1.8.gem.sha512
|
|
62
67
|
- checksums/sof-cycle-0.1.9.gem.sha512
|
|
63
68
|
- lib/sof-cycle.rb
|
|
64
69
|
- lib/sof/cycle.rb
|
|
70
|
+
- lib/sof/cycle/parser.rb
|
|
71
|
+
- lib/sof/cycle/registry.rb
|
|
72
|
+
- lib/sof/cycle/time_span.rb
|
|
65
73
|
- lib/sof/cycle/version.rb
|
|
66
74
|
- lib/sof/cycles/calendar.rb
|
|
67
75
|
- lib/sof/cycles/dormant.rb
|
|
@@ -71,8 +79,6 @@ files:
|
|
|
71
79
|
- lib/sof/cycles/lookback_end_of.rb
|
|
72
80
|
- lib/sof/cycles/volume_only.rb
|
|
73
81
|
- lib/sof/cycles/within.rb
|
|
74
|
-
- lib/sof/parser.rb
|
|
75
|
-
- lib/sof/time_span.rb
|
|
76
82
|
homepage: https://github.com/SOFware/sof-cycle
|
|
77
83
|
licenses: []
|
|
78
84
|
metadata:
|
data/lib/sof/parser.rb
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
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 Parser
|
|
14
|
-
extend Forwardable
|
|
15
|
-
|
|
16
|
-
PARTS_REGEX = /
|
|
17
|
-
^(?<vol>V(?<volume>\d*))? # optional volume
|
|
18
|
-
(?<set>(?<kind>LE|L|C|W|E|I) # kind
|
|
19
|
-
(?<period_count>\d+) # period count
|
|
20
|
-
(?<period_key>D|W|M|Q|Y)?)? # period_key
|
|
21
|
-
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
|
|
22
|
-
/ix
|
|
23
|
-
|
|
24
|
-
def self.dormant_capable_kinds
|
|
25
|
-
Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def self.for(notation_or_parser)
|
|
29
|
-
return notation_or_parser if notation_or_parser.is_a? self
|
|
30
|
-
|
|
31
|
-
new(notation_or_parser)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def self.load(hash)
|
|
35
|
-
hash.symbolize_keys!
|
|
36
|
-
hash.reverse_merge!(volume: 1)
|
|
37
|
-
keys = %i[volume kind period_count period_key]
|
|
38
|
-
str = "V#{hash.values_at(*keys).join}"
|
|
39
|
-
return new(str) unless hash[:from_date]
|
|
40
|
-
|
|
41
|
-
new([str, "F#{hash[:from_date]}"].join)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def initialize(notation)
|
|
45
|
-
@notation = notation&.upcase
|
|
46
|
-
@match = @notation&.match(PARTS_REGEX)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
attr_reader :match, :notation
|
|
50
|
-
|
|
51
|
-
delegate [:dormant_capable_kinds] => "self.class"
|
|
52
|
-
delegate [:period, :humanized_period] => :time_span
|
|
53
|
-
|
|
54
|
-
# Return a TimeSpan object for the period and period_count
|
|
55
|
-
def time_span
|
|
56
|
-
@time_span ||= TimeSpan.for(period_count, period_key)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def valid? = match.present?
|
|
60
|
-
|
|
61
|
-
def inspect = notation
|
|
62
|
-
alias_method :to_s, :inspect
|
|
63
|
-
|
|
64
|
-
def activated_notation(date)
|
|
65
|
-
return notation unless dormant_capable?
|
|
66
|
-
|
|
67
|
-
self.class.load(to_h.merge(from_date: date.to_date)).notation
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def ==(other) = other.to_h == to_h
|
|
71
|
-
|
|
72
|
-
def to_h
|
|
73
|
-
{
|
|
74
|
-
volume:,
|
|
75
|
-
kind:,
|
|
76
|
-
period_count:,
|
|
77
|
-
period_key:,
|
|
78
|
-
from_date:
|
|
79
|
-
}
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def parses?(notation_id) = kind == notation_id
|
|
83
|
-
|
|
84
|
-
def active? = !dormant?
|
|
85
|
-
|
|
86
|
-
def dormant? = dormant_capable? && from_date.nil?
|
|
87
|
-
|
|
88
|
-
def dormant_capable? = kind.in?(dormant_capable_kinds)
|
|
89
|
-
|
|
90
|
-
def period_count = match[:period_count]
|
|
91
|
-
|
|
92
|
-
def period_key = match[:period_key]
|
|
93
|
-
|
|
94
|
-
def vol = match[:vol] || "V1"
|
|
95
|
-
|
|
96
|
-
def volume = (match[:volume] || 1).to_i
|
|
97
|
-
|
|
98
|
-
def from_data
|
|
99
|
-
return {} unless from
|
|
100
|
-
|
|
101
|
-
{from: from}
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def from_date = match[:from_date]
|
|
105
|
-
|
|
106
|
-
def from = match[:from]
|
|
107
|
-
|
|
108
|
-
def kind = match[:kind]
|
|
109
|
-
end
|
|
110
|
-
end
|
data/lib/sof/time_span.rb
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SOF
|
|
4
|
-
# This class is not intended to be referenced directly.
|
|
5
|
-
# This is an internal implementation of Cycle behavior.
|
|
6
|
-
class TimeSpan
|
|
7
|
-
extend Forwardable
|
|
8
|
-
|
|
9
|
-
# TimeSpan objects map Cycle notations to behaviors for their periods
|
|
10
|
-
#
|
|
11
|
-
# For example:
|
|
12
|
-
# 'M' => TimeSpan::DatePeriod::Month
|
|
13
|
-
# 'Y' => TimeSpan::DatePeriod::Year
|
|
14
|
-
# Read each DatePeriod subclass for more information.
|
|
15
|
-
#
|
|
16
|
-
class InvalidPeriod < StandardError; end
|
|
17
|
-
|
|
18
|
-
class << self
|
|
19
|
-
# Return a time_span for the given count and period
|
|
20
|
-
def for(count, period)
|
|
21
|
-
case count.to_i
|
|
22
|
-
when 0
|
|
23
|
-
TimeSpanNothing
|
|
24
|
-
when 1
|
|
25
|
-
TimeSpanOne
|
|
26
|
-
else
|
|
27
|
-
self
|
|
28
|
-
end.new(count, period)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Return a notation string from a hash
|
|
32
|
-
def notation(hash)
|
|
33
|
-
return unless hash.key?(:period) && hash[:period].present?
|
|
34
|
-
|
|
35
|
-
[
|
|
36
|
-
hash.fetch(:period_count) { 1 },
|
|
37
|
-
notation_id_from_name(hash[:period])
|
|
38
|
-
].compact.join
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Return the notation character for the given period name
|
|
42
|
-
def notation_id_from_name(name)
|
|
43
|
-
type = DatePeriod.types.find do |klass|
|
|
44
|
-
klass.period.to_s == name.to_s
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
raise InvalidPeriod, "'#{name}' is not a valid period" unless type
|
|
48
|
-
|
|
49
|
-
type.code
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def notation
|
|
54
|
-
[period_count, code].join
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Class used to calculate the windows of time so that
|
|
58
|
-
# a TimeSpan object will know the correct end of year,
|
|
59
|
-
# quarter, etc.
|
|
60
|
-
class DatePeriod
|
|
61
|
-
extend Forwardable
|
|
62
|
-
|
|
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
|
-
DatePeriod.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, :code] => :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
|