add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) * ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) * ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) * ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) * ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) * ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) * ->compute() * ; * $cover->getIntersections(); * ``` */ class DateRangeCovering { private bool $computed = false; private array $intersections = []; private array $intervals = []; private array $metadatas = []; private int $minCover; private array $sequence = []; private DateTimeZone $tz; private int $uniqueKeyCounter = 0; /** * @param int $minCover the minimum of covering required */ public function __construct(int $minCover, DateTimeZone $tz) { if (0 > $minCover) { throw new LogicException('argument minCover cannot be lower than 0'); } $this->minCover = $minCover; $this->tz = $tz; } public function add(DateTimeInterface $start, ?DateTimeInterface $end = null, $metadata = null): self { if ($this->computed) { throw new LogicException('You cannot add intervals to a computed instance'); } $k = $this->uniqueKeyCounter++; $this->intervals[$k] = [$start, $end]; $this->metadatas[$k] = $metadata; $this->addToSequence($start->getTimestamp(), $k, null); $this->addToSequence( null === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k ); return $this; } public function compute(): self { ksort($this->sequence); $currentPeriod = []; $currents = []; $isOpen = false; $overs = []; foreach ($this->sequence as $ts => $moves) { $currents = array_merge($currents, $moves['s']); $currents = array_diff($currents, $moves['e']); if (count($currents) > $this->minCover && !$isOpen) { $currentPeriod[0] = $ts; $currentPeriod[2] = $currents; $isOpen = true; } elseif ($isOpen && count($currents) <= $this->minCover) { $currentPeriod[1] = $ts; $overs[] = $currentPeriod; $currentPeriod = []; $isOpen = false; } elseif ($isOpen) { $currentPeriod[2] = array_merge($currentPeriod[2], $currents); } } // process metadata foreach ($overs as [$start, $end, $metadata]) { $this->intersections[] = [ (new DateTimeImmutable('@' . $start)) ->setTimezone($this->tz), PHP_INT_MAX === $end ? null : (new DateTimeImmutable('@' . $end)) ->setTimezone($this->tz), array_values( array_intersect_key( $this->metadatas, array_flip(array_unique($metadata)) ) ), ]; } $this->computed = true; return $this; } public function getIntersections(): array { if (!$this->computed) { throw new LogicException(sprintf('You cannot call the method %s before ' . "'process'", __METHOD__)); } return $this->intersections; } public function hasIntersections(): bool { if (!$this->computed) { throw new LogicException(sprintf('You cannot call the method %s before ' . "'process'", __METHOD__)); } return count($this->intersections) > 0; } private function addToSequence($timestamp, ?int $start = null, ?int $end = null) { if (!array_key_exists($timestamp, $this->sequence)) { $this->sequence[$timestamp] = ['s' => [], 'e' => []]; } if (null !== $start) { $this->sequence[$timestamp]['s'][] = $start; } if (null !== $end) { $this->sequence[$timestamp]['e'][] = $end; } } }