v1.0.0 - scenario projections & exports

This commit is contained in:
tomato6966 2024-12-23 15:53:25 +01:00
parent 4f30a32c48
commit d8ad384205
41 changed files with 3092 additions and 1877 deletions

View file

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

BIN
docs/analysis-page-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

BIN
docs/analysis-page-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
docs/white-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View file

@ -19,6 +19,7 @@ export default tseslint.config(
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': [
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },

1515
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "investment-portfolio-tracker", "name": "investment-portfolio-tracker",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -10,31 +10,31 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.24.1", "date-fns": "^4.1.0",
"date-fns": "^3.3.1", "jspdf": "^2.5.2",
"lucide-react": "^0.344.0", "jspdf-autotable": "^3.8.4",
"react": "^18.3.1", "lucide-react": "^0.469.0",
"react-dom": "^18.3.1", "react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0",
"recharts": "^2.12.1", "recharts": "^2.15.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4"
"zustand": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.17.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^18.3.5", "@types/react": "^19.0.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.20",
"eslint": "^9.9.1", "eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.9.0", "globals": "^15.14.0",
"postcss": "^8.4.35", "postcss": "^8.4.49",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.17",
"typescript": "^5.5.3", "typescript": "^5.7.2",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.18.1",
"vite": "^5.4.2" "vite": "^6.0.5"
} }
} }

View file

@ -1,66 +1,24 @@
import { Heart, Moon, Plus, Sun } from "lucide-react"; import { lazy, Suspense, useState } from "react";
import React, { useState } from "react";
import { AddAssetModal } from "./components/AddAssetModal"; import { AppShell } from "./components/Landing/AppShell";
import { InvestmentFormWrapper } from "./components/InvestmentForm"; import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
import { PortfolioChart } from "./components/PortfolioChart"; import { PortfolioProvider } from "./providers/PortfolioProvider";
import { PortfolioTable } from "./components/PortfolioTable";
import { useDarkMode } from "./providers/DarkModeProvider"; const MainContent = lazy(() => import("./components/Landing/MainContent"));
export default function App() { export default function App() {
const [isAddingAsset, setIsAddingAsset] = useState(false); const [isAddingAsset, setIsAddingAsset] = useState(false);
const { isDarkMode, toggleDarkMode } = useDarkMode();
return ( return (
<div className={`app ${isDarkMode ? 'dark' : ''}`}> <PortfolioProvider>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors relative"> <AppShell onAddAsset={() => setIsAddingAsset(true)}>
<div className="max-w-7xl mx-auto"> <Suspense fallback={<LoadingPlaceholder className="h-screen" />}>
<div className="flex justify-between items-center mb-8"> <MainContent
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1> isAddingAsset={isAddingAsset}
<div className="flex gap-4"> setIsAddingAsset={setIsAddingAsset}
<button />
onClick={toggleDarkMode} </Suspense>
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" </AppShell>
aria-label="Toggle dark mode" </PortfolioProvider>
>
{isDarkMode ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-600" />
)}
</button>
<button
onClick={() => setIsAddingAsset(true)}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add Asset
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 mb-8 dark:text-gray-300">
<div className="col-span-3">
<PortfolioChart />
</div>
<div className="col-span-3 lg:col-span-1">
<InvestmentFormWrapper />
</div>
</div>
<PortfolioTable />
{isAddingAsset && <AddAssetModal onClose={() => setIsAddingAsset(false)} />}
</div>
<a
href="https://github.com/Tomato6966/investment-portfolio-simulator"
target="_blank"
rel="noopener noreferrer"
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
>
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
</a>
</div>
</div>
); );
} }

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -1,15 +1,15 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore"; import { usePortfolioSelector } from "../hooks/usePortfolio";
import { generatePeriodicInvestments } from "../utils/calculations/assetValue"; import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
export const InvestmentFormWrapper = () => { export default function InvestmentFormWrapper() {
const [selectedAsset, setSelectedAsset] = useState<string | null>(null); const { assets, clearAssets } = usePortfolioSelector((state) => ({
const { assets, clearAssets } = usePortfolioStore((state) => ({
assets: state.assets, assets: state.assets,
clearAssets: state.clearAssets, clearAssets: state.clearAssets,
})); }));
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
const handleClearAssets = () => { const handleClearAssets = () => {
if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) { if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) {
@ -74,7 +74,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
const [yearInterval, setYearInterval] = useState('1'); const [yearInterval, setYearInterval] = useState('1');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { dateRange, addInvestment } = usePortfolioStore((state) => ({ const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
dateRange: state.dateRange, dateRange: state.dateRange,
addInvestment: state.addInvestment, addInvestment: state.addInvestment,
})); }));
@ -83,6 +83,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
setTimeout(async () => {
try { try {
if (type === "single") { if (type === "single") {
const investment = { const investment = {
@ -123,6 +124,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
setAmount(''); setAmount('');
clearSelectedAsset(); clearSelectedAsset();
} }
}, 10);
}; };
return ( return (
@ -264,7 +266,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50" className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isSubmitting ? ( {isSubmitting ? (
<Loader2 className="animate-spin mx-auto" size={16} /> <Loader2 className="animate-spin mx-auto" size={16} />

View 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>
);
};

View 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>
)}
</>
);
};

View 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>
);
};

View 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>
);
};

View file

