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 int $minCover; private int $uniqueKeyCounter = 0; private array $metadatas = []; private array $sequence = []; private \DateTimeZone $tz; /** * @param int $minCover the minimum of covering required */ public function __construct(int $minCover, \DateTimeZone $tz) { if ($minCover < 0) { 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; } 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; } } 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 list($start, $end, $metadata)) { $this->intersections[] = [ (new \DateTimeImmutable('@'.$start)) ->setTimezone($this->tz), $end === PHP_INT_MAX ? 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 hasIntersections(): bool { if (!$this->computed) { throw new \LogicException(sprintf("You cannot call the method %s before ". "'process'", __METHOD__)); } return count($this->intersections) > 0; } public function getIntersections(): array { if (!$this->computed) { throw new \LogicException(sprintf("You cannot call the method %s before ". "'process'", __METHOD__)); } return $this->intersections; } }