3 Commits
v0.3.1 ... v1.1

7 changed files with 304 additions and 47 deletions

View File

@@ -62,6 +62,12 @@ class RecurrenceController extends BaseController
} }
$this->taskMetadataModel->save($task['id'], $values); $this->taskMetadataModel->save($task['id'], $values);
// Reconcile the template's duplicate links with the setting right away (removes existing
// links to its RecoReco clones when the option is off).
$model = new \Kanboard\Plugin\RecoReco\Model\RecoRecoModel($this->container);
$model->syncCloneLinks($task['id'], $values['recoreco_link_copies'] == 1);
$this->flash->success(t('Recurring schedule saved.')); $this->flash->success(t('Recurring schedule saved.'));
return $this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true); return $this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true);

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,12 +105,13 @@ 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, $linkCopies); $this->spawn($task, $targetColumn);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false); $next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
@@ -105,9 +125,35 @@ class RecoRecoModel extends Base
$spawned++; $spawned++;
} }
// Reconcile the duplicate links with the setting (removes both new and old ones when off).
$this->syncCloneLinks($task_id, $linkCopies);
return $spawned; return $spawned;
} }
/**
* Reconcile a template's "is a duplicate of" links with the link-copies setting. When off, remove
* the links to THIS template's own RecoReco clones (identified by recoreco_source, so manual
* links are left untouched). When on, do nothing -- new spawns already carry the link.
*
* @param int $template_id
* @param bool $linkCopies
*/
public function syncCloneLinks($template_id, $linkCopies)
{
if ($linkCopies) {
return;
}
foreach ($this->taskLinkModel->getAll($template_id) as $link) {
$opposite = (int) $link['task_id'];
if ((int) $this->taskMetadataModel->get($opposite, 'recoreco_source', 0) === (int) $template_id) {
$this->taskLinkModel->remove($link['id']);
}
}
}
/** /**
* The first occurrence: respect an explicitly future anchor (the due date the user set) as the * The first occurrence: respect an explicitly future anchor (the due date the user set) as the
* first one; otherwise jump to the next occurrence on/after now. * first one; otherwise jump to the next occurrence on/after now.
@@ -128,7 +174,7 @@ class RecoRecoModel extends Base
$this->taskMetadataModel->save($task_id, array('recoreco_synced_due' => $cursor)); $this->taskMetadataModel->save($task_id, array('recoreco_synced_due' => $cursor));
} }
private function spawn(array $template, $targetColumn, $linkCopies) private function spawn(array $template, $targetColumn)
{ {
// The template's date_due is already the occurrence (setDue), so duplicate() copies it onto // The template's date_due is already the occurrence (setDue), so duplicate() copies it onto
// the clone. duplicate() copies fields + tags + links + subtasks, but NO metadata, so the // the clone. duplicate() copies fields + tags + links + subtasks, but NO metadata, so the
@@ -149,16 +195,8 @@ class RecoRecoModel extends Base
$this->taskModificationModel->update(array('id' => $newId, 'title' => $template['title']), false); $this->taskModificationModel->update(array('id' => $newId, 'title' => $template['title']), false);
// Kanboard's duplicate() adds an "is a duplicate of" task link between the template and the // Kanboard's duplicate() adds an "is a duplicate of" task link between the template and the
// clone. Kept when recoreco_link_copies is on (a running count + one-click navigation to // clone. It is reconciled with the recoreco_link_copies setting by syncCloneLinks() after
// each copy); stripped when off (the default), to avoid piling up links on frequent // the spawn loop (kept when on, removed when off).
// schedules. getAll() returns the opposite task as $link['task_id'] and the row id as 'id'.
if (! $linkCopies) {
foreach ($this->taskLinkModel->getAll($newId) as $link) {
if ((int) $link['task_id'] === (int) $template['id']) {
$this->taskLinkModel->remove($link['id']);
}
}
}
// Mark the clone: white icon + cannot itself be made recurring; record its source template. // Mark the clone: white icon + cannot itself be made recurring; record its source template.
$this->taskMetadataModel->save($newId, array( $this->taskMetadataModel->save($newId, array(

View File

@@ -36,7 +36,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '0.3.1'; return '1.1.0';
} }
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.1 RecoReco v1.1