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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. data/.gitignore +5 -0
  5. data/.replit +2 -0
  6. data/.rspec +2 -0
  7. data/.travis.yml +16 -0
  8. data/.yardopts +1 -0
  9. data/CODE_OF_CONDUCT.md +76 -0
  10. data/Gemfile +3 -0
  11. data/LICENSE.txt +22 -0
  12. data/README.md +233 -0
  13. data/Rakefile +5 -0
  14. data/bin/tbsh +15 -0
  15. data/lib/tasks/calendars.rake +22 -0
  16. data/lib/tasks/timeboss.rake +6 -0
  17. data/lib/timeboss.rb +6 -0
  18. data/lib/timeboss/calendar.rb +64 -0
  19. data/lib/timeboss/calendar/day.rb +48 -0
  20. data/lib/timeboss/calendar/half.rb +22 -0
  21. data/lib/timeboss/calendar/month.rb +22 -0
  22. data/lib/timeboss/calendar/parser.rb +53 -0
  23. data/lib/timeboss/calendar/period.rb +154 -0
  24. data/lib/timeboss/calendar/quarter.rb +22 -0
  25. data/lib/timeboss/calendar/support/formatter.rb +33 -0
  26. data/lib/timeboss/calendar/support/month_basis.rb +21 -0
  27. data/lib/timeboss/calendar/support/monthly_unit.rb +55 -0
  28. data/lib/timeboss/calendar/support/navigable.rb +72 -0
  29. data/lib/timeboss/calendar/support/shiftable.rb +241 -0
  30. data/lib/timeboss/calendar/support/translatable.rb +93 -0
  31. data/lib/timeboss/calendar/support/unit.rb +88 -0
  32. data/lib/timeboss/calendar/waypoints.rb +12 -0
  33. data/lib/timeboss/calendar/waypoints/absolute.rb +113 -0
  34. data/lib/timeboss/calendar/waypoints/relative.rb +267 -0
  35. data/lib/timeboss/calendar/week.rb +53 -0
  36. data/lib/timeboss/calendar/year.rb +18 -0
  37. data/lib/timeboss/calendars.rb +53 -0
  38. data/lib/timeboss/calendars/broadcast.rb +32 -0
  39. data/lib/timeboss/calendars/gregorian.rb +30 -0
  40. data/lib/timeboss/support/shellable.rb +17 -0
  41. data/lib/timeboss/version.rb +4 -0
  42. data/spec/calendar/day_spec.rb +60 -0
  43. data/spec/calendar/quarter_spec.rb +32 -0
  44. data/spec/calendar/support/monthly_unit_spec.rb +85 -0
  45. data/spec/calendar/support/unit_spec.rb +90 -0
  46. data/spec/calendar/week_spec.rb +80 -0
  47. data/spec/calendars/broadcast_spec.rb +796 -0
  48. data/spec/calendars/gregorian_spec.rb +684 -0
  49. data/spec/calendars_spec.rb +50 -0
  50. data/spec/spec_helper.rb +12 -0
  51. data/timeboss.gemspec +31 -0
  52. 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
@@ -0,0 +1,6 @@
1
+ namespace :timeboss do
2
+ task :init do
3
+ require './lib/timeboss'
4
+ require './lib/timeboss/calendars'
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ require "timeboss/version"
3
+
4
+ # TimeBoss
5
+ module TimeBoss
6
+ end
@@ -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