5 Commits

7 changed files with 189 additions and 32 deletions

View File

@@ -61,6 +61,58 @@
limit.disabled = checkbox.checked; limit.disabled = checkbox.checked;
} }
// Trigger column: it can never be the target, so hide+disable that option and reset to Any if it
// was selected; the invert checkbox only means something for a specific column.
function applyTrigger() {
var target = document.querySelector('select[name="recoreco_target_column"]');
var trigger = document.querySelector('select[name="recoreco_trigger_column"]');
var invert = document.querySelector('input[name="recoreco_trigger_invert"]');
if (!target || !trigger) { return; }
for (var i = 0; i < trigger.options.length; i++) {
var isTarget = trigger.options[i].value === target.value;
trigger.options[i].hidden = isTarget;
trigger.options[i].disabled = isTarget;
}
if (trigger.value === target.value) { trigger.value = "any"; }
if (invert) {
var invertDisabled = (trigger.value === "any");
invert.disabled = invertDisabled;
var invertLabel = invert.closest("label");
if (invertLabel) { invertLabel.style.opacity = invertDisabled ? "0.5" : ""; }
}
}
// Move-mode makes copies moot, so grey out "Link copies to the template" while it is checked
// (disabled -> it won't post, so the saved value is 0).
function applyMove() {
var move = document.querySelector('input[name="recoreco_move"]');
var link = document.querySelector('input[name="recoreco_link_copies"]');
if (!move || !link) { return; }
link.disabled = move.checked;
var label = link.closest("label");
if (label) { label.style.opacity = move.checked ? "0.5" : ""; }
}
// Italicize the card's current column in the target/trigger dropdowns, so you can see where it
// sits right now. (<option> styling is honored by Chrome/Firefox; Safari may ignore it.)
function applyCurrentItalic() {
var form = document.querySelector('form[data-recoreco-current]');
if (!form) { return; }
var current = form.getAttribute("data-recoreco-current");
var selects = form.querySelectorAll('select[name="recoreco_target_column"], select[name="recoreco_trigger_column"]');
for (var s = 0; s < selects.length; s++) {
for (var i = 0; i < selects[s].options.length; i++) {
if (selects[s].options[i].value === current) {
selects[s].options[i].style.fontStyle = "italic";
}
}
}
}
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);
@@ -68,6 +120,12 @@
if (e.target && e.target.name === "recoreco_follow_finance") { if (e.target && e.target.name === "recoreco_follow_finance") {
applyFollow(e.target); applyFollow(e.target);
} }
if (e.target && (e.target.name === "recoreco_target_column" || e.target.name === "recoreco_trigger_column")) {
applyTrigger();
}
if (e.target && e.target.name === "recoreco_move") {
applyMove();
}
}); });
if (window.MutationObserver) { if (window.MutationObserver) {
@@ -76,6 +134,9 @@
if (select) { if (select) {
select.setAttribute("data-recoreco-init", "1"); select.setAttribute("data-recoreco-init", "1");
apply(select); apply(select);
applyTrigger();
applyMove();
applyCurrentItalic();
} }
}).observe(document.body, { childList: true, subtree: true }); }).observe(document.body, { childList: true, subtree: true });
} }

View File

