How to compre DateTime with generic times in ruby - ruby-on-rails

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.

Related

How ActiveSupport TimeWithZone retains values?

I tried something like that:
a = ActiveSupport::TimeWithZone.new(Time.now,Time.zone)
b = a
a - b # it gives 0.0 (float)
While when I tried:
a.to_s # it gives "2019-06-30 11:11:42 -0700"
a.to_a # it gives [42, 11, 11, 30, 6, 2019, 0, 181, true, "PDT"]
So from where this float number?
Ruby's - (subtraction) method from the Time class is used for this as you can see looking at the Rails source code for TimeWithZone.
def -(other)
if other.acts_like?(:time)
to_time - other.to_time
elsif duration_of_variable_length?(other)
method_missing(:-, other)
else
result = utc.acts_like?(:date) ? utc.ago(other) : utc - other rescue utc.ago(other)
result.in_time_zone(time_zone)
end
end
As per the Ruby docs it returns a float.
Difference — Returns a difference in seconds as a Float between time
and other_time, or subtracts the given number of seconds in numeric
from time.

Looping through objects by date in rails

I'm trying to loop through records created during a date range by customer then create two arrays to pass to Chart.js. The first array is each day in the date range, the second is the total hosts scanned for the given day.
Chart.js is looking for these formats:
labels: ["3/11","3/12","3/13","3/14"] (a basic array)
data: [12,13,14,55] (another basic array)
In my case the labels need to be each date within the given range (I'm just testing it right now with a 14 day range; and data would be the amount of hosts scanned on that given day (which might be 0).
I can get the total hosts scanned for a Company pretty easily:
count = 0
company.tests.each do |test|
count += test.network_hosts.count
end
But I'm struggling to align the host count with a date range since a test
My code is working, but the count for the day is off by one. In this case, the results from a day like 3/2 are showing under 3/1, etc.
dates = []
array = []
hit = 0
today = Date.today
tests = company.tests.where(date: today - 30..today).order(date: :asc)
(today - 30..today).each{|date| dates.push(date.to_s)}
dates.each do |r|
hit = 0
tests.each do |t|
if t.date.strftime("%Y-%m-%d") == r
hit = hit + t.network_hosts.count
end
end
array.push(hit)
end
return array
end
=> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
The date range array that I'm feeing into Chart.js looks like:
today = Date.today
labels = []
(today - 30..today).each{ |date| labels.push(js_date_time_no_year(date))}
return labels
=> ["02/17", "02/18", "02/19", "02/20", "02/21", "02/22", "02/23", "02/24", "02/25", "02/26", "02/27", "02/28", "03/01", "03/02", "03/03", "03/04", "03/05", "03/06", "03/07", "03/08", "03/09", "03/10", "03/11", "03/12", "03/13", "03/14", "03/15", "03/16", "03/17", "03/18", "03/19"]
So the value 17 in the first array should be in position 14, not 13 as it is. and so on. I can easily adjust this in my code by going back 31 days in, but ideally I want the user to be able to click a 30/60/90 day button and not have the do the backend math to add 1 to the date range.
I think you should keep it simple. So it can easily be extended and code readability is higher.
Here is a snippet which does the same
grouped_data = company.tests.joins(:network_hosts).where(date: 30.days.ago.to_date..Date.today).order(date: :asc).group_by {|test| test.date.strftime("%m/%d")}
now
array = []
labels = []
(30.days.ago.to_date..Date.today).each do|day|
label = day.strftime("%d/%m")
labels.push label
array.push(grouped_data[label].try(:length) || 0)
end
Read here about group_by
grouped_data[label].try(:length) || 0 will return the actual network hosts whose company was created for specific date and 0 for those days where no company was created

remove array from multidimensional array if first 3 values aren't unique in ruby on rails

OK, so I have this array of arrays. Each array within the larger array is very much the same, ten specific values. If my value at location 3 is a specific value, then I want to iterate through the rest of the remaining arrays within the larger array and see if the first 3 values at locations 0, 1, and 2 match. if they then match, I'd like to delete the original array. I'm having a hard time with it, maybe there is an easy way? I'm sure there is, I'm fairly new to this whole coding stuff =) So much appreciation in advance for your help....
here's where I'm at:
#projectsandtrials.each do |removed|
if removed[3] == ["Not Harvested"]
#arraysforloop = #projectsandtrials.clone
#arraysforloop1 = #arraysforloop.clone.delete(removed)
#arraysforloop1.each do |m|
if (m & [removed[0], removed[1], removed[2]]).any?
#projectsandtrials.delete(removed)
end
end
end
end
Lets look at your situation:
#projectsandtrials.each do |removed|
// some logic, yada yada
#projectsandtrials.delete(removed)
end
You can't just delete stuff out of an array you're iterating through. At least not until you finish iterating through it. What you should be using instead is a filtering method like reject instead of just an each.
So instead of deleting right there, you should just return true when using reject.
I think about it like this when iterating through arrays.
Do I want the array to stay the same size and have the same content?
Use each.
Do I want the array to be the same size, but have different content?
Use map.
Do I want the array to be less than or equal to the current size?
Use select or reject.
Do I want it to end up being a single value?
Use reduce.
Code
def prune(arr, val)
arr.values_at(*(0..arr.size-4).reject { |i| arr[i][3] == val &&
arr[i+1..i+3].transpose[0,3].map(&:uniq).all? { |a| a.size==1 } }.
concat((arr.size-3..arr.size-1).to_a))
end
Example
arr = [ [1,2,3,4,0],
[3,4,5,6,1],
[3,4,5,4,2],
[3,4,5,6,3],
[3,4,5,6,4],
[3,4,0,6,5],
[2,3,5,4,6],
[2,3,5,5,7],
[2,3,5,7,8],
[2,3,5,8,9],
[2,3,5,7,0]
]
Notice that the last values of the elements (arrays) of arr are consecutive. This is to help you identify the elements of prune(arr, 4) (below) that have been dropped.
prune(arr, 4)
# => [[3, 4, 5, 6, 1],
# [3, 4, 5, 4, 2],
# [3, 4, 5, 6, 3],
# [3, 4, 5, 6, 4],
# [3, 4, 0, 6, 5],
# [2, 3, 5, 5, 7],
# [2, 3, 5, 7, 8],
# [2, 3, 5, 8, 9],
# [2, 3, 5, 7, 0]]
Explanation
The arrays at indices 0 and 6 have not been included in array returned.
arr[0] ([1,2,3,4,0]) has not been included because arr[0][3] = val = 4 and arr[1], arr[2] and arr[3] all begin [3,4,5].
arr[6] ([2,3,5,4,6]) has not been included because arr[6][3] = 4 and arr[7], arr[8] and arr[9] all begin [2,3,5].
arr[2] ([3,4,5,5,2]) has been included because, while arr[2][3] = 4, arr[3][0,3], arr[4][0,3] and arr[5][0,3] all not all equal (i.e., arr[5][2] = 0).
Notice that the last three elements of arr will always be included in the array returned.
Now let's examine the calculations. First consider the following.
arr.size
#=> 11
a = (0..arr.size-4).reject { |i| arr[i][3] == val &&
arr[i+1..i+3].transpose[0,3].map(&:uniq).all? { |a| a.size==1 } }
#=> (0..7).reject { |i| arr[i][3] == val &&
arr[i+1..i+3].transpose[0,3].map(&:uniq).all? { |a| a.size==1 } }
#=> [1, 2, 3, 4, 5, 7]
Consider reject's block calculation for i=0 (recall val=4).
arr[i][3] == val && arr[i+1..i+3].transpose[0,3].map(&:uniq).all? {|a| a.size==1 }}
#=> 4 == 4 && arr[1..3].transpose[0,3].map(&:uniq).all? { |a| a.size==1 }
#=> [[3,4,5,6,1],
# [3,4,5,4,2],
# [3,4,5,6,3]].transpose[0,3].map(&:uniq).all? { |a| a.size==1 }
#=> [[3, 3, 3],
# [4, 4, 4],
# [5, 5, 5],
# [6, 4, 6],
# [1, 2, 3]][0,3].map(&:uniq).all? { |a| a.size==1 }
#=> [[3, 3, 3],
# [4, 4, 4],
# [5, 5, 5]].map(&:uniq).all? { |a| a.size==1 }
#=> [[3], [4], [5]].all? { |a| a.size==1 }
#=> true
meaning arr[0] is to be rejected; i.e., not included in the returned array.
The remaining block calculations (for i=1,...,10) are similar.
We have computed
a #=> [1, 2, 3, 4, 5, 7]
which are the indices of all elements of arr except the last 3 that are to be retained. To a we add the indices of the last three elements of arr.
b = a.concat((arr.size-3..arr.size-1).to_a)
#=> a.concat((8..10).to_a)
#=> a.concat([8,9,10])
#=> [1, 2, 3, 4, 5, 7, 8, 9, 10]
Lastly,
arr.values_at(*b)
returns the array given in the example.
Your code snippet seems fine, although there are couple of things to note:
#arraysforloop.clone.delete(removed) removes all the occurences of removed array (not only the first one). E.g. [1,2,3,1].delete(1) would leave you with [2,3]. You could fix it with using an iterator for #projectsandtrials and delete_at method.
delete method returns the same argument you pass to it (or nil if no matches found). So #arraysforloop1 = #arraysforloop.clone.delete(removed) makes your #arraysforloop1 to contain the removed array's elements only! Removing an assignment could save you.
I see no reason to have two cloned arrays, #arraysforloop and #arraysforloop1, as the former one is not used anyhow later. May be we could omit one of them?
#projectsandtrials.delete(removed) leaves you in a strange state, as long as you're iterating the same array right now. This could end up with you missing the right next element after the removed one. Here is a simple snippet to illustrate the behaviour:
> a = [1,2,3]
> a.each{|e, index| puts("element is: #{e}"); a.delete(1);}
element is: 1
element is: 3
As you see, after deleting element 1 the loop moved to element 3 directly, omitting the 2 (as it became the first element in array and algorithm thinks it's been handled already).
One of the possibilities to make it less messy is to split it to a bundle of methods. Here is an option:
def has_searched_element? row
# I leave this method implementation to you
end
def next_rows_contain_three_duplicates?(elements, index)
# I leave this method implementation to you
end
def find_row_ids_to_remove elements
[].tap do |result|
elements.each_with_index do |row, index|
condition = has_searched_element?(row) && next_rows_contain_three_duplicates?(elements, index)
result << index if condition
end
end
end
row_ids_to_remove = find_row_ids_to_remove(#projectsandtrials)
# now remove all the elements at those ids out of #projectsandtrials

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

How would I order a set of objects based on using <=> on an array attribute?

I have a set of 2 or more objects that I'd like to order. I had been doing it like this:
card.max_by{|strength| strength.score
Where score was an integer score I had computed given some arbitrary rules. I knew this would be something I would refactor, so now I am doing so. And the "clean" way to give a score to a hand is to give it an array of values like
foo.score = [9,3,nil,4]
And compare it to another hand which might have an array like
bar.score = [5,10,12,12]
And foo <=> bar would tell me that foo is the greater array and so it should be returned by max_by. The problem is that max_by apparently won't make comparisons on arrays. Is there another way I can do this to sort by the array value?
max_by works with array "attributes" just fine:
# Phrogz's example
Hand = Struct.new(:score)
hands = [
Hand.new([9,3,0,4]),
Hand.new([8,8,8,8]),
Hand.new([5,10,12,12]),
Hand.new([1,99,99,99])
]
#
hands.max_by(&:score) # => #<struct Hand score=[9, 3, 0, 4]>
However if the arrays can contain nils or other values that don't compare with each other, <=> could return nil and max_by could fail.
If it's just array-based ordering that you want (you really do want the spaceship operator) and you want to find the 'biggest' by sorting, then:
Hand = Struct.new(:score)
hands = [
Hand.new([9,3,0,4]),
Hand.new([8,8,8,8]),
Hand.new([5,10,12,12]),
Hand.new([1,99,99,99])
]
biggest = hands.sort_by(&:score).last
p biggest
#=> #<struct Hand score=[9, 3, 0, 4]>
If you really only need to find the largest hand, however, the following will be more efficient than ordering the entire array:
biggest = hands.inject do |max,hand|
if (max.score <=> hand.score) == -1
hand
else
max
end
end
p biggest
#=> #<struct Hand score=[9, 3, 0, 4]>
Edit: Reading your comment, if you really need multiple values that match, I would do this:
Hand = Struct.new(:name,:score) do
MAX_SCORE_PART = 13 # 13 ranks in a suit
def numeric_score
value = 0
score.each_with_index do |part,i|
value += part.to_i * MAX_SCORE_PART**(score.length-i-1)
end
value
end
end
hands = [
Hand.new('Bob', [9,3,nil,4] ),
Hand.new('Jim', [8,8,8,8] ),
Hand.new('Foo', [5,10,12,12]),
Hand.new('Sam', [1,13,13,13]),
Hand.new('Zak', [9,3,0,4] ),
]
require 'pp'
by_score = hands.group_by(&:numeric_score)
pp by_score
#=> {20284=>
#=> [#<struct Hand name="Bob", score=[9, 3, nil, 4]>,
#=> #<struct Hand name="Zak", score=[9, 3, 0, 4]>],
#=> 19040=>[#<struct Hand name="Jim", score=[8, 8, 8, 8]>],
#=> 12843=>[#<struct Hand name="Foo", score=[5, 10, 12, 12]>],
#=> 4576=>[#<struct Hand name="Sam", score=[1, 13, 13, 13]>]}
pp by_score[by_score.keys.max]
#=> [#<struct Hand name="Bob", score=[9, 3, nil, 4]>,
#=> #<struct Hand name="Zak", score=[9, 3, 0, 4]>]
For an inject-based implementation:
def numeric_score
score.enum_for(:inject,0).with_index do |(val,part),i|
val += part.to_i * MAX_SCORE_PART**(score.length-i-1)
end
end

Resources