mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-12 09:38:43 +02:00
v1.1.1 finalize design and feature, allow savingsplans to edit and deleted with allocation overview
This commit is contained in:
parent
a5d014ec4d
commit
5f9b4f0797
4 changed files with 93 additions and 13 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "investment-portfolio-tracker",
|
"name": "investment-portfolio-tracker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
@ -54,7 +54,7 @@ export default function InvestmentFormWrapper() {
|
||||||
selectedAsset && (
|
selectedAsset && (
|
||||||
<div className="flex-1 h-[calc(100%-120px)] overflow-hidden">
|
<div className="flex-1 h-[calc(100%-120px)] overflow-hidden">
|
||||||
<div className="p-6 pr-3 pt-0">
|
<div className="p-6 pr-3 pt-0">
|
||||||
<InvestmentForm assetId={selectedAsset} clearSelectedAsset={() => setSelectedAsset(null)} />
|
<InvestmentForm assetId={selectedAsset} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -63,7 +63,7 @@ export default function InvestmentFormWrapper() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clearSelectedAsset: () => void }) => {
|
const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
||||||
const [type, setType] = useState<'single' | 'periodic'>('single');
|
const [type, setType] = useState<'single' | 'periodic'>('single');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [date, setDate] = useState('');
|
const [date, setDate] = useState('');
|
||||||
|
@ -80,7 +80,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
addInvestment: state.addInvestment,
|
addInvestment: state.addInvestment,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(() => {
|
||||||
console.log("timeout")
|
console.log("timeout")
|
||||||
try {
|
try {
|
||||||
if (type === "single") {
|
if (type === "single") {
|
||||||
|
@ -136,7 +136,6 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
console.timeEnd('generatePeriodicInvestments');
|
console.timeEnd('generatePeriodicInvestments');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setAmount('');
|
setAmount('');
|
||||||
clearSelectedAsset();
|
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
|
@ -282,20 +282,47 @@ export const FutureProjectionModal = ({
|
||||||
const renderScenarioDescription = () => {
|
const renderScenarioDescription = () => {
|
||||||
if (!scenarios.best.projection.length) return null;
|
if (!scenarios.best.projection.length) return null;
|
||||||
|
|
||||||
|
const getLastValue = (projection: ProjectionData[]) => {
|
||||||
|
const lastPoint = projection[projection.length - 1];
|
||||||
|
return {
|
||||||
|
value: lastPoint.value,
|
||||||
|
invested: lastPoint.invested,
|
||||||
|
returnPercentage: ((lastPoint.value - lastPoint.invested) / lastPoint.invested) * 100
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseCase = getLastValue(projectionData);
|
||||||
|
const bestCase = getLastValue(scenarios.best.projection);
|
||||||
|
const worstCase = getLastValue(scenarios.worst.projection);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
<div className="mb-4 p-4 bg-gray-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
||||||
<h4 className="font-semibold mb-2 dark:text-gray-200">Scenario Calculations</h4>
|
<h4 className="font-semibold mb-2 dark:text-gray-200">Scenario Calculations</h4>
|
||||||
<ul className="space-y-2 text-gray-600 dark:text-gray-400">
|
<ul className="space-y-2 text-gray-600 dark:text-gray-400">
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-indigo-600 dark:text-indigo-400">Avg. Base Case:</span> Using historical average return of <span className="font-bold underline">{performancePerAnno.toFixed(2)}%</span>
|
<span className="font-medium text-indigo-600 dark:text-indigo-400">Avg. Base Case:</span> Using historical average return of{' '}
|
||||||
|
<span className="font-bold underline">{performancePerAnno.toFixed(2)}%</span>
|
||||||
|
<i className="block text-gray-300 dark:text-gray-500">
|
||||||
|
After {years} years you'd have{' '}
|
||||||
|
<span className="font-bold">{formatCurrency(baseCase.value)}</span> from {formatCurrency(baseCase.invested)} invested,{' '}
|
||||||
|
that's a total return of <span className="font-bold">{baseCase.returnPercentage.toFixed(2)}%</span>
|
||||||
|
</i>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-green-600 dark:text-green-400">Best Case:</span> Average of top 50% performing years ({scenarios.best.avaragedAmount} years) at {scenarios.best.percentage.toFixed(2)}%,
|
<span className="font-medium text-green-600 dark:text-green-400">Best Case:</span> Average of top 50% performing years ({scenarios.best.avaragedAmount} years) at {scenarios.best.percentage.toFixed(2)}%,
|
||||||
averaged with base case to <span className="font-semibold underline">{scenarios.best.percentageAveraged.toFixed(2)}%</span>
|
averaged with base case to <span className="font-semibold underline">{scenarios.best.percentageAveraged.toFixed(2)}%</span>.{' '}
|
||||||
|
<i className="block text-gray-300 dark:text-gray-500">
|
||||||
|
After {years} years you'd have <span className="font-bold">{formatCurrency(bestCase.value)}</span> from {formatCurrency(bestCase.invested)} invested,{' '}
|
||||||
|
that's a total return of <span className="font-bold">{bestCase.returnPercentage.toFixed(2)}%</span>
|
||||||
|
</i>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-red-600 dark:text-red-400">Worst Case:</span> Average of bottom 50% performing years ({scenarios.worst.avaragedAmount} years) at {scenarios.worst.percentage.toFixed(2)}%,
|
<span className="font-medium text-red-600 dark:text-red-400">Worst Case:</span> Average of bottom 50% performing years ({scenarios.worst.avaragedAmount} years) at {scenarios.worst.percentage.toFixed(2)}%,
|
||||||
averaged with base case to <span className="font-semibold underline">{scenarios.worst.percentageAveraged.toFixed(2)}%</span>
|
averaged with base case to <span className="font-semibold underline">{scenarios.worst.percentageAveraged.toFixed(2)}%</span>.{' '}
|
||||||
|
<i className="block text-gray-300 dark:text-gray-500">
|
||||||
|
After {years} years you'd have <span className="font-bold">{formatCurrency(worstCase.value)}</span> from {formatCurrency(worstCase.invested)} invested,{' '}
|
||||||
|
that's a total return of <span className="font-bold">{worstCase.returnPercentage.toFixed(2)}%</span>
|
||||||
|
</i>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,6 +14,16 @@ import { EditSavingsPlanModal } from "./Modals/EditSavingsPlanModal";
|
||||||
import { FutureProjectionModal } from "./Modals/FutureProjectionModal";
|
import { FutureProjectionModal } from "./Modals/FutureProjectionModal";
|
||||||
import { Tooltip } from "./utils/ToolTip";
|
import { Tooltip } from "./utils/ToolTip";
|
||||||
|
|
||||||
|
interface SavingsPlanPerformance {
|
||||||
|
assetName: string;
|
||||||
|
amount: number;
|
||||||
|
totalInvested: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
performancePerAnnoPerformance: number;
|
||||||
|
allocation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PortfolioTable() {
|
export default function PortfolioTable() {
|
||||||
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
||||||
assets: state.assets,
|
assets: state.assets,
|
||||||
|
@ -110,9 +120,17 @@ export default function PortfolioTable() {
|
||||||
|
|
||||||
const savingsPlansPerformance = useMemo(() => {
|
const savingsPlansPerformance = useMemo(() => {
|
||||||
if(isSavingsPlanOverviewDisabled) return [];
|
if(isSavingsPlanOverviewDisabled) return [];
|
||||||
const performance = [];
|
const performance: SavingsPlanPerformance[] = [];
|
||||||
|
const totalSavingsPlansAmount = assets
|
||||||
|
.map(v => v.investments)
|
||||||
|
.flat()
|
||||||
|
.filter(inv => inv.type === 'periodic')
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
|
||||||
|
// Second pass to calculate individual performances with allocation
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const savingsPlans = asset.investments.filter(inv => inv.type === 'periodic');
|
const savingsPlans = asset.investments.filter(inv => inv.type === 'periodic');
|
||||||
|
const amount = savingsPlans.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
if (savingsPlans.length > 0) {
|
if (savingsPlans.length > 0) {
|
||||||
const assetPerformance = calculateInvestmentPerformance([{
|
const assetPerformance = calculateInvestmentPerformance([{
|
||||||
...asset,
|
...asset,
|
||||||
|
@ -121,13 +139,35 @@ export default function PortfolioTable() {
|
||||||
performance.push({
|
performance.push({
|
||||||
assetName: asset.name,
|
assetName: asset.name,
|
||||||
amount: savingsPlans[0].amount,
|
amount: savingsPlans[0].amount,
|
||||||
...assetPerformance.summary
|
...assetPerformance.summary,
|
||||||
|
allocation: amount / totalSavingsPlansAmount * 100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return performance;
|
return performance;
|
||||||
}, [assets, isSavingsPlanOverviewDisabled]);
|
}, [assets, isSavingsPlanOverviewDisabled]);
|
||||||
|
|
||||||
|
const savingsPlansSummary = useMemo(() => {
|
||||||
|
if (savingsPlansPerformance.length === 0) return null;
|
||||||
|
|
||||||
|
const totalCurrentValue = savingsPlansPerformance.reduce((sum, plan) => sum + plan.currentValue, 0);
|
||||||
|
const totalInvested = savingsPlansPerformance.reduce((sum, plan) => sum + plan.totalInvested, 0);
|
||||||
|
const weightedPerformance = savingsPlansPerformance.reduce((sum, plan) => {
|
||||||
|
return sum + (plan.performancePercentage * (plan.currentValue / totalCurrentValue));
|
||||||
|
}, 0);
|
||||||
|
const weightedPerformancePA = savingsPlansPerformance.reduce((sum, plan) => {
|
||||||
|
return sum + (plan.performancePerAnnoPerformance * (plan.currentValue / totalCurrentValue));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAmount: savingsPlansPerformance.reduce((sum, plan) => sum + plan.amount, 0),
|
||||||
|
totalInvested,
|
||||||
|
totalCurrentValue,
|
||||||
|
weightedPerformance,
|
||||||
|
weightedPerformancePA,
|
||||||
|
};
|
||||||
|
}, [savingsPlansPerformance]);
|
||||||
|
|
||||||
const handleGeneratePDF = async () => {
|
const handleGeneratePDF = async () => {
|
||||||
setIsGeneratingPDF(true);
|
setIsGeneratingPDF(true);
|
||||||
try {
|
try {
|
||||||
|
@ -224,6 +264,7 @@ export default function PortfolioTable() {
|
||||||
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
|
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
|
||||||
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
|
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
|
||||||
<th className="px-4 py-2">Interval Amount</th>
|
<th className="px-4 py-2">Interval Amount</th>
|
||||||
|
<th className="px-4 py-2">Allocation</th>
|
||||||
<th className="px-4 py-2">Total Invested</th>
|
<th className="px-4 py-2">Total Invested</th>
|
||||||
<th className="px-4 py-2">Current Value</th>
|
<th className="px-4 py-2">Current Value</th>
|
||||||
<th className="px-4 py-2">Performance (%)</th>
|
<th className="px-4 py-2">Performance (%)</th>
|
||||||
|
@ -232,7 +273,19 @@ export default function PortfolioTable() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{savingsPlansPerformance.map((plan) => {
|
{savingsPlansSummary && (
|
||||||
|
<tr className="font-bold bg-gray-50 dark:bg-slate-700 border-t border-gray-200 dark:border-slate-600">
|
||||||
|
<td className="px-4 py-2">Total</td>
|
||||||
|
<td className="px-4 py-2">€{savingsPlansSummary.totalAmount.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">100%</td>
|
||||||
|
<td className="px-4 py-2">€{savingsPlansSummary.totalInvested.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{savingsPlansSummary.totalCurrentValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">{savingsPlansSummary.weightedPerformance.toFixed(2)}%</td>
|
||||||
|
<td className="px-4 py-2">{savingsPlansSummary.weightedPerformancePA.toFixed(2)}%</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{savingsPlansPerformance.sort((a, b) => Number(b.allocation || 0) - Number(a.allocation || 0)).map((plan) => {
|
||||||
const asset = assets.find(a => a.name === plan.assetName)!;
|
const asset = assets.find(a => a.name === plan.assetName)!;
|
||||||
const firstInvestment = asset.investments.find(inv => inv.type === 'periodic')!;
|
const firstInvestment = asset.investments.find(inv => inv.type === 'periodic')!;
|
||||||
const groupId = firstInvestment.periodicGroupId!;
|
const groupId = firstInvestment.periodicGroupId!;
|
||||||
|
@ -240,7 +293,8 @@ export default function PortfolioTable() {
|
||||||
return (
|
return (
|
||||||
<tr key={plan.assetName} className="border-t border-gray-200 dark:border-slate-600">
|
<tr key={plan.assetName} className="border-t border-gray-200 dark:border-slate-600">
|
||||||
<td className="px-4 py-2">{plan.assetName}</td>
|
<td className="px-4 py-2">{plan.assetName}</td>
|
||||||
<td className="px-4 py-2">{plan.amount}</td>
|
<td className="px-4 py-2">€{plan.amount.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">{plan.allocation?.toFixed(2)}%</td>
|
||||||
<td className="px-4 py-2">€{plan.totalInvested.toFixed(2)}</td>
|
<td className="px-4 py-2">€{plan.totalInvested.toFixed(2)}</td>
|
||||||
<td className="px-4 py-2">€{plan.currentValue.toFixed(2)}</td>
|
<td className="px-4 py-2">€{plan.currentValue.toFixed(2)}</td>
|
||||||
<td className="px-4 py-2">{plan.performancePercentage.toFixed(2)}%</td>
|
<td className="px-4 py-2">{plan.performancePercentage.toFixed(2)}%</td>
|
||||||
|
|
Loading…
Add table
Reference in a new issue