Added future projections, and more insights as well as responsiveness

This commit is contained in:
tomato6966 2024-12-22 15:38:24 +01:00
parent 50a9cdb073
commit ad6f5ddf83
12 changed files with 1018 additions and 62 deletions

View file

@ -14,6 +14,7 @@ Why this Project?
![Dark Mode Preview](./docs/dark-mode.png)
![Light Mode Preview](./docs/light-mode.png)
![Future Projection Modal](./docs/future-projection.png)
## Features
@ -26,6 +27,7 @@ Why this Project?
- 💹 TTWOR (Time Travel Without Risk) calculations
- 🔄 Support for one-time and periodic investments
- 📊 Detailed performance metrics
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
## Tech Stack
@ -47,3 +49,11 @@ Why this Project?
### Local Development
1. Clone the repository
2. Run `npm install`
3. Run `npm run dev` -> developer preview
- Run `npm run build` -> build for production (dist folder) (you can then launch it with dockerfile or with a static file server like nginx)
- Run `npm run preview` -> preview the production build (dist folder)
### Credits:
> Thanks to [yahoofinance](https://finance.yahoo.com/) for the stock data.

BIN
docs/future-projection.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

68
package-lock.json generated
View file

@ -13,6 +13,7 @@
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"recharts": "^2.12.1",
"use-debounce": "^10.0.4",
"zustand": "^4.5.1"
@ -1285,6 +1286,12 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -1968,6 +1975,15 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -3579,6 +3595,46 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.0.tgz",
"integrity": "sha512-VcFhWqkNIcojDRYaUO8qV0Jib52s9ULpCp3nkBbmrvtoCVFRp6tmk3tJ2w9BZauVctA1YRnJlFYDn9iJRuCpGA==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.0.tgz",
"integrity": "sha512-F4/nYBC9e4s0/ZjxM8GkZ9a68DpX76LN1a9W9mfPl2GfbDJ9/vzJro6MThNR5qGBH6KkgcK1BziyEzXhHV46Xw==",
"license": "MIT",
"dependencies": {
"react-router": "7.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -3775,6 +3831,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4079,6 +4141,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -15,6 +15,7 @@
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"recharts": "^2.12.1",
"use-debounce": "^10.0.4",
"zustand": "^4.5.1"

View file

@ -0,0 +1,555 @@
import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react";
import { useCallback, useState } from "react";
import {
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
} from "recharts";
import { usePortfolioStore } from "../store/portfolioStore";
import { calculateFutureProjection } from "../utils/calculations/futureProjection";
import { formatCurrency } from "../utils/formatters";
interface FutureProjectionModalProps {
onClose: () => void;
performancePerAnno: number;
}
type ChartType = 'line' | 'bar';
export interface WithdrawalPlan {
amount: number;
interval: 'monthly' | 'yearly';
startTrigger: 'date' | 'portfolioValue' | 'auto';
startDate?: string;
startPortfolioValue?: number;
enabled: boolean;
autoStrategy?: {
type: 'maintain' | 'deplete' | 'grow';
targetYears?: number;
targetGrowth?: number;
};
}
export interface ProjectionData {
date: string;
value: number;
invested: number;
withdrawals: number;
totalWithdrawn: number;
}
export interface SustainabilityAnalysis {
yearsToReachTarget: number;
targetValue: number;
sustainableYears: number | 'infinite';
}
export const FutureProjectionModal = ({ onClose, performancePerAnno }: FutureProjectionModalProps) => {
const [years, setYears] = useState('10');
const [isCalculating, setIsCalculating] = useState(false);
const [chartType, setChartType] = useState<ChartType>('line');
const [projectionData, setProjectionData] = useState<ProjectionData[]>([]);
const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({
amount: 0,
interval: 'monthly',
startTrigger: 'date',
startDate: new Date().toISOString().split('T')[0],
startPortfolioValue: 0,
enabled: false,
autoStrategy: {
type: 'maintain',
targetYears: 30,
targetGrowth: 2,
},
});
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
const { assets } = usePortfolioStore();
const calculateProjection = useCallback(async () => {
setIsCalculating(true);
try {
const { projection, sustainability } = await calculateFutureProjection(
assets,
parseInt(years),
performancePerAnno,
withdrawalPlan.enabled ? withdrawalPlan : undefined,
);
setProjectionData(projection);
setSustainabilityAnalysis(sustainability);
} catch (error) {
console.error('Error calculating projection:', error);
} finally {
setIsCalculating(false);
}
}, [assets, years, withdrawalPlan, performancePerAnno]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const value = payload[0].value;
const invested = payload[1].value;
const withdrawn = payload[2]?.value || 0;
const totalWithdrawn = payload[3]?.value || 0;
const percentageGain = ((value - invested) / invested) * 100;
return (
<div className="bg-white dark:bg-slate-800 p-4 border rounded shadow-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
{new Date(label).toLocaleDateString('de-DE')}
</p>
<p className="font-bold text-indigo-600 dark:text-indigo-400">
Value: {formatCurrency(value)}
</p>
<p className="text-purple-600 dark:text-purple-400">
Invested: {formatCurrency(invested)}
</p>
{withdrawn > 0 && (
<>
<p className="text-orange-500">
Monthly Withdrawal: {formatCurrency(withdrawn)}
</p>
<p className="text-orange-600 font-bold">
Total Withdrawn: {formatCurrency(totalWithdrawn)}
</p>
</>
)}
<p className={`font-bold ${percentageGain >= 0 ? 'text-green-500' : 'text-red-500'}`}>
Return: {percentageGain.toFixed(2)}%
</p>
</div>
);
}
return null;
};
const renderChart = () => {
if (isCalculating) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="animate-spin" size={48} />
</div>
);
}
if (!projectionData.length) {
return (
<div className="flex items-center justify-center text-red-500 dark:text-red-400">
Click calculate to see the projection
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
{chartType === 'line' ? (
<LineChart data={projectionData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'numeric'
})}
/>
<YAxis />
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="value"
stroke="#4f46e5"
name="Portfolio Value"
/>
<Line
type="monotone"
dataKey="invested"
stroke="#9333ea"
name="Invested Amount"
/>
{withdrawalPlan.enabled && (
<>
<Line
type="step"
dataKey="withdrawals"
stroke="#f97316"
strokeDasharray="5 5"
name="Monthly Withdrawal"
/>
<Line
type="monotone"
dataKey="totalWithdrawn"
stroke="#ea580c"
name="Total Withdrawn"
/>
</>
)}
</LineChart>
) : (
<BarChart data={projectionData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'numeric'
})}
/>
<YAxis />
<Tooltip content={<CustomTooltip />} />
<Bar
type="monotone"
dataKey="value"
stroke="#4f46e5"
name="Portfolio Value"
/>
<Bar
type="monotone"
dataKey="invested"
stroke="#9333ea"
name="Invested Amount"
/>
{withdrawalPlan.enabled && (
<>
<Bar
type="step"
dataKey="withdrawals"
stroke="#f97316"
strokeDasharray="5 5"
name="Monthly Withdrawal"
/>
<Bar
type="monotone"
dataKey="totalWithdrawn"
stroke="#ea580c"
name="Total Withdrawn"
/>
</>
)}
</BarChart>
)}
</ResponsiveContainer>
);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-0 lg:p-4">
<div className="bg-white dark:bg-slate-800 rounded-none lg:rounded-lg w-full lg:w-[80vw] max-w-4xl h-screen lg:h-[75dvh] flex flex-col">
<div className="p-4 lg:p-6 border-b dark:border-slate-700 flex-shrink-0">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold dark:text-gray-200">Future Portfolio Projection</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<X size={24} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold mb-3 dark:text-gray-200">Projection Settings</h3>
<i className="block text-sm font-medium mb-1 dark:text-gray-300">
Project for next {years} years
</i>
<div className="flex gap-4">
<div>
<input
type="number"
value={years}
onChange={(e) => setYears(e.target.value)}
min="1"
max="50"
className="w-24 p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
<button
onClick={calculateProjection}
disabled={isCalculating}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isCalculating ? (
<Loader2 className="animate-spin" size={16} />
) : (
'Calculate'
)}
</button>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setChartType('line')}
className={`p-2 rounded ${chartType === 'line' ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-slate-700'}`}
title="Line Chart"
>
<LineChartIcon size={20} />
</button>
<button
onClick={() => setChartType('bar')}
className={`p-2 rounded ${chartType === 'bar' ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-slate-700'}`}
title="Bar Chart"
>
<BarChartIcon size={20} />
</button>
</div>
</div>
<div className="mt-10 text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-slate-700/50 p-3 rounded">
<p>
Future projections are calculated with your portfolio's average annual return rate of{' '}
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
</p>
<p className="mt-1">
Strategy explanations:
<ul className="list-disc ml-5 mt-1">
<li><span className="font-semibold">Maintain:</span> Portfolio value stays constant, withdrawing only the returns</li>
<li><span className="font-semibold">Deplete:</span> Portfolio depletes to zero over specified years</li>
<li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li>
</ul>
</p>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold dark:text-gray-200">Withdrawal Plan</h3>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={withdrawalPlan.enabled}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
enabled: e.target.checked
}))}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div className={`space-y-4 ${!withdrawalPlan.enabled && 'opacity-50 pointer-events-none'}`}>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Withdrawal Amount ()
</label>
<input
type="number"
value={withdrawalPlan.amount}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
amount: parseFloat(e.target.value)
}))}
min="0"
step="100"
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Withdrawal Interval
</label>
<select
value={withdrawalPlan.interval}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
interval: e.target.value as 'monthly' | 'yearly'
}))}
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Start Trigger
</label>
<select
value={withdrawalPlan.startTrigger}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
startTrigger: e.target.value as 'date' | 'portfolioValue' | 'auto'
}))}
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
>
<option value="date">Specific Date</option>
<option value="portfolioValue">Portfolio Value Threshold</option>
<option value="auto">Auto</option>
</select>
</div>
{withdrawalPlan.startTrigger === 'date' ? (
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Start Date
</label>
<input
type="date"
value={withdrawalPlan.startDate}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
startDate: e.target.value
}))}
min={new Date().toISOString().split('T')[0]}
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
) : withdrawalPlan.startTrigger === 'portfolioValue' ? (
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Start at Portfolio Value ()
</label>
<input
type="number"
value={withdrawalPlan.startPortfolioValue}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
startPortfolioValue: parseFloat(e.target.value)
}))}
min="0"
step="1000"
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
) : null}
{withdrawalPlan.startTrigger === 'auto' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Desired {withdrawalPlan.interval} Withdrawal ()
</label>
<input
type="number"
value={withdrawalPlan.amount}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
amount: parseFloat(e.target.value)
}))}
min="0"
step="100"
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Strategy
</label>
<select
value={withdrawalPlan.autoStrategy?.type}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
autoStrategy: {
...prev.autoStrategy!,
type: e.target.value as 'maintain' | 'deplete' | 'grow'
}
}))}
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
>
<option value="maintain">Maintain Portfolio Value</option>
<option value="deplete">Planned Depletion</option>
<option value="grow">Sustainable Growth</option>
</select>
</div>
{withdrawalPlan.autoStrategy?.type === 'deplete' && (
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Years to Deplete After Starting
</label>
<input
type="number"
value={withdrawalPlan.autoStrategy.targetYears}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
autoStrategy: {
...prev.autoStrategy!,
targetYears: parseInt(e.target.value)
}
}))}
min="1"
max="100"
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
)}
{withdrawalPlan.autoStrategy?.type === 'grow' && (
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Annual Growth After Starting (%)
</label>
<input
type="number"
value={withdrawalPlan.autoStrategy.targetGrowth}
onChange={(e) => setWithdrawalPlan(prev => ({
...prev,
autoStrategy: {
...prev.autoStrategy!,
targetGrowth: parseFloat(e.target.value)
}
}))}
min="0.1"
max="10"
step="0.1"
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
/>
</div>
)}
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded text-sm">
<p className="text-blue-800 dark:text-blue-200">
{withdrawalPlan.autoStrategy?.type === 'maintain' && (
"The calculator will determine when your portfolio can sustain this withdrawal amount while maintaining its value."
)}
{withdrawalPlan.autoStrategy?.type === 'deplete' && (
"The calculator will determine when you can start withdrawing this amount to deplete the portfolio over your specified timeframe."
)}
{withdrawalPlan.autoStrategy?.type === 'grow' && (
"The calculator will determine when you can start withdrawing this amount while maintaining the target growth rate."
)}
</p>
</div>
</div>
)}
</div>
</div>
</div>
{sustainabilityAnalysis && withdrawalPlan.enabled && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Withdrawal Analysis</h4>
<p className="text-blue-800 dark:text-blue-200">
To withdraw {formatCurrency(withdrawalPlan.amount)} {withdrawalPlan.interval}, you need to invest for{' '}
<span className="font-bold">{sustainabilityAnalysis.yearsToReachTarget} years</span> until your portfolio reaches{' '}
<span className="font-bold">{formatCurrency(sustainabilityAnalysis.targetValue)}</span>.
</p>
<p className="text-blue-800 dark:text-blue-200 mt-2">
With this withdrawal plan, your portfolio will{' '}
{sustainabilityAnalysis.sustainableYears === 'infinite' ? (
<span className="font-bold">remain sustainable indefinitely</span>
) : (
<>
last for{' '}
<span className="font-bold">
{sustainabilityAnalysis.sustainableYears} years
</span>{' '}
{sustainabilityAnalysis.sustainableYears > parseInt(years) && (
<span className="text-sm">
(extends beyond the current chart view of {years} years)
</span>
)}
</>
)}
.
</p>
</div>
)}
<div className="space-y-6">
<div className="h-[500px]">
{renderChart()}
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,3 +1,4 @@
import { Loader2 } from "lucide-react";
import React, { useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore";
@ -71,52 +72,57 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
const [dynamicValue, setDynamicValue] = useState('');
const [yearInterval, setYearInterval] = useState('1');
const [isSubmitting, setIsSubmitting] = useState(false);
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
dateRange: state.dateRange,
addInvestment: state.addInvestment,
}));
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
if (type === "single") {
const investment = {
id: crypto.randomUUID(),
assetId,
type,
amount: parseFloat(amount),
date
};
addInvestment(assetId, investment);
} else {
const periodicSettings = {
startDate: date,
dayOfMonth: parseInt(dayOfMonth),
interval: parseInt(interval),
amount: parseFloat(amount),
...(isDynamic ? {
dynamic: {
type: dynamicType,
value: parseFloat(dynamicValue),
yearInterval: parseInt(yearInterval),
},
} : undefined),
};
const investments = generatePeriodicInvestments(
periodicSettings,
new Date(dateRange.endDate),
assetId,
);
for(const investment of investments) {
try {
if (type === "single") {
const investment = {
id: crypto.randomUUID(),
assetId,
type,
amount: parseFloat(amount),
date
};
addInvestment(assetId, investment);
} else {
const periodicSettings = {
startDate: date,
dayOfMonth: parseInt(dayOfMonth),
interval: parseInt(interval),
amount: parseFloat(amount),
...(isDynamic ? {
dynamic: {
type: dynamicType,
value: parseFloat(dynamicValue),
yearInterval: parseInt(yearInterval),
},
} : undefined),
};
const investments = generatePeriodicInvestments(
periodicSettings,
dateRange.endDate,
assetId
);
for (const investment of investments) {
addInvestment(assetId, investment);
}
}
} finally {
setIsSubmitting(false);
setAmount('');
clearSelectedAsset();
}
// Reset form
setAmount('');
clearSelectedAsset();
};
return (
@ -257,9 +263,14 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
Add Investment
{isSubmitting ? (
<Loader2 className="animate-spin mx-auto" size={16} />
) : (
'Add Investment'
)}
</button>
</form>
);

View file

@ -1,11 +1,12 @@
import { format } from "date-fns";
import { HelpCircle, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
import { HelpCircle, LineChart, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore";
import { Investment } from "../types";
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
import { EditInvestmentModal } from "./EditInvestmentModal";
import { FutureProjectionModal } from "./FutureProjectionModal";
interface TooltipProps {
content: string | JSX.Element;
@ -49,7 +50,7 @@ export const PortfolioTable = () => {
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
const averagePerformance = useMemo(() => {
return (performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length).toFixed(2);
return ((performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length) || 0).toFixed(2);
}, [performance.investments]);
const handleDelete = useCallback((investmentId: string, assetId: string) => {
@ -67,13 +68,14 @@ export const PortfolioTable = () => {
const performanceTooltip = useMemo(() => (
<div className="space-y-2">
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p>
<p>The average performance of all positions is {averagePerformance}%</p>
<p>The average (acc.) performance of all positions is {averagePerformance}%</p>
<p>The average (p.a.) performance of every year is {performance.summary.performancePerAnnoPerformance.toFixed(2)}%</p>
<p className="text-xs mt-2">
Note: An average performance of positions doesn't always match your entire portfolio's average,
especially with single investments or investments on different time ranges.
</p>
</div>
), [performance.summary.performancePercentage, averagePerformance]);
), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance]);
const buyInTooltip = useMemo(() => (
<div className="space-y-2">
@ -95,16 +97,29 @@ export const PortfolioTable = () => {
</div>
), []);
const [showProjection, setShowProjection] = useState(false);
return (
<div className="overflow-x-auto min-h-[500px] dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
<button
onClick={handleClearAll}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Clear All Investments
</button>
<div className="flex gap-2">
<button
onClick={handleClearAll}
disabled={performance.investments.length === 0}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Clear All Investments
</button>
<button
onClick={() => setShowProjection(true)}
disabled={performance.investments.length === 0}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
>
<LineChart size={16} />
Future Projection
</button>
</div>
</div>
<div className="relative rounded-lg overflow-hidden">
<table className="min-w-full bg-white dark:bg-slate-800">
@ -145,7 +160,10 @@ export const PortfolioTable = () => {
<td className="px-4 py-2"></td>
<td className="px-4 py-2">
{performance.summary.performancePercentage.toFixed(2)}%
<i className="text-xs text-gray-500 dark:text-gray-400">(avg. {averagePerformance}%)</i>
<ul>
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. acc. {averagePerformance}%)</li>
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)</li>
</ul>
</td>
<td className="px-4 py-2"></td>
</tr>
@ -154,7 +172,7 @@ export const PortfolioTable = () => {
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 ">
<td className="px-4 py-2">TTWOR</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{performance.investments[0]?.date}</td>
<td className="px-4 py-2">{new Date(performance.investments[0]?.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</td>
<td className="px-4 py-2">{performance.summary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.ttworValue.toFixed(2)}</td>
<td className="px-4 py-2"></td>
@ -220,6 +238,9 @@ export const PortfolioTable = () => {
onClose={() => setEditingInvestment(null)}
/>
)}
{showProjection && (
<FutureProjectionModal performancePerAnno={performance.summary.performancePerAnnoPerformance} onClose={() => setShowProjection(false)} />
)}
</div>
);
};

View file

@ -15,6 +15,7 @@ interface PortfolioState {
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void;
updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void;
clearInvestments: () => void;
setAssets: (assets: Asset[]) => void;
}
export const usePortfolioStore = create<PortfolioState>((set) => ({
@ -77,4 +78,5 @@ export const usePortfolioStore = create<PortfolioState>((set) => ({
set((state) => ({
assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
})),
setAssets: (assets) => set({ assets }),
}));

View file

@ -1,4 +1,4 @@
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
import { isAfter, isBefore, isSameDay } from "date-fns";
import { Asset, Investment } from "../../types";
@ -51,13 +51,15 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
}
};
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: string, assetId: string): Investment[] => {
const investments: Investment[] = [];
const periodicGroupId = crypto.randomUUID();
let currentDate = new Date(settings.startDate);
let currentAmount = settings.amount;
const periodicGroupId = crypto.randomUUID();
const end = new Date(endDate);
while (isBefore(currentDate, endDate)) {
while (currentDate <= end) {
// Only create investment if it's on the specified day of month
if (currentDate.getDate() === settings.dayOfMonth) {
// Handle dynamic increases if configured
if (settings.dynamic) {
@ -65,7 +67,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
(currentDate.getTime() - new Date(settings.startDate).getTime()) /
(1000 * 60 * 60 * 24 * 365);
if (yearsSinceStart >= settings.dynamic.yearInterval) {
// Check if we've reached a year interval for increase
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
if (settings.dynamic.type === 'percentage') {
currentAmount *= (1 + settings.dynamic.value / 100);
} else {
@ -73,7 +76,7 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
}
}
}
// Create investment for this date
investments.push({
id: crypto.randomUUID(),
type: 'periodic',
@ -82,13 +85,20 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
periodicGroupId,
assetId
});
// Move to next interval
currentDate = addDays(currentDate, settings.interval);
} else {
// Move to next day if not the investment day
currentDate = addDays(currentDate, 1);
}
// Move to next interval day
const nextDate = new Date(currentDate);
nextDate.setDate(nextDate.getDate() + settings.interval);
// Ensure we maintain the correct day of month
if (nextDate.getDate() !== settings.dayOfMonth) {
nextDate.setDate(1);
nextDate.setMonth(nextDate.getMonth() + 1);
nextDate.setDate(settings.dayOfMonth);
}
currentDate = nextDate;
}
return investments;

View file

@ -0,0 +1,244 @@
import { addMonths, differenceInYears, format } from "date-fns";
import { Asset, Investment } from "../../types";
import type {
ProjectionData, SustainabilityAnalysis, WithdrawalPlan
} from "../../components/FutureProjectionModal";
const findOptimalStartingPoint = (
currentPortfolioValue: number,
monthlyGrowth: number,
desiredWithdrawal: number,
strategy: WithdrawalPlan['autoStrategy'],
interval: 'monthly' | 'yearly'
): { startDate: string; requiredPortfolioValue: number } => {
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
let requiredPortfolioValue = 0;
// Declare variables outside switch
const months = (strategy?.targetYears || 30) * 12;
const r = monthlyGrowth;
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1/12) - 1;
switch (strategy?.type) {
case 'maintain':
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
break;
case 'deplete':
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
break;
case 'grow':
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
break;
}
// Calculate when we'll reach the required value
const monthsToReach = Math.ceil(
Math.log(requiredPortfolioValue / currentPortfolioValue) /
Math.log(1 + monthlyGrowth)
);
const startDate = new Date();
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
return {
startDate: startDate.toISOString().split('T')[0],
requiredPortfolioValue,
};
};
export const calculateFutureProjection = async (
currentAssets: Asset[],
yearsToProject: number,
annualReturnRate: number,
withdrawalPlan?: WithdrawalPlan,
): Promise<{
projection: ProjectionData[];
sustainability: SustainabilityAnalysis;
}> => {
await new Promise(resolve => setTimeout(resolve, 1000));
const projectionData: ProjectionData[] = [];
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
// Get all periodic investment patterns
const periodicInvestments = currentAssets.flatMap(asset => {
const patterns = new Map<string, Investment[]>();
asset.investments.forEach(inv => {
if (inv.type === 'periodic' && inv.periodicGroupId) {
if (!patterns.has(inv.periodicGroupId)) {
patterns.set(inv.periodicGroupId, []);
}
patterns.get(inv.periodicGroupId)!.push(inv);
}
});
return Array.from(patterns.values())
.map(group => ({
pattern: group.sort((a, b) =>
new Date(a.date!).getTime() - new Date(b.date!).getTime()
)
}));
});
// Project future investments
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
if (pattern.length < 2) return [];
const lastInvestment = pattern[pattern.length - 1];
const secondLastInvestment = pattern[pattern.length - 2];
const interval = new Date(lastInvestment.date!).getTime() -
new Date(secondLastInvestment.date!).getTime();
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
const future: Investment[] = [];
let currentDate = new Date(lastInvestment.date!);
let currentAmount = lastInvestment.amount;
while (currentDate <= endDateForCalculation) {
currentDate = new Date(currentDate.getTime() + interval);
currentAmount += amountDiff;
future.push({
...lastInvestment,
date: format(currentDate, 'yyyy-MM-dd'),
amount: currentAmount,
});
}
return future;
});
// Calculate monthly values
let currentDate = new Date();
let totalInvested = currentAssets.reduce(
(sum, asset) => sum + asset.investments.reduce(
(assetSum, inv) => assetSum + inv.amount, 0
), 0
);
let totalWithdrawn = 0;
let yearsToReachTarget = 0;
let targetValue = 0;
let sustainableYears: number | 'infinite' = 'infinite';
let portfolioValue = totalInvested; // Initialize portfolio value with current investments
let withdrawalsStarted = false;
let withdrawalStartDate: Date | null = null;
let portfolioDepletionDate: Date | null = null;
// Calculate optimal withdrawal plan if auto strategy is selected
if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') {
const { startDate, requiredPortfolioValue } = findOptimalStartingPoint(
portfolioValue,
Math.pow(1 + annualReturnRate/100, 1/12) - 1,
withdrawalPlan.amount,
withdrawalPlan.autoStrategy,
withdrawalPlan.interval
);
withdrawalPlan.startDate = startDate;
withdrawalPlan.startPortfolioValue = requiredPortfolioValue;
}
while (currentDate <= endDateForCalculation) {
// Check if withdrawals should start
if (!withdrawalsStarted && withdrawalPlan?.enabled) {
withdrawalsStarted = withdrawalPlan.startTrigger === 'date'
? new Date(currentDate) >= new Date(withdrawalPlan.startDate!)
: portfolioValue >= (withdrawalPlan.startPortfolioValue || 0);
if (withdrawalsStarted) {
withdrawalStartDate = new Date(currentDate);
}
}
// Handle monthly growth if portfolio isn't depleted
if (portfolioValue > 0) {
const monthlyReturn = Math.pow(1 + annualReturnRate/100, 1/12) - 1;
portfolioValue *= (1 + monthlyReturn);
}
// Add new investments only if withdrawals haven't started
if (!withdrawalsStarted) {
const monthInvestments = futureInvestments.filter(
inv => new Date(inv.date!).getMonth() === currentDate.getMonth() &&
new Date(inv.date!).getFullYear() === currentDate.getFullYear()
);
const monthlyInvestment = monthInvestments.reduce(
(sum, inv) => sum + inv.amount, 0
);
totalInvested += monthlyInvestment;
portfolioValue += monthlyInvestment;
}
// Handle withdrawals
let monthlyWithdrawal = 0;
if (withdrawalsStarted && portfolioValue > 0) {
monthlyWithdrawal = withdrawalPlan!.interval === 'monthly'
? withdrawalPlan!.amount
: (currentDate.getMonth() === 0 ? withdrawalPlan!.amount : 0);
portfolioValue -= monthlyWithdrawal;
if (portfolioValue < 0) {
monthlyWithdrawal += portfolioValue; // Adjust final withdrawal
portfolioValue = 0;
if (sustainableYears === 'infinite') {
sustainableYears = differenceInYears(currentDate, withdrawalStartDate!);
}
}
totalWithdrawn += monthlyWithdrawal;
}
// Update target metrics
if (withdrawalsStarted && !targetValue) {
targetValue = portfolioValue;
yearsToReachTarget = differenceInYears(currentDate, new Date());
}
if (portfolioValue <= 0 && !portfolioDepletionDate) {
portfolioDepletionDate = new Date(currentDate);
}
// Only add to projection data if within display timeframe
if (currentDate <= endDateForDisplay) {
projectionData.push({
date: format(currentDate, 'yyyy-MM-dd'),
value: Math.max(0, portfolioValue),
invested: totalInvested,
withdrawals: monthlyWithdrawal,
totalWithdrawn,
});
}
currentDate = addMonths(currentDate, 1);
}
// Calculate actual sustainability duration
let actualSustainableYears: number | 'infinite' = 'infinite';
if (portfolioDepletionDate) {
actualSustainableYears = differenceInYears(
portfolioDepletionDate,
withdrawalStartDate || new Date()
);
} else if (portfolioValue > 0) {
// If portfolio is still growing after maxProjectionYears, it's truly sustainable
actualSustainableYears = 'infinite';
}
return {
projection: projectionData,
sustainability: {
yearsToReachTarget,
targetValue,
sustainableYears: actualSustainableYears,
},
};
};

View file

@ -1,4 +1,4 @@
import { isAfter, isBefore } from "date-fns";
import { differenceInDays, isAfter, isBefore } from "date-fns";
import { Asset } from "../../types";
@ -18,6 +18,7 @@ export interface PortfolioPerformance {
totalInvested: number;
currentValue: number;
performancePercentage: number;
performancePerAnnoPerformance: number;
ttworValue: number;
ttworPercentage: number;
};
@ -27,6 +28,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
const investments: InvestmentPerformance[] = [];
let totalInvested = 0;
let totalCurrentValue = 0;
let earliestDate: Date | null = null;
// TTWOR Berechnung
const firstDayPrices: Record<string, number> = {};
@ -49,6 +51,16 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
return acc;
}, 0);
// Finde das früheste Investmentdatum
for(const asset of assets) {
for(const investment of asset.investments) {
const investmentDate = new Date(investment.date!);
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
earliestDate = investmentDate;
}
}
}
// Normale Performance-Berechnungen...
for(const asset of assets) {
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
@ -90,6 +102,21 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
? ((ttworValue - totalInvested) / totalInvested) * 100
: 0;
// Berechne die jährliche Performance
const performancePerAnnoPerformance = (() => {
if (!earliestDate || totalInvested === 0) return 0;
const years = differenceInDays(new Date(), earliestDate) / 365;
if (years < 0.01) return 0; // Verhindere Division durch sehr kleine Zahlen
// Formel: (1 + r)^n = FV/PV
// r = (FV/PV)^(1/n) - 1
const totalReturn = totalCurrentValue / totalInvested;
const annualizedReturn = Math.pow(totalReturn, 1 / years) - 1;
return annualizedReturn * 100;
})();
return {
investments,
summary: {
@ -98,6 +125,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
performancePercentage: totalInvested > 0
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
: 0,
performancePerAnnoPerformance,
ttworValue,
ttworPercentage,
},

6
src/utils/formatters.ts Normal file
View file

@ -0,0 +1,6 @@
export const formatCurrency = (value: number): string => {
return `${value.toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`;
};