availabiliter 0.2.0 → 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 +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
|