207 lines
6.4 KiB
PHP
207 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace Kanboard\Plugin\RecoReco\Model;
|
|
|
|
/**
|
|
* Pure calendar math for RecoReco.
|
|
*
|
|
* Given an anchor due date (the drift-free pattern: it carries the day-of-month, the weekday, and
|
|
* the time), a frequency, and the "last day" flag, it computes occurrence datetimes. It has NO
|
|
* container dependencies, so it is unit-testable in isolation (Test/OccurrenceCalculatorTest.php).
|
|
*
|
|
* Occurrences are always computed from the anchor, never by marching a clamped date forward, so a
|
|
* "30th" schedule goes 30, 28/29, 30, 30... and never drifts.
|
|
*/
|
|
class OccurrenceCalculator
|
|
{
|
|
const MAX_MONTHS = 120;
|
|
const MAX_YEARS = 20;
|
|
|
|
/**
|
|
* The earliest occurrence at/after $reference (when $inclusive) or strictly after it.
|
|
*
|
|
* @param int $anchor Unix timestamp: the pattern.
|
|
* @param string $frequency daily | weekly | monthly_day | monthly_dow | yearly
|
|
* @param bool $lastDay
|
|
* @param int $reference Unix timestamp.
|
|
* @param bool $inclusive
|
|
* @return int|null Occurrence timestamp, or null for an unknown frequency.
|
|
*/
|
|
public function occurrenceFrom($anchor, $frequency, $lastDay, $reference, $inclusive = false)
|
|
{
|
|
$anchor = (int) $anchor;
|
|
$reference = (int) $reference;
|
|
$lastDay = (bool) $lastDay;
|
|
$inclusive = (bool) $inclusive;
|
|
|
|
switch ($frequency) {
|
|
case 'daily':
|
|
return $this->daily($anchor, $reference, $inclusive);
|
|
case 'weekly':
|
|
return $this->weekly($anchor, $reference, $inclusive);
|
|
case 'monthly_day':
|
|
return $this->monthlyByDay($anchor, $lastDay, $reference, $inclusive);
|
|
case 'monthly_dow':
|
|
return $this->monthlyByWeekday($anchor, $lastDay, $reference, $inclusive);
|
|
case 'yearly':
|
|
return $this->yearly($anchor, $lastDay, $reference, $inclusive);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function daily($anchor, $reference, $inclusive)
|
|
{
|
|
$h = (int) date('G', $anchor);
|
|
$i = (int) date('i', $anchor);
|
|
$s = (int) date('s', $anchor);
|
|
$y = (int) date('Y', $reference);
|
|
$m = (int) date('n', $reference);
|
|
$d = (int) date('j', $reference);
|
|
|
|
for ($k = 0; $k < 3; $k++) {
|
|
$o = mktime($h, $i, $s, $m, $d + $k, $y);
|
|
|
|
if ($this->matches($o, $reference, $inclusive)) {
|
|
return $o;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function weekly($anchor, $reference, $inclusive)
|
|
{
|
|
$h = (int) date('G', $anchor);
|
|
$i = (int) date('i', $anchor);
|
|
$s = (int) date('s', $anchor);
|
|
$w = (int) date('w', $anchor);
|
|
$y = (int) date('Y', $reference);
|
|
$m = (int) date('n', $reference);
|
|
$d = (int) date('j', $reference);
|
|
|
|
for ($k = 0; $k < 8; $k++) {
|
|
$o = mktime($h, $i, $s, $m, $d + $k, $y);
|
|
|
|
if ((int) date('w', $o) === $w && $this->matches($o, $reference, $inclusive)) {
|
|
return $o;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function monthlyByDay($anchor, $lastDay, $reference, $inclusive)
|
|
{
|
|
$anchorDay = (int) date('j', $anchor);
|
|
$h = (int) date('G', $anchor);
|
|
$i = (int) date('i', $anchor);
|
|
$s = (int) date('s', $anchor);
|
|
|
|
$y = (int) date('Y', $reference);
|
|
$m = (int) date('n', $reference);
|
|
|
|
for ($k = 0; $k < self::MAX_MONTHS; $k++) {
|
|
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
|
|
$day = $lastDay ? $lastDom : min($anchorDay, $lastDom);
|
|
$o = mktime($h, $i, $s, $m, $day, $y);
|
|
|
|
if ($this->matches($o, $reference, $inclusive)) {
|
|
return $o;
|
|
}
|
|
|
|
$this->nextMonth($m, $y);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function monthlyByWeekday($anchor, $lastDay, $reference, $inclusive)
|
|
{
|
|
$h = (int) date('G', $anchor);
|
|
$i = (int) date('i', $anchor);
|
|
$s = (int) date('s', $anchor);
|
|
$w = (int) date('w', $anchor);
|
|
$k = intdiv((int) date('j', $anchor) - 1, 7) + 1; // 1..5: which occurrence of $w in the month
|
|
|
|
$y = (int) date('Y', $reference);
|
|
$m = (int) date('n', $reference);
|
|
|
|
for ($j = 0; $j < self::MAX_MONTHS; $j++) {
|
|
$day = $lastDay ? $this->lastWeekdayOfMonth($y, $m, $w) : $this->nthWeekdayOfMonth($y, $m, $w, $k);
|
|
|
|
if ($day !== null) {
|
|
$o = mktime($h, $i, $s, $m, $day, $y);
|
|
|
|
if ($this->matches($o, $reference, $inclusive)) {
|
|
return $o;
|
|
}
|
|
}
|
|
|
|
$this->nextMonth($m, $y);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function yearly($anchor, $lastDay, $reference, $inclusive)
|
|
{
|
|
$month = (int) date('n', $anchor);
|
|
$anchorDay = (int) date('j', $anchor);
|
|
$h = (int) date('G', $anchor);
|
|
$i = (int) date('i', $anchor);
|
|
$s = (int) date('s', $anchor);
|
|
|
|
$y = (int) date('Y', $reference);
|
|
|
|
for ($j = 0; $j < self::MAX_YEARS; $j++) {
|
|
$lastDom = (int) date('t', mktime(0, 0, 0, $month, 1, $y));
|
|
$day = $lastDay ? $lastDom : min($anchorDay, $lastDom);
|
|
$o = mktime($h, $i, $s, $month, $day, $y);
|
|
|
|
if ($this->matches($o, $reference, $inclusive)) {
|
|
return $o;
|
|
}
|
|
|
|
$y++;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// The day-of-month of the k-th weekday $w in month $m/$y, or null if that month has no k-th one.
|
|
private function nthWeekdayOfMonth($y, $m, $w, $k)
|
|
{
|
|
$firstDow = (int) date('w', mktime(0, 0, 0, $m, 1, $y));
|
|
$firstW = 1 + (($w - $firstDow + 7) % 7);
|
|
$day = $firstW + ($k - 1) * 7;
|
|
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
|
|
|
|
return $day <= $lastDom ? $day : null;
|
|
}
|
|
|
|
// The day-of-month of the last weekday $w in month $m/$y.
|
|
private function lastWeekdayOfMonth($y, $m, $w)
|
|
{
|
|
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
|
|
$lastDow = (int) date('w', mktime(0, 0, 0, $m, $lastDom, $y));
|
|
|
|
return $lastDom - (($lastDow - $w + 7) % 7);
|
|
}
|
|
|
|
private function matches($occurrence, $reference, $inclusive)
|
|
{
|
|
return $inclusive ? $occurrence >= $reference : $occurrence > $reference;
|
|
}
|
|
|
|
private function nextMonth(&$m, &$y)
|
|
{
|
|
$m++;
|
|
|
|
if ($m > 12) {
|
|
$m = 1;
|
|
$y++;
|
|
}
|
|
}
|
|
}
|