tags:

views:

334

answers:

12

Hey,

I need to find three previous working days from a given date, omitting weekends and holidays. This isn't a hard task in itself, but it seems that the way I was going to do it would be overly complicated, so I thought I'd ask for your opinion first.

To make things more interesting, let's make this a contest. I'm offering 300 as a bounty to whoever comes up with the shortest, cleanest solution that adheres to this specification:

  • Write a function that returns three previous working days from a given date
  • Working day is defined as any day that is not saturday or sunday and isn't an holiday
  • The function knows the holidays for the year of the given date and can take these into account
  • The function accepts one parameter, the date, in Y-m-d format
  • The function returns an array with three dates in Y-m-d format, sorted from oldest to newest.

Extra:

  • The function can find also the next three working days in addition to the previous three

An example of the holidays array:

$holidays = array(
    '2010-01-01',
    '2010-01-06',
    '2010-04-02',
    '2010-04-04',
    '2010-04-05',
    '2010-05-01',
    '2010-05-13',
    '2010-05-23',
    '2010-06-26',
    '2010-11-06',
    '2010-12-06',
    '2010-12-25',
    '2010-12-26'
);

Note that in the real scenario, the holidays aren't hardcoded but come from get_holidays($year) function. You can include / use that in your answer if you wish.

As I'm offering a bounty, that means there will be at least three days before I can mark an answer as accepted (2 days to add a bounty, 1 day until I can accept).


Note

If you use a fixed day length such as 86400 seconds to jump from day to another, you'll run into problems with daylight savings time. Use strtotime('-1 day', $timestamp) instead.

An example of this problem:

http://codepad.org/uSYiIu5w


Final solution

Here's the final solution I ended up using, adapted from Keith Minkler's idea of using strtotime's last weekday. Detects the direction from the passed count, if negative, searches backwards, and forwards on positive:

function working_days($date, $count) {

    $working_days = array();
    $direction    = $count < 0 ? 'last' : 'next';
    $holidays     = get_holidays(date("Y", strtotime($date)));

    while(count($working_days) < abs($count)) {
        $date = date("Y-m-d", strtotime("$direction weekday", strtotime($date)));
        if(!in_array($date, $holidays)) {
            $working_days[] = $date;
        }
    }

    sort($working_days);
    return $working_days;
}
A: 

You mean like the WORKDAY() function in Excel

If you take a look at the WORKDAYS function in PHPExcel, you'll find an example of how to code such a function

Mark Baker
Ah, I see not quite WORKDAY() from your edits... you need the set of dates returning, not just the last date
Mark Baker
+2  A: 

Pass true as the second argument to go forward in time instead of backwards. I've also edited the function to allow for more than three days if you should want to in the future.

function last_workingdays($date, $forward = false, $numberofdays = 3) {
        $time = strtotime($date);
        $holidays = get_holidays();
        $found = array();
        while(count($found) < $numberofdays) {
                $time -= 86400 * ($forward?-1:1);
                $new = date('Y-m-d', $time);
                $weekday = date('w', $time);
                if($weekday == 0 || $weekday == 6 || in_array($new, $holidays)) {
                        continue;
                }
                $found[] = $new;
        }
        if(!$forward) {
                $found = array_reverse($found);
        }
        return $found;
}
Emil Vikström
+8  A: 

This should do the trick:

    // Start Date must be in "Y-m-d" Format
    function LastThreeWorkdays($start_date) {
        $current_date = strtotime($start_date);
        $workdays = array();
        $holidays = get_holidays('2010');

        while (count($workdays) < 3) {
            $current_date = strtotime('-1 day', $current_date);

            if (in_array(date('Y-m-d', $current_date), $holidays)) {    
                // Public Holiday, Ignore.
                continue;
            }

            if (date('N', $current_date) < 6) {
                // Weekday. Add to Array.
                $workdays[] = date('Y-m-d', $current_date);
            }
        }

        return array_reverse($workdays);
    }

I've hard-coded in the get_holidays() function, but I'm sure you'll get the idea and tweak it to suit. The rest is all working code.

