[AI] feat(budget analysis report): allow selecting future months in date range (#8722)

* [AI] feat(budget-report): allow selecting future months in date range

Extend the allMonths picker list 12 months past the current month so
users can select future months for long-term goal planning.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* [AI] fix(budget-report): preserve running balance for future months with no budget data

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* [AI] fix(budget-report): fix balance zeroing for future months with no budget data

Use monthBalance as the next month's running balance instead of
carryoverToNextMonth, which is 0 for months where no category has
any budget or spending data.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* [AI] fix(budget-report): fix import path in budget-analysis-spreadsheet test

* [AI] fix: added in release note

Assisted-by: ClaudeCode:claude-opus-5

* [AI] fix(budget-report): address CodeRabbit review findings

Addresses both review comments from CodeRabbit on PR #8722:

1. "Keep later-dated transaction months selectable" — the month-range
endpoint now takes the later of the latest transaction month and December
of next year, so existing future-dated transactions are no longer hidden
from the picker. This PR had replaced latestMonth with a hardcoded
December-of-next-year, which dropped transactions dated beyond it.

2. "Preserve the no-carryover reset when propagating balances" —
running-balance propagation goes back to carrying over only what actually
carries over, instead of the full month balance. Using monthBalance had
re-included negative balances for categories with carryover disabled, so
an overspend both dragged forward through later months and still surfaced
as an overspending adjustment. A month with no budget data at all now
passes the running balance through untouched, which fixes the original
future-month bug without defeating the carryover reset.

Extracts summarizeMonthCategories, getNextRunningBalance and
getLastSelectableMonth so this logic is unit-testable, and replaces the
self-referential range tests with ones that exercise the real functions.

Assisted-by: ClaudeCode:claude-opus-5

* [AI] test(budget-report): cover never-budgeted months in data detection

CodeRabbit's follow-up review on PR #8722 suggested setting hasBudgetData
from cell presence rather than cell value. That change would be wrong here:
envelope-budget-month builds its response by iterating the category list, so
it emits a cell for every category in every month and normalizes missing
sheet values to 0 (see loot-core/src/server/budget/app.ts). Cells are always
present, so presence-based detection would report every month as populated
and re-introduce the running-balance bug the previous commit fixed.

The zero-value case it worries about is not reachable in a way that changes
the result: leftover already folds in the previous month's balance, so a
month with a real balance carrying in always has a non-zero cell and is
detected correctly. When all three are zero there is nothing to carry, and
both detection strategies agree.

Adds a regression test pinning the never-budgeted month to
hasBudgetData=false, and documents why detection is value-based so the
suggestion is not re-applied later.

Assisted-by: ClaudeCode:claude-opus-5

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
tabedzki
2026-08-16 20:36:11 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 008c609424
commit 30e671b3ec
4 changed files with 381 additions and 66 deletions
@@ -33,7 +33,10 @@ import { Header } from '#components/reports/Header';
import { LoadingIndicator } from '#components/reports/LoadingIndicator';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { buildBudgetAnalysisCsv } from '#components/reports/spreadsheets/budget-analysis-export';
import { createBudgetAnalysisSpreadsheet } from '#components/reports/spreadsheets/budget-analysis-spreadsheet';
import {
createBudgetAnalysisSpreadsheet,
getLastSelectableMonth,
} from '#components/reports/spreadsheets/budget-analysis-spreadsheet';
import { useReport } from '#components/reports/useReport';
import { fromDateRepr } from '#components/reports/util';
import { useDashboardWidget } from '#hooks/useDashboardWidget';
@@ -223,8 +226,13 @@ function BudgetAnalysisInternal({ widget }: BudgetAnalysisInternalProps) {
earliestMonth = yearAgo;
}
// Extend the selectable range to December of next year so users can
// plan ahead (e.g. June 2026 → December 2027), without hiding
// transactions dated beyond that.
const lastMonth = getLastSelectableMonth(currentMonth, latestMonth);
const allMonthsData = monthUtils
.rangeInclusive(earliestMonth, latestMonth)
.rangeInclusive(earliestMonth, lastMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
@@ -1,6 +1,13 @@
import { rangeInclusive } from '@actual-app/core/shared/months';
import type { CategoryEntity } from '@actual-app/core/types/models';
import { isBaseCategory } from './budget-analysis-spreadsheet';
import {
getLastSelectableMonth,
getNextRunningBalance,
isBaseCategory,
summarizeMonthCategories,
} from './budget-analysis-spreadsheet';
import type { BudgetMonthCell } from './budgetMonthCell';
const makeCategory = (
overrides: Partial<CategoryEntity> & Pick<CategoryEntity, 'id' | 'name'>,
@@ -74,4 +81,215 @@ describe('createBudgetAnalysisSpreadsheet', () => {
expect(result).toContain(hiddenExpense);
});
});
describe('getLastSelectableMonth', () => {
it('extends the range to December of next year', () => {
expect(getLastSelectableMonth('2026-06', '2026-06')).toBe('2027-12');
});
it('ends in December regardless of the current month', () => {
for (const month of ['2024-01', '2024-06', '2024-12']) {
expect(getLastSelectableMonth(month, month)).toBe('2025-12');
}
});
it('keeps a later transaction month selectable', () => {
// A transaction dated past December of next year must not be cut off.
expect(getLastSelectableMonth('2026-08', '2028-03')).toBe('2028-03');
});
it('prefers December of next year when the latest transaction is earlier', () => {
expect(getLastSelectableMonth('2026-08', '2026-11')).toBe('2027-12');
});
it('keeps the boundary month itself', () => {
expect(getLastSelectableMonth('2026-08', '2027-12')).toBe('2027-12');
});
it('produces a range that spans from the earliest month to the endpoint', () => {
const range = rangeInclusive(
'2026-06',
getLastSelectableMonth('2026-06', '2026-06'),
);
expect(range[0]).toBe('2026-06');
expect(range[range.length - 1]).toBe('2027-12');
});
it('lists future months before the current month once reversed', () => {
const last = getLastSelectableMonth('2026-06', '2026-06');
const allMonths = rangeInclusive('2026-06', last).reverse();
expect(allMonths[0]).toBe(last);
expect(allMonths[allMonths.length - 1]).toBe('2026-06');
});
});
describe('summarizeMonthCategories', () => {
const cells = (
values: Record<string, number | boolean>,
): BudgetMonthCell[] =>
Object.entries(values).map(([name, value]) => ({
name: `budget202601!${name}`,
value,
})) as BudgetMonthCell[];
it('reports no budget data when every cell is empty', () => {
const result = summarizeMonthCategories([], [visibleExpense]);
expect(result).toEqual({
budgeted: 0,
spent: 0,
carryoverToNextMonth: 0,
overspendingThisMonth: 0,
hasBudgetData: false,
});
});
it('reports no budget data for a never-budgeted month', () => {
// `envelope-budget-month` emits a cell for every category in every
// month, so an untouched future month arrives as present-but-zero
// cells rather than as missing ones. Presence alone cannot distinguish
// it from a real month.
const result = summarizeMonthCategories(
cells({
'budget-c1': 0,
'sum-amount-c1': 0,
'leftover-c1': 0,
'carryover-c1': false,
}),
[visibleExpense],
);
expect(result.hasBudgetData).toBe(false);
});
it('carries a positive balance to the next month', () => {
const result = summarizeMonthCategories(
cells({
'budget-c1': 10000,
'sum-amount-c1': -4000,
'leftover-c1': 6000,
'carryover-c1': false,
}),
[visibleExpense],
);
expect(result.carryoverToNextMonth).toBe(6000);
expect(result.overspendingThisMonth).toBe(0);
expect(result.hasBudgetData).toBe(true);
});
it('treats an overspend without carryover as an overspending adjustment', () => {
const result = summarizeMonthCategories(
cells({
'budget-c1': 0,
'sum-amount-c1': -10000,
'leftover-c1': -10000,
'carryover-c1': false,
}),
[visibleExpense],
);
expect(result.carryoverToNextMonth).toBe(0);
expect(result.overspendingThisMonth).toBe(-10000);
expect(result.hasBudgetData).toBe(true);
});
it('carries a negative balance forward when carryover is enabled', () => {
const result = summarizeMonthCategories(
cells({
'budget-c1': 0,
'sum-amount-c1': -10000,
'leftover-c1': -10000,
'carryover-c1': true,
}),
[visibleExpense],
);
expect(result.carryoverToNextMonth).toBe(-10000);
expect(result.overspendingThisMonth).toBe(0);
});
it('only counts the categories it is given', () => {
const result = summarizeMonthCategories(
cells({
'budget-c1': 10000,
'leftover-c1': 10000,
'budget-c2': 5000,
'leftover-c2': 5000,
}),
[visibleExpense],
);
expect(result.budgeted).toBe(10000);
expect(result.carryoverToNextMonth).toBe(10000);
});
});
describe('getNextRunningBalance', () => {
it('hands off only what carries over when the month has data', () => {
expect(
getNextRunningBalance({
hasBudgetData: true,
carryoverToNextMonth: 6000,
runningBalance: 20000,
}),
).toBe(6000);
});
it('resets a non-carryover overspend instead of dragging it forward', () => {
// The overspend surfaces as next month's overspending adjustment, so it
// must not also reduce the running balance.
expect(
getNextRunningBalance({
hasBudgetData: true,
carryoverToNextMonth: 0,
runningBalance: 20000,
}),
).toBe(0);
});
it('passes the running balance through months with no budget data', () => {
expect(
getNextRunningBalance({
hasBudgetData: false,
carryoverToNextMonth: 0,
runningBalance: 20000,
}),
).toBe(20000);
});
it('preserves a balance across a run of empty future months', () => {
let runningBalance = 15000;
for (let i = 0; i < 6; i++) {
runningBalance = getNextRunningBalance({
hasBudgetData: false,
carryoverToNextMonth: 0,
runningBalance,
});
}
expect(runningBalance).toBe(15000);
});
it('does not carry a non-carryover overspend into later empty months', () => {
// Overspend of -10000 in a category without carryover enabled.
let runningBalance = getNextRunningBalance({
hasBudgetData: true,
carryoverToNextMonth: 0,
runningBalance: 0,
});
for (let i = 0; i < 3; i++) {
runningBalance = getNextRunningBalance({
hasBudgetData: false,
carryoverToNextMonth: 0,
runningBalance,
});
}
expect(runningBalance).toBe(0);
});
});
});
@@ -43,6 +43,134 @@ export function isBaseCategory(
return !cat.is_income && (showHiddenCategories || !cat.hidden);
}
/**
* Last month the date pickers offer.
*
* The range runs through December of next year so the report can be used for
* planning ahead, but it never cuts off existing data: if a transaction is
* dated even further out, that month stays selectable.
*/
export function getLastSelectableMonth(
currentMonth: string,
latestMonth: string,
): string {
const futureMonth = `${Number(currentMonth.slice(0, 4)) + 1}-12`;
return latestMonth > futureMonth ? latestMonth : futureMonth;
}
export type MonthCategoryTotals = {
budgeted: number;
spent: number;
/**
* Sum of category balances that roll into the next month: positive balances
* always roll over, negative ones only when the category has carryover
* ("rollover overspending") enabled.
*/
carryoverToNextMonth: number;
/**
* Sum of negative balances for categories without carryover enabled. These
* are zeroed out at the month boundary and surface as next month's
* overspending adjustment instead of reducing the running balance.
*/
overspendingThisMonth: number;
/**
* Whether the month returned any budget data at all. Months in the future
* that have never been budgeted come back with every cell empty; there is
* nothing to carry over from them, so the running balance must pass through
* untouched rather than being reset to zero.
*/
hasBudgetData: boolean;
};
/**
* Reduce a month's raw budget cells down to the totals the report needs.
*/
export function summarizeMonthCategories(
monthData: BudgetMonthCell[],
categoriesToInclude: CategoryEntity[],
): MonthCategoryTotals {
let budgeted = 0;
let spent = 0;
let carryoverToNextMonth = 0;
let overspendingThisMonth = 0;
let hasBudgetData = false;
for (const cat of categoriesToInclude) {
// Find the budget, spent, balance, and carryover flag for this category
const budgetCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`budget-${cat.id}`),
);
const spentCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`sum-amount-${cat.id}`),
);
const balanceCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`leftover-${cat.id}`),
);
const carryoverCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`carryover-${cat.id}`),
);
const catBudgeted = (budgetCell?.value as number) || 0;
const catSpent = (spentCell?.value as number) || 0;
const catBalance = (balanceCell?.value as number) || 0;
const hasCarryover = Boolean(carryoverCell?.value);
// Detect by value, not by cell presence: `envelope-budget-month` builds
// its response from the category list, so it emits a cell for every
// category in every month and fills missing sheet values with 0. Every
// month therefore has all its cells, and presence tells us nothing.
// A zero here is unambiguous: `leftover` already folds in the previous
// month's balance, so a month with a real balance carrying in cannot
// report zero across all three.
if (catBudgeted !== 0 || catSpent !== 0 || catBalance !== 0) {
hasBudgetData = true;
}
budgeted += catBudgeted;
spent += catSpent;
// Add to next month's carryover if:
// - Balance is positive (always carries over), OR
// - Balance is negative AND carryover is enabled
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
carryoverToNextMonth += catBalance;
} else if (catBalance < 0 && !hasCarryover) {
// If balance is negative and carryover is NOT enabled,
// this will be zeroed out and becomes next month's overspending adjustment
overspendingThisMonth += catBalance; // Keep as negative
}
}
return {
budgeted,
spent,
carryoverToNextMonth,
overspendingThisMonth,
hasBudgetData,
};
}
/**
* The balance that seeds the following month.
*
* Months with budget data hand off only what actually carries over, so
* overspending in a category without carryover enabled is reset rather than
* dragged forward. Months with no data at all (typically future months that
* have never been budgeted) have nothing to carry over and must leave the
* running balance untouched.
*/
export function getNextRunningBalance({
hasBudgetData,
carryoverToNextMonth,
runningBalance,
}: {
hasBudgetData: boolean;
carryoverToNextMonth: number;
runningBalance: number;
}): number {
return hasBudgetData ? carryoverToNextMonth : runningBalance;
}
export function createBudgetAnalysisSpreadsheet({
conditions = [],
conditionsOp = 'and',
@@ -174,7 +302,6 @@ export function createBudgetAnalysisSpreadsheet({
// Track running balance that respects carryover flags
// Get the balance from the month before the start period to initialize properly
let runningBalance = 0;
const monthBeforeStart = monthUtils.subMonths(
monthUtils.getMonth(startDate),
1,
@@ -184,22 +311,10 @@ export function createBudgetAnalysisSpreadsheet({
});
// Calculate the carryover from the previous month
for (const cat of categoriesToInclude) {
const balanceCell = prevMonthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`leftover-${cat.id}`),
);
const carryoverCell = prevMonthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`carryover-${cat.id}`),
);
const catBalance = (balanceCell?.value as number) || 0;
const hasCarryover = Boolean(carryoverCell?.value);
// Add to running balance if it would carry over
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
runningBalance += catBalance;
}
}
let runningBalance = summarizeMonthCategories(
prevMonthData,
categoriesToInclude,
).carryoverToNextMonth;
// Track totals across all months
let totalBudgeted = 0;
@@ -215,48 +330,13 @@ export function createBudgetAnalysisSpreadsheet({
// This uses the same calculations as the budget page
const monthData = await send('envelope-budget-month', { month });
let budgeted = 0;
let spent = 0;
let overspendingThisMonth = 0;
// Track what will carry over to next month
let carryoverToNextMonth = 0;
// Sum up values for categories we're interested in
for (const cat of categoriesToInclude) {
// Find the budget, spent, balance, and carryover flag for this category
const budgetCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`budget-${cat.id}`),
);
const spentCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`sum-amount-${cat.id}`),
);
const balanceCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`leftover-${cat.id}`),
);
const carryoverCell = monthData.find((cell: BudgetMonthCell) =>
cell.name.endsWith(`carryover-${cat.id}`),
);
const catBudgeted = (budgetCell?.value as number) || 0;
const catSpent = (spentCell?.value as number) || 0;
const catBalance = (balanceCell?.value as number) || 0;
const hasCarryover = Boolean(carryoverCell?.value);
budgeted += catBudgeted;
spent += catSpent;
// Add to next month's carryover if:
// - Balance is positive (always carries over), OR
// - Balance is negative AND carryover is enabled
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
carryoverToNextMonth += catBalance;
} else if (catBalance < 0 && !hasCarryover) {
// If balance is negative and carryover is NOT enabled,
// this will be zeroed out and becomes next month's overspending adjustment
overspendingThisMonth += catBalance; // Keep as negative
}
}
const {
budgeted,
spent,
carryoverToNextMonth,
overspendingThisMonth,
hasBudgetData,
} = summarizeMonthCategories(monthData, categoriesToInclude);
// Apply overspending adjustment from previous month (negative value)
const overspendingAdjustment = overspendingFromPrevMonth;
@@ -272,13 +352,16 @@ export function createBudgetAnalysisSpreadsheet({
intervalData.push({
date: month,
budgeted,
spent, // Display as positive
spent,
balance: monthBalance,
overspendingAdjustment: Math.abs(overspendingAdjustment), // Display as positive
overspendingAdjustment: Math.abs(overspendingAdjustment),
});
// Update running balance for next month
runningBalance = carryoverToNextMonth;
runningBalance = getNextRunningBalance({
hasBudgetData,
carryoverToNextMonth,
runningBalance,
});
// Save this month's overspending to apply in next month
overspendingFromPrevMonth = overspendingThisMonth;
}
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [tabedzki]
---
Allow selecting future months in the Budget Analysis report date range, so you can plan ahead.