fix: preview categorizer rule application (#421)

## Summary
- show the existing transaction preview only when the user explicitly
chooses `Apply to existing transactions` from an automation rule row
menu
- keep create/update rule save prompt behavior
- stop opening the apply modal just because the categorizer/onboarding
rules dialog closed

## Videos
### Apply existing rule — categorizer

https://github.com/user-attachments/assets/18806421-340d-4337-998f-308ebe60d11f

### Apply existing rule — onboarding

https://github.com/user-attachments/assets/ad615365-5927-4b2d-b9e5-5f4e20e80dce

### Create new rule — categorizer

https://github.com/user-attachments/assets/55ed2dea-5cec-4e8c-9c5f-d760194bc661

### Create new rule — onboarding

https://github.com/user-attachments/assets/cb045683-e5ce-4b93-9849-5722324848da

## Tests
- `npm test -- --run
resources/js/components/automation-rules/apply-automation-rule-flow.test.ts
resources/js/components/automation-rules/post-save-apply-rule-prompt.test.ts`
- `npx eslint resources/js/hooks/use-categorize-transactions.ts
resources/js/pages/transactions/categorize.tsx
resources/js/components/onboarding/step-categorize-transactions.tsx
resources/js/components/automation-rules/automation-rules-dialog.tsx`
- CI passing
This commit is contained in:
Víctor Falcón 2026-05-25 10:24:21 +02:00 committed by GitHub
parent 364c72db72
commit faf046b3c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 37 additions and 105 deletions

View File

@ -66,6 +66,7 @@
":count matching transaction(s)": ":count transacción(es) coincidente(s)",
":count new transactions synced on Whisper Money": ":count nuevas transacciones sincronizadas en Whisper Money",
":count transaction": ":count transacción",
":count transaction(s) will be affected.": ":count transacción(es) serán afectadas.",
":count transactions": ":count transacciones",
"@alext_money": "@alext_money",
"@davidk_dev": "@davidk_dev",
@ -182,6 +183,7 @@
"Appearance settings": "Configuración de apariencia",
"Apply": "Aplicar",
"Apply rule to existing transactions": "Aplicar regla a transacciones existentes",
"Apply rules to remaining transactions?": "¿Aplicar reglas a las transacciones restantes?",
"Apply this rule to your existing transactions?": "¿Aplicar esta regla a tus transacciones existentes?",
"Apply to :count transaction(s)": "Aplicar a :count transacción(es)",
"Apply to existing transactions": "Aplicar a transacciones existentes",
@ -698,6 +700,7 @@
"If you have any questions, concerns, or feedback\\n about these Terms of Service, please contact us:": "Si tienes alguna pregunta, inquietud o comentario sobre estos Términos de Servicio, contáctanos:",
"If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud con respecto a esta Política de Privacidad o nuestras prácticas de datos, por favor contáctanos:",
"If you have any questions, concerns, or requests\\n regarding this Privacy Policy or our data\\n practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud sobre esta Política de Privacidad o nuestras prácticas de datos, contáctanos:",
"If you skip, rules will still apply automatically to future transactions.": "Si omites este paso, las reglas se seguirán aplicando automáticamente a futuras transacciones.",
"If you'd like to come back, here's a special offer just for you:": "Si te gustaría volver, aquí tienes una oferta especial solo para ti:",
"If you're having trouble clicking the \"Verify Email Address\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Verificar Correo Electrónico\", copia y pega la URL de abajo en tu navegador web:",
"If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.": "Si tienes problemas con la importación o necesitas ayuda con el formato de tu banco, simplemente responde a este correo. Yo personalmente manejo el soporte y estaré encantado de ayudarte.",
@ -1028,6 +1031,7 @@
"Preview Balances": "Vista Previa de Balances",
"Preview Owed Amounts": "Vista Previa de Montos Adeudados",
"Preview Transactions": "Vista Previa de Transacciones",
"Preview the transactions these rules will categorize before applying them.": "Previsualiza las transacciones que estas reglas categorizarán antes de aplicarlas.",
"Preview the transactions this rule will affect and apply its actions.": "Previsualiza las transacciones que esta regla afectará y aplica sus acciones.",
"Previous": "Anterior",
"Previous month": "Mes anterior",
@ -1448,7 +1452,6 @@
"Unused budget will carry over to the next period.": "El presupuesto no utilizado se acumulará al siguiente período.",
"Update": "Actualizar",
"Update Credentials": "Actualizar Credenciales",
"Update payment": "Actualizar pago",
"Update account balance": "Actualizar balance de cuenta",
"Update balance": "Actualizar balance",
"Update balances periodically to track growth": "Actualiza los balances periódicamente para rastrear el crecimiento",
@ -1456,6 +1459,7 @@
"Update market value": "Actualizar valor de mercado",
"Update owed amount": "Actualizar monto adeudado",
"Update password": "Actualizar contraseña",
"Update payment": "Actualizar pago",
"Update the account information.": "Actualiza la información de la cuenta.",
"Update the balance record.": "Actualiza el registro de balance.",
"Update the category and notes for this transaction.": "Actualiza la categoría y notas de esta transacción.",
@ -1517,9 +1521,9 @@
"We built Whisper Money because we were tired of finance apps that mine your data and sell your habits to third parties. We wanted something simple, private, and actually useful.": "Creamos Whisper Money porque estábamos cansados de apps de finanzas que explotan tus datos y venden tus hábitos a terceros. Queríamos algo simple, privado y realmente útil.",
"We built Whisper Money because we were tired of giving our financial data to big companies who use it for who knows what. Whisper Money **never shares your data with third parties** - you are always the owner of your financial information.": "Creamos Whisper Money porque estábamos cansados de dar nuestros datos financieros a grandes empresas que los usan para quién sabe qué. Whisper Money **nunca comparte tus datos con terceros**: tú eres siempre el propietario de tu información financiera.",
"We built Whisper Money to help you truly understand your finances — without giving up your privacy to do it. Here's what you can look forward to:": "Creamos Whisper Money para ayudarte a entender de verdad tus finanzas, sin sacrificar tu privacidad para ello. Esto es lo que puedes esperar:",
"We could not collect your subscription payment.": "No hemos podido cobrar el pago de tu suscripción.",
"We collect the following types of personal information:": "Recopilamos los siguientes tipos de información personal:",
"We collect the following types of personal\\n information:": "Recopilamos los siguientes tipos de información personal:",
"We could not collect your subscription payment.": "No hemos podido cobrar el pago de tu suscripción.",
"We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.": "Desconectamos tu conexión bancaria para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.|Desconectamos tus :count conexiones bancarias para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.",
"We do not sell, trade, or rent your personal information to third parties for marketing purposes.": "No vendemos, comercializamos ni alquilamos tu información personal a terceros con fines de marketing.",
"We do not sell, trade, or rent your personal\\n information to third parties for marketing\\n purposes.": "No vendemos, intercambiamos ni alquilamos tu información personal a terceros con fines de marketing.",
@ -1552,8 +1556,8 @@
"We use essential cookies to maintain your\\n session and ensure the proper functioning of our\\n service. These cookies are necessary for the\\n service to work and cannot be disabled. We do\\n not use tracking cookies or analytics cookies\\n without your explicit consent.": "Usamos cookies esenciales para mantener tu sesión y garantizar el correcto funcionamiento de nuestro servicio. Estas cookies son necesarias para que el servicio funcione y no se pueden deshabilitar. No usamos cookies de seguimiento ni de análisis sin tu consentimiento explícito.",
"We use the following third-party services to operate our platform:": "Usamos los siguientes servicios de terceros para operar nuestra plataforma:",
"We use the following third-party services to\\n operate our platform:": "Utilizamos los siguientes servicios de terceros para operar nuestra plataforma:",
"We will retry up to 4 times during the week. You keep access for now, but the subscription will be canceled if all retries fail.": "Lo reintentaremos hasta 4 veces durante la semana. Mantienes el acceso por ahora, pero la suscripción se cancelará si fallan todos los reintentos.",
"We were unable to sync your :provider connection because your credentials appear to have expired or been revoked.": "No pudimos sincronizar tu conexión con :provider porque tus credenciales parecen haber expirado o sido revocadas.",
"We will retry up to 4 times during the week. You keep access for now, but the subscription will be canceled if all retries fail.": "Lo reintentaremos hasta 4 veces durante la semana. Mantienes el acceso por ahora, pero la suscripción se cancelará si fallan todos los reintentos.",
"We're **Victor and Alvaro**, the founders of Whisper Money. Thanks so much for joining the list — it genuinely means a lot to us.": "Somos **Víctor y Álvaro**, los fundadores de Whisper Money. Muchísimas gracias por unirte a la lista — significa mucho para nosotros.",
"We're Victor and Álvaro, the founders of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and we're excited to tell you - we're live!": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te registraste hace un tiempo para recibir noticias sobre nuestra app de finanzas personales centrada en la privacidad, y estamos emocionados de contarte: ¡ya estamos en vivo!",
"We're Víctor and Álvaro, the founders of Whisper Money. Now Whisper Money is open. As a thank you, your first months are on us.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Ya puedes registrate. Como agradecimiento, los primeros meses los invitamos nosotros.",

View File

@ -15,6 +15,7 @@ import {
import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ApplyAutomationRuleDialog } from '@/components/automation-rules/apply-automation-rule-dialog';
import { AutomationRuleActionBadges } from '@/components/automation-rules/automation-rule-action-badges';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
@ -71,6 +72,7 @@ function AutomationRuleActions({
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
return (
<>
@ -90,6 +92,9 @@ function AutomationRuleActions({
<DropdownMenuItem onClick={() => setEditOpen(true)}>
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setApplyOpen(true)}>
{__('Apply to existing transactions')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
@ -106,6 +111,11 @@ function AutomationRuleActions({
open={editOpen}
onOpenChange={setEditOpen}
/>
<ApplyAutomationRuleDialog
rule={rule}
open={applyOpen}
onOpenChange={setApplyOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
@ -127,6 +137,7 @@ function AutomationRuleRow({
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
return (
@ -156,6 +167,9 @@ function AutomationRuleRow({
<ContextMenuItem onClick={() => setEditOpen(true)}>
{__('Edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => setApplyOpen(true)}>
{__('Apply to existing transactions')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
@ -172,6 +186,11 @@ function AutomationRuleRow({
open={editOpen}
onOpenChange={setEditOpen}
/>
<ApplyAutomationRuleDialog
rule={rule}
open={applyOpen}
onOpenChange={setApplyOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}

View File

@ -1,4 +1,5 @@
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
import { StepButton } from '@/components/onboarding/step-button';
import { CategorizerCard } from '@/components/transactions/categorizer-card';
import { CategorizerCommand } from '@/components/transactions/categorizer-command';
@ -368,6 +369,7 @@ export function StepCategorizeTransactions({
open={rulesDialogOpen}
onOpenChange={handleRulesDialogCloseWithHint}
/>
<PostSaveApplyRulePrompt />
</div>
);
}

View File

@ -3,17 +3,14 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { captureEvent } from '@/lib/posthog';
import { evaluateRules } from '@/lib/rule-engine';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import {
type DecryptedTransaction,
type Transaction,
} from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { parseISO } from 'date-fns';
import {
createElement,
@ -84,9 +81,6 @@ export function useCategorizeTransactions({
transactions: initialTransactions,
}: UseCategorizeTransactionsOptions) {
const { isKeySet } = useEncryptionKey();
const { automationRules: sharedAutomationRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
DecryptedTransaction[]
@ -103,22 +97,10 @@ export function useCategorizeTransactions({
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
const [automateCandidate, setAutomateCandidate] =
useState<AutomateCategorizationCandidate | null>(null);
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
const [, setEncryptionKey] = useState<CryptoKey | null>(null);
const [categorizedCount, setCategorizedCount] = useState(0);
const commandInputRef = useRef<HTMLInputElement>(null);
const automationRules = useMemo(
() =>
sharedAutomationRules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
})) as AutomationRule[],
[sharedAutomationRules],
);
useEffect(() => {
setCategoryUsageOrder(getCategoryUsageOrder());
}, []);
@ -248,87 +230,13 @@ export function useCategorizeTransactions({
return sortCategoriesByUsage(categories, categoryUsageOrder);
}, [categories, categoryUsageOrder]);
const applyAutomationRulesToQueue = useCallback(async () => {
if (
!encryptionKey ||
!automationRules.length ||
uncategorizedTransactions.length === 0
) {
return;
const handleRulesDialogClose = useCallback((open: boolean) => {
setRulesDialogOpen(open);
if (!open) {
commandInputRef.current?.focus();
}
const remainingTransactions =
uncategorizedTransactions.slice(currentIndex);
let appliedCount = 0;
const newUncategorizedList: DecryptedTransaction[] = [
...uncategorizedTransactions.slice(0, currentIndex),
];
for (const transaction of remainingTransactions) {
const result = await evaluateRules(
transaction,
automationRules,
categories,
accounts,
banks,
encryptionKey,
);
if (result?.categoryId) {
try {
await transactionSyncService.update(transaction.id, {
category_id: result.categoryId,
});
appliedCount++;
const matchedCategory = categories.find(
(c) => c.id === result.categoryId,
);
if (matchedCategory) {
updateCategoryUsageOrder(matchedCategory.id);
}
} catch (error) {
console.error(
'Failed to apply automation rule to transaction:',
error,
);
newUncategorizedList.push(transaction);
}
} else {
newUncategorizedList.push(transaction);
}
}
if (appliedCount > 0) {
setCategoryUsageOrder(getCategoryUsageOrder());
setUncategorizedTransactions(newUncategorizedList);
setCurrentIndex(
Math.min(currentIndex, newUncategorizedList.length),
);
}
return appliedCount;
}, [
encryptionKey,
automationRules,
uncategorizedTransactions,
currentIndex,
categories,
accounts,
banks,
]);
const handleRulesDialogClose = useCallback(
async (open: boolean) => {
setRulesDialogOpen(open);
if (!open) {
await applyAutomationRulesToQueue();
commandInputRef.current?.focus();
}
},
[applyAutomationRulesToQueue],
);
}, []);
const handleCategorySelect = useCallback(
async (category: Category) => {
@ -426,11 +334,10 @@ export function useCategorizeTransactions({
}
}, []);
const handleAutomateSaved = useCallback(async () => {
const handleAutomateSaved = useCallback(() => {
setAutomateDialogOpen(false);
await applyAutomationRulesToQueue();
commandInputRef.current?.focus();
}, [applyAutomationRulesToQueue]);
}, []);
const handlePrevious = useCallback(() => {
if (animationState !== 'idle' || currentIndex === 0) {