trigger column (cross-cutting) + move-mode; move relocates instead of copies (v1.9)

This commit is contained in:
2026-07-10 23:04:51 -03:00
parent 9b486970d3
commit fc1f38fb30
7 changed files with 115 additions and 5 deletions

View File

@@ -61,6 +61,23 @@
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) { invert.disabled = (trigger.value === "any"); }
}
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 +85,9 @@
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 (window.MutationObserver) { if (window.MutationObserver) {
@@ -76,6 +96,7 @@
if (select) { if (select) {
select.setAttribute("data-recoreco-init", "1"); select.setAttribute("data-recoreco-init", "1");
apply(select); apply(select);
applyTrigger();
} }
}).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.
@@ -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

@@ -145,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.2'; return '1.9.0';
} }
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

@@ -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) ?>

View File

@@ -1 +1 @@
RecoReco v1.6.2 RecoReco v1.9