Flatten overlapping sequences into a contiguous set - ruby-on-rails

For a Ruby on Rails planning application I am running into an algorithm / combination problem that I have trouble solving efficiently.
In my application I have 2 types of records:
Availabilities (when is someone freely available, on stand-by or explicitly unavailable (sick, vacation))
Plan records (when is someone actually scheduled in).
Both types of records are defined by a start and end time, and availabilities have an additional type (available, stand-by, unavailable).
Now I would like to get a flat list of non-overlapping periods that show me when someone has plan records first, but additionally has availabilities
To give an example:
Time: 0-----------6-----------12-----------18-----------24
Avail: |-----available-----||--standby--|
Plans: |------------------|
Result: |------||------------------||----|
The desired result is 3 non-overlapping periods:
3-6: Available
6-15: Planned
15-18: Standby
Another example, where an Availability needs to be split:
Time: 0-----------6-----------12-----------18-----------24
Avail: |-----available-----||--standby--|
Plans: |-----|
Result: |------||-----||----||-----------|
The desired result is 3 non-overlapping periods:
3-6: Available
6-9: Planned
9-12: Available
12-18: Standby
I already have all (overlapping) periods in an array. What is the best way to achieve what I want efficiently?

I assume that we are given hour ranges for 'plan', 'available' and 'coverage', where 'coverage' is the 'available` range preceded and followed by 'stand-by' ranges. Moreover, 'coverage' contains 'plan' and either or both of its 'stand-by' ranges may be of zero duration.
Code
def categories(avail, plan)
adj_avail = adj_avail(avail)
adj_plan = adj_plan(avail, plan)
arr = []
finish = [adj_avail[:available][:start], adj_plan[:start]].min
add_block(arr, :standby, adj_avail[:coverage][:start], finish)
start = finish
finish = [adj_avail[:available][:finish], adj_plan[:start]].min
add_block(arr, :available, start, finish)
start = finish
add_block(arr, :standby, finish, adj_plan[:start])
arr << [:plan, adj_plan]
finish = [adj_plan[:finish], adj_avail[:available][:finish]].max
add_block(arr, :available, adj_plan[:finish], finish)
add_block(arr, :standby, finish, adj_avail[:coverage][:finish])
restore_times(arr)
end
def adj_avail(avail)
avail.each_with_object({}) do |(k,g),h|
start, finish = g[:start], g[:finish]
h[k] = case k
when :coverage
{ start: start, finish: finish + (finish < start ? 24 : 0) }
else # when :available
{ start: start + (start < h[:coverage][:start] ? 24 : 0),
finish: finish + (finish < start ? 24 : 0) }
end
end
end
def adj_plan(avail, plan)
{ start: plan[:start] + (plan[:start] < avail[:coverage][:start] ? 24 : 0),
finish: plan[:finish] + (plan[:finish] < plan[:start] ? 24 : 0) }
end
def add_block(arr, value, curr_epoch, nxt_epoch)
arr << [value, { start: curr_epoch, finish: nxt_epoch }] if nxt_epoch > curr_epoch
end
def restore_times(arr)
arr.map! do |k,g|
start, finish = g.values_at(:start, :finish)
start -= 24 if start > 24
finish -= 24 if finish > 24
[k, { start: start, finish: finish }]
end
end
Examples
avail = { coverage: { start: 3, finish: 18 },
available: { start: 3, finish: 12 } }
plan = { start: 6, finish: 15 }
categories(avail, plan)
#=> [[:available, {:start=>3, :finish=>6} ],
# [:plan, {:start=>6, :finish=>15} ],
# [:standby, {:start=>15, :finish=>18}]]
avail = { coverage: { start: 22, finish: 11 },
available: { start: 23, finish: 10 } }
plan = { start: 24, finish: 9 }
categories(avail, plan)
#=> [[:standby, {:start=>22, :finish=>23}],
# [:available, {:start=>23, :finish=>24}],
# [:plan, {:start=>24, :finish=>9 }],
# [:available, {:start=>9, :finish=>10}],
# [:standby, {:start=>10, :finish=>11}]]
avail = { coverage: { start: 1, finish: 13 },
available: { start: 2, finish: 3 } }
plan = { start: 4, finish: 12 }
categories(avail, plan)
#=> [[:standby, {:start=>1, :finish=>2 }],
# [:available, {:start=>2, :finish=>3 }],
# [:standby, {:start=>3, :finish=>4 }],
# [:plan, {:start=>4, :finish=>12}],
# [:standby, {:start=>12, :finish=>13}]]
Explanation
The main complication here is when the finish hour for 'coverage' is less than the start hour, meaning that the 'coverage' range contains midnight. When this occurs, the 'available' and 'plan' ranges may also contain midnight. I've dealt with this by adding 24 hours to hours after midnight before computing the ranges and then subtracting 24 hours from all hour values that exceed 24 after computing the ranges.
Consider the second example above.
avail = { coverage: { start: 22, finish: 11 },
available: { start: 23, finish: 10 } }
adj_avail(avail)
#=> {:coverage=> {:start=>22, :finish=>35},
# :available=>{:start=>23, :finish=>34}}
plan = { start: 24, finish: 9 }
adj_plan(avail, plan)
#=> {:start=>24, :finish=>33}
If I execute categories for these values of avail and plan, with the last line commented out, I obtain
a = categories(avail, plan)
#=> [[:standby, {:start=>22, :finish=>23}],
# [:available, {:start=>23, :finish=>24}],
# [:plan, {:start=>24, :finish=>33}],
# [:available, {:start=>33, :finish=>34}],
# [:standby, {:start=>34, :finish=>35}]]
and
restore_times(a)
#=> the return value shown above

