+
+ )}
+
+ );
+}
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,
+ }}
+ >
+
+
+
+
+
+ {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,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {t('Estimated return (annual %, optional)')}
+
+
+
+ The expected annual return rate for your
+ investments, used to project growth of Income
+ Accounts. If not specified, the historical return
+ from your Income Accounts will be used instead.
+
+
+ Note: Historical return calculation includes
+ contributions and may not reflect actual
+ investment performance.
+
+
+
+ }
+ placement="right top"
+ style={{
+ ...styles.tooltip,
+ }}
+ >
+
+
+
+