5 Commits

9 changed files with 446 additions and 68 deletions

View File

@@ -0,0 +1,56 @@
/*
* 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) {
// "days before" ceiling / disable by frequency.
var input = document.querySelector('input[name="recoreco_days_before"]');
if (input) {
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;
}
}
}
// The last-day/weekday checkbox only means something for monthly and yearly.
var lastday = document.querySelector(".recoreco-lastday");
if (lastday) {
var monthlyOrYearly = select.value === "monthly_day" || select.value === "monthly_dow" || select.value === "yearly";
lastday.style.display = monthlyOrYearly ? "" : "none";
}
}
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

@@ -7,29 +7,32 @@ use Kanboard\Model\TaskModel;
/** /**
* The "Recurring schedule" modal: configure a card's calendar recurrence and store it in task * The "Recurring schedule" modal: configure a card's calendar recurrence and store it in task
* metadata. v0.2 is storage only -- no spawning (that is the CLI engine in later versions). * metadata. The CLI engine (RecoRecoModel) does the actual spawning.
* *
* Gate: a card can only be enabled when it has a due date (the recurrence anchor) and is not * Gate: a card can only be enabled when it has a due date (the recurrence anchor), is not already a
* already a native-recurring card (RecoReco and native recurrence are mutually exclusive). * native-recurring card (mutually exclusive), and the board has a column other than the card's own
* (the copy must land elsewhere).
*/ */
class RecurrenceController extends BaseController class RecurrenceController extends BaseController
{ {
public function edit(array $values = array(), array $errors = array()) public function edit(array $values = array(), array $errors = array())
{ {
$task = $this->getTask(); $task = $this->getTask();
$columns = $this->targetColumns($task);
if (empty($values)) { if (empty($values)) {
$values = $this->getStoredValues($task); $values = $this->getStoredValues($task, $columns);
} }
$this->response->html($this->template->render('recoReco:recurrence/edit', array( $this->response->html($this->template->render('recoReco:recurrence/edit', array(
'task' => $task, 'task' => $task,
'values' => $values, 'values' => $values,
'errors' => $errors, 'errors' => $errors,
'columns_list' => $this->columnModel->getList($task['project_id']), 'columns_list' => $columns,
'frequency_list' => $this->getFrequencyList(), 'frequency_list' => $this->getFrequencyList(),
'has_due_date' => ! empty($task['date_due']), 'has_due_date' => ! empty($task['date_due']),
'is_native' => $task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE, 'is_native' => $task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE,
'has_target' => ! empty($columns),
))); )));
} }
@@ -37,43 +40,77 @@ class RecurrenceController extends BaseController
{ {
$task = $this->getTask(); $task = $this->getTask();
$input = $this->request->getValues(); $input = $this->request->getValues();
$columns = $this->targetColumns($task);
// Only a plain card (has a due date, not native-recurring) may be enabled. // Only a plain card (a due date, not native-recurring, and a valid target) may be enabled.
$can_recur = ! empty($task['date_due']) && $task['recurrence_status'] == TaskModel::RECURRING_STATUS_NONE; $can_recur = ! empty($task['date_due'])
&& $task['recurrence_status'] == TaskModel::RECURRING_STATUS_NONE
&& ! empty($columns);
$enabled = ($can_recur && isset($input['recoreco_enabled']) && $input['recoreco_enabled'] == 1) ? 1 : 0; $enabled = ($can_recur && isset($input['recoreco_enabled']) && $input['recoreco_enabled'] == 1) ? 1 : 0;
$frequency = isset($input['recoreco_frequency']) && array_key_exists($input['recoreco_frequency'], $this->getFrequencyList()) $frequency = isset($input['recoreco_frequency']) && array_key_exists($input['recoreco_frequency'], $this->getFrequencyList())
? $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' => $this->resolveTarget(isset($input['recoreco_target_column']) ? $input['recoreco_target_column'] : 0, $columns),
'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,
); );
// Capture the anchor (the due date at enable time) -- the drift-free pattern. The engine // Capture the anchor (the due date at enable time) -- the drift-free pattern.
// (v0.3) reads it to compute occurrences.
if ($enabled) { if ($enabled) {
$values['recoreco_anchor'] = (int) $task['date_due']; $values['recoreco_anchor'] = (int) $task['date_due'];
} }
$this->taskMetadataModel->save($task['id'], $values); $this->taskMetadataModel->save($task['id'], $values);
// Reconcile the template's duplicate links with the setting right away.
$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);
} }
private function getStoredValues(array $task) // Columns the copy can land in: every column except the template's own (the copy must move).
private function targetColumns(array $task)
{
$columns = $this->columnModel->getList($task['project_id']);
unset($columns[$task['column_id']]);
return $columns;
}
// A stored/posted target, if still a valid non-current column; otherwise the first one (or 0).
private function resolveTarget($candidate, array $columns)
{
$candidate = (int) $candidate;
if (array_key_exists($candidate, $columns)) {
return $candidate;
}
$keys = array_keys($columns);
return empty($keys) ? 0 : (int) $keys[0];
}
private function getStoredValues(array $task, array $columns)
{ {
$meta = $this->taskMetadataModel->getAll($task['id']); $meta = $this->taskMetadataModel->getAll($task['id']);
return array( return array(
'recoreco_enabled' => isset($meta['recoreco_enabled']) ? (int) $meta['recoreco_enabled'] : 0, 'recoreco_enabled' => isset($meta['recoreco_enabled']) ? (int) $meta['recoreco_enabled'] : 0,
'recoreco_target_column' => isset($meta['recoreco_target_column']) ? (int) $meta['recoreco_target_column'] : (int) $task['column_id'], 'recoreco_target_column' => $this->resolveTarget(isset($meta['recoreco_target_column']) ? $meta['recoreco_target_column'] : 0, $columns),
'recoreco_frequency' => isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : 'monthly_day', 'recoreco_frequency' => isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : 'monthly_day',
'recoreco_last_day' => isset($meta['recoreco_last_day']) ? (int) $meta['recoreco_last_day'] : 0, 'recoreco_last_day' => isset($meta['recoreco_last_day']) ? (int) $meta['recoreco_last_day'] : 0,
'recoreco_days_before' => isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0, 'recoreco_days_before' => isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0,
@@ -91,4 +128,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,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

@@ -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.1'; return '1.1.2';
} }
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

@@ -1,13 +1,25 @@
<div class="page-header"> <div class="page-header">
<h2><?= t('Recurring schedule') ?><?php if ($has_due_date): ?> <small>(<?= date('Y-m-d H:i', $task['date_due']) ?>)</small><?php endif ?></h2> <h2>
<?= t('Recurring schedule') ?>
<?php if ($has_due_date): ?>
<small>(<?= $this->url->link(date('Y-m-d H:i', $task['date_due']), 'TaskModificationController', 'edit', array('task_id' => $task['id']), false, 'js-modal-large') ?>)</small>
<?php else: ?>
<small>-- <?= $this->url->link(t('set a due date'), 'TaskModificationController', 'edit', array('task_id' => $task['id']), false, 'js-modal-large') ?></small>
<?php endif ?>
</h2>
</div> </div>
<?php $can_recur = $has_due_date && ! $is_native ?> <?php $can_recur = $has_due_date && ! $is_native && $has_target ?>
<?php if (! $has_due_date): ?> <?php if (! $has_due_date): ?>
<p class="alert alert-info"><?= t('Please set a due date first. RecoReco uses the card due date as the recurrence anchor.') ?></p> <p class="alert alert-info"><?= t('Please set a due date first. RecoReco uses the card due date as the recurrence anchor.') ?></p>
<?php elseif ($is_native): ?> <?php elseif ($is_native): ?>
<p class="alert alert-info"><?= t('This card already uses Kanboard built-in recurrence. RecoReco and native recurrence cannot be used together.') ?></p> <p class="alert alert-info">
<?= t('This card uses Kanboard built-in recurrence.') ?>
<?= $this->url->link(t('Edit built-in recurrence'), 'TaskRecurrenceController', 'edit', array('task_id' => $task['id']), false, 'js-modal-medium') ?>
</p>
<?php elseif (! $has_target): ?>
<p class="alert alert-info"><?= t('Add a target column first.') ?></p>
<?php endif ?> <?php endif ?>
<form method="post" action="<?= $this->url->href('RecurrenceController', 'save', array('plugin' => 'RecoReco', 'task_id' => $task['id'])) ?>" autocomplete="off"> <form method="post" action="<?= $this->url->href('RecurrenceController', 'save', array('plugin' => 'RecoReco', 'task_id' => $task['id'])) ?>" autocomplete="off">
@@ -29,16 +41,15 @@
<?= $this->form->label(t('Frequency'), 'recoreco_frequency') ?> <?= $this->form->label(t('Frequency'), 'recoreco_frequency') ?>
<?= $this->form->select('recoreco_frequency', $frequency_list, $values) ?> <?= $this->form->select('recoreco_frequency', $frequency_list, $values) ?>
<?= $this->form->checkbox('recoreco_last_day', t('Last day of the month (honored only when the due date is the last day / last weekday of its month)'), 1, $values['recoreco_last_day'] == 1) ?> <div class="recoreco-lastday">
<?= $this->form->checkbox('recoreco_last_day', t('Fires on last day/weekday of the month'), 1, $values['recoreco_last_day'] == 1) ?>
</div>
<?= $this->form->label(t('Create the copy this many days before the due date'), 'recoreco_days_before') ?> <?= $this->form->label(t('Create the copy this many days before the due date'), 'recoreco_days_before') ?>
<input type="number" name="recoreco_days_before" min="0" value="<?= $this->text->e($values['recoreco_days_before']) ?>"> <input type="number" name="recoreco_days_before" min="0" value="<?= $this->text->e($values['recoreco_days_before']) ?>">
<?= $this->form->checkbox('recoreco_link_copies', t('Link copies to the template'), 1, $values['recoreco_link_copies'] == 1) ?> <?= $this->form->checkbox('recoreco_link_copies', t('Link copies to the template'), 1, $values['recoreco_link_copies'] == 1) ?>
<p class="form-help"> <p class="form-help"><?= t('Links each copy back to the template (a count and quick navigation). Off by default.') ?></p>
<?= t('When on, the template keeps a link to each copy -- a running count and one-click navigation to every one. Off by default; best left off for frequent (daily/weekly) schedules to avoid piling up links.') ?>
</p>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>
</form> </form>

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.2