add recurrence limit + follow FinanceBuddy installments to stop finite plans (v1.6.1)

This commit is contained in:
2026-07-10 09:12:30 -03:00
parent e9fc5df763
commit daac5f3b54
8 changed files with 224 additions and 8 deletions

View File

@@ -38,10 +38,36 @@
} }
} }
// "Follow FinanceBuddy installments": when on, the current/limit fields mirror FinanceBuddy's
// live installment current/total and are disabled (so they do not post, keeping FinanceBuddy the
// single source of truth); when off, they restore RecoReco's own stored values.
function applyFollow(checkbox) {
var group = checkbox.closest(".recoreco-limit-group");
if (!group) { return; }
var count = group.querySelector('input[name="recoreco_count"]');
var limit = group.querySelector('input[name="recoreco_limit"]');
if (!count || !limit) { return; }
if (checkbox.checked) {
count.value = group.getAttribute("data-fb-current");
limit.value = group.getAttribute("data-fb-total");
} else {
count.value = group.getAttribute("data-rr-count");
limit.value = group.getAttribute("data-rr-limit");
}
count.disabled = checkbox.checked;
limit.disabled = checkbox.checked;
}
document.addEventListener("change", function (e) { document.addEventListener("change", function (e) {
if (e.target && e.target.name === "recoreco_frequency") { if (e.target && e.target.name === "recoreco_frequency") {
apply(e.target); apply(e.target);
} }
if (e.target && e.target.name === "recoreco_follow_finance") {
applyFollow(e.target);
}
}); });
if (window.MutationObserver) { if (window.MutationObserver) {

View File

@@ -21,8 +21,12 @@ class RecurrenceController extends BaseController
$columns = $this->targetColumns($task); $columns = $this->targetColumns($task);
$meta = $this->taskMetadataModel->getAll($task['id']); $meta = $this->taskMetadataModel->getAll($task['id']);
// The "Follow FinanceBuddy installments" option only appears when FinanceBuddy is enabled on
// this board (installed but not enabled means no installment data to follow).
$fb_enabled = (int) $this->projectMetadataModel->get($task['project_id'], 'financebuddy_enabled', 0) === 1;
if (empty($values)) { if (empty($values)) {
$values = $this->getStoredValues($meta, $task, $columns); $values = $this->getStoredValues($meta, $task, $columns, $fb_enabled);
} }
$this->response->html($this->template->render('recoReco:recurrence/edit', array( $this->response->html($this->template->render('recoReco:recurrence/edit', array(
@@ -36,6 +40,9 @@ class RecurrenceController extends BaseController
'has_target' => ! empty($columns), 'has_target' => ! empty($columns),
'is_clone' => isset($meta['recoreco_clone']) && $meta['recoreco_clone'] == 1, 'is_clone' => isset($meta['recoreco_clone']) && $meta['recoreco_clone'] == 1,
'source_id' => isset($meta['recoreco_source']) ? (int) $meta['recoreco_source'] : 0, 'source_id' => isset($meta['recoreco_source']) ? (int) $meta['recoreco_source'] : 0,
'fb_enabled' => $fb_enabled,
'fb_current' => isset($meta['financebuddy_installment_current']) ? $meta['financebuddy_installment_current'] : '',
'fb_total' => isset($meta['financebuddy_installment_total']) ? $meta['financebuddy_installment_total'] : '',
))); )));
} }
@@ -78,6 +85,19 @@ class RecurrenceController extends BaseController
$values['recoreco_anchor'] = (int) $task['date_due']; $values['recoreco_anchor'] = (int) $task['date_due'];
} }
// Finite-plan limit. When FinanceBuddy is enabled on the board and "Follow" is on, RecoReco
// reads its installment total/current live at run time -- so nothing is stored here and the
// standalone limit/counter are left untouched (the modal greys them). Otherwise store the
// standalone limit (blank/0 = forever) and the 1-based progress counter (seedable).
$fb_enabled = (int) $this->projectMetadataModel->get($task['project_id'], 'financebuddy_enabled', 0) === 1;
$follow = $fb_enabled && isset($input['recoreco_follow_finance']) && $input['recoreco_follow_finance'] == 1;
$values['recoreco_follow_finance'] = $follow ? 1 : 0;
if (! $follow) {
$values['recoreco_limit'] = (isset($input['recoreco_limit']) && ctype_digit((string) $input['recoreco_limit'])) ? (int) $input['recoreco_limit'] : 0;
$values['recoreco_count'] = (isset($input['recoreco_count']) && ctype_digit((string) $input['recoreco_count']) && (int) $input['recoreco_count'] >= 1) ? (int) $input['recoreco_count'] : 1;
}
$this->taskMetadataModel->save($task['id'], $values); $this->taskMetadataModel->save($task['id'], $values);
// Reconcile the template's duplicate links with the setting right away. // Reconcile the template's duplicate links with the setting right away.
@@ -112,7 +132,7 @@ class RecurrenceController extends BaseController
return empty($keys) ? 0 : (int) $keys[0]; return empty($keys) ? 0 : (int) $keys[0];
} }
private function getStoredValues(array $meta, array $task, array $columns) private function getStoredValues(array $meta, array $task, array $columns, $fb_enabled)
{ {
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,
@@ -121,6 +141,10 @@ class RecurrenceController extends BaseController
'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,
'recoreco_link_copies' => isset($meta['recoreco_link_copies']) ? (int) $meta['recoreco_link_copies'] : 0, 'recoreco_link_copies' => isset($meta['recoreco_link_copies']) ? (int) $meta['recoreco_link_copies'] : 0,
// Follow defaults ON for a fresh card on a FinanceBuddy board; otherwise the stored choice.
'recoreco_follow_finance' => isset($meta['recoreco_follow_finance']) ? (int) $meta['recoreco_follow_finance'] : ($fb_enabled ? 1 : 0),
'recoreco_limit' => isset($meta['recoreco_limit']) ? (int) $meta['recoreco_limit'] : 0,
'recoreco_count' => isset($meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1,
); );
} }

