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>
This commit is contained in:
Julian Dominguez-Schatz
2026-02-14 16:52:48 -05:00
committed by GitHub
parent c6656a2815
commit 26dbb219aa
8 changed files with 185 additions and 6 deletions

View File

@@ -38,6 +38,8 @@ export const getInitialState = (template: Template | null): ReducerState => {
throw new Error('Remainder is not yet supported');
case 'limit':
throw new Error('Limit is not yet supported');
case 'refill':
throw new Error('Refill is not yet supported');
case 'average':
case 'copy':
return {

View File

@@ -169,6 +169,105 @@ describe('CategoryTemplateContext', () => {
});
});
describe('runRefill', () => {
it('should refill up to the monthly limit', async () => {
const category: CategoryEntity = {
id: 'test',
name: 'Test Category',
group: 'test-group',
is_income: false,
};
const limitTemplate: Template = {
type: 'limit',
amount: 150,
hold: false,
period: 'monthly',
directive: 'template',
priority: null,
};
const refillTemplate: Template = {
type: 'refill',
directive: 'template',
priority: 1,
};
const instance = new TestCategoryTemplateContext(
[limitTemplate, refillTemplate],
category,
'2024-01',
9000,
0,
);
const result = await instance.runTemplatesForPriority(1, 10000, 10000);
expect(result).toBe(6000); // 150 - 90
});
it('should handle weekly limit refill', async () => {
const category: CategoryEntity = {
id: 'test',
name: 'Test Category',
group: 'test-group',
is_income: false,
};
const limitTemplate: Template = {
type: 'limit',
amount: 100,
hold: false,
period: 'weekly',
start: '2024-01-01',
directive: 'template',
priority: null,
};
const refillTemplate: Template = {
type: 'refill',
directive: 'template',
priority: 1,
};
const instance = new TestCategoryTemplateContext(
[limitTemplate, refillTemplate],
category,
'2024-01',
0,
0,
);
const result = await instance.runTemplatesForPriority(1, 100000, 100000);
expect(result).toBe(50000); // 5 Mondays * 100
});
it('should handle daily limit refill', async () => {
const category: CategoryEntity = {
id: 'test',
name: 'Test Category',
group: 'test-group',
is_income: false,
};
const limitTemplate: Template = {
type: 'limit',
amount: 10,
hold: false,
period: 'daily',
directive: 'template',
priority: null,
};
const refillTemplate: Template = {
type: 'refill',
directive: 'template',
priority: 1,
};
const instance = new TestCategoryTemplateContext(
[limitTemplate, refillTemplate],
category,
'2024-01',
0,
0,
);
const result = await instance.runTemplatesForPriority(1, 100000, 100000);
expect(result).toBe(31000); // 31 days * 10
});
});
describe('runCopy', () => {
let instance: TestCategoryTemplateContext;

View File

@@ -12,6 +12,7 @@ import type {
GoalTemplate,
PercentageTemplate,
PeriodicTemplate,
RefillTemplate,
RemainderTemplate,
SimpleTemplate,
SpendTemplate,
@@ -157,6 +158,10 @@ export class CategoryTemplateContext {
newBudget = CategoryTemplateContext.runSimple(template, this);
break;
}
case 'refill': {
newBudget = CategoryTemplateContext.runRefill(template, this);
break;
}
case 'copy': {
newBudget = await CategoryTemplateContext.runCopy(template, this);
break;
@@ -553,6 +558,13 @@ export class CategoryTemplateContext {
}
}
static runRefill(
template: RefillTemplate,
templateContext: CategoryTemplateContext,
): number {
return templateContext.limitAmount - templateContext.fromLastMonth;
}
static async runCopy(
template: CopyTemplate,
templateContext: CategoryTemplateContext,
@@ -577,7 +589,8 @@ export class CategoryTemplateContext {
);
const period = template.period.period;
const numPeriods = template.period.amount;
let date = template.starting;
let date =
template.starting ?? monthUtils.firstDayOfMonth(templateContext.month);
let dateShiftFunction;
switch (period) {

View File

@@ -107,4 +107,4 @@ rawScheduleName = $(
)
) { return text().trim() }
name 'Name' = $([^\r\n\t]+) { return text() }
name 'Name' = $([^\r\n\t]+) { return text() }

View File

@@ -354,3 +354,40 @@ describe('unparse/parse round-trip', () => {
expect(parsed).toEqual(reparsed);
});
});
describe('unparse limit templates', () => {
it('serializes refill limits to notes syntax', async () => {
const serialized = await unparse([
{
type: 'limit',
amount: 150,
hold: false,
period: 'monthly',
directive: 'template',
priority: null,
},
{
type: 'refill',
directive: 'template',
priority: 2,
},
]);
expect(serialized).toBe('#template-2 up to 150');
});
it('serializes non-refill limits with a zero base amount', async () => {
const serialized = await unparse([
{
type: 'limit',
amount: 200,
hold: false,
period: 'monthly',
directive: 'template',
priority: null,
},
]);
expect(serialized).toBe('#template 0 up to 200');
});
});

View File

@@ -142,8 +142,17 @@ async function getCategoriesWithTemplates(): Promise<
return templatesForCategory;
}
function prefixFromPriority(priority: number | null): string {
return priority === null ? TEMPLATE_PREFIX : `${TEMPLATE_PREFIX}-${priority}`;
}
export async function unparse(templates: Template[]): Promise<string> {
return templates
// Refill will be merged into the limit template if both exist
// Assumption: at most one limit and one refill template per category
const refill = templates.find(t => t.type === 'refill');
const withoutRefill = templates.filter(t => t.type !== 'refill');
return withoutRefill
.flatMap(template => {
if (template.type === 'error') {
return [];
@@ -153,9 +162,7 @@ export async function unparse(templates: Template[]): Promise<string> {
return `${GOAL_PREFIX} ${template.amount}`;
}
const prefix = template.priority
? `${TEMPLATE_PREFIX}-${template.priority}`
: TEMPLATE_PREFIX;
const prefix = prefixFromPriority(template.priority);
switch (template.type) {
case 'simple': {
@@ -245,6 +252,16 @@ export async function unparse(templates: Template[]): Promise<string> {
const result = `${prefix} copy from ${template.lookBack} months ago`;
return result;
}
case 'limit': {
if (!refill) {
// #template 0 up to <limit>
return `${prefix} 0 ${limitToString(template)}`;
}
// #template up to <limit>
const mergedPrefix = prefixFromPriority(refill.priority);
return `${mergedPrefix} ${limitToString(template)}`;
}
// No 'refill' support since a refill requires a limit
default:
return [];
}

View File

@@ -92,6 +92,10 @@ export type RemainderTemplate = {
priority: null;
} & BaseTemplate;
export type RefillTemplate = {
type: 'refill';
} & BaseTemplateWithPriority;
export type GoalTemplate = {
type: 'goal';
amount: number;
@@ -126,5 +130,6 @@ export type Template =
| AverageTemplate
| GoalTemplate
| CopyTemplate
| RefillTemplate
| LimitTemplate
| ErrorTemplate;

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jfdoming]
---
Implement missing logic for refill template type