Skip to content

Instantly share code, notes, and snippets.

@webgago
Last active October 1, 2020 15:50
Show Gist options
  • Save webgago/7219c107350a6995114be2c81da7301f to your computer and use it in GitHub Desktop.
Save webgago/7219c107350a6995114be2c81da7301f to your computer and use it in GitHub Desktop.
#
# 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>]
# 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
# 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
# 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
# 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