View File

@@ -121,6 +121,26 @@ class RecoRecoModel extends Base
// Default off: only keep the duplicate link when explicitly turned on. // Default off: only keep the duplicate link when explicitly turned on.
$linkCopies = isset($meta['recoreco_link_copies']) && $meta['recoreco_link_copies'] == 1; $linkCopies = isset($meta['recoreco_link_copies']) && $meta['recoreco_link_copies'] == 1;
// Finite-plan stop. RecoReco caps its own recurrence: either by its own limit (standalone),
// or by following FinanceBuddy's installment total. In follow mode both numbers are read LIVE
// from FinanceBuddy (never copied into RecoReco), so a mid-plan start, a re-purchase, or an
// extend all just work by editing FinanceBuddy. A blank/0 limit means recur forever. The
// spawn loop below stops when counter > limit (1-based; the counter ends on limit+1).
$fbTotal = isset($meta['financebuddy_installment_total']) ? (string) $meta['financebuddy_installment_total'] : '';
$follow = isset($meta['recoreco_follow_finance']) && $meta['recoreco_follow_finance'] == 1
&& $fbTotal !== '' && ctype_digit($fbTotal);
if ($follow) {
$limit = (int) $fbTotal;
} else {
$limitRaw = isset($meta['recoreco_limit']) ? (string) $meta['recoreco_limit'] : '';
$limit = ($limitRaw !== '' && ctype_digit($limitRaw)) ? (int) $limitRaw : 0;
}
// Standalone progress counter (1-based "next occurrence"). Unused in follow mode, where the
// counter is FinanceBuddy's live installment_current instead.
$count = isset($meta['recoreco_count']) && ctype_digit((string) $meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1;
// Phase 1 -- walk forward from the cursor, collecting every occurrence whose fire time has // Phase 1 -- walk forward from the cursor, collecting every occurrence whose fire time has
// arrived. The loop exits on the first occurrence still ahead of the horizon, which is then // arrived. The loop exits on the first occurrence still ahead of the horizon, which is then
// the "next future" one to park on. // the "next future" one to park on.
@@ -149,23 +169,53 @@ class RecoRecoModel extends Base
// Phase 3 -- spawn each kept occurrence, dated to that occurrence. Idempotent: skip any that // Phase 3 -- spawn each kept occurrence, dated to that occurrence. Idempotent: skip any that
// this template has already spawned (survives a run overlapping cron on the same occurrence). // this template has already spawned (survives a run overlapping cron on the same occurrence).
// Two guards enforce the finite-plan limit: the leading one catches an already-complete plan
// (a re-enabled done template, or a back-fill overshoot); the trailing one disables the
// instant this run's spawn completes the plan.
$spawned = 0; $spawned = 0;
$completed = false;
foreach ($toSpawn as $occurrence) { foreach ($toSpawn as $occurrence) {
$counter = $follow ? $this->financeCurrent($task_id) : $count;
if ($limit > 0 && $counter > $limit) {
$completed = true;
break;
}
if ($this->cloneExists($task_id, $occurrence)) { if ($this->cloneExists($task_id, $occurrence)) {
continue; continue;
} }
// duplicate() copies the template's date_due onto the clone, so set it to this occurrence // duplicate() copies the template's date_due onto the clone, so set it to this occurrence
// first, then spawn. // first, then spawn. spawn() fires the hook that lets FinanceBuddy advance its installment.
$this->setDue($task_id, $occurrence); $this->setDue($task_id, $occurrence);
$this->spawn($task, $targetColumn); $this->spawn($task, $targetColumn);
$spawned++; $spawned++;
// Standalone advances its own counter (only meaningful with a limit set); follow mode
// relies on FinanceBuddy having advanced its installment during spawn().
if (! $follow && $limit > 0) {
$count++;
$this->setCount($task_id, $count);
} }
// Phase 4 -- park the cursor on the next future occurrence and stop. $counter = $follow ? $this->financeCurrent($task_id) : $count;
if ($limit > 0 && $counter > $limit) {
$completed = true;
break;
}
}
// Phase 4 -- park the cursor on the next future occurrence. When the plan just completed,
// disable the template (its p(total+1) tombstone is already in place) and log it.
$this->setDue($task_id, $nextFuture); $this->setDue($task_id, $nextFuture);
if ($completed) {
$this->completePlan($task, $task_id);
}
// Reconcile the duplicate links with the setting (removes both new and old ones when off). // Reconcile the duplicate links with the setting (removes both new and old ones when off).
$this->syncCloneLinks($task_id, $linkCopies); $this->syncCloneLinks($task_id, $linkCopies);
@@ -227,6 +277,46 @@ class RecoRecoModel extends Base
$this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due)); $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due));
} }
/**
* FinanceBuddy's live "next installment to spawn" for a task (1-based), read fresh because
* FinanceBuddy advances it on every spawn. Defaults to 1 when absent or non-numeric. RecoReco
* only reads this key -- it never writes FinanceBuddy metadata.
*
* @param int $task_id
* @return int
*/
private function financeCurrent($task_id)
{
$current = $this->taskMetadataModel->get($task_id, 'financebuddy_installment_current', 1);
return ctype_digit((string) $current) ? (int) $current : 1;
}
private function setCount($task_id, $count)
{
$this->taskMetadataModel->save($task_id, array('recoreco_count' => (int) $count));
}
/**
* A finite plan reached its limit: disable the template and record it in the activity stream,
* attributed to the card owner (creator_id has a FK to users, so a system id would fail silently).
*
* @param array $task
* @param int $task_id
*/
private function completePlan(array $task, $task_id)
{
$this->taskMetadataModel->save($task_id, array('recoreco_enabled' => 0));
$this->logger->info('RecoReco: plan complete on task '.$task_id.' (recurrence disabled)');
$this->projectActivityModel->createEvent(
(int) $task['project_id'],
$task_id,
(int) $task['creator_id'],
'recoreco.task.complete',
array('task' => array('id' => $task_id, 'title' => $task['title']))
);
}
private function spawn(array $template, $targetColumn) 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

