2

How to check if one UNIX timestamp range is overlapping another UNIX timestamp range in PHP?

I am developing an application which takes future reservations. But, only one (1) reservation is allowed per period.


Example:

Mr. X has a reservation for a resource from 10:00 A.M. to 12:00 P.M. (noon). Later, Ms. Y wants to reserve that same resource from 8:00 A.M. to 11:00 P.M.. My application should reject Ms. Y's attempted reservation because it overlaps Mr. X's prior reservation.


I am storing the start and end times of existing reservations in UNIX timestamps (integers), but I could convert them into the following format "yyyy-mm-dd hh:mm:ss" if required, or vice versa.

I do not understand how to solve this problem. If I check the new start time with all the existing reservation start times, and the new end time in a similar fashion, the logic will have many if statements and make the application slow.

Would you please help me to solve this issue in an efficient way without using lots of server resources.

Your help is greatly appreciated.

Thanks

Anthony Rutledge
  • 6,980
  • 2
  • 39
  • 44
Vidit Mody
  • 45
  • 5
  • So... Appointments if valid should always be start1 < end1 < start2 < end2 or start2 < end2 < start1 < end1? Seems like a pretty simple if statement to me. – Gerrit0 Feb 28 '17 at 20:17
  • Thank you @Gerrit0 for the answer, i thought of the following if statement after posting the question. True if StartA > EndB True if EndA < StartB But if any of the above statement is false then it is overlapping the time. I tried that if putting it in my code but it slow down the page when i check for about 15 different date range. – Vidit Mody Feb 28 '17 at 20:22
  • Might not be obvious, but all these conditions could be reduced to one: `SELECT ... WHERE start < :end AND end > :start` (if row found - overlap). See [here](http://stackoverflow.com/a/325964/2329487) for more details. – shudder Feb 28 '17 at 21:20
  • @shudder Ah, but then the reason for the conflict would not be known. – Anthony Rutledge Feb 28 '17 at 22:18
  • @Gerrit0 Kind of like my answer. – Anthony Rutledge Feb 28 '17 at 22:20
  • @ViditMody Well, I tried to help. – Anthony Rutledge Feb 28 '17 at 22:20
  • Perhaps a very similar problem: http://stackoverflow.com/q/42458547/2298301 – Dhruv Saxena Feb 28 '17 at 23:19
  • @AnthonyRutledge The reason is exactly the same as OP's question - one date range overlaps another. – shudder Mar 01 '17 at 00:19
  • @shudder True, but I mean the exact reason, and the exact part of the current reservation the new reservation is bumping into: beginning, middle, or ending (if the first conditional is broken into two separate ones). – Anthony Rutledge Mar 01 '17 at 02:44
  • @AnthonyRutledge Exact reason is far less important than general one at the moment. That said, you'd better fix the query in your answer, because you've missed 2/3 of the cases there. – shudder Mar 01 '17 at 06:04
  • @shudder You do not know that. That is an assumption. – Anthony Rutledge Mar 01 '17 at 11:56
  • @shudder My "string" reasons are just an example of how you could track the reason a reservation cannot be made. There might be a need to visually indicate conflicts on the screen, and the query solution does not provide this information in a precise and flexible way. The query is fine. The business logic in PHP handles the real work. – Anthony Rutledge Mar 01 '17 at 12:02
  • @ViditMody Forgot one contition, but now I think the answer below provides the most ***precision*** and ***flexibility***, while at the same time generalizes the problem. – Anthony Rutledge Mar 01 '17 at 12:04
  • Does your dbms support the SQL `overlaps` operator? – Mike Sherrill 'Cat Recall' Mar 01 '17 at 14:44
  • @AnthonyRutledge My mistake. Didn't figure out your query searches for all reservations within some arbitrary range. But how's that *efficient* then? – shudder Mar 01 '17 at 19:35
  • @shudder No problem. Well, the method `Reservation::isOpen` takes two arguments: 1) `$minLimit` and 2) `$maxLimit`. My guess that `$minLimit` will always be the output of the PHP `time()` method. However `$maxLimit` would be application specific. Luckily, the query only returns `startTime` and `endTime` for each reservation for one resource, not all reservations for all resources till the end of time. The query can be constrained to only provide results for days greater than **now** (eliminating past reservations from the results). I will make that change. – Anthony Rutledge Mar 01 '17 at 20:58
  • @shudder Ok, I was just winging it, but I updated the SQL query and the call to `Reservation::isOpen` – Anthony Rutledge Mar 01 '17 at 21:31

