artic 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 677bcdd6f44140488725e808cc21352cae9a637d
4
+ data.tar.gz: f8c0969a9fe9a90483bbe8daede9739b6f59f0c2
5
+ SHA512:
6
+ metadata.gz: 4892f61298533796efdb513c5f34a1a15cea5c999a9485c8da110df61482170acbbe7cbb1f33ecb84402671279a2060b8d052337f8b349a8cfa1c47edaa32f35
7
+ data.tar.gz: 7c4199ff8a5478a62eb36a94e02a5c0a2e15c0e929c402cb0d81df5da44abffe682b3e1f3e9988b6ec22515284f0cb837c037a44fbd864ca4ec40a1d20dc715e
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /spec/examples.txt
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format Fuubar
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,62 @@
1
+ require: rubocop-rspec
2
+
3
+ AllCops:
4
+ TargetRubyVersion: 2.3
5
+ Include:
6
+ - '**/Gemfile'
7
+ - '**/Rakefile'
8
+ Exclude:
9
+ - 'bin/*'
10
+ - 'db/**/*'
11
+ - 'vendor/bundle/**/*'
12
+ - 'spec/spec_helper.rb'
13
+ - 'spec/rails_helper.rb'
14
+ - 'spec/support/**/*'
15
+ - 'config/**/*'
16
+ - 'Rakefile'
17
+ - 'Gemfile'
18
+
19
+ Style/BlockDelimiters:
20
+ Exclude:
21
+ - 'spec/**/*'
22
+
23
+ Style/AlignParameters:
24
+ EnforcedStyle: with_fixed_indentation
25
+
26
+ Style/ClosingParenthesisIndentation:
27
+ Enabled: false
28
+
29
+ Metrics/LineLength:
30
+ Max: 100
31
+ AllowURI: true
32
+
33
+ Style/FirstParameterIndentation:
34
+ Enabled: false
35
+
36
+ Style/MultilineMethodCallIndentation:
37
+ EnforcedStyle: indented
38
+
39
+ Style/IndentArray:
40
+ EnforcedStyle: consistent
41
+
42
+ Style/IndentHash:
43
+ EnforcedStyle: consistent
44
+
45
+ Style/SignalException:
46
+ EnforcedStyle: semantic
47
+
48
+ Style/BracesAroundHashParameters:
49
+ EnforcedStyle: context_dependent
50
+
51
+ Lint/EndAlignment:
52
+ AlignWith: variable
53
+ AutoCorrect: true
54
+
55
+ Style/AndOr:
56
+ EnforcedStyle: conditionals
57
+
58
+ Metrics/MethodLength:
59
+ Max: 15
60
+
61
+ RSpec/MessageExpectation:
62
+ Enabled: false
data/.travis.yml ADDED
@@ -0,0 +1,9 @@
1
+ rvm:
2
+ - 2.3.0
3
+ before_install:
4
+ - gem update --system
5
+ - gem update bundler
6
+ - gem cleanup bundler
7
+ branches:
8
+ only:
9
+ - master
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in agenda.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Alessandro Desantis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # A.R.TI.C.
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/artic.svg?maxAge=3600&style=flat-square)](https://rubygems.org/gems/artic)
4
+ [![Build Status](https://img.shields.io/travis/alessandro1997/artic.svg?maxAge=3600&style=flat-square)](https://travis-ci.org/alessandro1997/artic)
5
+ [![Dependency Status](https://img.shields.io/gemnasium/alessandro1997/artic.svg?maxAge=3600&style=flat-square)](https://gemnasium.com/github.com/alessandro1997/artic)
6
+ [![Code Climate](https://img.shields.io/codeclimate/github/alessandro1997/artic.svg?maxAge=3600&style=flat-square)](https://codeclimate.com/github/alessandro1997/artic)
7
+
8
+ **A** **R**uby gem for **TI**me **C**omputations.
9
+
10
+ A.R.TI.C. can take questions like:
11
+
12
+ > If I'm available 9am-5pm on Mondays and I have a meeting from 10am to 1pm and another from 3pm
13
+ > to 4pm next Monday, when am I _free_ next Monday?
14
+
15
+ And give you an answer like:
16
+
17
+ > You are free in these time slots:
18
+ >
19
+ > - 9am-10am;
20
+ > - 1pm-3pm;
21
+ > - 4pm-5pm.
22
+
23
+ ## Installation
24
+
25
+ Add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'artic'
29
+ ```
30
+
31
+ And then execute:
32
+
33
+ $ bundle
34
+
35
+ Or install it yourself as:
36
+
37
+ $ gem install artic
38
+
39
+ ## Usage
40
+
41
+ First of all, you will need to create a new calendar:
42
+
43
+ ```ruby
44
+ calendar = Artic::Calendar.new
45
+ ```
46
+
47
+ ### Setting available times
48
+
49
+ Now you can start defining the free slots in your calendar:
50
+
51
+ ```ruby
52
+ calendar.availabilities << Artic::Availability.new(:monday, '09:00'..'11:00')
53
+ calendar.availabilities << Artic::Availability.new(:monday, '11:00'..'13:00')
54
+ calendar.availabilities << Artic::Availability.new(:monday, '15:00'..'19:00')
55
+ ```
56
+
57
+ If you want, you can also use specific dates in place of days of the week:
58
+
59
+ ```ruby
60
+ calendar.availabilities << Artic::Availability.new(Date.parse('2016-10-03'), '15:00'..'19:00')
61
+ ```
62
+
63
+ Or you can mix the two! In this case, we won't consider the availability slots for that day of the
64
+ week when calculating availabilities:
65
+
66
+ ```ruby
67
+ calendar.availabilities << Artic::Availability.new(:monday, '09:00'..'17:00')
68
+
69
+ # Only available 15-19 on Monday, October 3rd 2016.
70
+ calendar.availabilities << Artic::Availability.new(Date.parse('2016-10-03'), '15:00'..'19:00')
71
+ ```
72
+
73
+ ### Defining occupations
74
+
75
+ You can also define some specific slots when you will be busy with something:
76
+
77
+ ```ruby
78
+ calendar.occupations << Artic::Occupation.new(Date.parse('2016-09-26'), '10:00'..'12:00'))
79
+ ```
80
+
81
+ The times do not have to respect your availability slots:
82
+
83
+ ```ruby
84
+ calendar.occupations << Artic::Occupation.new(Date.parse('2016-09-26'), '18:00'..'20:00'))
85
+ ```
86
+
87
+ ### Computing available slots
88
+
89
+ This is where the fun part begins. Suppose you want to get your work hours on Mondays:
90
+
91
+ ```ruby
92
+ calendar.available_slots_on(:monday)
93
+ # => Artic::Collection::AvailabilityCollection[
94
+ # Artic::Availability<:monday, 09:00..13:00>,
95
+ # Artic::Availability<:monday, 15:00..19:00>
96
+ # ]
97
+
98
+ # We overrode this, remember?
99
+ calendar.available_slots_on(Date.parse('2016-10-03'))
100
+ # => Artic::Collection::AvailabilityCollection[
101
+ # Artic::Availability<2016-10-03 15:00..10:00>
102
+ # ]
103
+ ```
104
+
105
+ ### Computing free slots
106
+
107
+ Or maybe you want to see when you have time for a meeting a particular Monday?
108
+
109
+ In that case, use `#free_slots_on` and we'll take care of removing any occupied times from your
110
+ available slots:
111
+
112
+ ```ruby
113
+ calendar.free_slots_on(Date.parse('2016-09-26'))
114
+ # => Artic::Collection::AvailabilityCollection[
115
+ # Artic::Availability<2016-09-26 09:00..13:00>,
116
+ # Artic::Availability<2016-09-26 15:00..18:00>
117
+ # ]
118
+ ```
119
+
120
+ ## Caveats
121
+
122
+ - All times should be in the same timezone (ideally, UTC).
123
+
124
+ ## Development
125
+
126
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run
127
+ the tests. You can also run `bin/console` for an interactive prompt that will allow you to
128
+ experiment.
129
+
130
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new
131
+ version, update the version number in `version.rb`, and then run `bundle exec rake release`, which
132
+ will create a git tag for the version, push git commits and tags, and push the `.gem` file to
133
+ [rubygems.org](https://rubygems.org).
134
+
135
+ ## Contributing
136
+
137
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alessandro1997/artic.
138
+
139
+ ## License
140
+
141
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/artic.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'artic/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "artic"
8
+ spec.version = Artic::VERSION
9
+ spec.authors = ["Alessandro Desantis"]
10
+ spec.email = ["desa.alessandro@gmail.com"]
11
+
12
+ spec.summary = %q{A Ruby gem for time computations.}
13
+ spec.homepage = "https://github.com/alessandro1997/artic"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency 'tzinfo', '~> 1.2.2'
24
+ spec.add_dependency 'tzinfo-data', '~> 1.2016.7'
25
+
26
+ spec.add_development_dependency "bundler"
27
+ spec.add_development_dependency "rake"
28
+ spec.add_development_dependency "rspec"
29
+ spec.add_development_dependency "pry"
30
+ spec.add_development_dependency 'rubocop'
31
+ spec.add_development_dependency 'rubocop-rspec'
32
+ spec.add_development_dependency 'fuubar'
33
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "artic"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/artic.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require 'tzinfo'
3
+
4
+ require 'artic/version'
5
+
6
+ require 'artic/time_range'
7
+
8
+ require 'artic/availability'
9
+ require 'artic/occupation'
10
+
11
+ require 'artic/collection/availability_collection'
12
+ require 'artic/collection/occupation_collection'
13
+
14
+ require 'artic/calendar'
15
+
16
+ # Artic is a Ruby gem for time computations.
17
+ #
18
+ # It can solve problems like: if I'm available 9am-5pm on Mondays and I have a meeting from 10am
19
+ # to 1pm and another from 3pm to 4pm next Monday, when am I free next Monday?
20
+ #
21
+ # @author Alessandro Desantis
22
+ module Artic
23
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ # Availability represents a slot of time in a givn day of the week or date when you're available.
4
+ #
5
+ # @author Alessandro Desantis
6
+ class Availability
7
+ DAYS_OF_WEEK = %i(monday tuesday wednesday thursday friday saturday sunday).freeze
8
+
9
+ # @!attribute [r] day_of_week
10
+ # @todo Rename to +wday+
11
+ # @return [Symbol] the day of the week, as a lowercase symbol (e.g. +:monday+)
12
+ #
13
+ # @!attribute [r] date
14
+ # @return [Date] the date of this availability
15
+ #
16
+ # @!attribute [r] time_range
17
+ # @return [TimeRange] the time range of this availability
18
+ attr_reader :day_of_week, :date, :time_range
19
+
20
+ # Initializes the availability.
21
+ #
22
+ # @param dow_or_date [Symbol|Date] a day of the week (e.g. +:monday+) or date
23
+ # @param time_range [TimeRange|Range] a time range
24
+ #
25
+ # @raise [ArgumentError] if an invalid day of the week is passed
26
+ def initialize(dow_or_date, time_range)
27
+ @date = dow_or_date if dow_or_date.is_a?(Date)
28
+ @day_of_week = (@date ? @date.strftime('%A').downcase : dow_or_date).to_sym
29
+ @time_range = TimeRange.build(time_range)
30
+
31
+ validate_day_of_week
32
+ end
33
+
34
+ # Returns the identifier used to create this availability (i.e. either a day of the week or
35
+ # date).
36
+ #
37
+ # @return [Date|Symbol]
38
+ def identifier
39
+ date || day_of_week
40
+ end
41
+
42
+ # Returns whether the availability is appliable to the given date (i.e. the date is the same
43
+ # or the weekday is the same).
44
+ #
45
+ # @param date [Date]
46
+ #
47
+ # @return [Boolean]
48
+ def for_date?(date)
49
+ if self.date
50
+ self.date == date
51
+ else
52
+ day_of_week == date.strftime('%A').downcase.to_sym
53
+ end
54
+ end
55
+
56
+ # Determines whether this availability and the one passed as an argument represent the same
57
+ # day/time range combination, by checking for equality of both the identifier and the time
58
+ # range.
59
+ #
60
+ # @param other [Availability]
61
+ #
62
+ # @return [Boolean]
63
+ def ==(other)
64
+ identifier == other.identifier && time_range == other.time_range
65
+ end
66
+
67
+ # Returns whether this availability should be before, after or in the same position of the
68
+ # availability passed as an argument, by following these rules:
69
+ #
70
+ # * availabilities with a weekday come before those with a date;
71
+ # * if both availabilities are for a weekday, the day and time ranges are compared;
72
+ # * if both availabilities are for a date, the date and time ranges are compared.
73
+ #
74
+ # @return [Fixnum] -1 if this availability should come before the argument, 0 if it should
75
+ # be at the same position, 1 if it should come after the argument.
76
+ #
77
+ # @see TimeRange#<=>
78
+ def <=>(other)
79
+ # availabilities for weekdays come before availabilities for specific dates
80
+ return -1 if date.nil? && !other.date.nil?
81
+ return 1 if !date.nil? && other.date.nil?
82
+
83
+ if date.nil? && other.date.nil? # both availabilities are for a weekday
84
+ if day_of_week == other.day_of_week # availabilities are for the same weekday
85
+ time_range.min <=> other.time_range.min # compare times
86
+ else # availabilities are for different weekdays
87
+ index1 = DAYS_OF_WEEK.index(day_of_week)
88
+ index2 = DAYS_OF_WEEK.index(other.day_of_week)
89
+
90
+ index1 <=> index2 # compares weekdays
91
+ end
92
+ else # both availabilities are for a date
93
+ if date == other.date # both availabilities are for the same date
94
+ time_range.min <=> other.time_range.min # compare times
95
+ else # availabilities are for different dates
96
+ date <=> other.date # compare dates
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def validate_day_of_week
104
+ fail(
105
+ ArgumentError,
106
+ "#{day_of_week} is not a valid day of the week"
107
+ ) unless DAYS_OF_WEEK.include?(day_of_week)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ # A calendar keeps track of both your availabilities and your occupations.
4
+ #
5
+ # @author Alessandro Desantis
6
+ class Calendar
7
+ # @!attribute [r] availabilities
8
+ # @return [Collection::AvailabilityCollection]
9
+ #
10
+ # @!attribute [r] occupations
11
+ # @return [Collection::OccupationCollection]
12
+ attr_reader :availabilities, :occupations
13
+
14
+ # Initializes the calendar.
15
+ def initialize
16
+ @availabilities = Collection::AvailabilityCollection.new
17
+ @occupations = Collection::OccupationCollection.new
18
+ end
19
+
20
+ # Returns the slots available on the given day of the week or date, including any slots that
21
+ # might be occupied on the given date.
22
+ #
23
+ # If the passed argument is an instance of +Date+ but no availabilities have been defined
24
+ # for that specific date, returns any availabilities defined for that day of the week.
25
+ #
26
+ # @param dow_or_date [Date|Symbol] a day of the week or date
27
+ #
28
+ # @return Collection::AvailabilityCollection
29
+ #
30
+ # @example
31
+ # calendar.available_slots_on(:monday) # => #<Artic::Collection::AvailabilityCollection>
32
+ # @example
33
+ # calendar.available_slots_on(Date.tomorrow) # => #<Artic::Collection::AvailabilityCollection>
34
+ #
35
+ # @see Collection::AvailabilityCollection#normalize
36
+ def available_slots_on(dow_or_date)
37
+ if !availabilities.identifier?(dow_or_date) && dow_or_date.is_a?(Date) && availabilities.identifier?(dow_or_date.strftime('%A').downcase)
38
+ return available_slots_on(dow_or_date.strftime('%A').downcase)
39
+ end
40
+
41
+ availabilities.normalize dow_or_date
42
+ end
43
+
44
+ # Returns the slots free on the given date, computed by calling {Occupation#bisect} on each
45
+ # of the availability slots.
46
+ #
47
+ # @param date [Date]
48
+ #
49
+ # @return Collection::AvailabilityCollection
50
+ #
51
+ # @see Collection::OccupationCollection#bisect
52
+ def free_slots_on(date)
53
+ availabilities = available_slots_on(date).flat_map do |availability|
54
+ occupations.bisect(availability)
55
+ end
56
+
57
+ Collection::AvailabilityCollection.new availabilities
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ module Collection
4
+ # Keeps a collection of availabilities and performs calculations on them.
5
+ #
6
+ # @author Alessandro Desantis
7
+ class AvailabilityCollection < Array
8
+ # Returns all the identifiers in this collection.
9
+ #
10
+ # @return [Array<Symbol|Date>]
11
+ def identifiers
12
+ map(&:identifier).uniq
13
+ end
14
+
15
+ # Returns whether an identifier exists in this collection.
16
+ #
17
+ # @param identifier [Symbol,Date]
18
+ #
19
+ # @return [Boolean]
20
+ def identifier?(identifier)
21
+ identifiers.include? cast_identifier(identifier)
22
+ end
23
+
24
+ # Returns all the availabilities with the given identifier, without normalizing them.
25
+ #
26
+ # @param identifier [Symbol|Date] a weekday or a date
27
+ #
28
+ # @return AvailabilityCollection
29
+ def by_identifier(identifier)
30
+ identifier = cast_identifier identifier
31
+ availabilities = select { |availability| availability.identifier == identifier }
32
+ self.class.new availabilities
33
+ end
34
+
35
+ # Normalizes all the availabilities with the given identifier in this collection by sorting
36
+ # them and merging any contiguous availability slots.
37
+ #
38
+ # @param identifier [Symbol|Date]
39
+ #
40
+ # @return AvailabilityCollection
41
+ def normalize(identifier)
42
+ availabilities = by_identifier(identifier).sort
43
+
44
+ normalized_availabilities = availabilities.inject([]) do |accumulator, availability|
45
+ next (accumulator << availability) if accumulator.empty?
46
+
47
+ last_availability = accumulator.pop
48
+
49
+ next (
50
+ accumulator + [last_availability, availability]
51
+ ) unless last_availability.time_range.overlaps?(availability.time_range)
52
+
53
+ new_time_range = Range.new(
54
+ [last_availability.time_range.min, availability.time_range.min].min,
55
+ [last_availability.time_range.max, availability.time_range.max].max
56
+ )
57
+
58
+ accumulator << Availability.new(availability.identifier, new_time_range)
59
+ end
60
+
61
+ self.class.new normalized_availabilities
62
+ end
63
+
64
+ # Normalizes all the availabilities in this collection by sorting them and merging any
65
+ # contiguous availability slots.
66
+ #
67
+ # @return AvailabilityCollection
68
+ #
69
+ # @see #normalize
70
+ def normalize_all
71
+ normalized_availabilities = identifiers.flat_map do |identifier|
72
+ normalize identifier
73
+ end
74
+
75
+ self.class.new normalized_availabilities.sort
76
+ end
77
+
78
+ private
79
+
80
+ def cast_identifier(identifier)
81
+ identifier.is_a?(String) ? identifier.to_sym : identifier
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ module Collection
4
+ # Keeps a collection of occupations.
5
+ #
6
+ # @author Alessandro Desantis
7
+ class OccupationCollection < Array
8
+ # Returns all the dates in this collection.
9
+ #
10
+ # @return [Array<Date>]
11
+ def dates
12
+ map(&:date).uniq
13
+ end
14
+
15
+ # Returns whether a date exists in this collection.
16
+ #
17
+ # @param date [Date]
18
+ #
19
+ # @return [Boolean]
20
+ def date?(date)
21
+ dates.include? date
22
+ end
23
+
24
+ # Returns all the occupations for the given date, without normalizing them.
25
+ #
26
+ # @param date [Date]
27
+ #
28
+ # @return OccupationCollection
29
+ def by_date(date)
30
+ occupations = select { |occupation| occupation.date == date }
31
+ self.class.new occupations
32
+ end
33
+
34
+ # Normalizes all the occupations with the given identifier in this collection by sorting
35
+ # them and merging any contiguous availability slots.
36
+ #
37
+ # @param date [Date]
38
+ #
39
+ # @return AvailabilityCollection
40
+ def normalize(date)
41
+ occupations = by_date(date).sort
42
+
43
+ normalized_occupations = occupations.inject([]) do |accumulator, occupation|
44
+ next (accumulator << occupation) if accumulator.empty?
45
+
46
+ last_occupation = accumulator.pop
47
+
48
+ next (
49
+ accumulator + [last_occupation, occupation]
50
+ ) unless last_occupation.time_range.overlaps?(occupation.time_range)
51
+
52
+ new_time_range = Range.new(
53
+ [last_occupation.time_range.min, occupation.time_range.min].min,
54
+ [last_occupation.time_range.max, occupation.time_range.max].max
55
+ )
56
+
57
+ accumulator << Occupation.new(occupation.date, new_time_range)
58
+ end
59
+
60
+ self.class.new normalized_occupations
61
+ end
62
+
63
+ # Normalizes all the occupations in this collection by sorting them and merging any
64
+ # contiguous availability slots.
65
+ #
66
+ # @return OccupationCollection
67
+ #
68
+ # @see #normalize
69
+ def normalize_all
70
+ normalized_occupations = dates.sort.flat_map do |date|
71
+ normalize date
72
+ end
73
+
74
+ self.class.new normalized_occupations
75
+ end
76
+
77
+ # Sorts the occupations in the collection, then bisects the availability until all the
78
+ # occupations have been accounted for.
79
+ #
80
+ # @param availability [Availability]
81
+ #
82
+ # @return AvailabilityCollection
83
+ #
84
+ # @see Occupation#bisect
85
+ def bisect(availability)
86
+ availabilities = normalize_all.inject([availability]) do |accumulator, occupation|
87
+ accumulator + occupation.bisect(accumulator.pop)
88
+ end
89
+
90
+ AvailabilityCollection.new availabilities
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ # An occupation represents a slot of time in a given date where you are not available.
4
+ #
5
+ # @author Alessandro Desantis
6
+ class Occupation
7
+ attr_reader :date, :time_range
8
+
9
+ # Initializes the occupation.
10
+ #
11
+ # @param date [Date] the date of the occupation
12
+ # @param time_range [TimeRange|Range] the time range of the occupation
13
+ def initialize(date, time_range)
14
+ @date = date
15
+ @time_range = TimeRange.build(time_range)
16
+ end
17
+
18
+ # Converts the occupation to a range of +DateTime+ objects.
19
+ #
20
+ # @return [Range]
21
+ #
22
+ # @see TimeRange#with_date
23
+ def to_range
24
+ time_range.with_date(date)
25
+ end
26
+
27
+ # Returns whether the occupation overlaps the availability (i.e. whether there's at least one
28
+ # moment in time shared by both the availability and the occupation).
29
+ #
30
+ # @param availability [Availability]
31
+ #
32
+ # @return [Boolean]
33
+ def overlaps?(availability)
34
+ return false unless availability.for_date?(date)
35
+
36
+ availability_range = availability.time_range.with_date(date)
37
+ (availability_range.min <= to_range.max) && (availability_range.max >= to_range.min)
38
+ end
39
+
40
+ # Returns whether the occupation covers the availability (i.e. whether all moments of the
41
+ # availability are also part of the occupation).
42
+ #
43
+ # @param availability [Availability]
44
+ #
45
+ # @return [Boolean]
46
+ def covers?(availability)
47
+ return false unless availability.for_date?(date)
48
+
49
+ availability_range = availability.time_range.with_date(date)
50
+ to_range.min <= availability_range.min && to_range.max >= availability_range.max
51
+ end
52
+
53
+ # Determines whether this occupation and the one passed as an argument represent the same
54
+ # day/time range combination, by checking for equality of both the date and the time
55
+ # range.
56
+ #
57
+ # @param other [Occupation]
58
+ #
59
+ # @return [Boolean]
60
+ def ==(other)
61
+ date == other.date && time_range == other.time_range
62
+ end
63
+
64
+ # Compares this occupation with another one by comparing their ranges' start.
65
+ #
66
+ # @param other [Occupation]
67
+ #
68
+ # @return [Fixnum] -1 if this occupation should come before the other one, 0 if it should be
69
+ # in the same position, 1 if it should come after the other one
70
+ def <=>(other)
71
+ to_range.min <=> other.to_range.min
72
+ end
73
+
74
+ # Reduces the time range of the given availability or bisects it into two availabilities,
75
+ # depending on where the occupation falls within the availability.
76
+ #
77
+ # If the occupation completely covers the availability, returns an empty collection.
78
+ #
79
+ # If the occupation does not overlap the availability at all, returns a collection with
80
+ # the original availability.
81
+ #
82
+ # @param availability [Availability]
83
+ #
84
+ # @return Collection::AvailabilityCollection a collection of availabilities where the time
85
+ # slot assigned to the occupation has been removed
86
+ #
87
+ # @example
88
+ # occupation = Artic::Occupation.new(Date.today, '08:00'..'14:00')
89
+ # availability = Artic::Availability.new(Date.today, '09:00'..'18:00')
90
+ #
91
+ # occupation.bisect(availability)
92
+ # # => [
93
+ # # #<Artic::Availability 14:00..18:00>
94
+ # # ]
95
+ #
96
+ # @example
97
+ # occupation = Artic::Occupation.new(Date.today, '12:00'..'14:00')
98
+ # availability = Artic::Availability.new(Date.today, '09:00'..'18:00')
99
+ #
100
+ # occupation.bisect(availability)
101
+ # # => [
102
+ # # #<Artic::Availability 09:00..12:00>,
103
+ # # #<Artic::Availability 14:00..18:00>
104
+ # # ]
105
+ #
106
+ # @example
107
+ # occupation = Artic::Occupation.new(Date.today, '16:00'..'20:00')
108
+ # availability = Artic::Availability.new(Date.today, '09:00'..'18:00')
109
+ #
110
+ # occupation.bisect(availability)
111
+ # # => [
112
+ # # #<Artic::Availability 09:00..16:00>
113
+ # # ]
114
+ def bisect(availability)
115
+ return Collection::AvailabilityCollection.new if covers?(availability)
116
+ return Collection::AvailabilityCollection.new([availability]) unless overlaps?(availability)
117
+
118
+ bisected_ranges = time_range.bisect(availability.time_range)
119
+
120
+ availabilities = bisected_ranges.map do |bisected_range|
121
+ Availability.new(date, bisected_range)
122
+ end
123
+
124
+ Collection::AvailabilityCollection.new availabilities
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ # Represents a range of two times in the same day (e.g. 09:00-17:00).
4
+ #
5
+ # @author Alessandro Desantis
6
+ class TimeRange < Range
7
+ TIME_REGEX = /\A^(0\d|1\d|2[0-3]):[0-5]\d$\z/
8
+
9
+ class << self
10
+ # Builds a time range from the provided value.
11
+ #
12
+ # @param range [Range|TimeRange] a range of times (e.g. +'09:00'..'18:00'+ or
13
+ # a +TimeRange+ object)
14
+ #
15
+ # @return [TimeRange] the passed time range or a new time range
16
+ def build(range)
17
+ range.is_a?(TimeRange) ? range : TimeRange.new(range.min, range.max)
18
+ end
19
+ end
20
+
21
+ # Initializes a new range.
22
+ #
23
+ # @param min [String] the start time (in HH:MM format)
24
+ # @param max [String] the end time (in HH:MM format)
25
+ #
26
+ # @raise [ArgumentError] if the start time or the end time is invalid
27
+ # @raise [ArgumentError] if the start time is after the end time
28
+ def initialize(min, max)
29
+ super
30
+ validate_range
31
+ end
32
+
33
+ # Returns whether the two ranges overlap (i.e. whether there's at least a moment in time that
34
+ # belongs to both ranges).
35
+ #
36
+ # @param other [TimeRange]
37
+ #
38
+ # @return [Boolean]
39
+ def overlaps?(other)
40
+ min <= other.max && max >= other.min
41
+ end
42
+
43
+ # Returns whether this range completely covers the one given.
44
+ #
45
+ # @param other [TimeRange]
46
+ #
47
+ # @return [Boolean]
48
+ def covers?(other)
49
+ min <= other.min && max >= other.max
50
+ end
51
+
52
+ # Returns a range of +DateTime+ objects for this time range.
53
+ #
54
+ # @param date [Date] the date to use for the range
55
+ #
56
+ # @return [Range]
57
+ def with_date(date)
58
+ Range.new(
59
+ DateTime.parse("#{date} #{min}"),
60
+ DateTime.parse("#{date} #{max}")
61
+ )
62
+ end
63
+
64
+ # Uses this range to bisect another.
65
+ #
66
+ # If this range does not overlap the other, returns an array with the original range.
67
+ #
68
+ # If this range completely covers the other, returns an empty array.
69
+ #
70
+ # @param other [TimeRange]
71
+ #
72
+ # @return [Array<TimeRange>]
73
+ #
74
+ # @example
75
+ # range1 = TimeRange.new('10:00'..'12:00')
76
+ # range2 = TimeRange.new('09:00'..'18:00')
77
+ # range3 = TimeRange.new('08:00'..'11:00')
78
+ #
79
+ # range1.bisect(range2)
80
+ # # => [
81
+ # # #<TimeRange 09:00..10:00>,
82
+ # # #<TimeRange 12:00..18:00>
83
+ # # ]
84
+ #
85
+ # range2.bisect(range1)
86
+ # # => []
87
+ #
88
+ # range3.bisect(range2)
89
+ # # => [
90
+ # # #<TimeRange 11:00..18:00>
91
+ # # ]
92
+ #
93
+ # range2.bisect(range3)
94
+ # # => [
95
+ # # #<TimeRange 08:00..09:00>
96
+ # # ]
97
+ def bisect(other)
98
+ return [other] unless overlaps?(other)
99
+ return [] if covers?(other)
100
+
101
+ if min <= other.min && max <= other.max
102
+ [max..other.max]
103
+ elsif min >= other.min && max <= other.max
104
+ [
105
+ (other.min..min),
106
+ (max..other.max)
107
+ ]
108
+ elsif min >= other.min && max >= other.max
109
+ [other.min..min]
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def validate_range
116
+ fail(
117
+ ArgumentError,
118
+ "#{min} is not a valid time"
119
+ ) unless min =~ TIME_REGEX
120
+
121
+ fail(
122
+ ArgumentError,
123
+ "#{max} is not a valid time"
124
+ ) unless max =~ TIME_REGEX
125
+
126
+ fail(
127
+ ArgumentError,
128
+ "#{min} is greater than #{max}"
129
+ ) if min > max
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Artic
3
+ VERSION = '1.0.0'
4
+ end
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: artic
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alessandro Desantis
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-10-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tzinfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.2.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.2.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: tzinfo-data
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.2016.7
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.2016.7
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: fuubar
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description:
140
+ email:
141
+ - desa.alessandro@gmail.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".gitignore"
147
+ - ".rspec"
148
+ - ".rubocop.yml"
149
+ - ".travis.yml"
150
+ - Gemfile
151
+ - LICENSE.txt
152
+ - README.md
153
+ - Rakefile
154
+ - artic.gemspec
155
+ - bin/console
156
+ - bin/setup
157
+ - lib/artic.rb
158
+ - lib/artic/availability.rb
159
+ - lib/artic/calendar.rb
160
+ - lib/artic/collection/availability_collection.rb
161
+ - lib/artic/collection/occupation_collection.rb
162
+ - lib/artic/occupation.rb
163
+ - lib/artic/time_range.rb
164
+ - lib/artic/version.rb
165
+ homepage: https://github.com/alessandro1997/artic
166
+ licenses:
167
+ - MIT
168
+ metadata: {}
169
+ post_install_message:
170
+ rdoc_options: []
171
+ require_paths:
172
+ - lib
173
+ required_ruby_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: '0'
178
+ required_rubygems_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '0'
183
+ requirements: []
184
+ rubyforge_project:
185
+ rubygems_version: 2.5.1
186
+ signing_key:
187
+ specification_version: 4
188
+ summary: A Ruby gem for time computations.
189
+ test_files: []