availability 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/availability.svg)](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: []
|