Class: SOF::Cycle

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/sof/cycle.rb,
lib/sof/cycle/parser.rb,
lib/sof/cycle/version.rb,
lib/sof/cycle/registry.rb,
lib/sof/cycle/time_span.rb

Defined Under Namespace

Classes: InvalidInput, InvalidKind, InvalidPeriod, Parser, Registry, TimeSpan

Constant Summary collapse

VERSION =
"0.1.15"

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(notation, parser: Parser.new(notation)) ⇒ Cycle

Returns a new instance of Cycle.

Raises:



252
253
254
255
256
257
258
259
260
# File 'lib/sof/cycle.rb', line 252

def initialize(notation, parser: Parser.new(notation))
  @notation = notation
  @parser = parser
  validate_period

  return if @parser.valid?

  raise InvalidInput, "'#{notation}' is not a valid input"
end

Class Attribute Details

.kindObject (readonly)

Returns the value of attribute kind.



124
125
126
# File 'lib/sof/cycle.rb', line 124

def kind
  @kind
end

.notation_idObject (readonly)

Returns the value of attribute notation_id.



124
125
126
# File 'lib/sof/cycle.rb', line 124

def notation_id
  @notation_id
end

.valid_periodsObject (readonly)

Returns the value of attribute valid_periods.



124
125
126
# File 'lib/sof/cycle.rb', line 124

def valid_periods
  @valid_periods
end

Instance Attribute Details

#parserObject (readonly)

Returns the value of attribute parser.



262
263
264
# File 'lib/sof/cycle.rb', line 262

def parser
  @parser
end

Class Method Details

.class_for_kind(sym) ⇒ Object

Return the class handling the kind

Examples:

class_for_kind(:lookback)

Parameters:

  • sym (Symbol)

    symbol matching the kind of Cycle class



93
# File 'lib/sof/cycle.rb', line 93

def class_for_kind(sym) = registry.handling(sym)

.class_for_notation_id(notation_id) ⇒ Object

Return the appropriate class for the give notation id

Examples:

class_for_notation_id('L')

Parameters:

  • notation (String)

    notation id matching the kind of Cycle class



86
# File 'lib/sof/cycle.rb', line 86

def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id)

.dormant_capable?Boolean

Returns:

  • (Boolean)


127
# File 'lib/sof/cycle.rb', line 127

def dormant_capable? = false

.dump(cycle_or_string) ⇒ Object

Turn a cycle or notation string into a hash



19
20
21
22
23
24
25
# File 'lib/sof/cycle.rb', line 19

def dump(cycle_or_string)
  if cycle_or_string.is_a? Cycle
    cycle_or_string
  else
    Cycle.for(cycle_or_string)
  end.to_h
end

.for(notation) ⇒ Cycle

Return a Cycle object from a notation string

Examples:

