3 Commits

8 changed files with 346 additions and 36 deletions

View 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 });
}
})();

View File

@@ -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;
}
}
} }

View File

@@ -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++;
}
}
} }

View File

@@ -23,6 +23,7 @@ class RecoRecoModel extends Base
public function run($now = null) public function run($now = null)
{ {
$now = $now ?: time(); $now = $now ?: time();
$horizon = $this->horizon($now);
$spawned = 0; $spawned = 0;
$task_ids = $this->db->table('task_has_metadata') $task_ids = $this->db->table('task_has_metadata')
@@ -31,13 +32,31 @@ class RecoRecoModel extends Base
->findAllByColumn('task_id'); ->findAllByColumn('task_id');
foreach ($task_ids as $task_id) { foreach ($task_ids as $task_id) {
$spawned += $this->processTemplate((int) $task_id, $now); $spawned += $this->processTemplate((int) $task_id, $now, $horizon);
} }
return $spawned; return $spawned;
} }
public function processTemplate($task_id, $now) /**
* The look-ahead horizon for this run: fire everything whose fire time is before it. Computed as
* "ceil now to the next 6h boundary, then +12h", which lands on the four daily runs:
* 05:58 -> 18:00, 11:58 -> 00:00, 17:58 -> 06:00, 23:58 -> 12:00. Gives a 12h look-ahead with a
* 6h overlap between runs, so a missed run is covered by its neighbour. Past-due occurrences are
* naturally included (their fire time is < now < horizon) -- that is the catch-up.
*
* @param int $now
* @return int
*/
private function horizon($now)
{
$startOfDay = mktime(0, 0, 0, (int) date('n', $now), (int) date('j', $now), (int) date('Y', $now));
$boundaryHour = (intdiv((int) date('G', $now), 6) + 1) * 6;
return $startOfDay + $boundaryHour * 3600 + 12 * 3600;
}
public function processTemplate($task_id, $now, $horizon)
{ {
$task = $this->taskFinderModel->getById($task_id); $task = $this->taskFinderModel->getById($task_id);
@@ -55,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;
} }
@@ -86,11 +105,12 @@ class RecoRecoModel extends Base
$this->setDue($task_id, $cursor); $this->setDue($task_id, $cursor);
} }
// Fire every occurrence whose fire time has arrived, advancing the cursor each time. // Fire every occurrence whose fire time is before this run's horizon (this includes
// past-due ones -> catch-up), advancing the cursor each time.
$spawned = 0; $spawned = 0;
$fireTime = $cursor - $daysBefore * 86400; $fireTime = $cursor - $daysBefore * 86400;
while ($fireTime <= $now && $spawned < self::CATCHUP_CAP) { while ($fireTime < $horizon && $spawned < self::CATCHUP_CAP) {
$this->spawn($task, $targetColumn); $this->spawn($task, $targetColumn);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false); $next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);

View File

@@ -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 '0.3.2'; return '1.1.1';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -21,21 +21,36 @@ leaves native recurrence untouched (the two are mutually exclusive per card).
- **Lead time:** "create the copy N days before the due date", so the card shows up early enough - **Lead time:** "create the copy N days before the due date", so the card shows up early enough
to act on; the copy is still due on the real day. to act on; the copy is still due on the real day.
## Scheduling (cron)
The engine runs from the CLI command `recoreco:run`. Add it to cron **four times a day** -- at
05:58, 11:58, 17:58 and 23:58:
```
58 5,11,17,23 * * * cd /path/to/kanboard && php cli recoreco:run >> /var/log/recoreco.log 2>&1
```
(Point the path at your install and run it as the user that owns Kanboard's data.) Each run looks
ahead 12 hours; the runs overlap by 6 hours, so a single missed run is covered by the next one, and
any occurrence whose time is already past is still caught up. `days before` shifts a copy earlier
within that lead. You can also run it by hand any time: `php cli recoreco:run`.
## Status ## Status
Early development. This version adds the **Recurring schedule** modal (a task sidebar action) and Working for **monthly by day** (with the last-day rule), driven by cron. The remaining frequencies
stores the settings in task metadata. The scheduling engine and the cron command arrive in the (yearly, weekly, daily, monthly by weekday), the recurrence icons, and the FinanceBuddy
following versions. installment hand-off arrive in the following versions.
## Requirements ## Requirements
- Kanboard >= 1.2.0 - Kanboard >= 1.2.0
- cron (to run `recoreco:run`)
## Installation ## Installation
Copy this folder into your Kanboard installation as `plugins/RecoReco/`. The directory name must be Copy this folder into your Kanboard installation as `plugins/RecoReco/`. The directory name must be
exactly `RecoReco` (Kanboard derives the plugin namespace from the folder name). No build step and exactly `RecoReco` (Kanboard derives the plugin namespace from the folder name). No build step and
no database migration. no database migration. Then add the cron entry above.
## License ## License

View File

@@ -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
); );

View File

@@ -1 +1 @@
RecoReco v0.3.2 RecoReco v1.1.1