Formula Card: Add budget analysis functions (#7078)

* feat(formula): Add QUERY_BUDGET function for budget-aware formula reporting

* docs(release): Add QUERY_BUDGET feature release notes

* refactor: decompose into multiple functions and support goal dimension

* [autofix.ci] apply automated fixes

* refactor: simplified code

* [autofix.ci] apply automated fixes

* updated release notes

---------

Co-authored-by: Your Name <tomgriffin@localhost>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
sys044
2026-03-17 16:37:10 +00:00
committed by GitHub
parent ee8f8bfbba
commit 108ccc8aba
4 changed files with 578 additions and 1 deletions

View File

@@ -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,
},
])
: [];

View File

@@ -116,6 +116,54 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
{ 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.'),

View File

@@ -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<typeof parseBudgetParam>,
extractionResults: Record<string, Record<string, unknown>>,
): 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<string, Record<string, unknown>> = {
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<number> {
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<string[]> {
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<Array<{ name: string; value: string | number | boolean }>> {
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<string[]> {
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<string> {
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<string> {
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<number> {
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<number> => {
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<string, { start: number; end: number }> = {};
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;
}