@@ -34,6 +34,7 @@ class RecurrenceController extends BaseController
'values' => $values, 'values' => $values,
'errors' => $errors, 'errors' => $errors,
'columns_list' => $columns, 'columns_list' => $columns,
'all_columns' => $this->columnModel->getList($task['project_id']),
'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,
@@ -98,6 +99,16 @@ class RecurrenceController extends BaseController
$values['recoreco_count'] = (isset($input['recoreco_count']) && ctype_digit((string) $input['recoreco_count']) && (int) $input['recoreco_count'] >= 1) ? (int) $input['recoreco_count'] : 1; $values['recoreco_count'] = (isset($input['recoreco_count']) && ctype_digit((string) $input['recoreco_count']) && (int) $input['recoreco_count'] >= 1) ? (int) $input['recoreco_count'] : 1;
} }
// Trigger column + move-mode (v1.9).
$allColumns = $this->columnModel->getList($task['project_id']);
$trig = isset($input['recoreco_trigger_column']) ? (string) $input['recoreco_trigger_column'] : 'any';
$values['recoreco_trigger_column'] = ($trig !== 'any' && array_key_exists((int) $trig, $allColumns)) ? (int) $trig : 'any';
$values['recoreco_trigger_invert'] = isset($input['recoreco_trigger_invert']) ? 1 : 0;
$values['recoreco_move'] = isset($input['recoreco_move']) ? 1 : 0;
if ($values['recoreco_move'] === 1) {
$values['recoreco_follow_finance'] = 0; // Follow-FinanceBuddy is N/A in move-mode
}
$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.
@@ -109,13 +120,13 @@ class RecurrenceController extends BaseController
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);
} }
// Columns the copy can land in: every column except the template's own (the copy must move). // Target columns: ALL of the project's columns. This used to exclude the card's own column (a
// duplicate copy "must move"), but move-mode legitimately rests the card IN the target, so the
// target must be any column -- otherwise a moved card's own target vanishes from the dropdown and
// a re-save would silently reset it. `target != trigger` is enforced in the modal instead.
private function targetColumns(array $task) private function targetColumns(array $task)
{ {
$columns = $this->columnModel->getList($task['project_id']); return $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). // A stored/posted target, if still a valid non-current column; otherwise the first one (or 0).
@@ -145,6 +156,9 @@ class RecurrenceController extends BaseController
'recoreco_follow_finance' => isset($meta['recoreco_follow_finance']) ? (int) $meta['recoreco_follow_finance'] : ($fb_enabled ? 1 : 0), '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_limit' => isset($meta['recoreco_limit']) ? (int) $meta['recoreco_limit'] : 0,
'recoreco_count' => isset($meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1, 'recoreco_count' => isset($meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1,
'recoreco_move' => isset($meta['recoreco_move']) ? (int) $meta['recoreco_move'] : 0,
'recoreco_trigger_column' => isset($meta['recoreco_trigger_column']) ? $meta['recoreco_trigger_column'] : 'any',
'recoreco_trigger_invert' => isset($meta['recoreco_trigger_invert']) ? (int) $meta['recoreco_trigger_invert'] : 0,
); );
} }

View File

