diff --git a/Model/OccurrenceCalculator.php b/Model/OccurrenceCalculator.php index 8e8fd65..005eeb7 100644 --- a/Model/OccurrenceCalculator.php +++ b/Model/OccurrenceCalculator.php @@ -5,40 +5,92 @@ namespace Kanboard\Plugin\RecoReco\Model; /** * Pure calendar math for RecoReco. * - * Given an anchor due date (the drift-free pattern: day-of-month + time), a frequency, and the - * "last day" flag, it computes occurrence datetimes. It has NO container dependencies, so it is - * unit-testable in isolation (see Test/OccurrenceCalculatorTest.php). + * 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's day, never by marching a clamped date forward, - * so a "30th" schedule goes 30, 28/29, 30, 30... and never drifts to the 28th. - * - * v0.3 implements monthly-by-day; the other frequencies arrive in v1.1 (occurrenceFrom returns null - * for them until then). + * 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 (day-of-month + time). - * @param string $frequency + * @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 if the frequency is not yet supported. + * @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((int) $anchor, (bool) $lastDay, (int) $reference, (bool) $inclusive); + 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); @@ -52,19 +104,103 @@ class OccurrenceCalculator 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); - $occurrence = mktime($h, $i, $s, $m, $day, $y); + $o = mktime($h, $i, $s, $m, $day, $y); - if ($inclusive ? $occurrence >= $reference : $occurrence > $reference) { - return $occurrence; + if ($this->matches($o, $reference, $inclusive)) { + return $o; } - $m++; - if ($m > 12) { - $m = 1; - $y++; - } + $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++; + } + } } diff --git a/Model/RecoRecoModel.php b/Model/RecoRecoModel.php index a848004..257a073 100644 --- a/Model/RecoRecoModel.php +++ b/Model/RecoRecoModel.php @@ -74,8 +74,8 @@ class RecoRecoModel extends Base $meta = $this->taskMetadataModel->getAll($task_id); $frequency = isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : ''; - // v0.3 handles monthly-by-day only; the rest arrive in v1.1. - if ($frequency !== 'monthly_day') { + // All five frequencies are supported since v1.1; skip anything unknown. + if (! in_array($frequency, array('daily', 'weekly', 'monthly_day', 'monthly_dow', 'yearly'), true)) { return 0; } diff --git a/Plugin.php b/Plugin.php index 82973e5..3cf350e 100644 --- a/Plugin.php +++ b/Plugin.php @@ -36,7 +36,7 @@ class Plugin extends Base public function getPluginVersion() { - return '1.0.0'; + return '1.1.0'; } public function getPluginHomepage() diff --git a/Test/OccurrenceCalculatorTest.php b/Test/OccurrenceCalculatorTest.php index 61e6675..1442dd3 100644 --- a/Test/OccurrenceCalculatorTest.php +++ b/Test/OccurrenceCalculatorTest.php @@ -91,10 +91,72 @@ check( $failures ); -// 6. Unsupported frequency (until v1.1) returns null. +// 6. Daily: same time every day. check( - 'weekly not supported yet -> null', - $calc->occurrenceFrom(ts('2023-03-10 12:00'), 'weekly', false, ts('2023-03-10 12:00'), false), + 'daily -> next day same time', + $calc->occurrenceFrom(ts('2023-03-10 09:30'), 'daily', false, ts('2023-03-10 12:00'), false), + ts('2023-03-11 09:30'), + $failures +); + +// 7. Weekly: same weekday every 7 days (2023-03-07 is a Tuesday). +check( + 'weekly -> +7 days on the same weekday', + $calc->occurrenceFrom(ts('2023-03-07 08:00'), 'weekly', false, ts('2023-03-07 08:00'), false), + ts('2023-03-14 08:00'), + $failures +); + +// 8. Monthly by weekday: 2nd Tuesday (2023-03-14) -> 2nd Tuesday of April (2023-04-11). +check( + 'monthly-by-weekday: 2nd Tuesday -> next month 2nd Tuesday', + $calc->occurrenceFrom(ts('2023-03-14 07:00'), 'monthly_dow', false, ts('2023-03-14 07:00'), false), + ts('2023-04-11 07:00'), + $failures +); + +// 9. Monthly by weekday, last: last Tuesday of March (2023-03-28) -> last Tuesday of April (04-25). +check( + 'monthly-by-weekday last: last Tue -> next month last Tue', + $calc->occurrenceFrom(ts('2023-03-28 07:00'), 'monthly_dow', true, ts('2023-03-28 07:00'), false), + ts('2023-04-25 07:00'), + $failures +); + +// 10. Yearly: same month/day next year. +check( + 'yearly -> next year same date', + $calc->occurrenceFrom(ts('2023-03-15 10:00'), 'yearly', false, ts('2023-03-15 10:00'), false), + ts('2024-03-15 10:00'), + $failures +); + +// 11. Yearly last-day on Feb 29 (leap) -> next year Feb 28. +check( + 'yearly last-day: 2024-02-29 -> 2025-02-28', + $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', true, ts('2024-02-29 08:00'), false), + ts('2025-02-28 08:00'), + $failures +); + +// 12. Yearly numeric Feb 29 -> clamps to 28 in a non-leap year, back to 29 in the next leap year. +check( + 'yearly numeric 29-Feb -> 2025 clamps to 28', + $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2024-02-29 08:00'), false), + ts('2025-02-28 08:00'), + $failures +); +check( + 'yearly numeric 29-Feb -> 2028 back to 29 (leap)', + $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2027-03-01 00:00'), false), + ts('2028-02-29 08:00'), + $failures +); + +// 13. Unknown frequency returns null. +check( + 'unknown frequency -> null', + $calc->occurrenceFrom(ts('2023-03-10 12:00'), 'nope', false, ts('2023-03-10 12:00'), false), null, $failures ); diff --git a/VERSION b/VERSION index 0406fea..f6d943c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -RecoReco v1.0 +RecoReco v1.1