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.
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 +83 -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 +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
@@ -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,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