Skip to content

Commit

Permalink
Add percentage adjustments to schedule templates (#4098) (#4257)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
3 people authored Feb 13, 2025
1 parent 7e1c0a8 commit f90fc69
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 8 deletions.
32 changes: 26 additions & 6 deletions packages/loot-core/src/server/budget/goal-template.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 }}
Expand Down Expand Up @@ -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() }
7 changes: 6 additions & 1 deletion packages/loot-core/src/server/budget/goalsSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions packages/loot-core/src/server/budget/template-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)(
Expand Down
22 changes: 21 additions & 1 deletion packages/loot-core/src/server/budget/template-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ export async function checkTemplates(): Promise<Notification> {
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)
Expand Down Expand Up @@ -91,6 +96,21 @@ async function getCategoriesWithTemplates(): Promise<CategoryWithTemplates[]> {
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({
Expand Down
1 change: 1 addition & 0 deletions packages/loot-core/src/server/budget/types/templates.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface ScheduleTemplate extends BaseTemplate {
type: 'schedule';
name: string;
full?: boolean;
adjustment?: number;
}

interface RemainderTemplate extends BaseTemplate {
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/4257.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MattFaz]
---

Add percentage adjustments to schedule templates

0 comments on commit f90fc69

Please sign in to comment.