chill-bundles/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php

170 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Util;
/**
* Utilities to compare date periods.
*
* This class allow to compare periods when there are period covering. The
* argument `minCovers` allow to find also when there are more than 2 period
* which intersects.
*
* Example: a team may have maximum 2 leaders on a same period: you will
* find here all periods where there are more than 2 leaders.
*
* Usage:
*
* ```php
* $cover = new DateRangeCovering(2); // 2 means we will have periods
* // when there are 2+ periods intersecting
* $cover
* ->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;
}
}
}