Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
5d850c2
Add Crossover Report
sjones512 Aug 14, 2025
14a79fb
Fix lint and typecheck
sjones512 Aug 14, 2025
6dc62ad
Use useFormat hook for formatting, make spreadsheet always return cents
sjones512 Aug 16, 2025
d3cd1f1
Change defaults to all categories and accounts. Return 0 when no cate…
sjones512 Aug 16, 2025
bb2652c
Change error handling to use addNotification
sjones512 Aug 16, 2025
de9c712
Detect if crossover has already happened before projecting
sjones512 Aug 16, 2025
5e5b66e
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 16, 2025
d8131c4
lint:fix. Only get first crossoverIdx
sjones512 Aug 16, 2025
444ccd2
Annualize historical return via compounding, not multiplication
sjones512 Aug 16, 2025
53b120a
Optional guard in median helper for future-proofing
sjones512 Aug 16, 2025
c49ef7f
Merge branch 'master' into feat/add-crossover-report
sjones512 Aug 21, 2025
8c0774e
Merge branch 'master' into feat/add-crossover-report
sjones512 Aug 23, 2025
5bc606a
Merge branch 'master' into feat/add-crossover-report
sjones512 Aug 30, 2025
2662561
Merge branch 'master' into feat/add-crossover-report
youngcw Sep 5, 2025
25e4ec9
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 3, 2025
d3b37dc
Add Crossover Report Feature Flag
sjones512 Oct 3, 2025
223bcaf
Add option to show hidden categories
sjones512 Oct 3, 2025
22b7efe
Remove estimated return placeholder text since the formatting was unc…
sjones512 Oct 3, 2025
4e80941
Initialize defaults when widget meta is absent
sjones512 Oct 3, 2025
6b69ed1
Hidden categories remain in expense totals
sjones512 Oct 3, 2025
0de12d6
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 3, 2025
9d4fc52
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 10, 2025
e944432
Update packages/loot-core/src/types/prefs.ts
sjones512 Oct 10, 2025
07b78cd
Update prefs.ts
sjones512 Oct 10, 2025
d3f6621
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 11, 2025
4e0e0cf
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 15, 2025
acb2e23
Fix percentage inputs showing trailing zeros for some numbers
sjones512 Oct 16, 2025
5dfe838
Fix projection not happening if crossover happens historically but th…
sjones512 Oct 16, 2025
86e5e2d
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 17, 2025
a51c36a
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 25, 2025
7316072
some tweaks for consideration. Remove if undesired
youngcw Oct 27, 2025
ff3124c
go back to original math
youngcw Oct 27, 2025
940d8a9
Fix change in percentage formatting for CrossoverCard
sjones512 Oct 27, 2025
f1eb465
Merge branch 'master' into feat/add-crossover-report
sjones512 Oct 27, 2025
3562ae2
Optimize date range calculation and add cleanup to CrossoverCard
sjones512 Oct 28, 2025
d000078
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 28, 2025
e9f56d9
Merge branch 'master' into feat/add-crossover-report
sjones512 Nov 4, 2025
0bd7320
Try again to standardize display and storage of percentages without r…
sjones512 Nov 4, 2025
65e7db0
Add tooltips with descriptions to crossover chart inputs
sjones512 Nov 4, 2025
7c4c1c3
Refactor Crossover component to use translation for 'N/A' strings and…
sjones512 Nov 4, 2025
027291b
Refactor Crossover components to improve date range calculations and …
sjones512 Nov 5, 2025
7593023
Fix typo in Crossover component: corrected "retirment" to "retirement…
sjones512 Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
421 changes: 421 additions & 0 deletions packages/desktop-client/src/components/reports/AccountSelector.tsx

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions packages/desktop-client/src/components/reports/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -564,6 +574,16 @@ export function Overview() {
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
/>
) : item.type === 'crossover-card' &&
crossoverReportEnabled ? (
<CrossoverCard
widgetId={item.i}
isEditing={isEditing}
accounts={accounts}
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
/>
) : item.type === 'cash-flow-card' ? (
<CashFlowCard
widgetId={item.i}
Expand Down
11 changes: 11 additions & 0 deletions packages/desktop-client/src/components/reports/ReportRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,29 @@ import { Route, Routes } from 'react-router';
import { Overview } from './Overview';
import { Calendar } from './reports/Calendar';
import { CashFlow } from './reports/CashFlow';
import { Crossover } from './reports/Crossover';
import { CustomReport } from './reports/CustomReport';
import { Formula } from './reports/Formula';
import { NetWorth } from './reports/NetWorth';
import { Spending } from './reports/Spending';
import { Summary } from './reports/Summary';

import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';

export function ReportRouter() {
const crossoverReportEnabled = useFeatureFlag('crossoverReport');

return (
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/net-worth" element={<NetWorth />} />
<Route path="/net-worth/:id" element={<NetWorth />} />
{crossoverReportEnabled && (
<>
<Route path="/crossover" element={<Crossover />} />
<Route path="/crossover/:id" element={<Crossover />} />
</>
)}
<Route path="/cash-flow" element={<CashFlow />} />
<Route path="/cash-flow/:id" element={<CashFlow />} />
<Route path="/custom" element={<CustomReport />} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={css({
zIndex: 1000,
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>{payload[0].payload.x}</strong>
{payload[0].payload.isProjection ? (
<span style={{ marginLeft: 8, opacity: 0.7 }}>
{t('(projected)')}
</span>
) : null}
</div>
<div style={{ lineHeight: 1.5 }}>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly investment income:</Trans>
</div>
<div>
{format(payload[0].payload.investmentIncome, 'financial')}
</div>
</View>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly expenses:</Trans>
</div>
<div>{format(payload[0].payload.expenses, 'financial')}</div>
</View>
</div>
</div>
</div>
);
}
};

return (
<Container
style={{
...style,
...(compact && { height: 'auto' }),
}}
>
{(width, height) => (
<ResponsiveContainer>
<div style={{ ...(!compact && { marginTop: '15px' }) }}>
<LineChart
width={width}
height={height}
data={graphData.data}
margin={{
top: 0,
right: 0,
left: compact ? 0 : 20,
bottom: compact ? 0 : 10,
}}
>
{!compact && <CartesianGrid strokeDasharray="3 3" />}
<XAxis
dataKey="x"
hide={compact}
tick={{ fill: theme.pageText }}
tickLine={{ stroke: theme.pageText }}
/>
<YAxis
hide={compact}
tickFormatter={tickFormatter}
tick={{ fill: theme.pageText }}
tickLine={{ stroke: theme.pageText }}
/>
{showTooltip && (
<Tooltip
content={<CustomTooltip />}
isAnimationActive={false}
/>
)}
{graphData.crossoverXLabel && (
<ReferenceLine
x={graphData.crossoverXLabel}
stroke={theme.noticeText}
strokeDasharray="4 4"
/>
)}
<Line
type="monotone"
dataKey="investmentIncome"
dot={false}
stroke={theme.reportsBlue}
strokeWidth={2}
animationDuration={0}
/>
<Line
type="monotone"
dataKey="expenses"
dot={false}
stroke={theme.reportsRed}
strokeWidth={2}
animationDuration={0}
/>
</LineChart>
</div>
</ResponsiveContainer>
)}
</Container>
);
}
Loading