Add Category Group filtering in Budget Analysis Report (#7116)

* feat: add FilterInclude to match FilterExclude

* feat: enhance category filtering to support category groups in budget analysis

* add release notes

* fix: applied corabbit's suggestion

* applied coderabbit's fixes

* removed duped code

* applied more coderabbit comments

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* fix: include docs about include parameter

* fix: contains and matches now properly filter category names instead of UUID

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <[email protected]>
This commit is contained in:
tabedzki
2026-04-08 14:34:26 +00:00
committed by GitHub
co-authored by autofix-ci[bot] youngcw
parent 7ce44c2e56
commit 446fde6cd9
5 changed files with 107 additions and 31 deletions
@@ -356,18 +356,47 @@ function ConfigureField<T extends RuleConditionEntity>({
);
}
/**
* Props for the shared report filter picker.
* Note: the `include` and `exclude` props only control which fields are shown in the picker, they do not limit the fields that can be applied as filters. Additionally, if both `include` and `exclude` are provided, `include` acts as the allowlist before `exclude` is applied.
*/
type FilterButtonProps<T extends RuleConditionEntity> = {
onApply: (cond: T) => void;
compact: boolean;
hover: boolean;
/** Fields hidden from the picker unless allowed by `include`. */
exclude?: string[];
/** If both are provided, `include` acts as the allowlist before `exclude` is applied. */
include?: string[];
};
/**
* Returns whether a filter field should be shown in the picker.
*
* If both `include` and `exclude` are provided, `include` acts as the
* allowlist before `exclude` is applied.
*/
function shouldShowFilterField(
field: string,
include?: string[],
exclude?: string[],
) {
if (include && !include.includes(field)) {
return false;
}
return exclude ? !exclude.includes(field) : true;
}
/**
* Shared filter picker used by reports to choose and apply filter conditions.
*/
export function FilterButton<T extends RuleConditionEntity>({
onApply,
compact,
hover,
exclude,
include,
}: FilterButtonProps<T>) {
const { t } = useTranslation();
const filters = useTransactionFilters();
@@ -476,16 +505,17 @@ export function FilterButton<T extends RuleConditionEntity>({
scopes: ['app'],
});
const filterMenuItems: ComponentProps<typeof Menu>['items'] =
translatedFilterFields
.filter(f => (exclude ? !exclude.includes(f[0]) : true))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, text]) => ({
name,
text: titleFirst(text),
}));
const visibleFilterFields = translatedFilterFields
.filter(([field]) => shouldShowFilterField(field, include, exclude))
.sort((a, b) => a[0].localeCompare(b[0]));
if (!exclude?.includes('saved')) {
const filterMenuItems: ComponentProps<typeof Menu>['items'] =
visibleFilterFields.map(([name, text]) => ({
name,
text: titleFirst(text),
}));
if (shouldShowFilterField('saved', include, exclude)) {
filterMenuItems.push(Menu.line);
filterMenuItems.push({
name: 'saved',
@@ -42,6 +42,7 @@ type HeaderProps = {
inlineContent?: ReactNode;
// no separate category filter; use main filters instead
filterExclude?: string[];
filterInclude?: string[];
} & (
| {
filters: RuleConditionEntity[];
@@ -82,6 +83,7 @@ export function Header({
children,
inlineContent,
filterExclude,
filterInclude,
}: HeaderProps) {
const locale = useLocale();
const { t } = useTranslation();
@@ -295,6 +297,7 @@ export function Header({
onApply={onApply}
hover={false}
exclude={filterExclude}
include={filterInclude}
/>
)}
{inlineContent}
@@ -317,16 +317,7 @@ function BudgetAnalysisInternal({ widget }: BudgetAnalysisInternalProps) {
onUpdateFilter={onUpdateFilter}
onDeleteFilter={onDeleteFilter}
onConditionsOpChange={onConditionsOpChange}
filterExclude={[
'date',
'account',
'payee',
'notes',
'amount',
'cleared',
'reconciled',
'transfer',
]}
filterInclude={['category', 'saved']}
inlineContent={
<Tooltip
content={
@@ -46,11 +46,22 @@ export function createBudgetAnalysisSpreadsheet({
setData: (data: BudgetAnalysisData) => void,
) => {
// Get all categories
const { list: allCategories } = await send('get-categories');
const { list: allCategories, grouped: allCategoryGroups } =
await send('get-categories');
// Filter categories based on conditions
const categoryConditions = conditions.filter(
cond => !cond.customName && cond.field === 'category',
// Build a UUID → name map for category groups so text-based operators
// (contains, doesNotContain, matches) can match against the group name.
const groupNameById = new Map<string, string>(
allCategoryGroups.map(
(g: { id: string; name: string }) => [g.id, g.name] as const,
),
);
// Filter categories based on conditions (supports both 'category' and 'category_group' fields)
const relevantConditions = conditions.filter(
cond =>
!cond.customName &&
(cond.field === 'category' || cond.field === 'category_group'),
);
// Base set: expense categories only (exclude income and hidden)
@@ -59,18 +70,53 @@ export function createBudgetAnalysisSpreadsheet({
);
let categoriesToInclude: CategoryEntity[];
if (categoryConditions.length > 0) {
// Evaluate each condition to get sets of matching categories
const conditionResults = categoryConditions.map(cond => {
if (relevantConditions.length > 0) {
// Evaluate each condition to get sets of matching categories.
// category_group conditions are expanded to their member categories via cat.group.
const conditionResults = relevantConditions.map(cond => {
const getKey = (cat: CategoryEntity) =>
cond.field === 'category_group' ? cat.group : cat.id;
const matchesRegex =
cond.op === 'matches' &&
typeof cond.value === 'string' &&
cond.value.length <= 256
? (() => {
try {
return new RegExp(cond.value, 'i');
} catch {
return null;
}
})()
: null;
return baseCategories.filter((cat: CategoryEntity) => {
const key = getKey(cat);
// For text-based operators, compare against the human-readable name
// rather than the UUID. For category_group, resolve UUID → name via
// the map; for category, use the category's own name directly.
const textValue =
cond.field === 'category_group'
? (groupNameById.get(key) ?? key)
: cat.name;
if (cond.op === 'is') {
return cond.value === cat.id;
return cond.value === key;
} else if (cond.op === 'isNot') {
return cond.value !== cat.id;
return cond.value !== key;
} else if (cond.op === 'oneOf') {
return cond.value.includes(cat.id);
return Array.isArray(cond.value) && cond.value.includes(key);
} else if (cond.op === 'notOneOf') {
return !cond.value.includes(cat.id);
return Array.isArray(cond.value) && !cond.value.includes(key);
} else if (cond.op === 'contains') {
return (
typeof cond.value === 'string' &&
textValue.toLowerCase().includes(cond.value.toLowerCase())
);
} else if (cond.op === 'doesNotContain') {
return (
typeof cond.value === 'string' &&
!textValue.toLowerCase().includes(cond.value.toLowerCase())
);
} else if (cond.op === 'matches') {
return matchesRegex?.test(textValue) ?? false;
}
return false;
});
@@ -104,7 +150,7 @@ export function createBudgetAnalysisSpreadsheet({
}
}
} else {
// No category filter, use all expense categories
// No category or category group filter — include all expense categories
categoriesToInclude = baseCategories;
}
+6
View File
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [tabedzki]
---
Add Category Group support to Budget Analysis Report