Related

Ruby On Rails sort the array of hash based on priority

Please help me finding the solution sort by based on priority
Im not getting the solution
priority based on:
1st pick earliest slot
2nd max rating
3rd cronofy_enabled
interview_proposal = [
{ interviewer_id: 3903, rating: 4, cronofy_enabled: false, slot_date: "2022-05-09" },
{ interviewer_id: 10, rating: 3.5, cronofy_enabled: false, slot_date: "2022-05-06" },
{ interviewer_id: 3902, rating: 2, cronofy_enabled: true, slot_date: "2022-05-06" },
{ interviewer_id: 3904, rating: 2.5, cronofy_enabled: false, slot_date: "2022-05-09" },
{ interviewer_id: 3905, rating: 3.5, cronofy_enabled: false, slot_date: "2022-05-09" }
]
# First priority picked earliest_slot
#earliest_slot = interview_proposal.find{|proposal| proposal[:slot_date] == "2022-05-06"}
#second priority rating
#max_rating = interview_proposal.max_by{|proposal| proposal[:rating]}
# Third priority cronofy_enabled
#calendar_synced = interview_proposal.find{|proposal| proposal[:cronofy_enabled]}
sorted_array = []
interview_proposal.each do |interview_proposal|
priority = match_count
sorted_array << { priority: priority, interview_proposal: interview_proposal }
end
sorted_array = sorted_array.
sort_by { |obj| obj[:priority] }.
reverse.map { |obj| obj[:interview_proposal] }
sorted_array
def match_count
count = 0
count += 1 if #earliest_slot[:slot_date].present?
count += 1 if #max_rating[:rating].present?
count += 1 if #calendar_synced[:cronofy_enabled]
count
end
It seems to me you can reduce the overhead in your code by using Ruby's sort method. All you need to provide in each block iteration for the sort to work is a positive integer if a > b, 0 if they are equal and a negative integer if b >a. The starship operator <=> is a nice shortcut to use for the first two cases but does not work on booleans so we added the extra logic there.
sorted_array = interview_proposal.sort do |a, b|
if (a[:slot_date] <=> b[:slot_date]) != 0
a[:slot_date] <=> b[:slot_date]
elsif (a[:rating] <=> b[:rating]) != 0
a[:rating] <=> b[:rating]
elsif a[:cronofy_enabled] == b[:cronofy_enabled]
0
elsif a[:cronofy_enabled]
1
else
-1
end
end

Get differences between Array and ActiveRecord thousands of records

