timeboss 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|