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>
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -219,6 +219,7 @@ const sidebars = {
|
||||
'experimental/pluggyai',
|
||||
'experimental/crossover-point-report',
|
||||
'experimental/custom-themes',
|
||||
'experimental/budget-analysis-report',
|
||||
],
|
||||
},
|
||||
'getting-started/tips-tricks',
|
||||
|
||||
39
packages/docs/docs/experimental/budget-analysis-report.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Budget Analysis Report
|
||||
|
||||
:::warning
|
||||
This is an **experimental feature**. That means we’re 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**.
|
||||
|
||||

|
||||
|
||||
## Important information
|
||||
|
||||
- The report pulls the numbers directly from the Budget page, so it only includes budget categories (no transfers or off‑budget 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 aren’t 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.
|
||||
BIN
packages/docs/static/img/experimental/budget-analysis/budget-analysis-image.webp
vendored
Normal file
|
After Width: | Height: | Size: 75 KiB |
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,8 @@ export type FeatureFlag =
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'customThemes';
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
6
upcoming-release-notes/6137.md
Normal 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.
|
||||