diff --git a/src/Model/Day.php b/src/Model/Day.php index c5e9ab41c..846cdf5de 100644 --- a/src/Model/Day.php +++ b/src/Model/Day.php @@ -440,6 +440,21 @@ protected function mapRestrictions( array &$slots ) { } } + public function getStartTimestamp(): int { + $dt = new DateTime( $this->getDate() ); + $dt->modify( 'midnight' ); + + return $dt->getTimestamp(); + } + + public function getEndTimestamp(): int { + $dt = new DateTime( $this->getDate() ); + $dt->modify( '23:59:59' ); + + return $dt->getTimestamp(); + } + + /** * Remove empty and merge connected slots. * diff --git a/src/Repository/Timeframe.php b/src/Repository/Timeframe.php index ecb2ea84f..4905cb1d6 100644 --- a/src/Repository/Timeframe.php +++ b/src/Repository/Timeframe.php @@ -720,6 +720,35 @@ private static function filterTimeframesForCurrentUser( $posts ): array { } ); } + /** + * Will filter out all timeframes that are not in the given timerange. + * + * @param \CommonsBooking\Model\Timeframe[] $timeframes + * @param int $startTimestamp + * @param int $endTimestamp + * + * @return \CommonsBooking\Model\Timeframe[] + * @throws Exception + */ + public static function filterTimeframesForTimerange( array $timeframes, int $startTimestamp, int $endTimestamp ): array { + return array_filter( $timeframes, function ( $timeframe ) use ( $startTimestamp, $endTimestamp ) { + //filter out anything in the future + if ( $timeframe->getStartDate() > $endTimestamp ) { + return false; + } + //always include infinite timeframes + if ( ! $timeframe->getEndDate() ) { + return true; + } + //filter out anything in the past + if ( $timeframe->getEndDate() < $startTimestamp ) { + return false; + } + + return true; + } ); + } + /** * Instantiate models for posts. * Why? In some cases we need more than WP_Post methods and for this case we have Models, that enrich WP_Post diff --git a/src/View/Calendar.php b/src/View/Calendar.php index 804ef91ee..59bc9f58e 100644 --- a/src/View/Calendar.php +++ b/src/View/Calendar.php @@ -406,22 +406,45 @@ public static function getCalendarDataArray( $item, $location, string $startDate * * @return \CommonsBooking\Model\Timeframe|null */ - private static function getClosestBookableTimeFrameForToday( $bookableTimeframes ): ?\CommonsBooking\Model\Timeframe { - // Sort timeframes by startdate - usort( - $bookableTimeframes, - function ( \CommonsBooking\Model\Timeframe $item1, \CommonsBooking\Model\Timeframe $item2 ) { - $item1StartDateDistance = abs( time() - $item1->getStartDate() ); - $item1EndDateDistance = abs( time() - $item1->getEndDate() ); - $item1SmallestDistance = min( $item1StartDateDistance, $item1EndDateDistance ); - - $item2StartDateDistance = abs( time() - $item2->getStartDate() ); - $item2EndDateDistance = abs( time() - $item2->getEndDate() ); - $item2SmallestDistance = min( $item2StartDateDistance, $item2EndDateDistance ); - - return $item2SmallestDistance <=> $item1SmallestDistance; - } - ); + public static function getClosestBookableTimeFrameForToday( $bookableTimeframes ): ?\CommonsBooking\Model\Timeframe { + $today = new Day( date( 'Y-m-d' ) ); + $todayTimeframes = \CommonsBooking\Repository\Timeframe::filterTimeframesForTimerange( $bookableTimeframes, $today->getStartTimestamp(), $today->getEndTimestamp() ); + $todayTimeframes = array_filter( $todayTimeframes, function ( $timeframe ) use ( $today ) { //also consider repetition + return $today->isInTimeframe( $timeframe ); + } ); + switch ( count( $todayTimeframes ) ) { + case 1: + $bookableTimeframes = $todayTimeframes; + break; + case 0: + usort( $bookableTimeframes, function ( $a, $b ) { + $aStartDate = $a->getStartDate(); + $bStartDate = $b->getStartDate(); + + if ( $aStartDate == $bStartDate ) { + $aStartTimeDT = $a->getStartTimeDateTime(); + $bStartTimeDT = $b->getStartTimeDateTime(); + + return $bStartTimeDT <=> $aStartTimeDT; + } + + return $bStartDate <=> $aStartDate; + } ); + break; + default: //More than one timeframe for current day + // consider starttime and endtime + $now = new DateTime(); + /** @var \CommonsBooking\Model\Timeframe $todayTimeframes */ + $bookableTimeframes = array_filter( $todayTimeframes, function ( $timeframe ) use ( $now ) { + $startTime = $timeframe->getStartTime(); + $startTimeDT = new DateTime( $startTime ); + $endTime = $timeframe->getEndTime(); + $endTimeDT = new DateTime( $endTime ); + + return $startTimeDT <= $now && $now <= $endTimeDT; + } ); + break; + } return array_pop( $bookableTimeframes ); } diff --git a/tests/php/Model/DayTest.php b/tests/php/Model/DayTest.php index c51a882e9..15ef2b9bb 100644 --- a/tests/php/Model/DayTest.php +++ b/tests/php/Model/DayTest.php @@ -206,4 +206,17 @@ public function testGetRestrictions() { $this->assertTrue(count($this->instance->getRestrictions()) == 1); } + + public function testGetStartTimestamp() { + $start = strtotime( self::CURRENT_DATE . ' midnight' ); + $this->assertEquals( $start, $this->instance->getStartTimestamp() ); + } + + public function testGetEndTimestamp() { + $end = strtotime( self::CURRENT_DATE . ' 23:59:59' ); + $this->assertEquals( $end, $this->instance->getEndTimestamp() ); + } + + + } diff --git a/tests/php/Repository/TimeframeTest.php b/tests/php/Repository/TimeframeTest.php index 8279c3135..2b6d8e24d 100644 --- a/tests/php/Repository/TimeframeTest.php +++ b/tests/php/Repository/TimeframeTest.php @@ -178,6 +178,22 @@ public function testGetHoliday() { ); } + public function testFilterTimeframesForTimerange() { + $allTimeframeModels = array_map( function ( $timeframeId ) { + return new \CommonsBooking\Model\Timeframe( $timeframeId ); + }, $this->allTimeframes ); + //should return everything in the repetition + $filteredTimeframes = Timeframe::filterTimeframesForTimerange( + $allTimeframeModels, + $this->repetition_start, + $this->repetition_end + ); + $this->assertEqualsCanonicalizing( $this->allTimeframes, array_map( function ( $timeframe ) { + return $timeframe->ID; + }, $filteredTimeframes ) ); + + } + protected function setUp(): void { parent::setUp(); $this->repetition_start = strtotime( self::CURRENT_DATE ); diff --git a/tests/php/View/CalendarTest.php b/tests/php/View/CalendarTest.php index e95f139ab..a966eb9f1 100644 --- a/tests/php/View/CalendarTest.php +++ b/tests/php/View/CalendarTest.php @@ -11,7 +11,6 @@ /** * @TODO: Write test for restriction cache invalidation. */ - class CalendarTest extends CustomPostTypeTest { protected const bookingDaysInAdvance = 35; @@ -26,6 +25,8 @@ class CalendarTest extends CustomPostTypeTest { protected $secondClosestTimeframe; + private $now; + public function testKeepDateRangeParam() { $startDate = date( 'Y-m-d', strtotime( self::CURRENT_DATE ) ); $jsonresponse = Calendar::getCalendarDataArray( @@ -70,12 +71,12 @@ public function testAdvancedBookingDays() { // days between start date and latest possible booking date $maxBookableDays = date_diff( $latestPossibleBookingDate, $timeframeStart )->days; - $this->assertTrue( $maxBookableDays == (self::bookingDaysInAdvance - self::timeframeStart - 1) ); + $this->assertTrue( $maxBookableDays == ( self::bookingDaysInAdvance - self::timeframeStart - 1 ) ); } public function testClosestBookableTimeFrameFuntion() { - $startDate = date( 'Y-m-d', strtotime( 'midnight', strtotime(self::CURRENT_DATE) ) ); - $endDate = date( 'Y-m-d', strtotime( '+60 days midnight', strtotime(self::CURRENT_DATE) ) ); + $startDate = date( 'Y-m-d', strtotime( 'midnight', strtotime( self::CURRENT_DATE ) ) ); + $endDate = date( 'Y-m-d', strtotime( '+60 days midnight', strtotime( self::CURRENT_DATE ) ) ); $jsonresponse = Calendar::getCalendarDataArray( $this->itemId, @@ -84,7 +85,7 @@ public function testClosestBookableTimeFrameFuntion() { $endDate ); - $this->assertTrue($jsonresponse['minDate'] == date('Y-m-d')); + $this->assertTrue( $jsonresponse['minDate'] == date( 'Y-m-d' ) ); } /* @@ -96,42 +97,42 @@ public function testOverbookingDefaultValues() { $jsonresponse = Calendar::getCalendarDataArray( $this->itemId, $this->locationId, - date( 'Y-m-d', strtotime( 'midnight', strtotime(self::CURRENT_DATE) ) ), - date( 'Y-m-d', strtotime( '+60 days midnight', strtotime(self::CURRENT_DATE) ) ) + date( 'Y-m-d', strtotime( 'midnight', strtotime( self::CURRENT_DATE ) ) ), + date( 'Y-m-d', strtotime( '+60 days midnight', strtotime( self::CURRENT_DATE ) ) ) ); - $this->assertTrue($jsonresponse['disallowLockDaysInRange']); - $this->assertFalse($jsonresponse['countLockDaysInRange']); - $this->assertEquals(0, $jsonresponse['countLockDaysMaxDays']); + $this->assertTrue( $jsonresponse['disallowLockDaysInRange'] ); + $this->assertFalse( $jsonresponse['countLockDaysInRange'] ); + $this->assertEquals( 0, $jsonresponse['countLockDaysMaxDays'] ); //old locations which only have overbooking enabled should not have the countLockDaysInRange set and countLockDaysMaxDays should be 0 - $differentItemId = $this->createItem("Different Item",'publish'); - $oldLocationId = $this->createLocation("Old Location",'publish'); - $otherTimeframe = $this->createBookableTimeFrameIncludingCurrentDay($oldLocationId,$differentItemId); + $differentItemId = $this->createItem( "Different Item", 'publish' ); + $oldLocationId = $this->createLocation( "Old Location", 'publish' ); + $otherTimeframe = $this->createBookableTimeFrameIncludingCurrentDay( $oldLocationId, $differentItemId ); update_post_meta( $oldLocationId, COMMONSBOOKING_METABOX_PREFIX . 'allow_lockdays_in_range', 'on' ); - ClockMock::freeze( new \DateTime( self::CURRENT_DATE )); + ClockMock::freeze( new \DateTime( self::CURRENT_DATE ) ); $jsonresponse = Calendar::getCalendarDataArray( $differentItemId, $oldLocationId, - date( 'Y-m-d', strtotime( '-1 days', strtotime(self::CURRENT_DATE) ) ), - date( 'Y-m-d', strtotime( '+60 days midnight', strtotime(self::CURRENT_DATE) ) ) + date( 'Y-m-d', strtotime( '-1 days', strtotime( self::CURRENT_DATE ) ) ), + date( 'Y-m-d', strtotime( '+60 days midnight', strtotime( self::CURRENT_DATE ) ) ) ); - $this->assertFalse($jsonresponse['disallowLockDaysInRange']); - $this->assertFalse($jsonresponse['countLockDaysInRange']); - $this->assertEquals(0, $jsonresponse['countLockDaysMaxDays']); + $this->assertFalse( $jsonresponse['disallowLockDaysInRange'] ); + $this->assertFalse( $jsonresponse['countLockDaysInRange'] ); + $this->assertEquals( 0, $jsonresponse['countLockDaysMaxDays'] ); } public function testBookingOffset() { - ClockMock::freeze( new \DateTime( self::CURRENT_DATE )); - $startDate = date( 'Y-m-d', strtotime( '-1 day', strtotime(self::CURRENT_DATE) ) ); - $today = date( 'Y-m-d', strtotime( self::CURRENT_DATE ) ); - $endDate = date( 'Y-m-d', strtotime( '+60 days midnight', strtotime(self::CURRENT_DATE) ) ); - $otherItemId = $this->createItem("Other Item",'publish'); - $otherLocationId = $this->createLocation("Other Location",'publish'); - $offsetTF = $this->createTimeframe( + ClockMock::freeze( new \DateTime( self::CURRENT_DATE ) ); + $startDate = date( 'Y-m-d', strtotime( '-1 day', strtotime( self::CURRENT_DATE ) ) ); + $today = date( 'Y-m-d', strtotime( self::CURRENT_DATE ) ); + $endDate = date( 'Y-m-d', strtotime( '+60 days midnight', strtotime( self::CURRENT_DATE ) ) ); + $otherItemId = $this->createItem( "Other Item", 'publish' ); + $otherLocationId = $this->createLocation( "Other Location", 'publish' ); + $offsetTF = $this->createTimeframe( $otherLocationId, $otherItemId, - strtotime($startDate), - strtotime($endDate), + strtotime( $startDate ), + strtotime( $endDate ), \CommonsBooking\Wordpress\CustomPostType\Timeframe::BOOKABLE_ID, "on", 'd', @@ -146,7 +147,7 @@ public function testBookingOffset() { 30, 2 ); - $jsonresponse = Calendar::getCalendarDataArray( + $jsonresponse = Calendar::getCalendarDataArray( $otherItemId, $otherLocationId, $startDate, @@ -154,37 +155,199 @@ public function testBookingOffset() { ); //considering the advance booking days $days = $jsonresponse['days']; - $this->assertEquals(32, count($days)); + $this->assertEquals( 32, count( $days ) ); //considering the offset, today and tomorrow should be locked - $this->assertTrue($days[$today]['locked']); - $this->assertTrue($days[date('Y-m-d', strtotime('+1 day', strtotime($today)))]['locked']); + $this->assertTrue( $days[ $today ]['locked'] ); + $this->assertTrue( $days[ date( 'Y-m-d', strtotime( '+1 day', strtotime( $today ) ) ) ]['locked'] ); } public function testRenderTable() { - $calendar = Calendar::renderTable([]); - $item = new \CommonsBooking\Model\Item($this->itemId); - $location = new \CommonsBooking\Model\Location($this->locationId); - $this->assertStringContainsString('assertStringContainsString($item->post_title, $calendar); - $this->assertStringContainsString($location->post_title, $calendar); + $calendar = Calendar::renderTable( [] ); + $item = new \CommonsBooking\Model\Item( $this->itemId ); + $location = new \CommonsBooking\Model\Location( $this->locationId ); + $this->assertStringContainsString( 'assertStringContainsString( $item->post_title, $calendar ); + $this->assertStringContainsString( $location->post_title, $calendar ); //in a year, all timeframes will have expired -> calendar should be empty $inAYear = new \DateTime(); - $inAYear->modify('+1 year'); - ClockMock::freeze($inAYear); - $calendar = Calendar::renderTable([]); - $this->assertStringContainsString('No items found', $calendar); + $inAYear->modify( '+1 year' ); + ClockMock::freeze( $inAYear ); + $calendar = Calendar::renderTable( [] ); + $this->assertStringContainsString( 'No items found', $calendar ); + } + + /** + * + * @return array + */ + public function provideGetClosestBookableTimeFrameForToday() { + $currentTimestamp = strtotime( self::CURRENT_DATE . ' 12:00' ); + //will define an array with settings for the timeframes + //that the getClosestBookableTimeFrameForToday function will be tested against + //you can provide the name of the test, the closest timeframe and another timeframe. + //supported arguments for timeframe, if not specified default values will be used + //repetition, repetition_start, repetition_end = null,weekdays = ["1","2","3","4","5","6","7"], start_time = '8:00 AM', end_time = '12:00 PM' + //if no start and endtime are provided, the timeframe will span the full day. + //If they are provided, fullday is turned off. + //Please note: The date that we test against is a thursday. + return [ + "daily not overlapping" => [ + "closest" => [ + "repetition" => "d", + "repetition_start" => strtotime( "-7 days", $currentTimestamp ), + "repetition_end" => strtotime( "+7 days", $currentTimestamp ), + ], + "other" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+8 days", $currentTimestamp ), + "repetition_end" => strtotime( "+14 days", $currentTimestamp ), + ] + ], + "weekly (different weekdays)" => [ + "closest" => [ + "repetition" => "w", + "repetition_start" => strtotime( "-7 days", $currentTimestamp ), + "repetition_end" => strtotime( "+7 days", $currentTimestamp ), + "weekdays" => [ "4" ] //just thursday + ], + "other" => [ + "repetition" => "w", + "repetition_start" => strtotime( "-7 days", $currentTimestamp ), + "repetition_end" => strtotime( "+7 days", $currentTimestamp ), + "weekdays" => [ "1", "2", "3", "5", "6", "7" ] //all but thursday + ] + ], + "both timeframes in future (daily rep)" => [ + "closest" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+7 days", $currentTimestamp ), + "repetition_end" => strtotime( "+14 days", $currentTimestamp ), + ], + "other" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+15 days", $currentTimestamp ), + "repetition_end" => strtotime( "+21 days", $currentTimestamp ), + ] + ], + "weekly and daily" => [ + "closest" => [ + "repetition" => "w", + "repetition_start" => strtotime( "-64 days", $currentTimestamp ), + "repetition_end" => strtotime( "+199 days", $currentTimestamp ), + "weekdays" => [ "1", "2", "3", "4", "5" ] + ], + "other" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+21 days", $currentTimestamp ), + "repetition_end" => strtotime( "+21 days", $currentTimestamp ) + ] + ], + "daily overlap with different times (present)" => [ + "closest" => [ + "repetition" => "d", + "repetition_start" => strtotime( "-1 days", $currentTimestamp ), + "repetition_end" => strtotime( "+1 days", $currentTimestamp ), + "start_time" => "8:00 AM", + "end_time" => "01:00 PM" + ], + "other" => [ + "repetition" => "d", + "repetition_start" => strtotime( "-1 days", $currentTimestamp ), + "repetition_end" => strtotime( "+1 days", $currentTimestamp ), + "start_time" => "02:00 PM", + "end_time" => "06:00 PM" + ] + ], + "daily overlap with different times (future)" => [ + "closest" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+5 days", $currentTimestamp ), + "repetition_end" => strtotime( "+7 days", $currentTimestamp ), + "start_time" => "8:00 AM", + "end_time" => "01:00 PM" + ], + "other" => [ + "repetition" => "d", + "repetition_start" => strtotime( "+5 days", $currentTimestamp ), + "repetition_end" => strtotime( "+7 days", $currentTimestamp ), + "start_time" => "02:00 PM", + "end_time" => "06:00 PM" + ], + ] + ]; + } + + /** + * These are the tests for timeframes with daily repetition + * @return void + * @throws \Exception + * @dataProvider provideGetClosestBookableTimeFrameForToday + */ + public function testGetClosestBookableTimeFrameForToday( array $closest, array $other ) { + $testItem = $this->createItem( "Item" ); + $testLocation = $this->createLocation( "Location" ); + $currentTime = new \DateTime( self::CURRENT_DATE ); + $currentTime->setTime( 12, 0 ); + //Time set to '01.07.2021 12:00' + ClockMock::freeze( $currentTime ); + $expectedClosestTimeframe = $this->createTimeframeFromConfig( "closest timeframe", $testItem, $testLocation, $closest ); + $otherTimeframe = $this->createTimeframeFromConfig( "other timeframe", $testItem, $testLocation, $other ); + $closestTimeframe = Calendar::getClosestBookableTimeFrameForToday( [ + $expectedClosestTimeframe, + $otherTimeframe + ] ); + $this->assertEquals( $expectedClosestTimeframe->ID, $closestTimeframe->ID ); + } + + /** + * Will create the timeframes from the configuration defined in the dataProvider of testGetClosestBookableTimeFrameForToday + * + * @param int $itemId + * @param int $locationID + * @param array $config + * + * @return void + */ + private function createTimeframeFromConfig( string $name, int $itemId, int $locationID, array $config ): Timeframe { + $fullDay = ! ( isset ( $config["start_time"] ) && isset( $config["end_time"] ) ); + $grid = $fullDay ? 1 : 0; //Currently, grid is becoming hourly when not full day (TODO: Also test slots) + + return new Timeframe( + $this->createTimeframe( + $locationID, + $itemId, + $config["repetition_start"], + $config["repetition_end"] ?? null, + \CommonsBooking\Wordpress\CustomPostType\Timeframe::BOOKABLE_ID, + $fullDay ? "on" : "off", + $config["repetition"], + $grid, + $config["start_time"] ?? '8:00 AM', + $config["end_time"] ?? '12:00 PM', + "publish", + $config["weekdays"] ?? [ "1", "2", "3", "4", "5", "6", "7" ], + "", + self::USER_ID, + 3, + 30, + 0, + "on", + "on", + $name + ) + ); } - protected function setUp() : void { + protected function setUp(): void { parent::setUp(); - $now = time(); + $this->now = time(); $this->timeframeId = $this->createTimeframe( $this->locationId, $this->itemId, - strtotime( '+' . self::timeframeStart . ' days midnight', $now ), - strtotime( '+' . self::timeframeEnd . ' days midnight', $now ) + strtotime( '+' . self::timeframeStart . ' days midnight', $this->now ), + strtotime( '+' . self::timeframeEnd . ' days midnight', $this->now ) ); // set booking days in advance update_post_meta( $this->timeframeId, Timeframe::META_TIMEFRAME_ADVANCE_BOOKING_DAYS, self::bookingDaysInAdvance );