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
+32
View File
@@ -617,6 +617,38 @@ export interface RuleRun {
matched: number;
transactions_affected: number;
reverted_at: string | null;
// Provenance (migration 0017). NULL on runs recorded before it existed.
rule_id: number | null;
rule_name: string | null;
source: "all" | "rule" | "selection" | null;
}
export interface RuleRunDetail {
run: RuleRun & { transactions_affected: number };
transactions: {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
bank_name: string;
merchant: string | null;
changes: { field: string; from: string | null; to: string | null }[];
}[];
still_changed: number;
}
/** What a run actually did — loaded on demand when a row is expanded. */
export function useRuleRunDetail(runId: number | null) {
return useQuery<RuleRunDetail>({
queryKey: ["rule-run-detail", runId],
enabled: runId != null,
queryFn: async () => {
const res = await fetch(`/api/rules/runs/${runId}`);
if (!res.ok) throw new Error("Failed to load run detail");
return res.json();
},
});
}
export function useRuleRuns() {