timeboss 0.3.1

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