From f90fc6934195548daf98b771f2eaea5f44a85587 Mon Sep 17 00:00:00 2001 From: Matt Farrell <10377148+MattFaz@users.noreply.github.com> Date: Fri, 14 Feb 2025 10:53:28 +1100 Subject: [PATCH] Add percentage adjustments to schedule templates (#4098) (#4257) * add percentage adjustments to schedule templates * update release note * remove unecessary parentheses * Update packages/loot-core/src/server/budget/goalsSchedule.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * PR comments addressed * Linting fixes * Updated error handling, added tests * Linting fixes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: youngcw --- .../src/server/budget/goal-template.pegjs | 32 +++++++++++++++---- .../src/server/budget/goalsSchedule.ts | 7 +++- .../src/server/budget/template-notes.test.ts | 32 +++++++++++++++++++ .../src/server/budget/template-notes.ts | 22 ++++++++++++- .../src/server/budget/types/templates.d.ts | 1 + upcoming-release-notes/4257.md | 6 ++++ 6 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 upcoming-release-notes/4257.md diff --git a/packages/loot-core/src/server/budget/goal-template.pegjs b/packages/loot-core/src/server/budget/goal-template.pegjs index 3d78ada8fb6..730da339421 100644 --- a/packages/loot-core/src/server/budget/goal-template.pegjs +++ b/packages/loot-core/src/server/budget/goal-template.pegjs @@ -18,8 +18,8 @@ expr { return { type: 'simple', monthly, limit, priority: template.priority, directive: template.directive }} / template: template _ limit: limit { return { type: 'simple', monthly: null, limit, priority: template.priority, directive: template.directive }} - / template: template _ schedule _ full:full? name: name - { return { type: 'schedule', name, priority: template.priority, directive: template.directive, full }} + / template: template _ schedule:schedule _ full:full? name:rawScheduleName modifiers:modifiers? + { return { type: 'schedule', name: name.trim(), priority: template.priority, directive: template.directive, full, adjustment: modifiers?.adjustment }} / template: template _ remainder: remainder limit: limit? { return { type: 'remainder', priority: null, directive: template.directive, weight: remainder, limit }} / template: template _ 'average'i _ amount: positive _ 'months'i? @@ -28,6 +28,13 @@ expr { return { type: 'copy', priority: template.priority, directive: template.directive, lookBack: +lookBack, limit }} / goal: goal amount: amount { return {type: 'simple', amount: amount, priority: null, directive: goal }} +modifiers = _ '[' modifier:modifier ']' { return modifier } + +modifier + = op:('increase'i / 'decrease'i) _ value:percent { + const multiplier = op.toLowerCase() === 'increase' ? 1 : -1; + return { adjustment: multiplier * +value } + } repeat 'repeat interval' = 'month'i { return { annual: false }} @@ -59,24 +66,37 @@ repeatEvery = 'repeat'i _ 'every'i starting = 'starting'i upTo = 'up'i _ 'to'i hold = 'hold'i {return true} -schedule = 'schedule'i +schedule = 'schedule'i { return text() } full = 'full'i _ {return true} priority = '-'i number: number {return number} remainder = 'remainder'i _? weight: positive? { return +weight || 1 } template = '#template' priority: priority? {return {priority: +priority, directive: 'template'}} goal = '#goal'i { return 'goal'} -_ 'space' = ' '+ +_ "whitespace" = [ \t]* { return text() } +__ "mandatory whitespace" = [ \t]+ { return text() } + d 'digit' = [0-9] number 'number' = $(d+) positive = $([1-9][0-9]*) amount 'amount' = currencySymbol? _? amount: $('-'?d+ ('.' (d d?)?)?) { return +amount } -percent 'percentage' = percent: $(d+ ('.' (d+)?)?) _? '%' { return +percent } +percent 'percentage' = percent: $(d+ ('.' (d+)?)?) _? '%' { return percent } year 'year' = $(d d d d) month 'month' = $(year '-' d d) day 'day' = $(d d) date = $(month '-' day) currencySymbol 'currency symbol' = symbol: . & { return /\p{Sc}/u.test(symbol) } -name 'Name' = $([^\r\n\t]+) +// Match schedule name including spaces up until we see a [, looking ahead to make sure it's followed by increase/decrease +rawScheduleName = $( + ( + [^ \t\r\n\[] // First character can't be space or [ + ( + [^\r\n\[] // Subsequent characters can include spaces but not [ + / + (![^\r\n\[]* '['('increase'i/'decrease'i)) [ ] // Or spaces if not followed by [increase/decrease + )* + ) +) { return text() } +name 'Name' = $([^\r\n\t]+) { return text() } \ No newline at end of file diff --git a/packages/loot-core/src/server/budget/goalsSchedule.ts b/packages/loot-core/src/server/budget/goalsSchedule.ts index 9eeef419113..9705a81afc2 100644 --- a/packages/loot-core/src/server/budget/goalsSchedule.ts +++ b/packages/loot-core/src/server/budget/goalsSchedule.ts @@ -29,11 +29,16 @@ async function createScheduleList( const conditions = rule.serialize().conditions; const { date: dateConditions, amount: amountCondition } = extractScheduleConds(conditions); - const scheduleAmount = + let scheduleAmount = amountCondition.op === 'isbetween' ? Math.round(amountCondition.value.num1 + amountCondition.value.num2) / 2 : amountCondition.value; + // Apply adjustment percentage if specified + if (template[ll].adjustment) { + const adjustmentFactor = 1 + template[ll].adjustment / 100; + scheduleAmount = Math.round(scheduleAmount * adjustmentFactor); + } const { amount: postRuleAmount, subtransactions } = rule.execActions({ amount: scheduleAmount, category: category.id, diff --git a/packages/loot-core/src/server/budget/template-notes.test.ts b/packages/loot-core/src/server/budget/template-notes.test.ts index f2e36c7281c..2621b03273d 100644 --- a/packages/loot-core/src/server/budget/template-notes.test.ts +++ b/packages/loot-core/src/server/budget/template-notes.test.ts @@ -227,6 +227,38 @@ describe('checkTemplates', () => { pre: 'Category 1: Schedule “Non-existent Schedule” does not exist', }, }, + { + description: 'Returns errors for invalid increase schedule adjustments', + mockTemplateNotes: [ + { + id: 'cat1', + name: 'Category 1', + note: '#template schedule Mock Schedule 1 [increase 1001%]', + }, + ], + mockSchedules: mockSchedules(), + expected: { + sticky: true, + message: 'There were errors interpreting some templates:', + pre: 'Category 1: #template schedule Mock Schedule 1 [increase 1001%]\nError: Invalid adjustment percentage (1001%). Must be between -100% and 1000%', + }, + }, + { + description: 'Returns errors for invalid decrease schedule adjustments', + mockTemplateNotes: [ + { + id: 'cat1', + name: 'Category 1', + note: '#template schedule Mock Schedule 1 [decrease 101%]', + }, + ], + mockSchedules: mockSchedules(), + expected: { + sticky: true, + message: 'There were errors interpreting some templates:', + pre: 'Category 1: #template schedule Mock Schedule 1 [decrease 101%]\nError: Invalid adjustment percentage (-101%). Must be between -100% and 1000%', + }, + }, ]; it.each(testCases)( diff --git a/packages/loot-core/src/server/budget/template-notes.ts b/packages/loot-core/src/server/budget/template-notes.ts index 7761b4f4439..e0243c7ed3c 100644 --- a/packages/loot-core/src/server/budget/template-notes.ts +++ b/packages/loot-core/src/server/budget/template-notes.ts @@ -43,7 +43,12 @@ export async function checkTemplates(): Promise { categoryWithTemplates.forEach(({ name, templates }) => { templates.forEach(template => { if (template.type === 'error') { - errors.push(`${name}: ${template.line}`); + // Only show detailed error for adjustment-related errors + if (template.error && template.error.includes('adjustment')) { + errors.push(`${name}: ${template.line}\nError: ${template.error}`); + } else { + errors.push(`${name}: ${template.line}`); + } } else if ( template.type === 'schedule' && !scheduleNames.includes(template.name) @@ -91,6 +96,21 @@ async function getCategoriesWithTemplates(): Promise { try { const parsedTemplate: Template = parse(trimmedLine); + // Validate schedule adjustments + if ( + parsedTemplate.type === 'schedule' && + parsedTemplate.adjustment !== undefined + ) { + if ( + parsedTemplate.adjustment <= -100 || + parsedTemplate.adjustment > 1000 + ) { + throw new Error( + `Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`, + ); + } + } + parsedTemplates.push(parsedTemplate); } catch (e: unknown) { parsedTemplates.push({ diff --git a/packages/loot-core/src/server/budget/types/templates.d.ts b/packages/loot-core/src/server/budget/types/templates.d.ts index 8b5eb6e323d..73969f1554d 100644 --- a/packages/loot-core/src/server/budget/types/templates.d.ts +++ b/packages/loot-core/src/server/budget/types/templates.d.ts @@ -45,6 +45,7 @@ interface ScheduleTemplate extends BaseTemplate { type: 'schedule'; name: string; full?: boolean; + adjustment?: number; } interface RemainderTemplate extends BaseTemplate { diff --git a/upcoming-release-notes/4257.md b/upcoming-release-notes/4257.md new file mode 100644 index 00000000000..27615aac638 --- /dev/null +++ b/upcoming-release-notes/4257.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MattFaz] +--- + +Add percentage adjustments to schedule templates