Feat/add-budget-analysis-report (#6137)

* Add Budget Analysis report with full implementation

Co-authored-by: tabedzki <35670232+tabedzki@users.noreply.github.com>

* Add preset time ranges, intervals, bar chart, hide balance, and display controls

Co-authored-by: tabedzki <35670232+tabedzki@users.noreply.github.com>

* Fix duplicate function declaration syntax error in budget-analysis-spreadsheet.ts

Co-authored-by: tabedzki <35670232+tabedzki@users.noreply.github.com>

* Fix floating point precision error in daily/weekly intervals and replace interval button with dropdown

Co-authored-by: tabedzki <35670232+tabedzki@users.noreply.github.com>

* Make card always display monthly data and match report's chart type

Co-authored-by: tabedzki <35670232+tabedzki@users.noreply.github.com>

* fix: adjust widget placement and presentation

* fix: adjusted the dot presentation

* feat: added svg for line chart/barchart

* fix: added one month to the report

* added the upcoming release notes

* amended the upcoming release notes

* fix: removed unused variables

* formatted using prettier --write

* [autofix.ci] apply automated fixes

* feat: added new feature to the Reports Page in the test budget

* fix: amended the reports.test.ts file to expect Budget Analysis

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* revert: removed the inclusion of the Budget Analysis tool from the test file

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* fix: changed the display to always be monthly since budgets are monthly; removed the 1month view

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fix: removed comment

* feat: added experimental feature flag for Budget Analysis tool

* feat: Switched option to use SVG Icons instead of words to shorten horizontal layout

* Removed interval possibilities and removed unnecessary compact variable as indicated by CodeRabbit

* Update packages/desktop-client/src/components/reports/reports/BudgetAnalysisCard.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Added basic documentation for Budget Analysis Report

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* fix: added budget-analysis doc to sidebar

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* feat(reports): improve Budget Analysis report UI and behavior

- Update chart colors to match Cash Flow report style
  (Budgeted: blue, Balance: pageTextLight)
- Remove legend items from report view for cleaner UI
- Add 1 month quick select option to header
- Pass isConcise prop to BudgetAnalysisGraph for proper date formatting
- Add dynamic daily/monthly interval switching based on date range

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* feat(reports): refactor BudgetAnalysisGraph and improve isConcise calculation according to Rabbit AI

* feat(reports): add translation support for Budget Analysis graph labels

* feat(reports): centralize translation for Budget Analysis graph labels

* feat: use the budget values directly from the budget spreadsheet/server

* feat: enhance budget analysis with overspending adjustments and detailed reporting

* style: format code for better readability in BudgetAnalysis and budget-analysis-spreadsheet components

* refactor: remove unused variables

* docs: added in the image to the docs

* fix: reimplement support for conditionsOp

* [autofix.ci] apply automated fixes

* style: simplify budget analysis labels for clarity

* fix: add the on copy function

* feat: tooltip improvements

* feat: enhance budget analysis to track overspending adjustments across months

* fix: removed the absolute value for spent

* feat: update the charts to look closer to the CashFlow report

* fix: correct financial formatting for totalSpent in BudgetAnalysis

* feat: add filterExclude prop to Header and BudgetAnalysis for improved filtering options

* feat: implement privacy mode for Y-axis values in BudgetAnalysisGraph

* feat: change default graph type to Bar in BudgetAnalysis

* feat: remove commented-out filter button code in Header component

* feat: remove commented-out code for filter exclusion in Header component

* fix: update the feedback link to the dedicated issue

* refactor: financial display components to use FinancialText for consistency in Budget Analysis reports

* fix: update the card to also start as bar graph

* docs: update Budget Analysis report to include category filtering information

* style: refactor imports and whitespace

* refactor: simplify inline content structure in BudgetAnalysis component

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* fix: removed color descriptors from the chart

* fix: update color themes for Budget Analysis to use custom theme definitions

* [autofix.ci] apply automated fixes

* feat: update Budget Analysis merge md file

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6137

* fix: update budget analysis report image

* fix: white space adjustment in descriptor

* [autofix.ci] apply automated fixes

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
This commit is contained in:
tabedzki
2026-01-24 15:00:10 -05:00
committed by GitHub
parent d768cfa508
commit e4903ca6e3
19 changed files with 1391 additions and 3 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -43,6 +43,8 @@ type HeaderProps = {
) => void;
children?: ReactNode;
inlineContent?: ReactNode;
// no separate category filter; use main filters instead
filterExclude?: string[];
} & (
| {
filters: RuleConditionEntity[];
@@ -82,10 +84,12 @@ export function Header({
onConditionsOpChange,
children,
inlineContent,
filterExclude,
}: HeaderProps) {
const locale = useLocale();
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
function convertToMonth(
start: string,
end: string,
@@ -293,10 +297,11 @@ export function Header({
compact={isNarrowWidth}
onApply={onApply}
hover={false}
exclude={filterExclude}
/>
)}
{inlineContent}
</SpaceBetween>
<SpaceBetween gap={0}>{inlineContent}</SpaceBetween>
</View>
{children && (

View File

@@ -26,6 +26,7 @@ import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
import { DashboardHeader } from './DashboardHeader';
import { DashboardSelector } from './DashboardSelector';
import { LoadingIndicator } from './LoadingIndicator';
import { BudgetAnalysisCard } from './reports/BudgetAnalysisCard';
import { CalendarCard } from './reports/CalendarCard';
import { CashFlowCard } from './reports/CashFlowCard';
import { CrossoverCard } from './reports/CrossoverCard';
@@ -70,6 +71,7 @@ export function Overview({ dashboard }: OverviewProps) {
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
const crossoverReportEnabled = useFeatureFlag('crossoverReport');
const budgetAnalysisReportEnabled = useFeatureFlag('budgetAnalysisReport');
const formulaMode = useFeatureFlag('formulaMode');
@@ -518,6 +520,14 @@ export function Overview({ dashboard }: OverviewProps) {
name: 'spending-card' as const,
text: t('Spending analysis'),
},
...(budgetAnalysisReportEnabled
? [
{
name: 'budget-analysis-card' as const,
text: t('Budget analysis'),
},
]
: []),
{
name: 'markdown-card' as const,
text: t('Text widget'),
@@ -726,6 +736,18 @@ export function Overview({ dashboard }: OverviewProps) {
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'budget-analysis-card' &&
budgetAnalysisReportEnabled ? (
<BudgetAnalysisCard
widgetId={item.i}
isEditing={isEditing}
meta={widget.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'markdown-card' ? (
<MarkdownCard
isEditing={isEditing}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Route, Routes } from 'react-router';
import { BudgetAnalysis } from './reports/BudgetAnalysis';
import { Calendar } from './reports/Calendar';
import { CashFlow } from './reports/CashFlow';
import { Crossover } from './reports/Crossover';
@@ -15,6 +16,7 @@ import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
export function ReportRouter() {
const crossoverReportEnabled = useFeatureFlag('crossoverReport');
const budgetAnalysisReportEnabled = useFeatureFlag('budgetAnalysisReport');
return (
<Routes>
@@ -34,6 +36,12 @@ export function ReportRouter() {
<Route path="/custom/:id" element={<CustomReport />} />
<Route path="/spending" element={<Spending />} />
<Route path="/spending/:id" element={<Spending />} />
{budgetAnalysisReportEnabled && (
<>
<Route path="/budget-analysis" element={<BudgetAnalysis />} />
<Route path="/budget-analysis/:id" element={<BudgetAnalysis />} />
</>
)}
<Route path="/summary" element={<Summary />} />
<Route path="/summary/:id" element={<Summary />} />
<Route path="/calendar" element={<Calendar />} />

View File

@@ -0,0 +1,371 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { AlignedText } from '@actual-app/components/aligned-text';
import { type CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
LineChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import * as monthUtils from 'loot-core/shared/months';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { Container } from '@desktop-client/components/reports/Container';
import { useFormat, type FormatType } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { usePrivacyMode } from '@desktop-client/hooks/usePrivacyMode';
/**
* Interval data for the Budget Analysis graph.
* @property date - A date string in format 'YYYY-MM' for monthly intervals
* or 'YYYY-MM-DD' for daily intervals, compatible with monthUtils.format
*/
type BudgetAnalysisIntervalData = {
date: string;
budgeted: number;
spent: number;
balance: number;
overspendingAdjustment: number;
};
type PayloadItem = {
payload: {
date: string;
budgeted: number;
spent: number;
balance: number;
overspendingAdjustment: number;
};
};
type BudgetAnalysisGraphProps = {
style?: CSSProperties;
data: {
intervalData: BudgetAnalysisIntervalData[];
};
graphType?: 'Line' | 'Bar';
showBalance?: boolean;
isConcise?: boolean;
};
type CustomTooltipProps = {
active?: boolean;
payload?: PayloadItem[];
isConcise: boolean;
format: (value: unknown, type?: FormatType) => string;
showBalance: boolean;
};
function CustomTooltip({
active,
payload,
isConcise,
format,
showBalance,
}: CustomTooltipProps) {
const locale = useLocale();
const { t } = useTranslation();
if (!active || !payload || !Array.isArray(payload) || !payload[0]) {
return null;
}
const [{ payload: data }] = payload;
return (
<div
className={css({
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>
{monthUtils.format(
data.date,
isConcise ? 'MMMM yyyy' : 'MMMM dd, yyyy',
locale,
)}
</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<AlignedText
left={
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span
style={{
width: 10,
height: 10,
backgroundColor: theme.reportsNumberPositive,
display: 'inline-block',
}}
/>
<Trans>Budgeted:</Trans>
</span>
}
right={
<FinancialText>
{format(data.budgeted, 'financial')}
</FinancialText>
}
/>
<AlignedText
left={
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span
style={{
width: 10,
height: 10,
backgroundColor: theme.reportsNumberNegative,
display: 'inline-block',
}}
/>
<Trans>Spent:</Trans>
</span>
}
right={
<FinancialText>{format(data.spent, 'financial')}</FinancialText>
}
/>
<AlignedText
left={
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span
style={{
width: 10,
height: 10,
backgroundColor: theme.templateNumberUnderFunded,
display: 'inline-block',
}}
/>
{t('Overspending Adjustment:')}
</span>
}
right={
<FinancialText>
{format(data.overspendingAdjustment, 'financial')}
</FinancialText>
}
/>
{showBalance && (
<AlignedText
left={
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span
style={{
width: 10,
height: 10,
backgroundColor: theme.reportsNumberNeutral,
display: 'inline-block',
}}
/>
<Trans>Balance:</Trans>
</span>
}
right={
<FinancialText>
<strong>{format(data.balance, 'financial')}</strong>
</FinancialText>
}
/>
)}
</div>
</div>
</div>
);
}
export function BudgetAnalysisGraph({
style,
data,
graphType = 'Line',
showBalance = true,
isConcise = true,
}: BudgetAnalysisGraphProps) {
const { t } = useTranslation();
const format = useFormat();
const locale = useLocale();
const privacyMode = usePrivacyMode();
const [yAxisIsHovered, setYAxisIsHovered] = useState(false);
// Centralize translated labels to avoid repetition
const budgetedLabel = t('Budgeted');
const spentLabel = t('Spent');
const balanceLabel = t('Balance');
const overspendingLabel = t('Overspending Adjustment');
const graphData = data.intervalData;
const formatDate = (date: string) => {
if (isConcise) {
// Monthly format
return monthUtils.format(date, 'MMM', locale);
}
// Daily format
return monthUtils.format(date, 'MMM d', locale);
};
const commonProps = {
width: 0,
height: 0,
data: graphData,
margin: { top: 5, right: 5, left: 5, bottom: 5 },
};
return (
<Container style={style}>
{(width, height) => {
const chartProps = { ...commonProps, width, height };
return graphType === 'Bar' ? (
<ComposedChart {...chartProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.pillBorder} />
<XAxis
dataKey="date"
tick={{ fill: theme.reportsLabel }}
tickFormatter={formatDate}
minTickGap={50}
/>
<YAxis
tick={{ fill: theme.reportsLabel }}
tickCount={8}
tickFormatter={value =>
privacyMode && !yAxisIsHovered
? '...'
: format(value, 'financial-no-decimals')
}
onMouseEnter={() => setYAxisIsHovered(true)}
onMouseLeave={() => setYAxisIsHovered(false)}
stroke={theme.pageTextSubdued}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={
<CustomTooltip
isConcise={isConcise}
format={format}
showBalance={showBalance}
/>
}
isAnimationActive={false}
/>
<Bar
dataKey="budgeted"
fill={theme.reportsNumberPositive}
name={budgetedLabel}
animationDuration={1000}
/>
<Bar
dataKey="spent"
fill={theme.reportsNumberNegative}
name={spentLabel}
animationDuration={1000}
/>
<Bar
dataKey="overspendingAdjustment"
fill={theme.templateNumberUnderFunded}
name={overspendingLabel}
animationDuration={1000}
/>
{showBalance && (
<Line
type="monotone"
dataKey="balance"
stroke={theme.reportsNumberNeutral}
strokeWidth={2}
name={balanceLabel}
dot={false}
animationDuration={1000}
/>
)}
</ComposedChart>
) : (
<LineChart {...chartProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.pillBorder} />
<XAxis
dataKey="date"
tick={{ fill: theme.reportsLabel }}
tickFormatter={formatDate}
minTickGap={50}
/>
<YAxis
tick={{ fill: theme.reportsLabel }}
tickCount={8}
tickFormatter={value =>
privacyMode && !yAxisIsHovered
? '...'
: format(value, 'financial-no-decimals')
}
onMouseEnter={() => setYAxisIsHovered(true)}
onMouseLeave={() => setYAxisIsHovered(false)}
stroke={theme.pageTextSubdued}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={
<CustomTooltip
isConcise={isConcise}
format={format}
showBalance={showBalance}
/>
}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="budgeted"
stroke={theme.reportsNumberPositive}
strokeWidth={2}
name={budgetedLabel}
dot={false}
animationDuration={1000}
/>
<Line
type="monotone"
dataKey="spent"
stroke={theme.reportsNumberNegative}
strokeWidth={2}
name={spentLabel}
dot={false}
animationDuration={1000}
/>
<Line
type="monotone"
dataKey="overspendingAdjustment"
stroke={theme.templateNumberUnderFunded}
strokeWidth={2}
name={overspendingLabel}
dot={false}
animationDuration={1000}
/>
{showBalance && (
<Line
type="monotone"
dataKey="balance"
stroke={theme.reportsNumberNeutral}
strokeWidth={2}
name={balanceLabel}
dot={false}
animationDuration={1000}
/>
)}
</LineChart>
);
}}
</Container>
);
}

View File

@@ -0,0 +1,508 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { AlignedText } from '@actual-app/components/aligned-text';
import { Block } from '@actual-app/components/block';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { SvgChart, SvgChartBar } from '@actual-app/components/icons/v1';
import { Paragraph } from '@actual-app/components/paragraph';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import * as d from 'date-fns';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import {
type BudgetAnalysisWidget,
type RuleConditionEntity,
type TimeFrame,
} from 'loot-core/types/models';
import { EditablePageHeaderTitle } from '@desktop-client/components/EditablePageHeaderTitle';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import {
MobilePageHeader,
Page,
PageHeader,
} from '@desktop-client/components/Page';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { Change } from '@desktop-client/components/reports/Change';
import { BudgetAnalysisGraph } from '@desktop-client/components/reports/graphs/BudgetAnalysisGraph';
import { Header } from '@desktop-client/components/reports/Header';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createBudgetAnalysisSpreadsheet } from '@desktop-client/components/reports/spreadsheets/budget-analysis-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { fromDateRepr } from '@desktop-client/components/reports/util';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useRuleConditionFilters } from '@desktop-client/hooks/useRuleConditionFilters';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useWidget } from '@desktop-client/hooks/useWidget';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function BudgetAnalysis() {
const params = useParams();
const { data: widget, isLoading } = useWidget<BudgetAnalysisWidget>(
params.id ?? '',
'budget-analysis-card',
);
if (isLoading) {
return <LoadingIndicator />;
}
return <BudgetAnalysisInternal widget={widget} />;
}
type BudgetAnalysisInternalProps = {
widget?: BudgetAnalysisWidget;
};
function BudgetAnalysisInternal({ widget }: BudgetAnalysisInternalProps) {
const locale = useLocale();
const dispatch = useDispatch();
const { t } = useTranslation();
const format = useFormat();
const {
conditions,
conditionsOp,
onApply: onApplyFilter,
onDelete: onDeleteFilter,
onUpdate: onUpdateFilter,
onConditionsOpChange,
} = useRuleConditionFilters<RuleConditionEntity>(
widget?.meta?.conditions,
widget?.meta?.conditionsOp,
);
const [allMonths, setAllMonths] = useState<Array<{
name: string;
pretty: string;
}> | null>(null);
const [start, setStart] = useState(monthUtils.currentMonth());
const [end, setEnd] = useState(monthUtils.currentMonth());
const [mode, setMode] = useState<TimeFrame['mode']>('sliding-window');
const [graphType, setGraphType] = useState<'Line' | 'Bar'>(
widget?.meta?.graphType || 'Bar',
);
const [showBalance, setShowBalance] = useState(
widget?.meta?.showBalance ?? true,
);
const [latestTransaction, setLatestTransaction] = useState('');
const [isConcise, setIsConcise] = useState(() => {
// Default to concise (monthly) view until we load the actual date range
return true;
});
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
const calculateIsConcise = (startMonth: string, endMonth: string) => {
const numDays = d.differenceInCalendarDays(
d.parseISO(endMonth + '-01'),
d.parseISO(startMonth + '-01'),
);
return numDays > 31 * 3;
};
useEffect(() => {
async function run() {
const earliestTrans = await send('get-earliest-transaction');
const latestTrans = await send('get-latest-transaction');
const latestTransDate = latestTrans
? fromDateRepr(latestTrans.date)
: monthUtils.currentDay();
setLatestTransaction(latestTransDate);
const currentMonth = monthUtils.currentMonth();
let earliestMonth = earliestTrans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(earliestTrans.date)))
: currentMonth;
const latestTransactionMonth = latestTrans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(latestTrans.date)))
: currentMonth;
const latestMonth =
latestTransactionMonth > currentMonth
? latestTransactionMonth
: currentMonth;
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonthsData = monthUtils
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
}))
.reverse();
setAllMonths(allMonthsData);
if (widget?.meta?.timeFrame) {
const [calculatedStart, calculatedEnd] = calculateTimeRange(
widget.meta.timeFrame,
undefined,
latestTransDate,
);
setStart(calculatedStart);
setEnd(calculatedEnd);
setMode(widget.meta.timeFrame.mode);
setIsConcise(calculateIsConcise(calculatedStart, calculatedEnd));
} else {
const [liveStart, liveEnd] = calculateTimeRange({
start: monthUtils.subMonths(currentMonth, 5),
end: currentMonth,
mode: 'sliding-window',
});
setStart(liveStart);
setEnd(liveEnd);
setIsConcise(calculateIsConcise(liveStart, liveEnd));
}
}
run();
}, [locale, widget?.meta?.timeFrame]);
const startDate = start + '-01';
const endDate = monthUtils.getMonthEnd(end + '-01');
const getGraphData = useMemo(
() =>
createBudgetAnalysisSpreadsheet({
conditions,
conditionsOp,
startDate,
endDate,
}),
[conditions, conditionsOp, startDate, endDate],
);
const data = useReport('default', getGraphData);
const navigate = useNavigate();
const { isNarrowWidth } = useResponsive();
const onChangeDates = (
newStart: string,
newEnd: string,
newMode: TimeFrame['mode'],
) => {
setStart(newStart);
setEnd(newEnd);
setMode(newMode);
setIsConcise(calculateIsConcise(newStart, newEnd));
};
async function onSaveWidget() {
if (!widget) {
throw new Error('No widget that could be saved.');
}
await send('dashboard-update-widget', {
id: widget.id,
meta: {
...(widget.meta ?? {}),
conditions,
conditionsOp,
timeFrame: {
start,
end,
mode,
},
graphType,
showBalance,
},
});
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Dashboard widget successfully saved.'),
},
}),
);
}
if (!data || !allMonths) {
return <LoadingIndicator />;
}
const latestInterval = data.intervalData[data.intervalData.length - 1];
const endingBalance = latestInterval?.balance ?? 0;
const title = widget?.meta?.name || t('Budget Analysis');
const onSaveWidgetName = async (newName: string) => {
if (!widget) {
throw new Error('No widget that could be saved.');
}
const name = newName || t('Budget Analysis');
await send('dashboard-update-widget', {
id: widget.id,
meta: {
...(widget.meta ?? {}),
name,
},
});
};
return (
<Page
header={
isNarrowWidth ? (
<MobilePageHeader
title={title}
leftContent={
<MobileBackButton onPress={() => navigate('/reports')} />
}
/>
) : (
<PageHeader
title={
widget ? (
<EditablePageHeaderTitle
title={title}
onSave={onSaveWidgetName}
/>
) : (
title
)
}
/>
)
}
padding={0}
>
<Header
start={start}
end={end}
mode={mode}
show1Month
allMonths={allMonths}
earliestTransaction={allMonths[allMonths.length - 1].name}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
onChangeDates={onChangeDates}
filters={conditions}
conditionsOp={conditionsOp}
onApply={onApplyFilter}
onUpdateFilter={onUpdateFilter}
onDeleteFilter={onDeleteFilter}
onConditionsOpChange={onConditionsOpChange}
filterExclude={[
'date',
'account',
'payee',
'notes',
'amount',
'cleared',
'reconciled',
'transfer',
]}
inlineContent={
<Tooltip
content={
graphType === 'Line'
? t('Switch to bar chart')
: t('Switch to line chart')
}
>
<Button
variant="bare"
onPress={() =>
setGraphType(graphType === 'Line' ? 'Bar' : 'Line')
}
>
{graphType === 'Line' ? (
<SvgChartBar style={{ width: 12, height: 12 }} />
) : (
<SvgChart style={{ width: 12, height: 12 }} />
)}
</Button>
</Tooltip>
}
>
<View style={{ flexDirection: 'row', gap: 10 }}>
<Button onPress={() => setShowBalance(state => !state)}>
{showBalance ? t('Hide balance') : t('Show balance')}
</Button>
{widget && (
<Button variant="primary" onPress={onSaveWidget}>
<Trans>Save widget</Trans>
</Button>
)}
</View>
</Header>
<View
style={{
display: 'flex',
flexDirection: 'row',
paddingTop: 0,
flexGrow: 1,
}}
>
<View
style={{
flexGrow: 1,
}}
>
<View
style={{
backgroundColor: theme.tableBackground,
padding: 20,
paddingTop: 0,
flex: '1 0 auto',
overflowY: 'auto',
}}
>
<View
style={{
flexDirection: 'column',
flexGrow: 1,
padding: 10,
paddingTop: 10,
}}
>
<View
style={{
alignItems: 'flex-end',
flexDirection: 'row',
}}
>
<View style={{ flex: 1 }} />
<View
style={{
alignItems: 'flex-end',
color: theme.pageText,
}}
>
<View>
{data && (
<>
<AlignedText
style={{ marginBottom: 5, minWidth: 210 }}
left={
<Block>
<Trans>Budgeted:</Trans>
</Block>
}
right={
<FinancialText style={{ fontWeight: 600 }}>
<PrivacyFilter>
{format(data.totalBudgeted, 'financial')}
</PrivacyFilter>
</FinancialText>
}
/>
<AlignedText
style={{ marginBottom: 5, minWidth: 210 }}
left={
<Block>
<Trans>Spent:</Trans>
</Block>
}
right={
<FinancialText style={{ fontWeight: 600 }}>
<PrivacyFilter>
{format(data.totalSpent, 'financial')}
</PrivacyFilter>
</FinancialText>
}
/>
<AlignedText
style={{ marginBottom: 5, minWidth: 210 }}
left={
<Block>
<Trans>Overspending adj:</Trans>
</Block>
}
right={
<FinancialText style={{ fontWeight: 600 }}>
<PrivacyFilter>
{format(
data.totalOverspendingAdjustment,
'financial',
)}
</PrivacyFilter>
</FinancialText>
}
/>
{showBalance && (
<AlignedText
style={{ marginBottom: 5, minWidth: 210 }}
left={
<Block>
<Trans>Ending balance:</Trans>
</Block>
}
right={
<FinancialText style={{ fontWeight: 600 }}>
<PrivacyFilter>
<Change amount={endingBalance} />
</PrivacyFilter>
</FinancialText>
}
/>
)}
</>
)}
</View>
</View>
</View>
<BudgetAnalysisGraph
style={{ flexGrow: 1 }}
data={data}
graphType={graphType}
showBalance={showBalance}
isConcise={isConcise}
/>
<View style={{ marginTop: 30 }}>
<Trans>
<Paragraph>
<strong>Understanding the Chart</strong>
<br /> <strong>Budgeted:</strong> The amount you allocated
each month
<br /> <strong>Spent:</strong> Your actual spending
<br /> <strong>Overspending Adjustment:</strong> Amounts
from categories without rollover that were reset
<br /> <strong>Balance:</strong> Your cumulative budget
performance, starting with any prior balance. Respects
category rollover settings from your budget.
</Paragraph>
<Paragraph>
<strong>Understanding the Budget Summary</strong>
<br />
The balance starts from the month before your selected
period. Budgeted, spent, and overspending adjustments show
totals over the period. Ending balance shows the final
balance at period end. You can filter by categories to track
changes in a specific area.
</Paragraph>
<Paragraph>
Hint: You can use the icon in the header to toggle between
line and bar chart views.
</Paragraph>
</Trans>
</View>
</View>
</View>
</View>
</View>
</Page>
);
}

View File

@@ -0,0 +1,168 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Block } from '@actual-app/components/block';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import * as monthUtils from 'loot-core/shared/months';
import { type BudgetAnalysisWidget } from 'loot-core/types/models';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { DateRange } from '@desktop-client/components/reports/DateRange';
import { BudgetAnalysisGraph } from '@desktop-client/components/reports/graphs/BudgetAnalysisGraph';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
import { createBudgetAnalysisSpreadsheet } from '@desktop-client/components/reports/spreadsheets/budget-analysis-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat';
type BudgetAnalysisCardProps = {
widgetId: string;
isEditing?: boolean;
meta?: BudgetAnalysisWidget['meta'];
onMetaChange: (newMeta: BudgetAnalysisWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function BudgetAnalysisCard({
widgetId,
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: BudgetAnalysisCardProps) {
const { t } = useTranslation();
const format = useFormat();
const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const timeFrame = meta?.timeFrame ?? {
start: monthUtils.subMonths(monthUtils.currentMonth(), 5),
end: monthUtils.currentMonth(),
mode: 'sliding-window' as const,
};
// Calculate date range
let startDate = timeFrame.start + '-01';
let endDate = monthUtils.getMonthEnd(timeFrame.end + '-01');
if (timeFrame.mode === 'sliding-window') {
const currentMonth = monthUtils.currentMonth();
startDate = monthUtils.subMonths(currentMonth, 5) + '-01';
endDate = monthUtils.getMonthEnd(currentMonth + '-01');
}
const getGraphData = useMemo(() => {
return createBudgetAnalysisSpreadsheet({
conditions: meta?.conditions,
conditionsOp: meta?.conditionsOp,
startDate,
endDate,
});
}, [meta?.conditions, meta?.conditionsOp, startDate, endDate]);
const data = useReport('default', getGraphData);
const latestInterval =
data && data.intervalData.length > 0
? data.intervalData[data.intervalData.length - 1]
: undefined;
const balance = latestInterval?.balance ?? 0;
return (
<ReportCard
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/budget-analysis/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
>
<View
style={{ flex: 1 }}
onPointerEnter={() => setIsCardHovered(true)}
onPointerLeave={() => setIsCardHovered(false)}
>
<View style={{ flexDirection: 'row', padding: 20 }}>
<View style={{ flex: 1 }}>
<ReportCardName
name={meta?.name || t('Budget Analysis')}
isEditing={nameMenuOpen}
onChange={newName => {
onMetaChange({
...meta,
name: newName,
});
setNameMenuOpen(false);
}}
onClose={() => setNameMenuOpen(false)}
/>
<DateRange
start={monthUtils.getMonth(startDate)}
end={monthUtils.getMonth(endDate)}
/>
</View>
{data && (
<View style={{ textAlign: 'right' }}>
<Block
style={{
...styles.mediumText,
fontWeight: 500,
marginBottom: 5,
color: balance >= 0 ? theme.noticeTextLight : theme.errorText,
}}
>
<FinancialText>
<PrivacyFilter activationFilters={[!isCardHovered]}>
{format(balance, 'financial')}
</PrivacyFilter>
</FinancialText>
</Block>
</View>
)}
</View>
{data ? (
<BudgetAnalysisGraph
style={{ flex: 1 }}
data={data}
graphType={meta?.graphType || 'Bar'}
showBalance={meta?.showBalance ?? true}
/>
) : (
<LoadingIndicator />
)}
</View>
</ReportCard>
);
}

View File

@@ -0,0 +1,238 @@
// @ts-strict-ignore
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import {
type CategoryEntity,
type RuleConditionEntity,
} from 'loot-core/types/models';
import { type useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet';
type BudgetAnalysisIntervalData = {
date: string;
budgeted: number;
spent: number;
balance: number;
overspendingAdjustment: number;
};
type BudgetAnalysisData = {
intervalData: BudgetAnalysisIntervalData[];
startDate: string;
endDate: string;
totalBudgeted: number;
totalSpent: number;
totalOverspendingAdjustment: number;
finalOverspendingAdjustment: number;
};
type createBudgetAnalysisSpreadsheetProps = {
conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or';
startDate: string;
endDate: string;
};
export function createBudgetAnalysisSpreadsheet({
conditions = [],
conditionsOp = 'and',
startDate,
endDate,
}: createBudgetAnalysisSpreadsheetProps) {
return async (
spreadsheet: ReturnType<typeof useSpreadsheet>,
setData: (data: BudgetAnalysisData) => void,
) => {
// Get all categories
const { list: allCategories } = await send('get-categories');
// Filter categories based on conditions
const categoryConditions = conditions.filter(
cond => !cond.customName && cond.field === 'category',
);
// Base set: expense categories only (exclude income and hidden)
const baseCategories = allCategories.filter(
(cat: CategoryEntity) => !cat.is_income && !cat.hidden,
);
let categoriesToInclude: CategoryEntity[];
if (categoryConditions.length > 0) {
// Evaluate each condition to get sets of matching categories
const conditionResults = categoryConditions.map(cond => {
return baseCategories.filter((cat: CategoryEntity) => {
if (cond.op === 'is') {
return cond.value === cat.id;
} else if (cond.op === 'isNot') {
return cond.value !== cat.id;
} else if (cond.op === 'oneOf') {
return cond.value.includes(cat.id);
} else if (cond.op === 'notOneOf') {
return !cond.value.includes(cat.id);
}
return false;
});
});
// Combine results based on conditionsOp
if (conditionsOp === 'or') {
// OR: Union of all matching categories
const categoryIds = new Set(conditionResults.flat().map(cat => cat.id));
categoriesToInclude = baseCategories.filter(cat =>
categoryIds.has(cat.id),
);
} else {
// AND: Intersection of all matching categories
if (conditionResults.length === 0) {
categoriesToInclude = [];
} else {
const firstSet = new Set(conditionResults[0].map(cat => cat.id));
for (let i = 1; i < conditionResults.length; i++) {
const currentIds = new Set(conditionResults[i].map(cat => cat.id));
// Keep only categories that are in both sets
for (const id of firstSet) {
if (!currentIds.has(id)) {
firstSet.delete(id);
}
}
}
categoriesToInclude = baseCategories.filter(cat =>
firstSet.has(cat.id),
);
}
}
} else {
// No category filter, use all expense categories
categoriesToInclude = baseCategories;
}
// Get monthly intervals (Budget Analysis only supports monthly)
const intervals = monthUtils.rangeInclusive(
monthUtils.getMonth(startDate),
monthUtils.getMonth(endDate),
);
const intervalData: BudgetAnalysisIntervalData[] = [];
// 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,
);
const prevMonthData = await send('envelope-budget-month', {
month: monthBeforeStart,
});
// Calculate the carryover from the previous month
for (const cat of categoriesToInclude) {
const balanceCell = prevMonthData.find((cell: { name: string }) =>
cell.name.endsWith(`leftover-${cat.id}`),
);
const carryoverCell = prevMonthData.find((cell: { name: string }) =>
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;
}
}
// Track totals across all months
let totalBudgeted = 0;
let totalSpent = 0;
let totalOverspendingAdjustment = 0;
// Track overspending from previous month to apply in next month
let overspendingFromPrevMonth = 0;
// Process each month
for (const month of intervals) {
// Get budget values from the server for this month
// 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: { name: string }) =>
cell.name.endsWith(`budget-${cat.id}`),
);
const spentCell = monthData.find((cell: { name: string }) =>
cell.name.endsWith(`sum-amount-${cat.id}`),
);
const balanceCell = monthData.find((cell: { name: string }) =>
cell.name.endsWith(`leftover-${cat.id}`),
);
const carryoverCell = monthData.find((cell: { name: string }) =>
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
}
}
// Apply overspending adjustment from previous month (negative value)
const overspendingAdjustment = overspendingFromPrevMonth;
// This month's balance = budgeted + spent + running balance + overspending adjustment
const monthBalance = budgeted + spent + runningBalance;
// Update totals
totalBudgeted += budgeted;
totalSpent += spent;
totalOverspendingAdjustment += Math.abs(overspendingAdjustment);
intervalData.push({
date: month,
budgeted,
spent, // Display as positive
balance: monthBalance,
overspendingAdjustment: Math.abs(overspendingAdjustment), // Display as positive
});
// Update running balance for next month
runningBalance = carryoverToNextMonth;
// Save this month's overspending to apply in next month
overspendingFromPrevMonth = overspendingThisMonth;
}
setData({
intervalData,
startDate,
endDate,
totalBudgeted,
totalSpent,
totalOverspendingAdjustment,
finalOverspendingAdjustment: overspendingFromPrevMonth,
});
};
}

View File

@@ -208,11 +208,17 @@ export function ExperimentalFeatures() {
>
<Trans>Custom themes</Trans>
</FeatureToggle>
<FeatureToggle
flag="budgetAnalysisReport"
feedbackLink="https://github.com/actualbudget/actual/pull/6137"
>
<Trans>Budget Analysis Report</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"
disableToggle
feedbackLink="https://github.com/actualbudget/actual/issues/5950"
feedbackLink="https://github.com/actualbudget/actual/issues/6742"
>
<Trans>Client-Side plugins (soon)</Trans>
</ServerFeatureToggle>

View File

@@ -10,6 +10,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
currency: false,
crossoverReport: false,
customThemes: false,
budgetAnalysisReport: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -219,6 +219,7 @@ const sidebars = {
'experimental/pluggyai',
'experimental/crossover-point-report',
'experimental/custom-themes',
'experimental/budget-analysis-report',
],
},
'getting-started/tips-tricks',

View File

@@ -0,0 +1,39 @@
# Budget Analysis Report
:::warning
This is an **experimental feature**. That means were still working on finishing it. There may be bugs, missing functionality or incomplete documentation, and we may decide to remove the feature in a future release. If you have any feedback, please comment on the [dedicated issue](https://github.com/actualbudget/actual/issues/6742) or post a message in the Discord.
:::
## What it is
The Budget Analysis is a financial planning tool, that tracks the balance of your budget over time.
It tracks four separate series: **Budgeted**, **Spent**, **Overspending Adjustment**, and the cumulative **Balance**.
![Example image of Budget Analysis Report](/img/experimental/budget-analysis/budget-analysis-image.webp)
## Important information
- The report pulls the numbers directly from the Budget page, so it only includes budget categories (no transfers or offbudget accounts).
- The report's numbers reflect your filtered view, so if you exclude categories or change date ranges, the report updates accordingly.
- Category rollover rules affect negative balances:
- If **Rollover overspending** is enabled for a category, negative balances carry forward.
- If **Rollover overspending** is disabled, negative balances for that category are zeroed and recorded as an **Overspending Adjustment** (aggregated and shown as its own series).
## Display options
- **Live / Static**: toggle a rolling window (auto-updates) or a fixed date range.
- **Start / End**: pick start and end months.
- **Quick ranges**: 1, 3, 6 months, 1 year, Year-to-date, Previous year-to-date, All time.
- **Filters**: use the Filter button → choose _Category_ to include/exclude categories; active filters appear as editable chips.
- **Graph type**: toggle Line ↔ Bar via the header icon.
- **Show/Hide balance**: toggle the running balance series.
## Quick troubleshooting
- **No data**: check that budgets and transactions exist in the selected months and that filters arent excluding everything.
- **Balance looks wrong**: verify category rollover settings and transaction categorization.
## Related
- [Budget page](/docs/tour/budget.md) — configure budgets and rollover settings.
- [Reports index](/docs/reports/index.md) — other report types and tips.

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -34,6 +34,7 @@ function isWidgetType(type: string): type is Widget['type'] {
'cash-flow-card',
'spending-card',
'crossover-card',
'budget-analysis-card',
'markdown-card',
'summary-card',
'calendar-card',

View File

@@ -69,6 +69,18 @@ export type SpendingWidget = AbstractWidget<
mode?: 'single-month' | 'budget' | 'average';
} | null
>;
export type BudgetAnalysisWidget = AbstractWidget<
'budget-analysis-card',
{
name?: string;
conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or';
timeFrame?: TimeFrame;
interval?: 'Daily' | 'Weekly' | 'Monthly' | 'Yearly';
graphType?: 'Line' | 'Bar';
showBalance?: boolean;
} | null
>;
export type CustomReportWidget = AbstractWidget<
'custom-report',
{ id: string }
@@ -97,6 +109,7 @@ type SpecializedWidget =
| NetWorthWidget
| CashFlowWidget
| SpendingWidget
| BudgetAnalysisWidget
| CrossoverWidget
| MarkdownWidget
| SummaryWidget

View File

@@ -5,7 +5,8 @@ export type FeatureFlag =
| 'formulaMode'
| 'currency'
| 'crossoverReport'
| 'customThemes';
| 'customThemes'
| 'budgetAnalysisReport';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [tabedzki]
---
Add "Budget Analysis" : a report that tracks the balance of your budget categories over time. Displays budgeted amounts, actual spending, overspending adjustments, and cumulative balance, considering category rollover settings.