sof-cycle 0.1.13 → 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/.tool-versions +1 -1
- data/CHANGELOG.md +2 -18
- 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 +116 -31
- 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 +40 -0
- 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 +11 -5
- data/.claude/settings.local.json +0 -17
- data/lib/sof/parser.rb +0 -109
- data/lib/sof/time_span.rb +0 -234
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/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby
|
|
1
|
+
ruby 4.0.2
|
data/CHANGELOG.md
CHANGED
|
@@ -5,22 +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.
|
|
8
|
+
## [0.1.15] - 2026-07-27
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)
|
|
13
|
-
|
|
14
|
-
### Changed
|
|
15
|
-
|
|
16
|
-
- Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds`
|
|
17
|
-
|
|
18
|
-
### Fixed
|
|
19
|
-
|
|
20
|
-
- EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th
|
|
21
|
-
|
|
22
|
-
## [0.1.12] - 2025-09-05
|
|
23
|
-
|
|
24
|
-
### Added
|
|
25
|
-
|
|
26
|
-
- Missing code coverage and updated EndOf to properly handle dormant cycles.
|
|
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
|