1 Answers1

1

Introduction

In other words, you need to do a comparison of all reservation intervals (UNIX timestamps) for a particular resource to determine if a new reservation is valid (within the domain for new reservations).

Step 1

First, a SQL query similar to this might help. While key words like ANY, ALL, NOT, EXISTS and others may seem tempting, it is up to you to decide how much information you need in the event of a scheduling conflict (based on your UI). This query provides the opportunity to extract the maximum amount of information (in PHP, etc ...) about a potential reservation with look ahead forecasting.

// A query like this might help. It's not perfect, but you get the idea.
// This query looks for ALL potential conflicts, starting and ending.

    $sql = "SELECT DISTINCT `t1`.`startTime`, `t1`.`endTime`
              FROM `reservations` AS `t1`
             INNER JOIN `resources` AS `t2`
                ON `t1`.`resourceId` = `t2`.`resourceId`
             WHERE `t2`.`resourceId` = :resourceId
               AND (`t1`.`startTime` BETWEEN :minTime1 AND :maxTime1)
                OR (`t1`.`endTime` BETWEEN :minTime2 AND :maxTime2)
             ORDER BY `t1`.`startTime` ASC";

Potentially. this will leave you with a multi-dimentional array. The following logic allows you to get a report detailing why the reservation cannot be made. It is up to you to interpret the report in another module.

Step 2

Generalize the solution as a methods of a Reservation class. Depending on your RDBMS, you may be able to do something like this in SQL. Although, it will probably be far less specific and you may want that granularity later. You could send the report in JSON format to a JavaScript front end (just something to think about).

private function inOpenDomain(array $exclusion, $testStart, $testEnd)
{
    $result = null;
    $code   = null;

    $start = $exclusion[0];
    $end   = $exclusion[1];

    if (($testStart > $end) || ($testEnd < $start)) {
        $result = true;
        $code = 0;  //Good! No conflict.
    } elseif ($testStart === $start) {
        $result = false;
        $code = 1;
    } elseif ($testStart === $end) {
        $result = false;
        $code = 2;
    } elseif ($testEnd === $start) {
        $result = false;
        $code = 3;
    } elseif ($testEnd === $end) {
        $result = false;
        $code = 4;
    } elseif (($testStart > $start) && ($testEnd < $end)) {   //Middle
        $result = false;
        $code = 5;
    } elseif (($testStart < $start) && ($testEnd > $start)) { //Left limit
        $result = false;
        $code = 6;
    } elseif (($testStart < $end) && ($testEnd > $end)) {     //Right limit
        $result = false;
        $code = 7;
    } elseif (($testStart < $start) && ($testEnd > $end)) {   //Both limits
        $result = false;
        $code = 8;
    } else {
        $result = false;
        $code = 9;
    }

    return ['start' => $start, 'end' => $end, 'result' => $result => 'code' => $code];
}

Step 3

Make a method that manages the checking of prior reservation times (assuming PDO::FETCH_ASSOC).

private function checkPeriods(array $periods, $newStartTime, $newEndTime)
{
    $report = [];

    if (!isset($periods[0])) { //If NOT multi-dimensional
        $report = inOpenDomain($periods, $newStartTime, $newEndTime)
    } else {
        for ($i = 0, $length = $count($periods); $i < $length; ++$i) {
            $report[$i] = inOpenDomain($periods[$i], $newStartTime, $newEndTime);
        }
    }

    return $report;
}

Step 4

Fashion a method for doing a SELECT on the reservations table using a PDO prepared statement. Generally, ...

private function getReservationTimes($resourceId, $minTime, $maxTime) 
{
    $sql = "SELECT DISTINCT `t1`.`startTime`, `t1`.`endTime`
              FROM `reservations` AS `t1`
             INNER JOIN `resources` AS `t2`
                ON `t1`.`resourceId` = `t2`.`resourceId`
             WHERE `t2`.`resourceId` = :resourceId
               AND (`t1`.`startTime` BETWEEN :minTime1 AND :maxTime1)
                OR (`t1`.`endTime` BETWEEN :minTime2 AND :maxTime2)
             ORDER BY `t1`.`startTime` ASC";

    $stmt = $this->db->prepare($sql);
    $stmt->bindParam(:resourceId , $resourceId);
    $stmt->bindParam(:minTime1 , $minTime);
    $stmt->bindParam(:maxTime1 , $maxTime);
    $stmt->bindParam(:minTime2 , $minTime);
    $stmt->bindParam(:maxTime2 , $maxTime);
    $stmt->execute();
    return $stmt->fetchAll();
}

