Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 873df71ed0 | |||
| 18b31e6d62 |
50
Asset/js/recoreco-modal.js
Normal file
50
Asset/js/recoreco-modal.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* RecoReco -- keep the "days before" field within one period of the selected frequency.
|
||||||
|
*
|
||||||
|
* The Recurring schedule modal is injected by AJAX, so we react to the frequency <select> both when
|
||||||
|
* it appears (MutationObserver) and when it changes (delegated event). Daily -> field disabled (0);
|
||||||
|
* weekly -> max 6; monthly -> max 27; yearly -> max 364. The server clamps too, so this is only the
|
||||||
|
* UI guard.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var CAPS = { daily: 0, weekly: 6, monthly_day: 27, monthly_dow: 27, yearly: 364 };
|
||||||
|
|
||||||
|
function apply(select) {
|
||||||
|
var input = document.querySelector('input[name="recoreco_days_before"]');
|
||||||
|
if (! input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = CAPS.hasOwnProperty(select.value) ? CAPS[select.value] : 27;
|
||||||
|
|
||||||
|
if (max === 0) {
|
||||||
|
input.value = 0;
|
||||||
|
input.setAttribute("max", "0");
|
||||||
|
input.disabled = true;
|
||||||
|
} else {
|
||||||
|
input.disabled = false;
|
||||||
|
input.setAttribute("max", String(max));
|
||||||
|
if (parseInt(input.value, 10) > max) {
|
||||||
|
input.value = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("change", function (e) {
|
||||||
|
if (e.target && e.target.name === "recoreco_frequency") {
|
||||||
|
apply(e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.MutationObserver) {
|
||||||
|
new MutationObserver(function () {
|
||||||
|
var select = document.querySelector('select[name="recoreco_frequency"]:not([data-recoreco-init])');
|
||||||
|
if (select) {
|
||||||
|
select.setAttribute("data-recoreco-init", "1");
|
||||||
|
apply(select);
|
||||||
|
}
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -46,12 +46,16 @@ class RecurrenceController extends BaseController
|
|||||||
? $input['recoreco_frequency']
|
? $input['recoreco_frequency']
|
||||||
: 'monthly_day';
|
: 'monthly_day';
|
||||||
|
|
||||||
|
// Keep the lead time under one period (else a copy's fire time crosses the prior occurrence).
|
||||||
|
$daysBefore = isset($input['recoreco_days_before']) && ctype_digit((string) $input['recoreco_days_before']) ? (int) $input['recoreco_days_before'] : 0;
|
||||||
|
$daysBefore = min($daysBefore, $this->maxDaysBefore($frequency));
|
||||||
|
|
||||||
$values = array(
|
$values = array(
|
||||||
'recoreco_enabled' => $enabled,
|
'recoreco_enabled' => $enabled,
|
||||||
'recoreco_target_column' => isset($input['recoreco_target_column']) ? (int) $input['recoreco_target_column'] : (int) $task['column_id'],
|
'recoreco_target_column' => isset($input['recoreco_target_column']) ? (int) $input['recoreco_target_column'] : (int) $task['column_id'],
|
||||||
'recoreco_frequency' => $frequency,
|
'recoreco_frequency' => $frequency,
|
||||||
'recoreco_last_day' => isset($input['recoreco_last_day']) ? 1 : 0,
|
'recoreco_last_day' => isset($input['recoreco_last_day']) ? 1 : 0,
|
||||||
'recoreco_days_before' => isset($input['recoreco_days_before']) && ctype_digit((string) $input['recoreco_days_before']) ? (int) $input['recoreco_days_before'] : 0,
|
'recoreco_days_before' => $daysBefore,
|
||||||
'recoreco_link_copies' => isset($input['recoreco_link_copies']) ? 1 : 0,
|
'recoreco_link_copies' => isset($input['recoreco_link_copies']) ? 1 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,4 +101,22 @@ class RecurrenceController extends BaseController
|
|||||||
'daily' => t('Daily'),
|
'daily' => t('Daily'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest allowed "days before" for a frequency -- kept strictly under one period so a
|
||||||
|
* copy's fire time never crosses the previous occurrence. Daily = 0 (disabled).
|
||||||
|
*/
|
||||||
|
private function maxDaysBefore($frequency)
|
||||||
|
{
|
||||||
|
switch ($frequency) {
|
||||||
|
case 'daily':
|
||||||
|
return 0;
|
||||||
|
case 'weekly':
|
||||||
|
return 6;
|
||||||
|
case 'yearly':
|
||||||
|
return 364;
|
||||||
|
default: // monthly_day, monthly_dow
|
||||||
|
return 27;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,40 +5,92 @@ namespace Kanboard\Plugin\RecoReco\Model;
|
|||||||
/**
|
/**
|
||||||
* Pure calendar math for RecoReco.
|
* Pure calendar math for RecoReco.
|
||||||
*
|
*
|
||||||
* Given an anchor due date (the drift-free pattern: day-of-month + time), a frequency, and the
|
* Given an anchor due date (the drift-free pattern: it carries the day-of-month, the weekday, and
|
||||||
* "last day" flag, it computes occurrence datetimes. It has NO container dependencies, so it is
|
* the time), a frequency, and the "last day" flag, it computes occurrence datetimes. It has NO
|
||||||
* unit-testable in isolation (see Test/OccurrenceCalculatorTest.php).
|
* 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,
|
* Occurrences are always computed from the anchor, never by marching a clamped date forward, so a
|
||||||
* so a "30th" schedule goes 30, 28/29, 30, 30... and never drifts to the 28th.
|
* "30th" schedule goes 30, 28/29, 30, 30... and never drifts.
|
||||||
*
|
|
||||||
* v0.3 implements monthly-by-day; the other frequencies arrive in v1.1 (occurrenceFrom returns null
|
|
||||||
* for them until then).
|
|
||||||
*/
|
*/
|
||||||
class OccurrenceCalculator
|
class OccurrenceCalculator
|
||||||
{
|
{
|
||||||
const MAX_MONTHS = 120;
|
const MAX_MONTHS = 120;
|
||||||
|
const MAX_YEARS = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The earliest occurrence at/after $reference (when $inclusive) or strictly after it.
|
* The earliest occurrence at/after $reference (when $inclusive) or strictly after it.
|
||||||
*
|
*
|
||||||
* @param int $anchor Unix timestamp: the pattern (day-of-month + time).
|
* @param int $anchor Unix timestamp: the pattern.
|
||||||
* @param string $frequency
|
* @param string $frequency daily | weekly | monthly_day | monthly_dow | yearly
|
||||||
* @param bool $lastDay
|
* @param bool $lastDay
|
||||||
* @param int $reference Unix timestamp.
|
* @param int $reference Unix timestamp.
|
||||||
* @param bool $inclusive
|
* @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)
|
public function occurrenceFrom($anchor, $frequency, $lastDay, $reference, $inclusive = false)
|
||||||
{
|
{
|
||||||
|
$anchor = (int) $anchor;
|
||||||
|
$reference = (int) $reference;
|
||||||
|
$lastDay = (bool) $lastDay;
|
||||||
|
$inclusive = (bool) $inclusive;
|
||||||
|
|
||||||
switch ($frequency) {
|
switch ($frequency) {
|
||||||
|
case 'daily':
|
||||||
|
return $this->daily($anchor, $reference, $inclusive);
|
||||||
|
case 'weekly':
|
||||||
|
return $this->weekly($anchor, $reference, $inclusive);
|
||||||
case 'monthly_day':
|
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:
|
default:
|
||||||
return null;
|
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)
|
private function monthlyByDay($anchor, $lastDay, $reference, $inclusive)
|
||||||
{
|
{
|
||||||
$anchorDay = (int) date('j', $anchor);
|
$anchorDay = (int) date('j', $anchor);
|
||||||
@@ -52,19 +104,103 @@ class OccurrenceCalculator
|
|||||||
for ($k = 0; $k < self::MAX_MONTHS; $k++) {
|
for ($k = 0; $k < self::MAX_MONTHS; $k++) {
|
||||||
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
|
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
|
||||||
$day = $lastDay ? $lastDom : min($anchorDay, $lastDom);
|
$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) {
|
if ($this->matches($o, $reference, $inclusive)) {
|
||||||
return $occurrence;
|
return $o;
|
||||||
}
|
}
|
||||||
|
|
||||||
$m++;
|
$this->nextMonth($m, $y);
|
||||||
if ($m > 12) {
|
|
||||||
$m = 1;
|
|
||||||
$y++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class RecoRecoModel extends Base
|
|||||||
$meta = $this->taskMetadataModel->getAll($task_id);
|
$meta = $this->taskMetadataModel->getAll($task_id);
|
||||||
$frequency = isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : '';
|
$frequency = isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : '';
|
||||||
|
|
||||||
// v0.3 handles monthly-by-day only; the rest arrive in v1.1.
|
// All five frequencies are supported since v1.1; skip anything unknown.
|
||||||
if ($frequency !== 'monthly_day') {
|
if (! in_array($frequency, array('daily', 'weekly', 'monthly_day', 'monthly_dow', 'yearly'), true)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class Plugin extends Base
|
|||||||
// (next to native "Edit recurrence"). Opens the RecoReco config modal.
|
// (next to native "Edit recurrence"). Opens the RecoReco config modal.
|
||||||
$this->template->hook->attach('template:task:sidebar:after-basic-actions', 'recoReco:task/sidebar_action');
|
$this->template->hook->attach('template:task:sidebar:after-basic-actions', 'recoReco:task/sidebar_action');
|
||||||
|
|
||||||
|
// Modal JS: keep "days before" within one period of the selected frequency.
|
||||||
|
$this->hook->on('template:layout:js', array(
|
||||||
|
'template' => 'plugins/RecoReco/Asset/js/recoreco-modal.js',
|
||||||
|
));
|
||||||
|
|
||||||
// The scheduling engine runs from the CLI (cron). Registered CLI-only so web requests do
|
// The scheduling engine runs from the CLI (cron). Registered CLI-only so web requests do
|
||||||
// not build the console app.
|
// not build the console app.
|
||||||
if (php_sapi_name() === 'cli') {
|
if (php_sapi_name() === 'cli') {
|
||||||
@@ -36,7 +41,7 @@ class Plugin extends Base
|
|||||||
|
|
||||||
public function getPluginVersion()
|
public function getPluginVersion()
|
||||||
{
|
{
|
||||||
return '1.0.0';
|
return '1.1.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPluginHomepage()
|
public function getPluginHomepage()
|
||||||
|
|||||||
@@ -91,10 +91,72 @@ check(
|
|||||||
$failures
|
$failures
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Unsupported frequency (until v1.1) returns null.
|
// 6. Daily: same time every day.
|
||||||
check(
|
check(
|
||||||
'weekly not supported yet -> null',
|
'daily -> next day same time',
|
||||||
$calc->occurrenceFrom(ts('2023-03-10 12:00'), 'weekly', false, ts('2023-03-10 12:00'), false),
|
$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,
|
null,
|
||||||
$failures
|
$failures
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user