timeboss 0.3.1
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/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- data/.gitignore +5 -0
- data/.replit +2 -0
- data/.rspec +2 -0
- data/.travis.yml +16 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +233 -0
- data/Rakefile +5 -0
- data/bin/tbsh +15 -0
- data/lib/tasks/calendars.rake +22 -0
- data/lib/tasks/timeboss.rake +6 -0
- data/lib/timeboss.rb +6 -0
- data/lib/timeboss/calendar.rb +64 -0
- data/lib/timeboss/calendar/day.rb +48 -0
- data/lib/timeboss/calendar/half.rb +22 -0
- data/lib/timeboss/calendar/month.rb +22 -0
- data/lib/timeboss/calendar/parser.rb +53 -0
- data/lib/timeboss/calendar/period.rb +154 -0
- data/lib/timeboss/calendar/quarter.rb +22 -0
- data/lib/timeboss/calendar/support/formatter.rb +33 -0
- data/lib/timeboss/calendar/support/month_basis.rb +21 -0
- data/lib/timeboss/calendar/support/monthly_unit.rb +55 -0
- data/lib/timeboss/calendar/support/navigable.rb +72 -0
- data/lib/timeboss/calendar/support/shiftable.rb +241 -0
- data/lib/timeboss/calendar/support/translatable.rb +93 -0
- data/lib/timeboss/calendar/support/unit.rb +88 -0
- data/lib/timeboss/calendar/waypoints.rb +12 -0
- data/lib/timeboss/calendar/waypoints/absolute.rb +113 -0
- data/lib/timeboss/calendar/waypoints/relative.rb +267 -0
- data/lib/timeboss/calendar/week.rb +53 -0
- data/lib/timeboss/calendar/year.rb +18 -0
- data/lib/timeboss/calendars.rb +53 -0
- data/lib/timeboss/calendars/broadcast.rb +32 -0
- data/lib/timeboss/calendars/gregorian.rb +30 -0
- data/lib/timeboss/support/shellable.rb +17 -0
- data/lib/timeboss/version.rb +4 -0
- data/spec/calendar/day_spec.rb +60 -0
- data/spec/calendar/quarter_spec.rb +32 -0
- data/spec/calendar/support/monthly_unit_spec.rb +85 -0
- data/spec/calendar/support/unit_spec.rb +90 -0
- data/spec/calendar/week_spec.rb +80 -0
- data/spec/calendars/broadcast_spec.rb +796 -0
- data/spec/calendars/gregorian_spec.rb +684 -0
- data/spec/calendars_spec.rb +50 -0
- data/spec/spec_helper.rb +12 -0
- data/timeboss.gemspec +31 -0
- metadata +215 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
require './lib/timeboss/calendars'
|
2
|
+
|
3
|
+
namespace :timeboss do
|
4
|
+
namespace :calendars do
|
5
|
+
TimeBoss::Calendars.each do |entry|
|
6
|
+
namespace entry.name do
|
7
|
+
desc "Evaluate an expression for the #{entry.name} calendar"
|
8
|
+
task :evaluate, %i[expression] => ['timeboss:init'] do |_, args|
|
9
|
+
puts entry.calendar.parse(args[:expression])
|
10
|
+
end
|
11
|
+
|
12
|
+
desc "Open a REPL with the #{entry.name} calendar"
|
13
|
+
task repl: ['timeboss:init'] do
|
14
|
+
require 'timeboss/support/shellable'
|
15
|
+
TimeBoss::Support::Shellable.open(entry.calendar)
|
16
|
+
end
|
17
|
+
|
18
|
+
task shell: ["timeboss:calendars:#{entry.name}:repl"]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/timeboss.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'active_support/inflector'
|
3
|
+
require 'active_support/core_ext/numeric/time'
|
4
|
+
|
5
|
+
%w[day week month quarter half year].each { |f| require_relative "./calendar/#{f}" }
|
6
|
+
%w[waypoints period parser].each { |f| require_relative "./calendar/#{f}" }
|
7
|
+
require_relative './calendar/support/month_basis'
|
8
|
+
|
9
|
+
module TimeBoss
|
10
|
+
class Calendar
|
11
|
+
include Waypoints
|
12
|
+
|
13
|
+
# @!method parse
|
14
|
+
# Parse an identifier into a unit or period.
|
15
|
+
# Valid identifiers can include simple units (like "2020Q3", "2020M8W3", "last_quarter"),
|
16
|
+
# mathematical expressions (like "this_month+6"),
|
17
|
+
# or period expressions (like "2020W1..2020W8", "this_quarter-2..next_quarter")
|
18
|
+
# @param identifier [String]
|
19
|
+
# @return [Support::Unit, Period]
|
20
|
+
|
21
|
+
delegate :parse, to: :parser
|
22
|
+
|
23
|
+
# Get a name by which this calendar can be referenced.
|
24
|
+
# @return [String]
|
25
|
+
def name
|
26
|
+
self.class.to_s.demodulize.underscore
|
27
|
+
end
|
28
|
+
alias_method :to_s, :name
|
29
|
+
|
30
|
+
# Get a friendly title for this calendar.
|
31
|
+
# @return [String]
|
32
|
+
def title
|
33
|
+
name.titleize
|
34
|
+
end
|
35
|
+
|
36
|
+
# Can this calendar support weeks?
|
37
|
+
# For custom calendars, this value can generally not be overridden.
|
38
|
+
# But for calendars like our Gregorian implementation, weeks are irrelevant, and should be suppressed.
|
39
|
+
# @return [Boolean]
|
40
|
+
def supports_weeks?
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.register!
|
45
|
+
return unless TimeBoss::Calendars.method_defined?(:register)
|
46
|
+
TimeBoss::Calendars.register(self.name.to_s.demodulize.underscore, self)
|
47
|
+
end
|
48
|
+
private_class_method :register!
|
49
|
+
|
50
|
+
protected
|
51
|
+
|
52
|
+
attr_reader :basis
|
53
|
+
|
54
|
+
def initialize(basis:)
|
55
|
+
@basis = basis
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def parser
|
61
|
+
@_parser ||= Parser.new(self)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative './support/unit'
|
3
|
+
|
4
|
+
module TimeBoss
|
5
|
+
class Calendar
|
6
|
+
class Day < Support::Unit
|
7
|
+
def initialize(calendar, start_date)
|
8
|
+
super(calendar, start_date, start_date)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Get a simple representation of this day.
|
12
|
+
# @return [String] (e.g. "2020-08-03")
|
13
|
+
def name
|
14
|
+
start_date.to_s
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get a "pretty" representation of this day.
|
18
|
+
# @return [String] (e.g. "August 3, 2020")
|
19
|
+
def title
|
20
|
+
start_date.strftime('%B %-d, %Y')
|
21
|
+
end
|
22
|
+
|
23
|
+
alias_method :to_s, :name
|
24
|
+
|
25
|
+
# Get the index of this day within its containing year.
|
26
|
+
# @return [Integer]
|
27
|
+
def index
|
28
|
+
@_index ||= (start_date - year.start_date).to_i + 1
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get the year number for this day.
|
32
|
+
# @return [Integer] (e.g. 2020)
|
33
|
+
def year_index
|
34
|
+
@_year_index ||= year.year_index
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def down
|
40
|
+
self.class.new(calendar, start_date - 1.day)
|
41
|
+
end
|
42
|
+
|
43
|
+
def up
|
44
|
+
self.class.new(calendar, start_date + 1.day)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative './support/monthly_unit'
|
3
|
+
|
4
|
+
module TimeBoss
|
5
|
+
class Calendar
|
6
|
+
class Half < Support::MonthlyUnit
|
7
|
+
NUM_MONTHS = 6
|
8
|
+
|
9
|
+
# Get a simple representation of this half.
|
10
|
+
# @return [String] (e.g. "2020H2")
|
11
|
+
def name
|
12
|
+
"#{year_index}H#{index}"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Get a "pretty" representation of this half.
|
16
|
+
# @return [String] (e.g. "H2 2020")
|
17
|
+
def title
|
18
|
+
"H#{index} #{year_index}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative './support/monthly_unit'
|
3
|
+
|
4
|
+
module TimeBoss
|
5
|
+
class Calendar
|
6
|
+
class Month < Support::MonthlyUnit
|
7
|
+
NUM_MONTHS = 1
|
8
|
+
|
9
|
+
# Get a simple representation of this month.
|
10
|
+
# @return [String] (e.g. "2020M8")
|
11
|
+
def name
|
12
|
+
"#{year_index}M#{index}"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Get a "pretty" representation of this month.
|
16
|
+
# @return [String] (e.g. "August 2020")
|
17
|
+
def title
|
18
|
+
"#{Date::MONTHNAMES[index]} #{year_index}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module TimeBoss
|
3
|
+
class Calendar
|
4
|
+
class Parser
|
5
|
+
RANGE_DELIMITER = '..'
|
6
|
+
InvalidPeriodIdentifierError = Class.new(StandardError)
|
7
|
+
attr_reader :calendar
|
8
|
+
|
9
|
+
def initialize(calendar)
|
10
|
+
@calendar = calendar
|
11
|
+
end
|
12
|
+
|
13
|
+
def parse(identifier = nil)
|
14
|
+
return nil unless (identifier || '').strip.length > 0
|
15
|
+
return parse_identifier(identifier) unless identifier&.include?(RANGE_DELIMITER)
|
16
|
+
bases = identifier.split(RANGE_DELIMITER).map { |i| parse_identifier(i.strip) } unless identifier.nil?
|
17
|
+
bases ||= [parse_identifier(nil)]
|
18
|
+
Period.new(calendar, *bases)
|
19
|
+
rescue ArgumentError
|
20
|
+
raise InvalidPeriodIdentifierError
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def parse_identifier(identifier)
|
26
|
+
captures = identifier&.match(/^([^-]+)(\s*[+-]\s*[0-9]+)$/)&.captures
|
27
|
+
base, offset = captures || [identifier, '0']
|
28
|
+
period = parse_period(base&.strip) or raise InvalidPeriodIdentifierError
|
29
|
+
period.offset(offset.gsub(/\s+/, '').to_i)
|
30
|
+
end
|
31
|
+
|
32
|
+
def parse_period(identifier)
|
33
|
+
return calendar.send(identifier) if calendar.respond_to?(identifier.to_s)
|
34
|
+
parse_term(identifier || Date.today.year.to_s)
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_term(identifier)
|
38
|
+
return Day.new(calendar, Date.parse(identifier)) if identifier.match?(/^[0-9]{4}-?[01][0-9]-?[0-3][0-9]$/)
|
39
|
+
|
40
|
+
raise InvalidPeriodIdentifierError unless identifier.match?(/^[HQMWD0-9]+$/)
|
41
|
+
period = if identifier.to_i == 0 then calendar.this_year else calendar.year(identifier.to_i) end
|
42
|
+
%w[half quarter month week day].each do |size|
|
43
|
+
prefix = size[0].upcase
|
44
|
+
next unless identifier.include?(prefix)
|
45
|
+
junk, identifier = identifier.split(prefix)
|
46
|
+
raise InvalidPeriodIdentifierError if junk.match?(/\D/)
|
47
|
+
period = period.send(size.pluralize)[identifier.to_i - 1] or raise InvalidPeriodIdentifierError
|
48
|
+
end
|
49
|
+
period
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module TimeBoss
|
3
|
+
class Calendar
|
4
|
+
class Period
|
5
|
+
attr_reader :begin, :end
|
6
|
+
|
7
|
+
# @!method start_date
|
8
|
+
# Get the start date of this period.
|
9
|
+
# @return [Date]
|
10
|
+
delegate :start_date, to: :begin
|
11
|
+
|
12
|
+
# @!method end_date
|
13
|
+
# Get the end date of this period.
|
14
|
+
# @return [Date]
|
15
|
+
delegate :end_date, to: :end
|
16
|
+
|
17
|
+
# @!method name
|
18
|
+
# Get a simple representation of this period.
|
19
|
+
# @return [String]
|
20
|
+
|
21
|
+
# @!method title
|
22
|
+
# Get a "pretty" representation of this period.
|
23
|
+
# @return [String]
|
24
|
+
|
25
|
+
# @!method to_s
|
26
|
+
# Get a stringified representation of this period.
|
27
|
+
# @return [String]
|
28
|
+
|
29
|
+
%i[name title to_s].each do |message|
|
30
|
+
define_method(message) do
|
31
|
+
text = self.begin.send(message)
|
32
|
+
text = "#{text} #{Parser::RANGE_DELIMITER} #{self.end.send(message)}" unless self.end == self.begin
|
33
|
+
text
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# i hate this
|
39
|
+
#
|
40
|
+
|
41
|
+
### Days
|
42
|
+
|
43
|
+
# @!method days
|
44
|
+
# Get a list of days that fall within this period.
|
45
|
+
# @return [Array<Calendar::Day>]
|
46
|
+
|
47
|
+
# @!method day(index = nil)
|
48
|
+
# Get the day this period represents.
|
49
|
+
# Returns nil if no single day can be identified.
|
50
|
+
# @return [Array<Calendar::Day>, nil]
|
51
|
+
|
52
|
+
### Weeks
|
53
|
+
|
54
|
+
# @!method weeks
|
55
|
+
# Get a list of weeks that fall within this period.
|
56
|
+
# @return [Array<Calendar::Week>]
|
57
|
+
|
58
|
+
# @!method week(index = nil)
|
59
|
+
# Get the week this period represents.
|
60
|
+
# Returns nil if no single week can be identified.
|
61
|
+
# @return [Array<Calendar::Week>, nil]
|
62
|
+
|
63
|
+
### Months
|
64
|
+
|
65
|
+
# @!method months
|
66
|
+
# Get a list of months that fall within this period.
|
67
|
+
# @return [Array<Calendar::Month>]
|
68
|
+
|
69
|
+
# @!method month(index = nil)
|
70
|
+
# Get the month this period represents.
|
71
|
+
# Returns nil if no single month can be identified.
|
72
|
+
# @return [Array<Calendar::Month>, nil]
|
73
|
+
|
74
|
+
### Quarters
|
75
|
+
|
76
|
+
# @!method quarters
|
77
|
+
# Get a list of quarters that fall within this period.
|
78
|
+
# @return [Array<Calendar::Quarter>]
|
79
|
+
|
80
|
+
# @!method quarter(index = nil)
|
81
|
+
# Get the quarter this period represents.
|
82
|
+
# Returns nil if no single quarter can be identified.
|
83
|
+
# @return [Array<Calendar::Quarter>, nil]
|
84
|
+
|
85
|
+
### Halves
|
86
|
+
|
87
|
+
# @!method halves
|
88
|
+
# Get a list of halves that fall within this period.
|
89
|
+
# @return [Array<Calendar::Half>]
|
90
|
+
|
91
|
+
# @!method half(index = nil)
|
92
|
+
# Get the half this period represents.
|
93
|
+
# Returns nil if no single half can be identified.
|
94
|
+
# @return [Array<Calendar::Half>, nil]
|
95
|
+
|
96
|
+
### Years
|
97
|
+
|
98
|
+
# @!method years
|
99
|
+
# Get a list of years that fall within this period.
|
100
|
+
# @return [Array<Calendar::Year>]
|
101
|
+
|
102
|
+
# @!method year(index = nil)
|
103
|
+
# Get the year this period represents.
|
104
|
+
# Returns nil if no single year can be identified.
|
105
|
+
# @return [Array<Calendar::Year>, nil]
|
106
|
+
|
107
|
+
# Does this period cover the current date?
|
108
|
+
# @return [Boolean]
|
109
|
+
def current?
|
110
|
+
to_range.include?(Date.today)
|
111
|
+
end
|
112
|
+
|
113
|
+
%w[day week month quarter half year].each do |size|
|
114
|
+
define_method(size.pluralize) do
|
115
|
+
entry = calendar.send("#{size}_for", self.begin.start_date)
|
116
|
+
build_entries entry
|
117
|
+
end
|
118
|
+
|
119
|
+
define_method(size) do |index = nil|
|
120
|
+
entries = send(size.pluralize)
|
121
|
+
return entries[index - 1] unless index.nil?
|
122
|
+
return nil unless entries.length == 1
|
123
|
+
entries.first
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Express this period as a date range.
|
128
|
+
# @return [Range<Date, Date>]
|
129
|
+
def to_range
|
130
|
+
@_to_range ||= start_date .. end_date
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
attr_reader :calendar
|
136
|
+
|
137
|
+
def initialize(calendar, begin_basis, end_basis = nil)
|
138
|
+
@calendar = calendar
|
139
|
+
@begin = begin_basis
|
140
|
+
@end = end_basis || @begin
|
141
|
+
end
|
142
|
+
|
143
|
+
def build_entries(entry)
|
144
|
+
return [] if entry.start_date > self.end.end_date
|
145
|
+
entries = [entry]
|
146
|
+
while entry.end_date < self.end.end_date
|
147
|
+
entry = entry.next
|
148
|
+
entries << entry
|
149
|
+
end
|
150
|
+
entries
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative './support/monthly_unit'
|
3
|
+
|
4
|
+
module TimeBoss
|
5
|
+
class Calendar
|
6
|
+
class Quarter < Support::MonthlyUnit
|
7
|
+
NUM_MONTHS = 3
|
8
|
+
|
9
|
+
# Get a simple representation of this quarter.
|
10
|
+
# @return [String] (e.g. "2020Q3")
|
11
|
+
def name
|
12
|
+
"#{year_index}Q#{index}"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Get a "pretty" representation of this quarter.
|
16
|
+
# @return [String] (e.g. "Q3 2020")
|
17
|
+
def title
|
18
|
+
"Q#{index} #{year_index}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative './translatable'
|
3
|
+
|
4
|
+
module TimeBoss
|
5
|
+
class Calendar
|
6
|
+
module Support
|
7
|
+
private
|
8
|
+
|
9
|
+
# The formatter is responsible for the implementation of name formatting for a unit.
|
10
|
+
class Formatter
|
11
|
+
PERIODS = Translatable::PERIODS.reverse.map(&:to_sym).drop(1)
|
12
|
+
attr_reader :unit, :periods
|
13
|
+
|
14
|
+
def initialize(unit, periods)
|
15
|
+
@unit = unit
|
16
|
+
@periods = PERIODS & periods.map(&:to_sym).push(unit.class.type.to_sym)
|
17
|
+
@periods -= [:week] unless unit.calendar.supports_weeks?
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
base, text = 'year', unit.year.name
|
22
|
+
periods.each do |period|
|
23
|
+
sub = unit.send(period) or break
|
24
|
+
index = sub.send("in_#{base}")
|
25
|
+
text += "#{period[0].upcase}#{index}"
|
26
|
+
base = period
|
27
|
+
end
|
28
|
+
text
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|