I'm trying to get deltas of additions, deletions, and updates between a csv data with about 500,000 records in comparison to an ActiveRecord.
iden being the identifier for their differences
ex. csv_data
[{
iden: 1, group_num: 111
},
{
iden: 2, group_num: 222
},
{
iden: 3, group_num: 333
},
{
iden: 4, group_num: 444
}]
ex. ActiveRecordData
[{
iden: 2, group_num: 222
},
{
iden: 3, group_num: 333
},
{
iden: 4, group_num: 999
},
{
iden: 5, group_num: 555
}]
As a result I would want to get
an array of additions
[{
iden: 5, group_num: 555
}]
an array of removals
[{
iden: 1, group_num: 111
}]
an array of updates
[{
iden: 4, group_num: 999
}]
I tried iterating through each to get the particular deltas but it's taking hours for large hundred thousand data sets. How would I better optimize this?
additions = []
updates = []
csv_data.each_slice(1000).map do |chunk|
chunk.map { |csv_item|
active_record = ActiveRecordData.where(iden: csv_item[:iden])
if !active_record.exists?
additions << active_record
elsif active_record.first.group_num != csv_item[:group_num]
updates << active_record
end
}
end
deletions = ActiveRecordData.all.select{|active_record| !csv_data.any?{|csv_item| csv_item[:iden] == active_record.iden}}
I'd start by addressing these issues:
Multiple queries for every item
Loading data you don't use
Instantiating ActiveRecord models unnecessarily
csv_data.each_slice(1000).map do |chunk|
records = ActiveRecordData
.where(iden: chunk.map(&:iden))
.pluck(:iden, :group_num)
additions += chunk.reject do |row|
records.find { |record| record.iden == row.iden }
end
updates += chunk.select do |row|
record = records.find { |record| record.iden == row.iden }
record.group_num != row.group_num
end
end
Finally, you likely need to go about your deletions in a different way. If your iden values are numeric and relatively sequential, one low-hanging approach is to fetch just iden values within a range (e.g. where(iden: 1..100_000).pluck(:iden)), then loop through your data to identify and add deleted records to a deletions buffer before moving on to the next batch.
I would create a temporary table with a primary key over ident and the load the CSV data into the table using bulk inserts in chunks. Once there it would be trivial (and very fast) to get the diff of two tables:
SELECT table_a.ident, table_a.group_num FROM table_a WHERE table_a.ident NOT IN (SELECT table_b.ident FROM table_b)
SELECT table_b.ident, table_b.group_num FROM table_b WHERE table_b.ident NOT IN (SELECT table_a.ident FROM table_a)
SELECT table_a.ident, table_a.group_num INNER JOIN table_b ON table_a.ident = table_b.ident AND table_a.group_num <> table_b.group_num

How to compre DateTime with generic times in ruby

