Skip to content

Commit 4f0437f

Browse files
committed
[AI] Add 'Copy to future months' budget option
Adds a new per-category budget menu option that copies the current month's budgeted amount to all future months that already exist in the budget. Works for both envelope and tracking budget types, and on both desktop (inline popover) and mobile (modal) views.
1 parent 477fed1 commit 4f0437f

13 files changed

Lines changed: 161 additions & 0 deletions

File tree

packages/desktop-client/src/budget/mutations.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,13 @@ type ApplyBudgetActionPayload =
649649
args: {
650650
category: CategoryEntity['id'];
651651
};
652+
}
653+
| {
654+
type: 'copy-to-future-months';
655+
month: string;
656+
args: {
657+
category: CategoryEntity['id'];
658+
};
652659
};
653660

654661
export function useBudgetActions() {
@@ -778,6 +785,12 @@ export function useBudgetActions() {
778785
category: args.category,
779786
});
780787
return null;
788+
case 'copy-to-future-months':
789+
await send('budget/copy-to-future-months', {
790+
month,
791+
category: args.category,
792+
});
793+
return null;
781794
default:
782795
throw new Error(`Unknown budget action type: ${String(type)}`);
783796
}

packages/desktop-client/src/components/budget/envelope/BudgetMenu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ type BudgetMenuProps = Omit<
1313
onCopyLastMonthAverage: () => void;
1414
onSetMonthsAverage: (numberOfMonths: number) => void;
1515
onApplyBudgetTemplate: () => void;
16+
onCopyToFutureMonths: () => void;
1617
};
1718
export function BudgetMenu({
1819
onCopyLastMonthAverage,
1920
onSetMonthsAverage,
2021
onApplyBudgetTemplate,
22+
onCopyToFutureMonths,
2123
...props
2224
}: BudgetMenuProps) {
2325
const { t } = useTranslation();
@@ -40,6 +42,9 @@ export function BudgetMenu({
4042
case 'apply-single-category-template':
4143
onApplyBudgetTemplate?.();
4244
break;
45+
case 'copy-to-future-months':
46+
onCopyToFutureMonths?.();
47+
break;
4348
default:
4449
throw new Error(`Unrecognized menu item: ${name}`);
4550
}
@@ -66,6 +71,10 @@ export function BudgetMenu({
6671
name: 'set-single-12-avg',
6772
text: t('Set to yearly average'),
6873
},
74+
{
75+
name: 'copy-to-future-months',
76+
text: t('Copy to future months'),
77+
},
6978
...(isGoalTemplatesEnabled
7079
? [
7180
{

packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,14 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
372372
message: t(`Budget template applied.`),
373373
});
374374
}}
375+
onCopyToFutureMonths={() => {
376+
onMenuAction(month, 'copy-to-future-months', {
377+
category: category.id,
378+
});
379+
showUndoNotification({
380+
message: t(`Budget copied to future months.`),
381+
});
382+
}}
375383
/>
376384
</Popover>
377385
</View>

packages/desktop-client/src/components/budget/tracking/BudgetMenu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ type BudgetMenuProps = Omit<
1313
onCopyLastMonthAverage: () => void;
1414
onSetMonthsAverage: (numberOfMonths: number) => void;
1515
onApplyBudgetTemplate: () => void;
16+
onCopyToFutureMonths: () => void;
1617
};
1718
export function BudgetMenu({
1819
onCopyLastMonthAverage,
1920
onSetMonthsAverage,
2021
onApplyBudgetTemplate,
22+
onCopyToFutureMonths,
2123
...props
2224
}: BudgetMenuProps) {
2325
const { t } = useTranslation();
@@ -39,6 +41,9 @@ export function BudgetMenu({
3941
case 'apply-single-category-template':
4042
onApplyBudgetTemplate?.();
4143
break;
44+
case 'copy-to-future-months':
45+
onCopyToFutureMonths?.();
46+
break;
4247
default:
4348
throw new Error(`Unrecognized menu item: ${name}`);
4449
}
@@ -65,6 +70,10 @@ export function BudgetMenu({
6570
name: 'set-single-12-avg',
6671
text: t('Set to yearly average'),
6772
},
73+
{
74+
name: 'copy-to-future-months',
75+
text: t('Copy to future months'),
76+
},
6877
...(isGoalTemplatesEnabled
6978
? [
7079
{

packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,14 @@ export const CategoryMonth = memo(function CategoryMonth({
349349
message: t(`Budget template applied.`),
350350
});
351351
}}
352+
onCopyToFutureMonths={() => {
353+
onMenuAction(month, 'copy-to-future-months', {
354+
category: category.id,
355+
});
356+
showUndoNotification({
357+
message: t(`Budget copied to future months.`),
358+
});
359+
}}
352360
/>
353361
</Popover>
354362
</View>

packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ export function BudgetCell<
131131
pre: categoryNotes ?? undefined,
132132
});
133133
},
134+
onCopyToFutureMonths: () => {
135+
onBudgetAction(month, 'copy-to-future-months', {
136+
category: category.id,
137+
});
138+
showUndoNotification({
139+
message: `${category.name} budget copied to future months.`,
140+
});
141+
},
134142
},
135143
},
136144
}),

packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function EnvelopeBudgetMenuModal({
4343
onCopyLastMonthAverage,
4444
onSetMonthsAverage,
4545
onApplyBudgetTemplate,
46+
onCopyToFutureMonths,
4647
onEditNotes,
4748
month,
4849
}: EnvelopeBudgetMenuModalProps) {
@@ -201,6 +202,7 @@ export function EnvelopeBudgetMenuModal({
201202
onCopyLastMonthAverage={onCopyLastMonthAverage}
202203
onSetMonthsAverage={onSetMonthsAverage}
203204
onApplyBudgetTemplate={onApplyBudgetTemplate}
205+
onCopyToFutureMonths={onCopyToFutureMonths}
204206
/>
205207
)}
206208
</>

packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function TrackingBudgetMenuModal({
4343
onCopyLastMonthAverage,
4444
onSetMonthsAverage,
4545
onApplyBudgetTemplate,
46+
onCopyToFutureMonths,
4647
onEditNotes,
4748
month,
4849
}: TrackingBudgetMenuModalProps) {
@@ -201,6 +202,7 @@ export function TrackingBudgetMenuModal({
201202
onCopyLastMonthAverage={onCopyLastMonthAverage}
202203
onSetMonthsAverage={onSetMonthsAverage}
203204
onApplyBudgetTemplate={onApplyBudgetTemplate}
205+
onCopyToFutureMonths={onCopyToFutureMonths}
204206
/>
205207
)}
206208
</>

packages/desktop-client/src/modals/modalsSlice.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export type Modal =
345345
onCopyLastMonthAverage: () => void;
346346
onSetMonthsAverage: (numberOfMonths: number) => void;
347347
onApplyBudgetTemplate: () => void;
348+
onCopyToFutureMonths: () => void;
348349
onEditNotes: (id: NoteEntity['id'], month: string) => void;
349350
};
350351
}
@@ -357,6 +358,7 @@ export type Modal =
357358
onCopyLastMonthAverage: () => void;
358359
onSetMonthsAverage: (numberOfMonths: number) => void;
359360
onApplyBudgetTemplate: () => void;
361+
onCopyToFutureMonths: () => void;
360362
onEditNotes: (id: NoteEntity['id'], month: string) => void;
361363
};
362364
}

packages/loot-core/src/server/budget/actions.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,78 @@ import * as db from '../db';
44
import * as sheet from '../sheet';
55

66
import {
7+
copyToFutureMonths,
78
coverOverbudgeted,
89
getSheetValue,
910
setBudget,
1011
setCategoryCarryover,
1112
} from './actions';
1213
import * as budget from './base';
1314

15+
describe('copyToFutureMonths', () => {
16+
beforeEach(global.emptyDatabase());
17+
afterEach(global.emptyDatabase());
18+
19+
async function setupDatabase() {
20+
await db.insertCategoryGroup({
21+
id: 'income-group',
22+
name: 'Income',
23+
is_income: 1,
24+
});
25+
await db.insertCategory({
26+
id: 'income-cat',
27+
name: 'Income',
28+
cat_group: 'income-group',
29+
is_income: 1,
30+
});
31+
await db.insertCategoryGroup({
32+
id: 'group1',
33+
name: 'group1',
34+
is_income: 0,
35+
});
36+
await db.insertCategory({
37+
id: 'cat1',
38+
name: 'cat1',
39+
cat_group: 'group1',
40+
is_income: 0,
41+
});
42+
await sheet.loadSpreadsheet(db);
43+
await budget.createBudget(['2024-01', '2024-02', '2024-03']);
44+
}
45+
46+
it('copies the current month budget to all future created months', async () => {
47+
await setupDatabase();
48+
49+
await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
50+
await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 });
51+
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
52+
await sheet.waitOnSpreadsheet();
53+
54+
await copyToFutureMonths({ month: '2024-01', category: 'cat1' });
55+
await sheet.waitOnSpreadsheet();
56+
57+
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
58+
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
59+
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
60+
});
61+
62+
it('does not affect months before or equal to the current month', async () => {
63+
await setupDatabase();
64+
65+
await setBudget({ category: 'cat1', month: '2024-01', amount: 1000 });
66+
await setBudget({ category: 'cat1', month: '2024-02', amount: 5000 });
67+
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
68+
await sheet.waitOnSpreadsheet();
69+
70+
await copyToFutureMonths({ month: '2024-02', category: 'cat1' });
71+
await sheet.waitOnSpreadsheet();
72+
73+
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(1000);
74+
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
75+
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
76+
});
77+
});
78+
1479
describe('coverOverbudgeted', () => {
1580
beforeEach(global.emptyDatabase());
1681
afterEach(global.emptyDatabase());

0 commit comments

Comments
 (0)