Last active
October 1, 2020 15:50
-
-
Save webgago/7219c107350a6995114be2c81da7301f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# | |
# inspired by https://www.youtube.com/watch?v=3Q_oYDQ2whs | |
# | |
require './military_time' | |
require './time_slot' | |
require './schedule' | |
my_schedule = Schedule.new([%w(9:00 10:30), %w(12:00 13:00), %w(16:00 18:00)], bounds: %w(9:00 20:00)) | |
your_schedule = Schedule.new([%w(10:00 11:30), %w(12:30 14:30), %w(14:30 15:00), %w(16:00 17:00)], bounds: %w(10:00 18:30)) | |
my_schedule.merge(your_schedule).available_slots_for(30) | |
# => [<TimeSlot 30 minutes from 11:30 till 12:00>, | |
# <TimeSlot 60 minutes from 15:00 till 16:00>, | |
# <TimeSlot 30 minutes from 18:00 till 18:30>] | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# MilitaryTime is an abstraction of military time format. For instance, 20:00, 12:00, 09:00, etc | |
# | |
# Examples: | |
# time = MilitaryTime.from_minutes(1200) # 20:00 | |
# time = MilitaryTime.new(20, 0) # 20:00 | |
# time = MilitaryTime.new(20) # 20:00 | |
# time = MilitaryTime.parse('20:00') # 20:00 | |
# time = MilitaryTime.parse(Time.now) # 20:00 | |
# time = MilitaryTime.parse(nil) # ArgumentError | |
# time.minutes # => 0 | |
# time.hours # => 20 | |
# time.to_i # => 1200 (minutes) | |
# time.to_minutes # => 1200 (minutes) | |
# time.to_s # => "20:00" | |
# time.format # => "8:00pm" | |
# time.pm? # => true | |
# time.am? # => false | |
# | |
# You can also do standard functions like compare two times. | |
# | |
# t1 = MilitaryTime.new('10:00') | |
# t2 = MilitaryTime.new('20:00') | |
# t1 > t2 # => false | |
# t1 < t2 # => true | |
# t1 == t2 # => false | |
# t1 <=> t2 # => -1 | |
# t2 <=> t1 # => 1 | |
# t1 <=> t1 # => 0 | |
# | |
# You can also add or subtract. | |
# | |
# t1 = MilitaryTime.new('12:30') | |
# t2 = MilitaryTime.new('10:20') | |
# t1 + t2 # => 22:50 | |
# t1 - t2 # => 02:10 | |
# t2 - t1 # => 21:50 | |
# | |
# Convert to standard format: | |
# | |
# MilitaryTime.new('12:30').format # => 12:30pm | |
# MilitaryTime.new('00:00').format # => 12:00am | |
# MilitaryTime.new('16:00').format # => 4:00pm | |
# | |
# Convert to Time: | |
# | |
# MilitaryTime.new('16:00').to_time # => 2020-09-30 16:00:00 +0300 | |
# MilitaryTime.new('16:00').on(Date.parse('2020-10-10')) # => 2020-10-10 16:00:00 +0300 | |
# | |
# includes +Comparable+ module | |
# | |
class MilitaryTime | |
MINUTES_IN_DAY = 1440 | |
MINUTES_IN_HOUR = 60 | |
LAST_HOUR = 24 | |
MID_HOUR = 12 | |
include Comparable | |
# Returns a new instance of MilitaryTime | |
# | |
# parse('10:00') # 10:00 | |
# parse(Time.now) # 10:00 | |
# parse(600) # 10:00 | |
# parse(nil) # ArgumentError | |
# | |
# @param [Time, MilitaryTime, String, Integer] time | |
# @return [MilitaryTime] | |
def self.parse(time) | |
return new(time.hour, time.min) if time.is_a?(Time) || time.is_a?(self) | |
return new(*time.split(':', 2).map(&:to_i)) if time.is_a?(String) | |
return at(time) if time.is_a?(Integer) | |
raise ArgumentError.new("#{ time.inspect } not a valid input") | |
end | |
# Returns a new MilitaryTime object | |
# | |
# MilitaryTime.from_minutes(60) # => 01:00 | |
# MilitaryTime.from_minutes(1000) # => 16:40 | |
# | |
# @param [Integer] minutes | |
# @return [MilitaryTime] | |
def self.from_minutes(minutes) | |
raise ArgumentError.new('minutes must be zero or a positive integer') unless minutes.is_a?(Integer) | |
raise ArgumentError.new('minutes must be zero or a positive integer') if minutes.negative? | |
hours = minutes / MINUTES_IN_HOUR | |
minutes = minutes % MINUTES_IN_HOUR | |
new(hours, minutes) | |
end | |
def self.at(minutes) # :nodoc: | |
from_minutes(minutes) | |
end | |
def self.now # :nodoc: | |
parse Time.now | |
end | |
attr_reader :hour, :min | |
# @param [Integer] hours | |
# @param [Integer] minutes | |
def initialize(hours, minutes = 0) | |
@min = normalize_min(minutes) | |
@hour = normalize_hour(hours, minutes) | |
@duration = normalize_duration(hour, min) | |
end | |
# Returns a number of minutes since 00:00 | |
# | |
# t = MilitaryTime.now #=> 08:23 | |
# t.to_i # => 503, i.e. 8 * 60 + 23 | |
# t.to_minutes # => 503 | |
# | |
# @return [Integer] | |
def to_i | |
@duration | |
end | |
alias to_minutes to_i | |
# Returns a string representation, i.e. "10:00" | |
# | |
# t = MilitaryTime.now #=> 08:23 | |
# t.to_s # => "08:23" | |
# | |
# @return [String] | |
def to_s | |
'%02d:%02d' % [hour % LAST_HOUR, min] | |
end | |
alias inspect to_s | |
# time + other_time -> time | |
# time + numeric -> time | |
# | |
# Addition --- Adds some number of minutes to | |
# +time+ and returns that value as a new MilitaryTime object. | |
# | |
# t = MilitaryTime.now #=> 08:23 | |
# t2 = t - 60 #=> 07:23 | |
# t + t2 #=> 15:46 | |
# t2 - 30 #=> 06:53 | |
# | |
# @param [MilitaryTime, Integer] other | |
# @return [MilitaryTime] | |
def +(other) | |
minutes = (to_i + other.to_i) % MINUTES_IN_DAY | |
self.class.from_minutes(minutes) | |
end | |
# time - other_time -> time | |
# time - numeric -> time | |
# | |
# Difference --- Subtracts the given number of minutes from +time+ | |
# and returns that value as a new MilitaryTime object. | |
# | |
# t = MilitaryTime.now #=> 08:23 | |
# t2 = t + 60 #=> 09:23 | |
# t2 - t1 #=> 01:00 | |
# t2 - t2 #=> 00:00 | |
# t2 - 30 #=> 08:53 | |
# | |
# @param [MilitaryTime, Integer] other | |
# @return [MilitaryTime] | |
def -(other) | |
minutes = (to_i - other.to_i) % MINUTES_IN_DAY | |
self.class.from_minutes(minutes) | |
end | |
# time <=> other_time -> -1, 0, +1, or nil | |
# | |
# Compares +time_slot+ with +other_time_slot+. | |
# | |
# -1, 0, +1 or nil depending on whether +time+ is less than, equal to, or | |
# greater than +other_time+. | |
# | |
# +nil+ is returned if the two values are incomparable. | |
# | |
# t1 = MilitaryTime.parse('10:00') | |
# t2 = MilitaryTime.parse('11:00') | |
# t1 <=> t2 # => -1 | |
# t2 <=> t1 # => 1 | |
# t2 <=> t2 # => 0 | |
# | |
# @param [MilitaryTime] time | |
# @return [Integer] | |
def <=>(time) | |
to_i <=> time.to_i | |
rescue NoMethodError | |
nil | |
end | |
# time.format # => '1:00pm' | |
# | |
# Returns a standard time format | |
# | |
# t1 = MilitaryTime.parse('10:00') | |
# t1.format # => "10:00am" | |
# | |
# @return [String] | |
def format | |
suffix = am? ? 'am' : 'pm' | |
reminder = hour % MID_HOUR | |
if reminder.zero? | |
'12:%02d%s' % [min, suffix] | |
else | |
'%d:%02d%s' % [reminder, min, suffix] | |
end | |
end | |
# time.pm? # => true or false | |
# | |
# Returns true or false depending whether it is +PM+ time | |
# | |
# t1 = MilitaryTime.parse('10:00') | |
# t1.pm? # => false | |
# | |
# t2 = MilitaryTime.parse('13:00') | |
# t2.pm? # => true | |
# | |
# @return [Boolean] | |
def pm? | |
hour >= MID_HOUR && hour < LAST_HOUR | |
end | |
# time.am? # => true or false | |
# | |
# Returns true or false depending whether it is +AM+ time | |
# | |
# t1 = MilitaryTime.parse('10:00') | |
# t1.am? # => true | |
# | |
# t2 = MilitaryTime.parse('13:00') | |
# t2.am? # => false | |
# | |
# @return [Boolean] | |
def am? | |
hour == LAST_HOUR || hour < MID_HOUR | |
end | |
# time.to_time # => 2020-01-01 22:22:00.3261004 +0000 | |
# | |
# Returns true or false depending whether it is +AM+ time | |
# | |
# t1 = MilitaryTime.parse('10:00') | |
# t1.to_time # => 2020-01-01 10:00:00 +0000 | |
# | |
# t2 = MilitaryTime.parse('13:00') | |
# t2.to_time(Date.new(2020, 10, 10)) # => 2020-10-10 13:00:00 +0000 | |
# t2.on(Date.new(2020, 10, 10)) # => 2020-10-10 13:00:00 +0000 | |
# | |
# @return [Boolean] | |
def to_time(date = nil) | |
date ||= Date.respond_to?(:current) ? Date.current : Date.today | |
Time.new date.year, date.month, date.day, hour, min, 0, timezone | |
end | |
alias on to_time | |
# Returns true if two times are equal. The equality of each couple | |
# of elements is defined according to Object#eql?. | |
# | |
# MilitaryTime.new(10, 30) == MilitaryTime.new(10, 30) #=> true | |
# MilitaryTime.new(10, 00) == MilitaryTime.new(10, 30) #=> false | |
# MilitaryTime.new(0) == 0 #=> true | |
# MilitaryTime.new(0).eql? 0 #=> false | |
# | |
def eql?(other) | |
self.class === other && to_i == other.to_i | |
end | |
def hash # :nodoc: | |
to_i.hash ^ min.hash ^ hour.hash | |
end | |
private | |
def timezone | |
now = Time.respond_to?(:current) ? Time.current : Time.now | |
now.strftime('%:z') | |
end | |
def normalize_hour(hours, minutes) | |
hours % LAST_HOUR + (minutes / MINUTES_IN_HOUR) | |
end | |
def normalize_min(minutes) | |
minutes % MINUTES_IN_HOUR | |
end | |
def normalize_duration(hour, min) | |
(hour * MINUTES_IN_HOUR + min) % MINUTES_IN_DAY | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Schedule is an abstraction of a set of TimeSlot | |
# | |
# Examples: | |
# slot1 = TimeSlot.parse('10:00', '11:00') # => <TimeSlot 60 minutes from 10:00 till 11:00> | |
# slot2 = TimeSlot.parse('11:00', '12:30') # => <TimeSlot 90 minutes from 11:00 till 12:30> | |
# | |
# schedule = Schedule.new([slot1, slot2]) | |
# schedule.min == slot1 # true | |
# schedule.max == slot2 # true | |
# | |
# includes +Enumerable+ module | |
# | |
class Schedule | |
include Enumerable | |
def initialize(timeslots = [], bounds: []) | |
@slots = SortedSet.new | |
timeslots.each { |from, to| add TimeSlot.parse(from, to) } | |
add_bounds(bounds) | |
end | |
# Returns a slot by index | |
# | |
# schedule[0] # => slot1 | |
# | |
def [](index) | |
to_a[index] | |
end | |
# Returns an array | |
# | |
# schedule.to_a # => [slot1, slot2] | |
# | |
def to_a | |
@slots.to_a | |
end | |
# Iterate the schedule | |
# | |
# schedule.each { |slot| ... } # => [slot1, slot2] | |
# | |
def each(&block) | |
@slots.each(&block) | |
end | |
# Add a slot to the schedule | |
# Returns the schedule | |
# | |
# schedule.add slot3 # => Schedule[slot1, slot2, slot3] | |
# | |
def add(slot) | |
@slots.add slot | |
self | |
end | |
def merge(other) | |
merged_slots = | |
(@slots + other.to_a).each.with_object([]) do |slot, result| | |
if result.last&.intersect?(slot) | |
result[-1] = result.last + slot | |
else | |
result.push slot | |
end | |
end | |
merged_slots.inject(self.class.new) do |schedule, slot| | |
schedule.add(slot) | |
end | |
end | |
def available_slots_for(time) | |
available_time_generator(time).to_a | |
end | |
def inspect | |
"#<Schedule: {#{to_a.map { |slot| [slot.from, slot.to].inspect }.join(',')}}" | |
end | |
private | |
def available_time_generator(time) | |
Enumerator.new do |out| | |
slots_generator = @slots.each | |
prev = slots_generator.next | |
loop do | |
curr = slots_generator.next | |
slot = TimeSlot.new(prev.to, curr.from) | |
out << slot if slot.duration >= time | |
prev = curr | |
end | |
end | |
end | |
def add_bounds(bounds) | |
add TimeSlot.parse('00:00', bounds[0]) if bounds[0] | |
add TimeSlot.parse(bounds[1], '23:59') if bounds[1] | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ScheduleFactory is a factory that generates time periods | |
# | |
# Examples: | |
# from = MilitaryTime.new(10) # => 10:00 | |
# | |
# factory = ScheduleFactory.new | |
# factory.create(TimeSlot, 10, from: from, step: 5) | |
# # => [<TimeSlot 10 minutes from 10:00 till 10:10>, | |
# <TimeSlot 10 minutes from 10:10 till 10:20>, | |
# <TimeSlot 10 minutes from 10:20 till 10:30>] | |
# | |
# factory.generate(2, step: 10, from: from) | |
# # => [10:00, 10:10] | |
# # => [10:10, 10:20] | |
# | |
# time = MilitaryTime.new(12, 00) # => 12:00 | |
# factory.generate_until(time, step: 60, from: from) | |
# # => [10:00, 11:00] | |
# # => [11:00, 12:00] | |
# | |
class ScheduleFactory | |
# @param [Integer] spacer: | |
# @param [Boolean] include_spaces | |
def initialize(spacer: 0, include_spaces: false) | |
@spacer = spacer.to_enum if spacer.is_a?(Array) | |
@spacer = [spacer].cycle if spacer.is_a?(Integer) | |
@spacer = spacer if spacer.is_a?(Enumerator) | |
@include_spaces = include_spaces | |
end | |
# factory.create(klass, count: 5, from: time, step: minutes) | |
# | |
# Generate +count+ slots with +step+ from +from+ time and | |
# returns that values as a new Array. | |
# | |
# f = ScheduleFactory.new | |
# f.create(TimeSlot, 10, from: from, step: 5) # => [<TimeSlot 10 minutes from 10:00 till 10:10>, | |
# <TimeSlot 10 minutes from 10:10 till 10:20>, | |
# <TimeSlot 10 minutes from 10:20 till 10:30>] | |
# | |
# f = ScheduleFactory.new(spacer: 5) | |
# f.create(TimeSlot, 5, from: from, step: 5) # => [<TimeSlot 5 minutes from 10:00 till 10:05>, | |
# <TimeSlot 5 minutes from 10:10 till 10:15>, | |
# <TimeSlot 5 minutes from 10:20 till 10:25>] | |
# | |
# from = MilitaryTime.new(10) | |
# f = ScheduleFactory.new(spacer: 5) | |
# f.create(FooBarBa, 3, from: from, step: 5) # => [<FooBarBa 5 minutes from 10:00 till 10:05>, | |
# <FooBarBa 5 minutes from 10:10 till 10:15>, | |
# <FooBarBa 5 minutes from 10:20 till 10:25>] | |
# | |
# @param [Integer] minutes | |
# @return [Array<TimeSlot>] | |
def create(klass, count, step:, from: 0) | |
generator(step, from).take(count).map { |from, to| klass.new(from, to) } | |
end | |
# Generate +count+ periods [from, to] | |
# | |
# time = MilitaryTime.new(8,30) | |
# factory.generate(5, step: 10, from: time).map { |from, to| [from, to] } | |
# # => [08:30, 08:40] | |
# # => [08:40, 08:50] | |
# # => [08:50, 09:00] | |
# # => [09:00, 09:10] | |
# # => [09:10, 09:20] | |
# | |
# @param [Integer] count | |
# @param [Integer] step | |
# @param [Object] from: | |
def generate(count = 1, step: 1, from: 0) | |
generator(step, from).take(count) | |
end | |
# Generate periods while the end of a period is less than +to+ | |
# | |
# time = MilitaryTime.parse('12:00') | |
# from = MilitaryTime.parse('10:00') | |
# factory.generate_until(time, step: 60, from: from) | |
# # => [10:00, 11:00] | |
# # => [11:00, 12:00] | |
# | |
# @param [Integer] count | |
# @param [Integer] step | |
# @param [Object] from: | |
def generate_until(time, step: 1, from: 0) | |
generator(step, from).take_while { |from, to| to <= time } | |
end | |
private | |
def generator(step, from) | |
spacer.rewind | |
Enumerator.new do |out| | |
to = from + step | |
out << [from, to] | |
loop do | |
space = spacer.next | |
out << [to, to + space, :space] if include_spaces | |
out << [to + space, to + space + step] | |
to = to + space + step | |
end | |
end | |
end | |
attr_reader :spacer, :include_spaces | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# TimeSlot is an abstraction of time period, i.e. from '10:00' to '11:30' | |
# | |
# Examples: | |
# t1 = MilitaryTime.new(10) # => 10:00 | |
# t2 = MilitaryTime.new(11, 30) # => 11:30 | |
# slot1 = TimeSlot.new(t1, t2) # => <TimeSlot 90 minutes from 10:00 till 11:30> | |
# slot2 = TimeSlot.parse('11:00', '12:30') # => <TimeSlot 90 minutes from 11:00 till 12:30> | |
# | |
# slot1.duration # => 90 | |
# slot2.intersect? slot1 # => true | |
# slot1 + slot2 # => <TimeSlot 150 minutes from 10:00 till 12:30> | |
# slot1 + 30 # => <TimeSlot 90 minutes from 10:30 till 12:00> | |
# slot1 + 30 + slot2 # => <TimeSlot 120 minutes from 10:30 till 12:30> | |
# | |
# includes +Comparable+ module | |
# | |
class TimeSlot | |
include Comparable | |
attr_reader :from, :to | |
PARSER = MilitaryTime | |
# Returns a new instance of TimeSlot | |
# | |
# It uses +MilitaryTime+ to parse from/to values by default, see PARSER constant | |
# | |
# TimeSlot.parse('10:00', '11:00') # => <TimeSlot 60 minutes from 10:00 till 11:00> | |
# TimeSlot.parse('10:00', 660) # => <TimeSlot 60 minutes from 10:00 till 11:00> | |
# | |
# @param [Class] parser: | |
# @return [TimeSlot] | |
def self.parse(from, to, parser: PARSER) | |
if to.nil? && from.is_a?(TimeSlot) | |
new from.from, from.to | |
else | |
new parser.parse(from), parser.parse(to) | |
end | |
end | |
# @param [MilitaryTime] from | |
# @param [MilitaryTime] to | |
def initialize(from, to) | |
raise ArgumentError.new('from must be greater or equal to to') if to < from | |
@from = from | |
@to = to | |
end | |
def inspect | |
"<#{self.class.name} #{duration} minutes from #{from} till #{to}>" | |
end | |
# Returns the number of minutes as an integer | |
# @return [Integer] | |
def duration | |
to.to_i - from.to_i | |
end | |
# time_slot <=> other_time_slot -> -1, 0, +1, or nil | |
# | |
# Compares +time_slot+ with +other_time_slot+. | |
# | |
# -1, 0, +1 or nil depending on whether +time_slot+ is less than, equal to, or | |
# greater than +other_time_slot+. | |
# | |
# +nil+ is returned if the two values are incomparable. | |
# | |
# t1 = TimeSlot.parse('10:00', '10:30') | |
# t2 = TimeSlot.parse('11:00', '11:30') | |
# t1 <=> t2 # => -1 | |
# t2 <=> t1 # => 1 | |
# t2 <=> t2 # => 0 | |
# | |
def <=>(other) | |
if to == other.to | |
from <=> other.from | |
else | |
to <=> other.to | |
end | |
rescue NoMethodError | |
nil | |
end | |
# time_slot.intersect? other_time_slot -> true or false | |
# time_slot.crosses? other_time_slot -> true or false | |
# | |
# Returns true if the slot and the given slot have something in common. | |
# | |
# t1 = TimeSlot.parse('10:00', '11:20') | |
# t2 = TimeSlot.parse('11:00', '11:30') | |
# t1.intersect? t2 # => true | |
# t2.intersect? t1 # => true | |
# | |
# t3 = TimeSlot.parse('12:00', '12:30') | |
# t1.intersect? t3 # => false | |
# | |
# @param [TimeSlot] other | |
# @return [Boolean] | |
def intersect?(other) | |
[from, to].any? { |time| time.between?(other.from, other.to) } | |
end | |
alias crosses? intersect? | |
# slot + other_slot -> slot | |
# slot + numeric -> slot | |
# | |
# Addition --- Adds some number of minutes to | |
# +from+ and to +to+ returns that value as a new TimeSlot object. | |
# or joins two TimeSlot together | |
# | |
# t1 = TimeSlot.parse('10:00', '10:30') | |
# t2 = TimeSlot.parse('11:00', '11:30') | |
# t1 + t2 # => <TimeSlot 90 minutes from 10:00 till 11:30> | |
# t1 + 30 # => <TimeSlot 30 minutes from 10:30 till 11:00> | |
# | |
# @param [TimeSlot, Integer] other | |
# @return [TimeSlot] | |
def +(other) | |
if other.is_a?(TimeSlot) | |
times = [self, other] | |
self.class.new(times.min.from, times.max.to) | |
else | |
self.class.new(from + other, to + other) | |
end | |
end | |
def hash # :nodoc: | |
from.hash ^ to.hash | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment