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 readonly int $minCover; private array $sequence = []; private int $uniqueKeyCounter = 0; /** * @param int $minCover the minimum of covering required */ public function __construct(int $minCover, private readonly \DateTimeZone $tz) { if (0 > $minCover) { throw new \LogicException('argument minCover cannot be lower than 0'); } $this->minCover = $minCover; } 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; } } }