Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4f0437f
[AI] Add 'Copy to future months' budget option
nikhilweee Apr 8, 2026
14be73f
[AI] Rename release note to PR #7420
nikhilweee Apr 8, 2026
1a8e865
Merge remote-tracking branch 'origin/master' into copy-to-future-months
nikhilweee Apr 8, 2026
46d3c21
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 12, 2026
ce63562
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 13, 2026
7010197
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 17, 2026
736fac7
[AI] Skip empty budget months in copyToFutureMonths
nikhilweee Apr 17, 2026
3762f88
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 17, 2026
ee07335
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 20, 2026
f02a2f3
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 21, 2026
92d4725
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 22, 2026
60bef0d
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 23, 2026
40377e0
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 26, 2026
f39a312
[AI] Rename to 'Copy until year end', limit to tracking budget, add y…
nikhilweee May 2, 2026
d88e283
Merge branch 'master' into copy-to-future-months
nikhilweee May 2, 2026
efd96b6
[AI] Address CodeRabbit feedback: i18n undo notification, clarify docs
nikhilweee May 5, 2026
b4c273a
[AI] Fix typecheck: branch dispatch by budgetType for proper modal na…
nikhilweee May 5, 2026
9f223dc
[AI] Fix lint: add t to useCallback deps
nikhilweee May 5, 2026
7c63ee7
Merge remote-tracking branch 'origin/master' into copy-to-future-months
nikhilweee May 5, 2026
db665a8
Merge branch 'master' into copy-to-future-months
nikhilweee May 6, 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
13 changes: 13 additions & 0 deletions packages/desktop-client/src/budget/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,13 @@ type ApplyBudgetActionPayload =
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-to-future-months';
month: string;
args: {
category: CategoryEntity['id'];
};
};

export function useBudgetActions() {
Expand Down Expand Up @@ -776,6 +783,12 @@ export function useBudgetActions() {
category: args.category,
});
return null;
case 'copy-to-future-months':
await send('budget/copy-to-future-months', {
month,
category: args.category,
});
return null;
default:
throw new Error(`Unknown budget action type: ${String(type)}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ type BudgetMenuProps = Omit<
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyToFutureMonths: () => void;
};
export function BudgetMenu({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyToFutureMonths,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
Expand All @@ -40,6 +42,9 @@ export function BudgetMenu({
case 'apply-single-category-template':
onApplyBudgetTemplate?.();
break;
case 'copy-to-future-months':
onCopyToFutureMonths?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
Expand All @@ -66,6 +71,10 @@ export function BudgetMenu({
name: 'set-single-12-avg',
text: t('Set to yearly average'),
},
{
name: 'copy-to-future-months',
text: t('Copy to future months'),
},
...(isGoalTemplatesEnabled
? [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
message: t(`Budget template applied.`),
});
}}
onCopyToFutureMonths={() => {
onMenuAction(month, 'copy-to-future-months', {
category: category.id,
});
showUndoNotification({
message: t(`Budget copied to future months.`),
});
}}
/>
</Popover>
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ type BudgetMenuProps = Omit<
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyToFutureMonths: () => void;
};
export function BudgetMenu({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyToFutureMonths,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
Expand All @@ -39,6 +41,9 @@ export function BudgetMenu({
case 'apply-single-category-template':
onApplyBudgetTemplate?.();
break;
case 'copy-to-future-months':
onCopyToFutureMonths?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
Expand All @@ -65,6 +70,10 @@ export function BudgetMenu({
name: 'set-single-12-avg',
text: t('Set to yearly average'),
},
{
name: 'copy-to-future-months',
text: t('Copy to future months'),
},
...(isGoalTemplatesEnabled
? [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,14 @@ export const CategoryMonth = memo(function CategoryMonth({
message: t(`Budget template applied.`),
});
}}
onCopyToFutureMonths={() => {
onMenuAction(month, 'copy-to-future-months', {
category: category.id,
});
showUndoNotification({
message: t(`Budget copied to future months.`),
});
}}
/>
</Popover>
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export function BudgetCell<
pre: categoryNotes ?? undefined,
});
},
onCopyToFutureMonths: () => {
onBudgetAction(month, 'copy-to-future-months', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget copied to future months.`,
});
},
},
},
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function EnvelopeBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyToFutureMonths,
onEditNotes,
month,
}: EnvelopeBudgetMenuModalProps) {
Expand Down Expand Up @@ -200,6 +201,7 @@ export function EnvelopeBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyToFutureMonths={onCopyToFutureMonths}
/>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyToFutureMonths,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
Expand Down Expand Up @@ -200,6 +201,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyToFutureMonths={onCopyToFutureMonths}
/>
)}
</>
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop-client/src/modals/modalsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyToFutureMonths: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
Expand All @@ -356,6 +357,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyToFutureMonths: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
Expand Down
81 changes: 81 additions & 0 deletions packages/loot-core/src/server/budget/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,94 @@ import * as db from '#server/db';
import * as sheet from '#server/sheet';