Cycle.for('V2C1Y)

Parameters:

  • notation (String)

    a string notation representing a Cycle

Returns:

  • (Cycle)

    a Cycle object representing the provide string notation



66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/sof/cycle.rb', line 66

def for(notation)
  return notation if notation.is_a? Cycle
  return notation if notation.is_a? Cycles::Dormant
  parser = Parser.new(notation)
  unless parser.valid?
    raise InvalidInput, "'#{notation}' is not a valid input"
  end

  cycle = registry.for_notation_id(parser.kind).new(notation, parser:)
  return cycle if parser.active?

  Cycles::Dormant.new(cycle, parser:)
end

.handles(kind, notation: nil, periods: [], volume_only: false) ⇒ Object

Declare what kind of cycle this class handles, and register it.

Registration is a consequence of declaring, so the two can never disagree, and subclassing Cycle for any other reason — an abstract intermediate, a test double — registers nothing.

An application can declare its own kind this way and the parser will recognise its notation, because the pattern is built from what is registered.

Examples:

class Fortnightly < SOF::Cycle
  handles :fortnightly, notation: "X", periods: %w[D W]
  def self.recurring? = true
end

Parameters:

  • kind (Symbol)

    the kind this class handles, e.g. :lookback

  • notation (String, nil) (defaults to: nil)

    the notation id opening the cycle, e.g. "L". Omit for a cycle with no notation of its own, such as volume-only.

  • periods (Array<String>) (defaults to: [])

    period keys this kind accepts. Empty means the kind takes no period.

  • volume_only (Boolean) (defaults to: false)

    whether this kind carries volume alone.



169
170
171
172
173
174
175
# File 'lib/sof/cycle.rb', line 169

def handles(kind, notation: nil, periods: [], volume_only: false)
  @kind = kind
  @notation_id = notation
  @valid_periods = periods
  @volume_only = volume_only
  register(self)
end

.handles?(sym) ⇒ Boolean

Returns:

  • (Boolean)


142
143
144
# File 'lib/sof/cycle.rb', line 142

def handles?(sym)
  kind.to_s == sym.to_s
end

.legendHash

Return a legend explaining all notation components

Returns:

  • (Hash)

    hash with notation components organized by category



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/sof/cycle.rb', line 98

def legend
  {
    "quantity" => {
      "V" => {
        description: "Volume - the number of times something should occur",
        examples: ["V1L1D - once in the prior 1 day", "V3L3D - three times in the prior 3 days", "V10L10D - ten times in the prior 10 days"]
      }
    },
    "kind" => build_kind_legend,
    "period" => build_period_legend,
    "date" => {
      "F" => {
        description: "From - specifies the anchor date for Within cycles",
        examples: ["F2024-01-01 - from January 1, 2024", "F2024-12-31 - from December 31, 2024"]
      }
    }
  }
end

.load(hash) ⇒ Object

Return a Cycle object from a hash



28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/sof/cycle.rb', line 28

def load(hash)
  symbolized_hash = hash.symbolize_keys
  cycle_class = class_for_kind(symbolized_hash[:kind])

  unless cycle_class.valid_periods.empty?
    cycle_class.validate_period(
      TimeSpan.notation_id_from_name(symbolized_hash[:period])
    )
  end

  Cycle.for notation(symbolized_hash)
rescue TimeSpan::InvalidPeriod => exc
  raise InvalidPeriod, exc.message
end

.notation(hash) ⇒ String

Retun a notation string from a hash

Parameters:

  • hash (Hash)

    hash of data for a valid Cycle

Returns:

  • (String)

    string representation of a Cycle



47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/sof/cycle.rb', line 47

def notation(hash)
  volume_notation = "V#{hash.fetch(:volume) { 1 }}"
  return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only

  cycle_class = class_for_kind(hash[:kind].to_sym)
  [
    volume_notation,
    cycle_class.notation_id,
    TimeSpan.notation(hash.slice(:period, :period_count)),
    hash.fetch(:from, nil)
  ].compact.join
end

.recurring?Boolean

Returns:

  • (Boolean)


129
# File 'lib/sof/cycle.rb', line 129

def recurring? = raise "#{name} must implement #{__method__}"

.register(cycle_class) ⇒ Class

Register a handler that does not inherit from Cycle. Inheriting and declaring with handles is the easier path, because it supplies the behaviour Cycle.for goes on to use; this is for a class that would rather implement that itself.

The class must answer, at the class level:

kind             the kind it handles, e.g. :lookback
notation_id      the notation opening it, e.g. "L", or nil
valid_periods    period keys it accepts, e.g. %w[D W M Y]
volume_only?     whether it carries volume alone
handles?(kind)   whether it answers to a kind
recurring?       whether the window repeats
dormant_capable? whether it can be written without a from date
validate_period(key)
description, examples   (for the legend)

and instances are built as new(notation, parser:).

Registering displaces any handler already answering to the same kind or notation id, so this replaces a built-in as readily as it adds one.

Parameters:

  • cycle_class (Class)

    the handler to register

Returns:

  • (Class)

    the class registered



201
# File 'lib/sof/cycle.rb', line 201

def register(cycle_class) = registry.register(cycle_class)

.registryObject



206
# File 'lib/sof/cycle.rb', line 206

def registry = Registry.instance

.unregister(cycle_class) ⇒ Object

Drop a handler, undoing register or a handles declaration.



204
# File 'lib/sof/cycle.rb', line 204

def unregister(cycle_class) = registry.unregister(cycle_class)

.validate_period(period) ⇒ Object

Raises an error if the given period isn't in the list of valid periods.

Parameters:

  • period (String)

    period matching the class valid periods

Raises:



135
136
137
138
139
140
# File 'lib/sof/cycle.rb', line 135

def validate_period(period)
  raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
    Invalid period value of '#{period}' provided. Valid periods are:
    #{valid_periods.join(", ")}
  ERR
end

.volume_only?Boolean

Returns:

  • (Boolean)


125
# File 'lib/sof/cycle.rb', line 125

def volume_only? = @volume_only

Instance Method Details

#==(other) ⇒ Object

Cycles are considered equal if their hash representations are equal



292
# File 'lib/sof/cycle.rb', line 292

def ==(other) = to_h == other.to_h

#as_jsonObject



381
# File 'lib/sof/cycle.rb', line 381

def as_json(...) = notation

#considered_dates(completion_dates, anchor: Date.current) ⇒ Object



340
341
342
# File 'lib/sof/cycle.rb', line 340

def considered_dates(completion_dates, anchor: Date.current)
  covered_dates(completion_dates, anchor:).max_by(volume) { it }
end

#cover?(date, anchor: Date.current) ⇒ Boolean

Returns:

  • (Boolean)


350
351
352
# File 'lib/sof/cycle.rb', line 350

def cover?(date, anchor: Date.current)
  range(anchor).cover?(date)
end

#covered_dates(dates, anchor: Date.current) ⇒ Object



344
345
346
347
348
# File 'lib/sof/cycle.rb', line 344

def covered_dates(dates, anchor: Date.current)
  dates.select do |date|
    cover?(date, anchor:)
  end
end

#dormant_capable?Boolean

Whether this kind of cycle can be written without a from date, and so can be anchored later. Answers on the instance so a dormant cycle can be asked too — cycle.class is Cycles::Dormant there, which knows nothing of the kind it wraps.

Returns:

  • (Boolean)


303
# File 'lib/sof/cycle.rb', line 303

def dormant_capable? = self.class.dormant_capable?

#expiration_of(_completion_dates, anchor: Date.current) ⇒ Object



361
# File 'lib/sof/cycle.rb', line 361

def expiration_of(_completion_dates, anchor: Date.current) = nil

#extend_period(_ = nil) ⇒ Object



297
# File 'lib/sof/cycle.rb', line 297

def extend_period(_ = nil) = self

#final_date(_anchor) ⇒ Object

Return the final date of the cycle



359
# File 'lib/sof/cycle.rb', line 359

def final_date(_anchor) = nil

#from_dataObject



375
376
377
378
379
# File 'lib/sof/cycle.rb', line 375

def from_data
  return {} unless from

  {from: from}
end

#humanized_spanObject



356
# File 'lib/sof/cycle.rb', line 356

def humanized_span = [period_count, humanized_period].join(" ")

#kind_inquiryObject



272
# File 'lib/sof/cycle.rb', line 272

def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)

