From 446fde6cd9a5e84bb5e01aca89e6987b36161921 Mon Sep 17 00:00:00 2001 From: tabedzki <35670232+tabedzki@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:34:26 -0400 Subject: [PATCH] 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 --- .../src/components/filters/FiltersMenu.tsx | 48 ++++++++++--- .../src/components/reports/Header.tsx | 3 + .../reports/reports/BudgetAnalysis.tsx | 11 +-- .../budget-analysis-spreadsheet.ts | 70 +++++++++++++++---- upcoming-release-notes/7116.md | 6 ++ 5 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 upcoming-release-notes/7116.md diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.tsx b/packages/desktop-client/src/components/filters/FiltersMenu.tsx index a43fbc9f68..0cc771c009 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.tsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.tsx @@ -356,18 +356,47 @@ function ConfigureField({ ); } +/** + * 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 = { 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({ onApply, compact, hover, exclude, + include, }: FilterButtonProps) { const { t } = useTranslation(); const filters = useTransactionFilters(); @@ -476,16 +505,17 @@ export function FilterButton({ scopes: ['app'], }); - const filterMenuItems: ComponentProps['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['items'] = + visibleFilterFields.map(([name, text]) => ({ + name, + text: titleFirst(text), + })); + + if (shouldShowFilterField('saved', include, exclude)) { filterMenuItems.push(Menu.line); filterMenuItems.push({ name: 'saved', diff --git a/packages/desktop-client/src/components/reports/Header.tsx b/packages/desktop-client/src/components/reports/Header.tsx index 52c8e29251..f94116f099 100644 --- a/packages/desktop-client/src/components/reports/Header.tsx +++ b/packages/desktop-client/src/components/reports/Header.tsx @@ -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} diff --git a/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx b/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx index 2d37668f81..bf808cfd59 100644 --- a/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx +++ b/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx @@ -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={ 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( + 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; } diff --git a/upcoming-release-notes/7116.md b/upcoming-release-notes/7116.md new file mode 100644 index 0000000000..1e48158d40 --- /dev/null +++ b/upcoming-release-notes/7116.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [tabedzki] +--- + +Add Category Group support to Budget Analysis Report