availabiliter 0.2.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +7 -1
- data/.rubocop.yml +8 -1
- data/CHANGELOG.md +11 -1
- data/README.md +150 -27
- data/availabiliter.gemspec +2 -1
- data/bin/console +6 -0
- data/lib/availabiliter/availabilities_calculator.rb +41 -0
- data/lib/availabiliter/errors.rb +5 -0
- data/lib/availabiliter/input_formatter.rb +48 -0
- data/lib/availabiliter/options_validator.rb +56 -0
- data/lib/availabiliter/output_formatter.rb +42 -0
- data/lib/availabiliter/time_slot.rb +50 -0
- data/lib/availabiliter/time_slot_collection.rb +58 -0
- data/lib/availabiliter/version.rb +2 -2
- data/lib/availabiliter.rb +40 -7
- data/rakelib/measure_performance.rake +31 -0
- data/rakelib/test_ruby_versions.rake +13 -0
- metadata +28 -7
- data/lib/availabiliter/date_range.rb +0 -68
- data/lib/availabiliter/timeframe.rb +0 -69
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: acbff7e9ed2d5615c2298a6b8802fe20920b594a19292fdff70d933100610a4d
|
4
|
+
data.tar.gz: 71b01103cb94ebfd73733135d95758bbc4fbd0c4936d2199f7917d3f9be06904
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b5658fb8a5aae7db823d0767b6eff6ecd65fc305258e2e79ec483df63d8bed008fdca0a54ec1ccdb1a0079fabdc1887262f130401e77a716f297d959ef5fbe64
|
7
|
+
data.tar.gz: 5c0bf3d49fd0064b597cbfd5d83eccbebe6bfebf2a78c265904bb9e9ea044d00fb027e6588e38337bf61c6884faa41340851185089b426c3d3897cddcecae0a4
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
AllCops:
|
2
2
|
TargetRubyVersion: 2.7.2
|
3
|
+
NewCops: enable
|
3
4
|
|
4
5
|
Style/StringLiterals:
|
5
6
|
Enabled: true
|
@@ -11,7 +12,13 @@ Style/StringLiteralsInInterpolation:
|
|
11
12
|
|
12
13
|
Layout/LineLength:
|
13
14
|
Max: 120
|
15
|
+
Exclude:
|
16
|
+
- 'spec/**/*'
|
17
|
+
- 'rakelib/*'
|
14
18
|
|
15
19
|
Metrics/BlockLength:
|
16
20
|
Exclude:
|
17
|
-
- 'spec/**/*'
|
21
|
+
- 'spec/**/*'
|
22
|
+
|
23
|
+
Style/FrozenStringLiteralComment:
|
24
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
## Future releases
|
2
|
-
-
|
2
|
+
- Easy integration in Rails
|
3
|
+
- Allow to pick your own infinity format
|
4
|
+
|
5
|
+
## 1.0.0
|
6
|
+
|
7
|
+
- Adds support for Time and Unix timestamps
|
8
|
+
- Allows to pick preferred format for the output: Time or Unix timestamps.
|
9
|
+
- Allow to pick your timezone when choosing time format
|
10
|
+
- Ruby ranges are not accepted anymore as input
|
11
|
+
- Float::INFINITY replaces Nil class to represent time infinity
|
12
|
+
- Major architectural internal changes to improve maintainability
|
3
13
|
|
4
14
|
## 0.2.0
|
5
15
|
- Active Support dependency removal
|
data/README.md
CHANGED
@@ -1,54 +1,177 @@
|
|
1
1
|
# Availabiliter
|
2
2
|
|
3
|
-
|
3
|
+
Calulating gaps/availabilities between two time slots should be easy right ? Well very often it is not because of various edge cases like:
|
4
|
+
- endless or beginless time slots
|
5
|
+
- overlapping time slots
|
6
|
+
- consecutive time slots
|
7
|
+
- one second time slots
|
8
|
+
- different time zones
|
4
9
|
|
5
|
-
|
6
|
-
- overlapping date ranges
|
7
|
-
- consecutive date ranges
|
8
|
-
- one day date ranges
|
10
|
+
Availabiter is a tested and documented Ruby libary which provides an easy way of performing this calculation while handling all edge cases above.
|
9
11
|
|
10
|
-
|
12
|
+
## Basic example
|
11
13
|
|
12
|
-
|
14
|
+
For this given array of time slots...
|
13
15
|
|
14
|
-
|
16
|
+
```ruby
|
17
|
+
shift_1 = [Time.new(2021, 1, 1, 8), Time.new(2021, 1, 1, 12)]
|
18
|
+
shift_2 = [Time.new(2021, 1, 1, 14), Time.new(2021, 1, 1, 18)]
|
19
|
+
working_hours = [shift_1, shift_2]
|
20
|
+
```
|
21
|
+
|
22
|
+
...you can calculate the gaps between those time slots by doing the following.
|
15
23
|
|
16
24
|
```ruby
|
17
|
-
|
25
|
+
Availabiliter.call(working_hours, format: :time)
|
26
|
+
# returns:
|
27
|
+
# [
|
28
|
+
# [-Infinity, 2021-01-01 07:59:59 +0100],
|
29
|
+
# [2021-01-01 12:00:01 +0100, 2021-01-01 13:59:59 +0100],
|
30
|
+
# [2021-01-01 18:00:01 +0100, Infinity]
|
31
|
+
# ]
|
18
32
|
```
|
19
33
|
|
20
|
-
And then execute:
|
21
34
|
|
22
|
-
|
35
|
+
## Features
|
23
36
|
|
24
|
-
|
37
|
+
### Overlapping time slots
|
25
38
|
|
26
|
-
|
39
|
+
```ruby
|
40
|
+
shift_1 = [Time.new(2021, 1, 1, 12), Time.new(2021, 1, 1, 20)]
|
41
|
+
shift_2 = [Time.new(2021, 1, 1, 10), Time.new(2021, 1, 1, 22)]
|
42
|
+
available_hours = [shift_1, shift_2]
|
43
|
+
|
44
|
+
Availabiliter.call(working_hours, format: :time)
|
45
|
+
# => [[-Infinity, 2021-01-01 09:59:59 +0100], [2021-01-01 22:00:01 +0100, Infinity]]
|
46
|
+
```
|
47
|
+
|
48
|
+
## Consecutive time slots
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
shift_1 = [Time.new(2021, 1, 1, 12), Time.new(2021, 1, 1, 20, 0, 1)]
|
52
|
+
shift_2 = [Time.new(2021, 1, 1, 20, 0, 1), Time.new(2021, 1, 1, 22)]
|
53
|
+
working_hours = [shift_1, shift_2]
|
54
|
+
|
55
|
+
Availabiliter.call(working_hours, format: :time)
|
56
|
+
# => [[-Infinity, 2021-01-01 11:59:59 +0100], [2021-01-01 22:00:01 +0100, Infinity]]
|
57
|
+
```
|
58
|
+
|
59
|
+
### Endless or beginless time slots
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
shift_1 = [-Float::INFINITY, Time.new(2021, 1, 1, 12)]
|
63
|
+
shift_2 = [Time.new(2021, 1, 2, 8), Float::INFINITY]
|
64
|
+
working_hours = [shift_1, shift_2]
|
65
|
+
|
66
|
+
Availabiliter.call(working_hours, format: :time)
|
67
|
+
# => [[2021-01-01 12:00:01 +0100, 2021-01-02 07:59:59 +0100]]
|
68
|
+
```
|
69
|
+
|
70
|
+
### Boundaries
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
minimum_availability_start = Time.new(2021, 1, 1, 6)
|
74
|
+
maximum_availability_end = Time.new(2021, 1, 1, 11)
|
75
|
+
|
76
|
+
shift_1 = [Time.new(2021, 1, 1, 8), Time.new(2021, 1, 1, 12)]
|
77
|
+
working_hours = [shift_1]
|
78
|
+
|
79
|
+
Availabiliter.call(
|
80
|
+
working_hours,
|
81
|
+
minimum_availability_start: minimum_availability_start,
|
82
|
+
maximum_availability_end: maximum_availability_end,
|
83
|
+
format: :time
|
84
|
+
)
|
85
|
+
# => [[2021-01-01 06:00:00 +0100, 2021-01-01 07:59:59 +0100]]
|
86
|
+
```
|
87
|
+
|
88
|
+
### Format
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
shift = [Time.new(2021, 1, 1, 8), Time.new(2021, 1, 1, 12)]
|
92
|
+
working_hours = [shift]
|
93
|
+
|
94
|
+
Availabiliter.call(working_hours)
|
95
|
+
# => [[-Infinity, 1609484399], [1609498801, Infinity]]
|
96
|
+
|
97
|
+
Availabiliter.call(working_hours, format: :time)
|
98
|
+
# => [[-Infinity, 2021-01-01 07:59:59 +0100], [2021-01-01 12:00:01 +0100, Infinity]]
|
99
|
+
```
|
100
|
+
|
101
|
+
### Timezone
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
time_zone = "+00:00"
|
105
|
+
shift = [Time.new(2021, 1, 1, 8, 0, 0, time_zone), Time.new(2021, 1, 1, 12, 0, 0, time_zone)]
|
106
|
+
working_hours = [shift]
|
107
|
+
|
108
|
+
Availabiliter.call(working_hours, time_zone: "+10:00", format: :time)
|
109
|
+
# => [[-Infinity, 2021-01-01 17:59:59 +1000], [2021-01-01 22:00:01 +1000, Infinity]]
|
110
|
+
```
|
111
|
+
|
112
|
+
When a time zone is not specifed by default Ruby determines the time zone according to the current system time.
|
27
113
|
|
28
114
|
## Usage
|
29
115
|
|
116
|
+
### Expected input
|
117
|
+
|
118
|
+
Expected input is an array of arrays. Each of those subarray should represent a time slot with the first element being the time slot start and the last element being the time slot end.
|
119
|
+
A "time slot array" size should contain only 2 elements or an error will be returned.
|
120
|
+
|
30
121
|
```ruby
|
31
|
-
|
32
|
-
|
122
|
+
incorrect_shift = [Time.new(2021, 1, 1, 8), Time.new(2021, 1, 1, 12), Time.new(2021, 1, 1, 14)]
|
123
|
+
working_hours = [incorrect_shift]
|
124
|
+
|
125
|
+
Availabiliter.call(working_hours)
|
126
|
+
# => Availabiliter::IncorrectInput (In the array input there is a time slot array which size is different from 2)
|
127
|
+
```
|
128
|
+
|
129
|
+
Time slot start and end should be instances of `Date`, `Time` and `Integer` classes. `Integer` in that case represent unix timestamps.
|
130
|
+
During calculations `Date` instances will be converted to timestamp representing the beginning of the day. Be aware that if a time zone is specified Availabiliter will take it into account while doing this conversion.
|
131
|
+
|
132
|
+
### Expected output
|
133
|
+
|
134
|
+
The expected output is an array of arrays, each representing an availability. Availabilities are unix timestamps by default but this can be overriden using the `format` option.
|
135
|
+
The supported output formats are:
|
136
|
+
- unix timestamps
|
137
|
+
- Time instances
|
138
|
+
|
139
|
+
### Infinity
|
140
|
+
|
141
|
+
Time slots with an infinite start or an infinite end are supported. As input positive infinity should be represented with `Float::INFINITY`and negative infinity with `Float::INFINTIY`.
|
142
|
+
The same values can be expected as output.
|
33
143
|
|
34
|
-
|
35
|
-
# => [Date.new(1999, 5, 2)..Date.new(1999, 12, 31), Date.new(2000, 2, 2)..Date.new(2000, 2, 29)]
|
144
|
+
### Lowest time value accepted
|
36
145
|
|
37
|
-
|
38
|
-
Availabiliter.get_availabilities(holidays, Date.new(1999, 12, 15))
|
39
|
-
# => [Date.new(1999, 12, 15)..Date.new(1999, 12, 31), Date.new(2000, 2, 2)..Date.new(2000, 2, 29)]
|
146
|
+
Since we rely on unix timestamps to deal with time zones, seconds are the lowest time value that can be accepted as an input.
|
40
147
|
|
41
|
-
|
42
|
-
holidays = [Date.new(1999, 1, 2)..Date.new(1999, 5, 1)]
|
148
|
+
### Performance
|
43
149
|
|
44
|
-
|
45
|
-
# => [Date.new(1999, 5, 2)..]
|
150
|
+
Benchmark done with ruby 2.7.2p137 on MacBook Pro (13-inch, M1, 2020) with an Apple M1 processor.
|
46
151
|
|
152
|
+
| | Real time per second |
|
153
|
+
|---------------|----------------------|
|
154
|
+
| 1000 input | 0.004353 |
|
155
|
+
| 10 000 input | 0.038922 |
|
156
|
+
| 100 000 input | 0.401442 |
|
157
|
+
|
158
|
+
To do a benchmark on your machine git clone the repo and run `rake measure_performance`.
|
159
|
+
|
160
|
+
## Installation
|
161
|
+
|
162
|
+
Add this line to your application's Gemfile:
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
gem 'availabiliter'
|
47
166
|
```
|
48
|
-
## Future improvements
|
49
167
|
|
50
|
-
|
51
|
-
|
168
|
+
And then execute:
|
169
|
+
|
170
|
+
$ bundle install
|
171
|
+
|
172
|
+
Or install it yourself as:
|
173
|
+
|
174
|
+
$ gem install availabiliter
|
52
175
|
|
53
176
|
## Development
|
54
177
|
|
data/availabiliter.gemspec
CHANGED
@@ -28,7 +28,8 @@ Gem::Specification.new do |spec|
|
|
28
28
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
29
|
spec.require_paths = ["lib"]
|
30
30
|
|
31
|
-
spec.add_development_dependency "
|
31
|
+
spec.add_development_dependency "pry", "~> 0.13.1"
|
32
|
+
spec.add_development_dependency "pry-byebug", "~> 3.10.1"
|
32
33
|
spec.add_development_dependency "rake", "~> 13.0.3"
|
33
34
|
spec.add_development_dependency "rspec", "~> 3.10"
|
34
35
|
spec.add_development_dependency "rubocop", "~> 1.16.1"
|
data/bin/console
CHANGED
@@ -0,0 +1,41 @@
|
|
1
|
+
require_relative "output_formatter"
|
2
|
+
require_relative "input_formatter"
|
3
|
+
require_relative "time_slot_collection"
|
4
|
+
|
5
|
+
class Availabiliter
|
6
|
+
# Centralize and orchestrate all behavior for availabilities calculations
|
7
|
+
class AvailabilitiesCalculator
|
8
|
+
attr_reader :raw_time_slots, :minimum_availability_start, :maximum_availability_end, :format, :time_zone
|
9
|
+
|
10
|
+
def initialize(raw_time_slots, minimum_availability_start:, maximum_availability_end:, format:, time_zone:)
|
11
|
+
@raw_time_slots = raw_time_slots
|
12
|
+
@minimum_availability_start = minimum_availability_start
|
13
|
+
@maximum_availability_end = maximum_availability_end
|
14
|
+
@format = format
|
15
|
+
@time_zone = time_zone
|
16
|
+
end
|
17
|
+
|
18
|
+
def call
|
19
|
+
output_availabilities
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def time_slots
|
25
|
+
InputFormatter.new(input_array: raw_time_slots, time_zone: time_zone).time_slots
|
26
|
+
end
|
27
|
+
|
28
|
+
def availabilities
|
29
|
+
time_slot_collection.availabilities
|
30
|
+
end
|
31
|
+
|
32
|
+
def output_availabilities
|
33
|
+
OutputFormatter.new(availabilities, format: format, time_zone: time_zone).format_availabilities
|
34
|
+
end
|
35
|
+
|
36
|
+
def time_slot_collection
|
37
|
+
TimeSlotCollection.new(time_slots: time_slots, minimum_availability_start: minimum_availability_start,
|
38
|
+
maximum_availability_end: maximum_availability_end)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative "time_slot"
|
2
|
+
|
3
|
+
class Availabiliter
|
4
|
+
# Convert raw time slots to TimeSlot instances
|
5
|
+
class InputFormatter
|
6
|
+
attr_reader :input_array, :time_zone
|
7
|
+
|
8
|
+
def initialize(input_array:, time_zone:)
|
9
|
+
@input_array = input_array
|
10
|
+
@time_zone = time_zone
|
11
|
+
end
|
12
|
+
|
13
|
+
def time_slots
|
14
|
+
input_array.map do |time_slot_input|
|
15
|
+
validate_time_slot_input(time_slot_input)
|
16
|
+
|
17
|
+
TimeSlot.new(starting_time: to_timestamp(time_slot_input.first),
|
18
|
+
ending_time: to_timestamp(time_slot_input.last))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def validate_time_slot_input(time_slot_input)
|
25
|
+
return if time_slot_input.size == 2
|
26
|
+
|
27
|
+
raise IncorrectInput, "In the array input there is a time slot array which size is different from 2"
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_timestamp(value)
|
31
|
+
case value.class.to_s
|
32
|
+
when "Time" then value.to_i
|
33
|
+
when "Date"
|
34
|
+
convert_date(value)
|
35
|
+
else
|
36
|
+
value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def convert_date(date)
|
41
|
+
if time_zone
|
42
|
+
Time.new(date.year, date.month, date.day, 0, 0, 0, time_zone).to_i
|
43
|
+
else
|
44
|
+
date.to_time.to_i
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
class Availabiliter
|
2
|
+
# Verify that the options input are valid
|
3
|
+
class OptionsValidator
|
4
|
+
attr_reader :minimum_availability_start, :maximum_availability_end, :format
|
5
|
+
|
6
|
+
def initialize(minimum_availability_start:, maximum_availability_end:, format:, **_options)
|
7
|
+
@minimum_availability_start = minimum_availability_start
|
8
|
+
@maximum_availability_end = maximum_availability_end
|
9
|
+
@format = format
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
validate_format
|
14
|
+
validate_boundary_class
|
15
|
+
validate_boundary_value
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def validate_format
|
21
|
+
return if PROCESSABLE_FORMATS.include?(format)
|
22
|
+
|
23
|
+
raise IncorrectInput, "#{format} is not an available format"
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_boundary_class
|
27
|
+
raise_invalid_boundary_class if invalid_boundary_class?
|
28
|
+
end
|
29
|
+
|
30
|
+
def invalid_boundary_class?
|
31
|
+
[minimum_availability_start, maximum_availability_end].one? do |boundary|
|
32
|
+
PROCESSABLE_TIME_CLASSES.none? { |klass| klass == boundary.class }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_boundary_value
|
37
|
+
raise_invalid_boundary_value if invalid_boundary_value?
|
38
|
+
end
|
39
|
+
|
40
|
+
def invalid_boundary_value?
|
41
|
+
minimum_availability_start > maximum_availability_end
|
42
|
+
end
|
43
|
+
|
44
|
+
def raise_invalid_boundary_class
|
45
|
+
raise IncorrectInput, invalid_boundary_class_message
|
46
|
+
end
|
47
|
+
|
48
|
+
def raise_invalid_boundary_value
|
49
|
+
raise IncorrectInput, "minimum_availability_start can't be greater than maximum_availability_end"
|
50
|
+
end
|
51
|
+
|
52
|
+
def invalid_boundary_class_message
|
53
|
+
"#{minimum_availability_start.class}, #{maximum_availability_end.class} : one of this boundary class is not valid"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
class Availabiliter
|
2
|
+
# Transform availabilities to the required format
|
3
|
+
class OutputFormatter
|
4
|
+
attr_reader :availabilities, :format, :time_zone
|
5
|
+
|
6
|
+
def initialize(availabilities, format:, time_zone:)
|
7
|
+
@availabilities = availabilities
|
8
|
+
@format = format
|
9
|
+
@time_zone = time_zone
|
10
|
+
end
|
11
|
+
|
12
|
+
def format_availabilities
|
13
|
+
availabilities.map do |availability|
|
14
|
+
convert_to_format(availability)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def convert_to_format(availability)
|
21
|
+
[convert_timestamp(availability.first), convert_timestamp(availability.last)]
|
22
|
+
end
|
23
|
+
|
24
|
+
def convert_timestamp(timestamp)
|
25
|
+
return timestamp if timestamp.infinite?
|
26
|
+
|
27
|
+
case format
|
28
|
+
when :time then convert_time(timestamp)
|
29
|
+
else
|
30
|
+
timestamp
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def convert_time(timestamp)
|
35
|
+
if time_zone
|
36
|
+
Time.at(timestamp, in: time_zone)
|
37
|
+
else
|
38
|
+
Time.at(timestamp)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class Availabiliter
|
2
|
+
# A TimeSlot represent a slot in time with a start and an end that can be finite or infinite.
|
3
|
+
# It can also act as a boundary in availabilities calculation.
|
4
|
+
class TimeSlot
|
5
|
+
attr_reader :starting_time, :ending_time, :boundary
|
6
|
+
|
7
|
+
def initialize(starting_time:, ending_time:, boundary: false)
|
8
|
+
@starting_time = starting_time
|
9
|
+
@ending_time = ending_time
|
10
|
+
@boundary = boundary
|
11
|
+
validate
|
12
|
+
end
|
13
|
+
|
14
|
+
def furthest(other)
|
15
|
+
[self, other].max_by(&:ending_time)
|
16
|
+
end
|
17
|
+
|
18
|
+
def dependent?(other)
|
19
|
+
adjacent?(other) || overlaps?(other)
|
20
|
+
end
|
21
|
+
|
22
|
+
def next_second
|
23
|
+
ending_time + 1
|
24
|
+
end
|
25
|
+
|
26
|
+
def previous_second
|
27
|
+
starting_time - 1
|
28
|
+
end
|
29
|
+
|
30
|
+
def overlaps?(other)
|
31
|
+
!does_not_overlap?(other)
|
32
|
+
end
|
33
|
+
|
34
|
+
def does_not_overlap?(other)
|
35
|
+
starting_time > other.ending_time || ending_time < other.starting_time
|
36
|
+
end
|
37
|
+
|
38
|
+
def adjacent?(other)
|
39
|
+
other.ending_time == previous_second || other.starting_time == next_second
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def validate
|
45
|
+
return if ending_time >= starting_time
|
46
|
+
|
47
|
+
raise IncorrectInput, "A time slot ending time must be equal or greater than its starting time"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require_relative "time_slot"
|
2
|
+
require "forwardable"
|
3
|
+
|
4
|
+
class Availabiliter
|
5
|
+
# A time slot collection is a sorted collection of time slots with a start and an end boundary
|
6
|
+
class TimeSlotCollection
|
7
|
+
include Enumerable
|
8
|
+
attr_reader :collection
|
9
|
+
|
10
|
+
def initialize(time_slots:, minimum_availability_start:, maximum_availability_end:)
|
11
|
+
@minimum_availability_start = minimum_availability_start
|
12
|
+
@maximum_availability_end = maximum_availability_end
|
13
|
+
@collection = build_collection(time_slots)
|
14
|
+
end
|
15
|
+
|
16
|
+
def availabilities
|
17
|
+
furthest_time_slot = start_boundary
|
18
|
+
|
19
|
+
filter_map.with_index do |time_slot, index|
|
20
|
+
next_time_slot = collection[index + 1]
|
21
|
+
furthest_time_slot = time_slot.furthest(furthest_time_slot)
|
22
|
+
|
23
|
+
next if index == last_time_slot_index
|
24
|
+
next if furthest_time_slot.dependent?(next_time_slot)
|
25
|
+
|
26
|
+
[furthest_time_slot.next_second, next_time_slot.previous_second]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :minimum_availability_start, :maximum_availability_end
|
33
|
+
|
34
|
+
def build_collection(time_slots)
|
35
|
+
time_slots.then do |array|
|
36
|
+
array.sort_by!(&:starting_time)
|
37
|
+
array.unshift(start_boundary)
|
38
|
+
array.push(end_boundary)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def each(&block)
|
43
|
+
collection.each(&block)
|
44
|
+
end
|
45
|
+
|
46
|
+
def start_boundary
|
47
|
+
TimeSlot.new(starting_time: -Float::INFINITY, ending_time: minimum_availability_start - 1, boundary: true)
|
48
|
+
end
|
49
|
+
|
50
|
+
def end_boundary
|
51
|
+
TimeSlot.new(starting_time: maximum_availability_end + 1, ending_time: Float::INFINITY, boundary: true)
|
52
|
+
end
|
53
|
+
|
54
|
+
def last_time_slot_index
|
55
|
+
@last_time_slot_index ||= count - 1
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/availabiliter.rb
CHANGED
@@ -1,13 +1,46 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
1
|
require_relative "availabiliter/version"
|
4
|
-
require_relative "availabiliter/
|
2
|
+
require_relative "availabiliter/availabilities_calculator"
|
3
|
+
require_relative "availabiliter/errors"
|
4
|
+
require_relative "availabiliter/options_validator"
|
5
|
+
require "date"
|
6
|
+
|
7
|
+
# Availabilityer main class and namespace
|
8
|
+
class Availabiliter
|
9
|
+
PROCESSABLE_FORMATS = %i[time integer].freeze
|
10
|
+
DEFAULT_FORMAT = :integer
|
11
|
+
PROCESSABLE_TIME_CLASSES = [Date, Time, Integer, Float].freeze
|
5
12
|
|
6
|
-
# Availability calculator class
|
7
|
-
module Availabiliter
|
8
13
|
class << self
|
9
|
-
def
|
10
|
-
|
14
|
+
def call(raw_time_slots, **options)
|
15
|
+
new(raw_time_slots, options).calculate
|
11
16
|
end
|
12
17
|
end
|
18
|
+
|
19
|
+
def initialize(raw_time_slots, options)
|
20
|
+
@options = default_options.merge(options)
|
21
|
+
@raw_time_slots = raw_time_slots
|
22
|
+
|
23
|
+
validate_options
|
24
|
+
end
|
25
|
+
|
26
|
+
def calculate
|
27
|
+
AvailabilitiesCalculator.new(raw_time_slots, **options).call
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :raw_time_slots, :options
|
33
|
+
|
34
|
+
def default_options
|
35
|
+
{
|
36
|
+
minimum_availability_start: -Float::INFINITY,
|
37
|
+
maximum_availability_end: Float::INFINITY,
|
38
|
+
format: DEFAULT_FORMAT,
|
39
|
+
time_zone: nil
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_options
|
44
|
+
OptionsValidator.new(options).call
|
45
|
+
end
|
13
46
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "benchmark"
|
2
|
+
require "date"
|
3
|
+
require_relative "../lib/availabiliter"
|
4
|
+
|
5
|
+
desc "Benchmark easily the performance of gem"
|
6
|
+
task :measure_performance do
|
7
|
+
Benchmark.bm do |benchmark|
|
8
|
+
raw_time_slots = build_raw_time_slots(1000)
|
9
|
+
benchmark.report(:one_thousand_raw_time_slots) { Availabiliter.call(raw_time_slots) }
|
10
|
+
|
11
|
+
raw_time_slots = build_raw_time_slots(10_000)
|
12
|
+
benchmark.report(:ten_thousand_raw_time_slots) { Availabiliter.call(raw_time_slots) }
|
13
|
+
|
14
|
+
raw_time_slots = build_raw_time_slots(100_000)
|
15
|
+
benchmark.report(:one_hundrer_thousand_raw_time_slots) { Availabiliter.call(raw_time_slots) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def build_raw_time_slots(number)
|
20
|
+
date_range = Date.new(2020, 1, 1)..Date.new(2030, 1, 1)
|
21
|
+
timestamp_range = 1_577_833_200..1_893_452_400
|
22
|
+
time_range = Time.new(2020, 1, 1)..Time.new(2030, 1, 1)
|
23
|
+
rand_array = [date_range, timestamp_range, time_range]
|
24
|
+
|
25
|
+
number.times.each_with_object([]) do |_i, array|
|
26
|
+
starting_time = Random.rand(rand_array.sample)
|
27
|
+
finish_time = starting_time + Random.rand(1000)
|
28
|
+
|
29
|
+
array << [starting_time, finish_time]
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "date"
|
2
|
+
require_relative "../lib/availabiliter"
|
3
|
+
|
4
|
+
desc "Run the tests against all ruby versions"
|
5
|
+
task :test_ruby_versions do
|
6
|
+
authorized_versions = ["3.1.2", "3.0.4", "2.7.6", "2.7.2"]
|
7
|
+
|
8
|
+
authorized_versions.each do |version|
|
9
|
+
print "---------RUNNING TEST FOR RUBY #{version}---------"
|
10
|
+
system("export RBENV_VERSION=#{version} && bundle install && bundle exec rspec --fail-fast --format progress && export RBENV_VERSION=")
|
11
|
+
print "--------------------------------------------------"
|
12
|
+
end
|
13
|
+
end
|
metadata
CHANGED
@@ -1,29 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: availabiliter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- lioneldebauge
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-10-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: pry
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 0.13.1
|
20
20
|
type: :development
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 0.13.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: pry-byebug
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 3.10.1
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 3.10.1
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: rake
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -86,9 +100,16 @@ files:
|
|
86
100
|
- bin/console
|
87
101
|
- bin/setup
|
88
102
|
- lib/availabiliter.rb
|
89
|
-
- lib/availabiliter/
|
90
|
-
- lib/availabiliter/
|
103
|
+
- lib/availabiliter/availabilities_calculator.rb
|
104
|
+
- lib/availabiliter/errors.rb
|
105
|
+
- lib/availabiliter/input_formatter.rb
|
106
|
+
- lib/availabiliter/options_validator.rb
|
107
|
+
- lib/availabiliter/output_formatter.rb
|
108
|
+
- lib/availabiliter/time_slot.rb
|
109
|
+
- lib/availabiliter/time_slot_collection.rb
|
91
110
|
- lib/availabiliter/version.rb
|
111
|
+
- rakelib/measure_performance.rake
|
112
|
+
- rakelib/test_ruby_versions.rake
|
92
113
|
homepage: https://github.com/lioneldebauge/availabiliter.git
|
93
114
|
licenses:
|
94
115
|
- MIT
|
@@ -1,68 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "date"
|
4
|
-
|
5
|
-
module Availabiliter
|
6
|
-
# DateRange is a Range with only dates
|
7
|
-
# DateRange start_date is always the earliest date and end_date the latest date
|
8
|
-
class DateRange < Range
|
9
|
-
include Comparable
|
10
|
-
|
11
|
-
alias start_date begin
|
12
|
-
alias end_date end
|
13
|
-
|
14
|
-
def initialize(start_date, end_date)
|
15
|
-
super
|
16
|
-
raise ArgumentError, "bad value for DateRange" unless valid?
|
17
|
-
end
|
18
|
-
|
19
|
-
def independent?(other)
|
20
|
-
return true if other.nil?
|
21
|
-
|
22
|
-
!overlaps?(other) && !adjacent?(other)
|
23
|
-
end
|
24
|
-
|
25
|
-
def tomorrow
|
26
|
-
end_date&.next_day
|
27
|
-
end
|
28
|
-
|
29
|
-
def yesterday
|
30
|
-
start_date.prev_day
|
31
|
-
end
|
32
|
-
|
33
|
-
## adjacent == touches but doesn't overlap other DateRange
|
34
|
-
def adjacent?(other)
|
35
|
-
return other.end_date == yesterday if end_date.nil?
|
36
|
-
|
37
|
-
other.end_date == yesterday || other.start_date == tomorrow
|
38
|
-
end
|
39
|
-
|
40
|
-
def overlaps?(other)
|
41
|
-
cover?(other.begin) || other.cover?(first)
|
42
|
-
end
|
43
|
-
|
44
|
-
def next_availability(next_date_range)
|
45
|
-
return if end_date.nil?
|
46
|
-
return tomorrow..nil if next_date_range.nil?
|
47
|
-
return unless independent?(next_date_range)
|
48
|
-
|
49
|
-
gap_start = tomorrow
|
50
|
-
gap_end = next_date_range.yesterday
|
51
|
-
|
52
|
-
gap_start..gap_end
|
53
|
-
end
|
54
|
-
|
55
|
-
def furthest(other)
|
56
|
-
return self if end_date.nil? || other.nil?
|
57
|
-
return other if other.end_date.nil?
|
58
|
-
|
59
|
-
[self, other].max_by(&:end_date)
|
60
|
-
end
|
61
|
-
|
62
|
-
private
|
63
|
-
|
64
|
-
def valid?
|
65
|
-
start_date.instance_of?(Date) && (end_date.nil? || start_date < end_date)
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|
@@ -1,69 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative "date_range"
|
4
|
-
|
5
|
-
module Availabiliter
|
6
|
-
# A TimeFrame is an object representing several DateRange instances. It can have a start.
|
7
|
-
# It can have only one endless DateRange.
|
8
|
-
class TimeFrame
|
9
|
-
attr_reader :start_date, :date_ranges
|
10
|
-
|
11
|
-
def initialize(array, start_date = nil)
|
12
|
-
@start_date = start_date
|
13
|
-
@date_ranges = build_date_ranges(array)
|
14
|
-
|
15
|
-
raise ArgumentError unless valid?
|
16
|
-
end
|
17
|
-
|
18
|
-
def availabilities
|
19
|
-
return [start_date..nil] if date_ranges.empty?
|
20
|
-
return build_availabilities if start_date.nil?
|
21
|
-
|
22
|
-
availabilities = build_availabilities
|
23
|
-
start_date < first_date_range.start_date ? availabilities.unshift(first_availability) : availabilities
|
24
|
-
end
|
25
|
-
|
26
|
-
private
|
27
|
-
|
28
|
-
def build_date_ranges(array)
|
29
|
-
date_range_array = array.filter_map do |range|
|
30
|
-
next if out_of_timeframe?(range.end)
|
31
|
-
|
32
|
-
DateRange.new(range.begin, range.end)
|
33
|
-
end
|
34
|
-
|
35
|
-
date_range_array.sort_by(&:start_date)
|
36
|
-
end
|
37
|
-
|
38
|
-
def build_availabilities
|
39
|
-
furthest_date_range = first_date_range
|
40
|
-
|
41
|
-
date_ranges.filter_map.with_index do |date_range, index|
|
42
|
-
next_date_range = date_ranges[index + 1]
|
43
|
-
|
44
|
-
furthest_date_range = furthest_date_range.furthest(date_range)
|
45
|
-
furthest_date_range.next_availability(next_date_range)
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
def out_of_timeframe?(range_end)
|
50
|
-
!start_date.nil? && (!range_end.nil? && range_end < start_date)
|
51
|
-
end
|
52
|
-
|
53
|
-
def first_availability
|
54
|
-
start_date..first_date_range.yesterday
|
55
|
-
end
|
56
|
-
|
57
|
-
def first_date_range
|
58
|
-
date_ranges.first
|
59
|
-
end
|
60
|
-
|
61
|
-
def valid?
|
62
|
-
end_date_valid? && date_ranges.count { |date_range| date_range.end_date.nil? } <= 1
|
63
|
-
end
|
64
|
-
|
65
|
-
def end_date_valid?
|
66
|
-
start_date.instance_of?(Date) || start_date.instance_of?(NilClass)
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|