Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6dd121f
Add 'until' feature to templates, skip expired templates & optimized …
Gylfkxjyjdll Feb 10, 2026
4887baf
Add 'until' feature to templates, skip expired templates & optimized …
Gylfkxjyjdll Feb 10, 2026
71f2945
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 10, 2026
6019dcd
Fix greedy matching in PEG.js grammar. The rawScheduleName and name r…
Gylfkxjyjdll Feb 10, 2026
50cc145
Add starting and until fields to template test case.
Gylfkxjyjdll Feb 10, 2026
e32ee7f
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 10, 2026
511e766
Add starting and until fields to storeNoteTemplates test cases
Gylfkxjyjdll Feb 10, 2026
77e9ed2
Remove trailing whitespace on lines 55-56 to fix lint formatting error.
Gylfkxjyjdll Feb 10, 2026
c499ee7
Remove unused import from template-notes.test.ts
Gylfkxjyjdll Feb 10, 2026
23f8700
Add filename option to visualizer in vite.config.mts for web stats ou…
Gylfkxjyjdll Feb 10, 2026
0e7538a
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 10, 2026
332811b
undo.. web stats visualizer configuration
Gylfkxjyjdll Feb 10, 2026
351d416
undo vite.config.mts changes......
Gylfkxjyjdll Feb 10, 2026
00193a5
repeat template checks if YYYY-MM then add -1
Gylfkxjyjdll Feb 11, 2026
81f94e7
Refactor runPeriodic method to handle date normalization and until co…
Gylfkxjyjdll Feb 14, 2026
1f3df9e
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 14, 2026
bbec1c4
Refactor goal-template.pegjs to streamline
Gylfkxjyjdll Feb 15, 2026
f7f9c3f
Improve comments for date normalization and until logic in CategoryTe…
Gylfkxjyjdll Feb 15, 2026
f84f9ab
Merge branch 'master' into Until-template
Gylfkxjyjdll Feb 15, 2026
5c1a175
Refactor date handling in CategoryTemplateContext and update name par…
Gylfkxjyjdll Feb 15, 2026
8d7b1f1
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 15, 2026
8f48f8e
Add date fields to budget templates for management
Gylfkxjyjdll Feb 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ export class CategoryTemplateContext {
// sort the template lines into regular template, goals, and remainder templates
if (templates) {
templates.forEach(t => {
// Skip expired templates (where month > until)
if (CategoryTemplateContext.isTemplateExpired(t, month)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is simpler then I would have thought. I was worried this would have to touch a lot of things to work.

return;
}
if (
t.directive === 'template' &&
t.type !== 'remainder' &&
Expand Down Expand Up @@ -441,6 +445,26 @@ export class CategoryTemplateContext {
});
}

private static isTemplateExpired(template: Template, month: string): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name here probably should be something more general as expired sounds like you are only doing the "until" check.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look later on it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, name should be more specific now.

// Check if template hasn't started yet (if starting is set)
if ('starting' in template && template.starting) {
const startingMonth = template.starting.substring(0, 7);
if (monthUtils.differenceInCalendarMonths(month, startingMonth) < 0) {
return true;
}
}
// Check if template has expired (if until is set)
if ('until' in template && template.until) {
// Extract the month part if until is a full date (YYYY-MM-DD), otherwise use as-is (YYYY-MM)
const untilMonth = template.until.substring(0, 7);
// Return true if current month is after the until month
if (monthUtils.differenceInCalendarMonths(month, untilMonth) > 0) {
return true;
}
}
return false;
}

private checkLimit(templates: Template[]) {
for (const template of templates.filter(
t =>
Expand Down
39 changes: 21 additions & 18 deletions packages/loot-core/src/server/budget/goal-template.pegjs
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
// https://peggyjs.org

expr
= template: template _ percentOf:percentOf category: name
{ return { type: 'percentage', percent: +percentOf.percent, previous: percentOf.prev, category, priority: template.priority, directive: template.directive }}
/ template: template _ amount: amount _ repeatEvery _ period: periodCount _ starting _ starting: date limit: limit?
{ return { type: 'periodic', amount, period, starting, limit, priority: template.priority, directive: template.directive }}
/ template: template _ amount: amount _ by _ month: month from: spendFrom? repeat: (_ repeatEvery _ repeat)?
= template: template _ percentOf:percentOf category: name starting: startingDate? until:until?
{ return { type: 'percentage', percent: +percentOf.percent, previous: percentOf.prev, category, starting, until, priority: template.priority, directive: template.directive }}
/ template: template _ amount: amount _ repeatEvery _ period: periodCount _ starting: startingDate limit: limit? until: until?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks existing functionality. Weekly and daily periodic templates need a specific day to start on. If there is a clean way to force the start date to have a day on weekly and daily versions then we are fine. If not, allowing YYYY-MM dates needs to be re-evaluated.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right... I didn't think about that. I need to think about this and look at it more closely.
The YYYY-MM format should be a convenience function that makes things intuitive when you have to write a lot. And the syntax "starting YYYY-MM until YYYY-MM" remains consistent.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just the periodic template could require a day for the starting value. I would think the timeframe check would still work basically the same if the starting value has a day for those.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the repeat template:

      // Wenn "repeat every week/day" und "starting" ist  YYYY-MM = 7 Zeichen, ergänze -01
      starting: (period.period === 'week' || period.period === 'day') && starting?.length === 7 ? starting + '-01' : starting,

But i have to think about, if this will work in all cases..

And if this is needed in the until template as well..

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to overthink this and will take some time. Because repeat every day starting 2026-01-01 until 2026-01-05 still calculates the whole month. didn't think about this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think only allowing the until and starting values to be months makes sense for everything but the periodic template. If just the periodic template can be fixed then you should be good

{ return { type: 'periodic', amount, period, starting, limit, until, priority: template.priority, directive: template.directive }}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if you would catch that there was already a start date here 🙂

/ template: template _ amount: amount _ by _ month: month from: spendFrom? repeat: (_ repeatEvery _ repeat)? starting: startingDate? until: until?
{ return {
type: from ? 'spend' : 'by',
amount,
month,
...(repeat ? repeat[3] : {}),
from,
starting,
until,
priority: template.priority, directive: template.directive
}}
/ template: template _ monthly: amount limit: limit?
{ 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:schedule _ full:full? name:rawScheduleName modifiers:modifiers?
{ return { type: 'schedule', name: name.trim(), priority: template.priority, directive: template.directive, full, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType }}
/ 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? modifiers:modifiers?
{ return { type: 'average', numMonths: +amount, priority: template.priority, directive: template.directive, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType }}
/ template: template _ 'copy from'i _ lookBack: positive _ 'months ago'i limit:limit?
{ return { type: 'copy', priority: template.priority, directive: template.directive, lookBack: +lookBack, limit }}
/ template: template _ monthly: amount limit: limit? starting: startingDate? until: until?
{ return { type: 'simple', monthly, limit, starting, until, priority: template.priority, directive: template.directive }}
/ template: template _ limit: limit starting: startingDate? until: until?
{ return { type: 'simple', monthly: null, limit, starting, until, priority: template.priority, directive: template.directive }}
/ template: template _ schedule:schedule _ full:full? name:rawScheduleName modifiers:modifiers? starting: startingDate? until: until?
{ return { type: 'schedule', name: name.trim(), priority: template.priority, directive: template.directive, full, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType, starting, until }}
/ template: template _ remainder: remainder limit: limit? starting: startingDate? until: until?
{ return { type: 'remainder', priority: null, directive: template.directive, weight: remainder, limit, starting, until }}
/ template: template _ 'average'i _ amount: positive _ 'months'i? modifiers:modifiers? starting: startingDate? until: until?
{ return { type: 'average', numMonths: +amount, priority: template.priority, directive: template.directive, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType, starting, until }}
/ template: template _ 'copy from'i _ lookBack: positive _ 'months ago'i limit:limit? starting: startingDate? until: until?
{ return { type: 'copy', priority: template.priority, directive: template.directive, lookBack: +lookBack, limit, starting, until }}
/ goal: goal amount: amount { return {type: 'goal', amount: amount, priority: null, directive: goal }}

modifiers = _ '[' modifier:modifier ']' { return modifier }
Expand Down Expand Up @@ -71,9 +73,10 @@ weeks = 'weeks'i
by = 'by'i
of = 'of'i
repeatEvery = 'repeat'i _ 'every'i
starting = 'starting'i
startingDate = _ 'starting'i _ val: $(year '-' d d ('-' d d)?) { return val }
upTo = 'up'i _ 'to'i
hold = 'hold'i {return true}
until = _ 'until'i _ val: $(year '-' d d ('-' d d)?) { return val }
schedule = 'schedule'i { return text() }
full = 'full'i _ {return true}
priority = '-'i number: number {return number}
Expand Down
33 changes: 33 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 @@ -147,7 +147,7 @@
}

mockTemplateNotes.forEach(({ id }) => {
expect(db.updateWithSchema).toHaveBeenCalledWith('categories', {

Check failure on line 150 in packages/loot-core/src/server/budget/template-notes.test.ts

View workflow job for this annotation

GitHub Actions / test

src/server/budget/template-notes.test.ts > storeNoteTemplates > 'Stores template when prefix is used w…'

AssertionError: expected "vi.fn()" to be called with arguments: [ 'categories', { id: 'cat1', …(2) } ] Received: 1st vi.fn() call: @@ -1,9 +1,9 @@ [ "categories", { - "goal_def": "[{\"type\":\"simple\",\"monthly\":12,\"limit\":null,\"priority\":0,\"directive\":\"template\"}]", + "goal_def": "[{\"type\":\"simple\",\"monthly\":12,\"limit\":null,\"starting\":null,\"until\":null,\"priority\":0,\"directive\":\"template\"}]", "id": "cat1", "template_settings": { "source": "notes", }, }, Number of calls: 1 ❯ src/server/budget/template-notes.test.ts:150:37 ❯ src/server/budget/template-notes.test.ts:149:25

Check failure on line 150 in packages/loot-core/src/server/budget/template-notes.test.ts

View workflow job for this annotation

GitHub Actions / test

src/server/budget/template-notes.test.ts > storeNoteTemplates > 'Stores negative templates for categor…'

AssertionError: expected "vi.fn()" to be called with arguments: [ 'categories', { id: 'cat1', …(2) } ] Received: 1st vi.fn() call: @@ -1,9 +1,9 @@ [ "categories", { - "goal_def": "[{\"type\":\"simple\",\"monthly\":-103.23,\"limit\":null,\"priority\":0,\"directive\":\"template\"}]", + "goal_def": "[{\"type\":\"simple\",\"monthly\":-103.23,\"limit\":null,\"starting\":null,\"until\":null,\"priority\":0,\"directive\":\"template\"}]", "id": "cat1", "template_settings": { "source": "notes", }, }, Number of calls: 1 ❯ src/server/budget/template-notes.test.ts:150:37 ❯ src/server/budget/template-notes.test.ts:149:25

Check failure on line 150 in packages/loot-core/src/server/budget/template-notes.test.ts

View workflow job for this annotation

GitHub Actions / test

src/server/budget/template-notes.test.ts > storeNoteTemplates > 'Stores templates for categories with …'

AssertionError: expected "vi.fn()" to be called with arguments: [ 'categories', { id: 'cat1', …(2) } ] Received: 1st vi.fn() call: @@ -1,9 +1,9 @@ [ "categories", { - "goal_def": "[{\"type\":\"simple\",\"monthly\":10,\"limit\":null,\"priority\":0,\"directive\":\"template\"}]", + "goal_def": "[{\"type\":\"simple\",\"monthly\":10,\"limit\":null,\"starting\":null,\"until\":null,\"priority\":0,\"directive\":\"template\"}]", "id": "cat1", "template_settings": { "source": "notes", }, }, Number of calls: 1 ❯ src/server/budget/template-notes.test.ts:150:37 ❯ src/server/budget/template-notes.test.ts:149:25
id,
goal_def: JSON.stringify(expectedTemplates),
template_settings: { source: 'notes' },
Expand Down Expand Up @@ -312,18 +312,35 @@
'#template up to 25 per day hold',
'#template up to 100 per week starting 2025-01-01',
'#template-2 123.45',
// simple with starting and until
'#template 10 starting 2025-01',
'#template 10 until 2025-12',
'#template 10 starting 2025-01 until 2025-12',
'#template up to 50 until 2025-06',
'#template 10 until 2025-12-15',
// schedule
'#template schedule Rent',
'#template schedule full Mortgage',
'#template schedule Netflix [increase 10%]',
'#template schedule full Groceries [decrease 5%]',
// schedule with starting and until
'#template schedule Rent starting 2025-01',
'#template schedule Rent until 2025-12',
'#template schedule Rent starting 2025-01 until 2025-12',
// percentage
'#template 50% of Utilities',
'#template 75% of previous Dining Out',
// percentage with starting and until
'#template 50% of Utilities starting 2025-01',
'#template 50% of Utilities until 2025-12',
'#template 50% of Utilities starting 2025-01 until 2025-12',
// periodic
'#template 200 repeat every 2 months starting 2025-06-01',
'#template 200 repeat every 2 months starting 2025-06',
'#template 300 repeat every week starting 2025-01-07',
'#template 400 repeat every year starting 2025-01-01 up to 50',
// periodic with until
'#template 200 repeat every 2 months starting 2025-06-01 until 2025-12',
// by / spend
'#template 500 by 2025-12',
'#template 600 by 2025-11 repeat every month',
Expand All @@ -332,16 +349,32 @@
'#template 900 by 2025-08 repeat every 3 years',
'#template 1000 by 2025-07 spend from 2025-01 repeat every month',
'#template 1100 by 2025-06 spend from 2025-02 repeat every 2 months',
// by / spend with starting and until
'#template 500 by 2025-12 starting 2025-01',
'#template 500 by 2025-12 until 2026-06',
'#template 500 by 2025-12 starting 2025-01 until 2026-06',
// remainder
'#template remainder',
'#template remainder 2',
'#template remainder 3 up to 10',
// remainder with starting and until
'#template remainder starting 2025-01',
'#template remainder until 2025-12',
'#template remainder starting 2025-01 until 2025-12',
// average
'#template average 6 months',
'#template-5 average 12 months',
// average with starting and until
'#template average 6 months starting 2025-01',
'#template average 6 months until 2025-12',
'#template average 6 months starting 2025-01 until 2025-12',
// copy
'#template copy from 3 months ago',
'#template copy from 6 months ago',
// copy with starting and until
'#template copy from 3 months ago starting 2025-01',
'#template copy from 3 months ago until 2025-12',
'#template copy from 3 months ago starting 2025-01 until 2025-12',
// goal
'#goal 1234',
];
Expand Down
66 changes: 56 additions & 10 deletions packages/loot-core/src/server/budget/template-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,24 @@ export async function unparse(templates: Template[]): Promise<string> {

switch (template.type) {
case 'simple': {
// Simple template syntax: #template[-prio] simple [monthly N] [limit]
// Simple template syntax: #template[-prio] simple [monthly N] [limit] [starting YYYY-MM] [until YYYY-MM]
let result = prefix;
if (template.monthly != null) {
result += ` ${template.monthly}`;
}
if (template.limit) {
result += ` ${limitToString(template.limit)}`;
}
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result.trim();
}
case 'schedule': {
// schedule syntax: #template[-prio] schedule <name> [full] [ [increase/decrease N%] ]
// schedule syntax: #template[-prio] schedule <name> [full] [ [increase/decrease N%] ] [starting YYYY-MM] [until YYYY-MM]
let result = `${prefix} schedule`;
if (template.full) {
result += ' full';
Expand All @@ -183,25 +189,41 @@ export async function unparse(templates: Template[]): Promise<string> {
const type = template.adjustmentType === 'percent' ? '%' : '';
result += ` [${op} ${val}${type}]`;
}
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
case 'percentage': {
// #template[-prio] <percent>% of [previous ]<category>
// #template[-prio] <percent>% of [previous ]<category> [starting YYYY-MM] [until YYYY-MM]
const prev = template.previous ? 'previous ' : '';
return `${prefix} ${trimTrailingZeros(template.percent)}% of ${prev}${template.category}`.trim();
let result = `${prefix} ${trimTrailingZeros(template.percent)}% of ${prev}${template.category}`;
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result.trim();
}
case 'periodic': {
// #template[-prio] <amount> repeat every <n> <period>(s) starting <date> [limit]
// #template[-prio] <amount> repeat every <n> <period>(s) starting <date> [limit] [until YYYY-MM]
const periodPart = periodToString(template.period);
let result = `${prefix} ${template.amount} repeat every ${periodPart} starting ${template.starting}`;
if (template.limit) {
result += ` ${limitToString(template.limit)}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
case 'by':
case 'spend': {
// #template[-prio] <amount> by <month> [spend from <month>] [repeat every <...>]
// #template[-prio] <amount> by <month> [spend from <month>] [repeat every <...>] [starting YYYY-MM] [until YYYY-MM]
let result = `${prefix} ${template.amount} by ${template.month}`;
if (template.type === 'spend' && template.from) {
result += ` spend from ${template.from}`;
Expand All @@ -213,20 +235,33 @@ export async function unparse(templates: Template[]): Promise<string> {
result += ` repeat every ${repeatInfo}`;
}
}
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
case 'remainder': {
// #template remainder [weight] [limit]
// #template remainder [weight] [limit] [starting YYYY-MM] [until YYYY-MM]
let result = `${prefix} remainder`;
if (template.weight !== undefined && template.weight !== 1) {
result += ` ${template.weight}`;
}
if (template.limit) {
result += ` ${limitToString(template.limit)}`;
}
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
case 'average': {
// #template average <numMonths> months [increase/decrease {number|number%}] [starting YYYY-MM] [until YYYY-MM]
let result = `${prefix} average ${template.numMonths} months`;

if (template.adjustment !== undefined) {
Expand All @@ -237,12 +272,23 @@ export async function unparse(templates: Template[]): Promise<string> {
result += ` [${op} ${val}${type}]`;
}

// #template average <numMonths> months [increase/decrease {number|number%}]
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
case 'copy': {
// #template copy from <lookBack> months ago [limit]
const result = `${prefix} copy from ${template.lookBack} months ago`;
// #template copy from <lookBack> months ago [starting YYYY-MM] [until YYYY-MM]
let result = `${prefix} copy from ${template.lookBack} months ago`;
if (template.starting) {
result += ` starting ${template.starting}`;
}
if (template.until) {
result += ` until ${template.until}`;
}
return result;
}
default:
Expand Down
4 changes: 4 additions & 0 deletions packages/loot-core/src/types/models/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ type BaseTemplate = {
type BaseTemplateWithPriority = {
priority: number;
directive: 'template';
starting?: string;
until?: string;
} & BaseTemplate;

export type PercentageTemplate = {
Expand Down Expand Up @@ -88,6 +90,8 @@ export type RemainderTemplate = {
period: 'daily' | 'weekly' | 'monthly';
start?: string;
};
starting?: string;
until?: string;
directive: 'template';
priority: null;
} & BaseTemplate;
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/6927.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [Gylfkxjyjdll]
---

Add 'until' feature to templates, skip expired templates & optimized the 'starting' template. This allows more control over the templates. Introduced 'until' field in template syntax to specify when a template should stop being applied. Updated parsing and un-parsing logic to handle 'until' in various template types. Implemented logic to skip expired templates based on the current month and 'until' date. Supports YYYY-MM and YYYY-MM-DD formats for the 'starting' template 'starting' can now be used everywhere
Loading