View File

@@ -29,6 +29,7 @@ class Plugin extends Base
// native recurrence on the template). createEvent() writes the rows directly from the model. // native recurrence on the template). createEvent() writes the rows directly from the model.
$this->template->setTemplateOverride('event/recoreco_task_spawn', 'recoReco:event/recoreco_task_spawn'); $this->template->setTemplateOverride('event/recoreco_task_spawn', 'recoReco:event/recoreco_task_spawn');
$this->template->setTemplateOverride('event/recoreco_task_disable', 'recoReco:event/recoreco_task_disable'); $this->template->setTemplateOverride('event/recoreco_task_disable', 'recoReco:event/recoreco_task_disable');
$this->template->setTemplateOverride('event/recoreco_task_complete', 'recoReco:event/recoreco_task_complete');
// 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.
@@ -61,7 +62,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.5.0'; return '1.6.1';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -8,6 +8,13 @@ A card marked recurring **stays** as a template; on schedule, RecoReco spawns a
(non-recurring) copy into a column you choose. The template advances to the next date; the copy (non-recurring) copy into a column you choose. The template advances to the next date; the copy
keeps the fired date. keeps the fired date.
> **A note on card order.** Because the template keeps its identity (and its id) for the life of the
> plan while its due date advances, the template ends up as the *oldest* id carrying the *latest*
> date. The spawned copies, by contrast, are ordered naturally (older copy = older id = older date).
> This is intentional: a stable, editable template that stays in place is worth more than making the
> one generator card sort by date. Native Kanboard recurrence avoids the mismatch only by
> reincarnating the card every cycle, which RecoReco deliberately does not do.
RecoReco is opt-in per card and inert until you mark a card, so it needs no per-board setting. It RecoReco is opt-in per card and inert until you mark a card, so it needs no per-board setting. It
leaves native recurrence untouched (the two are mutually exclusive per card). leaves native recurrence untouched (the two are mutually exclusive per card).
@@ -44,11 +51,29 @@ You can trigger the exact same pass by hand -- either on the CLI (`php cli recor
the **Run now** button under **Settings -> RecoReco** (admin only). The button and cron do the the **Run now** button under **Settings -> RecoReco** (admin only). The button and cron do the
identical thing. identical thing.
## Stopping a finite plan
By default a recurring card recurs forever. To make it stop, give it a **limit** in the Recurring
schedule dialog:
- **Stop after N recurrences** -- a plain count; blank or 0 means recur forever. RecoReco keeps a
1-based progress counter and turns the recurrence off once it passes the limit.
- **Follow FinanceBuddy installments** -- shown only on boards where FinanceBuddy is enabled (and on
by default there). RecoReco reads the card's installment total and current *live* from FinanceBuddy
(never copied) and stops after the last installment. Because the numbers are read fresh, editing
them in FinanceBuddy just works: start mid-plan (enter `222/420`), re-purchase (reset the current
installment), or extend (raise the total) -- no RecoReco bookkeeping to keep in sync.
When a followed plan ends, the parked template is left one past the last installment (`p4/3` for a
three-installment plan) as a visible "spent" marker, its recurrence off, with an activity-stream
entry recording the completion.
## Status ## Status
All five frequencies work (yearly, monthly by day, monthly by weekday, weekly, daily) with the All five frequencies work (yearly, monthly by day, monthly by weekday, weekly, daily) with the
last-day rule, board recurrence icons, backfill with the 12-occurrence cap, the Run-now button, and last-day rule, board recurrence icons, backfill with the 12-occurrence cap, the Run-now button, a
the FinanceBuddy installment hand-off. recurrence limit (standalone or following FinanceBuddy installments), and the FinanceBuddy
installment hand-off.
## Requirements ## Requirements
@@ -64,3 +89,21 @@ no database migration. Then add the cron entry above.
## License ## License
AGPL-3.0. See LICENSE. AGPL-3.0. See LICENSE.
## More Kanboard plugins by Ruben (drbeco)
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit
and installments) to cards, shown on the card and totalled per column. Pairs directly with
RecoReco: recurring bills spawn on schedule and their installments count down until the plan is
paid off.
- **[OrganonTweaks](https://code.beco.cc/beco/OrganonTweaks)** -- an umbrella of small
quality-of-life board tweaks: remove an empty column, always show the comment icon, emphasize due
dates, extra search keywords and shared board filters, and more.
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
column to another in a single action.
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
board columns so the horizontal scrollbar stays within reach.
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
drag-to-scroll, a wider column gap, and smoother card dragging.

View File

@@ -0,0 +1,9 @@
<p class="activity-title">
<?= e('RecoReco finished the recurrence plan on %s and turned recurrence off',
$this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id']))
) ?>
<small class="activity-date"><?= $this->dt->datetime($date_creation) ?></small>
</p>
<div class="activity-description">
<p class="activity-task-title"><?= $this->text->e($task['title']) ?></p>
</div>

View File

@@ -62,6 +62,29 @@
<?= $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"><?= t('Links each copy back to the template (a count and quick navigation). Off by default.') ?></p> <p class="form-help"><?= t('Links each copy back to the template (a count and quick navigation). Off by default.') ?></p>
<?php $following = $fb_enabled && $values['recoreco_follow_finance'] == 1 ?>
<div class="recoreco-limit-group"
data-fb-current="<?= $this->text->e($fb_current === '' ? '1' : $fb_current) ?>"
data-fb-total="<?= $this->text->e($fb_total === '' ? '0' : $fb_total) ?>"
data-rr-count="<?= (int) $values['recoreco_count'] ?>"
data-rr-limit="<?= (int) $values['recoreco_limit'] ?>">
<?php if ($fb_enabled): ?>
<?= $this->form->checkbox('recoreco_follow_finance', t('Follow FinanceBuddy installments'), 1, $following) ?>
<p class="form-help"><?= t('Stop recurring when the installment plan ends, following the card total and current installment.') ?></p>
<?php endif ?>
<?= $this->form->label(t('Current recurrence'), 'recoreco_count') ?>
<input type="number" name="recoreco_count" min="1"
value="<?= $this->text->e($following ? ($fb_current === '' ? '1' : $fb_current) : $values['recoreco_count']) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>>
<?= $this->form->label(t('Stop after this many recurrences (0 = never)'), 'recoreco_limit') ?>
<input type="number" name="recoreco_limit" min="0"
value="<?= $this->text->e($following ? ($fb_total === '' ? '0' : $fb_total) : $values['recoreco_limit']) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>>
</div>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>
</form> </form>

View File

@@ -1 +1 @@
RecoReco v1.5 RecoReco v1.6.1