Wireblue
A good, concise answer. Let's see if someone comes up with an even simpler answer, otherwise you're about to quadruple your reputation :)
Tatu Ulmanen
This will fail if you input a date that's near the beginning of the year and there are holidays near the end of the previous year, as your function will never check for those holidays.
Josh Leitzel
@josh. Yes, that's correct. As I mentioned on one of my other comments, ideally you want to pass the date you're working around as an argument to the function. That way all holidays 2 weeks either side of that date (for example) will be returned (even if they span over the year end). I agree with you though... passing a year as an argument is not a good idea. I alluded to this in my answer. ;)
Wireblue
+1  A: 

Here's my go at it:

function business_days($date) {
    $out = array();
    $day = 60*60*24;

    //three back
    $count = 0;
    $prev = strtotime($date);
    while ($count < 3) {
        $prev -= $day;
        $info = getdate($prev);
        $holidays = get_holidays($info['year']);
        if ($info['wday'] == 0 || $info['wday'] == 6 || in_array($date,$holidays))
                continue;
        else {
            $out[] = date('Y-m-d',$prev);
            $count++;
        }
    }

    $count = 0;
    $next = strtotime($date);
    while ($count < 3) {
        $next += $day;
        $info = getdate($next);
        $holidays = get_holidays($info['year']);
        if ($info['wday']==0 || $info['wday']==6 || in_array($date,$holidays))
                continue;
        else {
            $out[] = date('Y-m-d',$next);
            $count++;
        }
    }

    sort($out);

    return $out;
}
Austin Hyde
+1  A: 

Edit:

Changed the 86400 to -1 day although I don't fully understand if this was really an issue.

Made some modifications to the original functions but it's pretty much the same.

// -----------------------
// Previous 3 working days # this is almost the same that someone already posted
function getWorkingDays($date){
    $workdays = array();
    $holidays = getHolidays();
    $date     = strtotime($date);

    while(count($workdays) < 3){
        $date = strtotime("-1 day", $date);

        if(date('N',$date) < 6 && !in_array(date('Y-m-d',$date),$holidays))
            $workdays[] = date('Y-m-d',$date);
    }

    krsort($workdays);
    return $workdays;
}
// --------------------------------
// Previous and Next 3 working days
function getWorkingDays2($date){
    $workdays['prev'] = $workdays['next'] = array();
    $holidays = getHolidays();
    $date     = strtotime($date);

    $start_date = $date;
    while(count($workdays['prev']) < 3){
        $date = strtotime("-1 day", $date);

        if(date('N',$date) < 6 && !in_array(date('Y-m-d',$date),$holidays))
            $workdays['prev'][] = date('Y-m-d',$date);
    }
    $date = $start_date;
    while(count($workdays['next']) < 3){
        $date = strtotime("+1 day", $date);

        if(date('N',$date) < 6 && !in_array(date('Y-m-d',$date),$holidays))
            $workdays['next'][] = date('Y-m-d',$date);
    }

    krsort($workdays['prev']);
    return $workdays;
}

function getHolidays(){
    $holidays = array(
        '2010-01-01', '2010-01-06',
        '2010-04-02', '2010-04-04', '2010-04-05',
        '2010-05-01', '2010-05-13', '2010-05-23',
        '2010-06-26',
        '2010-11-06',
        '2010-12-06', '2010-12-25', '2010-12-26'
    );
    return $holidays;
}

echo '<pre>';
print_r( getWorkingDays( '2010-04-04' ) );
print_r( getWorkingDays2( '2010-04-04' ) );
echo '</pre>';

Outputs:

Array
(
    [2] => 2010-03-30
    [1] => 2010-03-31
    [0] => 2010-04-01
)
Array
(
    [next] => Array
        (
            [0] => 2010-04-06
            [1] => 2010-04-07
            [2] => 2010-04-08
        )

    [prev] => Array
        (
            [2] => 2010-03-30
            [1] => 2010-03-31
            [0] => 2010-04-01
        )

)
acmatos
Note that by using fixed 86400 seconds in a day, you'll run into problems with daylight savings time. The same applies to other answers, so I've updated my question to include this point.
Tatu Ulmanen
Hi Tatu, could you please explain that better? Isn't a day always 24 * 60 * 60? Why does the daylight saving time would change that assumption? Thanks for the alert anyway! :-)
acmatos
@acmatos, check this: http://codepad.org/uSYiIu5w. Basically, if the given date falls inside DST and the previous day is outside, your dates are off by one. This of course applies only to timezones where DST is in use.
Tatu Ulmanen
@Tatu, Thanks for the example! :)
acmatos
and don't forget leap seconds, too, which we don't have a nice simple formula to compute -- http://tycho.usno.navy.mil/leapsec.html
Joe
+1  A: 

