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 <calebyoung94@gmail.com>
This commit is contained in:
Matt Farrell
2025-02-14 10:53:28 +11:00
committed by GitHub
parent 7e1c0a8682
commit f90fc69341
6 changed files with 92 additions and 8 deletions

View File

@@ -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() }

View File

@@ -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,

View File

@@ -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)(

View File

@@ -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)
@@ -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({

View File

@@ -45,6 +45,7 @@ interface ScheduleTemplate extends BaseTemplate {
type: 'schedule';
name: string;
full?: boolean;
adjustment?: number;
}
interface RemainderTemplate extends BaseTemplate {

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MattFaz]
---
Add percentage adjustments to schedule templates