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
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- 📈 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
|
||||
- 🌓 Dark/Light mode support
|
||||
- 📱 Responsive design
|
||||
- *Mobile friendly*
|
||||
- 📅 Historical data analysis
|
||||
- *The portfolio is fully based on real-historical data, with configurable timeranges*
|
||||
- 💹 TTWOR (Time Travel Without Risk) calculations
|
||||
- *Including metrics for TTWOR*
|
||||
- 🔄 Support for one-time and periodic investments
|
||||
- *You can config your dream-portfolio by one time and periodic investments easily*
|
||||
- 📊 Detailed performance metrics
|
||||
- *See all needed performance metrics in one place*
|
||||
- 📅 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
|
||||
|
||||
- React 18
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite
|
||||
- Vite@6
|
||||
- Recharts
|
||||
- date-fns
|
||||
- Lucide Icons
|
||||
|
@ -41,8 +56,7 @@ https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
|||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20 or higher
|
||||
- npm or yarn
|
||||
- Node.js & npm 20 or higher
|
||||
|
||||
### Local Development
|
||||
|
||||
|
@ -55,6 +69,10 @@ https://github.com/user-attachments/assets/78b027fa-9883-4813-8086-8b6aa19767de
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
### 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: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
|
|
1515
package-lock.json
generated
46
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "investment-portfolio-tracker",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@ -10,31 +10,31 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.24.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.12.1",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zustand": "^4.5.1"
|
||||
"recharts": "^2.15.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
|
74
src/App.tsx
|
@ -1,66 +1,24 @@
|
|||
import { Heart, Moon, Plus, Sun } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
|
||||
import { AddAssetModal } from "./components/AddAssetModal";
|
||||
import { InvestmentFormWrapper } from "./components/InvestmentForm";
|
||||
import { PortfolioChart } from "./components/PortfolioChart";
|
||||
import { PortfolioTable } from "./components/PortfolioTable";
|
||||
import { useDarkMode } from "./providers/DarkModeProvider";
|
||||
import { AppShell } from "./components/Landing/AppShell";
|
||||
import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
|
||||
import { PortfolioProvider } from "./providers/PortfolioProvider";
|
||||
|
||||
const MainContent = lazy(() => import("./components/Landing/MainContent"));
|
||||
|
||||
export default function App() {
|
||||
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
||||
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={() => 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>
|
||||
<PortfolioProvider>
|
||||
<AppShell onAddAsset={() => setIsAddingAsset(true)}>
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-screen" />}>
|
||||
<MainContent
|
||||
isAddingAsset={isAddingAsset}
|
||||
setIsAddingAsset={setIsAddingAsset}
|
||||
/>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</PortfolioProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 React, { useState } from "react";
|
||||
|
||||
import { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||
|
||||
export const InvestmentFormWrapper = () => {
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
const { assets, clearAssets } = usePortfolioStore((state) => ({
|
||||
export default function InvestmentFormWrapper() {
|
||||
const { assets, clearAssets } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
clearAssets: state.clearAssets,
|
||||
}));
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
|
||||
const handleClearAssets = () => {
|
||||
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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
|
||||
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
}));
|
||||
|
@ -83,6 +83,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
|
@ -123,6 +124,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
setAmount('');
|
||||
clearSelectedAsset();
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -264,7 +266,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<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
|
||||
} from "recharts";
|
||||
|
||||
import { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { calculateFutureProjection } from "../utils/calculations/futureProjection";
|
||||
import { formatCurrency } from "../utils/formatters";
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { calculateFutureProjection } from "../../utils/calculations/futureProjection";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types";
|
||||
|
||||
interface FutureProjectionModalProps {
|
||||
onClose: () => void;
|
||||
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 {
|
||||
amount: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
startTrigger: 'date' | 'portfolioValue' | 'auto';
|
||||
startDate?: string;
|
||||
startPortfolioValue?: number;
|
||||
enabled: boolean;
|
||||
autoStrategy?: {
|
||||
type: 'maintain' | 'deplete' | 'grow';
|
||||
targetYears?: number;
|
||||
targetGrowth?: number;
|
||||
};
|
||||
}
|
||||
type ScenarioCalc = { projection: ProjectionData[], sustainability: SustainabilityAnalysis | null, avaragedAmount: number, percentage: number, percentageAveraged: number };
|
||||
|
||||
export interface ProjectionData {
|
||||
date: string;
|
||||
value: number;
|
||||
invested: number;
|
||||
withdrawals: number;
|
||||
totalWithdrawn: number;
|
||||
}
|
||||
|
||||
export interface SustainabilityAnalysis {
|
||||
yearsToReachTarget: number;
|
||||
targetValue: number;
|
||||
sustainableYears: number | 'infinite';
|
||||
}
|
||||
|
||||
export const FutureProjectionModal = ({ onClose, performancePerAnno }: FutureProjectionModalProps) => {
|
||||
export const FutureProjectionModal = ({
|
||||
performancePerAnno,
|
||||
bestPerformancePerAnno,
|
||||
worstPerformancePerAnno,
|
||||
onClose
|
||||
}: FutureProjectionModalProps) => {
|
||||
const [years, setYears] = useState('10');
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [chartType, setChartType] = useState<ChartType>('line');
|
||||
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>({
|
||||
amount: 0,
|
||||
interval: 'monthly',
|
||||
startTrigger: 'date',
|
||||
startTrigger: 'auto',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
startPortfolioValue: 0,
|
||||
enabled: false,
|
||||
|
@ -63,7 +50,9 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
});
|
||||
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
|
||||
|
||||
const { assets } = usePortfolioStore();
|
||||
const { assets } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
const calculateProjection = useCallback(async () => {
|
||||
setIsCalculating(true);
|
||||
|
@ -76,14 +65,44 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
);
|
||||
setProjectionData(projection);
|
||||
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) {
|
||||
console.error('Error calculating projection:', error);
|
||||
} finally {
|
||||
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) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value;
|
||||
|
@ -122,6 +141,36 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
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 = () => {
|
||||
if (isCalculating) {
|
||||
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 (
|
||||
<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">
|
||||
|
@ -263,7 +403,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
<button
|
||||
onClick={calculateProjection}
|
||||
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 ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
|
@ -293,16 +433,17 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
Future projections are calculated with your portfolio's average annual return rate of{' '}
|
||||
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<div className="mt-1">
|
||||
Strategy explanations:
|
||||
<ul className="list-disc ml-5 mt-1">
|
||||
<li><span className="font-semibold">Maintain:</span> Portfolio value stays constant, withdrawing only the returns</li>
|
||||
<li><span className="font-semibold">Deplete:</span> Portfolio depletes to zero over specified years</li>
|
||||
<li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderScenarioDescription()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
@ -370,7 +511,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
>
|
||||
<option value="date">Specific Date</option>
|
||||
<option value="portfolioValue">Portfolio Value Threshold</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="auto" >Auto-Finder</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
@ -547,6 +688,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
|
|||
<div className="h-[500px]">
|
||||
{renderChart()}
|
||||
</div>
|
||||
{renderScenarioChart()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -6,49 +6,20 @@ import {
|
|||
} from "recharts";
|
||||
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 { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { DateRange } from "../types";
|
||||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
import { DateRangePicker } from "./utils/DateRangePicker";
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
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 = () => {
|
||||
export default function PortfolioChart() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [hideAssets, setHideAssets] = useState(false);
|
||||
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
|
||||
const { isDarkMode } = useDarkMode();
|
||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioStore((state) => ({
|
||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
dateRange: state.dateRange,
|
||||
updateDateRange: state.updateDateRange,
|
||||
|
@ -57,11 +28,13 @@ export const PortfolioChart = () => {
|
|||
|
||||
const fetchHistoricalData = useCallback(
|
||||
async (startDate: string, endDate: string) => {
|
||||
assets.forEach(async (asset) => {
|
||||
const historicalData = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||
updateAssetHistoricalData(asset.id, historicalData);
|
||||
});
|
||||
}, [assets, updateAssetHistoricalData]);
|
||||
for (const asset of assets) {
|
||||
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||
}
|
||||
},
|
||||
[assets, updateAssetHistoricalData]
|
||||
);
|
||||
|
||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
||||
maxWait: 5000,
|
||||
|
@ -168,8 +141,7 @@ export const PortfolioChart = () => {
|
|||
<button
|
||||
key={`asset-${index}`}
|
||||
onClick={() => toggleAsset(assetId)}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${
|
||||
isHidden ? 'opacity-40' : ''
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${isHidden ? 'opacity-40' : ''
|
||||
} hover:bg-gray-100 dark:hover:bg-gray-800`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
@ -324,7 +296,8 @@ export const PortfolioChart = () => {
|
|||
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
||||
</i>
|
||||
</>
|
||||
), [assets, isDarkMode, assetColors, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, updateDateRange, isFullscreen]);
|
||||
), [assets, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen]);
|
||||
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
|
|
|
@ -1,42 +1,19 @@
|
|||
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 { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { Investment } from "../types";
|
||||
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
|
||||
import { EditInvestmentModal } from "./EditInvestmentModal";
|
||||
import { FutureProjectionModal } from "./FutureProjectionModal";
|
||||
import { downloadTableAsCSV, generatePortfolioPDF } from "../utils/export";
|
||||
import { EditInvestmentModal } from "./Modals/EditInvestmentModal";
|
||||
import { FutureProjectionModal } from "./Modals/FutureProjectionModal";
|
||||
import { Tooltip } from "./utils/ToolTip";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | JSX.Element;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const PortfolioTable = () => {
|
||||
const { assets, removeInvestment, clearInvestments } = usePortfolioStore((state) => ({
|
||||
export default function PortfolioTable() {
|
||||
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
removeInvestment: state.removeInvestment,
|
||||
clearInvestments: state.clearInvestments,
|
||||
|
@ -46,6 +23,8 @@ export const PortfolioTable = () => {
|
|||
investment: Investment;
|
||||
assetId: string;
|
||||
} | null>(null);
|
||||
const [showSavingsPlans, setShowSavingsPlans] = useState(true);
|
||||
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||
|
||||
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
|
||||
|
||||
|
@ -70,12 +49,14 @@ export const PortfolioTable = () => {
|
|||
<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.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance, performance.summary.bestPerformancePerAnno, performance.summary.worstPerformancePerAnno]);
|
||||
|
||||
const buyInTooltip = useMemo(() => (
|
||||
<div className="space-y-2">
|
||||
|
@ -99,29 +80,163 @@ export const PortfolioTable = () => {
|
|||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<div className="flex flex-wrap 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">
|
||||
<div className="flex flex-wrap 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"
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
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"
|
||||
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 className="relative rounded-lg overflow-hidden">
|
||||
|
||||
{!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">
|
||||
|
@ -163,6 +278,8 @@ export const PortfolioTable = () => {
|
|||
<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>
|
||||
|
@ -181,7 +298,7 @@ export const PortfolioTable = () => {
|
|||
</tr>
|
||||
</>
|
||||
)}
|
||||
{performance.investments.map((inv, index) => {
|
||||
{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);
|
||||
|
@ -231,6 +348,9 @@ export const PortfolioTable = () => {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingInvestment && (
|
||||
<EditInvestmentModal
|
||||
investment={editingInvestment.investment}
|
||||
|
@ -239,7 +359,12 @@ export const PortfolioTable = () => {
|
|||
/>
|
||||
)}
|
||||
{showProjection && (
|
||||
<FutureProjectionModal performancePerAnno={performance.summary.performancePerAnnoPerformance} onClose={() => setShowProjection(false)} />
|
||||
<FutureProjectionModal
|
||||
performancePerAnno={performance.summary.performancePerAnnoPerformance}
|
||||
bestPerformancePerAnno={performance.summary.bestPerformancePerAnno}
|
||||
worstPerformancePerAnno={performance.summary.worstPerformancePerAnno}
|
||||
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]);
|
||||
};
|
|
@ -71,7 +71,8 @@ body {
|
|||
}
|
||||
|
||||
/* Ensure the app background extends properly */
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
interface DarkModeContextType {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
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 DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||
|
||||
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
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";
|
||||
|
||||
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[];
|
||||
}];
|
||||
};
|
||||
}
|
||||
|
||||
import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
|
||||
|
||||
// this is only needed when hosted staticly without a proxy server or smt
|
||||
// TODO change it to use the proxy server
|
||||
|
@ -51,6 +13,7 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
|
|||
query,
|
||||
lang: 'en-US',
|
||||
type: 'equity,etf',
|
||||
longName: 'true',
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
|
@ -74,7 +37,9 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
|
|||
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,
|
||||
|
@ -104,15 +69,18 @@ export const getHistoricalData = async (symbol: string, startDate: string, endDa
|
|||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
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];
|
||||
|
||||
return timestamp.map((time: number, index: number) => ({
|
||||
return {
|
||||
historicalData: timestamp.map((time: number, index: number) => ({
|
||||
date: new Date(time * 1000).toISOString().split('T')[0],
|
||||
price: quotes.close[index],
|
||||
}));
|
||||
})),
|
||||
longName: meta.longName
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
return [];
|
||||
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 }),
|
||||
}));
|
|
@ -2,6 +2,8 @@ export interface Asset {
|
|||
id: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
quoteType: string;
|
||||
rank: string;
|
||||
wkn: string;
|
||||
symbol: string;
|
||||
historicalData: HistoricalData[];
|
||||
|
@ -41,10 +43,166 @@ export interface InvestmentPerformance {
|
|||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
periodicGroupId?: string;
|
||||
avgBuyIn: number;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: 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,18 +1,6 @@
|
|||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { Asset, Investment } from "../../types";
|
||||
|
||||
export interface PeriodicSettings {
|
||||
startDate: string;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
amount: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
}
|
||||
import type { Asset, Investment, PeriodicSettings } from "../../types";
|
||||
|
||||
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
|
||||
let totalShares = 0;
|
||||
|
@ -70,7 +58,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
|||
// Check if we've reached a year interval for increase
|
||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||
if (settings.dynamic.type === 'percentage') {
|
||||
currentAmount *= (1 + settings.dynamic.value / 100);
|
||||
console.log('percentage', settings.dynamic.value, (1 + (settings.dynamic.value / 100)));
|
||||
currentAmount *= (1 + (settings.dynamic.value / 100));
|
||||
} else {
|
||||
currentAmount += settings.dynamic.value;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import { addMonths, differenceInYears, format } from "date-fns";
|
||||
|
||||
import { Asset, Investment } from "../../types";
|
||||
|
||||
import type {
|
||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan
|
||||
} from "../../components/FutureProjectionModal";
|
||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
|
||||
} from "../../types";
|
||||
|
||||
const findOptimalStartingPoint = (
|
||||
currentPortfolioValue: number,
|
||||
|
|
|
@ -1,28 +1,7 @@
|
|||
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 => {
|
||||
const investments: InvestmentPerformance[] = [];
|
||||
|
@ -61,6 +40,85 @@ 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...
|
||||
for (const asset of assets) {
|
||||
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||
|
@ -87,6 +145,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
date: investment.date!,
|
||||
investedAmount: investment.amount,
|
||||
investedAtPrice: buyInPrice,
|
||||
periodicGroupId: investment.periodicGroupId,
|
||||
currentValue,
|
||||
performancePercentage: investment.amount > 0
|
||||
? (((currentValue - investment.amount) / investment.amount)) * 100
|
||||
|
@ -128,6 +187,8 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
performancePerAnnoPerformance,
|
||||
ttworValue,
|
||||
ttworPercentage,
|
||||
worstPerformancePerAnno: worstPerformancePerAnno,
|
||||
bestPerformancePerAnno: bestPerformancePerAnno
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,16 +1,9 @@
|
|||
import { addDays, isAfter, isBefore } from "date-fns";
|
||||
|
||||
import { Asset, DateRange } from "../../types";
|
||||
import { calculateAssetValueAtDate } from "./assetValue";
|
||||
|
||||
type DayData = {
|
||||
date: string;
|
||||
total: number;
|
||||
invested: number;
|
||||
percentageChange: number;
|
||||
/* Current price of asset */
|
||||
assets: { [key: string]: number };
|
||||
};
|
||||
import type { Asset, DateRange, DayData } from "../../types";
|
||||
|
||||
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
||||
const { startDate, endDate } = dateRange;
|
||||
const data: DayData[] = [];
|
||||
|
|
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
|
||||
})}`;
|
||||
};
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
|