availability 0.0.1
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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +5 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +4 -0
- data/CREDITS +1 -0
- data/Gemfile +4 -0
- data/README.md +51 -0
- data/Rakefile +6 -0
- data/UNLICENSE +24 -0
- data/WAIVER +17 -0
- data/availability.gemspec +28 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/examples/scheduler.rb +88 -0
- data/examples/scheduler_spec.rb +136 -0
- data/lib/availability.rb +22 -0
- data/lib/availability/abstract_availability.rb +173 -0
- data/lib/availability/class_methods.rb +20 -0
- data/lib/availability/createable.rb +12 -0
- data/lib/availability/daily.rb +20 -0
- data/lib/availability/exclusion.rb +91 -0
- data/lib/availability/factory_methods.rb +37 -0
- data/lib/availability/monthly.rb +22 -0
- data/lib/availability/once.rb +29 -0
- data/lib/availability/version.rb +3 -0
- data/lib/availability/weekly.rb +24 -0
- data/lib/availability/yearly.rb +22 -0
- metadata +143 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: b234c8bd7ec4affc5c2021508d9d7f3875541e94
|
4
|
+
data.tar.gz: 2accd7d37b256be224033646289723257fea91ec
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2bc9136b9b206d295305bc283bb3624499827017080981c8c3589014292f058b0a637af1a97494e8c7207eb5b1f4ddb5f74aed709bd218e056dda8ad3f033fcd
|
7
|
+
data.tar.gz: f1170cb99e6773867917b83407e55f5d001c38ea453588553633d5f9a82b589e3f39570bcae419d192d9bacffba9f50a57d1bfbbc0f3d329ed732518b4f969a2
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.ruby-gemset
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
modulo-math-poc
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby-2.3.0
|
data/.travis.yml
ADDED
data/CREDITS
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
* Jason Rogers <jacaetevha@gmail.com>
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# availability - easily and quickly calculate schedule availability
|
2
|
+
|
3
|
+
[![Build Status][travis-availability-png]][travis-availability]
|
4
|
+
[](http://badge.fury.io/rb/availability)
|
5
|
+
|
6
|
+
This library uses modular arithmetic and residue classes to calculate schedule availability for dates. Time ranges within a date are handled differently. The goal is to create an easy-to-use API for schedule availability that is very fast and lightweight that is also easy and lightweight to persist in a database.
|
7
|
+
|
8
|
+
Shout out to @dpmccabe for his [original article](http://dmcca.be/2014/01/09/recurring-subscriptions-with-ruby-rspec-and-modular-arithmetic.html) and code.
|
9
|
+
|
10
|
+
```
|
11
|
+
gem install availability
|
12
|
+
```
|
13
|
+
|
14
|
+
## TODO
|
15
|
+
|
16
|
+
add more documentation
|
17
|
+
|
18
|
+
## Authors
|
19
|
+
|
20
|
+
* Jason Rogers <jacaetevha@gmail.com>
|
21
|
+
|
22
|
+
## Contributors
|
23
|
+
|
24
|
+
* Jason Rogers <jacaetevha@gmail.com>
|
25
|
+
|
26
|
+
## Contributing
|
27
|
+
|
28
|
+
* Do your best to adhere to the existing coding conventions and idioms.
|
29
|
+
* Don't use hard tabs, and don't leave trailing whitespace on any line.
|
30
|
+
Before committing, run `git diff --check` to make sure of this.
|
31
|
+
* Do document every method you add using [YARD][] annotations. Read the
|
32
|
+
[tutorial][YARD-GS] or just look at the existing code for examples.
|
33
|
+
* Don't touch the `availability.gemspec` or `VERSION` files. If you need
|
34
|
+
to change them, do so on your private branch only.
|
35
|
+
* Do feel free to add yourself to the `CREDITS` file and the
|
36
|
+
corresponding list in the the `README`. Alphabetical order applies.
|
37
|
+
* Don't touch the `AUTHORS` file. If your contributions are significant
|
38
|
+
enough, be assured we will eventually add you in there.
|
39
|
+
* Do note that in order for us to merge any non-trivial changes (as a rule
|
40
|
+
of thumb, additions larger than about 15 lines of code), we need an
|
41
|
+
explicit on record from you. You can submit this dedication as a GitHub
|
42
|
+
Issue in this repository. See [public domain dedication][PDD] for an example.
|
43
|
+
|
44
|
+
## License
|
45
|
+
|
46
|
+
This is free and unencumbered public domain software. For more information,
|
47
|
+
see <http://unlicense.org/> or the accompanying [UNLICENSE]{UNLICENSE} file.
|
48
|
+
|
49
|
+
[YARD]: http://yardoc.org/
|
50
|
+
[YARD-GS]: http://rubydoc.info/docs/yard/file/docs/GettingStarted.md
|
51
|
+
[PDD]: http://lists.w3.org/Archives/Public/public-rdf-ruby/2010May/0013.html
|
data/Rakefile
ADDED
data/UNLICENSE
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
2
|
+
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
4
|
+
distribute this software, either in source code form or as a compiled
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
6
|
+
means.
|
7
|
+
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
9
|
+
of this software dedicate any and all copyright interest in the
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
11
|
+
of the public at large and to the detriment of our heirs and
|
12
|
+
successors. We intend this dedication to be an overt act of
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
14
|
+
software under copyright law.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
23
|
+
|
24
|
+
For more information, please refer to <http://unlicense.org>
|
data/WAIVER
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# Copyright waiver for <https://github.com/upper-hand/availability>
|
2
|
+
|
3
|
+
I dedicate any and all copyright interest in this software to the
|
4
|
+
public domain. I make this dedication for the benefit of the public at
|
5
|
+
large and to the detriment of my heirs and successors. I intend this
|
6
|
+
dedication to be an overt act of relinquishment in perpetuity of all
|
7
|
+
present and future rights to this software under copyright law.
|
8
|
+
|
9
|
+
To the best of my knowledge and belief, my contributions are either
|
10
|
+
originally authored by me or are derived from prior works which I have
|
11
|
+
verified are also in the public domain and are not subject to claims
|
12
|
+
of copyright by other parties.
|
13
|
+
|
14
|
+
To the best of my knowledge and belief, no individual, business,
|
15
|
+
organization, government, or other entity has any copyright interest
|
16
|
+
in my contributions, and I affirm that I will not make contributions
|
17
|
+
that are otherwise encumbered.
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'availability/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "availability"
|
8
|
+
spec.version = Availability::VERSION
|
9
|
+
spec.authors = ["Jason Rogers"]
|
10
|
+
spec.email = ["jacaetevha@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Calculating schedule availability}
|
13
|
+
spec.description = %q{Use modular arithmetic and residue classes to calculate schedule availability for dates (times handled separately).}
|
14
|
+
spec.homepage = "https://github.com/upper-hand/availability"
|
15
|
+
spec.license = "Unlicense"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_dependency "activesupport"
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
25
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
26
|
+
spec.add_development_dependency "rspec"
|
27
|
+
spec.add_development_dependency "rspec-its"
|
28
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "availability"
|
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,88 @@
|
|
1
|
+
require_relative '../lib/availability'
|
2
|
+
|
3
|
+
#
|
4
|
+
# This is just an example of a scheduler that might be used with availabilities. It's a work in
|
5
|
+
# progress that's helping to flesh out the design of availabilities.
|
6
|
+
#
|
7
|
+
class Scheduler
|
8
|
+
attr_reader :availabilities, :scheduled
|
9
|
+
|
10
|
+
#
|
11
|
+
# availabilities: a list of Availability instances defining when the resource is available
|
12
|
+
#
|
13
|
+
def initialize(availabilities)
|
14
|
+
@availabilities = validate_availabilities availabilities
|
15
|
+
@scheduled = Hash.new{|h, k| h[k] = []}
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# This method expects either an availability request or a start/end time pair. If the
|
20
|
+
# start/end time pair is offered, this method acts the same as if an Availability::Once
|
21
|
+
# request was offered with the given start_time and a duration going through the given
|
22
|
+
# end_time.
|
23
|
+
#
|
24
|
+
# availability_request: an Availability instance
|
25
|
+
# start_time: Time/DateTime representative of a single request starting at that time
|
26
|
+
# end_time: Time/DateTime representative of a single request ending at that time
|
27
|
+
#
|
28
|
+
# Returns the first availability that corresponds to the availability request. If no
|
29
|
+
# availability is found, returns nil.
|
30
|
+
#
|
31
|
+
def allow?(**args)
|
32
|
+
availability_request = convert **args
|
33
|
+
availability_for availability_request
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# This method expects either an availability request or a start/end time pair. If the
|
38
|
+
# start/end time pair is offered, this method acts the same as if an Availability::Once
|
39
|
+
# request was offered with the given start_time and a duration going through the given
|
40
|
+
# end_time.
|
41
|
+
#
|
42
|
+
# availability_request: an Availability instance
|
43
|
+
# start_time: Time/DateTime representative of a single request starting at that time
|
44
|
+
# end_time: Time/DateTime representative of a single request ending at that time
|
45
|
+
#
|
46
|
+
# returns boolean indicating whether the availability request was scheduled.
|
47
|
+
#
|
48
|
+
def schedule(**args)
|
49
|
+
request = convert **args
|
50
|
+
availability = allow? availability_request: request
|
51
|
+
return self unless availability
|
52
|
+
scheduled[availability] << request unless scheduled[availability].size >= availability.capacity
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
def availability_for(request)
|
58
|
+
@availabilities.detect do |some_availability|
|
59
|
+
some_availability.corresponds_to? request
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def convert(availability_request: nil, start_time: nil, end_time: nil)
|
64
|
+
validate_allow_params! availability_request, start_time, end_time
|
65
|
+
if availability_request.nil?
|
66
|
+
availability_request = Availability::Once.create(
|
67
|
+
start_time: start_time, duration: (end_time.to_time - start_time.to_time).to_i)
|
68
|
+
else
|
69
|
+
availability_request
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def validate_availabilities(availabilities)
|
74
|
+
list = Array(availabilities).flatten.compact
|
75
|
+
return list if list.all? { |e| e.respond_to? :corresponds_to? }
|
76
|
+
raise ArgumentError, "expected a list of availabilities"
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate_allow_params!(availability, start_time, end_time)
|
80
|
+
return if Availability.availability?(availability)
|
81
|
+
return if valid_time?(start_time) && valid_time?(end_time)
|
82
|
+
raise ArgumentError, "must specify either availability_request or (start_time and end_time)"
|
83
|
+
end
|
84
|
+
|
85
|
+
def valid_time?(a_time)
|
86
|
+
Time === a_time || DateTime === a_time || Date === a_time
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require_relative 'scheduler'
|
2
|
+
|
3
|
+
module SchedulerSpecHelpers
|
4
|
+
def T(*args)
|
5
|
+
Time.new *args
|
6
|
+
end
|
7
|
+
|
8
|
+
def one_hour_slot_per_week(**args)
|
9
|
+
Availability.weekly **args, duration: 1.hour
|
10
|
+
end
|
11
|
+
|
12
|
+
def half_hour_slot_per_week(**args)
|
13
|
+
Availability.weekly **args, duration: 30.minutes
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
RSpec.describe Scheduler do
|
18
|
+
include SchedulerSpecHelpers
|
19
|
+
|
20
|
+
context '#allow?' do
|
21
|
+
let(:bob_availabilities) do
|
22
|
+
[
|
23
|
+
one_hour_slot_per_week(start_time: T(2016, 4, 11, 9), stops_by: T(2016, 5, 9, 9)),
|
24
|
+
one_hour_slot_per_week(start_time: T(2016, 4, 12, 9), stops_by: T(2016, 5, 10, 9)),
|
25
|
+
one_hour_slot_per_week(start_time: T(2016, 4, 13, 9), stops_by: T(2016, 5, 11, 9)),
|
26
|
+
one_hour_slot_per_week(start_time: T(2016, 4, 14, 9), stops_by: T(2016, 5, 12, 9)),
|
27
|
+
one_hour_slot_per_week(start_time: T(2016, 4, 15, 9), stops_by: T(2016, 5, 13, 9))
|
28
|
+
]
|
29
|
+
end
|
30
|
+
|
31
|
+
let(:bobs_schedule) { Scheduler.new bob_availabilities }
|
32
|
+
|
33
|
+
describe '#schedule' do
|
34
|
+
let(:request) { bob_availabilities.first.dup }
|
35
|
+
let(:short_request) do
|
36
|
+
half_hour_slot_per_week start_time: T(2016, 4, 14, 9, 15), stops_by: T(2016, 5, 12, 9, 45)
|
37
|
+
end
|
38
|
+
let(:scheduled_count) { -> { bobs_schedule.scheduled.values.map(&:size).sum } }
|
39
|
+
|
40
|
+
before :each do
|
41
|
+
bob_availabilities.each { |e| e.capacity = 1 }
|
42
|
+
end
|
43
|
+
|
44
|
+
context 'at capacity' do
|
45
|
+
before :each do
|
46
|
+
bob_availabilities.each { |e| bobs_schedule.scheduled[e] = [double('a request')] }
|
47
|
+
end
|
48
|
+
|
49
|
+
context 'with a shorter slot that fits within the availability' do
|
50
|
+
it 'does not accept a request' do
|
51
|
+
expect{ bobs_schedule.schedule availability_request: short_request }.not_to change(&scheduled_count)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'does not accept a request' do
|
56
|
+
expect{ bobs_schedule.schedule availability_request: request }.not_to change(&scheduled_count)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context 'not at capacity' do
|
61
|
+
context 'with a shorter slot that fits within the availability' do
|
62
|
+
it 'accepts a request' do
|
63
|
+
expect{ bobs_schedule.schedule availability_request: short_request }.to change(&scheduled_count).from(0).to(1)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'accepts a request' do
|
68
|
+
expect{ bobs_schedule.schedule availability_request: request }.to change(&scheduled_count).from(0).to(1)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe '#allow?' do
|
74
|
+
context 'enforces parameter values' do
|
75
|
+
it { expect{bobs_schedule.allow?}.to raise_error ArgumentError }
|
76
|
+
it { expect{bobs_schedule.allow? availability_request: nil}.to raise_error ArgumentError }
|
77
|
+
it { expect{bobs_schedule.allow? start_time: nil }.to raise_error ArgumentError }
|
78
|
+
it { expect{bobs_schedule.allow? start_time: Date.today }.to raise_error ArgumentError }
|
79
|
+
it { expect{bobs_schedule.allow? end_time: nil }.to raise_error ArgumentError }
|
80
|
+
it { expect{bobs_schedule.allow? end_time: Date.today }.to raise_error ArgumentError }
|
81
|
+
it { expect{bobs_schedule.allow? availability_request: one_hour_slot_per_week(start_time: T(0))}.not_to raise_error }
|
82
|
+
it { expect{bobs_schedule.allow? start_time: Date.today, end_time: Date.tomorrow}.not_to raise_error }
|
83
|
+
end
|
84
|
+
|
85
|
+
context 'with Availability objects' do
|
86
|
+
it 'allows an event requests that start in the first week and go through the end of the availability' do
|
87
|
+
bob_availabilities.each do |a|
|
88
|
+
expect(bobs_schedule.allow? availability_request: a).to be_truthy, "at #{a.start_time}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'allows an event request in the second week that lasts until the end of the availability' do
|
93
|
+
ar = one_hour_slot_per_week(start_time: T(2016, 4, 18, 9), stops_by: T(2016, 5, 2, 9))
|
94
|
+
expect(bobs_schedule.allow? availability_request: ar).to be_truthy
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'does not allow an event request that is beyond the availability frequency' do
|
98
|
+
expect(bobs_schedule.allow? availability_request: one_hour_slot_per_week(start_time: T(2016, 4, 18, 9), frequency: 4)).to be_falsey
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'does not allow an event request that starts before the availability frequency' do
|
102
|
+
expect(bobs_schedule.allow? availability_request: one_hour_slot_per_week(start_time: T(2016, 4, 4, 9), frequency: 4)).to be_falsey
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'does not allow an event request with a different frequency' do
|
106
|
+
expect(bobs_schedule.allow? availability_request: one_hour_slot_per_week(start_time: T(2016, 4, 4, 9), frequency: 5)).to be_falsey
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
context 'with start/end times' do
|
111
|
+
it 'allows an event requests that start in the first week and go through the end of the availability' do
|
112
|
+
bob_availabilities.each do |a|
|
113
|
+
expect(bobs_schedule.allow? start_time: a.start_time, end_time: a.end_time).to be_truthy, "at #{a.start_time}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'allows an event request in the second week that lasts until the end of the availability' do
|
118
|
+
a = bob_availabilities[1]
|
119
|
+
expect(bobs_schedule.allow? start_time: a.start_time, end_time: a.end_time).to be_truthy
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'does not allow an event request that is beyond the availability' do
|
123
|
+
start_time = bob_availabilities.last.start_time + 1.day
|
124
|
+
end_time = bob_availabilities.last.start_time + 2.days - 1.hour
|
125
|
+
expect(bobs_schedule.allow? start_time: start_time, end_time: end_time).to be_falsey
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'does not allow an event request that starts before the availability frequency' do
|
129
|
+
start_time = bob_availabilities.first.start_time - 1.hour
|
130
|
+
end_time = bob_availabilities.first.start_time
|
131
|
+
expect(bobs_schedule.allow? start_time: start_time, end_time: end_time).to be_falsey
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
data/lib/availability.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext'
|
3
|
+
require_relative "availability/version"
|
4
|
+
require_relative 'availability/createable'
|
5
|
+
require_relative 'availability/exclusion'
|
6
|
+
require_relative 'availability/abstract_availability'
|
7
|
+
require_relative 'availability/daily'
|
8
|
+
require_relative 'availability/weekly'
|
9
|
+
require_relative 'availability/monthly'
|
10
|
+
require_relative 'availability/yearly'
|
11
|
+
require_relative 'availability/once'
|
12
|
+
require_relative 'availability/class_methods'
|
13
|
+
require_relative 'availability/factory_methods'
|
14
|
+
|
15
|
+
# shamelessly adapted from the following article
|
16
|
+
# http://dmcca.be/2014/01/09/recurring-subscriptions-with-ruby-rspec-and-modular-arithmetic.html
|
17
|
+
module Availability
|
18
|
+
class AbstractAvailability
|
19
|
+
extend FactoryMethods
|
20
|
+
extend ClassMethods
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
module Availability
|
2
|
+
class AbstractAvailability
|
3
|
+
private_class_method :new # :nodoc:
|
4
|
+
|
5
|
+
attr_accessor :capacity, :duration, :frequency, :stops_by
|
6
|
+
attr_reader :exclusions, :interval, :residue, :start_time
|
7
|
+
|
8
|
+
#
|
9
|
+
# Required arguments:
|
10
|
+
# interval: an integer that is the interval of occurrences per frequency
|
11
|
+
# start_time: a Time, Date, or DateTime that indicates when the availability begins
|
12
|
+
# duration: an integer indicating how long the availability lasts in seconds
|
13
|
+
#
|
14
|
+
# Optional arguements:
|
15
|
+
# frequency: a symbol, one of [:once, :daily, :monthly, :yearly]; defaults to :daily
|
16
|
+
# stops_by: specific date by which the availability ends
|
17
|
+
#
|
18
|
+
def initialize(capacity: Float::INFINITY, exclusions: nil, frequency: :daily, stops_by: nil, duration: , interval: , start_time: )
|
19
|
+
raise ArgumentError, "start_time is required" if start_time.nil?
|
20
|
+
raise ArgumentError, "duration is required" if duration.nil?
|
21
|
+
raise ArgumentError, "interval is required" if interval.nil?
|
22
|
+
@capacity = capacity
|
23
|
+
@duration = duration
|
24
|
+
@frequency = frequency
|
25
|
+
@interval = interval
|
26
|
+
@start_time = start_time.to_time
|
27
|
+
@stops_by = stops_by
|
28
|
+
self.exclusions = exclusions
|
29
|
+
compute_residue
|
30
|
+
end
|
31
|
+
|
32
|
+
#
|
33
|
+
# The copy constructor that Ruby calls when cloning or duping an object.
|
34
|
+
#
|
35
|
+
def initialize_copy(orig)
|
36
|
+
super
|
37
|
+
@exclusions = orig.exclusions
|
38
|
+
compute_residue
|
39
|
+
end
|
40
|
+
|
41
|
+
def beginning
|
42
|
+
self.class.beginning
|
43
|
+
end
|
44
|
+
|
45
|
+
def corresponds_to?(availability)
|
46
|
+
return unless occurs_at?(availability.start_time) && occurs_at?(availability.start_time + availability.duration)
|
47
|
+
if !!stops_by
|
48
|
+
that_last = availability.last_occurrence
|
49
|
+
!that_last.nil? &&
|
50
|
+
occurs_at?(that_last) &&
|
51
|
+
occurs_at?(that_last + availability.duration) &&
|
52
|
+
that_last.to_date <= self.last_occurrence.to_date
|
53
|
+
else
|
54
|
+
true
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def end_time
|
59
|
+
start_time + duration
|
60
|
+
end
|
61
|
+
|
62
|
+
def exclusions=(exclusions)
|
63
|
+
@exclusions = Array(exclusions).flatten.compact + [
|
64
|
+
Exclusion.before_day(start_time),
|
65
|
+
Exclusion.before_time(start_time)
|
66
|
+
]
|
67
|
+
if stops_by
|
68
|
+
@exclusions += [
|
69
|
+
Exclusion.after_day(stops_by),
|
70
|
+
Exclusion.after_time(stops_by)
|
71
|
+
# TODO: should previous be: Exclusion.after_time(start_time + duration)
|
72
|
+
]
|
73
|
+
end
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
def interval
|
78
|
+
@interval
|
79
|
+
end
|
80
|
+
|
81
|
+
def interval=(interval)
|
82
|
+
@interval = interval
|
83
|
+
compute_residue
|
84
|
+
self
|
85
|
+
end
|
86
|
+
|
87
|
+
def interval_difference(first, second)
|
88
|
+
raise 'subclass responsibility'
|
89
|
+
end
|
90
|
+
|
91
|
+
def last_occurrence
|
92
|
+
return nil unless stops_by
|
93
|
+
unless @last_occurrence
|
94
|
+
next_date = move_by start_time, interval_difference(start_time, stops_by)
|
95
|
+
next_date = move_by next_date, -1 * interval while next_date >= stops_by && residue_for(next_date) != residue
|
96
|
+
@last_occurrence = next_occurrence next_date
|
97
|
+
end
|
98
|
+
@last_occurrence
|
99
|
+
end
|
100
|
+
|
101
|
+
def move_by(time, amount)
|
102
|
+
raise 'subclass responsibility'
|
103
|
+
end
|
104
|
+
|
105
|
+
def next_occurrence(from_date)
|
106
|
+
residue = @residue - residue_for(from_date)
|
107
|
+
date = move_by from_date, residue.modulo(interval)
|
108
|
+
time = Time.new(date.year, date.month, date.day, start_time.hour, start_time.min, start_time.sec)
|
109
|
+
if exclusions.any? {|rule| rule.violated_by? time}
|
110
|
+
if stops_by && time > stops_by
|
111
|
+
nil
|
112
|
+
else
|
113
|
+
next_occurrence(move_by time, 1)
|
114
|
+
end
|
115
|
+
else
|
116
|
+
time
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
#
|
121
|
+
# Returns an array of occurrences for n <= 1000, otherwise it returns a lazy enumerator
|
122
|
+
#
|
123
|
+
# n: Fixnum, how many occurrences to get
|
124
|
+
# from_date: a Date, Time, or DateTime from which to start calculating
|
125
|
+
#
|
126
|
+
def next_n_occurrences(n, from_date)
|
127
|
+
first_next_occurrence = next_occurrence(from_date)
|
128
|
+
blk = proc { |i| move_by first_next_occurrence, interval * i }
|
129
|
+
range = 0.upto(n - 1)
|
130
|
+
range = range.lazy if n > 1000
|
131
|
+
range.map &blk
|
132
|
+
end
|
133
|
+
|
134
|
+
def occurs_at?(time)
|
135
|
+
residue_for(time) == @residue && time_overlaps?(time, start_time, start_time + duration)
|
136
|
+
end
|
137
|
+
|
138
|
+
def residue_for(time)
|
139
|
+
raise 'subclass responsibility'
|
140
|
+
end
|
141
|
+
|
142
|
+
def start_time=(start_time)
|
143
|
+
@start_time = start_time
|
144
|
+
compute_residue
|
145
|
+
self
|
146
|
+
end
|
147
|
+
|
148
|
+
def time_overlaps?(time, start_time, end_time)
|
149
|
+
that_start = time.seconds_since_midnight.to_i
|
150
|
+
this_start = start_time.seconds_since_midnight.to_i
|
151
|
+
this_end = end_time.seconds_since_midnight.to_i
|
152
|
+
(this_start..this_end).include?(that_start)
|
153
|
+
end
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def compute_residue
|
158
|
+
@residue = residue_for(@start_time)
|
159
|
+
end
|
160
|
+
|
161
|
+
def hour_offset_from_midnight(time)
|
162
|
+
time.to_time.hour * 60 * 60
|
163
|
+
end
|
164
|
+
|
165
|
+
def minute_offset_from_midnight(time)
|
166
|
+
time.to_time.min * 60
|
167
|
+
end
|
168
|
+
|
169
|
+
def second_offset_from_midnight(time)
|
170
|
+
time.to_time.sec
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Availability
|
2
|
+
module ClassMethods
|
3
|
+
def availability?(thing)
|
4
|
+
AbstractAvailability === thing
|
5
|
+
end
|
6
|
+
|
7
|
+
def beginning
|
8
|
+
@@beginning ||= Date.new(1970, 1, 1)
|
9
|
+
end
|
10
|
+
|
11
|
+
def default_args
|
12
|
+
{}
|
13
|
+
end
|
14
|
+
|
15
|
+
def subclass_for(frequency)
|
16
|
+
Availability.const_get frequency.to_s.capitalize rescue nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
extend ClassMethods
|
20
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Availability
|
2
|
+
module Createable
|
3
|
+
def self.extended(base)
|
4
|
+
base.public_class_method :new
|
5
|
+
end
|
6
|
+
|
7
|
+
def create(**args)
|
8
|
+
frequency = name.split(':').last.downcase.to_sym
|
9
|
+
super **args.merge(frequency: frequency, event_class: self)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require_relative 'abstract_availability'
|
2
|
+
|
3
|
+
module Availability
|
4
|
+
class Daily < AbstractAvailability
|
5
|
+
extend Createable
|
6
|
+
|
7
|
+
def interval_difference(this, that)
|
8
|
+
first, second = [this.to_date, that.to_date].sort
|
9
|
+
(second - first).to_i
|
10
|
+
end
|
11
|
+
|
12
|
+
def move_by(time, amount)
|
13
|
+
time + amount.days
|
14
|
+
end
|
15
|
+
|
16
|
+
def residue_for(time)
|
17
|
+
interval_difference(time, beginning).modulo(@interval)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module Availability
|
2
|
+
class Exclusion
|
3
|
+
private_class_method :new # :nodoc:
|
4
|
+
|
5
|
+
def self.after_day(date)
|
6
|
+
raise ArgumentError, "invalid date" if date.nil?
|
7
|
+
new Rule::AfterDate.new(date.to_date)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.after_time(time)
|
11
|
+
raise ArgumentError, "invalid time" if time.nil?
|
12
|
+
new Rule::AfterTime.new(time.to_time)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.all_day(date)
|
16
|
+
raise ArgumentError, "invalid date" if date.nil?
|
17
|
+
new Rule::OnDate.new(date.to_date)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.before_day(date)
|
21
|
+
raise ArgumentError, "invalid date" if date.nil?
|
22
|
+
new Rule::BeforeDate.new(date.to_date)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.before_time(time)
|
26
|
+
raise ArgumentError, "invalid time" if time.nil?
|
27
|
+
new Rule::BeforeTime.new(time.to_time)
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize(rule)
|
31
|
+
@rule = rule
|
32
|
+
end
|
33
|
+
|
34
|
+
def violated_by?(time)
|
35
|
+
@rule.violated_by? time
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
module Rule
|
40
|
+
class AfterDate
|
41
|
+
def initialize(date)
|
42
|
+
@date = date
|
43
|
+
end
|
44
|
+
|
45
|
+
def violated_by?(time)
|
46
|
+
time.to_date > @date
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class AfterTime
|
51
|
+
def initialize(date_or_time)
|
52
|
+
@compare_to = date_or_time.to_time
|
53
|
+
end
|
54
|
+
|
55
|
+
def violated_by?(time)
|
56
|
+
time.to_time.seconds_since_midnight > @compare_to.to_time.seconds_since_midnight
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class BeforeDate
|
61
|
+
def initialize(date)
|
62
|
+
@date = date
|
63
|
+
end
|
64
|
+
|
65
|
+
def violated_by?(time)
|
66
|
+
time.to_date < @date
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class BeforeTime
|
71
|
+
def initialize(date_or_time)
|
72
|
+
@compare_to = date_or_time.to_time
|
73
|
+
end
|
74
|
+
|
75
|
+
def violated_by?(time)
|
76
|
+
time.to_time.seconds_since_midnight < @compare_to.to_time.seconds_since_midnight
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class OnDate
|
81
|
+
def initialize(date)
|
82
|
+
@date = date
|
83
|
+
end
|
84
|
+
|
85
|
+
def violated_by?(time)
|
86
|
+
time.to_date == @date
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Availability
|
2
|
+
module FactoryMethods
|
3
|
+
def create(**args)
|
4
|
+
cls = args.delete(:event_class) || Availability::subclass_for(args[:frequency] ||= :daily)
|
5
|
+
raise ArgumentError, "undefined frequency" if cls.nil?
|
6
|
+
cls.send :new, **args
|
7
|
+
end
|
8
|
+
|
9
|
+
def once(**args)
|
10
|
+
Once.create **args
|
11
|
+
end
|
12
|
+
|
13
|
+
%w{day week month year}.each do |suffix|
|
14
|
+
frequency = suffix == 'day' ? :daily : :"#{suffix}ly"
|
15
|
+
cls = Availability::subclass_for(frequency)
|
16
|
+
|
17
|
+
define_method frequency do |**args|
|
18
|
+
args[:interval] ||= 1 unless args[:interval]
|
19
|
+
cls.create **args
|
20
|
+
end
|
21
|
+
|
22
|
+
{
|
23
|
+
:"every_#{suffix}" => 1,
|
24
|
+
:"every_other_#{suffix}" => 2,
|
25
|
+
:"every_two_#{suffix}s" => 2,
|
26
|
+
:"every_three_#{suffix}s" => 3,
|
27
|
+
:"every_four_#{suffix}s" => 4,
|
28
|
+
:"every_five_#{suffix}s" => 5
|
29
|
+
}.each do |method_name, interval|
|
30
|
+
define_method method_name do |**args|
|
31
|
+
cls.send(frequency, **args, interval: interval)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
extend FactoryMethods
|
37
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative 'abstract_availability'
|
2
|
+
|
3
|
+
module Availability
|
4
|
+
class Monthly < AbstractAvailability
|
5
|
+
extend Createable
|
6
|
+
|
7
|
+
def interval_difference(first, second)
|
8
|
+
first_date, second_date = [first.to_date, second.to_date].sort
|
9
|
+
(second_date.year - first_date.year) * 12 + (second_date.month - first_date.month)
|
10
|
+
end
|
11
|
+
|
12
|
+
def move_by(time, amount)
|
13
|
+
time + amount.months
|
14
|
+
end
|
15
|
+
|
16
|
+
def residue_for(time)
|
17
|
+
# date = time.to_date
|
18
|
+
# ((date.year - beginning.year) * 12 + (date.month - beginning.month)).modulo(@interval)
|
19
|
+
interval_difference(beginning, time).modulo(@interval)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require_relative 'abstract_availability'
|
2
|
+
|
3
|
+
module Availability
|
4
|
+
class Once < AbstractAvailability
|
5
|
+
extend Createable
|
6
|
+
|
7
|
+
def initialize(**args)
|
8
|
+
raise ArgumentError, "start_time is required" unless args.has_key?(:start_time)
|
9
|
+
raise ArgumentError, "duration is required" unless args.has_key?(:duration)
|
10
|
+
super **args, frequency: :once, interval: 0, stops_by: args[:start_time] + args[:duration]
|
11
|
+
end
|
12
|
+
|
13
|
+
def interval_difference(this, that)
|
14
|
+
raise NotImplementedError.new('not supported')
|
15
|
+
end
|
16
|
+
|
17
|
+
def move_by(time, amount)
|
18
|
+
time + amount.days
|
19
|
+
end
|
20
|
+
|
21
|
+
def last_occurrence
|
22
|
+
start_time
|
23
|
+
end
|
24
|
+
|
25
|
+
def residue_for(time)
|
26
|
+
0
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require_relative 'abstract_availability'
|
2
|
+
|
3
|
+
module Availability
|
4
|
+
class Weekly < AbstractAvailability
|
5
|
+
extend Createable
|
6
|
+
|
7
|
+
def interval
|
8
|
+
@interval * 7
|
9
|
+
end
|
10
|
+
|
11
|
+
def interval_difference(first, second)
|
12
|
+
first_date, second_date = [first.to_date, second.to_date].sort
|
13
|
+
(second_date - first_date).to_i #/ interval
|
14
|
+
end
|
15
|
+
|
16
|
+
def move_by(time, amount)
|
17
|
+
time + amount.days
|
18
|
+
end
|
19
|
+
|
20
|
+
def residue_for(time)
|
21
|
+
(time.to_date - beginning.to_date).to_i.modulo(interval)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative 'abstract_availability'
|
2
|
+
|
3
|
+
module Availability
|
4
|
+
class Yearly < AbstractAvailability
|
5
|
+
extend Createable
|
6
|
+
|
7
|
+
def interval_difference(first, second)
|
8
|
+
first_date, second_date = [first.to_date, second.to_date].sort
|
9
|
+
second_date.year - first_date.year
|
10
|
+
end
|
11
|
+
|
12
|
+
def move_by(time, amount)
|
13
|
+
time + amount.years
|
14
|
+
end
|
15
|
+
|
16
|
+
def residue_for(time)
|
17
|
+
# date = time.to_date
|
18
|
+
# (date.year - beginning.year).modulo(@interval)
|
19
|
+
interval_difference(beginning, time).modulo(@interval)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: availability
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jason Rogers
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-05-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.10'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.10'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
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-its
|
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
|
+
description: Use modular arithmetic and residue classes to calculate schedule availability
|
84
|
+
for dates (times handled separately).
|
85
|
+
email:
|
86
|
+
- jacaetevha@gmail.com
|
87
|
+
executables: []
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- ".gitignore"
|
92
|
+
- ".rspec"
|
93
|
+
- ".ruby-gemset"
|
94
|
+
- ".ruby-version"
|
95
|
+
- ".travis.yml"
|
96
|
+
- CREDITS
|
97
|
+
- Gemfile
|
98
|
+
- README.md
|
99
|
+
- Rakefile
|
100
|
+
- UNLICENSE
|
101
|
+
- WAIVER
|
102
|
+
- availability.gemspec
|
103
|
+
- bin/console
|
104
|
+
- bin/setup
|
105
|
+
- examples/scheduler.rb
|
106
|
+
- examples/scheduler_spec.rb
|
107
|
+
- lib/availability.rb
|
108
|
+
- lib/availability/abstract_availability.rb
|
109
|
+
- lib/availability/class_methods.rb
|
110
|
+
- lib/availability/createable.rb
|
111
|
+
- lib/availability/daily.rb
|
112
|
+
- lib/availability/exclusion.rb
|
113
|
+
- lib/availability/factory_methods.rb
|
114
|
+
- lib/availability/monthly.rb
|
115
|
+
- lib/availability/once.rb
|
116
|
+
- lib/availability/version.rb
|
117
|
+
- lib/availability/weekly.rb
|
118
|
+
- lib/availability/yearly.rb
|
119
|
+
homepage: https://github.com/upper-hand/availability
|
120
|
+
licenses:
|
121
|
+
- Unlicense
|
122
|
+
metadata: {}
|
123
|
+
post_install_message:
|
124
|
+
rdoc_options: []
|
125
|
+
require_paths:
|
126
|
+
- lib
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
requirements: []
|
138
|
+
rubyforge_project:
|
139
|
+
rubygems_version: 2.5.1
|
140
|
+
signing_key:
|
141
|
+
specification_version: 4
|
142
|
+
summary: Calculating schedule availability
|
143
|
+
test_files: []
|