=$b){$next[]=[$a,$b];continue;}if($a<$start)$next[]=[$a,min($start,$b)];if($end<$b)$next[]=[max($end,$a),$b];}$intervals=$next;}return $intervals; } /** Split actual instants at hour boundaries; repeated DST hours both contribute. */ public static function hourParts(int $start,int $end,callable $consume): void { while($start<$end){$local=Time::timestamp($start);$next=min($end,$start+3600-(int)$local->format('i')*60-(int)$local->format('s'));$consume((int)$local->format('N'),(int)$local->format('G'),$next-$start,$local->format('Y-m-d'));$start=$next;} } public function calculate(array $resources,array $data,StatisticsFilter $f): array { $hours=[];$exceptions=[];$blocks=[]; foreach(['hours','exceptions'] as $kind)foreach($data[$kind] as $row){$row['resource_id']=$row['resource_id']===null?null:(int)$row['resource_id'];if($kind==='hours')$hours[(int)$row['day_of_week']][]=$row;else $exceptions[$row['exception_date']][]=$row;} foreach($data['blocks'] as $row)$blocks[$row['resource_id']??0][]=[Time::fromSql($row['starts_at']),Time::fromSql($row['ends_at'])]; $capacity=[];$heat=array_fill(0,168,0);$last=$f->values['date_to']; foreach($resources as $r){$id=(int)$r['id'];$capacity[$id]=['available_seconds'=>0,'blocked_seconds'=>0];if(!$r['is_active']||!$r['is_visible']||!$r['is_bookable'])continue; for($day=Time::day($f->values['date_from']);$day&&$day->format('Y-m-d')<=$last;$day=Time::nextDay($day->format('Y-m-d'))){$date=$day->format('Y-m-d');$intervals=$this->schedule->intervalsFromRows($id,$date,$hours[(int)$day->format('N')]??[],$exceptions[$date]??[]);$free=self::subtract($intervals,array_merge($blocks[0]??[],$blocks[$id]??[])); $original=0;foreach($intervals as [$a,$b])$original+=$b-$a;$available=0; foreach($free as [$a,$b]){$available+=$b-$a;self::hourParts($a,$b,static function($w,$h,$s)use(&$heat){$heat[($w-1)*24+$h]+=$s;});} $capacity[$id]['available_seconds']+=$available;$capacity[$id]['blocked_seconds']+=$original-$available; } } return ['resources'=>$capacity,'heat'=>$heat]; } }