I'm working on a ruby on rails app that can automatically schedule events. Every event has a start_time datetime and an end_time datetime.
Here is the problem:
In general, events shouldn't be scheduled before 9:00am or after 10:30pm. This is true for edge cases as well. I.e, even if an event ends after 9:00am, it shouldn't start before it. Similarly, if an event starts before 10:30pm, it should not go on passed 10:30pm.
How can I go about making sure that every event fits this criteria, when some of these events are on different dates?
The problem i'm having is that, yes, a DateTime object has a date and a time associated with it. But so does a Time object.
This means that if I have an event with a start and end datetime between 9am and 10:30pm, my comparison will always return false if I'm comparing a time object with a datetime object of a different date.
Here is what I had in mind:
def event_out_of_bounds?(event_start_time, event_end_time, min_start_time, max_end_time)
if event_start_time.hour > max_end_time.hour then
return true
end
if event_end_time.hour > max_end_time.hour then
return true
end
if event_start_time.hour < min_start_time.hour then
return true
end
if event_end_time.hour < min_start_time.hour then
return true
end
return false
end
Problems with this code:
It doesn't take into account minutes. I.e., if I ever change min_start_time to 9:30 am, then this method won't work anymore.
There are different dates involved. event_start_time and event_end_time, which are DateTimes, obviously have the same date. However, min_start_time and max_end_time, Which are created using Time.new have a different date (I just default it 2020-01-01). This wouldn't be a big problem if I knew beforehand what the dates would be for the events, however I won't in this case.
Any ideas for an algorithm to make this check elegantly?
events shouldn't be scheduled before 9:00am or after 10:30pm.
I might use Range#cover? for this.
require 'active_support/all'
def validate_schedule(s, e)
min = s.time_zone.local(s.year, s.month, s.day, 9, 0, 0)
max = s.time_zone.local(s.year, s.month, s.day, 22, 30, 0)
(min..max).cover?(s..e)
end
# Tests
et = ActiveSupport::TimeZone.new('Eastern Time (US & Canada)')
t = ->(d, h, m) { et.local(2020, 2, d, h, m, 0) }
test = ->(s, e) { puts format('%s %s = %s', s, e, validate_schedule(s, e)) }
test[t[1, 8, 59], t[1, 22, 30]] # false, start too early
test[t[1, 9, 0], t[1, 22, 30]] # true, 10:30 pm ok
test[t[1, 9, 0], t[1, 21, 30]] # true, 9:30 pm also ok
test[t[1, 9, 0], t[1, 22, 31]] # false, end too late
test[t[1, 9, 0], t[2, 22, 30]] # false, no all night parties
If you allow multi-day events, simply calculate max from the end date (e) instead:
max = e.time_zone.local(e.year, e.month, e.day, 22, 30, 0)
Range.cover? works with "any objects that can be compared using the <=> operator." (see Custom Objects in Ranges
Approach
It's curious that the question is framed in terms of DateTime objects, considering that the underlying problem seems to be just determining whether the scheduled time of a sporting match falls within a given window of time. Using DateTime objects is but one way of addressing the problem, but is not the only way. In that sense the question may fall under the heading of X-Y problem.
In fact, I find the use of DateTime objects complicates the problem unnecessarily. It's easier to express each time as simply a two-element array that contains the hour and minute. For example, the time-window that extends from 12:30 (24-hour clock) to 22:00 that evening could be expressed as the range:1
window = [12, 30]..[22, 0]
Event times could be defined similarly:2
event_time = [15, 45]..[19, 30]
For convenience, I will refer to the two-element arrays that are the endpoints of these ranges as simply "times".
An important consideration is that the start and end time of a sporting event does not necessarily fall on the same day. An example would be a tournament at which matches must fall within a window between 12:30 (24 hour clock) and 1:00 (AM) the following day. This is not unusual. This time-window would be expressed:
window = [12, 30]..[1, 0]
I assume:
end times are less than 24 hours after their corresponding start times; and
the time-window is the same every day.
Adjustments could of course be made to the code below if either assumption does not hold.
Code
Let's construct a method valid? that returns true (else false) if a given event falls within a given time-window:
def valid?(event_time, window)
e = time_to_mins(event_time.begin)..time_to_mins(event_time.end)
w = time_to_mins(window.begin)..time_to_mins(window.end)
if w.begin <= w.end
w.cover?(e)
else
(w.begin <= e.begin || e.begin <= w.end) &&
(w.begin <= e.end || e.end <= w.end)
end
end
def time_to_mins(time_arr)
60 * time_arr.first + time_arr.last
end
Examples
time_to_mins [ 0, 10] #=> 10
time_to_mins [22, 30] #=> 1350
window = [ 9, 30]..[22, 30]
valid?([13, 45]..[16, 30], window) #=> true
valid?([ 8, 45]..[11, 30], window) #=> false
valid?([22, 15]..[ 0, 15], window) #=> false
window = [12, 30]..[1, 0]
valid?([16, 15]..[19, 30], window) #=> true
valid?([22, 45]..[ 0, 30], window) #=> true
valid?([ 0, 15]..[ 0, 45], window) #=> true
valid?([22, 45]..[ 1, 30], window) #=> false
valid?([ 0, 15]..[ 1, 45], window) #=> false
1. If desired, one might alternatively express times as hashes, such as { hour: 12, min: 30, sec: 0 }.
2. Information for each event might be held in a larger data structure, such as event = { start_date: [2020, 2, 7], time_block: [[22, 45, 0]..[ 0, 30, 0]], player_1: 'R. Federer', player_2: 'R. Nadal' }, using only event[:time_block] in the above calculation.

Google CalendarV3 Event date format - error

it's related to Google::Apis::CalendarV3::Event date format error
while I execute my code to add an event to a google calendar i get this error message
invalid: Start and end times must either both be date or both be dateTime.
I tried many ways, I post you 2 of them, I can't make it work !
Is their any helper function in Google API consuming a 'DateTime' and returning to correct string ?
thnaks
Gregoire
start = DateTime.new(2017, 12, 9, 12, 0, 0)
ende = DateTime.new(2017, 12, 9, 12, 0, 0)
event = Google::Apis::CalendarV3::Event.new(
summary: 'test',
description: 'desc',
start: { datetime: start },
end: { datetime: ende }
)
# event = Google::Apis::CalendarV3::Event.new(
# summary: 'test',
# description: 'desc',
# start: { datetime: start.strftime("%Y-%m-%dT%l:%M:%S.000-07:00") },
# end: { datetime: ende.strftime("%Y-%m-%dT%l:%M:%S.000-07:00") }
# )
result = calendar.insert_event('primary', event)
puts "Event created: #{result.html_link}"
Ps : I write software for more than 20 years now, and we still have the same date/time problems !
Finaly the solution is :
date_time: start.to_datetime.rfc3339,

What is the best way of categorising segments of a time period?

I am developing a staff rota system. For payroll I need to calculate the correct rate of pay depending on the date/time period the shift covers.
How can I check for various date periods (weekend, holidays, weekday) without using a long chain of conditional statements with lengthy, verbose conditions.
Given any time range (a shift):
eg. 2015-01-20 15:00 --> 2015-01-21 17:00
What would be the best (and most efficient way) of categorising segments of the this period?
I would like to know:
The period (if any) between 22:00 and 07:00 on any weekday (Monday -
Friday) evening.
The period (if any) falling between 08:00 on a Saturday and 22:00 on a Sunday.
The period (if any) falling on a public holiday (using the
holidays gem)
So my two questions then are:
1) Knowing that a time period (shift) could span a weekend (although I would prefer a solution that would support a span of many days), how do I calculate which date/time ranges to compare against?
2) Once I have determined the time periods (weekends, holidays etc) to compare against, how do I best determine the intersection of these periods and determine the duration of them?
I don't fully understand your question, so I've put together some code that is based on many assumptions about the problem you face. I hope some of the issues I've addressed, and the way I've dealt with them, may be helpful to you. For example, if a worker is still working when a shift ends, it is necessary to identify the next shift, which may be the next day.
You'll see that my code is very rough and poorly structured. I have many temporary variables that are there just to help you follow what's going on. In a real app, you might want to add some classes, more methods, etc. Also, assuming the data will be stored in a database, you might want to use SQL for some of the calculations.
First, what I've assumed to be the data.
Data
A list of holidays:
holidays = ["2015:04:05", "2015:04:06"]
Information for each employee, including the employee's job classification, with keys being the employee id:
employees = {
123 => { name: 'Bob', job: :worker_bee1 },
221 => { name: 'Sue', job: :worker_bee2 }
}
Groups of days having the same daily periods, with pay rates the same for all days of the group, for each job and period within the day, unless the day falls on a holiday:
day_groups = { weekdays: [1,2,3,4,5], weekends: [6,0] }
Information for each work period:
work_periods = {
weekday_graveyard: {
day_group: :weekdays,
start_time: "00:00",
end_time: "08:00" },
weekday_day: {
day_group: :weekdays,
start_time: "08:00",
end_time: "16:00" },
weekday_swing: {
day_group: :weekdays,
start_time: "16:00",
end_time: "24:00" },
weekend: {
day_group: :weekends,
start_time: "00:00",
end_time: "24:00" } }
A wage schedule by job, dialy period, for non-holidays and holidays:
wage_by_job = {
worker_bee1: {
weekday_graveyard: { standard: 30.00, holiday: 60.00 },
weekday_day: { standard: 20.00, holiday: 40.00 },
weekday_swing: { standard: 25.00, holiday: 50.00 },
weekend: { standard: 22.00, holiday: 44.00 }
},
worker_bee2: {
weekday_graveyard: { standard: 32.00, holiday: 64.00 },
weekday_day: { standard: 22.00, holiday: 44.00 },
weekday_swing: { standard: 27.00, holiday: 54.00 },
weekend: { standard: 24.00, holiday: 48.00 }
}
}
Hours worked by all employees during the pay period:
shifts_worked = [
{ id: 123, date: "2015:04:03", start_time: "15:30", end_time: "00:15" },
{ id: 221, date: "2015:04:04", start_time: "23:30", end_time: "08:30" },
{ id: 123, date: "2015:04:06", start_time: "08:00", end_time: "16:00" },
{ id: 221, date: "2015:04:06", start_time: "23:00", end_time: "07:00" },
{ id: 123, date: "2015:04:07", start_time: "00:00", end_time: "09:00" }
]
Helpers
require 'set'
require 'date'
def date_str_to_obj(date_str)
Date.strptime(date_str, '%Y:%m:%d')
end
date_str_to_obj("2015:04:04")
#=> #<Date: 2015-04-04 ((2457117j,0s,0n),+0s,2299161j)>
def next_day(day)
(day==6) ? 0 : day+1
end
next_day(6)
#=> 0
Convert holidays to date objects and store in a set for fast lookup:
#holiday_set = Set.new(holidays.map { |date_str|
date_str_to_obj(date_str) }.to_set)
#=> #<Set: {#<Date: 2015-04-05 ((2457118j,0s,0n),+0s,2299161j)>,
# #<Date: 2015-04-06 ((2457119j,0s,0n),+0s,2299161j)>}>
def is_holiday?(date)
#holiday_set.include?(date)
end
is_holiday?(date_str_to_obj("2015:04:04"))
#=> false
is_holiday?(date_str_to_obj("2015:04:05"))
#=> true
Map each day of the week to an element of day_groups:
#day_group_by_dow = day_groups.each_with_object({}) { |(period,days),h|
days.each { |d| h[d] = period } }
#=> {1=>:weekdays, 2=>:weekdays, 3=>:weekdays, 4=>:weekdays,
# 5=>:weekdays, 6=>:weekend, 0=>:weekend}
Map each element of day_groups to an array of work periods:
#work_periods_by_day_group = work_periods.each_with_object({}) { |(k,g),h|
h.update(g[:day_group]=>[k]) { |_,nwp,owp| nwp+owp } }
#=> {:weekdays=>[:weekday_graveyard, :weekday_day, :weekday_swing],
# :weekend=> [:weekends]}
Compute hours worked within a work period:
def start_and_end_times_to_hours(start_time, end_time)
(time_to_minutes_after_midnight(end_time) -
time_to_minutes_after_midnight(start_time))/60.0
end
start_and_end_times_to_hours("10:00", "14:30")
#=> 4.5
A helper for the previous method:
private
def time_to_minutes_after_midnight(time_str)
hrs, mins = time_str.scan(/(\d\d):(\d\d)/).first.map(&:to_i)
60 * hrs + mins
end
public
time_to_minutes_after_midnight("10:00")
#=> 600
time_to_minutes_after_midnight("14:30")
#=> 870
As indicated by the method name:
def date_and_time_to_current_period(date, time, work_periods)
day_grp = #day_group_by_dow[date.wday]
periods = #work_periods_by_day_group[day_grp]
periods.find do |per|
p = work_periods[per]
p[:start_time] <= time && time < p[:end_time]
end
end
date_and_time_to_current_period(date_str_to_obj("2015:04:03"),
#=> :weekday_swing
date_and_time_to_current_period(date_str_to_obj("2015:04:04"),
#=> :weekend
Lastly, another self-explanatory method:
def next_period_and_date_by_period_and_date(work_periods, period, date)
end_time = work_periods[period][:end_time]
if end_time == "24:00" # next_day
day_grp = #day_group_by_dow[next_day(date.wday)]
wp = #work_periods_by_day_group[day_grp]
[wp.find { |period| work_periods[period][:start_time]=="00:00" }, date+1]
else # same day
day_grp = work_periods[period][:day_group]
wp = #work_periods_by_day_group[day_grp]
[wp.find { |period| work_periods[period][:start_time]==end_time }, date]
end
end
next_period_and_date_by_period_and_date(work_periods,
:weekday_day, date_str_to_obj("2015:04:03"))
#=> [:weekday_swing, #<Date: 2015-04-03 ((2457116j,0s,0n),+0s,2299161j)>]
next_period_and_date_by_period_and_date(work_periods,
:weekday_swing, date_str_to_obj("2015:04:02"))
#=> [:weekday_graveyard, #<Date: 2015-04-03...+0s,2299161j)>]
next_period_and_date_by_period_and_date(work_periods,
:weekday_swing, date_str_to_obj("2015:04:03"))
#=> [:weekend, #<Date: 2015-04-04 ((2457117j,0s,0n),+0s,2299161j)>]
next_period_and_date_by_period_and_date(work_periods,
:weekday_swing, date_str_to_obj("2015:04:04"))
#=> [:weekend, #<Date: 2015-04-05 ((2457118j,0s,0n),+0s,2299161j)>]
Calculation of payroll
shifts_worked.each_with_object(Hash.new(0.0)) do |shift, payroll|
id = shift[:id]
date = date_str_to_obj(shift[:date])
start_time = shift[:start_time]
end_time = shift[:end_time]
wage_schedule = wage_by_job[employees[id][:job]]
curr_period = date_and_time_to_current_period(date, start_time, work_periods)
while true
end_time_in_period = work_periods[curr_period][:end_time]
end_time_in_period = end_time if
(end_time > start_time && end_time < end_time_in_period)
hours_in_period =
start_and_end_times_to_hours(start_time, end_time_in_period)
wage = wage_schedule[curr_period][is_holiday?(date) ? :holiday : :standard]
payroll[id] += (wage * hours_in_period).round(2)
break if end_time == end_time_in_period
curr_period, date =
next_period_and_date_by_period_and_date(work_periods,
curr_period, date)
start_time = work_periods[curr_period][:start_time]
end
end
#=> {123=>795.5, 221=>698.0}
I've used the following gem:
https://github.com/fnando/recurrence
I haven't done Holidays yet.
Requirement
class Requirement < ActiveRecord::Base
# Model with attributes:
# start - datetime
# end - datetime
# is_sleepin - boolean
def duration
self.end - self.start
end
def to_range
self.start..self.end
end
def spans_multiple_days?
self.end.to_date != self.start.to_date
end
end
Breakdown of duration of requirement (shift)
class Breakdown
attr_reader :requirement,
:standard_total_duration,
:weekend_total_duration,
:wakein_total_duration
def initialize(requirement)
#requirement = requirement
#rules = Array.new
#rules << Breakdown::StandardRule.new(self)
#rules << Breakdown::WeekendRule.new(self)
#standard_total_duration = components[:standard].total_duration
#weekend_total_duration = components[:weekend].total_duration
if #requirement.is_sleepin?
#standard_total_duration = 0
#weekend_total_duration = 0
end
# Following is a special set of rules for certain Locations where staff work
# If a requirement is_sleepin? that means duration is not counted so set to 0
if ['Home 1', 'Home 2'].include?(#requirement.home.name.strip) and
#requirement.spans_multiple_days? and not #requirement.is_sleepin?
#rules << Aspirations::Breakdown::WakeinRule.new(self)
#wakein_total_duration = components[:wakein].total_duration
#standard_total_duration = 0
#weekend_total_duration = 0
end
end
def components
#rules.hmap{ |k,v| [ k.to_sym, k ] }
end
end
Which uses Rules to specify which parts of a shifts duration should be categorised:
module Breakdown
class Rule
def initialize(breakdown)
#requirement = breakdown.requirement
end
def to_sym
# eg 'Breakdown::StandardRule' becomes :standard
self.class.name.split('::').last.gsub("Rule", "").downcase.to_sym
end
def periods
output = []
occurrences = rule.events.map{ |e| rule_time_range(e) }
occurrences.each do |o|
if (o.max > #requirement.start and #requirement.end > o.min)
output << (o & #requirement.to_range)
end
end
return output
end
def total_duration
periods.reduce(0) { |sum, p| sum + (p.max - p.min).to_i }
end
end
end
Example of a Rule (in this case a weekend rule)
module Breakdown
class WeekendRule < Breakdown::Rule
def period_expansion
# This is an attempt to safely ensure that a weekend period
# is detected even though the start date of the requirement
# may be on Sunday evening, maybe could be just 2 days
4.days
end
def period_range
(#requirement.start.to_date - period_expansion)..(#requirement.end.to_date + period_expansion)
end
def rule
Recurrence.new(:every => :week, :on => :saturday, :starts => period_range.min, :until => period_range.max)
end
def rule_time_range(date)
# Saturday 8am
start = date + Time.parse("08:00").seconds_since_midnight.seconds
_end = (date + 1.day) + Time.parse("22:00").seconds_since_midnight.seconds
start.._end
end
end
end
One possible approach might be to break a week up into individual chunks of, say, 15 minutes (depending on how much resolution you need). Instead of ranges of time that are somewhat hard to deal with, you can then work with sets of these chunks, which Ruby supports very nicely.
Number the chunks of time sequentially:
Monday 12:00 AM-12:15 AM = 0
Monday 12:15 AM-12:30 AM = 1
...
Sunday 11:45 PM-12:00 AM = 671
Then prepare sets of integers for holidays, weekends, each shift, etc., whatever you need. Except for the holidays, those are probably all constants.
For instance, for your weekend from Saturday 8 AM to Sunday 10 PM:
weekend = [ 512..663 ]
Similarly, represent each employee's attendance as a set. For instance, somebody who worked from 8 AM to noon on Monday and from 10 AM to 11 AM on Saturday would be:
attendance = [ 32..47, 520..523 ]
With this approach, you can use set intersections to figure out how many hours were on weekends:
weekendAttendance = weekend & attendance

Resources