Reputation: 3970
First question. Be gentle.
I'm working on software that tracks technicians' time spent working on tasks. The software needs to be enhanced to recognize different billable rate multipliers based on the day of the week and the time of day. (For example, "Time and a half after 5 PM on weekdays.")
The tech using the software is only required to log the date, his start time and his stop time (in hours and minutes). The software is expected to break the time entry into parts at the boundaries of when the rate multipliers change. A single time entry is not permitted to span multiple days.
Here is a partial sample of the rate table. The first-level array keys are the days of the week, obviously. The second-level array keys represent the time of the day when the new multiplier kicks in, and runs until the next sequential entry in the array. The array values are the multiplier for that time range.
[rateTable] => Array
(
[Monday] => Array
(
[00:00:00] => 1.5
[08:00:00] => 1
[17:00:00] => 1.5
[23:59:59] => 1
)
[Tuesday] => Array
(
[00:00:00] => 1.5
[08:00:00] => 1
[17:00:00] => 1.5
[23:59:59] => 1
)
...
)
In plain English, this represents a time-and-a-half rate from midnight to 8 am, regular rate from 8 to 5 pm, and time-and-a-half again from 5 till 11:59 pm. The time that these breaks occur may be arbitrary to the second and there can be an arbitrary number of them for each day. (This format is entirely negotiable, but my goal is to make it as easily human-readable as possible.)
As an example: a time entry logged on Monday from 15:00:00 (3 PM) to 21:00:00 (9 PM) would consist of 2 hours billed at 1x and 4 hours billed at 1.5x. It is also possible for a single time entry to span multiple breaks. Using the example rateTable above, a time entry from 6 AM to 9 PM would have 3 sub-ranges from 6-8 AM @ 1.5x, 8AM-5PM @ 1x, and 5-9 PM @ 1.5x. By contrast, it's also possible that a time entry may only be from 08:15:00 to 08:30:00 and be entirely encompassed in the range of a single multiplier.
I could really use some help coding up some PHP (or at least devising an algorithm) that can take a day of the week, a start time and a stop time and parse into into the required subparts. It would be ideal to have the output be an array that consists of multiple entries for a (start,stop,multiplier) triplet. For the above example, the output would be:
[output] => Array
(
[0] => Array
(
[start] => 15:00:00
[stop] => 17:00:00
[multiplier] => 1
)
[1] => Array
(
[start] => 17:00:00
[stop] => 21:00:00
[multiplier] => 1.5
)
)
I just plain can't wrap my head around the logic of splitting a single (start,stop) into (potentially) multiple subparts.
Upvotes: 20
Views: 2130
Reputation: 3970
Eineki cracked the algorithm. The part missing from my attempts was having the start and the stop time available in each multiplier range. I value the density of data in my original rateTable, so I used the guts of Eineki's convert() routine to take the table stored in config and add the stop times in. My code already auto-created (or filled in) a minimal rate table, guaranteeing that the rest of the code won't choke or throw warning/errors, so I included that. I also condensed bill() and map_shift() together since in my mind the two don't have any useful purpose without each other.
<?php
//-----------------------------------------------------------------------
function CompactSliceData($start, $stop, $multiplier)
// Used by the VerifyRateTable() to change the format of the multiplier table.
{
return compact('start', 'stop','multiplier');
}
//-----------------------------------------------------------------------
function VerifyAndConvertRateTable($configRateTable)
// The rate table must contain keyed elements for all 7 days of the week.
// Each subarray must contain at LEAST a single entry for '00:00:00' =>
// 1 and '23:59:59' => 1. If the first entry does not start at midnight,
// a new element will be added to the array to represent this. If given
// an empty array, this function will auto-vivicate a "default" rate
// table where all time is billed at 1.0x.
{
$weekDays = array('Monday', 'Tuesday', 'Wednesday',
'Thursday', 'Friday', 'Saturday',
'Sunday',); // Not very i18n friendly?
$newTable = array();
foreach($weekDays as $day)
{
if( !array_key_exists($day, $configRateTable)
|| !is_array($configRateTable[$day])
|| !array_key_exists('00:00:00', $configRateTable[$day]) )
{
$configRateTable[$day]['00:00:00'] = 1;
}
if( !array_key_exists($day, $configRateTable)
|| !is_array($configRateTable[$day])
|| !array_key_exists('23:59:59', $configRateTable[$day]) )
{
$configRateTable[$day]['23:59:59'] = 1;
}
// Convert the provided table format to something we can work with internally.
// Ref: http://stackoverflow.com/questions/2792048/slicing-a-time-range-into-parts
$newTable[$day] = array_slice(
array_map(
'CompactSliceData',
array_keys($configRateTable[$day]),
array_keys(array_slice($configRateTable[$day],1)),
$configRateTable[$day]),
0,-1);
}
return $newTable;
}
//-----------------------------------------------------------------------
function SliceTimeEntry($dayTable, $start, $stop)
// Iterate through a day's table of rate slices and split the $start/$stop
// into parts along the boundaries.
// Ref: http://stackoverflow.com/questions/2792048/slicing-a-time-range-into-parts
{
$report = array();
foreach($dayTable as $slice)
{
if ($start < $slice['stop'] && $stop > $slice['start'])
{
$report[] = array(
'start'=> max($start, $slice['start']),
'stop' => min($stop, $slice['stop']),
'multiplier' => $slice['multiplier']
);
}
}
return $report;
}
/* examples */
$rateTable = array(
'Monday' => array('00:00:00' => 1.5, '08:00:00' => 1, '17:00:00' => 1.5),
'Tuesday' => array('00:00:00' => 1.5, '08:00:00' => 1, '17:00:00' => 1.5),
'Wednesday' => array('00:00:00' => 1.5, '08:00:00' => 1, '17:00:00' => 1.5),
'Thursday' => array('00:00:00' => 1.5, '08:00:00' => 1, '17:00:00' => 1.5),
'Friday' => array('00:00:00' => 1.5, '08:00:00' => 1, '17:00:00' => 1.5),
'Saturday' => array('00:00:00' => 1.5, '15:00:00' => 2),
'Sunday' => array('00:00:00' => 1.5, '15:00:00' => 2),
);
$rateTable = VerifyAndConvertRateTable($rateTable);
print_r(SliceTimeEntry($rateTable['Monday'],'08:05:00','18:05:00'));
print_r(SliceTimeEntry($rateTable['Monday'],'08:05:00','12:00:00'));
print_r(SliceTimeEntry($rateTable['Tuesday'],'07:15:00','19:30:00'));
print_r(SliceTimeEntry($rateTable['Tuesday'],'07:15:00','17:00:00'));
?>
Thanks everyone, especially Eineki.
Upvotes: 0
Reputation: 14909
I would use a different approach, and I will change the rateTable representation based of a couple of considerations.
Last but not least, my personal experience let me say that if you can't wrap your head on an algorithm it is likely that your co-workers will have the same difficulties (even if you succeed and resolve the issues) and the code will be a primary source of bug. If you find a simpler and efficient solution it will be a gain of time, money and headaches. Maybe it will be a gain even if the solution is not so efficient.
$rateTable = array(
'Monday' => array (
array('start'=>'00:00:00','stop'=>'07:59:59','multiplier'=>1.5),
array('start'=>'08:00:00','stop'=>'16:59:59','multiplier'=>1),
array('start'=>'17:00:00','stop'=>'23:59:59','multiplier'=>1.5)
),
'Tuesday'=> array (
array('start'=>'00:00:00','stop'=>'08:00:00','multiplier'=>1.5),
array('start'=>'08:00:00','stop'=>'17:00:00','multiplier'=>1),
array('start'=>'17:00:00','stop'=>'23:59:59','multiplier'=>1.5)
)
);
function map_shift($shift, $startTime, $stopTime)
{
if ($startTime >= $shift['stop'] or $stopTime <= $shift['start']) {
return;
}
return array(
'start'=> max($startTime, $shift['start']),
'stop' => min($stopTime, $shift['stop']),
'multiplier' => $shift['multiplier']
);
}
function bill($day, $start, $stop)
{
$report = array();
foreach($day as $slice) {
$result = map_shift($slice, $start, $stop);
if ($result) {
array_push($report,$result);
}
}
return $report;
}
/* examples */
var_dump(bill($rateTable['Monday'],'08:05:00','18:05:00'));
var_dump(bill($rateTable['Monday'],'08:05:00','12:00:00'));
var_dump(bill($rateTable['Tuesday'],'07:15:00','19:30:00'));
var_dump(bill($rateTable['Tuesday'],'07:15:00','17:00:00'));
At the very least you need a function to convert the original format to the new one.
$oldMonday = array (
'00:00:00'=>1.5,
'08:00:00'=>1,
'17:00:00'=>1.5,
'23:59:59'=>1
);
function convert($array)
{
return array_slice(
array_map(
function($start,$stop, $multiplier)
{
return compact('start', 'stop','multiplier');
},
array_keys($array),
array_keys(array_slice($array,1)),
$array),
0,
-1);
}
var_dump(convert($oldMonday));
And yes, you could do the conversion on the fly with
bill(convert($oldRateTable['Tuesday']),'07:15:00','17:00:00');
but if you care a bit of performances...
Upvotes: 3
Reputation: 30170
Here's my method
I converted everything to seconds to make it a lot easier.
Here's the rate table indexed by seconds. Theres only 3 time slots for monday
// 0-28800 (12am-8am) = 1.5
// 28800-61200 (8am-5pm) = 1
// 61200-86399 (5pm-11:50pm) = 1.5
$rate_table = array(
'monday' => array (
'28800' => 1.5,
'61200' => 1,
'86399' => 1.5
)
);
It uses this function to convert hh:mm:ss to seconds
function time2seconds( $time ){
list($h,$m,$s) = explode(':', $time);
return ((int)$h*3600)+((int)$m*60)+(int)$s;
}
This is the function that returns a rate table
function get_rates( $start, $end, $rate_table ) {
$day = strtolower( date( 'l', strtotime( $start ) ) );
// these should probably be pulled out and the function
// should accept integers and not time strings
$start_time = time2seconds( end( explode( 'T', $start ) ) );
$end_time = time2seconds( end( explode( 'T', $end ) ) );
$current_time = $start_time;
foreach( $rate_table[$day] as $seconds => $multiplier ) {
// loop until we get to the first slot
if ( $start_time < $seconds ) {
//$rate[ $seconds ] = ( $seconds < $end_time ? $seconds : $end_time ) - $current_time;
$rate[] = array (
'start' => $current_time,
'stop' => $seconds < $end_time ? $seconds : $end_time,
'duration' => ( $seconds < $end_time ? $seconds : $end_time ) - $current_time,
'multiplier' => $multiplier
);
$current_time=$seconds;
// quit the loop if the next time block is after clock out time
if ( $current_time > $end_time ) break;
}
}
return $rate;
}
Here's how you use it
$start = '2010-05-03T07:00:00';
$end = '2010-05-03T21:00:00';
print_r( get_rates( $start, $end, $rate_table ) );
returns
Array
(
[0] => Array
(
[start] => 25200
[stop] => 28800
[duration] => 3600
[multiplier] => 1.5
)
[1] => Array
(
[start] => 28800
[stop] => 61200
[duration] => 32400
[multiplier] => 1
)
[2] => Array
(
[start] => 61200
[stop] => 75600
[duration] => 14400
[multiplier] => 1.5
)
)
Basically the code loops over the rate table and finds how many seconds from the given time slot belong to each rate.
Upvotes: 1
Reputation: 90022
This is basically an adaptation of @Loopo's algorithm.
First, it'd be nice to be able to compare times using >
and <
, so first we convert all times (day of week + hour/minute/second) to UNIX time offsets:
// Code is messy and probably depends on how you structure things internally.
function timeOffset($dayOfWeek, $time) {
// TODO Use standard libraries for this.
$daysOfWeek = array('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday');
$splitTime = explode(':', $time);
$offset = (((int)array_search($dayOfWeek, $daysOfWeek) * 24 + (int)$time[0]) * 60 + (int)$time[1]) * 60 + (int)$time[2];
return $offset;
}
$rateTable = array(
'Monday' => array(
'00:00:00' => 1.5,
'08:00:00' => 1,
'17:00:00' => 1.5,
),
'Tuesday' => array(
'00:00:00' => 1.5,
'08:00:00' => 1,
'17:00:00' => 1.5,
)
);
$clockedTimes = array(
array('Monday', '15:00:00', '21:00:00')
);
$rateTableConverted = array();
foreach($rateTable as $dayOfWeek => $times) {
foreach($times as $time => $multiplier) {
$offset = timeOffset($dayOfWeek, $time);
$rateTableConverted[$offset] = $multiplier;
}
}
ksort($rateTableConverted);
$clockedTimesConverted = array();
foreach($clockedTimes as $clock) {
$convertedClock = array(
'start' => timeOffset($clock[0], $clock[1]),
'end' => timeOffset($clock[0], $clock[2]),
);
$clockedTimesConverted[] = $convertedClock;
}
Ideally, this would have already been done (e.g. you store these converted offsets in the database instead of the original xx:yy:zz D
strings).
Now the splitter (with a helper due to the lack of closures):
class BetweenValues {
public $start, $end;
public function __construct($start, $end) {
$this->start = $start;
$this->end = $end;
}
public function isValueBetween($value) {
return $this->start <= $value && $value <= $this->end;
}
}
class TimeRangeSplitter {
private $rateTable;
public function __construct($rateTable) {
$this->rateTable = $rateTable;
}
private function getIntersectingTimes($times, $start, $end) {
ksort($times);
$betweenCalculator = new BetweenValues($start, $end);
$intersecting = array_filter($times, array($betweenCalculator, 'isValueBetween'));
/* If possible, get the time before this one so we can use its multiplier later. */
if(key($intersecting) > 0 && current($intersecting) != $start) {
array_unshift($intersecting, $times[key($intersecting) - 1]);
}
return array_values($intersecting);
}
public function getSplitTimes($start, $end) {
$splits = array();
$intersecting = $this->getIntersectingTimes(array_keys($this->rateTable), $start, $end);
$curTime = $start;
$curMultiplier = 0;
foreach($intersecting as $sectionStartTime) {
$splits[] = $this->getSplit($curTime, $sectionStartTime, $curMultiplier, $curTime);
$curMultiplier = $this->rateTable[$sectionStartTime];
}
$splits[] = $this->getSplit($curTime, $end, $curMultiplier, $curTime);
return array_filter($splits);
}
private function getSplit($time, $split, $multiplier, &$newTime) {
$ret = NULL;
if($time < $split) {
$ret = array(
'start' => $time,
'end' => $split,
'multiplier' => $multiplier,
);
$newTime = $split;
}
return $ret;
}
}
And using the class:
$splitClockedTimes = array();
$splitter = new TimeRangeSplitter($rateTableConverted);
foreach($clockedTimesConverted as $clocked) {
$splitClockedTimes[] = $splitter->getSplitTimes($clocked['start'], $clocked['end']);
}
var_dump($splitClockedTimes);
Hope this helps.
Upvotes: 0
Reputation: 2195
I would suggest something like
get total time to allocate (workstop - workstart) find the start slot (the last element where time < workstart) and how much of start slot is billable, reduce time left to allocate move to next slot while you have time left to allocate if the end time is in the same slot get the portion of the time slot that is billable else the whole slot is billable reduce the time to allocate by the slot time (build your output array) and move to the next slot loop while
It might be easier to convert all your times to seconds internally to make the days/hours/minutes calculations easier to handle.
Upvotes: 0