diff --git a/Asset/js/recoreco-modal.js b/Asset/js/recoreco-modal.js index cb599cf..788c5c7 100644 --- a/Asset/js/recoreco-modal.js +++ b/Asset/js/recoreco-modal.js @@ -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) { if (e.target && e.target.name === "recoreco_frequency") { apply(e.target); } + if (e.target && e.target.name === "recoreco_follow_finance") { + applyFollow(e.target); + } }); if (window.MutationObserver) { diff --git a/Controller/RecurrenceController.php b/Controller/RecurrenceController.php index 0b71770..7630ebf 100644 --- a/Controller/RecurrenceController.php +++ b/Controller/RecurrenceController.php @@ -21,8 +21,12 @@ class RecurrenceController extends BaseController $columns = $this->targetColumns($task); $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)) { - $values = $this->getStoredValues($meta, $task, $columns); + $values = $this->getStoredValues($meta, $task, $columns, $fb_enabled); } $this->response->html($this->template->render('recoReco:recurrence/edit', array( @@ -36,6 +40,9 @@ class RecurrenceController extends BaseController 'has_target' => ! empty($columns), 'is_clone' => isset($meta['recoreco_clone']) && $meta['recoreco_clone'] == 1, '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']; } + // 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); // 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]; } - private function getStoredValues(array $meta, array $task, array $columns) + private function getStoredValues(array $meta, array $task, array $columns, $fb_enabled) { return array( '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_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, + // 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, ); } diff --git a/Model/RecoRecoModel.php b/Model/RecoRecoModel.php index 9b7cac5..2e61345 100644 --- a/Model/RecoRecoModel.php +++ b/Model/RecoRecoModel.php @@ -121,6 +121,26 @@ class RecoRecoModel extends Base // Default off: only keep the duplicate link when explicitly turned on. $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 // arrived. The loop exits on the first occurrence still ahead of the horizon, which is then // 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 // 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; + $completed = false; 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)) { continue; } // 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->spawn($task, $targetColumn); $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); + } + + $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 and stop. + // 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); + if ($completed) { + $this->completePlan($task, $task_id); + } + // Reconcile the duplicate links with the setting (removes both new and old ones when off). $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)); } + /** + * 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) { // The template's date_due is already the occurrence (setDue), so duplicate() copies it onto diff --git a/Plugin.php b/Plugin.php index fe697a8..47d9d8b 100644 --- a/Plugin.php +++ b/Plugin.php @@ -29,6 +29,7 @@ class Plugin extends Base // 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_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 // not build the console app. @@ -61,7 +62,7 @@ class Plugin extends Base public function getPluginVersion() { - return '1.5.0'; + return '1.6.1'; } public function getPluginHomepage() diff --git a/README.md b/README.md index cef3e5c..158de94 100644 --- a/README.md +++ b/README.md @@ -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 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 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 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 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 -the FinanceBuddy installment hand-off. +last-day rule, board recurrence icons, backfill with the 12-occurrence cap, the Run-now button, a +recurrence limit (standalone or following FinanceBuddy installments), and the FinanceBuddy +installment hand-off. ## Requirements @@ -64,3 +89,21 @@ no database migration. Then add the cron entry above. ## 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. diff --git a/Template/event/recoreco_task_complete.php b/Template/event/recoreco_task_complete.php new file mode 100644 index 0000000..e6f5969 --- /dev/null +++ b/Template/event/recoreco_task_complete.php @@ -0,0 +1,9 @@ +

+ url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'])) + ) ?> + dt->datetime($date_creation) ?> +

+
+

text->e($task['title']) ?>

+
diff --git a/Template/recurrence/edit.php b/Template/recurrence/edit.php index 239bd4c..63392a9 100644 --- a/Template/recurrence/edit.php +++ b/Template/recurrence/edit.php @@ -62,6 +62,29 @@ form->checkbox('recoreco_link_copies', t('Link copies to the template'), 1, $values['recoreco_link_copies'] == 1) ?>

+ +
+ + + form->checkbox('recoreco_follow_finance', t('Follow FinanceBuddy installments'), 1, $following) ?> +

+ + + form->label(t('Current recurrence'), 'recoreco_count') ?> + > + + form->label(t('Stop after this many recurrences (0 = never)'), 'recoreco_limit') ?> + > +
+ modal->submitButtons() ?> diff --git a/VERSION b/VERSION index 3bdc365..5e2a3cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -RecoReco v1.5 +RecoReco v1.6.1