import {
copyToFutureMonths,
coverOverbudgeted,
getSheetValue,
setBudget,
setCategoryCarryover,
} from './actions';
import * as budget from './base';

describe('copyToFutureMonths', () => {
beforeEach(global.emptyDatabase());
afterEach(global.emptyDatabase());

async function setupDatabase() {
await db.insertCategoryGroup({
id: 'income-group',
name: 'Income',
is_income: 1,
});
await db.insertCategory({
id: 'income-cat',
name: 'Income',
cat_group: 'income-group',
is_income: 1,
});
await db.insertCategoryGroup({
id: 'group1',
name: 'group1',
is_income: 0,
});
await db.insertCategory({
id: 'cat1',
name: 'cat1',
cat_group: 'group1',
is_income: 0,
});
await sheet.loadSpreadsheet(db);
await budget.createBudget(['2024-01', '2024-02', '2024-03']);
}

it('copies the current month budget to future months with non-zero budgets', async () => {
await setupDatabase();

await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 });
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();

await copyToFutureMonths({ month: '2024-01', category: 'cat1' });
await sheet.waitOnSpreadsheet();

expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});

it('skips future months with empty (zero) budgets', async () => {
await setupDatabase();

await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
// 2024-02 intentionally left at 0
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();

await copyToFutureMonths({ month: '2024-01', category: 'cat1' });
await sheet.waitOnSpreadsheet();

expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(0);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});

it('does not affect months before or equal to the current month', async () => {
await setupDatabase();

await setBudget({ category: 'cat1', month: '2024-01', amount: 1000 });
await setBudget({ category: 'cat1', month: '2024-02', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();

await copyToFutureMonths({ month: '2024-02', category: 'cat1' });
await sheet.waitOnSpreadsheet();

expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(1000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});
});

describe('coverOverbudgeted', () => {
beforeEach(global.emptyDatabase());
afterEach(global.emptyDatabase());
Expand Down
30 changes: 30 additions & 0 deletions packages/loot-core/src/server/budget/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,36 @@ export async function transferCategory({
});
}

export async function copyToFutureMonths({
month,
category,
}: {
month: string;
category: string;
}): Promise<void> {
const amount = await getSheetValue(
monthUtils.sheetForMonth(month),
'budget-' + category,
);

const { createdMonths } = sheet.get().meta();
const futureMonths = [...(createdMonths as Set<string>)]
.filter(m => m > month)
.sort();

await batchMessages(async () => {
for (const futureMonth of futureMonths) {
const existing = await getSheetValue(
monthUtils.sheetForMonth(futureMonth),
'budget-' + category,
);
if (existing !== 0) {
void setBudget({ category, month: futureMonth, amount });
}
}
});
}

export async function setCategoryCarryover({
startMonth,
category,
Expand Down
5 changes: 5 additions & 0 deletions packages/loot-core/src/server/budget/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type BudgetHandlers = {
'budget/transfer-available': typeof actions.transferAvailable;
'budget/cover-overbudgeted': typeof actions.coverOverbudgeted;
'budget/transfer-category': typeof actions.transferCategory;
'budget/copy-to-future-months': typeof actions.copyToFutureMonths;
'budget/set-carryover': typeof actions.setCategoryCarryover;
'budget/reset-income-carryover': typeof actions.resetIncomeCarryover;
'get-categories': typeof getCategories;
Expand Down Expand Up @@ -122,6 +123,10 @@ app.method(
'budget/transfer-category',
mutator(undoable(actions.transferCategory)),
);
app.method(
'budget/copy-to-future-months',
mutator(undoable(actions.copyToFutureMonths)),
);
app.method(
'budget/set-carryover',
mutator(undoable(actions.setCategoryCarryover)),
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/7420.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Features
authors: [nikhilweee]
---

Add 'Copy to future months' option to the per-category budget menu, which copies the current month's budgeted amount to all subsequent months that already exist in the budget.
Loading