diff --git a/packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx b/packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx index 58944b58ac..41037fcb9c 100644 --- a/packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx +++ b/packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx @@ -188,6 +188,10 @@ const DATE_FUNCTIONS = new Set([ const QUERY_FUNCTIONS = new Set([ 'QUERY', 'QUERY_COUNT', + 'BUDGET_QUERY', + 'QUERY_EXTRACT_CATEGORIES', + 'QUERY_EXTRACT_TIMEFRAME_START', + 'QUERY_EXTRACT_TIMEFRAME_END', 'LOOKUP', 'VLOOKUP', 'HLOOKUP', @@ -494,6 +498,50 @@ export function excelFormulaAutocomplete( apply: `QUERY_COUNT("${queryName}")`, boost: 14, }, + { + label: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + type: 'function', + section: '🔍 Query Functions', + info: t( + 'Sum of budgeted amounts with extracted parameters from {{queryName}}.', + { queryName }, + ), + apply: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + boost: 13, + }, + { + label: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + type: 'function', + section: '🔍 Query Functions', + info: t( + 'Sum of spending with extracted parameters from {{queryName}}.', + { queryName }, + ), + apply: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + boost: 13, + }, + { + label: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + type: 'function', + section: '🔍 Query Functions', + info: t( + 'Opening balance with extracted parameters from {{queryName}}.', + { queryName }, + ), + apply: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + boost: 13, + }, + { + label: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + type: 'function', + section: '🔍 Query Functions', + info: t( + 'Closing balance with extracted parameters from {{queryName}}.', + { queryName }, + ), + apply: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`, + boost: 13, + }, ]) : []; diff --git a/packages/desktop-client/src/components/formula/queryModeFunctions.ts b/packages/desktop-client/src/components/formula/queryModeFunctions.ts index 861bbfd522..6a236fe28c 100644 --- a/packages/desktop-client/src/components/formula/queryModeFunctions.ts +++ b/packages/desktop-client/src/components/formula/queryModeFunctions.ts @@ -116,6 +116,54 @@ export const queryModeFunctions: Record = { { name: 'decimals', description: 'Decimals' }, ], }, + BUDGET_QUERY: { + name: 'BUDGET_QUERY', + description: t( + 'Evaluate a budget query using extracted parameters. Supply dimension, categories, and timeframe explicitly.', + ), + parameters: [ + { + name: 'dimension', + description: + 'One of: budgeted, spent, balance_start, balance_end, goal (string)', + }, + { + name: 'categories', + description: + 'Categories result from QUERY_EXTRACT_CATEGORIES() (array)', + }, + { + name: 'timeframe_start', + description: + 'Start month from QUERY_EXTRACT_TIMEFRAME_START() (string)', + }, + { + name: 'timeframe_end', + description: 'End month from QUERY_EXTRACT_TIMEFRAME_END() (string)', + }, + ], + }, + QUERY_EXTRACT_CATEGORIES: { + name: 'QUERY_EXTRACT_CATEGORIES', + description: t('Extract category IDs from a named query.'), + parameters: [ + { name: 'queryName', description: 'Name of the saved query (string)' }, + ], + }, + QUERY_EXTRACT_TIMEFRAME_START: { + name: 'QUERY_EXTRACT_TIMEFRAME_START', + description: t('Extract the start month from a named query timeframe.'), + parameters: [ + { name: 'queryName', description: 'Name of the saved query (string)' }, + ], + }, + QUERY_EXTRACT_TIMEFRAME_END: { + name: 'QUERY_EXTRACT_TIMEFRAME_END', + description: t('Extract the end month from a named query timeframe.'), + parameters: [ + { name: 'queryName', description: 'Name of the saved query (string)' }, + ], + }, FLOOR: { name: 'FLOOR', description: t('Rounds down to nearest multiple of significance.'), diff --git a/packages/desktop-client/src/hooks/useFormulaExecution.ts b/packages/desktop-client/src/hooks/useFormulaExecution.ts index da8186cf4d..b687365ba1 100644 --- a/packages/desktop-client/src/hooks/useFormulaExecution.ts +++ b/packages/desktop-client/src/hooks/useFormulaExecution.ts @@ -7,11 +7,16 @@ import * as monthUtils from 'loot-core/shared/months'; import { q } from 'loot-core/shared/query'; import type { Query } from 'loot-core/shared/query'; import { integerToAmount } from 'loot-core/shared/util'; -import type { RuleConditionEntity, TimeFrame } from 'loot-core/types/models'; +import type { + CategoryEntity, + RuleConditionEntity, + TimeFrame, +} from 'loot-core/types/models'; import { useLocale } from './useLocale'; import { getLiveRange } from '@desktop-client/components/reports/getLiveRange'; +import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges'; type QueryConfig = { conditions?: RuleConditionEntity[]; @@ -25,6 +30,58 @@ function escapeRegExp(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +// Parse a BUDGET_QUERY parameter - can be extraction function, array literal, or string +function parseBudgetParam( + param: string, +): { type: 'extraction' | 'literal'; data: unknown } | null { + param = param.trim(); + + // Try extraction function: QUERY_EXTRACT_*("queryName") + const extractMatch = param.match( + /^(QUERY_EXTRACT_\w+)\s*\(\s*["']([^"']+)["']\s*\)$/, + ); + if (extractMatch) { + return { + type: 'extraction', + data: { funcName: extractMatch[1], queryName: extractMatch[2] }, + }; + } + + // Try array literal: {"id1";"id2";...} + const arrayMatch = param.match(/^\{([^}]*)\}$/); + if (arrayMatch) { + const items = arrayMatch[1] + .split(';') + .map(item => item.replace(/^["']|["']$/g, '').trim()) + .filter(item => item.length > 0); + return { type: 'literal', data: items }; + } + + // Try string literal: "value" + const stringMatch = param.match(/^["']([^"']*)["']$/); + if (stringMatch) { + return { type: 'literal', data: stringMatch[1] }; + } + + return null; +} + +// Resolve a parsed BUDGET_QUERY parameter to its actual value +function resolveBudgetParam( + parsed: ReturnType, + extractionResults: Record>, +): unknown { + if (!parsed || parsed.type === 'literal') { + return parsed?.data; + } + + const { funcName, queryName } = parsed.data as { + funcName: string; + queryName: string; + }; + return extractionResults[funcName]?.[`${funcName}(${queryName})`]; +} + export function useFormulaExecution( formula: string, queries: QueriesMap, @@ -70,6 +127,66 @@ export function useFormulaExecution( new Set(queryCountMatches.map(m => m[1])), ); + // Extract QUERY_EXTRACT_* calls for evaluation + // These extraction functions are used as parameters to BUDGET_QUERY + const extractionFunctions = { + QUERY_EXTRACT_CATEGORIES: + /QUERY_EXTRACT_CATEGORIES\s*\(\s*["']([^"']+)["']\s*\)/gi, + QUERY_EXTRACT_TIMEFRAME_START: + /QUERY_EXTRACT_TIMEFRAME_START\s*\(\s*["']([^"']+)["']\s*\)/gi, + QUERY_EXTRACT_TIMEFRAME_END: + /QUERY_EXTRACT_TIMEFRAME_END\s*\(\s*["']([^"']+)["']\s*\)/gi, + }; + + // Store extraction results + const extractionResults: Record> = { + QUERY_EXTRACT_CATEGORIES: {}, + QUERY_EXTRACT_TIMEFRAME_START: {}, + QUERY_EXTRACT_TIMEFRAME_END: {}, + }; + + // Evaluate extraction functions first + for (const [funcName, regex] of Object.entries(extractionFunctions)) { + const matches = Array.from(formula.matchAll(regex)); + for (const match of matches) { + const queryName = match[1]; + const key = `${funcName}(${queryName})`; + + if (!extractionResults[funcName][key]) { + try { + if (funcName === 'QUERY_EXTRACT_CATEGORIES') { + extractionResults[funcName][key] = + await extractQueryCategories(queryName, queries); + } else if (funcName === 'QUERY_EXTRACT_TIMEFRAME_START') { + extractionResults[funcName][key] = + await extractQueryTimeframeStart(queryName, queries); + } else if (funcName === 'QUERY_EXTRACT_TIMEFRAME_END') { + extractionResults[funcName][key] = + await extractQueryTimeframeEnd(queryName, queries); + } + } catch (err) { + console.error( + `Error evaluating ${funcName}(${queryName})`, + err, + ); + extractionResults[funcName][key] = null; + } + } + } + } + + // Match BUDGET_QUERY(dimension, param1, param2, param3) where each param can be: + // extraction function, array literal {...}, or string "..." + const paramPattern = String.raw`(?:QUERY_EXTRACT_\w+\s*\([^)]*\)|\{[^}]*\}|["'][^"']*["'])`; + const budgetMatches = Array.from( + formula.matchAll( + new RegExp( + `BUDGET_QUERY\\s*\\(\\s*["']([^"']+)["']\\s*,\\s*(${paramPattern})\\s*,\\s*(${paramPattern})\\s*,\\s*(${paramPattern})\\s*\\)`, + 'gi', + ), + ), + ); + for (const queryName of queryNames) { const queryConfig = queries[queryName]; @@ -113,6 +230,100 @@ export function useFormulaExecution( processedFormula = processedFormula.replace(regex, String(value)); } + // Process BUDGET_QUERY BEFORE replacing extraction functions + // This ensures we match BUDGET_QUERY with extraction functions still intact + if (budgetMatches.length > 0) { + for (const match of budgetMatches) { + const dimension = match[1]; + const param1Str = match[2].trim(); + const param2Str = match[3].trim(); + const param3Str = match[4].trim(); + + try { + // Parse and resolve parameters + const param1 = resolveBudgetParam( + parseBudgetParam(param1Str), + extractionResults, + ); + const param2 = resolveBudgetParam( + parseBudgetParam(param2Str), + extractionResults, + ); + const param3 = resolveBudgetParam( + parseBudgetParam(param3Str), + extractionResults, + ); + + // Validate resolved parameters + if ( + !Array.isArray(param1) || + typeof param2 !== 'string' || + typeof param3 !== 'string' + ) { + console.error( + 'Failed to resolve BUDGET_QUERY parameters:', + param1Str, + param2Str, + param3Str, + ); + continue; + } + + // Evaluate BUDGET_QUERY + const val = await fetchBudgetDimensionValueDirect( + dimension, + param1 as string[], + param2 as string, + param3 as string, + ); + + processedFormula = processedFormula.replace( + match[0], + String(val), + ); + } catch (err) { + console.error('Error evaluating BUDGET_QUERY', err); + } + } + } + + // NOW replace remaining QUERY_EXTRACT_* functions with their evaluated values + // (any that weren't consumed by BUDGET_QUERY) + for (const [funcName, regex] of Object.entries(extractionFunctions)) { + const matches = Array.from(processedFormula.matchAll(regex)); + for (const match of matches) { + const queryName = match[1]; + const key = `${funcName}(${queryName})`; + const value = extractionResults[funcName][key]; + + if (value !== null && value !== undefined) { + const escapedQueryName = escapeRegExp(queryName); + const replacementRegex = new RegExp( + `${funcName}\\s*\\(\\s*["']${escapedQueryName}["']\\s*\\)`, + 'gi', + ); + + // Format the replacement value based on type + let replacement: string; + if (Array.isArray(value)) { + // For arrays, convert to HyperFormula array literal + replacement = `{${value.map(v => `"${v}"`).join(';')}}`; + } else if (typeof value === 'string') { + // For strings, wrap in quotes + replacement = `"${value}"`; + } else { + // For numbers + replacement = String(value); + } + + processedFormula = processedFormula.replace( + replacementRegex, + replacement, + ); + } + } + } + // Create HyperFormula instance hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3', @@ -347,3 +558,267 @@ async function fetchQueryCount(config: QueryConfig): Promise { return 0; } } + +// Helper: Extract category-based conditions (ignore transaction-specific filters) +function extractCategoryConditions( + conditions: RuleConditionEntity[], +): RuleConditionEntity[] { + return conditions.filter( + cond => !cond.customName && cond.field === 'category', + ); +} + +// Helper: Evaluate category conditions to get matching categories +async function getCategoriesFromConditions( + allCategories: CategoryEntity[], + conditions: RuleConditionEntity[], + conditionsOp: 'and' | 'or', +): Promise { + if (conditions.length === 0) { + // No category filter: include all non-income, non-hidden categories + return allCategories + .filter((cat: CategoryEntity) => !cat.is_income && !cat.hidden) + .map((cat: CategoryEntity) => cat.id); + } + + // Evaluate each condition to get sets of matching categories + const conditionResults = conditions.map(cond => { + const matching = allCategories.filter((cat: CategoryEntity) => { + if (cond.op === 'is') { + return cond.value === cat.id; + } else if (cond.op === 'isNot') { + return cond.value !== cat.id; + } else if (cond.op === 'oneOf') { + return cond.value.includes(cat.id); + } else if (cond.op === 'notOneOf') { + return !cond.value.includes(cat.id); + } else if (cond.op === 'contains') { + return cat.name.includes(cond.value as string); + } else if (cond.op === 'doesNotContain') { + return !cat.name.includes(cond.value as string); + } else if (cond.op === 'matches') { + try { + return new RegExp(cond.value as string).test(cat.name); + } catch (e) { + console.warn('Invalid regexp in matches condition', e); + return true; + } + } + // Unknown operator: include category by default and log warning + console.warn(`Unknown category condition operator: ${cond.op}`); + return true; + }); + return matching.map((cat: CategoryEntity) => cat.id); + }); + + if (conditionsOp === 'or') { + // OR: Union of all matching categories + const categoryIds = new Set(conditionResults.flat()); + return Array.from(categoryIds); + } else { + // AND: Intersection of all matching categories + if (conditionResults.length === 0) { + return []; + } + const firstSet = new Set(conditionResults[0]); + for (let i = 1; i < conditionResults.length; i++) { + const currentIds = new Set(conditionResults[i]); + // Keep only categories that are in both sets + const toRemove: string[] = []; + firstSet.forEach(id => { + if (!currentIds.has(id)) { + toRemove.push(id); + } + }); + toRemove.forEach(id => firstSet.delete(id)); + } + return Array.from(firstSet); + } +} + +// Helper: Get month data from envelope-budget-month RPC +async function getMonthBudgetData( + month: string, +): Promise> { + const monthData = await send('envelope-budget-month', { month }); + return monthData || []; +} + +// Helper: Extract value from month data by field pattern +function getMonthDataValue( + monthData: Array<{ name: string; value: string | number | boolean }>, + pattern: string, + catId: string, +): string | number | boolean { + const fieldName = pattern.replace('{catId}', catId); + const cell = monthData.find(c => c.name.endsWith(fieldName)); + return cell?.value ?? 0; +} + +// Helper: Extract categories from a named query (for QUERY_EXTRACT_CATEGORIES) +async function extractQueryCategories( + queryName: string, + queries: QueriesMap, +): Promise { + const queryConfig = queries[queryName]; + if (!queryConfig) { + console.warn(`Query "${queryName}" not found in queries config`); + return []; + } + + const categoryConditions = extractCategoryConditions( + queryConfig.conditions || [], + ); + const { list: allCategories } = await send('get-categories'); + return getCategoriesFromConditions( + allCategories, + categoryConditions, + queryConfig.conditionsOp || 'and', + ); +} + +// Helper: Extract timeframe start month from a named query (for QUERY_EXTRACT_TIMEFRAME_START) +async function extractQueryTimeframeStart( + queryName: string, + queries: QueriesMap, +): Promise { + const queryConfig = queries[queryName]; + if (!queryConfig || !queryConfig.timeFrame) { + console.warn( + `Query "${queryName}" not found or has no timeframe; cannot extract start`, + ); + return monthUtils.currentMonth(); + } + + const [startMonth] = calculateTimeRange(queryConfig.timeFrame); + return startMonth; +} + +// Helper: Extract timeframe end month from a named query (for QUERY_EXTRACT_TIMEFRAME_END) +async function extractQueryTimeframeEnd( + queryName: string, + queries: QueriesMap, +): Promise { + const queryConfig = queries[queryName]; + if (!queryConfig || !queryConfig.timeFrame) { + console.warn( + `Query "${queryName}" not found or has no timeframe; cannot extract end`, + ); + return monthUtils.currentMonth(); + } + + const [, endMonth] = calculateTimeRange(queryConfig.timeFrame); + return endMonth; +} + +// Helper: Evaluate budget dimension with already-extracted parameters (used by compositional BUDGET_QUERY) +async function fetchBudgetDimensionValueDirect( + dimension: string, + categoryIds: string[], + startMonth: string, + endMonth: string, +): Promise { + const allowed = new Set([ + 'budgeted', + 'spent', + 'balance_start', + 'balance_end', + 'goal', + ]); + const dim = dimension.toLowerCase(); + if (!allowed.has(dim)) { + throw new Error(`Invalid BUDGET_QUERY dimension: ${dimension}`); + } + + const intervals = monthUtils.rangeInclusive(startMonth, endMonth); + + // Helper: sum a dimension across all months/categories + const sumDimension = async (fieldPattern: string): Promise => { + let total = 0; + for (const month of intervals) { + const monthData = await getMonthBudgetData(month); + for (const catId of categoryIds) { + total += getMonthDataValue(monthData, fieldPattern, catId) as number; + } + } + return total; + }; + + if (dim === 'budgeted') { + return integerToAmount(await sumDimension('budget-{catId}'), 2); + } + + if (dim === 'spent') { + return integerToAmount(await sumDimension('sum-amount-{catId}'), 2); + } + + if (dim === 'goal') { + return integerToAmount(await sumDimension('goal-{catId}'), 2); + } + + // Handle balance dimensions: chain month-by-month with carryover logic + if (dim === 'balance_start' || dim === 'balance_end') { + let runningBalance = 0; + const monthBeforeStart = monthUtils.subMonths(startMonth, 1); + const prevMonthData = await getMonthBudgetData(monthBeforeStart); + + for (const catId of categoryIds) { + const catBalance = getMonthDataValue( + prevMonthData, + 'leftover-{catId}', + catId, + ) as number; + const hasCarryover = Boolean( + getMonthDataValue(prevMonthData, 'carryover-{catId}', catId), + ); + if (catBalance > 0 || (catBalance < 0 && hasCarryover)) { + runningBalance += catBalance; + } + } + + const balances: Record = {}; + + for (const month of intervals) { + const monthData = await getMonthBudgetData(month); + let budgeted = 0; + let spent = 0; + let carryoverToNextMonth = 0; + + for (const catId of categoryIds) { + const catBudgeted = + Number(getMonthDataValue(monthData, 'budget-{catId}', catId)) || 0; + const catSpent = + Number(getMonthDataValue(monthData, 'sum-amount-{catId}', catId)) || + 0; + const catBalance = + Number(getMonthDataValue(monthData, 'leftover-{catId}', catId)) || 0; + const hasCarryover = Boolean( + getMonthDataValue(monthData, 'carryover-{catId}', catId), + ); + + budgeted += catBudgeted; + spent += catSpent; + + if (catBalance > 0 || (catBalance < 0 && hasCarryover)) { + carryoverToNextMonth += catBalance; + } + } + + const balanceStart = runningBalance; + const balanceEnd = budgeted + spent + runningBalance; + + balances[month] = { start: balanceStart, end: balanceEnd }; + runningBalance = carryoverToNextMonth; + } + + if (dim === 'balance_start') { + return integerToAmount(balances[intervals[0]]?.start || 0, 2); + } + return integerToAmount( + balances[intervals[intervals.length - 1]]?.end || 0, + 2, + ); + } + + return 0; +} diff --git a/upcoming-release-notes/7078.md b/upcoming-release-notes/7078.md new file mode 100644 index 0000000000..ab5e582d4e --- /dev/null +++ b/upcoming-release-notes/7078.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [sys044] +--- + +Add BUDGET_QUERY and QUERY_EXTRACT functions for formula-based budget analysis.