I'm adding another answer since it follows a different approach from the ones I've posted before:

function getWorkDays($date){
    list($year,$month,$day) = explode('-',$date);
    $holidays = getHolidays();
    $dates    = array();

    while(count($dates) < 3){
        $newDate = date('Y-m-d',mktime(0,0,0,$month,--$day,$year));
        if(date('N',strtotime($newDate)) < 6 && !in_array($newDate,$holidays))
            $dates[] = $newDate;
    }

    return array_reverse($dates);
}

print_r(getWorkDays('2010-12-08'));

Output:

Array
(
    [0] => 2010-12-02
    [1] => 2010-12-03
    [2] => 2010-12-07
)
acmatos
Good answer and novel thinking with the day substraction. And most compact (and still readable) to date that still gets the job done.
Tatu Ulmanen
Thank you. In fact I don't know why I didn't think about it sooner but well, better later than never. :-)
acmatos
+2  A: 

Here is my take on it using PHP's DateTime class. Regarding the holidays, it takes into account that you may start in one year and end in another.

function get_workdays($date, $num = 3, $next = false)
{
    $date = DateTime::createFromFormat('Y-m-d', $date);
    $interval = new DateInterval('P1D');
    $holidays = array();

    $res = array();
    while (count($res) < $num) {
        $date->{$next ? 'add' : 'sub'}($interval);

        $year = (int) $date->format('Y');
        $formatted = $date->format('Y-m-d');

        if (!isset($holidays[$year]))
            $holidays[$year] = get_holidays($year);

        if ($date->format('N') <= 5 && !in_array($formatted, $holidays[$year]))
            $res[] = $formatted;
    }
    return $next ? $res : array_reverse($res);
}
Daniel Egeberg
A: 

