diff --git a/packages/desktop-client/src/components/reports/AccountSelector.tsx b/packages/desktop-client/src/components/reports/AccountSelector.tsx new file mode 100644 index 00000000000..857c5c9125d --- /dev/null +++ b/packages/desktop-client/src/components/reports/AccountSelector.tsx @@ -0,0 +1,421 @@ +import React, { useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { Button } from '@actual-app/components/button'; +import { + SvgCheckAll, + SvgUncheckAll, + SvgViewHide, + SvgViewShow, +} from '@actual-app/components/icons/v2'; +import { Text } from '@actual-app/components/text'; +import { View } from '@actual-app/components/view'; + +import { type AccountEntity } from 'loot-core/types/models'; + +import { GraphButton } from './GraphButton'; + +import { Checkbox } from '@desktop-client/components/forms'; + +type AccountSelectorProps = { + accounts: AccountEntity[]; + selectedAccountIds: string[]; + setSelectedAccountIds: (selectedAccountIds: string[]) => void; +}; + +export function AccountSelector({ + accounts, + selectedAccountIds, + setSelectedAccountIds, +}: AccountSelectorProps) { + const { t } = useTranslation(); + const [uncheckedHidden, setUncheckedHidden] = useState(false); + + // Group accounts by on-budget, off-budget, and closed + const groupedAccounts = useMemo(() => { + const onBudget = accounts.filter( + account => !account.offbudget && !account.closed, + ); + const offBudget = accounts.filter( + account => account.offbudget && !account.closed, + ); + const closed = accounts.filter(account => account.closed); + return { onBudget, offBudget, closed }; + }, [accounts]); + + const selectedAccountMap = useMemo( + () => new Set(selectedAccountIds), + [selectedAccountIds], + ); + + // Calculate selection states for each group + const onBudgetSelected = groupedAccounts.onBudget.every(account => + selectedAccountMap.has(account.id), + ); + const offBudgetSelected = groupedAccounts.offBudget.every(account => + selectedAccountMap.has(account.id), + ); + const closedSelected = groupedAccounts.closed.every(account => + selectedAccountMap.has(account.id), + ); + + const allAccountsSelected = + onBudgetSelected && offBudgetSelected && closedSelected; + const allAccountsUnselected = !selectedAccountIds.length; + + return ( + + + + + + { + setSelectedAccountIds(accounts.map(account => account.id)); + }} + style={{ marginRight: 5, padding: 8 }} + > + + + { + setSelectedAccountIds([]); + }} + style={{ padding: 8 }} + > + + + + + + + + ); +} diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index e7a04faa663..c64de9d49a4 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -25,6 +25,7 @@ import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants'; import { LoadingIndicator } from './LoadingIndicator'; import { CalendarCard } from './reports/CalendarCard'; import { CashFlowCard } from './reports/CashFlowCard'; +import { CrossoverCard } from './reports/CrossoverCard'; import { CustomReportListCards } from './reports/CustomReportListCards'; import { FormulaCard } from './reports/FormulaCard'; import { MarkdownCard } from './reports/MarkdownCard'; @@ -63,6 +64,7 @@ export function Overview() { const dispatch = useDispatch(); const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx'); const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; + const crossoverReportEnabled = useFeatureFlag('crossoverReport'); const formulaMode = useFeatureFlag('formulaMode'); @@ -420,6 +422,14 @@ export function Overview() { name: 'net-worth-card' as const, text: t('Net worth graph'), }, + ...(crossoverReportEnabled + ? [ + { + name: 'crossover-card' as const, + text: t('Crossover point'), + }, + ] + : []), { name: 'spending-card' as const, text: t('Spending analysis'), @@ -564,6 +574,16 @@ export function Overview() { onMetaChange={newMeta => onMetaChange(item, newMeta)} onRemove={() => onRemoveWidget(item.i)} /> + ) : item.type === 'crossover-card' && + crossoverReportEnabled ? ( + onMetaChange(item, newMeta)} + onRemove={() => onRemoveWidget(item.i)} + /> ) : item.type === 'cash-flow-card' ? ( } /> } /> } /> + {crossoverReportEnabled && ( + <> + } /> + } /> + + )} } /> } /> } /> diff --git a/packages/desktop-client/src/components/reports/graphs/CrossoverGraph.tsx b/packages/desktop-client/src/components/reports/graphs/CrossoverGraph.tsx new file mode 100644 index 00000000000..a6379b7cb9b --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/CrossoverGraph.tsx @@ -0,0 +1,195 @@ +import { Trans, useTranslation } from 'react-i18next'; + +import { type CSSProperties } from '@actual-app/components/styles'; +import { theme } from '@actual-app/components/theme'; +import { View } from '@actual-app/components/view'; +import { css } from '@emotion/css'; +import { + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + ReferenceLine, +} from 'recharts'; + +import { Container } from '@desktop-client/components/reports/Container'; +import { useFormat } from '@desktop-client/hooks/useFormat'; +import { usePrivacyMode } from '@desktop-client/hooks/usePrivacyMode'; + +type CrossoverGraphProps = { + style?: CSSProperties; + graphData: { + data: Array<{ + x: string; + investmentIncome: number; + expenses: number; + isProjection?: boolean; + }>; + start: string; + end: string; + crossoverXLabel?: string | null; + }; + compact?: boolean; + showTooltip?: boolean; +}; + +export function CrossoverGraph({ + style, + graphData, + compact = false, + showTooltip = true, +}: CrossoverGraphProps) { + const { t } = useTranslation(); + const privacyMode = usePrivacyMode(); + const format = useFormat(); + + const tickFormatter = (tick: number) => { + if (privacyMode) { + return '...'; + } + return `${format(Math.round(tick), 'financial-no-decimals')}`; + }; + + type PayloadItem = { + payload: { + x: string; + investmentIncome: number | string; + expenses: number | string; + isProjection?: boolean; + }; + }; + + type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + }; + + // eslint-disable-next-line react/no-unstable-nested-components + const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].payload.x} + {payload[0].payload.isProjection ? ( + + {t('(projected)')} + + ) : null} +
+
+ +
+ Monthly investment income: +
+
+ {format(payload[0].payload.investmentIncome, 'financial')} +
+
+ +
+ Monthly expenses: +
+
{format(payload[0].payload.expenses, 'financial')}
+
+
+
+
+ ); + } + }; + + return ( + + {(width, height) => ( + +
+ + {!compact && } + + + {showTooltip && ( + } + isAnimationActive={false} + /> + )} + {graphData.crossoverXLabel && ( + + )} + + + +
+
+ )} +
+ ); +} diff --git a/packages/desktop-client/src/components/reports/reports/Crossover.tsx b/packages/desktop-client/src/components/reports/reports/Crossover.tsx new file mode 100644 index 00000000000..b3a3e00ecd3 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/Crossover.tsx @@ -0,0 +1,848 @@ +import React, { useEffect, useMemo, useState, useCallback } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; + +import { Button } from '@actual-app/components/button'; +import { useResponsive } from '@actual-app/components/hooks/useResponsive'; +import { SvgQuestion } from '@actual-app/components/icons/v1'; +import { SvgViewHide, SvgViewShow } from '@actual-app/components/icons/v2'; +import { Input } from '@actual-app/components/input'; +import { Paragraph } from '@actual-app/components/paragraph'; +import { Select } from '@actual-app/components/select'; +import { styles } from '@actual-app/components/styles'; +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 CrossoverWidget, + type TimeFrame, + type CategoryEntity, +} from 'loot-core/types/models'; + +import { Link } from '@desktop-client/components/common/Link'; +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 { AccountSelector } from '@desktop-client/components/reports/AccountSelector'; +import { CategorySelector } from '@desktop-client/components/reports/CategorySelector'; +import { CrossoverGraph } from '@desktop-client/components/reports/graphs/CrossoverGraph'; +import { Header } from '@desktop-client/components/reports/Header'; +import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator'; +import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet'; +import { useReport } from '@desktop-client/components/reports/useReport'; +import { fromDateRepr } from '@desktop-client/components/reports/util'; +import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useCategories } from '@desktop-client/hooks/useCategories'; +import { useFormat } from '@desktop-client/hooks/useFormat'; +import { useNavigate } from '@desktop-client/hooks/useNavigate'; +import { type useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet'; +import { useWidget } from '@desktop-client/hooks/useWidget'; +import { addNotification } from '@desktop-client/notifications/notificationsSlice'; +import { useDispatch } from '@desktop-client/redux'; + +// Type for the return value of the recalculate function +type CrossoverData = { + graphData: { + data: Array<{ + x: string; + investmentIncome: number; + expenses: number; + isProjection?: boolean; + }>; + start: string; + end: string; + crossoverXLabel: string | null; + }; + lastKnownBalance: number; + lastKnownMonthlyIncome: number; + lastKnownMonthlyExpenses: number; + historicalReturn: number | null; + yearsToRetire: number | null; + targetMonthlyIncome: number | null; +}; + +export function Crossover() { + const params = useParams(); + const { data: widget, isLoading } = useWidget( + params.id ?? '', + 'crossover-card', + ); + + if (isLoading) { + return ; + } + + return ; +} + +type CrossoverInnerProps = { widget?: CrossoverWidget }; + +function CrossoverInner({ widget }: CrossoverInnerProps) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const accounts = useAccounts(); + const categories = useCategories(); + const format = useFormat(); + + const expenseCategoryGroups = categories.grouped.filter( + group => !group.is_income, + ); + const expenseCategories = categories.list.filter(c => !c.is_income); + + const [allMonths, setAllMonths] = useState | null>(null); + + // Date range state + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [mode, setMode] = useState('static'); + const [earliestTransactionDate, setEarliestTransactionDate] = + useState(''); + + const [selectedExpenseCategories, setSelectedExpenseCategories] = + useState>(expenseCategories); + const [selectedIncomeAccountIds, setSelectedIncomeAccountIds] = useState< + string[] + >(accounts.map(a => a.id)); + + const [swr, setSwr] = useState(0.04); + const [estimatedReturn, setEstimatedReturn] = useState(null); + const [projectionType, setProjectionType] = useState<'trend' | 'hampel'>( + 'trend', + ); + const [showHiddenCategories, setShowHiddenCategories] = useState(false); + const [selectionsInitialized, setSelectionsInitialized] = useState(false); + + // reset when widget changes + useEffect(() => { + setSelectionsInitialized(false); + }, [widget?.id]); + + // initialize once when data is available + useEffect(() => { + if ( + selectionsInitialized || + accounts.length === 0 || + categories.list.length === 0 + ) { + return; + } + + const initialExpenseCategories = widget?.meta?.expenseCategoryIds?.length + ? categories.list.filter(c => + widget.meta!.expenseCategoryIds!.includes(c.id), + ) + : categories.list.filter(c => !c.is_income); + + const initialIncomeAccountIds = widget?.meta?.incomeAccountIds?.length + ? widget.meta!.incomeAccountIds! + : accounts.map(a => a.id); + + setSelectedExpenseCategories(initialExpenseCategories); + setSelectedIncomeAccountIds(initialIncomeAccountIds); + setSwr(widget?.meta?.safeWithdrawalRate ?? 0.04); + setEstimatedReturn(widget?.meta?.estimatedReturn ?? null); + setProjectionType(widget?.meta?.projectionType ?? 'trend'); + setShowHiddenCategories(widget?.meta?.showHiddenCategories ?? false); + + setSelectionsInitialized(true); + }, [selectionsInitialized, accounts, categories.list, widget?.meta]); + + useEffect(() => { + async function run() { + const trans = await send('get-earliest-transaction'); + const currentMonth = monthUtils.currentMonth(); + const earliestMonth = trans + ? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date))) + : currentMonth; + const latestMonth = monthUtils.subMonths(currentMonth, 1); + + // Initialize date range from widget meta or use default range + let startMonth = earliestMonth; + let endMonth = monthUtils.isBefore(startMonth, latestMonth) + ? latestMonth + : startMonth; + let timeMode: TimeFrame['mode'] = 'static'; + + if (widget?.meta?.timeFrame?.start && widget?.meta?.timeFrame?.end) { + startMonth = widget.meta.timeFrame.start; + endMonth = widget.meta.timeFrame.end; + timeMode = widget.meta.timeFrame.mode || 'static'; + } + + setStart(startMonth); + setEnd(endMonth); + setMode(timeMode); + setEarliestTransactionDate(earliestMonth); + + const months = monthUtils + .rangeInclusive(earliestMonth, latestMonth) + .map(month => ({ + name: month, + pretty: monthUtils.format(month, 'MMMM, yyyy'), + })) + .reverse(); + + setAllMonths(months); + } + run(); + }, [widget?.meta?.timeFrame]); + + function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) { + if (mode === 'sliding-window') { + // This is because we don't include the current month in the sliding window + start = monthUtils.subMonths(start, 1); + end = monthUtils.subMonths(end, 1); + } + setStart(start); + setEnd(end); + setMode(mode); + } + + async function onSaveWidget() { + if (!widget) { + dispatch( + addNotification({ + notification: { + type: 'error', + message: t('Save failed: No widget found to save.'), + }, + }), + ); + return; + } + + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + expenseCategoryIds: selectedExpenseCategories.map(c => c.id), + incomeAccountIds: selectedIncomeAccountIds, + safeWithdrawalRate: swr, + estimatedReturn, + projectionType, + showHiddenCategories, + timeFrame: { start, end, mode }, + }, + }); + dispatch( + addNotification({ + notification: { + type: 'message', + message: t('Dashboard widget successfully saved.'), + }, + }), + ); + } + + // Memoize the derived values to avoid recreating them on every render + const expenseCategoryIds = useMemo( + () => + selectedExpenseCategories + .filter(c => showHiddenCategories || !c.hidden) + .map(c => c.id), + [selectedExpenseCategories, showHiddenCategories], + ); + + const params = useCallback( + async ( + spreadsheet: ReturnType, + setData: (data: CrossoverData) => void, + ) => { + // Don't run if dates are not yet initialized + if (!start || !end) { + return; + } + + const crossoverSpreadsheet = createCrossoverSpreadsheet({ + start, + end, + expenseCategoryIds, + incomeAccountIds: selectedIncomeAccountIds, + safeWithdrawalRate: swr, + estimatedReturn, + projectionType, + }); + await crossoverSpreadsheet(spreadsheet, setData); + }, + [ + start, + end, + swr, + estimatedReturn, + projectionType, + expenseCategoryIds, + selectedIncomeAccountIds, + ], + ); + + const data = useReport('crossover', params); + + // Get the default estimated return from the spreadsheet data + const historicalReturn = data?.historicalReturn ?? null; + + // Get years to retire from spreadsheet data + const yearsToRetire = data?.yearsToRetire ?? null; + + // Get target monthly income from spreadsheet data + const targetMonthlyIncome = data?.targetMonthlyIncome ?? null; + + const navigate = useNavigate(); + const { isNarrowWidth } = useResponsive(); + + const title = widget?.meta?.name || t('Crossover Point'); + const onSaveWidgetName = async (newName: string) => { + if (!widget) { + dispatch( + addNotification({ + notification: { + type: 'error', + message: t('Save failed: No widget found to save.'), + }, + }), + ); + return; + } + + const name = newName || t('Crossover Point'); + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + name, + }, + }); + }; + + if ( + !allMonths || + !data || + !start || + !end || + categories.list.length === 0 || + accounts.length === 0 + ) { + return ; + } + + return ( + navigate('/reports')} /> + } + /> + ) : ( + + ) : ( + title + ) + } + /> + ) + } + padding={0} + > +
{}} + onDeleteFilter={() => {}} + onConditionsOpChange={() => {}} + latestTransaction="" + > + {widget && ( + + )} +
+ + + {/* Left sidebar */} + {!isNarrowWidth && ( + + +
+ + + Expenses categories + + + + + Used to estimate your future expenses. +
+
+ Select the budget categories that reflect your + living expenses in retirement. +
+ Ex: Food, Utilities, Entertainment, Medical +
+
+ Exclude categories that will not continue in + retirement. +
+ Ex: Retirement Savings +
+
+
+ } + placement="right top" + style={{ + ...styles.tooltip, + }} + > + + + +
+ + + + +
+ +
+ +
+ + + Income accounts + + + + + Used to estimate your future income. +
+
+ Select the accounts that will be used to fund your + retirement. +
+ Ex: Retirement Accounts, Savings Accounts +
+
+ Exclude accounts that will not. +
+ Ex: Mortgage Accounts, Child Education Accounts +
+
+
+ } + placement="right top" + style={{ + ...styles.tooltip, + }} + > + + + +
+
+ +
+ + +
+ + {t('Safe withdrawal rate (%)')} + + + + The amount you plan to withdraw from your Income + Accounts each year to fund your living expenses. +
+ + More info. + +
+
+
+ } + placement="right top" + style={{ + ...styles.tooltip, + }} + > + + + +
+ + setSwr( + isNaN(e.target.valueAsNumber) + ? 0 + : e.target.valueAsNumber / 100, + ) + } + style={{ width: 120, marginBottom: 12 }} + /> +
+ + +
+ + {t('Expense Projection Type')} + + + + How past expenses are projected into the future. +
+
+ Linear Trend: Projects expenses using a linear + regression of historical data. +
+
+ Hampel Filtered Median: Filters out outliers + before calculating the median expense. +
+
+
+ } + placement="right top" + style={{ + ...styles.tooltip, + }} + > + + + +
+ + setEstimatedReturn( + isNaN(e.target.valueAsNumber) + ? null + : e.target.valueAsNumber / 100, + ) + } + style={{ width: 120 }} + /> + {estimatedReturn == null && historicalReturn != null && ( +
+ + Using historical return:{' '} + {(historicalReturn * 100).toFixed(1)}% + +
+ )} +
+
+
+ )} + + {/* Right content */} + + {/* Header stats */} + + + + + Years to Retire:{' '} + + {yearsToRetire != null + ? t('{{years}} years', { + years: format(yearsToRetire, 'number'), + }) + : t('N/A')} + + + + + + Target Monthly Income:{' '} + + {targetMonthlyIncome != null && !isNaN(targetMonthlyIncome) + ? format(targetMonthlyIncome, 'financial') + : t('N/A')} + + + + + + + {/* Graph area */} + + + + + + + {/* Description */} + + + + What is the Crossover Point? + + + + + The crossover point is when your monthly investment income (from + selected accounts using the safe withdrawal rate) meets or + exceeds your total monthly expenses (from selected categories). + The chart projects into the future using your estimated return + until the lines cross. + + + + +
+
+ ); +} diff --git a/packages/desktop-client/src/components/reports/reports/CrossoverCard.tsx b/packages/desktop-client/src/components/reports/reports/CrossoverCard.tsx new file mode 100644 index 00000000000..6c9a7267c42 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CrossoverCard.tsx @@ -0,0 +1,240 @@ +import React, { useMemo, useState, useCallback, useEffect } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; + +import { Block } from '@actual-app/components/block'; +import { useResponsive } from '@actual-app/components/hooks/useResponsive'; +import { styles } from '@actual-app/components/styles'; +import { theme } from '@actual-app/components/theme'; +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 AccountEntity, + type CrossoverWidget, +} from 'loot-core/types/models'; + +import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter'; +import { CrossoverGraph } from '@desktop-client/components/reports/graphs/CrossoverGraph'; +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 { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet'; +import { useReport } from '@desktop-client/components/reports/useReport'; +import { fromDateRepr } from '@desktop-client/components/reports/util'; +import { useFormat } from '@desktop-client/hooks/useFormat'; + +// Type for the return value of the recalculate function +type CrossoverData = { + graphData: { + data: Array<{ + x: string; + investmentIncome: number; + expenses: number; + isProjection?: boolean; + }>; + start: string; + end: string; + crossoverXLabel: string | null; + }; + lastKnownBalance: number; + lastKnownMonthlyIncome: number; + lastKnownMonthlyExpenses: number; + historicalReturn: number | null; + yearsToRetire: number | null; + targetMonthlyIncome: number | null; +}; + +type CrossoverCardProps = { + widgetId: string; + isEditing?: boolean; + accounts: AccountEntity[]; + meta?: CrossoverWidget['meta']; + onMetaChange: (newMeta: CrossoverWidget['meta']) => void; + onRemove: () => void; +}; + +export function CrossoverCard({ + widgetId, + isEditing, + accounts, + meta = {}, + onMetaChange, + onRemove, +}: CrossoverCardProps) { + const { t } = useTranslation(); + const { isNarrowWidth } = useResponsive(); + + const [nameMenuOpen, setNameMenuOpen] = useState(false); + + // Calculate date range from meta or use default range + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + + const format = useFormat(); + + useEffect(() => { + let isMounted = true; + async function calculateDateRange() { + if (meta?.timeFrame?.start && meta?.timeFrame?.end) { + setStart(meta.timeFrame.start); + setEnd(meta.timeFrame.end); + return; + } + + const trans = await send('get-earliest-transaction'); + if (!isMounted) return; + + const currentMonth = monthUtils.currentMonth(); + const startMonth = trans + ? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date))) + : currentMonth; + + const previousMonth = monthUtils.subMonths(currentMonth, 1); + const endMonth = monthUtils.isBefore(startMonth, previousMonth) + ? previousMonth + : startMonth; + + // Use saved timeFrame from meta or default range + setStart(startMonth); + setEnd(endMonth); + } + calculateDateRange(); + return () => { + isMounted = false; + }; + }, [meta?.timeFrame]); + + const [isCardHovered, setIsCardHovered] = useState(false); + const onCardHover = useCallback(() => setIsCardHovered(true), []); + const onCardHoverEnd = useCallback(() => setIsCardHovered(false), []); + + // Memoize these to prevent unnecessary re-renders + const expenseCategoryIds = useMemo( + () => meta?.expenseCategoryIds ?? [], + [meta?.expenseCategoryIds], + ); + + const incomeAccountIds = useMemo( + () => meta?.incomeAccountIds ?? accounts.map(a => a.id), + [meta?.incomeAccountIds, accounts], + ); + + const swr = meta?.safeWithdrawalRate ?? 0.04; + const estimatedReturn = meta?.estimatedReturn ?? null; + const projectionType = meta?.projectionType ?? 'trend'; + + const params = useMemo( + () => + createCrossoverSpreadsheet({ + start, + end, + expenseCategoryIds, + incomeAccountIds, + safeWithdrawalRate: swr, + estimatedReturn: estimatedReturn == null ? null : estimatedReturn, + projectionType, + }), + [ + start, + end, + expenseCategoryIds, + incomeAccountIds, + swr, + estimatedReturn, + projectionType, + ], + ); + + const data = useReport('crossover', params); + + // Get years to retire from spreadsheet data + const yearsToRetire = data?.yearsToRetire ?? null; + + return ( + { + switch (item) { + case 'rename': + setNameMenuOpen(true); + break; + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > + + + + { + onMetaChange({ + ...meta, + name: newName, + }); + setNameMenuOpen(false); + }} + onClose={() => setNameMenuOpen(false)} + /> + {/* Date range is now fixed and not configurable */} + + {data && ( + + + + {yearsToRetire != null + ? t('{{years}} years', { + years: format(yearsToRetire, 'number'), + }) + : t('N/A')} + + + + Years to Retire + + + )} + + + {data ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/desktop-client/src/components/reports/spreadsheets/crossover-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/crossover-spreadsheet.ts new file mode 100644 index 00000000000..b722ee0cc19 --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/crossover-spreadsheet.ts @@ -0,0 +1,410 @@ +import * as d from 'date-fns'; + +import * as monthUtils from 'loot-core/shared/months'; +import { q } from 'loot-core/shared/query'; +import { type AccountEntity } from 'loot-core/types/models'; + +import { type useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet'; +import { aqlQuery } from '@desktop-client/queries/aqlQuery'; + +type MonthlyAgg = { date: string; amount: number }; + +// Utility functions for Hampel identifier +function calculateMedian(values: number[]): number { + if (values.length === 0) return 0; + if (values.length === 1) return values[0]; + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +function calculateMAD(values: number[], median: number): number { + const deviations = values.map(v => Math.abs(v - median)); + return calculateMedian(deviations); +} + +function calculateHampelFilteredMedian(expenses: number[]): number { + if (expenses.length === 0) return 0; + if (expenses.length === 1) return expenses[0]; + + const median = calculateMedian(expenses); + const mad = calculateMAD(expenses, median); + const threshold = 3; // Standard threshold for outlier detection + + const filteredExpenses = expenses.filter(expense => { + const lowerBound = median - 1.4826 * mad * threshold; + const upperBound = median + 1.4826 * mad * threshold; + return expense >= lowerBound && expense <= upperBound; + }); + + return calculateMedian(filteredExpenses); +} + +export type CrossoverParams = { + start: string; + end: string; + expenseCategoryIds: string[]; // which categories count as expenses + incomeAccountIds: AccountEntity['id'][]; // selected accounts for both historical returns and projections + safeWithdrawalRate: number; // annual percent, e.g. 0.04 for 4% + estimatedReturn?: number | null; // optional annual return to project future balances + projectionType: 'trend' | 'hampel'; // expense projection method +}; + +export function createCrossoverSpreadsheet({ + start, + end, + expenseCategoryIds, + incomeAccountIds, + safeWithdrawalRate, + estimatedReturn, + projectionType, +}: CrossoverParams) { + return async ( + _spreadsheet: ReturnType, + setData: (data: ReturnType) => void, + ) => { + if (!start || !end || incomeAccountIds.length === 0) { + setData({ + graphData: { + data: [], + start: start || '', + end: end || '', + crossoverXLabel: null, + }, + lastKnownBalance: 0, + lastKnownMonthlyIncome: 0, + lastKnownMonthlyExpenses: 0, + historicalReturn: null, + yearsToRetire: null, + targetMonthlyIncome: null, + }); + return; + } + + // Aggregate monthly expenses for selected categories (expenses are negative amounts) + const expensesPromise = (async () => { + if (!expenseCategoryIds.length) { + return monthUtils + .rangeInclusive(start, end) + .map(date => ({ date, amount: 0 })); + } + + const query = q('transactions') + .filter({ + $and: [ + { $or: expenseCategoryIds.map(id => ({ category: id })) }, + { date: { $gte: monthUtils.firstDayOfMonth(start) } }, + { date: { $lte: monthUtils.lastDayOfMonth(end) } }, + ], + }) + .groupBy({ $month: '$date' }) + .select([ + { date: { $month: '$date' } }, + { amount: { $sum: '$amount' } }, + ]); + + const { data } = await aqlQuery(query); + return data as MonthlyAgg[]; + })(); + + // Compute monthly balances for selected accounts (historical returns) + const historicalBalancesPromise = Promise.all( + incomeAccountIds.map(async accountId => { + // Get the account balance at the end of the first month (start month) + const startingBalance = await aqlQuery( + q('transactions') + .filter({ account: accountId }) + .filter({ + date: { $lte: monthUtils.lastDayOfMonth(start) }, + }) + .calculate({ $sum: '$amount' }), + ).then(({ data }) => (typeof data === 'number' ? data : 0)); + // Get all transactions from the start month onwards for balance calculations + // We need to exclude the first month since we already have its ending balance as starting + // Instead of adding months (which can cause invalid month strings), we'll filter out the first month later + const balances = await aqlQuery( + q('transactions') + .filter({ + account: accountId, + date: { $gte: monthUtils.firstDayOfMonth(start) }, + }) + .filter({ + $and: [{ date: { $lte: monthUtils.lastDayOfMonth(end) } }], + }) + .groupBy({ $month: '$date' }) + .select([ + { date: { $month: '$date' } }, + { amount: { $sum: '$amount' } }, + ]), + ).then(({ data }) => data as MonthlyAgg[]); + + // Filter out the first month since we already have its ending balance as starting + const filteredBalances = balances.filter(b => b.date !== start); + + return { + accountId, + starting: startingBalance, + balances: filteredBalances, + }; + }), + ); + + const [expenses, historicalBalances] = await Promise.all([ + expensesPromise, + historicalBalancesPromise, + ]); + + setData( + recalculate( + { + start, + end, + expenseCategoryIds, + incomeAccountIds, + safeWithdrawalRate, + estimatedReturn, + projectionType, + }, + expenses, + historicalBalances, + ), + ); + }; +} + +function recalculate( + params: Pick< + CrossoverParams, + | 'start' + | 'end' + | 'expenseCategoryIds' + | 'incomeAccountIds' + | 'safeWithdrawalRate' + | 'estimatedReturn' + | 'projectionType' + >, + expenses: MonthlyAgg[], + historicalAccounts: Array<{ + accountId: string; + starting: number; + balances: MonthlyAgg[]; + }>, +) { + const months = monthUtils.rangeInclusive(params.start, params.end); + + // Build total expenses per month (positive number for visualization) + const expenseMap = new Map(); + for (const e of expenses) { + // amounts for expenses are negative; flip sign to positive monthly spend + expenseMap.set(e.date, (expenseMap.get(e.date) || 0) + -e.amount); + } + + // Build total balances across selected accounts per month for CAGR calculation (historical returns) + const historicalBalances: number[] = months.map(() => 0); + + for (const acct of historicalAccounts) { + // Calculate running balance for each month + // Start with the account's starting balance (balance at the end of the first month) + let runningBalance = acct.starting; + + // Process each month in order + const byMonth = new Map(acct.balances.map(b => [b.date, b.amount])); + for (let i = 0; i < months.length; i++) { + const month = months[i]; + const delta = byMonth.get(month) ?? 0; + + runningBalance += delta; + + // Add this account's balance to the total for this month + historicalBalances[i] += runningBalance; + } + } + + // Determine historical monthly investment income using safe withdrawal rate: annual rate -> monthly + const monthlySWR = params.safeWithdrawalRate / 12; // e.g. 0.04 / 12 + + // Prepare historical data points and compute last known month data for projection seeds + const data: Array<{ + x: string; + investmentIncome: number; + expenses: number; + isProjection?: boolean; + }> = []; + + let lastBalance = 0; + let lastExpense = 0; + let crossoverIndex: number | null = null; + months.forEach((month, idx) => { + const balance = historicalBalances[idx]; // Use historical balances for data generation + const monthlyIncome = balance * monthlySWR; + const spend = expenseMap.get(month) || 0; + data.push({ + x: d.format(d.parseISO(month + '-01'), 'MMM yyyy'), + investmentIncome: Math.round(monthlyIncome), + expenses: spend, + }); + lastBalance = balance; + lastExpense = spend; + + if (crossoverIndex == null && Math.round(monthlyIncome) >= spend) { + crossoverIndex = idx; + } else if (crossoverIndex != null && Math.round(monthlyIncome) < spend) { + crossoverIndex = null; + } + }); + + // If estimatedReturn provided, project future months until investment income exceeds expenses + // Use either provided estimatedReturn or simple trailing growth from balances + // Determine default return from historical balances if not provided + const annualReturn = params.estimatedReturn ?? null; // e.g. 0.05 + let monthlyReturn = + annualReturn != null ? Math.pow(1 + annualReturn, 1 / 12) - 1 : null; + + // Always calculate the default return for display purposes + let defaultMonthlyReturn: number | null = null; + if (historicalBalances.length >= 2) { + // Use the starting balance (end of first month) and final balance (end of last month) for CAGR calculation + // The starting balance represents the account balance at the end of the first month + let startingBalance = historicalBalances[0]; + const finalBalance = historicalBalances[historicalBalances.length - 1]; + const n = historicalBalances.length - 1; // Number of months between start and end + + if (startingBalance === 0) { + for (let i = 1; i < historicalBalances.length; i++) { + if (historicalBalances[i] !== 0) { + startingBalance = historicalBalances[i]; + break; + } + } + } + + if (startingBalance > 0 && finalBalance > 0 && n > 0) { + // Calculate monthly CAGR: (final/starting)^(1/n) - 1 + const cagrMonthly = Math.pow(finalBalance / startingBalance, 1 / n) - 1; + + if (isFinite(cagrMonthly) && !isNaN(cagrMonthly)) { + defaultMonthlyReturn = cagrMonthly; + } else { + defaultMonthlyReturn = 0; + } + } else { + defaultMonthlyReturn = 0; + } + } + + if (months.length > 0 && crossoverIndex == null) { + // If no explicit return provided, use the calculated default + if (monthlyReturn == null) { + // not quite right. Need a better approximation + monthlyReturn = defaultMonthlyReturn; + } + // Project up to 600 months max to avoid infinite loops (50 years) + const maxProjectionMonths = 600; + let projectedBalance = lastBalance; + let monthCursor = d.parseISO(months[months.length - 1] + '-01'); + // Calculate expense projection parameters based on projection type + let expenseSlope = 0; + let expenseIntercept = lastExpense; + let hampelFilteredExpense = 0; + + if (expenseMap.size >= 2) { + const y: number[] = months.map(m => expenseMap.get(m) || 0); + + if (params.projectionType === 'trend') { + // Linear trend calculation: y = a + b * t + const x: number[] = months.map((_m, i) => i); + const n = x.length; + const sumX = x.reduce((a, b) => a + b, 0); + const sumY = y.reduce((a, b) => a + b, 0); + const sumXY = x.reduce((a, xi, idx) => a + xi * y[idx], 0); + const sumX2 = x.reduce((a, xi) => a + xi * xi, 0); + const denom = n * sumX2 - sumX * sumX; + if (denom !== 0) { + expenseSlope = (n * sumXY - sumX * sumY) / denom; + expenseIntercept = (sumY - expenseSlope * sumX) / n; + } + } else if (params.projectionType === 'hampel') { + // Hampel filtered median calculation + hampelFilteredExpense = calculateHampelFilteredMedian(y); + } + } + + for (let i = 1; i <= maxProjectionMonths; i++) { + monthCursor = d.addMonths(monthCursor, 1); + // grow balance + if (monthlyReturn != null) { + projectedBalance = projectedBalance * (1 + monthlyReturn); + } + const projectedIncome = projectedBalance * monthlySWR; + + // Project expenses based on projection type + let projectedExpenses: number; + if (params.projectionType === 'trend') { + projectedExpenses = Math.max( + 0, + expenseIntercept + expenseSlope * (months.length - 1 + i), + ); + } else { + // Hampel filtered median - flat projection + projectedExpenses = Math.max(0, hampelFilteredExpense); + } + + data.push({ + x: d.format(monthCursor, 'MMM yyyy'), + investmentIncome: Math.round(projectedIncome), + expenses: Math.round(projectedExpenses), + isProjection: true, + }); + + if ( + crossoverIndex == null && + Math.round(projectedIncome) >= Math.round(projectedExpenses) + ) { + crossoverIndex = months.length + (i - 1); + break; + } + } + } + // Calculate years to retire based on crossover point + let yearsToRetire: number | null = null; + let targetMonthlyIncome: number | null = null; + + if (crossoverIndex != null && crossoverIndex < data.length) { + const crossoverData = data[crossoverIndex]; + if (crossoverData) { + const currentDate = new Date(); + const crossoverDate = d.parse(crossoverData.x, 'MMM yyyy', currentDate); + const monthsDiff = d.differenceInMonths(crossoverDate, currentDate); + yearsToRetire = monthsDiff > 0 ? monthsDiff / 12 : 0; + targetMonthlyIncome = crossoverData.expenses; + } + } + + return { + graphData: { + data, + start: params.start, + end: params.end, + crossoverXLabel: + crossoverIndex != null ? (data[crossoverIndex]?.x ?? null) : null, + }, + // Provide some summary numbers + lastKnownBalance: historicalBalances[historicalBalances.length - 1] || 0, + lastKnownMonthlyIncome: Math.round( + (historicalBalances[historicalBalances.length - 1] || 0) * monthlySWR, + ), + lastKnownMonthlyExpenses: lastExpense, + // Return the calculated default return for display purposes + historicalReturn: + defaultMonthlyReturn != null + ? Math.pow(1 + defaultMonthlyReturn, 12) - 1 + : null, + // Years to retire calculation + yearsToRetire, + // Target monthly income at crossover point + targetMonthlyIncome, + }; +} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index c18ddbc1c1f..01e32f9b143 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -164,6 +164,13 @@ export function ExperimentalFeatures() { > Currency support + + + Crossover Report + = { actionTemplating: false, formulaMode: false, currency: false, + crossoverReport: false, plugins: false, }; diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts index 8fc72582488..e821239bd8e 100644 --- a/packages/loot-core/src/server/dashboard/app.ts +++ b/packages/loot-core/src/server/dashboard/app.ts @@ -76,6 +76,7 @@ const exportModel = { 'net-worth-card', 'cash-flow-card', 'spending-card', + 'crossover-card', 'custom-report', 'markdown-card', 'summary-card', diff --git a/packages/loot-core/src/types/models/dashboard.ts b/packages/loot-core/src/types/models/dashboard.ts index d8b0b98414e..26f85d792bb 100644 --- a/packages/loot-core/src/types/models/dashboard.ts +++ b/packages/loot-core/src/types/models/dashboard.ts @@ -63,6 +63,19 @@ export type CustomReportWidget = AbstractWidget< 'custom-report', { id: string } >; +export type CrossoverWidget = AbstractWidget< + 'crossover-card', + { + name?: string; + expenseCategoryIds?: string[]; + incomeAccountIds?: string[]; + timeFrame?: TimeFrame; + safeWithdrawalRate?: number; // 0.04 default + estimatedReturn?: number | null; // annual + projectionType?: 'trend' | 'hampel'; // expense projection method + showHiddenCategories?: boolean; // show hidden categories in selector + } | null +>; export type MarkdownWidget = AbstractWidget< 'markdown-card', { content: string; text_align?: 'left' | 'right' | 'center' } @@ -72,6 +85,7 @@ type SpecializedWidget = | NetWorthWidget | CashFlowWidget | SpendingWidget + | CrossoverWidget | MarkdownWidget | SummaryWidget | CalendarWidget diff --git a/packages/loot-core/src/types/prefs.ts b/packages/loot-core/src/types/prefs.ts index e90da97ea59..7541d5f0658 100644 --- a/packages/loot-core/src/types/prefs.ts +++ b/packages/loot-core/src/types/prefs.ts @@ -4,6 +4,7 @@ export type FeatureFlag = | 'actionTemplating' | 'formulaMode' | 'currency' + | 'crossoverReport' | 'plugins'; /** diff --git a/upcoming-release-notes/5554.md b/upcoming-release-notes/5554.md new file mode 100644 index 00000000000..e390e0da278 --- /dev/null +++ b/upcoming-release-notes/5554.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [sjones512] +--- + +Add a Crossover Report for tracking progress towards financial independence.