feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s

A rule flagged manual_only never runs in the apply-all pass; instead it shows
as a button in the transactions bulk bar and applies its actions to the current
selection (conditions ignored — the selection is the condition). Recorded as a
rule_apply_run, so it reverts from Rules -> Apply History like any other run.

Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every
time. Now it is one click, and any other combo can be defined the same way.

Extracts the action-application and snapshot logic from the apply route into
src/lib/rule-actions.ts so both callers share one implementation — splits upsert
rather than delete+reinsert, so settled flags survive.
This commit is contained in:
2026-07-25 23:02:26 +10:00
parent 856e1a51ab
commit e0b0fc91e0
10 changed files with 324 additions and 118 deletions
+42 -11
View File
@@ -86,6 +86,7 @@ export default function RulesPage() {
const [conditions, setConditions] = useState<Condition[]>([]);
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
const [priority, setPriority] = useState(0);
const [manualOnly, setManualOnly] = useState(false);
function openNewForm() {
setEditingId(null);
@@ -93,15 +94,17 @@ export default function RulesPage() {
setConditions([]);
setActions(EMPTY_ACTIONS);
setPriority(0);
setManualOnly(false);
setShowForm(true);
}
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) {
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number; manual_only?: boolean }) {
setEditingId(rule.id);
setName(rule.name);
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
setPriority(rule.priority);
setManualOnly(!!rule.manual_only);
setShowForm(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -141,7 +144,7 @@ export default function RulesPage() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const payload = { name, conditions, actions, enabled: true, priority };
const payload = { name, conditions, actions, enabled: true, manual_only: manualOnly, priority };
if (editingId !== null) {
await updateRule.mutateAsync({ id: editingId, ...payload });
} else {
@@ -416,6 +419,20 @@ export default function RulesPage() {
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
/>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer select-none pb-2">
<input
type="checkbox"
checked={manualOnly}
onChange={(e) => setManualOnly(e.target.checked)}
className="accent-amber-500"
/>
<span>
Quick action only
<span className="block text-xs text-zinc-600">
Never runs automatically appears as a button on selected transactions
</span>
</span>
</label>
<button
type="submit"
disabled={isPending}
@@ -475,21 +492,35 @@ export default function RulesPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<span className="font-medium text-sm">{rule.name}</span>
{rule.manual_only && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-800/60">
Quick action
</span>
)}
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
</div>
<p className="text-xs text-zinc-400">
{conds.length > 0 ? conds.map((c) => humanCondition(c, tagNames)).join(" AND ") : "(matches all)"}
{rule.manual_only
? "(fired by hand on selected transactions)"
: conds.length > 0
? conds.map((c) => humanCondition(c, tagNames)).join(" AND ")
: "(matches all)"}
</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => handleApply(rule.id)}
disabled={applyRules.isPending}
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Apply
</button>
{/* No Apply for quick actions: their conditions are empty, so a
bulk apply would hit every transaction. They run from the
transactions page against a selection instead. */}
{!rule.manual_only && (
<button
onClick={() => handleApply(rule.id)}
disabled={applyRules.isPending}
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Apply
</button>
)}
<button
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
@@ -503,7 +534,7 @@ export default function RulesPage() {
/>
</button>
<button
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority })}
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority, manual_only: rule.manual_only })}
className="text-zinc-400 hover:text-white text-sm"
>
Edit