settings->get()['public_calendar_visibility']==='hidden')throw new CatalogException('abece_booking_calendar_hidden',__('Log in to view the calendar.','abece-booking'),401); $now=$clock?DateTimeImmutable::createFromInterface($clock)->setTimezone(wp_timezone()):new DateTimeImmutable('now',wp_timezone()); $date=MembershipValidator::date($date??$now->format('Y-m-d'),'date'); if($sport!==null){CatalogValidator::integer($sport,'sport_id',1,PHP_INT_MAX);if(!$this->sports->find($sport))throw new CatalogException('abece_booking_not_found',__('The selected sport does not exist.','abece-booking'),404);} if(!$admin&&$date<$now->format('Y-m-d'))throw new CatalogException('abece_booking_calendar_past',__('The client calendar starts today.','abece-booking'),400,'date'); $day=Time::day($date);$next=Time::nextDay($date); if(!$day||!$next||$next<=$day)CatalogValidator::invalid('date',__('This calendar date does not exist in the WordPress timezone.','abece-booking')); $resources=$this->repository->resources($sport,$admin);$ids=array_column($resources,'id'); $bookings=$this->repository->bookings($ids,Time::sql($day),Time::sql($next),Time::sql($now)); $blocks=$this->repository->blocks($ids,Time::sql($day),Time::sql($next)); $daily=!$admin&&$viewer&&$this->eligibility?$this->eligibility->daily($viewer,$date):null; $bounds=[];$output=[]; foreach($resources as $resource){ $id=(int)$resource['id'];$publicTitle=$this->schedule->publicTitle($id,$date);$open=$this->schedule->intervals($id,$date);$allowed=[];$accessReason=[]; if($viewer){ // Admin cells represent potential member/non-member starts. The selected customer's existing workflow rechecks availability. foreach($admin?[false,true]:[null] as $member){ $available=$this->availability->available($id,$date,$viewer,$now,null,$member,$admin);if(!$admin&&isset($available['booking_restriction_code']))$accessReason=array_intersect_key($available,array_flip(['booking_restriction_code','booking_restriction_message','restriction_starts_after']));foreach($available['intervals'] as $slot)$allowed[$slot['starts_at']]=$slot; } } $r=['id'=>$id,'name'=>$resource['name'],'sport_id'=>$resource['sport_id']===null?null:(int)$resource['sport_id'],'sport_name'=>$resource['sport_name'],'surface'=>$resource['surface'],'capacity'=>$resource['capacity']===null?null:(int)$resource['capacity'],'booking_step_minutes'=>(int)$resource['booking_step_minutes'],'exception_title'=>$publicTitle,'opening_intervals'=>array_map(fn($i)=>['starts_at'=>Time::iso($i[0]),'ends_at'=>Time::iso($i[1])],$open),'slots'=>[],'bookings'=>[],'blocks'=>[]]; foreach($bookings as $b){if((int)$b['resource_id']!==$id)continue; $own=$viewer>0&&(int)$b['customer_user_id']===$viewer; // Whitelist, never trim a serialized full booking. Foreign records have no ID, reference or identity fields. $item=['resource_id'=>$id,'starts_at'=>Time::iso(Time::fromSql($b['starts_at'])),'ends_at'=>Time::iso(Time::fromSql($b['ends_at'])),'state'=>$own?'own':'busy','is_own'=>$own,'label'=>$own?__('My booking','abece-booking'):__('Busy','abece-booking')]; if($admin||$own){$item['is_recurring']=!empty($b['series_id']);$item+=['id'=>(int)$b['id'],'reference'=>$b['booking_reference'],'status'=>$b['status']];} if($admin){ $label=$b['external_customer_label']?:trim(($b['first_name']??'').' '.($b['last_name']??'')); $label=$label?:$b['display_name'];$label=$label?:$b['booking_reference']; $label=sanitize_text_field(explode("\n",$label)[0]);$item['label']=mb_substr($label,0,48).(mb_strlen($label)>48?'…':''); $item['booking_source']=$b['booking_source']; } $r['bookings'][]=$item;$bounds[]=[max($day->getTimestamp(),Time::fromSql($b['starts_at'])),min($next->getTimestamp(),Time::fromSql($b['ends_at']))]; } foreach($blocks as $b){if($b['resource_id']!==null&&(int)$b['resource_id']!==$id)continue; $item=['resource_id'=>$id,'starts_at'=>Time::iso(Time::fromSql($b['starts_at'])),'ends_at'=>Time::iso(Time::fromSql($b['ends_at'])),'state'=>'blocked','label'=>$b['public_title']?:($admin?__('Resource block','abece-booking'):__('Unavailable','abece-booking'))]; if($admin)$item+=['id'=>(int)$b['id'],'block_type'=>$b['block_type'],'reason'=>sanitize_text_field($b['reason']??'')]; $r['blocks'][]=$item;$bounds[]=[max($day->getTimestamp(),Time::fromSql($b['starts_at'])),min($next->getTimestamp(),Time::fromSql($b['ends_at']))]; } foreach($open as $i)$bounds[]=$i; $output[]=['resource'=>$r,'open'=>$open,'allowed'=>$allowed,'access_reason'=>$accessReason,'online_allowed'=>$admin||\ABeCe\Booking\Edition\Modules::current()->publicCanBook($id)]; } $start=$bounds?min(array_column($bounds,0)):null;$end=$bounds?max(array_column($bounds,1)):null; $ticks=[]; if($start!==null&&$end>$start){ // A shared elapsed-time axis with resource-specific wall-time steps also handles 7/15/30-minute grids and DST folds. for($t=$day->getTimestamp();$t<$end;$t+=60){$local=Time::timestamp($t);if($t>=$start&&(int)$local->format('i')%30===0)$ticks[]=['at'=>Time::iso($t),'label'=>$local->format('H:i'),'offset'=>$local->format('P')];} foreach($output as &$row){ $r=&$row['resource'];$step=$r['booking_step_minutes'];if($step<1)throw new CatalogException('abece_booking_invalid_booking_grid',__('The resource booking time grid is invalid.','abece-booking'),400); $points=[$start]; for($t=$day->getTimestamp();$t<$end;$t+=60){$local=Time::timestamp($t);if($t>$start&&((int)$local->format('H')*60+(int)$local->format('i'))%$step===0)$points[]=$t;} // Split display intervals at real boundaries as well as resource step starts. foreach($row['open'] as [$a,$b])foreach([$a,$b] as $boundary)if($boundary>$start&&$boundary<$end)$points[]=$boundary; foreach(array_merge($r['bookings'],$r['blocks']) as $event)foreach(['starts_at','ends_at'] as $field){$boundary=strtotime($event[$field]);if($boundary>$start&&$boundary<$end)$points[]=$boundary;} $points[]=$end;$points=array_values(array_unique($points));sort($points,SORT_NUMERIC); for($i=0;$i$a){$state='free';break;} if($from<=$now->getTimestamp())$state='past'; foreach($r['blocks'] as $event)if($fromstrtotime($event['starts_at'])){$state='blocked';$occupied=true;break;} foreach($r['bookings'] as $event)if($fromstrtotime($event['starts_at'])){$state=$event['state'];$occupied=true;break;} $can=$viewer>0&&!$occupied&&isset($row['allowed'][$iso]); if(!$occupied&&$from>=$now->getTimestamp()&&$state==='free'&&$viewer&&!$can)$state='unavailable'; // Anonymous free describes physical occupancy only; it grants no booking permission. if(!$admin&&!$row['online_allowed'])$can=false; $decision=['can_book_online'=>$can,'booking_restriction_code'=>null,'booking_restriction_message'=>null]; if(!$admin&&$this->eligibility&&!$occupied&&$from>$now->getTimestamp()&&($can||(!$viewer&&$state==='free'))){$decision=$this->eligibility->decision($from,$daily,$now);if(!$decision['can_book_online']){$can=false;$state=$decision['booking_restriction_code']==='abece_booking_online_booking_closed'?'online_closed':'unavailable';}} if(!$admin&&!$can&&$state==='unavailable'&&$row['access_reason']&&(!isset($row['access_reason']['restriction_starts_after'])||$from>$row['access_reason']['restriction_starts_after']))$decision=array_replace($decision,array_intersect_key($row['access_reason'],array_flip(['booking_restriction_code','booking_restriction_message']))); $decision['can_book_online']=$can; $r['slots'][]=$decision+['starts_at'=>$iso,'ends_at'=>Time::iso($to),'state'=>$state,'can_book'=>$can,'availability'=>$can?$row['allowed'][$iso]:null]; } unset($r); }unset($row); } $sports=[]; foreach($this->repository->resources(null,$admin) as $r)if($r['sport_id']!==null)$sports[(int)$r['sport_id']]=['id'=>(int)$r['sport_id'],'name'=>$r['sport_name']??'']; return ['date'=>$date,'today'=>$now->format('Y-m-d'),'previous_date'=>$day->modify('-1 day')->format('Y-m-d'),'next_date'=>$next->format('Y-m-d'),'timezone'=>wp_timezone()->getName(),'locale'=>\ABeCe\Booking\Support\LocaleContext::current(), 'time_format'=>get_option('time_format'),'now'=>Time::iso($now->getTimestamp()),'admin'=>$admin,'authenticated'=>$viewer>0,'sports'=>array_values($sports),'grid'=>['starts_at'=>$start===null?null:Time::iso($start),'ends_at'=>$end===null?null:Time::iso($end),'header_step_minutes'=>30,'resource_specific_steps'=>true,'ticks'=>$ticks],'resources'=>array_column($output,'resource')]; } }