Add contributions to crossover report (#6639)

Add the ability to specify expected monthly contributions to the
crossover report. This will allow users to estimate the growth of their
investments more accurately instead of treating all account growth as
exponential investment returns. When the checkbox is unclicked, the
automatically calculated historical return value will be used. When the
checkbox is clicked, it will display two input fields. One for expected
investment growth, and one for expected monthly contributions to your
retirement accounts.

Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org>
This commit is contained in:
Jonathon Jongsma
2026-01-22 10:52:34 -06:00
committed by GitHub
parent c4514b1fe6
commit bcfefde4ad
5 changed files with 240 additions and 58 deletions

View File

@@ -26,6 +26,7 @@ import {
import { Link } from '@desktop-client/components/common/Link';
import { EditablePageHeaderTitle } from '@desktop-client/components/EditablePageHeaderTitle';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { Checkbox } from '@desktop-client/components/forms';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import {
MobilePageHeader,
@@ -127,7 +128,11 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
>(accounts.map(a => a.id));
const [swr, setSwr] = useState(0.04);
const [useCustomGrowth, setUseCustomGrowth] = useState(false);
const [estimatedReturn, setEstimatedReturn] = useState<number | null>(null);
const [expectedContribution, setExpectedContribution] = useState<
number | null
>(null);
const [projectionType, setProjectionType] = useState<
'hampel' | 'median' | 'mean'
>('hampel');
@@ -163,7 +168,16 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
setSelectedExpenseCategories(initialExpenseCategories);
setSelectedIncomeAccountIds(initialIncomeAccountIds);
setSwr(widget?.meta?.safeWithdrawalRate ?? 0.04);
setEstimatedReturn(widget?.meta?.estimatedReturn ?? null);
const initialEstimatedReturn = widget?.meta?.estimatedReturn ?? null;
const initialExpectedContribution =
widget?.meta?.expectedContribution ?? null;
const hasCustomGrowth =
initialEstimatedReturn != null || initialExpectedContribution != null;
setUseCustomGrowth(hasCustomGrowth);
setEstimatedReturn(initialEstimatedReturn);
setExpectedContribution(initialExpectedContribution);
setProjectionType(widget?.meta?.projectionType ?? 'hampel');
setExpenseAdjustmentFactor(widget?.meta?.expenseAdjustmentFactor ?? 1.0);
setShowHiddenCategories(widget?.meta?.showHiddenCategories ?? false);
@@ -293,7 +307,10 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
expenseCategoryIds: selectedExpenseCategories.map(c => c.id),
incomeAccountIds: selectedIncomeAccountIds,
safeWithdrawalRate: swr,
estimatedReturn,
estimatedReturn: useCustomGrowth ? (estimatedReturn ?? 0) : null,
expectedContribution: useCustomGrowth
? (expectedContribution ?? 0)
: null,
projectionType,
expenseAdjustmentFactor,
showHiddenCategories,
@@ -335,7 +352,10 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
expenseCategoryIds,
incomeAccountIds: selectedIncomeAccountIds,
safeWithdrawalRate: swr,
estimatedReturn,
estimatedReturn: useCustomGrowth ? (estimatedReturn ?? 0) : null,
expectedContribution: useCustomGrowth
? (expectedContribution ?? 0)
: null,
projectionType,
expenseAdjustmentFactor,
});
@@ -345,7 +365,9 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
start,
end,
swr,
useCustomGrowth,
estimatedReturn,
expectedContribution,
projectionType,
expenseAdjustmentFactor,
expenseCategoryIds,
@@ -802,71 +824,207 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
</View>
<View style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
<View
<label
htmlFor="custom-growth"
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
userSelect: 'none',
marginBottom: 8,
}}
>
<Checkbox
id="custom-growth"
checked={useCustomGrowth}
onChange={e => {
const checked = e.target.checked;
setUseCustomGrowth(checked);
// On first enable (when estimatedReturn is null), default to 6%
if (checked && estimatedReturn === null) {
setEstimatedReturn(0.06);
}
}}
style={{ marginRight: 8 }}
/>
<Text style={{ fontWeight: 600 }}>
<Trans>Use custom growth projections</Trans>
</Text>
<Tooltip
content={
<View style={{ maxWidth: 300 }}>
<Text>
<Trans>
Enable this to specify custom monthly contribution
amounts and investment returns that will be used to
project your investments into the future.
<br />
<br />
When disabled, uses historical performance from your
Income Accounts (which includes both past
contributions and investment growth).
</Trans>
</Text>
</View>
}
placement="right top"
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...styles.tooltip,
}}
>
<Text>{t('Estimated return (annual %, optional)')}</Text>
<Tooltip
content={
<View style={{ maxWidth: 300 }}>
<Text>
<Trans>
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.
<br />
<br />
Note: Historical return calculation includes
contributions and may not reflect actual
investment performance.
</Trans>
</Text>
<SvgQuestion
height={12}
width={12}
cursor="pointer"
style={{ marginLeft: 4 }}
/>
</Tooltip>
</label>
{useCustomGrowth && (
<View
style={{
marginLeft: 24,
paddingLeft: 12,
borderLeft: `3px solid ${theme.tableBorder}`,
}}
>
<View style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text>{t('Expected return (annual %)')}</Text>
<Tooltip
content={
<View style={{ maxWidth: 300 }}>
<Text>
<Trans>
The expected annual return rate for your
investments, used to project growth of
Income Accounts.
</Trans>
</Text>
</View>
}
placement="right top"
style={{
...styles.tooltip,
}}
>
<SvgQuestion
height={12}
width={12}
cursor="pointer"
/>
</Tooltip>
</View>
}
placement="right top"
style={{
...styles.tooltip,
}}
>
<SvgQuestion height={12} width={12} cursor="pointer" />
</Tooltip>
</div>
<Input
type="number"
min={0}
max={100}
step={0.1}
value={
estimatedReturn === null
? ''
: Number((estimatedReturn * 100).toFixed(2))
}
onChange={e =>
setEstimatedReturn(
isNaN(e.target.valueAsNumber)
? null
: e.target.valueAsNumber / 100,
)
}
onBlur={() => {
if (estimatedReturn === null) {
setEstimatedReturn(0);
}
}}
style={{ width: 120 }}
/>
</View>
<View style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text>
<Trans>Expected monthly contribution</Trans>
</Text>
<Tooltip
content={
<View style={{ maxWidth: 300 }}>
<Text>
<Trans>
The amount you plan to contribute to your
Income Accounts each month. This amount is
added to your balance each month before
applying the investment return.
</Trans>
</Text>
</View>
}
placement="right top"
style={{
...styles.tooltip,
}}
>
<SvgQuestion
height={12}
width={12}
cursor="pointer"
/>
</Tooltip>
</View>
</div>
<Input
type="number"
min={0}
step={100}
value={
expectedContribution === null
? ''
: expectedContribution / 100
}
onChange={e =>
setExpectedContribution(
isNaN(e.target.valueAsNumber)
? null
: e.target.valueAsNumber * 100,
)
}
onBlur={() => {
if (expectedContribution === null) {
setExpectedContribution(0);
}
}}
style={{ width: 120 }}
/>
</View>
</View>
</div>
<Input
type="number"
min={0}
max={100}
step={0.1}
value={
estimatedReturn == null
? ''
: Number((estimatedReturn * 100).toFixed(2))
}
onChange={e =>
setEstimatedReturn(
isNaN(e.target.valueAsNumber)
? null
: e.target.valueAsNumber / 100,
)
}
style={{ width: 120 }}
/>
{estimatedReturn == null && historicalReturn != null && (
)}
{!useCustomGrowth && historicalReturn != null && (
<div
style={{
fontSize: 12,
color: theme.pageTextSubdued,
marginTop: 4,
marginLeft: 24,
}}
>
<Trans>
Using historical return:{' '}
Using calculated historical return of{' '}
{(historicalReturn * 100).toFixed(1)}%
</Trans>
</div>

View File

@@ -171,6 +171,7 @@ export function CrossoverCard({
const swr = meta?.safeWithdrawalRate ?? 0.04;
const estimatedReturn = meta?.estimatedReturn ?? null;
const expectedContribution = meta?.expectedContribution ?? null;
const projectionType: 'hampel' | 'median' | 'mean' =
meta?.projectionType ?? 'hampel';
const expenseAdjustmentFactor = meta?.expenseAdjustmentFactor ?? 1.0;
@@ -183,7 +184,8 @@ export function CrossoverCard({
expenseCategoryIds,
incomeAccountIds,
safeWithdrawalRate: swr,
estimatedReturn: estimatedReturn == null ? null : estimatedReturn,
estimatedReturn,
expectedContribution,
projectionType,
expenseAdjustmentFactor,
}),
@@ -194,6 +196,7 @@ export function CrossoverCard({
incomeAccountIds,
swr,
estimatedReturn,
expectedContribution,
projectionType,
expenseAdjustmentFactor,
],

View File

@@ -56,6 +56,7 @@ export type CrossoverParams = {
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
expectedContribution?: number | null; // optional monthly contribution to project future balances
projectionType: 'hampel' | 'median' | 'mean'; // expense projection method
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)
};
@@ -67,6 +68,7 @@ export function createCrossoverSpreadsheet({
incomeAccountIds,
safeWithdrawalRate,
estimatedReturn,
expectedContribution,
projectionType,
expenseAdjustmentFactor,
}: CrossoverParams) {
@@ -175,6 +177,7 @@ export function createCrossoverSpreadsheet({
incomeAccountIds,
safeWithdrawalRate,
estimatedReturn,
expectedContribution,
projectionType,
expenseAdjustmentFactor,
},
@@ -194,6 +197,7 @@ function recalculate(
| 'incomeAccountIds'
| 'safeWithdrawalRate'
| 'estimatedReturn'
| 'expectedContribution'
| 'projectionType'
| 'expenseAdjustmentFactor'
>,
@@ -310,6 +314,11 @@ function recalculate(
}
}
// Use user-provided contribution, or default to 0
// (We don't use historical contribution as default because the historical
// return calculation already includes contributions)
const monthlyContribution = params.expectedContribution ?? 0;
if (months.length > 0) {
// If no explicit return provided, use the calculated default
if (monthlyReturn == null) {
@@ -337,10 +346,15 @@ function recalculate(
for (let i = 1; i <= maxProjectionMonths; i++) {
monthCursor = d.addMonths(monthCursor, 1);
// grow balance
// Add contribution BEFORE applying growth
projectedBalance = projectedBalance + monthlyContribution;
// Then grow balance
if (monthlyReturn != null) {
projectedBalance = projectedBalance * (1 + monthlyReturn);
}
const projectedIncome = projectedBalance * monthlySWR;
const projectedExpenses = Math.max(0, flatExpense);

View File

@@ -80,6 +80,7 @@ export type CrossoverWidget = AbstractWidget<
timeFrame?: TimeFrame;
safeWithdrawalRate?: number; // 0.04 default
estimatedReturn?: number | null; // annual
expectedContribution?: number | null; // monthly dollar amount
projectionType?: 'hampel' | 'median' | 'mean'; // expense projection method
showHiddenCategories?: boolean; // show hidden categories in selector
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [jonner]
---
Add the ability to specify expected contributions to the crossover point report.