feat(rules): show what an apply run changed, and which rule ran
ci / lint-test (push) Successful in 38s

Apply History listed only counts - '13 matches · 13 transactions' - which reads
identically whether the run renamed a merchant or split every transaction with
another participant. Revert is destructive, so that is not enough to decide on.

Two additions. Migration 0017 records rule_id, rule_name and source on each run:
rule_name is denormalised so history stays readable after a rule is edited or
deleted, and there is no FK so deleting a rule cannot cascade away the audit
trail. Both write paths now populate it - the condition-matched run and the
selection-based quick action.

And rows expand to show the run's snapshot set against current values: which
transactions were touched and what changed on each. Rows changed by something
else since the run are called out, because reverting restores the pre-run value
and would discard that later edit.

Runs recorded before this show 'Unknown rule' - the rule they came from is not
recoverable.
This commit is contained in:
2026-07-26 15:20:56 +10:00
parent cc852e7c6f
commit 3778bfe836
7 changed files with 342 additions and 30 deletions
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useRuleRunDetail } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
/**
* The expanded body of an Apply History row: which transactions the run touched
* and what it changed on each, so Revert is an informed decision rather than a
* guess from a count.
*/
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n);
}
function describe(c: { field: string; from: string | null; to: string | null }) {
if (c.field === "category") return `${formatCategory(c.from)}${formatCategory(c.to)}`;
if (c.field === "merchant") return `${c.from ?? "—"}${c.to ?? "—"}`;
if (c.field === "tags") return `+ tag ${c.to}`;
return `split ${c.from ?? "none"}${c.to ?? "none"}`;
}
export function RuleRunDetail({ runId }: { runId: number }) {
const { data, isLoading, error } = useRuleRunDetail(runId);
if (isLoading) return <p className="px-4 py-3 text-xs text-zinc-500">Loading</p>;
if (error) return <p className="px-4 py-3 text-xs text-red-400">Failed to load detail.</p>;
if (!data) return null;
const { transactions, still_changed, run } = data;
return (
<div className="border-t border-zinc-800 bg-zinc-950/60">
<div className="px-4 py-2 text-xs text-zinc-500 flex gap-4 flex-wrap">
<span>{transactions.length} transactions</span>
{run.reverted_at ? (
<span className="text-zinc-500">already reverted values are back to their originals</span>
) : (
<span className="text-amber-400">{still_changed} still showing this run&apos;s changes</span>
)}
{still_changed < transactions.length && !run.reverted_at && (
<span className="text-zinc-600">
{transactions.length - still_changed} since changed by something else reverting restores the pre-run value
</span>
)}
</div>
<div className="max-h-72 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-950">
<tr className="text-zinc-600 border-b border-zinc-800">
<th className="text-left px-4 py-1.5">Date</th>
<th className="text-left px-3 py-1.5">Transaction</th>
<th className="text-right px-3 py-1.5">Amount</th>
<th className="text-left px-3 py-1.5">Changed</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id} className="border-b border-zinc-800/40">
<td className="px-4 py-1.5 text-zinc-500 whitespace-nowrap">
{String(t.transaction_date).slice(0, 10)}
</td>
<td className="px-3 py-1.5 text-zinc-300 max-w-[300px] truncate" title={t.description}>
{t.merchant || t.description}
<span className="text-zinc-600 ml-2">{t.bank_name}</span>
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-zinc-400">
{fmt(Number(t.amount_aud ?? t.amount))}
</td>
<td className="px-3 py-1.5">
{t.changes.length === 0 ? (
<span className="text-zinc-600">no change</span>
) : (
t.changes.map((c, i) => (
<div key={i} className="text-amber-400">{describe(c)}</div>
))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}