Try this one (fair warning - I don't have access to test this out so please correct any syntax errors).

function LastThreeWorkdays($start_date) { 
    $startdateseed = strtotime($start_date); 
    $workdays = array(); 
    $holidays = get_holidays('2010'); 

    for ($counter = -1; $counter >= -10; $counter--) 
      if (date('N', $current_date = strtotime($counter.' day', $startdateseed)) < 6) $workdays[] = date('Y-m-d', $currentdate);

    return array_slice(array_reverse(array_diff($workdays, $holidays)), 0, 3);
}

Basically create a "chunk" of dates and then use array diff to remove the holidays from it. Return only the top (last) three items. Obviously it takes a miniscule more storage space and time to compute than previous answers but the code is much shorter.

The "chunk" size can be tweaked for further optimization. Ideally it would be the maximum number of consecutive holidays plus 2 plus 3 but that assumes realistic holiday scenarios (an entire week of holidays isn't possible, etc).

The code can be "unrolled" too to make some of the tricks easier to read. Overall shows off some of the PHP functions a little bit better - could be combined with the other ideas as well though.

ktharsis
+2  A: 

You can use expressions like "last weekday" or "next thursday" in strtotime, such as this:

function last_working_days($date, $backwards = true)
{
    $holidays = get_holidays(date("Y", strtotime($date)));

    $working_days = array();

    do
    {
        $direction = $backwards ? 'last' : 'next';
        $date = date("Y-m-d", strtotime("$direction weekday", strtotime($date)));
        if (!in_array($date, $holidays))
        {
            $working_days[] = $date;
        }
    }
    while (count($working_days) < 3);

    return $working_days;
}
Keith Minkler
Wow! Now that's an elegant solution. I've cleaned up your code and added a few features. New version here. http://codepad.org/D3zU1DSA
Wireblue
@wireblue, thanks, you're right that I should have put the "$direction = ..." line outside the loop, since there's no need to keep defining it, thanks for the correction!However, I think your "return ($direction) ? ... : ...;" is unnecessary, since $direction will always evaluate to TRUE (it's a non-zero length string), you may have meant $backwards here.
Keith Minkler
@Keith. Oops, yes you're right! $direction should have been $backwards.
Wireblue
A: 
/**
  * @param $currentdate like 'YYYY-MM-DD'
  * @param $n number of workdays to return
  * @param $direction 'previous' or 'next', default is 'next'
  **/
function adjacentWorkingDays($currentdate, $n, $direction='next') {
    $sign = ($direction == 'previous') ? '-' : '+';
    $workdays = array();
    $holidays = get_holidays();
    $i = 1;
    while (count($workdays) < $n) {
        $dateinteger = strtotime("{$currentdate} {$sign}{$i} days");
        $date = date('Y-m-d', $dateinteger);
        if (!in_array($date, $holidays) && date('N', $dateinteger) < 6) {
            $workdays[] = $date;
        }
        $i++;
    }
    return $workdays;
}

// you pass a year into get_holidays, make sure folks
// are accounting for the fact that adjacent holidays
// might cross a year boundary
function get_holidays() {
    $holidays = array(
        '2010-01-01',
        '2010-01-06',
        '2010-04-02',
        '2010-04-04',
        '2010-04-05',
        '2010-05-01',
        '2010-05-13',
        '2010-05-23',
        '2010-06-26',
        '2010-11-06',
        '2010-12-06',
        '2010-12-25',
        '2010-12-26'
    );
    return $holidays;
}

In these functions we use the adjacentWorkingDays() function:

// next $n working days, in ascending order
function nextWorkingDays($date, $n) {
    return adjacentWorkingDays($date, $n, 'next');
}

// previous $n workind days, in ascending order
function previousWorkingDays($date, $n) {
    return array_reverse(adjacentWorkingDays($date, $n, 'previous'));
}

Here's testing it out:

print "<pre>";
print_r(nextWorkingDays('2010-06-24', 3));
print_r(previousWorkingDays('2010-06-24', 3));
print "<pre>";

Results:

Array
(
    [0] => 2010-06-25
    [1] => 2010-06-28
    [2] => 2010-06-29
)
Array
(
    [0] => 2010-06-21
    [1] => 2010-06-22
    [2] => 2010-06-23
)
artlung
A: 

here is my submission ;)

/**
 * Helper function to handle year overflow
 */
function isHoliday($date) {
  static $holidays = array(); // static cache
  $year = date('Y', $date);

  if(!isset($holidays["$year"])) {
    $holidays["$year"] = get_holidays($year);
  }

  return in_array(date('Y-m-d', $date), $holidays["$year"]);
}

/**
 * Returns adjacent working days (by default: the previous three)
 */
function adjacentWorkingDays($start_date, $limit = 3, $direction = 'previous') {
  $current_date = strtotime($start_date);
  $direction = ($direction === 'next') ? 'next' : 'previous'; // sanity
  $workdays = array();

  // no need to verify the count before checking the first day.
  do {
    // using weekday here skips weekends.
    $current_date = strtotime("$direction weekday", $current_date);
    if (!isHoliday()) {
      // not a public holiday.
      $workdays[] = date('Y-m-d', $current_date);
    }
  } while (count($workdays) < $limit)

  return array_reverse($workdays);
}
alexanderpas
A: 

Here's my take. This function (unlike most of the others posted) will not fail if you input a date at the beginning of the year. If you were to only call the get_holidays function on one year, the resulting array might include dates that are holidays from the previous year. My solution will call get_holidays again if we slip back into the previous year.

function get_working_days($date)
{
    $date_timestamp = strtotime($date);
    $year = date('Y', $date_timestamp);
    $holidays = get_holidays($year);
    $days = array();

    while (count($days) < 3)
    {
        $date_timestamp = strtotime('-1 day', $date_timestamp);
        $date = date('Y-m-d', $date_timestamp);         

        if (!in_array($date, $holidays) && date('N', $date_timestamp) < 6)
            $days[] = $date;


        $year2 = date('Y', $date_timestamp);
        if ($year2 != $year)
        {
            $holidays = array_merge($holidays, get_holidays($year2));
            $year = $year2;
        }
    }

    return $days;
}
Josh Leitzel