feat(ai): share AI sparkle icon and mark AI-generated rules (#538)

## What

- **Extract the AI sparkle into a shared component.** Moves the
Gemini-style gradient sparkle from
`components/transactions/ai-sparkle-icon` →
`components/ui/ai-sparkle-icon`. It's now the single icon to reuse
wherever a feature involves AI. Existing transaction-list usages
(`category-cell`, `transaction-filters`) point at the new path; no
visual change there.
- **Mark AI-generated automation rules.** In the Automation Rules table,
the rule **title** is now prefixed with the sparkle (tooltip +
`aria-label "Created by AI"`) when the rule's `origin` is `ai`.
Extracted into a small `AutomationRuleTitle` component.

The backend `origin` enum (`RuleOrigin`: user/ai) already shipped to
`main`; this PR is the frontend surface plus the `origin` field on the
TS `AutomationRule` type.

## Tests

- New `automation-rule-title.test.tsx`: sparkle shown for `origin:
'ai'`, absent for `'user'`.
- `bun run format`, `bun run lint` clean; vitest green.

## Note

Per review, the marker sits before the rule **name**, not next to the
category badge.
This commit is contained in:
Víctor Falcón 2026-06-15 18:22:11 +02:00 committed by GitHub
parent dbec1c4c13
commit 7dde67c606
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 92 additions and 6 deletions

View File

@ -2,6 +2,7 @@
"Categorized by AI": "Categorizado por IA",
"Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza",
"Only show AI guesses": "Mostrar solo sugerencias de IA",
"Created by AI": "Creado por IA",
"Analyze": "Analizar",
"Monthly average": "Media mensual",
"Trend": "Tendencia",

View File

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { type AutomationRule, type RuleOrigin } from '@/types/automation-rule';
import { AutomationRuleTitle } from './automation-rule-title';
function makeRule(origin: RuleOrigin): AutomationRule {
return {
id: 'rule-1',
user_id: 'user-1',
title: 'Supermarket',
priority: 1,
origin,
rules_json: {},
action_category_id: null,
action_note: null,
action_note_iv: null,
labels: [],
created_at: '2026-06-15T00:00:00Z',
updated_at: '2026-06-15T00:00:00Z',
deleted_at: null,
};
}
describe('AutomationRuleTitle', () => {
it('prefixes the title with the AI sparkle when the rule was created by AI', () => {
render(<AutomationRuleTitle rule={makeRule('ai')} />);
expect(screen.getByText('Supermarket')).toBeInTheDocument();
expect(screen.getByLabelText('Created by AI')).toBeInTheDocument();
});
it('shows no marker for user-created rules', () => {
render(<AutomationRuleTitle rule={makeRule('user')} />);
expect(screen.getByText('Supermarket')).toBeInTheDocument();
expect(
screen.queryByLabelText('Created by AI'),
).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,39 @@
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { type AutomationRule } from '@/types/automation-rule';
import { __ } from '@/utils/i18n';
/**
* A rule's title, prefixed with the shared AI sparkle when the rule itself was
* generated by AI (origin === 'ai').
*/
export function AutomationRuleTitle({ rule }: { rule: AutomationRule }) {
const isAiGenerated = rule.origin === 'ai';
const label = __('Created by AI');
return (
<div className="flex items-center gap-1.5 font-medium">
{isAiGenerated && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex shrink-0"
aria-label={label}
>
<AiSparkleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{rule.title}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { AiSparkleIcon } from '@/components/transactions/ai-sparkle-icon';
import { CategorySelect } from '@/components/transactions/category-select';
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import {
Tooltip,
TooltipContent,

View File

@ -6,8 +6,8 @@ import { ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { AccountName } from '@/components/accounts/account-name';
import { AiSparkleIcon } from '@/components/transactions/ai-sparkle-icon';
import { SavedFilters } from '@/components/transactions/saved-filters';
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';

View File

@ -2,7 +2,10 @@ import { cn } from '@/lib/utils';
import { Sparkles } from 'lucide-react';
/**
* The Gemini-style multi-color sparkle used to mark an AI-guessed category.
* The Gemini-style multi-color sparkle that marks anything powered by AI
* (AI-guessed categories, AI-generated rules, AI filters, ...). This is the
* single shared icon to reuse wherever a feature involves AI.
*
* The gradient is applied to the SVG via a referenced linearGradient so the
* stroke and fill render as a true multi-color gradient (not a flat color).
*/

View File

@ -18,6 +18,7 @@ import { useMemo, useState } from 'react';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { ApplyAutomationRuleDialog } from '@/components/automation-rules/apply-automation-rule-dialog';
import { AutomationRuleActionBadges } from '@/components/automation-rules/automation-rule-action-badges';
import { AutomationRuleTitle } from '@/components/automation-rules/automation-rule-title';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
@ -230,9 +231,7 @@ export default function AutomationRules() {
accessorKey: 'title',
header: __('Title'),
cell: ({ row }) => {
return (
<div className="font-medium">{row.getValue('title')}</div>
);
return <AutomationRuleTitle rule={row.original} />;
},
},
{

View File

@ -2,11 +2,14 @@ import type { Category } from './category';
import type { Label } from './label';
import { UUID } from './uuid';
export type RuleOrigin = 'user' | 'ai';
export interface AutomationRule {
id: UUID;
user_id: UUID;
title: string;
priority: number;
origin: RuleOrigin;
rules_json: Record<string, unknown>;
action_category_id: UUID | null;
action_note: string | null;