diff --git a/README.md b/README.md index 57026b5..fa7a952 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Why this Project?   + ## 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. diff --git a/docs/future-projection.png b/docs/future-projection.png new file mode 100644 index 0000000..5ad0484 Binary files /dev/null and b/docs/future-projection.png differ diff --git a/package-lock.json b/package-lock.json index a249d84..b4ea7c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 27e6453..7114d0e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/FutureProjectionModal.tsx b/src/components/FutureProjectionModal.tsx new file mode 100644 index 0000000..a682973 --- /dev/null +++ b/src/components/FutureProjectionModal.tsx @@ -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> + ); +}; diff --git a/src/components/InvestmentForm.tsx b/src/components/InvestmentForm.tsx index 1dc4aed..2ed0aad 100644 --- a/src/components/InvestmentForm.tsx +++ b/src/components/InvestmentForm.tsx @@ -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> ); diff --git a/src/components/PortfolioTable.tsx b/src/components/PortfolioTable.tsx index 5f7cfff..87bb8d8 100644 --- a/src/components/PortfolioTable.tsx +++ b/src/components/PortfolioTable.tsx @@ -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> ); }; diff --git a/src/store/portfolioStore.ts b/src/store/portfolioStore.ts index f85901f..ba08554 100644 --- a/src/store/portfolioStore.ts +++ b/src/store/portfolioStore.ts @@ -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 }), })); diff --git a/src/utils/calculations/assetValue.ts b/src/utils/calculations/assetValue.ts index 3b28a73..463080f 100644 --- a/src/utils/calculations/assetValue.ts +++ b/src/utils/calculations/assetValue.ts @@ -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; diff --git a/src/utils/calculations/futureProjection.ts b/src/utils/calculations/futureProjection.ts new file mode 100644 index 0000000..d872f6e --- /dev/null +++ b/src/utils/calculations/futureProjection.ts @@ -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, + }, + }; +}; diff --git a/src/utils/calculations/performance.ts b/src/utils/calculations/performance.ts index 5b5883c..ccf3d56 100644 --- a/src/utils/calculations/performance.ts +++ b/src/utils/calculations/performance.ts @@ -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, }, diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..002ce46 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,6 @@ +export const formatCurrency = (value: number): string => { + return `€${value.toLocaleString('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`; +};