v1.0.0 - scenario projections & exports
26
README.md
|
@ -14,6 +14,7 @@ Why this Project?
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📈 Real-time stock data from Yahoo Finance
|
- 📈 Real-time stock data from Yahoo Finance
|
||||||
|
@ -21,18 +22,32 @@ https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
||||||
- 📊 Interactive charts with performance visualization
|
- 📊 Interactive charts with performance visualization
|
||||||
- 🌓 Dark/Light mode support
|
- 🌓 Dark/Light mode support
|
||||||
- 📱 Responsive design
|
- 📱 Responsive design
|
||||||
|
- *Mobile friendly*
|
||||||
- 📅 Historical data analysis
|
- 📅 Historical data analysis
|
||||||
|
- *The portfolio is fully based on real-historical data, with configurable timeranges*
|
||||||
- 💹 TTWOR (Time Travel Without Risk) calculations
|
- 💹 TTWOR (Time Travel Without Risk) calculations
|
||||||
|
- *Including metrics for TTWOR*
|
||||||
- 🔄 Support for one-time and periodic investments
|
- 🔄 Support for one-time and periodic investments
|
||||||
|
- *You can config your dream-portfolio by one time and periodic investments easily*
|
||||||
- 📊 Detailed performance metrics
|
- 📊 Detailed performance metrics
|
||||||
|
- *See all needed performance metrics in one place*
|
||||||
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
|
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
|
||||||
|
- *Generate a future projection based on the current portfolio performance, with a withdrawal analysis and sustainability analysis + calculator*
|
||||||
|
- *Including with best, worst and average case scenarios*
|
||||||
|
- 📊 Savings Plan Performance Overview Tables
|
||||||
|
- *See the performance of your savings plans if you have multiple assets to compare them*
|
||||||
|
- 📄 Export to PDF
|
||||||
|
- *Export the entire portfolio Overview to a PDF, including Future Projections of 10, 15, 20, 30 and 40 years*
|
||||||
|
- 📄 Export to CSV Tables
|
||||||
|
- *Export all available tables to CSV*
|
||||||
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- React 18
|
- React 19
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- Tailwind CSS
|
- Tailwind CSS
|
||||||
- Vite
|
- Vite@6
|
||||||
- Recharts
|
- Recharts
|
||||||
- date-fns
|
- date-fns
|
||||||
- Lucide Icons
|
- Lucide Icons
|
||||||
|
@ -41,8 +56,7 @@ https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 20 or higher
|
- Node.js & npm 20 or higher
|
||||||
- npm or yarn
|
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
|
@ -55,6 +69,10 @@ https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Credits:
|
### Credits:
|
||||||
|
|
||||||
|
|
BIN
docs/analysis-page-1.png
Normal file
After Width: | Height: | Size: 161 KiB |
BIN
docs/analysis-page-2.png
Normal file
After Width: | Height: | Size: 97 KiB |
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 353 KiB |
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 205 KiB |
Before Width: | Height: | Size: 198 KiB |
BIN
docs/scenario-projection.png
Normal file
After Width: | Height: | Size: 264 KiB |
BIN
docs/white-mode.png
Normal file
After Width: | Height: | Size: 227 KiB |
|
@ -19,6 +19,7 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': [
|
||||||
'warn',
|
'warn',
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
|
|
1517
package-lock.json
generated
46
package.json
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "investment-portfolio-tracker",
|
"name": "investment-portfolio-tracker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
@ -10,31 +10,31 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns": "^3.3.1",
|
"jspdf": "^2.5.2",
|
||||||
"lucide-react": "^0.344.0",
|
"jspdf-autotable": "^3.8.4",
|
||||||
"react": "^18.3.1",
|
"lucide-react": "^0.469.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0",
|
"react-router-dom": "^7.1.0",
|
||||||
"recharts": "^2.12.1",
|
"recharts": "^2.15.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4"
|
||||||
"zustand": "^4.5.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.14.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^5.4.2"
|
"vite": "^6.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
74
src/App.tsx
|
@ -1,66 +1,24 @@
|
||||||
import { Heart, Moon, Plus, Sun } from "lucide-react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
import { AddAssetModal } from "./components/AddAssetModal";
|
import { AppShell } from "./components/Landing/AppShell";
|
||||||
import { InvestmentFormWrapper } from "./components/InvestmentForm";
|
import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
|
||||||
import { PortfolioChart } from "./components/PortfolioChart";
|
import { PortfolioProvider } from "./providers/PortfolioProvider";
|
||||||
import { PortfolioTable } from "./components/PortfolioTable";
|
|
||||||
import { useDarkMode } from "./providers/DarkModeProvider";
|
const MainContent = lazy(() => import("./components/Landing/MainContent"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
||||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
|
<PortfolioProvider>
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors relative">
|
<AppShell onAddAsset={() => setIsAddingAsset(true)}>
|
||||||
<div className="max-w-7xl mx-auto">
|
<Suspense fallback={<LoadingPlaceholder className="h-screen" />}>
|
||||||
<div className="flex justify-between items-center mb-8">
|
<MainContent
|
||||||
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
isAddingAsset={isAddingAsset}
|
||||||
<div className="flex gap-4">
|
setIsAddingAsset={setIsAddingAsset}
|
||||||
<button
|
/>
|
||||||
onClick={toggleDarkMode}
|
</Suspense>
|
||||||
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
</AppShell>
|
||||||
aria-label="Toggle dark mode"
|
</PortfolioProvider>
|
||||||
>
|
|
||||||
{isDarkMode ? (
|
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
|
||||||
) : (
|
|
||||||
<Moon className="w-5 h-5 text-gray-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsAddingAsset(true)}
|
|
||||||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
Add Asset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 mb-8 dark:text-gray-300">
|
|
||||||
<div className="col-span-3">
|
|
||||||
<PortfolioChart />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-3 lg:col-span-1">
|
|
||||||
<InvestmentFormWrapper />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PortfolioTable />
|
|
||||||
{isAddingAsset && <AddAssetModal onClose={() => setIsAddingAsset(false)} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
|
||||||
>
|
|
||||||
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,102 +0,0 @@
|
||||||
import { Search, X } from "lucide-react";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
|
|
||||||
import { getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
|
||||||
import { Asset } from "../types";
|
|
||||||
|
|
||||||
export const AddAssetModal = ({ onClose }: { onClose: () => void }) => {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [searchResults, setSearchResults] = useState<Asset[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { addAsset, dateRange } = usePortfolioStore((state) => ({
|
|
||||||
addAsset: state.addAsset,
|
|
||||||
dateRange: state.dateRange,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
|
||||||
if (query.length < 2) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const results = await searchAssets(query);
|
|
||||||
setSearchResults(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching assets:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
|
||||||
|
|
||||||
const handleAssetSelect = async (asset: Asset) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const historicalData = await getHistoricalData(
|
|
||||||
asset.symbol,
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate
|
|
||||||
);
|
|
||||||
|
|
||||||
const assetWithHistory = {
|
|
||||||
...asset,
|
|
||||||
historicalData,
|
|
||||||
};
|
|
||||||
|
|
||||||
addAsset(assetWithHistory);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching historical data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-bold dark:text-gray-200">Add Asset</h2>
|
|
||||||
<button onClick={onClose} className="p-2">
|
|
||||||
<X className="w-6 h-6 dark:text-gray-200" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by symbol or name..."
|
|
||||||
className="w-full p-2 pr-10 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
|
||||||
value={search}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
debouncedSearch(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Search className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-4">Loading...</div>
|
|
||||||
) : (
|
|
||||||
searchResults.map((result) => (
|
|
||||||
<button
|
|
||||||
key={result.symbol}
|
|
||||||
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded"
|
|
||||||
onClick={() => handleAssetSelect(result)}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{result.name}</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Symbol: {result.symbol}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,41 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
onStartDateChange: (date: string) => void;
|
|
||||||
onEndDateChange: (date: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DateRangePicker = ({
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
onStartDateChange,
|
|
||||||
onEndDateChange,
|
|
||||||
}: DateRangePickerProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">From</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => onStartDateChange(e.target.value)}
|
|
||||||
max={endDate}
|
|
||||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">To</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => onEndDateChange(e.target.value)}
|
|
||||||
min={startDate}
|
|
||||||
max={new Date().toISOString().split('T')[0]}
|
|
||||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,79 +0,0 @@
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
|
||||||
import { Investment } from "../types";
|
|
||||||
|
|
||||||
interface EditInvestmentModalProps {
|
|
||||||
investment: Investment;
|
|
||||||
assetId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
|
|
||||||
const updateInvestment = usePortfolioStore((state) => state.updateInvestment);
|
|
||||||
const [amount, setAmount] = useState(investment.amount.toString());
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
updateInvestment(assetId, investment.id, {
|
|
||||||
...investment,
|
|
||||||
amount: parseFloat(amount),
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-bold">Edit Investment</h2>
|
|
||||||
<button onClick={onClose} className="p-2">
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
Investment Amount
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
className="w-full p-2 border rounded"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{investment.type === 'periodic' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Note: Editing a periodic investment will affect all future investments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 border rounded hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||||
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||||
|
|
||||||
export const InvestmentFormWrapper = () => {
|
export default function InvestmentFormWrapper() {
|
||||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
const { assets, clearAssets } = usePortfolioSelector((state) => ({
|
||||||
const { assets, clearAssets } = usePortfolioStore((state) => ({
|
|
||||||
assets: state.assets,
|
assets: state.assets,
|
||||||
clearAssets: state.clearAssets,
|
clearAssets: state.clearAssets,
|
||||||
}));
|
}));
|
||||||
|
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleClearAssets = () => {
|
const handleClearAssets = () => {
|
||||||
if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) {
|
if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) {
|
||||||
|
@ -74,7 +74,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
const [yearInterval, setYearInterval] = useState('1');
|
const [yearInterval, setYearInterval] = useState('1');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
|
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
|
||||||
dateRange: state.dateRange,
|
dateRange: state.dateRange,
|
||||||
addInvestment: state.addInvestment,
|
addInvestment: state.addInvestment,
|
||||||
}));
|
}));
|
||||||
|
@ -83,46 +83,48 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
setTimeout(async () => {
|
||||||
if (type === "single") {
|
try {
|
||||||
const investment = {
|
if (type === "single") {
|
||||||
id: crypto.randomUUID(),
|
const investment = {
|
||||||
assetId,
|
id: crypto.randomUUID(),
|
||||||
type,
|
assetId,
|
||||||
amount: parseFloat(amount),
|
type,
|
||||||
date
|
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);
|
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();
|
||||||
}
|
}
|
||||||
} finally {
|
}, 10);
|
||||||
setIsSubmitting(false);
|
|
||||||
setAmount('');
|
|
||||||
clearSelectedAsset();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -264,7 +266,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Loader2 className="animate-spin mx-auto" size={16} />
|
<Loader2 className="animate-spin mx-auto" size={16} />
|
||||||
|
|
55
src/components/Landing/AppShell.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { Heart, Moon, Plus, Sun } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onAddAsset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
|
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors relative">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<Sun className="w-5 h-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAddAsset}
|
||||||
|
className={`flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700`}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Add Asset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
38
src/components/Landing/MainContent.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
|
||||||
|
import { LoadingPlaceholder } from "../utils/LoadingPlaceholder";
|
||||||
|
|
||||||
|
const AddAssetModal = lazy(() => import("../Modals/AddAssetModal"));
|
||||||
|
const InvestmentFormWrapper = lazy(() => import("../InvestmentForm"));
|
||||||
|
const PortfolioChart = lazy(() => import("../PortfolioChart"));
|
||||||
|
const PortfolioTable = lazy(() => import("../PortfolioTable"));
|
||||||
|
|
||||||
|
|
||||||
|
export default function MainContent({ isAddingAsset, setIsAddingAsset }: { isAddingAsset: boolean, setIsAddingAsset: (value: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 mb-8 dark:text-gray-300">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||||
|
<PortfolioChart />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 lg:col-span-1">
|
||||||
|
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||||
|
<InvestmentFormWrapper />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||||
|
<PortfolioTable />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{isAddingAsset && (
|
||||||
|
<Suspense>
|
||||||
|
<AddAssetModal onClose={() => setIsAddingAsset(false)} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
112
src/components/Modals/AddAssetModal.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { Loader2, Search, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||||
|
import { getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
||||||
|
import { Asset } from "../../types";
|
||||||
|
|
||||||
|
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [ search, setSearch ] = useState('');
|
||||||
|
const [ searchResults, setSearchResults ] = useState<Asset[]>([]);
|
||||||
|
const [ loading, setLoading ] = useState<null | "searching" | "adding">(null);
|
||||||
|
const { addAsset, dateRange, assets } = usePortfolioSelector((state) => ({
|
||||||
|
addAsset: state.addAsset,
|
||||||
|
dateRange: state.dateRange,
|
||||||
|
assets: state.assets,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
if (query.length < 2) return;
|
||||||
|
setLoading("searching");
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await searchAssets(query);
|
||||||
|
setSearchResults(results.filter((result) => !assets.some((asset) => asset.symbol === result.symbol)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching assets:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
||||||
|
|
||||||
|
const handleAssetSelect = (asset: Asset) => {
|
||||||
|
setLoading("adding");
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { historicalData, longName } = await getHistoricalData(
|
||||||
|
asset.symbol,
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetWithHistory = {
|
||||||
|
...asset,
|
||||||
|
// override name with the fetched long Name if available
|
||||||
|
name: longName || asset.name,
|
||||||
|
historicalData,
|
||||||
|
};
|
||||||
|
|
||||||
|
addAsset(assetWithHistory);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching historical data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold dark:text-gray-200">Add Asset</h2>
|
||||||
|
<button onClick={onClose} className="p-2">
|
||||||
|
<X className="w-6 h-6 dark:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by symbol or name..."
|
||||||
|
className="w-full p-2 pr-10 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
value={search}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
debouncedSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Search className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center text-center py-4 gap-2 dark:text-slate-300">
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
<span>{loading === "searching" ? "Searching Assets..." : "Fetching Details & Adding..."}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.symbol}
|
||||||
|
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded border-b dark:border-slate-700 border-gray-300"
|
||||||
|
onClick={() => handleAssetSelect(result)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{result.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
81
src/components/Modals/EditInvestmentModal.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||||
|
import { Investment } from "../../types";
|
||||||
|
|
||||||
|
interface EditInvestmentModalProps {
|
||||||
|
investment: Investment;
|
||||||
|
assetId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
|
||||||
|
const { updateInvestment } = usePortfolioSelector((state) => ({
|
||||||
|
updateInvestment: state.updateInvestment,
|
||||||
|
}));
|
||||||
|
const [amount, setAmount] = useState(investment.amount.toString());
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateInvestment(assetId, investment.id, {
|
||||||
|
...investment,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">Edit Investment</h2>
|
||||||
|
<button onClick={onClose} className="p-2">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Investment Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{investment.type === 'periodic' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Note: Editing a periodic investment will affect all future investments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 border rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -4,54 +4,41 @@ import {
|
||||||
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||||
import { calculateFutureProjection } from "../utils/calculations/futureProjection";
|
import { calculateFutureProjection } from "../../utils/calculations/futureProjection";
|
||||||
import { formatCurrency } from "../utils/formatters";
|
import { formatCurrency } from "../../utils/formatters";
|
||||||
|
|
||||||
|
import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types";
|
||||||
|
|
||||||
interface FutureProjectionModalProps {
|
interface FutureProjectionModalProps {
|
||||||
onClose: () => void;
|
|
||||||
performancePerAnno: number;
|
performancePerAnno: number;
|
||||||
|
bestPerformancePerAnno: { percentage: number, year: number }[];
|
||||||
|
worstPerformancePerAnno: { percentage: number, year: number }[];
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartType = 'line' | 'bar';
|
export type ChartType = 'line' | 'bar';
|
||||||
|
|
||||||
export interface WithdrawalPlan {
|
type ScenarioCalc = { projection: ProjectionData[], sustainability: SustainabilityAnalysis | null, avaragedAmount: number, percentage: number, percentageAveraged: number };
|
||||||
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 {
|
export const FutureProjectionModal = ({
|
||||||
date: string;
|
performancePerAnno,
|
||||||
value: number;
|
bestPerformancePerAnno,
|
||||||
invested: number;
|
worstPerformancePerAnno,
|
||||||
withdrawals: number;
|
onClose
|
||||||
totalWithdrawn: number;
|
}: FutureProjectionModalProps) => {
|
||||||
}
|
|
||||||
|
|
||||||
export interface SustainabilityAnalysis {
|
|
||||||
yearsToReachTarget: number;
|
|
||||||
targetValue: number;
|
|
||||||
sustainableYears: number | 'infinite';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FutureProjectionModal = ({ onClose, performancePerAnno }: FutureProjectionModalProps) => {
|
|
||||||
const [years, setYears] = useState('10');
|
const [years, setYears] = useState('10');
|
||||||
const [isCalculating, setIsCalculating] = useState(false);
|
const [isCalculating, setIsCalculating] = useState(false);
|
||||||
const [chartType, setChartType] = useState<ChartType>('line');
|
const [chartType, setChartType] = useState<ChartType>('line');
|
||||||
const [projectionData, setProjectionData] = useState<ProjectionData[]>([]);
|
const [projectionData, setProjectionData] = useState<ProjectionData[]>([]);
|
||||||
|
const [scenarios, setScenarios] = useState<{ best: ScenarioCalc, worst: ScenarioCalc }>({
|
||||||
|
best: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 },
|
||||||
|
worst: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 },
|
||||||
|
});
|
||||||
const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({
|
const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({
|
||||||
amount: 0,
|
amount: 0,
|
||||||
interval: 'monthly',
|
interval: 'monthly',
|
||||||
startTrigger: 'date',
|
startTrigger: 'auto',
|
||||||
startDate: new Date().toISOString().split('T')[0],
|
startDate: new Date().toISOString().split('T')[0],
|
||||||
startPortfolioValue: 0,
|
startPortfolioValue: 0,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
@ -63,7 +50,9 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
});
|
});
|
||||||
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
|
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
|
||||||
|
|
||||||
const { assets } = usePortfolioStore();
|
const { assets } = usePortfolioSelector((state) => ({
|
||||||
|
assets: state.assets,
|
||||||
|
}));
|
||||||
|
|
||||||
const calculateProjection = useCallback(async () => {
|
const calculateProjection = useCallback(async () => {
|
||||||
setIsCalculating(true);
|
setIsCalculating(true);
|
||||||
|
@ -76,14 +65,44 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
);
|
);
|
||||||
setProjectionData(projection);
|
setProjectionData(projection);
|
||||||
setSustainabilityAnalysis(sustainability);
|
setSustainabilityAnalysis(sustainability);
|
||||||
|
const slicedBestCase = bestPerformancePerAnno.slice(0, Math.floor(bestPerformancePerAnno.length / 2));
|
||||||
|
const slicedWorstCase = worstPerformancePerAnno.slice(0, Math.floor(worstPerformancePerAnno.length / 2));
|
||||||
|
const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0;
|
||||||
|
const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0;
|
||||||
|
|
||||||
|
const bestCaseAvaraged = (bestCase + performancePerAnno) / 2;
|
||||||
|
const worstCaseAvaraged = (worstCase + performancePerAnno) / 2;
|
||||||
|
setScenarios({
|
||||||
|
best: {
|
||||||
|
...await calculateFutureProjection(
|
||||||
|
assets,
|
||||||
|
parseInt(years),
|
||||||
|
bestCaseAvaraged,
|
||||||
|
withdrawalPlan.enabled ? withdrawalPlan : undefined
|
||||||
|
),
|
||||||
|
avaragedAmount: slicedBestCase.length,
|
||||||
|
percentageAveraged: bestCaseAvaraged,
|
||||||
|
percentage: bestCase
|
||||||
|
},
|
||||||
|
worst: {
|
||||||
|
...await calculateFutureProjection(
|
||||||
|
assets,
|
||||||
|
parseInt(years),
|
||||||
|
worstCaseAvaraged,
|
||||||
|
withdrawalPlan.enabled ? withdrawalPlan : undefined
|
||||||
|
),
|
||||||
|
avaragedAmount: slicedWorstCase.length,
|
||||||
|
percentage: worstCase,
|
||||||
|
percentageAveraged: worstCaseAvaraged
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error calculating projection:', error);
|
console.error('Error calculating projection:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCalculating(false);
|
setIsCalculating(false);
|
||||||
}
|
}
|
||||||
}, [assets, years, withdrawalPlan, performancePerAnno]);
|
}, [assets, years, withdrawalPlan, performancePerAnno, bestPerformancePerAnno, worstPerformancePerAnno]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const value = payload[0].value;
|
const value = payload[0].value;
|
||||||
|
@ -122,6 +141,36 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const CustomScenarioTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const bestCase = payload.find((p: any) => p.dataKey === 'bestCase')?.value || 0;
|
||||||
|
const baseCase = payload.find((p: any) => p.dataKey === 'baseCase')?.value || 0;
|
||||||
|
const worstCase = payload.find((p: any) => p.dataKey === 'worstCase')?.value || 0;
|
||||||
|
const invested = payload.find((p: any) => p.dataKey === 'invested')?.value || 0;
|
||||||
|
|
||||||
|
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-green-600 dark:text-green-400">
|
||||||
|
Best-Case: {formatCurrency(bestCase)} {((bestCase - invested) / invested * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
|
Avg. Base-Case: {formatCurrency(baseCase)} {((baseCase - invested) / invested * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-red-600 dark:text-red-400">
|
||||||
|
Worst-Case: {formatCurrency(worstCase)} {((worstCase - invested) / invested * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const renderChart = () => {
|
const renderChart = () => {
|
||||||
if (isCalculating) {
|
if (isCalculating) {
|
||||||
return (
|
return (
|
||||||
|
@ -230,6 +279,97 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderScenarioDescription = () => {
|
||||||
|
if (!scenarios.best.projection.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<ul className="space-y-2 text-gray-600 dark:text-gray-400">
|
||||||
|
<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>
|
||||||
|
</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)}%,
|
||||||
|
averaged with base case to <span className="font-semibold underline">{scenarios.best.percentageAveraged.toFixed(2)}%</span>
|
||||||
|
</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)}%,
|
||||||
|
averaged with base case to <span className="font-semibold underline">{scenarios.worst.percentageAveraged.toFixed(2)}%</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScenarioChart = () => {
|
||||||
|
if (!scenarios.best.projection.length) return null;
|
||||||
|
|
||||||
|
// Create a merged and sorted dataset for consistent x-axis
|
||||||
|
const mergedData = projectionData.map(basePoint => {
|
||||||
|
const date = basePoint.date;
|
||||||
|
const bestPoint = scenarios.best.projection.find(p => p.date === date);
|
||||||
|
const worstPoint = scenarios.worst.projection.find(p => p.date === date);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
bestCase: bestPoint?.value || 0,
|
||||||
|
baseCase: basePoint.value,
|
||||||
|
worstCase: worstPoint?.value || 0,
|
||||||
|
invested: basePoint.invested
|
||||||
|
};
|
||||||
|
}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="font-semibold mb-4 dark:text-gray-200">Scenario Comparison</h4>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={mergedData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip content={<CustomScenarioTooltip />}/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="bestCase"
|
||||||
|
stroke="#22c55e"
|
||||||
|
name="Best Case"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="baseCase"
|
||||||
|
stroke="#4f46e5"
|
||||||
|
name="Base Case"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="worstCase"
|
||||||
|
stroke="#ef4444"
|
||||||
|
name="Worst Case"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="invested"
|
||||||
|
stroke="#9333ea"
|
||||||
|
name="Invested Amount"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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="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="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">
|
||||||
|
@ -263,7 +403,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
<button
|
<button
|
||||||
onClick={calculateProjection}
|
onClick={calculateProjection}
|
||||||
disabled={isCalculating}
|
disabled={isCalculating}
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isCalculating ? (
|
{isCalculating ? (
|
||||||
<Loader2 className="animate-spin" size={16} />
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
@ -293,15 +433,16 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
Future projections are calculated with your portfolio's average annual return rate of{' '}
|
Future projections are calculated with your portfolio's average annual return rate of{' '}
|
||||||
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
|
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">
|
<div className="mt-1">
|
||||||
Strategy explanations:
|
Strategy explanations:
|
||||||
<ul className="list-disc ml-5 mt-1">
|
<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">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">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>
|
<li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li>
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderScenarioDescription()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -370,7 +511,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
>
|
>
|
||||||
<option value="date">Specific Date</option>
|
<option value="date">Specific Date</option>
|
||||||
<option value="portfolioValue">Portfolio Value Threshold</option>
|
<option value="portfolioValue">Portfolio Value Threshold</option>
|
||||||
<option value="auto">Auto</option>
|
<option value="auto" >Auto-Finder</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -547,6 +688,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
||||||
<div className="h-[500px]">
|
<div className="h-[500px]">
|
||||||
{renderChart()}
|
{renderChart()}
|
||||||
</div>
|
</div>
|
||||||
|
{renderScenarioChart()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -6,49 +6,20 @@ import {
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
import { useDarkMode } from "../providers/DarkModeProvider";
|
import { useDarkMode } from "../hooks/useDarkMode";
|
||||||
|
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||||
import { getHistoricalData } from "../services/yahooFinanceService";
|
import { getHistoricalData } from "../services/yahooFinanceService";
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
|
||||||
import { DateRange } from "../types";
|
import { DateRange } from "../types";
|
||||||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||||
import { DateRangePicker } from "./DateRangePicker";
|
import { getHexColor } from "../utils/formatters";
|
||||||
|
import { DateRangePicker } from "./utils/DateRangePicker";
|
||||||
|
|
||||||
const LIGHT_MODE_COLORS = [
|
export default function PortfolioChart() {
|
||||||
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
|
|
||||||
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
|
|
||||||
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
|
|
||||||
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
|
|
||||||
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
|
|
||||||
];
|
|
||||||
|
|
||||||
const DARK_MODE_COLORS = [
|
|
||||||
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
|
|
||||||
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
|
|
||||||
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
|
|
||||||
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
|
|
||||||
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
|
|
||||||
];
|
|
||||||
|
|
||||||
const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
|
|
||||||
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
|
|
||||||
|
|
||||||
// Find first unused color
|
|
||||||
const availableColor = colorPool.find(color => !usedColors.has(color));
|
|
||||||
|
|
||||||
if (availableColor) {
|
|
||||||
return availableColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to random color if all predefined colors are used
|
|
||||||
return `#${Math.floor(Math.random()*16777215).toString(16)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PortfolioChart = () => {
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [hideAssets, setHideAssets] = useState(false);
|
const [hideAssets, setHideAssets] = useState(false);
|
||||||
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
|
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
|
||||||
const { isDarkMode } = useDarkMode();
|
const { isDarkMode } = useDarkMode();
|
||||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioStore((state) => ({
|
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
|
||||||
assets: state.assets,
|
assets: state.assets,
|
||||||
dateRange: state.dateRange,
|
dateRange: state.dateRange,
|
||||||
updateDateRange: state.updateDateRange,
|
updateDateRange: state.updateDateRange,
|
||||||
|
@ -57,11 +28,13 @@ export const PortfolioChart = () => {
|
||||||
|
|
||||||
const fetchHistoricalData = useCallback(
|
const fetchHistoricalData = useCallback(
|
||||||
async (startDate: string, endDate: string) => {
|
async (startDate: string, endDate: string) => {
|
||||||
assets.forEach(async (asset) => {
|
for (const asset of assets) {
|
||||||
const historicalData = await getHistoricalData(asset.symbol, startDate, endDate);
|
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||||
updateAssetHistoricalData(asset.id, historicalData);
|
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||||
});
|
}
|
||||||
}, [assets, updateAssetHistoricalData]);
|
},
|
||||||
|
[assets, updateAssetHistoricalData]
|
||||||
|
);
|
||||||
|
|
||||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
||||||
maxWait: 5000,
|
maxWait: 5000,
|
||||||
|
@ -69,14 +42,14 @@ export const PortfolioChart = () => {
|
||||||
|
|
||||||
const assetColors: Record<string, string> = useMemo(() => {
|
const assetColors: Record<string, string> = useMemo(() => {
|
||||||
const usedColors = new Set<string>();
|
const usedColors = new Set<string>();
|
||||||
return assets.reduce((colors, asset) => {
|
return assets.reduce((colors, asset) => {
|
||||||
const color = getHexColor(usedColors, isDarkMode);
|
const color = getHexColor(usedColors, isDarkMode);
|
||||||
usedColors.add(color);
|
usedColors.add(color);
|
||||||
return {
|
return {
|
||||||
...colors,
|
...colors,
|
||||||
[asset.id]: color,
|
[asset.id]: color,
|
||||||
};
|
};
|
||||||
}, {});
|
}, {});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assets.map(a => a.id).join(','), isDarkMode]);
|
}, [assets.map(a => a.id).join(','), isDarkMode]);
|
||||||
|
|
||||||
|
@ -84,7 +57,7 @@ export const PortfolioChart = () => {
|
||||||
const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => {
|
const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => {
|
||||||
const investedKapitals: Record<string, number> = {};
|
const investedKapitals: Record<string, number> = {};
|
||||||
|
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0);
|
investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +74,7 @@ export const PortfolioChart = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
processed["ttwor"] = 0;
|
processed["ttwor"] = 0;
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
const initialPrice = data[0].assets[asset.id];
|
const initialPrice = data[0].assets[asset.id];
|
||||||
const currentPrice = point.assets[asset.id];
|
const currentPrice = point.assets[asset.id];
|
||||||
if (initialPrice && currentPrice) {
|
if (initialPrice && currentPrice) {
|
||||||
|
@ -133,7 +106,7 @@ export const PortfolioChart = () => {
|
||||||
const toggleAllAssets = useCallback(() => {
|
const toggleAllAssets = useCallback(() => {
|
||||||
setHideAssets(!hideAssets);
|
setHideAssets(!hideAssets);
|
||||||
setHiddenAssets(new Set());
|
setHiddenAssets(new Set());
|
||||||
}, [hideAssets] );
|
}, [hideAssets]);
|
||||||
|
|
||||||
const CustomLegend = useCallback(({ payload }: any) => {
|
const CustomLegend = useCallback(({ payload }: any) => {
|
||||||
return (
|
return (
|
||||||
|
@ -168,9 +141,8 @@ export const PortfolioChart = () => {
|
||||||
<button
|
<button
|
||||||
key={`asset-${index}`}
|
key={`asset-${index}`}
|
||||||
onClick={() => toggleAsset(assetId)}
|
onClick={() => toggleAsset(assetId)}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${
|
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${isHidden ? 'opacity-40' : ''
|
||||||
isHidden ? 'opacity-40' : ''
|
} hover:bg-gray-100 dark:hover:bg-gray-800`}
|
||||||
} hover:bg-gray-100 dark:hover:bg-gray-800`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
|
@ -324,7 +296,8 @@ export const PortfolioChart = () => {
|
||||||
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
||||||
</i>
|
</i>
|
||||||
</>
|
</>
|
||||||
), [assets, isDarkMode, assetColors, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, updateDateRange, isFullscreen]);
|
), [assets, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen]);
|
||||||
|
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,246 +1,371 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { HelpCircle, LineChart, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2
|
||||||
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { usePortfolioStore } from "../store/portfolioStore";
|
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||||
import { Investment } from "../types";
|
import { Investment } from "../types";
|
||||||
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
|
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
|
||||||
import { EditInvestmentModal } from "./EditInvestmentModal";
|
import { downloadTableAsCSV, generatePortfolioPDF } from "../utils/export";
|
||||||
import { FutureProjectionModal } from "./FutureProjectionModal";
|
import { EditInvestmentModal } from "./Modals/EditInvestmentModal";
|
||||||
|
import { FutureProjectionModal } from "./Modals/FutureProjectionModal";
|
||||||
|
import { Tooltip } from "./utils/ToolTip";
|
||||||
|
|
||||||
interface TooltipProps {
|
export default function PortfolioTable() {
|
||||||
content: string | JSX.Element;
|
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
||||||
children: React.ReactNode;
|
assets: state.assets,
|
||||||
}
|
removeInvestment: state.removeInvestment,
|
||||||
|
clearInvestments: state.clearInvestments,
|
||||||
|
}));
|
||||||
|
|
||||||
const Tooltip = ({ content, children }: TooltipProps) => {
|
const [editingInvestment, setEditingInvestment] = useState<{
|
||||||
const [show, setShow] = useState(false);
|
investment: Investment;
|
||||||
|
assetId: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [showSavingsPlans, setShowSavingsPlans] = useState(true);
|
||||||
|
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||||
|
|
||||||
|
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
|
||||||
|
|
||||||
|
const averagePerformance = useMemo(() => {
|
||||||
|
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) => {
|
||||||
|
if (window.confirm("Are you sure you want to delete this investment?")) {
|
||||||
|
removeInvestment(assetId, investmentId);
|
||||||
|
}
|
||||||
|
}, [removeInvestment]);
|
||||||
|
|
||||||
|
const handleClearAll = useCallback(() => {
|
||||||
|
if (window.confirm("Are you sure you want to clear all investments?")) {
|
||||||
|
clearInvestments();
|
||||||
|
}
|
||||||
|
}, [clearInvestments]);
|
||||||
|
|
||||||
|
const performanceTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</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>Best p.a.: {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</p>
|
||||||
|
<p>Worst p.a.: {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</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.performancePerAnnoPerformance, performance.summary.bestPerformancePerAnno, performance.summary.worstPerformancePerAnno]);
|
||||||
|
|
||||||
|
const buyInTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>"Buy-in" shows the asset's price when that position was bought.</p>
|
||||||
|
<p>"Avg" shows the average buy-in price across all positions for that asset.</p>
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const currentAmountTooltip = useMemo(() => (
|
||||||
|
"The current value of your investment based on the latest market price."
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const ttworTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.</p>
|
||||||
|
<p className="text-xs mt-2">
|
||||||
|
It helps to evaluate the impact of your investment timing strategy compared to a single early investment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const [showProjection, setShowProjection] = useState(false);
|
||||||
|
|
||||||
|
const isSavingsPlanOverviewDisabled = useMemo(() => {
|
||||||
|
return !assets.some(asset => asset.investments.some(inv => inv.type === 'periodic'));
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
const savingsPlansPerformance = useMemo(() => {
|
||||||
|
if(isSavingsPlanOverviewDisabled) return [];
|
||||||
|
const performance = [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
const savingsPlans = asset.investments.filter(inv => inv.type === 'periodic');
|
||||||
|
if (savingsPlans.length > 0) {
|
||||||
|
const assetPerformance = calculateInvestmentPerformance([{
|
||||||
|
...asset,
|
||||||
|
investments: savingsPlans
|
||||||
|
}]);
|
||||||
|
performance.push({
|
||||||
|
assetName: asset.name,
|
||||||
|
amount: savingsPlans[0].amount,
|
||||||
|
...assetPerformance.summary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return performance;
|
||||||
|
}, [assets, isSavingsPlanOverviewDisabled]);
|
||||||
|
|
||||||
|
const handleGeneratePDF = async () => {
|
||||||
|
setIsGeneratingPDF(true);
|
||||||
|
try {
|
||||||
|
await generatePortfolioPDF(
|
||||||
|
assets,
|
||||||
|
performance,
|
||||||
|
savingsPlansPerformance,
|
||||||
|
performance.summary.performancePerAnnoPerformance
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingPDF(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block">
|
<div className="space-y-4">
|
||||||
<div
|
<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">
|
||||||
className="flex items-center gap-1 cursor-help"
|
<div className="flex flex-wrap justify-between items-center mb-4">
|
||||||
onMouseEnter={() => setShow(true)}
|
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
|
||||||
onMouseLeave={() => setShow(false)}
|
<div className="flex flex-wrap gap-2">
|
||||||
>
|
<button
|
||||||
{children}
|
onClick={handleClearAll}
|
||||||
<HelpCircle className="w-4 h-4 text-gray-400" />
|
disabled={performance.investments.length === 0}
|
||||||
</div>
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
{show && (
|
>
|
||||||
<div className="absolute z-50 w-64 p-2 text-sm bg-black text-white rounded shadow-lg dark:shadow-black/60 -left-20 -bottom-2 transform translate-y-full">
|
Clear All Investments
|
||||||
{content}
|
</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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<LineChart size={16} />
|
||||||
|
Future Projection
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSavingsPlans(prev => !prev)}
|
||||||
|
disabled={isSavingsPlanOverviewDisabled}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
{showSavingsPlans ? 'Hide' : 'Show'} Savings Plans Performance
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGeneratePDF}
|
||||||
|
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={performance.investments.length === 0 || isGeneratingPDF}
|
||||||
|
>
|
||||||
|
{isGeneratingPDF ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown size={16} />
|
||||||
|
)}
|
||||||
|
{isGeneratingPDF ? 'Generating...' : 'Save Analysis'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isSavingsPlanOverviewDisabled && showSavingsPlans && savingsPlansPerformance.length > 0 && (
|
||||||
|
<div className="overflow-x-auto mb-4 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">
|
||||||
|
<h3 className="text-lg font-bold">Savings Plans Performance</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadTableAsCSV(savingsPlansPerformance, 'savings-plans-performance')}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Download CSV"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="min-w-full bg-white dark:bg-slate-800">
|
||||||
|
<thead>
|
||||||
|
<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">Interval Amount</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">Performance (%)</th>
|
||||||
|
<th className="px-4 py-2 last:rounded-tr-lg">Performance (p.a.)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{savingsPlansPerformance.map((plan) => (
|
||||||
|
<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.amount}</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.performancePercentage.toFixed(2)}%</td>
|
||||||
|
<td className="px-4 py-2">{plan.performancePerAnnoPerformance.toFixed(2)}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto 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">
|
||||||
|
<h3 className="text-lg font-bold">Positions Overview</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadTableAsCSV([
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
assetName: "Total Portfolio",
|
||||||
|
date: "",
|
||||||
|
investedAmount: performance.summary.totalInvested.toFixed(2),
|
||||||
|
investedAtPrice: "",
|
||||||
|
currentValue: performance.summary.currentValue.toFixed(2),
|
||||||
|
performancePercentage: `${performance.summary.performancePercentage.toFixed(2)}% (avg. acc. ${averagePerformance}%) (avg. p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`,
|
||||||
|
periodicGroupId: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
assetName: "TTWOR",
|
||||||
|
date: "",
|
||||||
|
investedAmount: performance.summary.totalInvested.toFixed(2),
|
||||||
|
investedAtPrice: "",
|
||||||
|
currentValue: performance.summary.ttworValue.toFixed(2),
|
||||||
|
performancePercentage: `${performance.summary.ttworPercentage.toFixed(2)}%`,
|
||||||
|
periodicGroupId: "",
|
||||||
|
},
|
||||||
|
...performance.investments
|
||||||
|
], 'portfolio-positions')}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Download CSV"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg">
|
||||||
|
<table className="min-w-full bg-white dark:bg-slate-800">
|
||||||
|
<thead>
|
||||||
|
<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">Type</th>
|
||||||
|
<th className="px-4 py-2">Date</th>
|
||||||
|
<th className="px-4 py-2">Invested Amount</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={currentAmountTooltip}>
|
||||||
|
Current Amount
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={buyInTooltip}>
|
||||||
|
Buy-In (avg)
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={performanceTooltip}>
|
||||||
|
Performance (%)
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{performance.summary && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<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 Portfolio</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.totalInvested.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.currentValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{performance.summary.performancePercentage.toFixed(2)}%
|
||||||
|
<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>
|
||||||
|
<li className="text-xs text-gray-500 dark:text-gray-400"> (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</li>
|
||||||
|
<li className="text-xs text-gray-500 dark:text-gray-400"> (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<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">{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>
|
||||||
|
<td className="px-4 py-2"><Tooltip content={ttworTooltip}>{performance.summary.ttworPercentage.toFixed(2)}%</Tooltip></td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{performance.investments.sort((a, b) => a.date.localeCompare(b.date)).map((inv, index) => {
|
||||||
|
const asset = assets.find(a => a.name === inv.assetName)!;
|
||||||
|
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
|
||||||
|
const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
|
||||||
|
const avgBuyIn = filtered.reduce((acc, curr) => acc + curr.investedAtPrice, 0) / filtered.length;
|
||||||
|
const isLast = index === performance.investments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={inv.id} className={`border-t border-gray-200 dark:border-slate-600 ${isLast ? 'last:rounded-b-lg' : ''}`}>
|
||||||
|
<td className={`px-4 py-2 ${isLast ? 'first:rounded-bl-lg' : ''}`}>{inv.assetName}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{investment?.type === 'periodic' ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
SavingsPlan
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
|
||||||
|
<ShoppingBag className="w-4 h-4 mr-1" />
|
||||||
|
OneTime
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">{format(new Date(inv.date), 'dd.MM.yyyy')}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.investedAmount.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.currentValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.investedAtPrice.toFixed(2)} (€{avgBuyIn.toFixed(2)})</td>
|
||||||
|
<td className="px-4 py-2">{inv.performancePercentage.toFixed(2)}%</td>
|
||||||
|
<td className={`px-4 py-2 ${isLast ? 'last:rounded-br-lg' : ''}`}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingInvestment({ investment, assetId: asset.id })}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(inv.id, asset.id)}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingInvestment && (
|
||||||
|
<EditInvestmentModal
|
||||||
|
investment={editingInvestment.investment}
|
||||||
|
assetId={editingInvestment.assetId}
|
||||||
|
onClose={() => setEditingInvestment(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showProjection && (
|
||||||
|
<FutureProjectionModal
|
||||||
|
performancePerAnno={performance.summary.performancePerAnnoPerformance}
|
||||||
|
bestPerformancePerAnno={performance.summary.bestPerformancePerAnno}
|
||||||
|
worstPerformancePerAnno={performance.summary.worstPerformancePerAnno}
|
||||||
|
onClose={() => setShowProjection(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PortfolioTable = () => {
|
|
||||||
const { assets, removeInvestment, clearInvestments } = usePortfolioStore((state) => ({
|
|
||||||
assets: state.assets,
|
|
||||||
removeInvestment: state.removeInvestment,
|
|
||||||
clearInvestments: state.clearInvestments,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [editingInvestment, setEditingInvestment] = useState<{
|
|
||||||
investment: Investment;
|
|
||||||
assetId: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
|
|
||||||
|
|
||||||
const averagePerformance = useMemo(() => {
|
|
||||||
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) => {
|
|
||||||
if (window.confirm("Are you sure you want to delete this investment?")) {
|
|
||||||
removeInvestment(assetId, investmentId);
|
|
||||||
}
|
|
||||||
}, [removeInvestment]);
|
|
||||||
|
|
||||||
const handleClearAll = useCallback(() => {
|
|
||||||
if (window.confirm("Are you sure you want to clear all investments?")) {
|
|
||||||
clearInvestments();
|
|
||||||
}
|
|
||||||
}, [clearInvestments]);
|
|
||||||
|
|
||||||
const performanceTooltip = useMemo(() => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</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.performancePerAnnoPerformance]);
|
|
||||||
|
|
||||||
const buyInTooltip = useMemo(() => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>"Buy-in" shows the asset's price when that position was bought.</p>
|
|
||||||
<p>"Avg" shows the average buy-in price across all positions for that asset.</p>
|
|
||||||
</div>
|
|
||||||
), []);
|
|
||||||
|
|
||||||
const currentAmountTooltip = useMemo(() => (
|
|
||||||
"The current value of your investment based on the latest market price."
|
|
||||||
), []);
|
|
||||||
|
|
||||||
const ttworTooltip = useMemo(() => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
It helps to evaluate the impact of your investment timing strategy compared to a single early investment.
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
<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">
|
|
||||||
<thead>
|
|
||||||
<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">Type</th>
|
|
||||||
<th className="px-4 py-2">Date</th>
|
|
||||||
<th className="px-4 py-2">Invested Amount</th>
|
|
||||||
<th className="px-4 py-2">
|
|
||||||
<Tooltip content={currentAmountTooltip}>
|
|
||||||
Current Amount
|
|
||||||
</Tooltip>
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2">
|
|
||||||
<Tooltip content={buyInTooltip}>
|
|
||||||
Buy-In (avg)
|
|
||||||
</Tooltip>
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2">
|
|
||||||
<Tooltip content={performanceTooltip}>
|
|
||||||
Performance (%)
|
|
||||||
</Tooltip>
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{performance.summary && (
|
|
||||||
<>
|
|
||||||
|
|
||||||
<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 Portfolio</td>
|
|
||||||
<td className="px-4 py-2"></td>
|
|
||||||
<td className="px-4 py-2"></td>
|
|
||||||
<td className="px-4 py-2">€{performance.summary.totalInvested.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2">€{performance.summary.currentValue.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2"></td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{performance.summary.performancePercentage.toFixed(2)}%
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
<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">{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>
|
|
||||||
<td className="px-4 py-2"><Tooltip content={ttworTooltip}>{performance.summary.ttworPercentage.toFixed(2)}%</Tooltip></td>
|
|
||||||
<td className="px-4 py-2"></td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{performance.investments.map((inv, index) => {
|
|
||||||
const asset = assets.find(a => a.name === inv.assetName)!;
|
|
||||||
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
|
|
||||||
const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
|
|
||||||
const avgBuyIn = filtered.reduce((acc, curr) => acc + curr.investedAtPrice, 0) / filtered.length;
|
|
||||||
const isLast = index === performance.investments.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={inv.id} className={`border-t border-gray-200 dark:border-slate-600 ${isLast ? 'last:rounded-b-lg' : ''}`}>
|
|
||||||
<td className={`px-4 py-2 ${isLast ? 'first:rounded-bl-lg' : ''}`}>{inv.assetName}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{investment?.type === 'periodic' ? (
|
|
||||||
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
|
||||||
SavingsPlan
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
|
|
||||||
<ShoppingBag className="w-4 h-4 mr-1" />
|
|
||||||
OneTime
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">{format(new Date(inv.date), 'dd.MM.yyyy')}</td>
|
|
||||||
<td className="px-4 py-2">€{inv.investedAmount.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2">€{inv.currentValue.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2">€{inv.investedAtPrice.toFixed(2)} (€{avgBuyIn.toFixed(2)})</td>
|
|
||||||
<td className="px-4 py-2">{inv.performancePercentage.toFixed(2)}%</td>
|
|
||||||
<td className={`px-4 py-2 ${isLast ? 'last:rounded-br-lg' : ''}`}>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingInvestment({ investment, assetId: asset.id })}
|
|
||||||
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(inv.id, asset.id)}
|
|
||||||
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{editingInvestment && (
|
|
||||||
<EditInvestmentModal
|
|
||||||
investment={editingInvestment.investment}
|
|
||||||
assetId={editingInvestment.assetId}
|
|
||||||
onClose={() => setEditingInvestment(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showProjection && (
|
|
||||||
<FutureProjectionModal performancePerAnno={performance.summary.performancePerAnnoPerformance} onClose={() => setShowProjection(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
82
src/components/utils/DateRangePicker.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onStartDateChange: (date: string) => void;
|
||||||
|
onEndDateChange: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateRangePicker = ({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
}: DateRangePickerProps) => {
|
||||||
|
const startDateRef = useRef<HTMLInputElement>(null);
|
||||||
|
const endDateRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isValidDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date instanceof Date && !isNaN(date.getTime()) && dateString.length === 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedStartDateChange = useDebouncedCallback(
|
||||||
|
(newDate: string) => {
|
||||||
|
if (newDate !== startDate && isValidDate(newDate)) {
|
||||||
|
onStartDateChange(newDate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
750
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedEndDateChange = useDebouncedCallback(
|
||||||
|
(newDate: string) => {
|
||||||
|
if (newDate !== endDate && isValidDate(newDate)) {
|
||||||
|
onEndDateChange(newDate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
750
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartDateChange = () => {
|
||||||
|
if (startDateRef.current) {
|
||||||
|
debouncedStartDateChange(startDateRef.current.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = () => {
|
||||||
|
if (endDateRef.current) {
|
||||||
|
debouncedEndDateChange(endDateRef.current.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">From</label>
|
||||||
|
<input
|
||||||
|
ref={startDateRef}
|
||||||
|
type="date"
|
||||||
|
defaultValue={startDate}
|
||||||
|
onChange={handleStartDateChange}
|
||||||
|
max={endDate}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">To</label>
|
||||||
|
<input
|
||||||
|
ref={endDateRef}
|
||||||
|
type="date"
|
||||||
|
defaultValue={endDate}
|
||||||
|
onChange={handleEndDateChange}
|
||||||
|
min={startDate}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
11
src/components/utils/LoadingPlaceholder.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface LoadingPlaceholderProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingPlaceholder = ({ className = "" }: LoadingPlaceholderProps) => (
|
||||||
|
<div className={`flex items-center justify-center bg-white dark:bg-slate-800 rounded-lg shadow-lg dark:shadow-black/60 ${className}`}>
|
||||||
|
<Loader2 className="animate-spin text-cyan-500" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
29
src/components/utils/ToolTip.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string | ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip = ({ content, children }: TooltipProps) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-help"
|
||||||
|
onMouseEnter={() => setShow(true)}
|
||||||
|
onMouseLeave={() => setShow(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<HelpCircle className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
{show && (
|
||||||
|
<div className="absolute z-50 w-64 p-2 text-sm bg-black text-white rounded shadow-lg dark:shadow-black/60 -left-20 -bottom-2 transform translate-y-full">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
11
src/hooks/useDarkMode.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { DarkModeContext } from "../providers/DarkModeProvider";
|
||||||
|
|
||||||
|
export const useDarkMode = () => {
|
||||||
|
const context = useContext(DarkModeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
18
src/hooks/usePortfolio.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
import { PortfolioContext, PortfolioContextType } from "../providers/PortfolioProvider";
|
||||||
|
|
||||||
|
// main way of how to access the context
|
||||||
|
const usePortfolio = () => {
|
||||||
|
const context = useContext(PortfolioContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePortfolio must be used within a PortfolioProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// performance optimized way of accessing the context
|
||||||
|
export const usePortfolioSelector = <T,>(selector: (state: PortfolioContextType) => T): T => {
|
||||||
|
const context = usePortfolio();
|
||||||
|
return useMemo(() => selector(context), [selector, context]);
|
||||||
|
};
|
|
@ -5,61 +5,61 @@
|
||||||
/* Modern Scrollbar Styling */
|
/* Modern Scrollbar Styling */
|
||||||
/* Webkit (Chrome, Safari, Edge) */
|
/* Webkit (Chrome, Safari, Edge) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #94a3b8;
|
background-color: #94a3b8;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #64748b;
|
background-color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure transparent background for the scrollbar area */
|
/* Ensure transparent background for the scrollbar area */
|
||||||
::-webkit-scrollbar-corner,
|
::-webkit-scrollbar-corner,
|
||||||
::-webkit-scrollbar-track-piece {
|
::-webkit-scrollbar-track-piece {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
.dark ::-webkit-scrollbar {
|
.dark ::-webkit-scrollbar {
|
||||||
background: black !important;
|
background: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark ::-webkit-scrollbar-track {
|
.dark ::-webkit-scrollbar-track {
|
||||||
background: black !important;
|
background: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
background-color: #475569 !important;
|
background-color: #475569 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #64748b !important;
|
background-color: #64748b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #94a3b8 #1d2127;
|
scrollbar-color: #94a3b8 #1d2127;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark * {
|
.dark * {
|
||||||
scrollbar-color: #475569 #1d212799 !important;
|
scrollbar-color: #475569 #1d212799 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For Internet Explorer */
|
/* For Internet Explorer */
|
||||||
body {
|
body {
|
||||||
-ms-overflow-style: auto;
|
-ms-overflow-style: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove default white background in dark mode */
|
/* Remove default white background in dark mode */
|
||||||
|
@ -67,58 +67,59 @@ body {
|
||||||
.dark ::-webkit-scrollbar-track,
|
.dark ::-webkit-scrollbar-track,
|
||||||
.dark ::-webkit-scrollbar-corner,
|
.dark ::-webkit-scrollbar-corner,
|
||||||
.dark ::-webkit-scrollbar-track-piece {
|
.dark ::-webkit-scrollbar-track-piece {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure the app background extends properly */
|
/* Ensure the app background extends properly */
|
||||||
html, body {
|
html,
|
||||||
background: inherit;
|
body {
|
||||||
|
background: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling für Investment Form */
|
/* Scrollbar Styling für Investment Form */
|
||||||
.scrollbar-styled {
|
.scrollbar-styled {
|
||||||
scrollbar-gutter: stable both-edges;
|
scrollbar-gutter: stable both-edges;
|
||||||
overflow-y: scroll !important;
|
overflow-y: scroll !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-styled::-webkit-scrollbar {
|
.scrollbar-styled::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-styled::-webkit-scrollbar-track {
|
.scrollbar-styled::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-styled::-webkit-scrollbar-thumb {
|
.scrollbar-styled::-webkit-scrollbar-thumb {
|
||||||
background-color: #94a3b8;
|
background-color: #94a3b8;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
.scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #64748b;
|
background-color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb {
|
.dark .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||||
background-color: #475569;
|
background-color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
.dark .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #64748b;
|
background-color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
.scrollbar-styled {
|
.scrollbar-styled {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #94a3b8 transparent;
|
scrollbar-color: #94a3b8 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-styled {
|
.dark .scrollbar-styled {
|
||||||
scrollbar-color: #475569 transparent;
|
scrollbar-color: #475569 transparent;
|
||||||
}
|
}
|
||||||
|
|
10
src/main.tsx
|
@ -7,9 +7,9 @@ import App from "./App.tsx";
|
||||||
import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
|
import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<DarkModeProvider>
|
<DarkModeProvider>
|
||||||
<App />
|
<App />
|
||||||
</DarkModeProvider>
|
</DarkModeProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,19 +1,11 @@
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface DarkModeContextType {
|
interface DarkModeContextType {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
toggleDarkMode: () => void;
|
toggleDarkMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
export const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const useDarkMode = () => {
|
|
||||||
const context = useContext(DarkModeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
|
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||||
|
|
172
src/providers/PortfolioProvider.tsx
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import { format, startOfYear } from "date-fns";
|
||||||
|
import { createContext, useMemo, useReducer } from "react";
|
||||||
|
|
||||||
|
import { Asset, DateRange, HistoricalData, Investment } from "../types";
|
||||||
|
|
||||||
|
// State Types
|
||||||
|
interface PortfolioState {
|
||||||
|
assets: Asset[];
|
||||||
|
isLoading: boolean;
|
||||||
|
dateRange: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Types
|
||||||
|
type PortfolioAction =
|
||||||
|
| { type: 'SET_LOADING'; payload: boolean }
|
||||||
|
| { type: 'ADD_ASSET'; payload: Asset }
|
||||||
|
| { type: 'REMOVE_ASSET'; payload: string }
|
||||||
|
| { type: 'CLEAR_ASSETS' }
|
||||||
|
| { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment } }
|
||||||
|
| { type: 'REMOVE_INVESTMENT'; payload: { assetId: string; investmentId: string } }
|
||||||
|
| { type: 'UPDATE_DATE_RANGE'; payload: DateRange }
|
||||||
|
| { type: 'UPDATE_ASSET_HISTORICAL_DATA'; payload: { assetId: string; historicalData: HistoricalData[]; longName?: string } }
|
||||||
|
| { type: 'UPDATE_INVESTMENT'; payload: { assetId: string; investmentId: string; investment: Investment } }
|
||||||
|
| { type: 'CLEAR_INVESTMENTS' }
|
||||||
|
| { type: 'SET_ASSETS'; payload: Asset[] };
|
||||||
|
|
||||||
|
// Initial State
|
||||||
|
const initialState: PortfolioState = {
|
||||||
|
assets: [],
|
||||||
|
isLoading: false,
|
||||||
|
dateRange: {
|
||||||
|
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'),
|
||||||
|
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
const portfolioReducer = (state: PortfolioState, action: PortfolioAction): PortfolioState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_LOADING':
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
|
||||||
|
case 'ADD_ASSET':
|
||||||
|
return { ...state, assets: [...state.assets, action.payload] };
|
||||||
|
|
||||||
|
case 'REMOVE_ASSET':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.filter(asset => asset.id !== action.payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'CLEAR_ASSETS':
|
||||||
|
return { ...state, assets: [] };
|
||||||
|
|
||||||
|
case 'ADD_INVESTMENT':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.map(asset =>
|
||||||
|
asset.id === action.payload.assetId
|
||||||
|
? { ...asset, investments: [...asset.investments, action.payload.investment] }
|
||||||
|
: asset
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'REMOVE_INVESTMENT':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.map(asset =>
|
||||||
|
asset.id === action.payload.assetId
|
||||||
|
? {
|
||||||
|
...asset,
|
||||||
|
investments: asset.investments.filter(inv => inv.id !== action.payload.investmentId)
|
||||||
|
}
|
||||||
|
: asset
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_DATE_RANGE':
|
||||||
|
return { ...state, dateRange: action.payload };
|
||||||
|
|
||||||
|
case 'UPDATE_ASSET_HISTORICAL_DATA':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.map(asset =>
|
||||||
|
asset.id === action.payload.assetId
|
||||||
|
? {
|
||||||
|
...asset,
|
||||||
|
historicalData: action.payload.historicalData,
|
||||||
|
name: action.payload.longName || asset.name
|
||||||
|
}
|
||||||
|
: asset
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_INVESTMENT':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.map(asset =>
|
||||||
|
asset.id === action.payload.assetId
|
||||||
|
? {
|
||||||
|
...asset,
|
||||||
|
investments: asset.investments.map(inv =>
|
||||||
|
inv.id === action.payload.investmentId ? action.payload.investment : inv
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: asset
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'CLEAR_INVESTMENTS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assets: state.assets.map(asset => ({ ...asset, investments: [] }))
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_ASSETS':
|
||||||
|
return { ...state, assets: action.payload };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export interface PortfolioContextType extends PortfolioState {
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
addAsset: (asset: Asset) => void;
|
||||||
|
removeAsset: (assetId: string) => void;
|
||||||
|
clearAssets: () => void;
|
||||||
|
addInvestment: (assetId: string, investment: Investment) => void;
|
||||||
|
removeInvestment: (assetId: string, investmentId: string) => void;
|
||||||
|
updateDateRange: (dateRange: DateRange) => void;
|
||||||
|
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[], longName?: string) => void;
|
||||||
|
updateInvestment: (assetId: string, investmentId: string, investment: Investment) => void;
|
||||||
|
clearInvestments: () => void;
|
||||||
|
setAssets: (assets: Asset[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PortfolioContext = createContext<PortfolioContextType | null>(null);
|
||||||
|
|
||||||
|
// Provider Component
|
||||||
|
export const PortfolioProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [state, dispatch] = useReducer(portfolioReducer, initialState);
|
||||||
|
|
||||||
|
// Memoized actions
|
||||||
|
const actions = useMemo(() => ({
|
||||||
|
setLoading: (loading: boolean) => dispatch({ type: 'SET_LOADING', payload: loading }),
|
||||||
|
addAsset: (asset: Asset) => dispatch({ type: 'ADD_ASSET', payload: asset }),
|
||||||
|
removeAsset: (assetId: string) => dispatch({ type: 'REMOVE_ASSET', payload: assetId }),
|
||||||
|
clearAssets: () => dispatch({ type: 'CLEAR_ASSETS' }),
|
||||||
|
addInvestment: (assetId: string, investment: Investment) =>
|
||||||
|
dispatch({ type: 'ADD_INVESTMENT', payload: { assetId, investment } }),
|
||||||
|
removeInvestment: (assetId: string, investmentId: string) =>
|
||||||
|
dispatch({ type: 'REMOVE_INVESTMENT', payload: { assetId, investmentId } }),
|
||||||
|
updateDateRange: (dateRange: DateRange) =>
|
||||||
|
dispatch({ type: 'UPDATE_DATE_RANGE', payload: dateRange }),
|
||||||
|
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[], longName?: string) =>
|
||||||
|
dispatch({ type: 'UPDATE_ASSET_HISTORICAL_DATA', payload: { assetId, historicalData, longName } }),
|
||||||
|
updateInvestment: (assetId: string, investmentId: string, investment: Investment) =>
|
||||||
|
dispatch({ type: 'UPDATE_INVESTMENT', payload: { assetId, investmentId, investment } }),
|
||||||
|
clearInvestments: () => dispatch({ type: 'CLEAR_INVESTMENTS' }),
|
||||||
|
setAssets: (assets: Asset[]) => dispatch({ type: 'SET_ASSETS', payload: assets }),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ ...state, ...actions }), [state, actions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortfolioContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PortfolioContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,42 +1,4 @@
|
||||||
import { Asset } from "../types";
|
import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
|
||||||
|
|
||||||
interface YahooQuoteDocument {
|
|
||||||
symbol: string;
|
|
||||||
shortName: string;
|
|
||||||
regularMarketPrice: {
|
|
||||||
raw: number;
|
|
||||||
fmt: string;
|
|
||||||
};
|
|
||||||
regularMarketChange: {
|
|
||||||
raw: number;
|
|
||||||
fmt: string;
|
|
||||||
};
|
|
||||||
regularMarketPercentChange: {
|
|
||||||
raw: number;
|
|
||||||
fmt: string;
|
|
||||||
};
|
|
||||||
exchange: string;
|
|
||||||
quoteType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface YahooSearchResponse {
|
|
||||||
finance: {
|
|
||||||
result: [{
|
|
||||||
documents: YahooQuoteDocument[];
|
|
||||||
}];
|
|
||||||
error: null | string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface YahooChartResult {
|
|
||||||
timestamp: number[];
|
|
||||||
indicators: {
|
|
||||||
quote: [{
|
|
||||||
close: number[];
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// this is only needed when hosted staticly without a proxy server or smt
|
// this is only needed when hosted staticly without a proxy server or smt
|
||||||
// TODO change it to use the proxy server
|
// TODO change it to use the proxy server
|
||||||
|
@ -46,73 +8,79 @@ const YAHOO_API = 'https://query1.finance.yahoo.com';
|
||||||
const API_BASE = isDev ? '/yahoo' : `${CORS_PROXY}${encodeURIComponent(YAHOO_API)}`;
|
const API_BASE = isDev ? '/yahoo' : `${CORS_PROXY}${encodeURIComponent(YAHOO_API)}`;
|
||||||
|
|
||||||
export const searchAssets = async (query: string): Promise<Asset[]> => {
|
export const searchAssets = async (query: string): Promise<Asset[]> => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
query,
|
query,
|
||||||
lang: 'en-US',
|
lang: 'en-US',
|
||||||
type: 'equity,etf',
|
type: 'equity,etf',
|
||||||
});
|
longName: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
|
||||||
const data = await response.json() as YahooSearchResponse;
|
const data = await response.json() as YahooSearchResponse;
|
||||||
|
|
||||||
if (data.finance.error) {
|
if (data.finance.error) {
|
||||||
throw new Error(data.finance.error);
|
throw new Error(data.finance.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.finance.result?.[0]?.documents) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.finance.result[0].documents
|
||||||
|
.filter(quote => quote.quoteType === 'equity' || quote.quoteType === 'etf')
|
||||||
|
.map((quote) => ({
|
||||||
|
id: quote.symbol,
|
||||||
|
isin: '', // not provided by Yahoo Finance API
|
||||||
|
wkn: '', // not provided by Yahoo Finance API
|
||||||
|
name: quote.shortName,
|
||||||
|
rank: quote.rank,
|
||||||
|
symbol: quote.symbol,
|
||||||
|
quoteType: quote.quoteType,
|
||||||
|
price: quote.regularMarketPrice.raw,
|
||||||
|
priceChange: quote.regularMarketChange.raw,
|
||||||
|
priceChangePercent: quote.regularMarketPercentChange.raw,
|
||||||
|
exchange: quote.exchange,
|
||||||
|
historicalData: [],
|
||||||
|
investments: [],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching assets:', error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.finance.result?.[0]?.documents) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.finance.result[0].documents
|
|
||||||
.filter(quote => quote.quoteType === 'equity' || quote.quoteType === 'etf')
|
|
||||||
.map((quote) => ({
|
|
||||||
id: quote.symbol,
|
|
||||||
isin: '', // not provided by Yahoo Finance API
|
|
||||||
wkn: '', // not provided by Yahoo Finance API
|
|
||||||
name: quote.shortName,
|
|
||||||
symbol: quote.symbol,
|
|
||||||
price: quote.regularMarketPrice.raw,
|
|
||||||
priceChange: quote.regularMarketChange.raw,
|
|
||||||
priceChangePercent: quote.regularMarketPercentChange.raw,
|
|
||||||
exchange: quote.exchange,
|
|
||||||
historicalData: [],
|
|
||||||
investments: [],
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching assets:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => {
|
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => {
|
||||||
try {
|
try {
|
||||||
const start = Math.floor(new Date(startDate).getTime() / 1000);
|
const start = Math.floor(new Date(startDate).getTime() / 1000);
|
||||||
const end = Math.floor(new Date(endDate).getTime() / 1000);
|
const end = Math.floor(new Date(endDate).getTime() / 1000);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
period1: start.toString(),
|
period1: start.toString(),
|
||||||
period2: end.toString(),
|
period2: end.toString(),
|
||||||
interval: '1d',
|
interval: '1d',
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const { timestamp, indicators } = data.chart.result[0] as YahooChartResult;
|
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||||
const quotes = indicators.quote[0];
|
const quotes = indicators.quote[0];
|
||||||
|
|
||||||
return timestamp.map((time: number, index: number) => ({
|
return {
|
||||||
date: new Date(time * 1000).toISOString().split('T')[0],
|
historicalData: timestamp.map((time: number, index: number) => ({
|
||||||
price: quotes.close[index],
|
date: new Date(time * 1000).toISOString().split('T')[0],
|
||||||
}));
|
price: quotes.close[index],
|
||||||
} catch (error) {
|
})),
|
||||||
console.error('Error fetching historical data:', error);
|
longName: meta.longName
|
||||||
return [];
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('Error fetching historical data:', error);
|
||||||
|
return { historicalData: [], longName: '' };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,82 +0,0 @@
|
||||||
import { format, startOfYear } from "date-fns";
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import { Asset, DateRange, HistoricalData, Investment } from "../types";
|
|
||||||
|
|
||||||
interface PortfolioState {
|
|
||||||
assets: Asset[];
|
|
||||||
dateRange: DateRange;
|
|
||||||
addAsset: (asset: Asset) => void;
|
|
||||||
removeAsset: (assetId: string) => void;
|
|
||||||
clearAssets: () => void;
|
|
||||||
addInvestment: (assetId: string, investment: Investment) => void;
|
|
||||||
removeInvestment: (assetId: string, investmentId: string) => void;
|
|
||||||
updateDateRange: (dateRange: DateRange) => void;
|
|
||||||
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) => ({
|
|
||||||
assets: [],
|
|
||||||
dateRange: {
|
|
||||||
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'),
|
|
||||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
|
||||||
},
|
|
||||||
addAsset: (asset) =>
|
|
||||||
set((state) => ({ assets: [...state.assets, asset] })),
|
|
||||||
removeAsset: (assetId) =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.filter((asset) => asset.id !== assetId),
|
|
||||||
})),
|
|
||||||
clearAssets: () =>
|
|
||||||
set(() => ({ assets: [] })),
|
|
||||||
addInvestment: (assetId, investment) =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.map((asset) =>
|
|
||||||
asset.id === assetId
|
|
||||||
? { ...asset, investments: [...asset.investments, investment] }
|
|
||||||
: asset
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
removeInvestment: (assetId, investmentId) =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.map((asset) =>
|
|
||||||
asset.id === assetId
|
|
||||||
? {
|
|
||||||
...asset,
|
|
||||||
investments: asset.investments.filter((inv) => inv.id !== investmentId),
|
|
||||||
}
|
|
||||||
: asset
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
updateDateRange: (dateRange) =>
|
|
||||||
set(() => ({ dateRange })),
|
|
||||||
updateAssetHistoricalData: (assetId, historicalData) =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.map((asset) =>
|
|
||||||
asset.id === assetId
|
|
||||||
? { ...asset, historicalData }
|
|
||||||
: asset
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
updateInvestment: (assetId, investmentId, updatedInvestment) =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.map((asset) =>
|
|
||||||
asset.id === assetId
|
|
||||||
? {
|
|
||||||
...asset,
|
|
||||||
investments: asset.investments.map((inv) =>
|
|
||||||
inv.id === investmentId ? updatedInvestment : inv
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: asset
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
clearInvestments: () =>
|
|
||||||
set((state) => ({
|
|
||||||
assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
|
|
||||||
})),
|
|
||||||
setAssets: (assets) => set({ assets }),
|
|
||||||
}));
|
|
|
@ -1,35 +1,37 @@
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
id: string;
|
id: string;
|
||||||
isin: string;
|
isin: string;
|
||||||
name: string;
|
name: string;
|
||||||
wkn: string;
|
quoteType: string;
|
||||||
symbol: string;
|
rank: string;
|
||||||
historicalData: HistoricalData[];
|
wkn: string;
|
||||||
investments: Investment[];
|
symbol: string;
|
||||||
|
historicalData: HistoricalData[];
|
||||||
|
investments: Investment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalData {
|
export interface HistoricalData {
|
||||||
date: string;
|
date: string;
|
||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Investment {
|
export interface Investment {
|
||||||
id: string;
|
id: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
type: 'single' | 'periodic';
|
type: 'single' | 'periodic';
|
||||||
amount: number;
|
amount: number;
|
||||||
date?: string;
|
date?: string;
|
||||||
periodicGroupId?: string;
|
periodicGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeriodicSettings {
|
export interface PeriodicSettings {
|
||||||
dayOfMonth: number;
|
dayOfMonth: number;
|
||||||
interval: number;
|
interval: number;
|
||||||
dynamic?: {
|
dynamic?: {
|
||||||
type: 'percentage' | 'fixed';
|
type: 'percentage' | 'fixed';
|
||||||
value: number;
|
value: number;
|
||||||
yearInterval: number;
|
yearInterval: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvestmentPerformance {
|
export interface InvestmentPerformance {
|
||||||
|
@ -41,10 +43,166 @@ export interface InvestmentPerformance {
|
||||||
currentValue: number;
|
currentValue: number;
|
||||||
performancePercentage: number;
|
performancePercentage: number;
|
||||||
periodicGroupId?: string;
|
periodicGroupId?: string;
|
||||||
avgBuyIn: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestmentPerformance {
|
||||||
|
id: string;
|
||||||
|
assetName: string;
|
||||||
|
date: string;
|
||||||
|
investedAmount: number;
|
||||||
|
investedAtPrice: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioPerformance {
|
||||||
|
investments: InvestmentPerformance[];
|
||||||
|
summary: {
|
||||||
|
totalInvested: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
performancePerAnnoPerformance: number;
|
||||||
|
ttworValue: number;
|
||||||
|
ttworPercentage: number;
|
||||||
|
bestPerformancePerAnno: { percentage: number, year: number }[];
|
||||||
|
worstPerformancePerAnno: { percentage: number, year: number }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DayData = {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
invested: number;
|
||||||
|
percentageChange: number;
|
||||||
|
/* Current price of asset */
|
||||||
|
assets: { [key: string]: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
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 interface PeriodicSettings {
|
||||||
|
startDate: string;
|
||||||
|
dayOfMonth: number;
|
||||||
|
interval: number;
|
||||||
|
amount: number;
|
||||||
|
dynamic?: {
|
||||||
|
type: 'percentage' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
yearInterval: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YahooQuoteDocument {
|
||||||
|
symbol: string;
|
||||||
|
shortName: string;
|
||||||
|
rank: string;
|
||||||
|
regularMarketPrice: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
regularMarketChange: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
regularMarketPercentChange: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
exchange: string;
|
||||||
|
quoteType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YahooSearchResponse {
|
||||||
|
finance: {
|
||||||
|
result: [{
|
||||||
|
documents: YahooQuoteDocument[];
|
||||||
|
}];
|
||||||
|
error: null | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YahooChartResult {
|
||||||
|
timestamp: number[];
|
||||||
|
meta: {
|
||||||
|
currency: string;
|
||||||
|
symbol: string;
|
||||||
|
exchangeName: string;
|
||||||
|
fullExchangeName: string;
|
||||||
|
instrumentType: string;
|
||||||
|
firstTradeDate: number;
|
||||||
|
regularMarketTime: number;
|
||||||
|
hasPrePostMarketData: boolean;
|
||||||
|
gmtoffset: number;
|
||||||
|
timezone: string;
|
||||||
|
exchangeTimezoneName: string;
|
||||||
|
regularMarketPrice: number;
|
||||||
|
fiftyTwoWeekHigh: number;
|
||||||
|
fiftyTwoWeekLow: number;
|
||||||
|
regularMarketDayHigh: number;
|
||||||
|
regularMarketDayLow: number;
|
||||||
|
regularMarketVolume: number;
|
||||||
|
longName: string;
|
||||||
|
shortName: string;
|
||||||
|
chartPreviousClose: number;
|
||||||
|
priceHint: number;
|
||||||
|
currentTradingPeriod: {
|
||||||
|
pre: {
|
||||||
|
timezone: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
gmtoffset: number;
|
||||||
|
};
|
||||||
|
regular: {
|
||||||
|
timezone: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
gmtoffset: number;
|
||||||
|
};
|
||||||
|
post: {
|
||||||
|
timezone: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
gmtoffset: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dataGranularity: string;
|
||||||
|
range: string;
|
||||||
|
validRanges: string[];
|
||||||
|
}
|
||||||
|
indicators: {
|
||||||
|
quote: [{
|
||||||
|
close: number[];
|
||||||
|
}];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,13 @@
|
||||||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||||
|
|
||||||
import { Asset, Investment } from "../../types";
|
import type { Asset, Investment, PeriodicSettings } from "../../types";
|
||||||
|
|
||||||
export interface PeriodicSettings {
|
|
||||||
startDate: string;
|
|
||||||
dayOfMonth: number;
|
|
||||||
interval: number;
|
|
||||||
amount: number;
|
|
||||||
dynamic?: {
|
|
||||||
type: 'percentage' | 'fixed';
|
|
||||||
value: number;
|
|
||||||
yearInterval: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
|
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
|
||||||
let totalShares = 0;
|
let totalShares = 0;
|
||||||
|
|
||||||
const buyIns:number[] = [];
|
const buyIns: number[] = [];
|
||||||
// Calculate shares for each investment up to the given date
|
// Calculate shares for each investment up to the given date
|
||||||
for(const investment of asset.investments) {
|
for (const investment of asset.investments) {
|
||||||
const invDate = new Date(investment.date!);
|
const invDate = new Date(investment.date!);
|
||||||
if (isAfter(invDate, date) || isSameDay(invDate, date)) continue;
|
if (isAfter(invDate, date) || isSameDay(invDate, date)) continue;
|
||||||
|
|
||||||
|
@ -70,7 +58,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
||||||
// Check if we've reached a year interval for increase
|
// Check if we've reached a year interval for increase
|
||||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||||
if (settings.dynamic.type === 'percentage') {
|
if (settings.dynamic.type === 'percentage') {
|
||||||
currentAmount *= (1 + settings.dynamic.value / 100);
|
console.log('percentage', settings.dynamic.value, (1 + (settings.dynamic.value / 100)));
|
||||||
|
currentAmount *= (1 + (settings.dynamic.value / 100));
|
||||||
} else {
|
} else {
|
||||||
currentAmount += settings.dynamic.value;
|
currentAmount += settings.dynamic.value;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,244 +1,242 @@
|
||||||
import { addMonths, differenceInYears, format } from "date-fns";
|
import { addMonths, differenceInYears, format } from "date-fns";
|
||||||
|
|
||||||
import { Asset, Investment } from "../../types";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan
|
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
|
||||||
} from "../../components/FutureProjectionModal";
|
} from "../../types";
|
||||||
|
|
||||||
const findOptimalStartingPoint = (
|
const findOptimalStartingPoint = (
|
||||||
currentPortfolioValue: number,
|
currentPortfolioValue: number,
|
||||||
monthlyGrowth: number,
|
monthlyGrowth: number,
|
||||||
desiredWithdrawal: number,
|
desiredWithdrawal: number,
|
||||||
strategy: WithdrawalPlan['autoStrategy'],
|
strategy: WithdrawalPlan['autoStrategy'],
|
||||||
interval: 'monthly' | 'yearly'
|
interval: 'monthly' | 'yearly'
|
||||||
): { startDate: string; requiredPortfolioValue: number } => {
|
): { startDate: string; requiredPortfolioValue: number } => {
|
||||||
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
||||||
let requiredPortfolioValue = 0;
|
let requiredPortfolioValue = 0;
|
||||||
|
|
||||||
// Declare variables outside switch
|
// Declare variables outside switch
|
||||||
const months = (strategy?.targetYears || 30) * 12;
|
const months = (strategy?.targetYears || 30) * 12;
|
||||||
const r = monthlyGrowth;
|
const r = monthlyGrowth;
|
||||||
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
|
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
|
||||||
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1/12) - 1;
|
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1 / 12) - 1;
|
||||||
|
|
||||||
switch (strategy?.type) {
|
switch (strategy?.type) {
|
||||||
case 'maintain':
|
case 'maintain':
|
||||||
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
|
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
|
||||||
break;
|
break;
|
||||||
case 'deplete':
|
case 'deplete':
|
||||||
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
|
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
|
||||||
break;
|
break;
|
||||||
case 'grow':
|
case 'grow':
|
||||||
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
|
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate when we'll reach the required value
|
// Calculate when we'll reach the required value
|
||||||
const monthsToReach = Math.ceil(
|
const monthsToReach = Math.ceil(
|
||||||
Math.log(requiredPortfolioValue / currentPortfolioValue) /
|
Math.log(requiredPortfolioValue / currentPortfolioValue) /
|
||||||
Math.log(1 + monthlyGrowth)
|
Math.log(1 + monthlyGrowth)
|
||||||
);
|
);
|
||||||
|
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate.toISOString().split('T')[0],
|
startDate: startDate.toISOString().split('T')[0],
|
||||||
requiredPortfolioValue,
|
requiredPortfolioValue,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calculateFutureProjection = async (
|
export const calculateFutureProjection = async (
|
||||||
currentAssets: Asset[],
|
currentAssets: Asset[],
|
||||||
yearsToProject: number,
|
yearsToProject: number,
|
||||||
annualReturnRate: number,
|
annualReturnRate: number,
|
||||||
withdrawalPlan?: WithdrawalPlan,
|
withdrawalPlan?: WithdrawalPlan,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
projection: ProjectionData[];
|
projection: ProjectionData[];
|
||||||
sustainability: SustainabilityAnalysis;
|
sustainability: SustainabilityAnalysis;
|
||||||
}> => {
|
}> => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
const projectionData: ProjectionData[] = [];
|
const projectionData: ProjectionData[] = [];
|
||||||
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
|
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
|
||||||
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
|
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
|
||||||
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
|
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
|
||||||
|
|
||||||
// Get all periodic investment patterns
|
// Get all periodic investment patterns
|
||||||
const periodicInvestments = currentAssets.flatMap(asset => {
|
const periodicInvestments = currentAssets.flatMap(asset => {
|
||||||
const patterns = new Map<string, Investment[]>();
|
const patterns = new Map<string, Investment[]>();
|
||||||
|
|
||||||
asset.investments.forEach(inv => {
|
asset.investments.forEach(inv => {
|
||||||
if (inv.type === 'periodic' && inv.periodicGroupId) {
|
if (inv.type === 'periodic' && inv.periodicGroupId) {
|
||||||
if (!patterns.has(inv.periodicGroupId)) {
|
if (!patterns.has(inv.periodicGroupId)) {
|
||||||
patterns.set(inv.periodicGroupId, []);
|
patterns.set(inv.periodicGroupId, []);
|
||||||
}
|
}
|
||||||
patterns.get(inv.periodicGroupId)!.push(inv);
|
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()
|
||||||
|
)
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(patterns.values())
|
// Project future investments
|
||||||
.map(group => ({
|
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
|
||||||
pattern: group.sort((a, b) =>
|
if (pattern.length < 2) return [];
|
||||||
new Date(a.date!).getTime() - new Date(b.date!).getTime()
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Project future investments
|
const lastInvestment = pattern[pattern.length - 1];
|
||||||
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
|
const secondLastInvestment = pattern[pattern.length - 2];
|
||||||
if (pattern.length < 2) return [];
|
|
||||||
|
|
||||||
const lastInvestment = pattern[pattern.length - 1];
|
const interval = new Date(lastInvestment.date!).getTime() -
|
||||||
const secondLastInvestment = pattern[pattern.length - 2];
|
new Date(secondLastInvestment.date!).getTime();
|
||||||
|
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
|
||||||
|
|
||||||
const interval = new Date(lastInvestment.date!).getTime() -
|
const future: Investment[] = [];
|
||||||
new Date(secondLastInvestment.date!).getTime();
|
let currentDate = new Date(lastInvestment.date!);
|
||||||
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
|
let currentAmount = lastInvestment.amount;
|
||||||
|
|
||||||
const future: Investment[] = [];
|
while (currentDate <= endDateForCalculation) {
|
||||||
let currentDate = new Date(lastInvestment.date!);
|
currentDate = new Date(currentDate.getTime() + interval);
|
||||||
let currentAmount = lastInvestment.amount;
|
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) {
|
while (currentDate <= endDateForCalculation) {
|
||||||
currentDate = new Date(currentDate.getTime() + interval);
|
// Check if withdrawals should start
|
||||||
currentAmount += amountDiff;
|
if (!withdrawalsStarted && withdrawalPlan?.enabled) {
|
||||||
|
withdrawalsStarted = withdrawalPlan.startTrigger === 'date'
|
||||||
|
? new Date(currentDate) >= new Date(withdrawalPlan.startDate!)
|
||||||
|
: portfolioValue >= (withdrawalPlan.startPortfolioValue || 0);
|
||||||
|
|
||||||
future.push({
|
if (withdrawalsStarted) {
|
||||||
...lastInvestment,
|
withdrawalStartDate = new Date(currentDate);
|
||||||
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;
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update target metrics
|
// Calculate actual sustainability duration
|
||||||
if (withdrawalsStarted && !targetValue) {
|
let actualSustainableYears: number | 'infinite' = 'infinite';
|
||||||
targetValue = portfolioValue;
|
if (portfolioDepletionDate) {
|
||||||
yearsToReachTarget = differenceInYears(currentDate, new Date());
|
actualSustainableYears = differenceInYears(
|
||||||
|
portfolioDepletionDate,
|
||||||
|
withdrawalStartDate || new Date()
|
||||||
|
);
|
||||||
|
} else if (portfolioValue > 0) {
|
||||||
|
// If portfolio is still growing after maxProjectionYears, it's truly sustainable
|
||||||
|
actualSustainableYears = 'infinite';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portfolioValue <= 0 && !portfolioDepletionDate) {
|
return {
|
||||||
portfolioDepletionDate = new Date(currentDate);
|
projection: projectionData,
|
||||||
}
|
sustainability: {
|
||||||
|
yearsToReachTarget,
|
||||||
// Only add to projection data if within display timeframe
|
targetValue,
|
||||||
if (currentDate <= endDateForDisplay) {
|
sustainableYears: actualSustainableYears,
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,28 +1,7 @@
|
||||||
import { differenceInDays, isAfter, isBefore } from "date-fns";
|
import { differenceInDays, isAfter, isBefore } from "date-fns";
|
||||||
|
|
||||||
import { Asset } from "../../types";
|
import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
|
||||||
|
|
||||||
export interface InvestmentPerformance {
|
|
||||||
id: string;
|
|
||||||
assetName: string;
|
|
||||||
date: string;
|
|
||||||
investedAmount: number;
|
|
||||||
investedAtPrice: number;
|
|
||||||
currentValue: number;
|
|
||||||
performancePercentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortfolioPerformance {
|
|
||||||
investments: InvestmentPerformance[];
|
|
||||||
summary: {
|
|
||||||
totalInvested: number;
|
|
||||||
currentValue: number;
|
|
||||||
performancePercentage: number;
|
|
||||||
performancePerAnnoPerformance: number;
|
|
||||||
ttworValue: number;
|
|
||||||
ttworPercentage: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
|
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
|
||||||
const investments: InvestmentPerformance[] = [];
|
const investments: InvestmentPerformance[] = [];
|
||||||
|
@ -36,7 +15,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
||||||
const investedPerAsset: Record<string, number> = {};
|
const investedPerAsset: Record<string, number> = {};
|
||||||
|
|
||||||
// Sammle erste und letzte Preise für jedes Asset
|
// Sammle erste und letzte Preise für jedes Asset
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0;
|
firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0;
|
||||||
currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||||
investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0);
|
investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
@ -52,8 +31,8 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Finde das früheste Investmentdatum
|
// Finde das früheste Investmentdatum
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
for(const investment of asset.investments) {
|
for (const investment of asset.investments) {
|
||||||
const investmentDate = new Date(investment.date!);
|
const investmentDate = new Date(investment.date!);
|
||||||
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
|
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
|
||||||
earliestDate = investmentDate;
|
earliestDate = investmentDate;
|
||||||
|
@ -61,11 +40,90 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate portfolio-level annual performances
|
||||||
|
const annualPerformances: { year: number; percentage: number }[] = [];
|
||||||
|
|
||||||
|
// Calculate portfolio performance for each year
|
||||||
|
const now = new Date();
|
||||||
|
const startYear = earliestDate ? earliestDate.getFullYear() : now.getFullYear();
|
||||||
|
const endYear = now.getFullYear();
|
||||||
|
|
||||||
|
for (let year = startYear; year <= endYear; year++) {
|
||||||
|
const yearStart = new Date(year, 0, 1); // 1. Januar
|
||||||
|
const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez.
|
||||||
|
|
||||||
|
const investmentsPerformances:number[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
// Get prices for the start and end of the year
|
||||||
|
const startPrice = asset.historicalData.filter(d =>
|
||||||
|
new Date(d.date).getFullYear() === year &&
|
||||||
|
new Date(d.date).getMonth() === 0
|
||||||
|
).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).find(d => d.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
const endPrice = asset.historicalData.filter(d =>
|
||||||
|
new Date(d.date).getFullYear() === year
|
||||||
|
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).find(d => d.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
if (startPrice === 0 || endPrice === 0) {
|
||||||
|
console.warn(`Skipping asset for year ${year} due to missing start or end price`);
|
||||||
|
continue; // Überspringe, wenn keine Daten vorhanden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all investments made before or during this year
|
||||||
|
const relevantInvestments = asset.investments.filter(inv =>
|
||||||
|
new Date(inv.date!) <= yearEnd && new Date(inv.date!) >= yearStart
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const investment of relevantInvestments) {
|
||||||
|
const investmentPrice = asset.historicalData.find(
|
||||||
|
(data) => data.date === investment.date
|
||||||
|
)?.price || 0;
|
||||||
|
|
||||||
|
const previousPrice = investmentPrice || asset.historicalData.filter(
|
||||||
|
(data) => isBefore(new Date(data.date), new Date(investment.date!))
|
||||||
|
).reverse().find((v) => v.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
|
||||||
|
(data) => isAfter(new Date(data.date), new Date(investment.date!))
|
||||||
|
).find((v) => v.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
|
||||||
|
if (buyInPrice > 0) {
|
||||||
|
const shares = investment.amount / buyInPrice; // Berechne Anzahl der Shares
|
||||||
|
const endValue = shares * endPrice;
|
||||||
|
const startValue = shares * startPrice;
|
||||||
|
investmentsPerformances.push((endValue - startValue) / startValue * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate performance for the year
|
||||||
|
if (investmentsPerformances.length > 0) {
|
||||||
|
const percentage = investmentsPerformances.reduce((acc, curr) => acc + curr, 0) / investmentsPerformances.length;
|
||||||
|
|
||||||
|
if (!isNaN(percentage)) {
|
||||||
|
annualPerformances.push({ year, percentage });
|
||||||
|
} else {
|
||||||
|
console.warn(`Invalid percentage calculated for year ${year}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Skipping year ${year} due to zero portfolio values`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get best and worst years for the entire portfolio
|
||||||
|
const bestPerformancePerAnno = annualPerformances.length > 0
|
||||||
|
? Array.from(annualPerformances).sort((a, b) => b.percentage - a.percentage)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const worstPerformancePerAnno = Array.from(bestPerformancePerAnno).reverse()
|
||||||
|
|
||||||
// Normale Performance-Berechnungen...
|
// Normale Performance-Berechnungen...
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||||
|
|
||||||
for(const investment of asset.investments) {
|
for (const investment of asset.investments) {
|
||||||
const investmentPrice = asset.historicalData.find(
|
const investmentPrice = asset.historicalData.find(
|
||||||
(data) => data.date === investment.date
|
(data) => data.date === investment.date
|
||||||
)?.price || 0;
|
)?.price || 0;
|
||||||
|
@ -87,6 +145,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
||||||
date: investment.date!,
|
date: investment.date!,
|
||||||
investedAmount: investment.amount,
|
investedAmount: investment.amount,
|
||||||
investedAtPrice: buyInPrice,
|
investedAtPrice: buyInPrice,
|
||||||
|
periodicGroupId: investment.periodicGroupId,
|
||||||
currentValue,
|
currentValue,
|
||||||
performancePercentage: investment.amount > 0
|
performancePercentage: investment.amount > 0
|
||||||
? (((currentValue - investment.amount) / investment.amount)) * 100
|
? (((currentValue - investment.amount) / investment.amount)) * 100
|
||||||
|
@ -128,6 +187,8 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
||||||
performancePerAnnoPerformance,
|
performancePerAnnoPerformance,
|
||||||
ttworValue,
|
ttworValue,
|
||||||
ttworPercentage,
|
ttworPercentage,
|
||||||
|
worstPerformancePerAnno: worstPerformancePerAnno,
|
||||||
|
bestPerformancePerAnno: bestPerformancePerAnno
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,9 @@
|
||||||
import { addDays, isAfter, isBefore } from "date-fns";
|
import { addDays, isAfter, isBefore } from "date-fns";
|
||||||
|
|
||||||
import { Asset, DateRange } from "../../types";
|
|
||||||
import { calculateAssetValueAtDate } from "./assetValue";
|
import { calculateAssetValueAtDate } from "./assetValue";
|
||||||
|
|
||||||
type DayData = {
|
import type { Asset, DateRange, DayData } from "../../types";
|
||||||
date: string;
|
|
||||||
total: number;
|
|
||||||
invested: number;
|
|
||||||
percentageChange: number;
|
|
||||||
/* Current price of asset */
|
|
||||||
assets: { [key: string]: number };
|
|
||||||
};
|
|
||||||
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
||||||
const { startDate, endDate } = dateRange;
|
const { startDate, endDate } = dateRange;
|
||||||
const data: DayData[] = [];
|
const data: DayData[] = [];
|
||||||
|
@ -31,9 +24,9 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
||||||
// this should contain the percentage gain of all investments till now
|
// this should contain the percentage gain of all investments till now
|
||||||
const pPercents: number[] = [];
|
const pPercents: number[] = [];
|
||||||
|
|
||||||
for(const asset of assets) {
|
for (const asset of assets) {
|
||||||
// calculate the invested kapital
|
// calculate the invested kapital
|
||||||
for(const investment of asset.investments) {
|
for (const investment of asset.investments) {
|
||||||
if (!isAfter(new Date(investment.date!), currentDate)) {
|
if (!isAfter(new Date(investment.date!), currentDate)) {
|
||||||
dayData.invested += investment.amount;
|
dayData.invested += investment.amount;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +49,7 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
||||||
dayData.assets[asset.id] = currentValueOfAsset;
|
dayData.assets[asset.id] = currentValueOfAsset;
|
||||||
|
|
||||||
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||||
if(!Number.isNaN(percent)) pPercents.push(percent);
|
if (!Number.isNaN(percent)) pPercents.push(percent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
338
src/utils/export.ts
Normal file
|
@ -0,0 +1,338 @@
|
||||||
|
import "jspdf-autotable";
|
||||||
|
|
||||||
|
import { jsPDF } from "jspdf";
|
||||||
|
|
||||||
|
import { Asset } from "../types";
|
||||||
|
import { calculateFutureProjection } from "./calculations/futureProjection";
|
||||||
|
|
||||||
|
// Add type augmentation for the autotable plugin
|
||||||
|
interface jsPDFWithPlugin extends jsPDF {
|
||||||
|
autoTable: (options: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEuro = (value: number) => {
|
||||||
|
return `€${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ".").replace(".", ",").replace(/,(\d{3})/g, ".$1")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadTableAsCSV = (tableData: any[], filename: string) => {
|
||||||
|
const headers = Object.keys(tableData[0])
|
||||||
|
.filter(header => !header.toLowerCase().includes('id'));
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.map(title => title.charAt(0).toUpperCase() + title.slice(1)).join(','),
|
||||||
|
...tableData.map(row =>
|
||||||
|
headers.map(header => {
|
||||||
|
const value = row[header]?.toString().replace(/,/g, '');
|
||||||
|
return isNaN(Number(value))
|
||||||
|
? `"${value}"`
|
||||||
|
: formatEuro(Number(value)).replace('€', '');
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `${filename}.csv`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatePortfolioPDF = async (
|
||||||
|
assets: Asset[],
|
||||||
|
performance: any,
|
||||||
|
savingsPlansPerformance: any[],
|
||||||
|
performancePerAnno: number
|
||||||
|
) => {
|
||||||
|
const doc = new jsPDF() as jsPDFWithPlugin;
|
||||||
|
doc.setFont('Arial', 'normal');
|
||||||
|
let yPos = 20;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.text('Portfolio Analysis Report', 15, yPos);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
// Explanations
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(100);
|
||||||
|
|
||||||
|
// TTWOR Explanation
|
||||||
|
doc.text('Understanding TTWOR (Time Travel Without Risk):', 15, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
const ttworText =
|
||||||
|
'TTWOR shows how your portfolio would have performed if all investments had been made at ' +
|
||||||
|
'the beginning of the period. This metric helps evaluate the impact of your investment ' +
|
||||||
|
'timing strategy compared to a single early investment. A higher portfolio performance ' +
|
||||||
|
'than TTWOR indicates successful timing of investments.';
|
||||||
|
|
||||||
|
const ttworLines = doc.splitTextToSize(ttworText, 180);
|
||||||
|
doc.text(ttworLines, 20, yPos);
|
||||||
|
yPos += ttworLines.length * 7;
|
||||||
|
|
||||||
|
doc.setTextColor(0);
|
||||||
|
|
||||||
|
// Portfolio Summary
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text('Portfolio Summary', 15, yPos);
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text(`Total Invested: ${formatEuro(performance.summary.totalInvested)}`, 20, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
doc.text(`Current Value: ${formatEuro(performance.summary.currentValue)}`, 20, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
doc.text(`Performance: ${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`, 20, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
|
||||||
|
// TTWOR values in italic
|
||||||
|
doc.setFont('Arial', 'italic');
|
||||||
|
doc.text(`TTWOR* Value: ${formatEuro(performance.summary.ttworValue)} (would perform: ${performance.summary.ttworPercentage.toFixed(2)}%)`, 20, yPos);
|
||||||
|
doc.setFont('Arial', 'normal');
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
// Add Positions Overview table
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text('Positions Overview', 15, yPos);
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
// Prepare positions data
|
||||||
|
const positionsTableData = [
|
||||||
|
// Summary row
|
||||||
|
[
|
||||||
|
'Total Portfolio',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
formatEuro(performance.summary.totalInvested),
|
||||||
|
formatEuro(performance.summary.currentValue),
|
||||||
|
'',
|
||||||
|
`${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`,
|
||||||
|
],
|
||||||
|
// TTWOR row
|
||||||
|
[
|
||||||
|
'TTWOR*',
|
||||||
|
'',
|
||||||
|
performance.investments[0]?.date
|
||||||
|
? new Date(performance.investments[0].date).toLocaleDateString('de-DE')
|
||||||
|
: '',
|
||||||
|
formatEuro(performance.summary.totalInvested),
|
||||||
|
formatEuro(performance.summary.ttworValue),
|
||||||
|
'',
|
||||||
|
`${performance.summary.ttworPercentage.toFixed(2)}%`,
|
||||||
|
],
|
||||||
|
// Individual positions
|
||||||
|
...performance.investments.sort((a: any, b: any) => a.date.localeCompare(b.date)).map((inv: any) => {
|
||||||
|
const asset = assets.find(a => a.name === inv.assetName)!;
|
||||||
|
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
|
||||||
|
const filtered = performance.investments.filter((v: any) => v.assetName === inv.assetName);
|
||||||
|
const avgBuyIn = filtered.reduce((acc: any, curr: any) => acc + curr.investedAtPrice, 0) / filtered.length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
inv.assetName,
|
||||||
|
investment.type === 'periodic' ? 'SavingsPlan' : 'OneTime',
|
||||||
|
new Date(inv.date).toLocaleDateString('de-DE'),
|
||||||
|
formatEuro(inv.investedAmount),
|
||||||
|
formatEuro(inv.currentValue),
|
||||||
|
`${formatEuro(inv.investedAtPrice)} (${formatEuro(avgBuyIn)})`,
|
||||||
|
`${inv.performancePercentage.toFixed(2)}%`,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
doc.autoTable({
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Asset', 'Type', 'Date', 'Invested Amount', 'Current Value', 'Buy-In (avg)', 'Performance']],
|
||||||
|
body: positionsTableData,
|
||||||
|
styles: {
|
||||||
|
cellPadding: 2,
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [240, 240, 240],
|
||||||
|
textColor: [0, 0, 0],
|
||||||
|
fontStyle: 'bold',
|
||||||
|
},
|
||||||
|
// Style for summary and TTWOR rows
|
||||||
|
rowStyles: (row:number) => {
|
||||||
|
if (row === 0) return { fontStyle: 'bold', fillColor: [245, 245, 245] };
|
||||||
|
if (row === 1) return { fontStyle: 'italic', textColor: [100, 100, 100] };
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||||
|
|
||||||
|
// Savings Plans Table if exists
|
||||||
|
if (savingsPlansPerformance.length > 0) {
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text('Savings Plans Performance', 15, yPos);
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
const savingsPlansTableData = savingsPlansPerformance.map(plan => [
|
||||||
|
plan.assetName,
|
||||||
|
formatEuro(plan.amount),
|
||||||
|
formatEuro(plan.totalInvested),
|
||||||
|
formatEuro(plan.currentValue),
|
||||||
|
`${plan.performancePercentage.toFixed(2)}%`,
|
||||||
|
`${plan.performancePerAnnoPerformance.toFixed(2)}%`
|
||||||
|
]);
|
||||||
|
|
||||||
|
doc.autoTable({
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Asset', 'Interval Amount', 'Total Invested', 'Current Value', 'Performance', 'Performance (p.a.)']],
|
||||||
|
body: savingsPlansTableData,
|
||||||
|
});
|
||||||
|
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page break before future projections
|
||||||
|
doc.addPage();
|
||||||
|
yPos = 20;
|
||||||
|
|
||||||
|
// Future Projections
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text('Future Projections', 15, yPos);
|
||||||
|
yPos += 15;
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(100);
|
||||||
|
// Future Projections Explanation
|
||||||
|
doc.text('About Future Projections:', 15, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
const projectionText =
|
||||||
|
'The future projections are calculated using your portfolio\'s historical performance ' +
|
||||||
|
`(${performancePerAnno.toFixed(2)}% p.a.) as a baseline. The chart shows different time horizons ` +
|
||||||
|
'to help visualize potential growth scenarios. These projections are estimates based on ' +
|
||||||
|
'historical data and should not be considered guaranteed returns.';
|
||||||
|
|
||||||
|
doc.setTextColor(0);
|
||||||
|
const projectionLines = doc.splitTextToSize(projectionText, 180);
|
||||||
|
doc.text(projectionLines, 20, yPos);
|
||||||
|
yPos += projectionLines.length * 7 - 7;
|
||||||
|
|
||||||
|
|
||||||
|
const years = [10, 15, 20, 30, 40];
|
||||||
|
const chartWidth = 180;
|
||||||
|
const chartHeight = 100;
|
||||||
|
|
||||||
|
// Calculate all projections first
|
||||||
|
const allProjections = await Promise.all(years.map(async year => {
|
||||||
|
const { projection } = await calculateFutureProjection(assets, year, performancePerAnno, {
|
||||||
|
enabled: false,
|
||||||
|
amount: 0,
|
||||||
|
interval: 'monthly',
|
||||||
|
startTrigger: 'date'
|
||||||
|
});
|
||||||
|
return { year, projection };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show summary table
|
||||||
|
const projectionSummary = allProjections.map(({ year, projection }) => {
|
||||||
|
const projected = projection[projection.length - 1];
|
||||||
|
return [
|
||||||
|
`${year} Years`,
|
||||||
|
formatEuro(projected.invested),
|
||||||
|
formatEuro(projected.value),
|
||||||
|
`${((projected.value - projected.invested) / projected.invested * 100).toFixed(2)}%`
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.autoTable({
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Timeframe', 'Invested Amount', 'Expected Value', '% Gain']],
|
||||||
|
body: projectionSummary,
|
||||||
|
});
|
||||||
|
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||||
|
|
||||||
|
// Draw combined chart
|
||||||
|
const maxValue = Math.max(...allProjections.flatMap(p => p.projection.map(d => d.value)));
|
||||||
|
const yAxisSteps = 5;
|
||||||
|
const stepSize = maxValue / yAxisSteps;
|
||||||
|
const legendHeight = 40; // Height for legend section
|
||||||
|
|
||||||
|
// Draw axes
|
||||||
|
doc.setDrawColor(200);
|
||||||
|
doc.line(15, yPos, 15, yPos + chartHeight); // Y axis
|
||||||
|
doc.line(15, yPos + chartHeight, 15 + chartWidth, yPos + chartHeight); // X axis
|
||||||
|
|
||||||
|
// Draw Y-axis labels and grid lines
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setDrawColor(230);
|
||||||
|
for (let i = 0; i <= yAxisSteps; i++) {
|
||||||
|
const value = maxValue - (i * stepSize);
|
||||||
|
const y = yPos + (i * (chartHeight / yAxisSteps));
|
||||||
|
doc.text(formatEuro(value), 5, y + 3);
|
||||||
|
doc.line(15, y, 15 + chartWidth, y); // Grid line
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors: [number, number, number][] = [
|
||||||
|
[0, 100, 255], // Blue
|
||||||
|
[255, 100, 0], // Orange
|
||||||
|
[0, 200, 100], // Green
|
||||||
|
[200, 0, 200], // Purple
|
||||||
|
[255, 0, 0], // Red
|
||||||
|
];
|
||||||
|
|
||||||
|
// Draw lines for each projection
|
||||||
|
allProjections.forEach(({ projection }, index) => {
|
||||||
|
const points = projection.map((p, i) => [
|
||||||
|
15 + (i * (chartWidth / projection.length)),
|
||||||
|
yPos + chartHeight - (p.value / maxValue * chartHeight)
|
||||||
|
]);
|
||||||
|
|
||||||
|
doc.setDrawColor(...(colors[index]));
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
if (i > 0) {
|
||||||
|
doc.line(points[i - 1][0], points[i - 1][1], point[0], point[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add date labels
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setDrawColor(0);
|
||||||
|
|
||||||
|
// Draw legend at bottom
|
||||||
|
const legendY = yPos + chartHeight + 20;
|
||||||
|
const legendItemWidth = chartWidth / years.length;
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
allProjections.forEach(({ year }, index) => {
|
||||||
|
const x = 15 + (index * legendItemWidth);
|
||||||
|
|
||||||
|
// Draw color line
|
||||||
|
doc.setDrawColor(...colors[index]);
|
||||||
|
doc.setLineWidth(1);
|
||||||
|
doc.line(x, legendY + 4, x + 15, legendY + 4);
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
doc.setTextColor(0);
|
||||||
|
doc.text(`${year} Years`, x + 20, legendY + 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos += chartHeight + legendHeight; // Update yPos to include legend space
|
||||||
|
|
||||||
|
// Add footer with link
|
||||||
|
const footerText = 'Built by Tomato6966 - SourceCode';
|
||||||
|
const link = 'https://github.com/Tomato6966/investment-portfolio-simulator';
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(100);
|
||||||
|
const pageHeight = doc.internal.pageSize.height;
|
||||||
|
|
||||||
|
// Add to all pages
|
||||||
|
// @ts-expect-error - doc.internal.getNumberOfPages() is not typed
|
||||||
|
const totalPages = doc.internal.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
|
||||||
|
// Footer text with link
|
||||||
|
doc.text(footerText, 15, pageHeight - 10);
|
||||||
|
|
||||||
|
// Add link annotation
|
||||||
|
doc.link(15, pageHeight - 15, doc.getTextWidth(footerText), 10, { url: link });
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
doc.text(`Page ${i} of ${totalPages}`, doc.internal.pageSize.width - 30, pageHeight - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save('portfolio-analysis.pdf');
|
||||||
|
};
|
|
@ -4,3 +4,33 @@ export const formatCurrency = (value: number): string => {
|
||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
})}`;
|
})}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIGHT_MODE_COLORS = [
|
||||||
|
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
|
||||||
|
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
|
||||||
|
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
|
||||||
|
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
|
||||||
|
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DARK_MODE_COLORS = [
|
||||||
|
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
|
||||||
|
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
|
||||||
|
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
|
||||||
|
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
|
||||||
|
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
|
||||||
|
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
|
||||||
|
|
||||||
|
// Find first unused color
|
||||||
|
const availableColor = colorPool.find(color => !usedColors.has(color));
|
||||||
|
|
||||||
|
if (availableColor) {
|
||||||
|
return availableColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to random color if all predefined colors are used
|
||||||
|
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
||||||
|
};
|
||||||
|
|