@ -4,54 +4,41 @@ import {
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
} from "recharts"; } from "recharts";
import { usePortfolioStore } from "../store/portfolioStore"; import { usePortfolioSelector } from "../../hooks/usePortfolio";
import { calculateFutureProjection } from "../utils/calculations/futureProjection"; import { calculateFutureProjection } from "../../utils/calculations/futureProjection";
import { formatCurrency } from "../utils/formatters"; import { formatCurrency } from "../../utils/formatters";
import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types";
interface FutureProjectionModalProps { interface FutureProjectionModalProps {
onClose: () => void;
performancePerAnno: number; performancePerAnno: number;
bestPerformancePerAnno: { percentage: number, year: number }[];
worstPerformancePerAnno: { percentage: number, year: number }[];
onClose: () => void;
} }
type ChartType = 'line' | 'bar'; export type ChartType = 'line' | 'bar';
export interface WithdrawalPlan { type ScenarioCalc = { projection: ProjectionData[], sustainability: SustainabilityAnalysis | null, avaragedAmount: number, percentage: number, percentageAveraged: number };
amount: number;
interval: 'monthly' | 'yearly';
startTrigger: 'date' | 'portfolioValue' | 'auto';
startDate?: string;
startPortfolioValue?: number;
enabled: boolean;
autoStrategy?: {
type: 'maintain' | 'deplete' | 'grow';
targetYears?: number;
targetGrowth?: number;
};
}
export interface ProjectionData { export const FutureProjectionModal = ({
date: string; performancePerAnno,
value: number; bestPerformancePerAnno,
invested: number; worstPerformancePerAnno,
withdrawals: number; onClose
totalWithdrawn: number; }: FutureProjectionModalProps) => {
}
export interface SustainabilityAnalysis {
yearsToReachTarget: number;
targetValue: number;
sustainableYears: number | 'infinite';
}
export const FutureProjectionModal = ({ onClose, performancePerAnno }: FutureProjectionModalProps) => {
const [years, setYears] = useState('10'); const [years, setYears] = useState('10');
const [isCalculating, setIsCalculating] = useState(false); const [isCalculating, setIsCalculating] = useState(false);
const [chartType, setChartType] = useState<ChartType>('line'); const [chartType, setChartType] = useState<ChartType>('line');
const [projectionData, setProjectionData] = useState<ProjectionData[]>([]); const [projectionData, setProjectionData] = useState<ProjectionData[]>([]);
const [scenarios, setScenarios] = useState<{ best: ScenarioCalc, worst: ScenarioCalc }>({
best: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 },
worst: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 },
});
const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({ const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({
amount: 0, amount: 0,
interval: 'monthly', interval: 'monthly',
startTrigger: 'date', startTrigger: 'auto',
startDate: new Date().toISOString().split('T')[0], startDate: new Date().toISOString().split('T')[0],
startPortfolioValue: 0, startPortfolioValue: 0,
enabled: false, enabled: false,
@ -63,7 +50,9 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
}); });
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null); const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
const { assets } = usePortfolioStore(); const { assets } = usePortfolioSelector((state) => ({
assets: state.assets,
}));
const calculateProjection = useCallback(async () => { const calculateProjection = useCallback(async () => {
setIsCalculating(true); setIsCalculating(true);
@ -76,14 +65,44 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
); );
setProjectionData(projection); setProjectionData(projection);
setSustainabilityAnalysis(sustainability); setSustainabilityAnalysis(sustainability);
const slicedBestCase = bestPerformancePerAnno.slice(0, Math.floor(bestPerformancePerAnno.length / 2));
const slicedWorstCase = worstPerformancePerAnno.slice(0, Math.floor(worstPerformancePerAnno.length / 2));
const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0;
const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0;
const bestCaseAvaraged = (bestCase + performancePerAnno) / 2;
const worstCaseAvaraged = (worstCase + performancePerAnno) / 2;
setScenarios({
best: {
...await calculateFutureProjection(
assets,
parseInt(years),
bestCaseAvaraged,
withdrawalPlan.enabled ? withdrawalPlan : undefined
),
avaragedAmount: slicedBestCase.length,
percentageAveraged: bestCaseAvaraged,
percentage: bestCase
},
worst: {
...await calculateFutureProjection(
assets,
parseInt(years),
worstCaseAvaraged,
withdrawalPlan.enabled ? withdrawalPlan : undefined
),
avaragedAmount: slicedWorstCase.length,
percentage: worstCase,
percentageAveraged: worstCaseAvaraged
}
});
} catch (error) { } catch (error) {
console.error('Error calculating projection:', error); console.error('Error calculating projection:', error);
} finally { } finally {
setIsCalculating(false); setIsCalculating(false);
} }
}, [assets, years, withdrawalPlan, performancePerAnno]); }, [assets, years, withdrawalPlan, performancePerAnno, bestPerformancePerAnno, worstPerformancePerAnno]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: any) => { const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const value = payload[0].value; const value = payload[0].value;
@ -122,6 +141,36 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
return null; return null;
}; };
const CustomScenarioTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const bestCase = payload.find((p: any) => p.dataKey === 'bestCase')?.value || 0;
const baseCase = payload.find((p: any) => p.dataKey === 'baseCase')?.value || 0;
const worstCase = payload.find((p: any) => p.dataKey === 'worstCase')?.value || 0;
const invested = payload.find((p: any) => p.dataKey === 'invested')?.value || 0;
return (
<div className="bg-white dark:bg-slate-800 p-4 border rounded shadow-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
{new Date(label).toLocaleDateString('de-DE')}
</p>
<p className="font-bold text-green-600 dark:text-green-400">
Best-Case: {formatCurrency(bestCase)} {((bestCase - invested) / invested * 100).toFixed(2)}%
</p>
<p className="font-bold text-indigo-600 dark:text-indigo-400">
Avg. Base-Case: {formatCurrency(baseCase)} {((baseCase - invested) / invested * 100).toFixed(2)}%
</p>
<p className="font-bold text-red-600 dark:text-red-400">
Worst-Case: {formatCurrency(worstCase)} {((worstCase - invested) / invested * 100).toFixed(2)}%
</p>
</div>
);
}
return null;
};
const renderChart = () => { const renderChart = () => {
if (isCalculating) { if (isCalculating) {
return ( return (
@ -230,6 +279,97 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
); );
}; };
const renderScenarioDescription = () => {
if (!scenarios.best.projection.length) return null;
return (
<div className="mb-4 p-4 bg-gray-50 dark:bg-slate-800/50 rounded-lg text-sm">
<h4 className="font-semibold mb-2 dark:text-gray-200">Scenario Calculations</h4>
<ul className="space-y-2 text-gray-600 dark:text-gray-400">
<li>
<span className="font-medium text-indigo-600 dark:text-indigo-400">Avg. Base Case:</span> Using historical average return of <span className="font-bold underline">{performancePerAnno.toFixed(2)}%</span>
</li>
<li>
<span className="font-medium text-green-600 dark:text-green-400">Best Case:</span> Average of top 50% performing years ({scenarios.best.avaragedAmount} years) at {scenarios.best.percentage.toFixed(2)}%,
averaged with base case to <span className="font-semibold underline">{scenarios.best.percentageAveraged.toFixed(2)}%</span>
</li>
<li>
<span className="font-medium text-red-600 dark:text-red-400">Worst Case:</span> Average of bottom 50% performing years ({scenarios.worst.avaragedAmount} years) at {scenarios.worst.percentage.toFixed(2)}%,
averaged with base case to <span className="font-semibold underline">{scenarios.worst.percentageAveraged.toFixed(2)}%</span>
</li>
</ul>
</div>
);
};
const renderScenarioChart = () => {
if (!scenarios.best.projection.length) return null;
// Create a merged and sorted dataset for consistent x-axis
const mergedData = projectionData.map(basePoint => {
const date = basePoint.date;
const bestPoint = scenarios.best.projection.find(p => p.date === date);
const worstPoint = scenarios.worst.projection.find(p => p.date === date);
return {
date,
bestCase: bestPoint?.value || 0,
baseCase: basePoint.value,
worstCase: worstPoint?.value || 0,
invested: basePoint.invested
};
}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return (
<div className="mt-6">
<h4 className="font-semibold mb-4 dark:text-gray-200">Scenario Comparison</h4>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={mergedData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'numeric'
})}
/>
<YAxis />
<Tooltip content={<CustomScenarioTooltip />}/>
<Line
type="monotone"
dataKey="bestCase"
stroke="#22c55e"
name="Best Case"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="baseCase"
stroke="#4f46e5"
name="Base Case"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="worstCase"
stroke="#ef4444"
name="Worst Case"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="invested"
stroke="#9333ea"
name="Invested Amount"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-0 lg:p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-0 lg:p-4">
<div className="bg-white dark:bg-slate-800 rounded-none lg:rounded-lg w-full lg:w-[80vw] max-w-4xl h-screen lg:h-[75dvh] flex flex-col"> <div className="bg-white dark:bg-slate-800 rounded-none lg:rounded-lg w-full lg:w-[80vw] max-w-4xl h-screen lg:h-[75dvh] flex flex-col">
@ -263,7 +403,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
<button <button
onClick={calculateProjection} onClick={calculateProjection}
disabled={isCalculating} disabled={isCalculating}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isCalculating ? ( {isCalculating ? (
<Loader2 className="animate-spin" size={16} /> <Loader2 className="animate-spin" size={16} />
@ -293,16 +433,17 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
Future projections are calculated with your portfolio's average annual return rate of{' '} Future projections are calculated with your portfolio's average annual return rate of{' '}
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>. <span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
</p> </p>
<p className="mt-1"> <div className="mt-1">
Strategy explanations: Strategy explanations:
<ul className="list-disc ml-5 mt-1"> <ul className="list-disc ml-5 mt-1">
<li><span className="font-semibold">Maintain:</span> Portfolio value stays constant, withdrawing only the returns</li> <li><span className="font-semibold">Maintain:</span> Portfolio value stays constant, withdrawing only the returns</li>
<li><span className="font-semibold">Deplete:</span> Portfolio depletes to zero over specified years</li> <li><span className="font-semibold">Deplete:</span> Portfolio depletes to zero over specified years</li>
<li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li> <li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li>
</ul> </ul>
</p>
</div> </div>
</div> </div>
{renderScenarioDescription()}
</div>
<div> <div>
<div className="flex items-center justify-between mb-3"> <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="date">Specific Date</option>
<option value="portfolioValue">Portfolio Value Threshold</option> <option value="portfolioValue">Portfolio Value Threshold</option>
<option value="auto">Auto</option> <option value="auto" >Auto-Finder</option>
</select> </select>
</div> </div>
@ -547,6 +688,7 @@ export const FutureProjectionModal = ({ onClose, performancePerAnno }: FuturePro
<div className="h-[500px]"> <div className="h-[500px]">
{renderChart()} {renderChart()}
</div> </div>
{renderScenarioChart()}
</div> </div>
</div> </div>
</div> </div>

View file

@ -6,49 +6,20 @@ import {
} from "recharts"; } from "recharts";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { useDarkMode } from "../providers/DarkModeProvider"; import { useDarkMode } from "../hooks/useDarkMode";
import { usePortfolioSelector } from "../hooks/usePortfolio";
import { getHistoricalData } from "../services/yahooFinanceService"; import { getHistoricalData } from "../services/yahooFinanceService";
import { usePortfolioStore } from "../store/portfolioStore";
import { DateRange } from "../types"; import { DateRange } from "../types";
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue"; import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
import { DateRangePicker } from "./DateRangePicker"; import { getHexColor } from "../utils/formatters";
import { DateRangePicker } from "./utils/DateRangePicker";
const LIGHT_MODE_COLORS = [ export default function PortfolioChart() {
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
];
const DARK_MODE_COLORS = [
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
];
const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
// Find first unused color
const availableColor = colorPool.find(color => !usedColors.has(color));
if (availableColor) {
return availableColor;
}
// Fallback to random color if all predefined colors are used
return `#${Math.floor(Math.random()*16777215).toString(16)}`;
};
export const PortfolioChart = () => {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [hideAssets, setHideAssets] = useState(false); const [hideAssets, setHideAssets] = useState(false);
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set()); const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
const { isDarkMode } = useDarkMode(); const { isDarkMode } = useDarkMode();
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioStore((state) => ({ const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
assets: state.assets, assets: state.assets,
dateRange: state.dateRange, dateRange: state.dateRange,
updateDateRange: state.updateDateRange, updateDateRange: state.updateDateRange,
@ -57,11 +28,13 @@ export const PortfolioChart = () => {
const fetchHistoricalData = useCallback( const fetchHistoricalData = useCallback(
async (startDate: string, endDate: string) => { async (startDate: string, endDate: string) => {
assets.forEach(async (asset) => { for (const asset of assets) {
const historicalData = await getHistoricalData(asset.symbol, startDate, endDate); const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
updateAssetHistoricalData(asset.id, historicalData); updateAssetHistoricalData(asset.id, historicalData, longName);
}); }
}, [assets, updateAssetHistoricalData]); },
[assets, updateAssetHistoricalData]
);
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, { const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
maxWait: 5000, maxWait: 5000,
@ -84,7 +57,7 @@ export const PortfolioChart = () => {
const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => { const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => {
const investedKapitals: Record<string, number> = {}; const investedKapitals: Record<string, number> = {};
for(const asset of assets) { for (const asset of assets) {
investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0); investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0);
} }
@ -101,7 +74,7 @@ export const PortfolioChart = () => {
}; };
processed["ttwor"] = 0; processed["ttwor"] = 0;
for(const asset of assets) { for (const asset of assets) {
const initialPrice = data[0].assets[asset.id]; const initialPrice = data[0].assets[asset.id];
const currentPrice = point.assets[asset.id]; const currentPrice = point.assets[asset.id];
if (initialPrice && currentPrice) { if (initialPrice && currentPrice) {
@ -133,7 +106,7 @@ export const PortfolioChart = () => {
const toggleAllAssets = useCallback(() => { const toggleAllAssets = useCallback(() => {
setHideAssets(!hideAssets); setHideAssets(!hideAssets);
setHiddenAssets(new Set()); setHiddenAssets(new Set());
}, [hideAssets] ); }, [hideAssets]);
const CustomLegend = useCallback(({ payload }: any) => { const CustomLegend = useCallback(({ payload }: any) => {
return ( return (
@ -168,8 +141,7 @@ export const PortfolioChart = () => {
<button <button
key={`asset-${index}`} key={`asset-${index}`}
onClick={() => toggleAsset(assetId)} onClick={() => toggleAsset(assetId)}
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${ className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${isHidden ? 'opacity-40' : ''
isHidden ? 'opacity-40' : ''
} hover:bg-gray-100 dark:hover:bg-gray-800`} } hover:bg-gray-100 dark:hover:bg-gray-800`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -324,7 +296,8 @@ export const PortfolioChart = () => {
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis. all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
</i> </i>
</> </>
), [assets, isDarkMode, assetColors, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, updateDateRange, isFullscreen]); ), [assets, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen]);
if (isFullscreen) { if (isFullscreen) {
return ( return (

View file

@ -1,42 +1,19 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { HelpCircle, LineChart, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react"; import {
Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2
} from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore"; import { usePortfolioSelector } from "../hooks/usePortfolio";
import { Investment } from "../types"; import { Investment } from "../types";
import { calculateInvestmentPerformance } from "../utils/calculations/performance"; import { calculateInvestmentPerformance } from "../utils/calculations/performance";
import { EditInvestmentModal } from "./EditInvestmentModal"; import { downloadTableAsCSV, generatePortfolioPDF } from "../utils/export";
import { FutureProjectionModal } from "./FutureProjectionModal"; import { EditInvestmentModal } from "./Modals/EditInvestmentModal";
import { FutureProjectionModal } from "./Modals/FutureProjectionModal";
import { Tooltip } from "./utils/ToolTip";
interface TooltipProps { export default function PortfolioTable() {
content: string | JSX.Element; const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
children: React.ReactNode;
}
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) => ({
assets: state.assets, assets: state.assets,
removeInvestment: state.removeInvestment, removeInvestment: state.removeInvestment,
clearInvestments: state.clearInvestments, clearInvestments: state.clearInvestments,
@ -46,6 +23,8 @@ export const PortfolioTable = () => {
investment: Investment; investment: Investment;
assetId: string; assetId: string;
} | null>(null); } | null>(null);
const [showSavingsPlans, setShowSavingsPlans] = useState(true);
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]); 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 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 (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>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"> <p className="text-xs mt-2">
Note: An average performance of positions doesn't always match your entire portfolio's average, 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. especially with single investments or investments on different time ranges.
</p> </p>
</div> </div>
), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance]); ), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance, performance.summary.bestPerformancePerAnno, performance.summary.worstPerformancePerAnno]);
const buyInTooltip = useMemo(() => ( const buyInTooltip = useMemo(() => (
<div className="space-y-2"> <div className="space-y-2">
@ -99,29 +80,163 @@ export const PortfolioTable = () => {
const [showProjection, setShowProjection] = useState(false); const [showProjection, setShowProjection] = useState(false);
const isSavingsPlanOverviewDisabled = useMemo(() => {
return !assets.some(asset => asset.investments.some(inv => inv.type === 'periodic'));
}, [assets]);
const savingsPlansPerformance = useMemo(() => {
if(isSavingsPlanOverviewDisabled) return [];
const performance = [];
for (const asset of assets) {
const savingsPlans = asset.investments.filter(inv => inv.type === 'periodic');
if (savingsPlans.length > 0) {
const assetPerformance = calculateInvestmentPerformance([{
...asset,
investments: savingsPlans
}]);
performance.push({
assetName: asset.name,
amount: savingsPlans[0].amount,
...assetPerformance.summary
});
}
}
return performance;
}, [assets, isSavingsPlanOverviewDisabled]);
const handleGeneratePDF = async () => {
setIsGeneratingPDF(true);
try {
await generatePortfolioPDF(
assets,
performance,
savingsPlansPerformance,
performance.summary.performancePerAnnoPerformance
);
} finally {
setIsGeneratingPDF(false);
}
};
return ( return (
<div className="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="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> <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 <button
onClick={handleClearAll} onClick={handleClearAll}
disabled={performance.investments.length === 0} 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 Clear All Investments
</button> </button>
<button <button
onClick={() => setShowProjection(true)} onClick={() => setShowProjection(true)}
disabled={performance.investments.length === 0} 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} /> <LineChart size={16} />
Future Projection Future Projection
</button> </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> </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"> <table className="min-w-full bg-white dark:bg-slate-800">
<thead> <thead>
<tr className="bg-gray-100 dark:bg-slate-700 text-left"> <tr className="bg-gray-100 dark:bg-slate-700 text-left">
@ -163,6 +278,8 @@ export const PortfolioTable = () => {
<ul> <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. 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"> (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> </ul>
</td> </td>
<td className="px-4 py-2"></td> <td className="px-4 py-2"></td>
@ -181,7 +298,7 @@ export const PortfolioTable = () => {
</tr> </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 asset = assets.find(a => a.name === inv.assetName)!;
const investment = asset.investments.find(i => i.id === inv.id)! || inv; const investment = asset.investments.find(i => i.id === inv.id)! || inv;
const filtered = performance.investments.filter(v => v.assetName === inv.assetName); const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
@ -231,6 +348,9 @@ export const PortfolioTable = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
{editingInvestment && ( {editingInvestment && (
<EditInvestmentModal <EditInvestmentModal
investment={editingInvestment.investment} investment={editingInvestment.investment}
@ -239,7 +359,12 @@ export const PortfolioTable = () => {
/> />
)} )}
{showProjection && ( {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> </div>
); );

View 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>
);
};

View 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>
);

View 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
View 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;
};

View 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]);
};

View file

@ -71,7 +71,8 @@ body {
} }
/* Ensure the app background extends properly */ /* Ensure the app background extends properly */
html, body { html,
body {
background: inherit; background: inherit;
} }

View file

@ -1,19 +1,11 @@
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useEffect, useState } from "react";
interface DarkModeContextType { interface DarkModeContextType {
isDarkMode: boolean; isDarkMode: boolean;
toggleDarkMode: () => void; toggleDarkMode: () => void;
} }
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined); export const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export const useDarkMode = () => {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
};
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => { export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
const [isDarkMode, setIsDarkMode] = useState(() => { const [isDarkMode, setIsDarkMode] = useState(() => {

View 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>
);
};

View file

@ -1,42 +1,4 @@
import { Asset } from "../types"; import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
interface YahooQuoteDocument {
symbol: string;
shortName: string;
regularMarketPrice: {
raw: number;
fmt: string;
};
regularMarketChange: {
raw: number;
fmt: string;
};
regularMarketPercentChange: {
raw: number;
fmt: string;
};
exchange: string;
quoteType: string;
}
interface YahooSearchResponse {
finance: {
result: [{
documents: YahooQuoteDocument[];
}];
error: null | string;
};
}
interface YahooChartResult {
timestamp: number[];
indicators: {
quote: [{
close: number[];
}];
};
}
// this is only needed when hosted staticly without a proxy server or smt // this is only needed when hosted staticly without a proxy server or smt
// TODO change it to use the proxy server // TODO change it to use the proxy server
@ -51,6 +13,7 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
query, query,
lang: 'en-US', lang: 'en-US',
type: 'equity,etf', type: 'equity,etf',
longName: 'true',
}); });
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`; const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
@ -74,7 +37,9 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
isin: '', // not provided by Yahoo Finance API isin: '', // not provided by Yahoo Finance API
wkn: '', // not provided by Yahoo Finance API wkn: '', // not provided by Yahoo Finance API
name: quote.shortName, name: quote.shortName,
rank: quote.rank,
symbol: quote.symbol, symbol: quote.symbol,
quoteType: quote.quoteType,
price: quote.regularMarketPrice.raw, price: quote.regularMarketPrice.raw,
priceChange: quote.regularMarketChange.raw, priceChange: quote.regularMarketChange.raw,
priceChangePercent: quote.regularMarketPercentChange.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'); if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json(); const data = await response.json();
const { timestamp, indicators } = data.chart.result[0] as YahooChartResult; const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
const quotes = indicators.quote[0]; const quotes = indicators.quote[0];
return timestamp.map((time: number, index: number) => ({ return {
historicalData: timestamp.map((time: number, index: number) => ({
date: new Date(time * 1000).toISOString().split('T')[0], date: new Date(time * 1000).toISOString().split('T')[0],
price: quotes.close[index], price: quotes.close[index],
})); })),
longName: meta.longName
}
} catch (error) { } catch (error) {
console.error('Error fetching historical data:', error); console.error('Error fetching historical data:', error);
return []; return { historicalData: [], longName: '' };
} }
}; };

View file

@ -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 }),
}));

View file

@ -2,6 +2,8 @@ export interface Asset {
id: string; id: string;
isin: string; isin: string;
name: string; name: string;
quoteType: string;
rank: string;
wkn: string; wkn: string;
symbol: string; symbol: string;
historicalData: HistoricalData[]; historicalData: HistoricalData[];
@ -41,10 +43,166 @@ export interface InvestmentPerformance {
currentValue: number; currentValue: number;
performancePercentage: number; performancePercentage: number;
periodicGroupId?: string; periodicGroupId?: string;
avgBuyIn: number;
} }
export interface DateRange { export interface DateRange {
startDate: string; startDate: string;
endDate: string; endDate: string;
} }
export interface InvestmentPerformance {
id: string;
assetName: string;
date: string;
investedAmount: number;
investedAtPrice: number;
currentValue: number;
performancePercentage: number;
}
export interface PortfolioPerformance {
investments: InvestmentPerformance[];
summary: {
totalInvested: number;
currentValue: number;
performancePercentage: number;
performancePerAnnoPerformance: number;
ttworValue: number;
ttworPercentage: number;
bestPerformancePerAnno: { percentage: number, year: number }[];
worstPerformancePerAnno: { percentage: number, year: number }[];
};
}
export type DayData = {
date: string;
total: number;
invested: number;
percentageChange: number;
/* Current price of asset */
assets: { [key: string]: number };
};
export interface WithdrawalPlan {
amount: number;
interval: 'monthly' | 'yearly';
startTrigger: 'date' | 'portfolioValue' | 'auto';
startDate?: string;
startPortfolioValue?: number;
enabled: boolean;
autoStrategy?: {
type: 'maintain' | 'deplete' | 'grow';
targetYears?: number;
targetGrowth?: number;
};
}
export interface ProjectionData {
date: string;
value: number;
invested: number;
withdrawals: number;
totalWithdrawn: number;
}
export interface SustainabilityAnalysis {
yearsToReachTarget: number;
targetValue: number;
sustainableYears: number | 'infinite';
}
export interface PeriodicSettings {
startDate: string;
dayOfMonth: number;
interval: number;
amount: number;
dynamic?: {
type: 'percentage' | 'fixed';
value: number;
yearInterval: number;
};
}
interface YahooQuoteDocument {
symbol: string;
shortName: string;
rank: string;
regularMarketPrice: {
raw: number;
fmt: string;
};
regularMarketChange: {
raw: number;
fmt: string;
};
regularMarketPercentChange: {
raw: number;
fmt: string;
};
exchange: string;
quoteType: string;
}
export interface YahooSearchResponse {
finance: {
result: [{
documents: YahooQuoteDocument[];
}];
error: null | string;
};
}
export interface YahooChartResult {
timestamp: number[];
meta: {
currency: string;
symbol: string;
exchangeName: string;
fullExchangeName: string;
instrumentType: string;
firstTradeDate: number;
regularMarketTime: number;
hasPrePostMarketData: boolean;
gmtoffset: number;
timezone: string;
exchangeTimezoneName: string;
regularMarketPrice: number;
fiftyTwoWeekHigh: number;
fiftyTwoWeekLow: number;
regularMarketDayHigh: number;
regularMarketDayLow: number;
regularMarketVolume: number;
longName: string;
shortName: string;
chartPreviousClose: number;
priceHint: number;
currentTradingPeriod: {
pre: {
timezone: string;
start: number;
end: number;
gmtoffset: number;
};
regular: {
timezone: string;
start: number;
end: number;
gmtoffset: number;
};
post: {
timezone: string;
start: number;
end: number;
gmtoffset: number;
};
};
dataGranularity: string;
range: string;
validRanges: string[];
}
indicators: {
quote: [{
close: number[];
}];
};
}

View file

@ -1,25 +1,13 @@
import { isAfter, isBefore, isSameDay } from "date-fns"; import { isAfter, isBefore, isSameDay } from "date-fns";
import { Asset, Investment } from "../../types"; import type { Asset, Investment, PeriodicSettings } from "../../types";
export interface PeriodicSettings {
startDate: string;
dayOfMonth: number;
interval: number;
amount: number;
dynamic?: {
type: 'percentage' | 'fixed';
value: number;
yearInterval: number;
};
}
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => { export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
let totalShares = 0; let totalShares = 0;
const buyIns:number[] = []; const buyIns: number[] = [];
// Calculate shares for each investment up to the given date // Calculate shares for each investment up to the given date
for(const investment of asset.investments) { for (const investment of asset.investments) {
const invDate = new Date(investment.date!); const invDate = new Date(investment.date!);
if (isAfter(invDate, date) || isSameDay(invDate, date)) continue; if (isAfter(invDate, date) || isSameDay(invDate, date)) continue;
@ -70,7 +58,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
// Check if we've reached a year interval for increase // Check if we've reached a year interval for increase
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) { if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
if (settings.dynamic.type === 'percentage') { if (settings.dynamic.type === 'percentage') {
currentAmount *= (1 + settings.dynamic.value / 100); console.log('percentage', settings.dynamic.value, (1 + (settings.dynamic.value / 100)));
currentAmount *= (1 + (settings.dynamic.value / 100));
} else { } else {
currentAmount += settings.dynamic.value; currentAmount += settings.dynamic.value;
} }

View file

@ -1,10 +1,8 @@
import { addMonths, differenceInYears, format } from "date-fns"; import { addMonths, differenceInYears, format } from "date-fns";
import { Asset, Investment } from "../../types";
import type { import type {
ProjectionData, SustainabilityAnalysis, WithdrawalPlan ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
} from "../../components/FutureProjectionModal"; } from "../../types";
const findOptimalStartingPoint = ( const findOptimalStartingPoint = (
currentPortfolioValue: number, currentPortfolioValue: number,
@ -20,7 +18,7 @@ const findOptimalStartingPoint = (
const months = (strategy?.targetYears || 30) * 12; const months = (strategy?.targetYears || 30) * 12;
const r = monthlyGrowth; const r = monthlyGrowth;
const targetGrowth = (strategy?.targetGrowth || 2) / 100; const targetGrowth = (strategy?.targetGrowth || 2) / 100;
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1/12) - 1; const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1 / 12) - 1;
switch (strategy?.type) { switch (strategy?.type) {
case 'maintain': case 'maintain':
@ -136,7 +134,7 @@ export const calculateFutureProjection = async (
if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') { if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') {
const { startDate, requiredPortfolioValue } = findOptimalStartingPoint( const { startDate, requiredPortfolioValue } = findOptimalStartingPoint(
portfolioValue, portfolioValue,
Math.pow(1 + annualReturnRate/100, 1/12) - 1, Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1,
withdrawalPlan.amount, withdrawalPlan.amount,
withdrawalPlan.autoStrategy, withdrawalPlan.autoStrategy,
withdrawalPlan.interval withdrawalPlan.interval
@ -160,7 +158,7 @@ export const calculateFutureProjection = async (
// Handle monthly growth if portfolio isn't depleted // Handle monthly growth if portfolio isn't depleted
if (portfolioValue > 0) { if (portfolioValue > 0) {
const monthlyReturn = Math.pow(1 + annualReturnRate/100, 1/12) - 1; const monthlyReturn = Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1;
portfolioValue *= (1 + monthlyReturn); portfolioValue *= (1 + monthlyReturn);
} }

View file

@ -1,28 +1,7 @@
import { differenceInDays, isAfter, isBefore } from "date-fns"; import { differenceInDays, isAfter, isBefore } from "date-fns";
import { Asset } from "../../types"; import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
export interface InvestmentPerformance {
id: string;
assetName: string;
date: string;
investedAmount: number;
investedAtPrice: number;
currentValue: number;
performancePercentage: number;
}
export interface PortfolioPerformance {
investments: InvestmentPerformance[];
summary: {
totalInvested: number;
currentValue: number;
performancePercentage: number;
performancePerAnnoPerformance: number;
ttworValue: number;
ttworPercentage: number;
};
}
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => { export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
const investments: InvestmentPerformance[] = []; const investments: InvestmentPerformance[] = [];
@ -36,7 +15,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
const investedPerAsset: Record<string, number> = {}; const investedPerAsset: Record<string, number> = {};
// Sammle erste und letzte Preise für jedes Asset // Sammle erste und letzte Preise für jedes Asset
for(const asset of assets) { for (const asset of assets) {
firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0; firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0;
currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0; currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0); investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0);
@ -52,8 +31,8 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
}, 0); }, 0);
// Finde das früheste Investmentdatum // Finde das früheste Investmentdatum
for(const asset of assets) { for (const asset of assets) {
for(const investment of asset.investments) { for (const investment of asset.investments) {
const investmentDate = new Date(investment.date!); const investmentDate = new Date(investment.date!);
if (!earliestDate || isBefore(investmentDate, earliestDate)) { if (!earliestDate || isBefore(investmentDate, earliestDate)) {
earliestDate = investmentDate; earliestDate = investmentDate;
@ -61,11 +40,90 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
} }
} }
// Calculate portfolio-level annual performances
const annualPerformances: { year: number; percentage: number }[] = [];
// Calculate portfolio performance for each year
const now = new Date();
const startYear = earliestDate ? earliestDate.getFullYear() : now.getFullYear();
const endYear = now.getFullYear();
for (let year = startYear; year <= endYear; year++) {
const yearStart = new Date(year, 0, 1); // 1. Januar
const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez.
const investmentsPerformances:number[] = [];
for (const asset of assets) {
// Get prices for the start and end of the year
const startPrice = asset.historicalData.filter(d =>
new Date(d.date).getFullYear() === year &&
new Date(d.date).getMonth() === 0
).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).find(d => d.price !== 0)?.price || 0;
const endPrice = asset.historicalData.filter(d =>
new Date(d.date).getFullYear() === year
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).find(d => d.price !== 0)?.price || 0;
if (startPrice === 0 || endPrice === 0) {
console.warn(`Skipping asset for year ${year} due to missing start or end price`);
continue; // Überspringe, wenn keine Daten vorhanden
}
// Get all investments made before or during this year
const relevantInvestments = asset.investments.filter(inv =>
new Date(inv.date!) <= yearEnd && new Date(inv.date!) >= yearStart
);
for (const investment of relevantInvestments) {
const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date
)?.price || 0;
const previousPrice = investmentPrice || asset.historicalData.filter(
(data) => isBefore(new Date(data.date), new Date(investment.date!))
).reverse().find((v) => v.price !== 0)?.price || 0;
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
(data) => isAfter(new Date(data.date), new Date(investment.date!))
).find((v) => v.price !== 0)?.price || 0;
if (buyInPrice > 0) {
const shares = investment.amount / buyInPrice; // Berechne Anzahl der Shares
const endValue = shares * endPrice;
const startValue = shares * startPrice;
investmentsPerformances.push((endValue - startValue) / startValue * 100);
}
}
}
// Calculate performance for the year
if (investmentsPerformances.length > 0) {
const percentage = investmentsPerformances.reduce((acc, curr) => acc + curr, 0) / investmentsPerformances.length;
if (!isNaN(percentage)) {
annualPerformances.push({ year, percentage });
} else {
console.warn(`Invalid percentage calculated for year ${year}`);
}
} else {
console.warn(`Skipping year ${year} due to zero portfolio values`);
}
}
// Get best and worst years for the entire portfolio
const bestPerformancePerAnno = annualPerformances.length > 0
? Array.from(annualPerformances).sort((a, b) => b.percentage - a.percentage)
: [];
const worstPerformancePerAnno = Array.from(bestPerformancePerAnno).reverse()
// Normale Performance-Berechnungen... // Normale Performance-Berechnungen...
for(const asset of assets) { for (const asset of assets) {
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0; const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
for(const investment of asset.investments) { for (const investment of asset.investments) {
const investmentPrice = asset.historicalData.find( const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date (data) => data.date === investment.date
)?.price || 0; )?.price || 0;
@ -87,6 +145,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
date: investment.date!, date: investment.date!,
investedAmount: investment.amount, investedAmount: investment.amount,
investedAtPrice: buyInPrice, investedAtPrice: buyInPrice,
periodicGroupId: investment.periodicGroupId,
currentValue, currentValue,
performancePercentage: investment.amount > 0 performancePercentage: investment.amount > 0
? (((currentValue - investment.amount) / investment.amount)) * 100 ? (((currentValue - investment.amount) / investment.amount)) * 100
@ -128,6 +187,8 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
performancePerAnnoPerformance, performancePerAnnoPerformance,
ttworValue, ttworValue,
ttworPercentage, ttworPercentage,
worstPerformancePerAnno: worstPerformancePerAnno,
bestPerformancePerAnno: bestPerformancePerAnno
}, },
}; };
}; };

View file

@ -1,16 +1,9 @@
import { addDays, isAfter, isBefore } from "date-fns"; import { addDays, isAfter, isBefore } from "date-fns";
import { Asset, DateRange } from "../../types";
import { calculateAssetValueAtDate } from "./assetValue"; import { calculateAssetValueAtDate } from "./assetValue";
type DayData = { import type { Asset, DateRange, DayData } from "../../types";
date: string;
total: number;
invested: number;
percentageChange: number;
/* Current price of asset */
assets: { [key: string]: number };
};
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => { export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
const { startDate, endDate } = dateRange; const { startDate, endDate } = dateRange;
const data: DayData[] = []; const data: DayData[] = [];
@ -31,9 +24,9 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
// this should contain the percentage gain of all investments till now // this should contain the percentage gain of all investments till now
const pPercents: number[] = []; const pPercents: number[] = [];
for(const asset of assets) { for (const asset of assets) {
// calculate the invested kapital // calculate the invested kapital
for(const investment of asset.investments) { for (const investment of asset.investments) {
if (!isAfter(new Date(investment.date!), currentDate)) { if (!isAfter(new Date(investment.date!), currentDate)) {
dayData.invested += investment.amount; dayData.invested += investment.amount;
} }
@ -56,7 +49,7 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
dayData.assets[asset.id] = currentValueOfAsset; dayData.assets[asset.id] = currentValueOfAsset;
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100; const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
if(!Number.isNaN(percent)) pPercents.push(percent); if (!Number.isNaN(percent)) pPercents.push(percent);
} }
} }

338
src/utils/export.ts Normal file
View 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');
};

View file

@ -4,3 +4,33 @@ export const formatCurrency = (value: number): string => {
maximumFractionDigits: 2 maximumFractionDigits: 2
})}`; })}`;
}; };
const LIGHT_MODE_COLORS = [
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
];
const DARK_MODE_COLORS = [
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
];
export const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
// Find first unused color
const availableColor = colorPool.find(color => !usedColors.has(color));
if (availableColor) {
return availableColor;
}
// Fallback to random color if all predefined colors are used
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
};