#last_completed(dates) ⇒ Object

Return the most recent completion date from the supplied array of dates



295
# File 'lib/sof/cycle.rb', line 295

def last_completed(dates) = dates.compact.map(&:to_date).max

#notationObject

Return the cycle representation as a notation string



281
282
283
284
285
286
287
288
289
# File 'lib/sof/cycle.rb', line 281

def notation
  hash = to_h
  [
    "V#{volume}",
    self.class.notation_id,
    time_span.notation,
    hash.fetch(:from, nil)
  ].compact.join
end

#range(anchor) ⇒ Object



354
# File 'lib/sof/cycle.rb', line 354

def range(anchor) = start_date(anchor)..final_date(anchor)

#reset_by(dates) ⇒ Cycle

The cycle restarted from the latest of dates past its current anchor.

A recurring, dormant-capable window (E and I) is reset by the act that satisfies it — "complete 1 by that date to reset the cycle". Nothing in the notation does that on its own, so this used to fall to each consuming app.

Dates on or before the current anchor are ignored: a back-dated act must not drag a forward-running window backwards. Every other kind returns itself — a lookback window already slides, a calendar window is pinned to the calendar, a one-shot window does not repeat, and an un-anchored one has no window to move until it is activated.

Examples:

Cycle.for("V1E18MF2026-02-01").reset_by([Date.new(2026, 9, 15)])
# => Cycle.for("V1E18MF2026-09-15")

Parameters:

  • dates (Array<Date, Time, nil>)

    the acts that satisfy this cycle

Returns:

  • (Cycle)

    the reset cycle, or self when nothing resets it



324
325
326
327
328
329
330
# File 'lib/sof/cycle.rb', line 324

def reset_by(dates)
  return self unless recurring? && dormant_capable?
  return self if from_date.nil?

  reset = reset_date(dates)
  reset ? Cycle.for(activated_notation(reset)) : self
end

#satisfied_by?(completion_dates, anchor: Date.current) ⇒ Boolean

From the supplied anchor date, are there enough in-window completions to satisfy the cycle?

Returns:

  • (Boolean)

    true if the cycle is satisfied, false otherwise



336
337
338
# File 'lib/sof/cycle.rb', line 336

def satisfied_by?(completion_dates, anchor: Date.current)
  covered_dates(completion_dates, anchor:).size >= volume
end

#to_hObject



365
366
367
368
369
370
371
372
373
# File 'lib/sof/cycle.rb', line 365

def to_h
  {
    kind:,
    volume:,
    period:,
    period_count:,
    **from_data
  }
end

#validate_periodObject



274
275
276
277
278
# File 'lib/sof/cycle.rb', line 274

def validate_period
  return if valid_periods.empty?

  self.class.validate_period(period_key)
end

#volume_to_delay_expiration(_completion_dates, anchor:) ⇒ Object



363
# File 'lib/sof/cycle.rb', line 363

def volume_to_delay_expiration(_completion_dates, anchor:) = 0