Skip to content

Commit 26dbb21

Browse files
jfdomingyoungcw
andauthored
Implement missing logic for limit template type (#6690)
* core: support limit refill templates * notes: refill templates * core: apply refill limits during runs * core: prioritize refill limits * Patch * Update release note * Fix typecheck * rework. Tests and template notes still need reworked * fix parser syntax * Fix type issue * Fix after rebase, support merging limit+refill * PR feedback --------- Co-authored-by: youngcw <calebyoung94@gmail.com>
1 parent c6656a2 commit 26dbb21

File tree

8 files changed

+185
-6
lines changed

8 files changed

+185
-6
lines changed

packages/desktop-client/src/components/budget/goals/reducer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export const getInitialState = (template: Template | null): ReducerState => {
3838
throw new Error('Remainder is not yet supported');
3939
case 'limit':
4040
throw new Error('Limit is not yet supported');
41+
case 'refill':
42+
throw new Error('Refill is not yet supported');
4143
case 'average':
4244
case 'copy':
4345
return {

packages/loot-core/src/server/budget/category-template-context.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,105 @@ describe('CategoryTemplateContext', () => {
169169
});
170170
});
171171

172+
describe('runRefill', () => {
173+
it('should refill up to the monthly limit', async () => {
174+
const category: CategoryEntity = {
175+
id: 'test',
176+
name: 'Test Category',
177+
group: 'test-group',
178+
is_income: false,
179+
};
180+
const limitTemplate: Template = {
181+
type: 'limit',
182+
amount: 150,
183+
hold: false,
184+
period: 'monthly',
185+
directive: 'template',
186+
priority: null,
187+
};
188+
const refillTemplate: Template = {
189+
type: 'refill',
190+
directive: 'template',
191+
priority: 1,
192+
};
193+
194+
const instance = new TestCategoryTemplateContext(
195+
[limitTemplate, refillTemplate],
196+
category,
197+
'2024-01',
198+
9000,
199+
0,
200+
);
201+
202+
const result = await instance.runTemplatesForPriority(1, 10000, 10000);
203+
expect(result).toBe(6000); // 150 - 90
204+
});
205+
206+
it('should handle weekly limit refill', async () => {
207+
const category: CategoryEntity = {
208+
id: 'test',
209+
name: 'Test Category',
210+
group: 'test-group',
211+
is_income: false,
212+
};
213+
const limitTemplate: Template = {
214+
type: 'limit',
215+
amount: 100,
216+
hold: false,
217+
period: 'weekly',
218+
start: '2024-01-01',
219+
directive: 'template',
220+
priority: null,
221+
};
222+
const refillTemplate: Template = {
223+
type: 'refill',
224+
directive: 'template',
225+
priority: 1,
226+
};
227+
228+
const instance = new TestCategoryTemplateContext(
229+
[limitTemplate, refillTemplate],
230+
category,
231+
'2024-01',
232+
0,
233+
0,
234+
);
235+
const result = await instance.runTemplatesForPriority(1, 100000, 100000);
236+
expect(result).toBe(50000); // 5 Mondays * 100
237+
});
238+
239+
it('should handle daily limit refill', async () => {
240+
const category: CategoryEntity = {
241+
id: 'test',
242+
name: 'Test Category',
243+
group: 'test-group',
244+
is_income: false,
245+
};
246+
const limitTemplate: Template = {
247+
type: 'limit',
248+
amount: 10,
249+
hold: false,
250+
period: 'daily',
251+
directive: 'template',
252+
priority: null,
253+
};
254+
const refillTemplate: Template = {
255+
type: 'refill',
256+
directive: 'template',
257+
priority: 1,
258+
};
259+
const instance = new TestCategoryTemplateContext(
260+
[limitTemplate, refillTemplate],
261+
category,
262+
'2024-01',
263+
0,
264+
0,
265+
);
266+
const result = await instance.runTemplatesForPriority(1, 100000, 100000);
267+
expect(result).toBe(31000); // 31 days * 10
268+
});
269+
});
270+
172271
describe('runCopy', () => {
173272
let instance: TestCategoryTemplateContext;
174273

packages/loot-core/src/server/budget/category-template-context.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
GoalTemplate,
1313
PercentageTemplate,
1414
PeriodicTemplate,
15+
RefillTemplate,
1516
RemainderTemplate,
1617
SimpleTemplate,
1718
SpendTemplate,
@@ -157,6 +158,10 @@ export class CategoryTemplateContext {
157158
newBudget = CategoryTemplateContext.runSimple(template, this);
158159
break;
159160
}
161+
case 'refill': {
162+
newBudget = CategoryTemplateContext.runRefill(template, this);
163+
break;
164+
}
160165
case 'copy': {
161166
newBudget = await CategoryTemplateContext.runCopy(template, this);
162167
break;
@@ -553,6 +558,13 @@ export class CategoryTemplateContext {
553558
}
554559
}
555560

561+
static runRefill(
562+
template: RefillTemplate,
563+
templateContext: CategoryTemplateContext,
564+
): number {
565+
return templateContext.limitAmount - templateContext.fromLastMonth;
566+
}
567+
556568
static async runCopy(
557569
template: CopyTemplate,
558570
templateContext: CategoryTemplateContext,
@@ -577,7 +589,8 @@ export class CategoryTemplateContext {
577589
);
578590
const period = template.period.period;
579591
const numPeriods = template.period.amount;
580-
let date = template.starting;
592+
let date =
593+
template.starting ?? monthUtils.firstDayOfMonth(templateContext.month);
581594

582595
let dateShiftFunction;
583596
switch (period) {

packages/loot-core/src/server/budget/goal-template.pegjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,4 @@ rawScheduleName = $(
107107
)
108108
) { return text().trim() }
109109

110-
name 'Name' = $([^\r\n\t]+) { return text() }
110+
name 'Name' = $([^\r\n\t]+) { return text() }

packages/loot-core/src/server/budget/template-notes.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,40 @@ describe('unparse/parse round-trip', () => {
354354
expect(parsed).toEqual(reparsed);
355355
});
356356
});
357+
358+
describe('unparse limit templates', () => {
359+
it('serializes refill limits to notes syntax', async () => {
360+
const serialized = await unparse([
361+
{
362+
type: 'limit',
363+
amount: 150,
364+
hold: false,
365+
period: 'monthly',
366+
directive: 'template',
367+
priority: null,
368+
},
369+
{
370+
type: 'refill',
371+
directive: 'template',
372+
priority: 2,
373+
},
374+
]);
375+
376+
expect(serialized).toBe('#template-2 up to 150');
377+
});
378+
379+
it('serializes non-refill limits with a zero base amount', async () => {
380+
const serialized = await unparse([
381+
{
382+
type: 'limit',
383+
amount: 200,
384+
hold: false,
385+
period: 'monthly',
386+
directive: 'template',
387+
priority: null,
388+
},
389+
]);
390+
391+
expect(serialized).toBe('#template 0 up to 200');
392+
});
393+
});

packages/loot-core/src/server/budget/template-notes.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,17 @@ async function getCategoriesWithTemplates(): Promise<
142142
return templatesForCategory;
143143
}
144144

145+
function prefixFromPriority(priority: number | null): string {
146+
return priority === null ? TEMPLATE_PREFIX : `${TEMPLATE_PREFIX}-${priority}`;
147+
}
148+
145149
export async function unparse(templates: Template[]): Promise<string> {
146-
return templates
150+
// Refill will be merged into the limit template if both exist
151+
// Assumption: at most one limit and one refill template per category
152+
const refill = templates.find(t => t.type === 'refill');
153+
const withoutRefill = templates.filter(t => t.type !== 'refill');
154+
155+
return withoutRefill
147156
.flatMap(template => {
148157
if (template.type === 'error') {
149158
return [];
@@ -153,9 +162,7 @@ export async function unparse(templates: Template[]): Promise<string> {
153162
return `${GOAL_PREFIX} ${template.amount}`;
154163
}
155164

156-
const prefix = template.priority
157-
? `${TEMPLATE_PREFIX}-${template.priority}`
158-
: TEMPLATE_PREFIX;
165+
const prefix = prefixFromPriority(template.priority);
159166

160167
switch (template.type) {
161168
case 'simple': {
@@ -245,6 +252,16 @@ export async function unparse(templates: Template[]): Promise<string> {
245252
const result = `${prefix} copy from ${template.lookBack} months ago`;
246253
return result;
247254
}
255+
case 'limit': {
256+
if (!refill) {
257+
// #template 0 up to <limit>
258+
return `${prefix} 0 ${limitToString(template)}`;
259+
}
260+
// #template up to <limit>
261+
const mergedPrefix = prefixFromPriority(refill.priority);
262+
return `${mergedPrefix} ${limitToString(template)}`;
263+
}
264+
// No 'refill' support since a refill requires a limit
248265
default:
249266
return [];
250267
}

packages/loot-core/src/types/models/templates.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export type RemainderTemplate = {
9292
priority: null;
9393
} & BaseTemplate;
9494

95+
export type RefillTemplate = {
96+
type: 'refill';
97+
} & BaseTemplateWithPriority;
98+
9599
export type GoalTemplate = {
96100
type: 'goal';
97101
amount: number;
@@ -126,5 +130,6 @@ export type Template =
126130
| AverageTemplate
127131
| GoalTemplate
128132
| CopyTemplate
133+
| RefillTemplate
129134
| LimitTemplate
130135
| ErrorTemplate;

upcoming-release-notes/6690.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
category: Enhancements
3+
authors: [jfdoming]
4+
---
5+
6+
Implement missing logic for refill template type

0 commit comments

Comments
 (0)