diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index 0209bd8affd..5f43094d289 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -27,6 +27,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'; @@ -72,6 +73,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'); @@ -509,6 +511,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'), @@ -703,6 +713,18 @@ export function Overview({ dashboard }: OverviewProps) { onCopyWidget(item.i, targetDashboardId) } /> + ) : item.type === 'budget-analysis-card' && + budgetAnalysisReportEnabled ? ( + onMetaChange(item, newMeta)} + onRemove={() => onRemoveWidget(item.i)} + onCopy={targetDashboardId => + onCopyWidget(item.i, targetDashboardId) + } + /> ) : item.type === 'markdown-card' ? ( @@ -34,6 +36,12 @@ export function ReportRouter() { } /> } /> } /> + {budgetAnalysisReportEnabled && ( + <> + } /> + } /> + + )} } /> } /> } /> diff --git a/packages/desktop-client/src/components/reports/graphs/BudgetAnalysisGraph.tsx b/packages/desktop-client/src/components/reports/graphs/BudgetAnalysisGraph.tsx new file mode 100644 index 00000000000..9a78525d3dd --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/BudgetAnalysisGraph.tsx @@ -0,0 +1,334 @@ +import { useTranslation, Trans } 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 { Container } from '@desktop-client/components/reports/Container'; +import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat'; +import { useLocale } from '@desktop-client/hooks/useLocale'; + +/** + * 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 ( +
+
+
+ + {monthUtils.format( + data.date, + isConcise ? 'MMMM yyyy' : 'MMMM dd, yyyy', + locale, + )} + +
+
+ + + Budgeted: + + } + right={format(data.budgeted, 'financial')} + /> + + + Spent: + + } + right={format(-data.spent, 'financial')} + /> + + + {t('Overspending Adjustment:')} + + } + right={format(data.overspendingAdjustment, 'financial')} + /> + {showBalance && ( + + + Balance: + + } + right={{format(data.balance, 'financial')}} + /> + )} +
+
+
+ ); +} + +export function BudgetAnalysisGraph({ + style, + data, + graphType = 'Line', + showBalance = true, + isConcise = true, +}: BudgetAnalysisGraphProps) { + const { t } = useTranslation(); + const format = useFormat(); + const locale = useLocale(); + + // 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 ( + + {(width, height) => { + const chartProps = { ...commonProps, width, height }; + + return graphType === 'Bar' ? ( + + + + format(value, 'financial')} + stroke={theme.pageTextSubdued} + /> + + } + isAnimationActive={false} + /> + + + + {showBalance && ( + + )} + + ) : ( + + + + format(value, 'financial')} + stroke={theme.pageTextSubdued} + /> + + } + isAnimationActive={false} + /> + + + + {showBalance && ( + + )} + + ); + }} + + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx b/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx new file mode 100644 index 00000000000..fb014123195 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/BudgetAnalysis.tsx @@ -0,0 +1,499 @@ +import React, { useState, useMemo, useEffect } 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 { SvgChartBar, SvgChart } from '@actual-app/components/icons/v1'; +import { Paragraph } from '@actual-app/components/paragraph'; +import { Text } from '@actual-app/components/text'; +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 { 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( + params.id ?? '', + 'budget-analysis-card', + ); + + if (isLoading) { + return ; + } + + return ; +} + +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( + widget?.meta?.conditions, + widget?.meta?.conditionsOp, + ); + + const [allMonths, setAllMonths] = useState | null>(null); + + const [start, setStart] = useState(monthUtils.currentMonth()); + const [end, setEnd] = useState(monthUtils.currentMonth()); + const [mode, setMode] = useState('sliding-window'); + const [graphType, setGraphType] = useState<'Line' | 'Bar'>( + widget?.meta?.graphType || 'Line', + ); + 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 ; + } + + 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 ( + navigate('/reports')} /> + } + /> + ) : ( + + ) : ( + title + ) + } + /> + ) + } + padding={0} + > +
+ + + + + } + > + + + + {widget && ( + + )} + +
+ + + + + + + + + {data && ( + <> + + Budgeted: + + } + right={ + + + {format(data.totalBudgeted, 'financial')} + + + } + /> + + Spent: + + } + right={ + + + {format(-data.totalSpent, 'financial')} + + + } + /> + + Overspending adj: + + } + right={ + + + {format( + data.totalOverspendingAdjustment, + 'financial', + )} + + + } + /> + {showBalance && ( + + Ending balance: + + } + right={ + + + + + + } + /> + )} + + )} + + + + + + + + Understanding the Chart +
Budgeted (blue): The amount you + allocated each month +
Spent (red): Your actual spending +
Overspending Adjustment (orange): + Amounts from categories without rollover that were reset +
Balance (gray): Your cumulative + budget performance, starting with any prior balance. + Respects category rollover settings from your budget. +
+ + Understanding the Budget Summary +
+ 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. +
+ + Hint: You can use the icon in the header to toggle between + line and bar chart views. + +
+
+
+
+
+
+
+ ); +} diff --git a/packages/desktop-client/src/components/reports/reports/BudgetAnalysisCard.tsx b/packages/desktop-client/src/components/reports/reports/BudgetAnalysisCard.tsx new file mode 100644 index 00000000000..ee6b5f1f48b --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/BudgetAnalysisCard.tsx @@ -0,0 +1,165 @@ +import React, { useState, useMemo } 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 { 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 ( + { + if (handleCopyMenuSelect(item)) return; + switch (item) { + case 'rename': + setNameMenuOpen(true); + break; + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > + setIsCardHovered(true)} + onPointerLeave={() => setIsCardHovered(false)} + > + + + { + onMetaChange({ + ...meta, + name: newName, + }); + setNameMenuOpen(false); + }} + onClose={() => setNameMenuOpen(false)} + /> + + + {data && ( + + = 0 ? theme.noticeTextLight : theme.errorText, + }} + > + + {format(balance, 'financial')} + + + + )} + + {data ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/desktop-client/src/components/reports/spreadsheets/budget-analysis-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/budget-analysis-spreadsheet.ts new file mode 100644 index 00000000000..18ff38021ef --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/budget-analysis-spreadsheet.ts @@ -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, + 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 += Math.abs(spent); + totalOverspendingAdjustment += Math.abs(overspendingAdjustment); + + intervalData.push({ + date: month, + budgeted, + spent: Math.abs(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, + }); + }; +} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index fb7f1194cd1..05585e5cbf8 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -208,6 +208,12 @@ export function ExperimentalFeatures() { > Custom themes + + Budget Analysis Report + {showServerPrefs && ( = { currency: false, crossoverReport: false, customThemes: false, + budgetAnalysisReport: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/docs/docs-sidebar.js b/packages/docs/docs-sidebar.js index b903264f1fe..9565443a1ff 100644 --- a/packages/docs/docs-sidebar.js +++ b/packages/docs/docs-sidebar.js @@ -219,6 +219,7 @@ const sidebars = { 'experimental/pluggyai', 'experimental/crossover-point-report', 'experimental/custom-themes', + 'experimental/budget-analysis-report', ], }, 'getting-started/tips-tricks', diff --git a/packages/docs/docs/experimental/budget-analysis-report.md b/packages/docs/docs/experimental/budget-analysis-report.md new file mode 100644 index 00000000000..a8c883c2f87 --- /dev/null +++ b/packages/docs/docs/experimental/budget-analysis-report.md @@ -0,0 +1,205 @@ +# 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 [open an issue](https://github.com/actualbudget/actual/issues) or post a message in the Discord. +::: + +![Example image of Budget Analysis Report](/img/experimental/budget-analysis/budget-analysis-image.webp) + +The Budget Analysis is a financial planning tool, inspired by the **Cash Flow** report. It tracks your budgets over time. For any given month, the report starts with the previous month's balance and adding the amount budgeted (and spent) for each category during the month. +Like **Cash flow**, it includes separate visualizations for the amount budgeted and expenses, as well as the remaining balance within the category. + +## How It Works + +The balance tracks your budget performance over time. It starts with the previous interval's balance, adds the budgeted amount for the current interval, and subtracts actual spending. A positive balance indicates under-spending while a negative balance shows over-spending. + +## Key Features + +### Time Period Controls + +Control the date range for your analysis: + +- **Live/Static toggle**: Switch between dynamic (rolling window) and fixed date ranges + - **Live**: The report automatically updates to show a rolling time window relative to the current date + - **Static**: The report shows a fixed date range that doesn't change +- **Date range dropdowns**: Select specific start and end months +- **Quick range buttons**: + - **1 month**: Shows the most recent month + - **3 months**: Shows the last 3 months + - **6 months**: Shows the last 6 months + - **1 year**: Shows the last 12 months + - **Year to date**: Shows from January 1st to the current month + - **All time**: Shows your entire budget history + +### Category Filtering + +Filter the report to analyze specific categories: + +1. Click the **Filter** button (funnel icon) in the header +2. Select **Category** as the field +3. Choose an operator: + - **is**: Analyze a single category + - **is not**: Exclude a specific category + - **one of**: Analyze multiple categories + - **not one of**: Exclude multiple categories +4. Select categories and click **Apply** + +#### Managing Filters + +Active filters appear as chips below the header: +- Click the **✕** on a filter chip to remove it +- Click on a filter chip to edit its settings +- When multiple filters are applied, choose how they combine: + - **and**: All conditions must be met (more restrictive) + - **or**: Any condition can be met (more inclusive) + +### Display Options + +Customize how the data is presented: + +- **Graph type toggle**: Switch between visualization styles + - **Line chart**: Better for viewing trends over time (click the bar chart icon to switch) + - **Bar chart**: Better for comparing discrete monthly values (click the line chart icon to switch) + +- **Show/Hide balance**: Toggle the running balance line visibility + - Click **Hide balance** to remove the balance line from the graph + - Click **Show balance** to display the balance line + +### Summary Statistics + +The report displays key metrics for the selected time period: + +- **Total budgeted**: Total amount budgeted across all months in the period +- **Total spent**: Total amount actually spent across all months (displayed as positive) +- **Total overspending adj**: Total overspending adjustments for categories without rollover enabled +- **Ending balance**: The final balance at the end of the period, accounting for carryover rules + +## Understanding the Balance Calculation + +The budget balance calculation respects your category carryover settings: + +1. **Starting balance**: Begins with the previous month's balance +2. **Add budgeted**: Adds this month's budgeted amount +3. **Subtract spent**: Subtracts actual spending +4. **Carryover rules**: + - Positive balances always carry over to the next month + - Negative balances only carry over if the category has "Rollover overspending" enabled + - Negative balances for categories WITHOUT rollover are treated as overspending adjustments (zeroed out) + +### Overspending Adjustments + +When a category has a negative balance and "Rollover overspending" is disabled, that overspending is adjusted (removed) rather than carried forward. The overspending adjustment line tracks these amounts, helping you see where budget discipline was reset rather than carried forward. + +**Example:** +``` +Previous balance: $100 +Budgeted: $200 +Spent: $250 +Category has rollover: No +New balance: $200 + (-$250) = -$50 +Overspending adjustment: $50 (negative balance is zeroed) +Carried to next month: $0 +``` + +A positive balance means you're under budget overall, while a negative balance indicates overspending that needs to be addressed. + +### Saving Widget Settings + +When viewing the full report, customize your settings: +1. Adjust the date range +2. Add/remove category filters +3. Toggle between line and bar chart +4. Show or hide the balance line +5. Click **Save widget** to persist all settings + +The saved configuration will be applied to the dashboard widget and remembered when you return to the full report. + +## Use Cases + +### Track Monthly Performance + +View how you're doing against your current month's budget: +- Set date range to **1 month** +- Review budgeted vs spent amounts +- Check if balance is positive or negative + +### Analyze Specific Spending Areas + +Focus on particular categories: +- Add category filters (e.g., "Groceries", "Dining Out", "Entertainment") +- Track discretionary vs essential spending separately +- Identify problem categories + +### Review Long-Term Trends + +Understand your budgeting habits over time: +- Select **Year to date** or **All time** +- Use line chart to see trends +- Look for patterns in the balance line: + - Steadily increasing: You're consistently under budget + - Steadily decreasing: You're consistently over budget + - Fluctuating: Mixed performance + +### Compare Planned vs Actual + +Find months with significant variances: +- Use a 6-month or 1-year range +- Look for gaps between budgeted and spent lines +- Investigate months where spending significantly exceeded budgets + +## Tips and Best Practices + +### Effective Category Selection + +- **Include related categories**: Group similar spending types together (e.g., all food-related categories) +- **Exclude irregular categories**: Filter out one-time expenses or income categories for clearer trends +- **Track problem areas**: Focus on categories where you frequently overspend + +### Time Period Recommendations + +- **1-3 months**: Good for current budget monitoring +- **6 months**: Ideal for identifying seasonal patterns +- **1 year**: Best for comprehensive annual review +- **All time**: Useful for understanding long-term budgeting habits + +### Balance Interpretation + +- **Consistently positive**: You're building budget reserves, which can handle future overspending +- **Occasionally negative**: Normal for months with irregular expenses; watch for recovery in subsequent months +- **Consistently negative**: Indicates systematic overspending that needs addressing +- **Volatile balance**: May indicate seasonal expenses or inconsistent budgeting + +## Limitations + +- **Monthly aggregation only**: The report works with monthly budget periods and cannot show daily or weekly breakdowns +- **Budget categories only**: Only includes categories used in your budget; off-budget accounts and transfers are excluded +- **Historical data**: Requires existing budget and transaction data to display meaningful results + +## Troubleshooting + +### No data appears + +- Verify that you have budgeted amounts in the selected time period +- Check that you have transactions in budgeted categories +- Ensure category filters aren't excluding all categories +- Confirm the date range includes months with budget activity + +### Balance seems incorrect + +- Review category carryover settings in the budget page +- Verify that all transactions are properly categorized +- Check for hidden categories that might be excluded +- Remember that the balance is cumulative across all selected categories + +### Widget not updating + +- Make sure to click **Save widget** after making changes in the full report +- Changes made in the full report without saving won't persist to the dashboard +- The widget automatically refreshes when new transactions are added + +## Related Features + +- [Budget Page](/docs/tour/budget.md): Set budgeted amounts and configure category carryover +- [Custom Reports](/docs/reports/custom-reports.md): For more detailed transaction-level analysis +- [Spending Analysis](/docs/reports/index.md#spending-analysis): For category-based spending breakdowns +- [Cash Flow Report](/docs/reports/index.md#cash-flow-graph): For overall income and expense trends diff --git a/packages/docs/static/img/experimental/budget-analysis/budget-analysis-image.webp b/packages/docs/static/img/experimental/budget-analysis/budget-analysis-image.webp new file mode 100644 index 00000000000..13608c59782 Binary files /dev/null and b/packages/docs/static/img/experimental/budget-analysis/budget-analysis-image.webp differ diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts index d409763723c..3c3aa323ce2 100644 --- a/packages/loot-core/src/server/dashboard/app.ts +++ b/packages/loot-core/src/server/dashboard/app.ts @@ -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', diff --git a/packages/loot-core/src/types/models/dashboard.ts b/packages/loot-core/src/types/models/dashboard.ts index 2ef3e35b73d..7f70b3fc491 100644 --- a/packages/loot-core/src/types/models/dashboard.ts +++ b/packages/loot-core/src/types/models/dashboard.ts @@ -67,6 +67,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 } @@ -94,6 +106,7 @@ type SpecializedWidget = | NetWorthWidget | CashFlowWidget | SpendingWidget + | BudgetAnalysisWidget | CrossoverWidget | MarkdownWidget | SummaryWidget diff --git a/packages/loot-core/src/types/prefs.ts b/packages/loot-core/src/types/prefs.ts index 87a6271c319..59e6395380b 100644 --- a/packages/loot-core/src/types/prefs.ts +++ b/packages/loot-core/src/types/prefs.ts @@ -5,7 +5,8 @@ export type FeatureFlag = | 'formulaMode' | 'currency' | 'crossoverReport' - | 'customThemes'; + | 'customThemes' + | 'budgetAnalysisReport'; /** * Cross-device preferences. These sync across devices when they are changed. diff --git a/upcoming-release-notes/6137.md b/upcoming-release-notes/6137.md new file mode 100644 index 00000000000..0cdb1afae70 --- /dev/null +++ b/upcoming-release-notes/6137.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [tabedzki] +--- + +Add "Budget Analysis" : a report that tracks the balance of a Category similar to the "Cash Flow" Report