@@ -121,17 +121,21 @@ 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), // Finite-plan stop. RecoReco caps its own recurrence, either by its own limit (standalone) or
// or by following FinanceBuddy's installment total. In follow mode both numbers are read LIVE // by following FinanceBuddy. When follow is on, FinanceBuddy governs: the plan is finite only
// from FinanceBuddy (never copied into RecoReco), so a mid-plan start, a re-purchase, or an // if the card has a numeric installment total, so an ordinary recurring bill (money but no
// extend all just work by editing FinanceBuddy. A blank/0 limit means recur forever. The // installments) recurs forever -- and any stale standalone limit is ignored. In follow mode
// spawn loop below stops when counter > limit (1-based; the counter ends on limit+1). // the numbers are read LIVE from FinanceBuddy (never copied), 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; counter ends on
// limit+1).
$followIntent = isset($meta['recoreco_follow_finance']) && $meta['recoreco_follow_finance'] == 1;
$fbTotal = isset($meta['financebuddy_installment_total']) ? (string) $meta['financebuddy_installment_total'] : ''; $fbTotal = isset($meta['financebuddy_installment_total']) ? (string) $meta['financebuddy_installment_total'] : '';
$follow = isset($meta['recoreco_follow_finance']) && $meta['recoreco_follow_finance'] == 1 $fbHasTotal = $fbTotal !== '' && ctype_digit($fbTotal);
&& $fbTotal !== '' && ctype_digit($fbTotal); $follow = $followIntent && $fbHasTotal;
if ($follow) { if ($followIntent) {
$limit = (int) $fbTotal; $limit = $fbHasTotal ? (int) $fbTotal : 0; // following FinanceBuddy; no total => forever
} else { } else {
$limitRaw = isset($meta['recoreco_limit']) ? (string) $meta['recoreco_limit'] : ''; $limitRaw = isset($meta['recoreco_limit']) ? (string) $meta['recoreco_limit'] : '';
$limit = ($limitRaw !== '' && ctype_digit($limitRaw)) ? (int) $limitRaw : 0; $limit = ($limitRaw !== '' && ctype_digit($limitRaw)) ? (int) $limitRaw : 0;
@@ -141,6 +145,49 @@ class RecoRecoModel extends Base
// counter is FinanceBuddy's live installment_current instead. // counter is FinanceBuddy's live installment_current instead.
$count = isset($meta['recoreco_count']) && ctype_digit((string) $meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1; $count = isset($meta['recoreco_count']) && ctype_digit((string) $meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1;
// Trigger gate (cross-cutting, both modes): fire only while the card is in a triggering column,
// and never while it sits in the target (Gate 2, uniform).
$col = (int) $task['column_id'];
$trig = isset($meta['recoreco_trigger_column']) ? $meta['recoreco_trigger_column'] : 'any';
$invert = isset($meta['recoreco_trigger_invert']) && $meta['recoreco_trigger_invert'] == 1;
$base = ($trig === 'any') ? true : ($invert ? ($col !== (int) $trig) : ($col === (int) $trig));
if (! ($base && $col !== $targetColumn)) {
return 0;
}
// Move-mode: relocate the single card to the target instead of spawning a clone. No back-fill,
// no clone metadata. Honors the standalone limit (save() forced follow=0, so $limit is it).
if (isset($meta['recoreco_move']) && $meta['recoreco_move'] == 1) {
$cursor = (int) $task['date_due'];
if ($cursor - $daysBefore * 86400 >= $horizon) {
return 0; // not due yet
}
if ($limit > 0 && $count > $limit) {
$this->completePlan($task, $task_id); // safety: re-armed after completion
return 0;
}
$this->taskPositionModel->movePosition($task['project_id'], $task_id, $targetColumn, 1, $task['swimlane_id'], false);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
if ($next !== null && $next > $cursor) {
$this->setDue($task_id, $next); // advance one period
}
if ($limit > 0) {
$count++;
$this->setCount($task_id, $count);
if ($count > $limit) {
$this->completePlan($task, $task_id);
}
}
return 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.

View File

@@ -62,7 +62,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.6.1'; return '1.9.2';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -68,12 +68,32 @@ When a followed plan ends, the parked template is left one past the last install
three-installment plan) as a visible "spent" marker, its recurrence off, with an activity-stream three-installment plan) as a visible "spent" marker, its recurrence off, with an activity-stream
entry recording the completion. entry recording the completion.
## Trigger column and move-mode
Two options in the Recurring schedule dialog change *where* and *how* a card recurs:
- **Trigger column.** By default (`Any`) a card fires wherever it sits. Set a **Trigger column** and
it only fires while parked there -- so a template spawns only from its "active" column, and moving
it elsewhere quietly pauses it. The **`any but selected`** checkbox inverts the choice (fire in
every column *except* the selected one, e.g. "any but Archive"). The **target column never
triggers**, in any mode -- this is what lets a moved card come to rest in the target without
re-firing. (One edge to know: a *duplicate* template dragged into its own target column no longer
spawns; previously it fired from any column. Templates normally sit elsewhere, so this rarely
bites.)
- **Move instead of copy.** Tick **"Move the card instead of creating a copy"** and RecoReco
relocates the *one* card to the target column each period instead of spawning a clone -- made for a
recurring chore you don't want piling up copies. Pair it with a trigger column: e.g. trigger =
`Done`, target = `Todo` -- cron moves the card `Done -> Todo` when it's due; you do the task and
drag it back to `Done`; repeat. Move-mode honors the recurrence limit (stop after N moves) but not
the FinanceBuddy follow (there is no copy to advance an installment on). Moves are silent (no
"moved to column" notification each period).
## 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, a 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 recurrence limit (standalone or following FinanceBuddy installments), the FinanceBuddy installment
installment hand-off. hand-off, a per-card trigger column, and move-mode (relocate instead of copy).
## Requirements ## Requirements

View File

@@ -33,7 +33,7 @@
<p class="alert alert-info"><?= t('Add a target column first.') ?></p> <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" data-recoreco-current="<?= (int) $task['column_id'] ?>">
<?= $this->form->csrf() ?> <?= $this->form->csrf() ?>
<?= $this->form->label(t('Make recurring'), 'recoreco_enabled') ?> <?= $this->form->label(t('Make recurring'), 'recoreco_enabled') ?>
@@ -46,9 +46,21 @@
</label> </label>
</div> </div>
<?= $this->form->label(t('Target column (where the copy appears)'), 'recoreco_target_column') ?> <?= $this->form->label(t('Target column'), 'recoreco_target_column') ?>
<?= $this->form->select('recoreco_target_column', $columns_list, $values) ?> <?= $this->form->select('recoreco_target_column', $columns_list, $values) ?>
<?= $this->form->label(t('Trigger column'), 'recoreco_trigger_column') ?>
<div class="recoreco-trigger" style="display:flex; align-items:center; gap:8px;">
<?= $this->form->select('recoreco_trigger_column', array('any' => t('Any')) + $all_columns, $values) ?>
<label style="white-space:nowrap;">
<input type="checkbox" name="recoreco_trigger_invert" value="1" <?= $values['recoreco_trigger_invert'] == 1 ? 'checked="checked"' : '' ?>> <?= t('any but selected') ?>
</label>
</div>
<p class="form-help"><?= t('Recurrence fires only while the card sits in a triggering column; the target column never triggers.') ?></p>
<?= $this->form->checkbox('recoreco_move', t('Move the card instead of creating a copy'), 1, $values['recoreco_move'] == 1) ?>
<p class="form-help"><?= t('Relocate this one card to the target column each period instead of spawning a copy.') ?></p>
<?= $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) ?>
@@ -63,26 +75,29 @@
<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 ?> <?php $following = $fb_enabled && $values['recoreco_follow_finance'] == 1 ?>
<?php $limit_display = (int) $values['recoreco_limit'] > 0 ? (int) $values['recoreco_limit'] : '' ?>
<div class="recoreco-limit-group" <div class="recoreco-limit-group"
data-fb-current="<?= $this->text->e($fb_current === '' ? '1' : $fb_current) ?>" data-fb-current="<?= $this->text->e($fb_current) ?>"
data-fb-total="<?= $this->text->e($fb_total === '' ? '0' : $fb_total) ?>" data-fb-total="<?= $this->text->e($fb_total) ?>"
data-rr-count="<?= (int) $values['recoreco_count'] ?>" data-rr-count="<?= (int) $values['recoreco_count'] ?>"
data-rr-limit="<?= (int) $values['recoreco_limit'] ?>"> data-rr-limit="<?= $this->text->e($limit_display) ?>">
<?php if ($fb_enabled): ?> <?php if ($fb_enabled): ?>
<?= $this->form->checkbox('recoreco_follow_finance', t('Follow FinanceBuddy installments'), 1, $following) ?> <?= $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> <p class="form-help"><?= t('Stop recurring when the installment plan ends, following the card total and current installment.') ?></p>
<?php endif ?> <?php endif ?>
<?= $this->form->label(t('Current recurrence'), 'recoreco_count') ?> <?= $this->form->label(t('Recurrence'), 'recoreco_count') ?>
<input type="number" name="recoreco_count" min="1" <div class="recoreco-limit-inline" style="display:flex; align-items:center; gap:6px;">
value="<?= $this->text->e($following ? ($fb_current === '' ? '1' : $fb_current) : $values['recoreco_count']) ?>" <input type="number" name="recoreco_count" min="1" style="width:5em;"
<?= $following ? 'disabled="disabled"' : '' ?>> value="<?= $this->text->e($following ? $fb_current : $values['recoreco_count']) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>>
<?= $this->form->label(t('Stop after this many recurrences (0 = never)'), 'recoreco_limit') ?> <span><?= t('of') ?></span>
<input type="number" name="recoreco_limit" min="0" <input type="number" name="recoreco_limit" min="1" style="width:5em;"
value="<?= $this->text->e($following ? ($fb_total === '' ? '0' : $fb_total) : $values['recoreco_limit']) ?>" value="<?= $this->text->e($following ? $fb_total : $limit_display) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>> <?= $following ? 'disabled="disabled"' : '' ?>>
</div>
<p class="form-help"><?= t('Current recurrence and how many to run. Leave the second box empty to recur forever.') ?></p>
</div> </div>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>

View File

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