timeboss 0.3.0
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 +83 -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 +216 -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,83 @@
|
|
|
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
|
+
%w[day week month quarter half year].each do |size|
|
|
38
|
+
define_method(size.pluralize) do
|
|
39
|
+
entry = calendar.send("#{size}_for", self.begin.start_date)
|
|
40
|
+
build_entries entry
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
define_method(size) do
|
|
44
|
+
entries = send(size.pluralize)
|
|
45
|
+
return nil unless entries.length == 1
|
|
46
|
+
entries.first
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Does this period cover the current date?
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def current?
|
|
53
|
+
to_range.include?(Date.today)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Express this period as a date range.
|
|
57
|
+
# @return [Range<Date, Date>]
|
|
58
|
+
def to_range
|
|
59
|
+
@_to_range ||= start_date .. end_date
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :calendar
|
|
65
|
+
|
|
66
|
+
def initialize(calendar, begin_basis, end_basis = nil)
|
|
67
|
+
@calendar = calendar
|
|
68
|
+
@begin = begin_basis
|
|
69
|
+
@end = end_basis || @begin
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_entries(entry)
|
|
73
|
+
return [] if entry.start_date > self.end.end_date
|
|
74
|
+
entries = [entry]
|
|
75
|
+
while entry.end_date < self.end.end_date
|
|
76
|
+
entry = entry.next
|
|
77
|
+
entries << entry
|
|
78
|
+
end
|
|
79
|
+
entries
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module TimeBoss
|
|
2
|
+
class Calendar
|
|
3
|
+
module Support
|
|
4
|
+
# @abstract
|
|
5
|
+
# A MonthBasis must define a `#start_date` and `#end_date` method.
|
|
6
|
+
# These methods should be calculated based on the incoming `#year` and `#month` values.
|
|
7
|
+
class MonthBasis
|
|
8
|
+
attr_reader :year, :month
|
|
9
|
+
|
|
10
|
+
def initialize(year, month)
|
|
11
|
+
@year = year
|
|
12
|
+
@month = month
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_range
|
|
16
|
+
@_to_range ||= start_date .. end_date
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative './unit'
|
|
3
|
+
|
|
4
|
+
module TimeBoss
|
|
5
|
+
class Calendar
|
|
6
|
+
module Support
|
|
7
|
+
class MonthlyUnit < Unit
|
|
8
|
+
attr_reader :year_index, :index
|
|
9
|
+
|
|
10
|
+
def initialize(calendar, year_index, index, start_date, end_date)
|
|
11
|
+
super(calendar, start_date, end_date)
|
|
12
|
+
@year_index = year_index
|
|
13
|
+
@index = index
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get a stringified representation of this unit.
|
|
17
|
+
# @return [String] (e.g. "2020Q3: 2020-06-29 thru 2020-09-27")
|
|
18
|
+
def to_s
|
|
19
|
+
"#{name}: #{start_date} thru #{end_date}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get a list of weeks contained within this period.
|
|
23
|
+
# @return [Array<Week>]
|
|
24
|
+
def weeks
|
|
25
|
+
base = calendar.year(year_index)
|
|
26
|
+
num_weeks = (((base.end_date - base.start_date) + 1) / 7.0).to_i
|
|
27
|
+
num_weeks.times.map { |i| Week.new(calendar, base.start_date + (i * 7).days, base.start_date + ((i * 7) + 6).days) }
|
|
28
|
+
.select { |w| w.start_date.between?(start_date, end_date) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def max_index
|
|
34
|
+
12 / self.class::NUM_MONTHS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def up
|
|
38
|
+
if index == max_index
|
|
39
|
+
calendar.send(self.class.type, year_index + 1, 1)
|
|
40
|
+
else
|
|
41
|
+
calendar.send(self.class.type, year_index, index + 1)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def down
|
|
46
|
+
if index == 1
|
|
47
|
+
calendar.send(self.class.type, year_index - 1, max_index)
|
|
48
|
+
else
|
|
49
|
+
calendar.send(self.class.type, year_index, index - 1)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|