Step 5

Make a public method (interface) for the entire process.

public function isOpen($minTime, $maxTime)
{
    $periods = $this->getReservationTimes($this->resource->getResourceId(), $minTime, $maxTime);

    if (empty($periods)) {
        return true;  //You may reserve the resource during the time period.
    }

    return $this->checkPeriods($periods, $this->start, $this->end));  
}

Step 6

Separate the concerns.

Create a class hierarchy for the actual items being reserved.

abstact class Product
{

}

class Resource extends Product implements Reservable  //Or, something ...
{
    private $resourceId;

   //etc ....
}

Create a class hierarchy for reservations.

abstract class Interval
{
    private $start;
    private $end;

    public function __construct($start, $end)
    {
        $this->start = $start;
        $this->end   = $end;
    }
}

class Reservation extends Interval
{
    private $db;
    private $resource;

    public function __construct(PDO $pdo, Reservable $resource, $reqStartTime, $reqEndTime)
    {
        parent::__construct($reqStartTime, $reqEndTime);
        $this->db = $pdo;
        $this->resource = $resource;
    }
}

Step 7

Run within try/catch

When you instantiate the Reservation object, supply at least a Reservable object, the requested start time, and requested end time (as UNIX timestamps, in this case).

try
{
    $day          = 84600;                   // Seconds per day.
    $future       = $day * 90;               // Application specific.

    //User requested times.
    $reqStartTime = 1488394687 + $day;       // Tomorrow.
    $reqEndTime   = 1488394687 + ($day * 2); // Two day duration.

    //Search constraints.
    $minTime      = time();                   // Today. Right now.
    $maxTime      = 1488394687 + $future;     // 90 day look ahead.

    $reservation  = new Reservation($pdo, $resourceObj, $reqStartTime, $reqEndTime);
    $availability = $reservation->isOpen($minTime, $maxTime);

    if($availability === true){
        $reservation->confirm();
    } else {
        //Have some other object deal with the report
        $reporter = new Reporter($availability);
        $reporter->analyzeReport();
        //Have some other object update the view, etc ...
    }

}
catch(Exception $e)
{
    //Handle it.
}
Anthony Rutledge
  • 6,980
  • 2
  • 39
  • 44
  • This will only check for the the start time, if it is in array or not. if it is in array the it will stop the execution and says you cannot reserve this resource. Like in the example I gave, The start time is 8 AM and the reserved resource start time is 10 AM. based on the function using in_array() it will give you false as 8 AM doesn't exist in the reserved timestamp and it will let me reserve the resource which it should't because 8 AM to 11 AM date range does overlap the existing reservation . same goes with end time checking. – Vidit Mody Feb 28 '17 at 20:34
  • Ok, now, how about that. – Anthony Rutledge Feb 28 '17 at 20:44
  • Ok, what about this. – Anthony Rutledge Feb 28 '17 at 21:29
  • I made a function for checking reservations for you. It might work. – Anthony Rutledge Feb 28 '17 at 21:51
  • it didn't pass the test cases in the following if statement
    
    startTime= 2017-2-28 14:00
    endTime= 2017-2-28 16:00
    newStartTime= 2017-2-28 12:00
    newEndTime= 2017-2-28 13:00
    
    if (($newStartTime < $startTime) && ($newEndTime < $endTime) {
     //execution pointer will go inside this segment and it will prevent user to book this resource.
    
     //Lets say, we will allow user to book it, if the above condition is true
     //Then for the case with newEndTime = 2017-2-28 15:00:00
     //This condition will met and it will allow user to book it. but actually it shoudn't
    }
    
    – Vidit Mody Feb 28 '17 at 22:47
  • Yes, that means both the $newStartTime and $newEndTime occure before the established $startTime. That is a good reservation. – Anthony Rutledge Feb 28 '17 at 23:05
  • Ok, now about this. I found some of my errors. Hopefully, all. – Anthony Rutledge Feb 28 '17 at 23:17
  • @ViditMody It is just an interval problem, like in math. But, those can trip anyone up. :-) – Anthony Rutledge Feb 28 '17 at 23:21
  • @ViditMody This is a better answer. Doing the entire thing in SQL has it's limitations. – Anthony Rutledge Mar 01 '17 at 12:39
  • @ViditMody You are very welcome. The more points, the better. – Anthony Rutledge Mar 01 '17 at 18:36
  • @ViditMody I improved the SQL and example. Good luck! – Anthony Rutledge Mar 01 '17 at 22:01